Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .contentrain/content/system/error-messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -244,17 +244,23 @@
"members.resend_failed": "Failed to send invitation email. Please try again.",
"members.resend_rate_limited": "Too many resend attempts. Please wait before trying again.",
"members.seat_limit_reached": "Team member limit reached ({limit}). Upgrade your plan to invite more members.",
"migrate.bundle_state_unsupported": "Studio cannot add this order to the existing plan yet. Contact support and we will finish it for you.",
"migrate.claim_expired": "This link has expired. Open Studio again from your migration’s delivery page to get a fresh one.",
"migrate.claim_invalid": "This link is not valid. Open Studio from your migration’s delivery page.",
"migrate.s2s_invalid": "This request from Migrate was not accepted.",
"migrate.s2s_replayed": "This request from Migrate was already used.",
"migrate.claim_taken": "This migration’s Studio offer was already claimed by another Studio account. Sign in with that account, or contact support.",
"migrate.email_unverified": "The email on this order is not verified, so Studio cannot create or link an account for it.",
"migrate.grant_bound_elsewhere": "This Studio offer is already tied to another workspace.",
"migrate.grant_bundle": "This Studio year is part of your Migrate order. It was paid at checkout and has no included trial to claim.",
"migrate.grant_not_found": "We couldn’t find this Studio offer on your account.",
"migrate.grant_used": "This Studio offer has already been used — its included days started on a subscription for this workspace.",
"migrate.grant_not_ready": "Studio is not ready for GitHub yet. Finish the Studio plan step in Migrate first.",
"migrate.grant_used": "This Studio offer has already been used — its included days started on a subscription for this workspace.",
"migrate.identity_conflict": "This GitHub account cannot be linked to the Studio account that owns this email.",
"migrate.install_already": "Studio is already connected to GitHub for this migration.",
"migrate.install_state_invalid": "This GitHub connection link is not valid or has expired. Start again from your Migrate page.",
"migrate.quote_changed": "The Studio price changed since the quote. Reload the quote and check out again.",
"migrate.return_url_not_allowed": "The return address is not on this Studio's Migrate allowlist.",
"migrate.s2s_invalid": "This request from Migrate was not accepted.",
"migrate.s2s_replayed": "This request from Migrate was already used.",
"migrate.unavailable": "Studio offers from Contentrain Migrate are not available on this Studio.",
"migration.export_fetch_failed": "Could not fetch the comments export from its URL",
"migration.export_too_large": "The comments export is too large to fetch; upload it in chunks instead",
Expand Down
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,13 @@ NUXT_POLAR_ACCESS_TOKEN=polar_oat_your-organization-access-token
NUXT_POLAR_WEBHOOK_SECRET=your-polar-webhook-signing-secret
NUXT_POLAR_STARTER_PRODUCT_ID=uuid-of-starter-product
NUXT_POLAR_PRO_PRODUCT_ID=uuid-of-pro-product
# Optional: the "Migrate with Studio" bundle (POST /api/migrate/provision). Per plan, the
# product the ad-hoc priced first invoice is sold on and the yearly list product the
# subscription moves to for renewal. Empty = the bundle is off (provision answers 502).
NUXT_POLAR_STARTER_BUNDLE_PRODUCT_ID=
NUXT_POLAR_PRO_BUNDLE_PRODUCT_ID=
NUXT_POLAR_STARTER_YEARLY_PRODUCT_ID=
NUXT_POLAR_PRO_YEARLY_PRODUCT_ID=
# 'sandbox' for Polar sandbox environment; 'production' for live.
NUXT_POLAR_SERVER=sandbox

Expand Down
26 changes: 26 additions & 0 deletions docs/PAYMENT_PROVIDERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,32 @@ NUXT_POLAR_SERVER=sandbox # or production

Note: `NUXT_PUBLIC_BILLING_ENABLED` is derived automatically at boot by the `server/plugins/00.billing-flag.ts` Nitro plugin — when the Polar access token resolves the plugin registry's `isConfigured()` gate, the public flag flips to `true`. You only need to set it explicitly to override (e.g. staging with Polar configured but checkout intentionally hidden).

## Migrate with Studio bundle (managed, Polar only)

Migrate can sell its own fee plus Studio year 1 as one order. Migrate calls
`POST /api/migrate/provision` (signed claim v2, same key as the claim link);
Studio finds or creates the account behind the GitHub user, records one grant
per order and opens one Polar checkout whose first invoice is the quoted total
(an ad-hoc fixed price on the plan's **bundle product**). Studio never prices
on this path: it refuses a quote it does not agree with (`migrate.quote_changed`)
and the states it cannot sell this way yet (an account that already has a plan,
a workspace with a live subscription).

When `subscription.created` arrives the webhook moves the subscription to the
plan's **yearly list product** with `proration_behavior=next_period`, so the
renewal is the list price. Polar keeps an ad-hoc price on a subscription for
good, so a move that failed is retried every 6 hours (`migrate-bundle-reconciler`
plugin) and an error-level `[migrate-bundle] ALARM` line is logged for any
subscription still unmoved 30 days before its renewal. Point a log alert at it.

```bash
NUXT_POLAR_STARTER_BUNDLE_PRODUCT_ID=… # sold with an ad-hoc price per checkout
NUXT_POLAR_PRO_BUNDLE_PRODUCT_ID=…
NUXT_POLAR_STARTER_YEARLY_PRODUCT_ID=… # the subscription's list product from the next period
NUXT_POLAR_PRO_YEARLY_PRODUCT_ID=…
NUXT_MIGRATE_ORIGINS=https://migrate.contentrain.io # the only hosts a return_url may name
```

## Studio included with a Migrate order (managed)

A paid Contentrain Migrate order can include N days of a Studio plan. Migrate
Expand Down
6 changes: 6 additions & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ export default defineNuxtConfig({
starterProductId: '', // NUXT_POLAR_STARTER_PRODUCT_ID
proProductId: '', // NUXT_POLAR_PRO_PRODUCT_ID
server: 'production', // NUXT_POLAR_SERVER — 'sandbox' | 'production'
// "Migrate with Studio" bundle: the product the ad-hoc priced first invoice is sold on, and the
// yearly list product the subscription moves to for renewal. Empty = the bundle is off.
starterBundleProductId: '', // NUXT_POLAR_STARTER_BUNDLE_PRODUCT_ID
proBundleProductId: '', // NUXT_POLAR_PRO_BUNDLE_PRODUCT_ID
starterYearlyProductId: '', // NUXT_POLAR_STARTER_YEARLY_PRODUCT_ID
proYearlyProductId: '', // NUXT_POLAR_PRO_YEARLY_PRODUCT_ID
},
migrate: {
// NUXT_MIGRATE_CLAIM_PUBLIC_KEY — Contentrain Migrate's Ed25519 public key
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
"@aws-sdk/client-s3": "^3.1076.0",
"@contentrain/mcp": "3.9.0",
"@contentrain/query": "7.4.0",
"@contentrain/types": "1.42.0",
"@contentrain/types": "1.44.0",
"@gitbeaker/rest": "^43.8.0",
"@nuxt/eslint": "1.16.0",
"@nuxt/image": "2.0.0",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 28 additions & 0 deletions postgres/migrations/043_managed_auth_identities.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-- 043 (plain-Postgres lineage only): every identity a user has signed in with.
--
-- `auth.users.provider / provider_account_id` hold one slot, overwritten by the
-- latest OAuth sign-in: a user who signed in with GitHub and later with Google
-- loses the GitHub id there. Supabase keeps all of them in `auth.identities`;
-- this is the same table (provider, provider_id, user_id) for the managed
-- AuthProvider, so `public.migrate_user_id_by_identity` (supabase/migrations/
-- 043_migrate_identity_lookup.sql) reads one shape on both pairs.
--
-- Existing users are backfilled from the slot they have. Sorts before the
-- lookup function's migration, which reads this table.

CREATE TABLE IF NOT EXISTS auth.identities (
provider text NOT NULL,
provider_id text NOT NULL,
user_id uuid NOT NULL REFERENCES auth.users (id) ON DELETE CASCADE,
last_sign_in_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (provider, provider_id)
);

CREATE INDEX IF NOT EXISTS identities_user_id_idx ON auth.identities (user_id);

INSERT INTO auth.identities (provider, provider_id, user_id, last_sign_in_at)
SELECT provider, provider_account_id, id, last_sign_in_at
FROM auth.users
WHERE provider IS NOT NULL AND provider_account_id IS NOT NULL
ON CONFLICT DO NOTHING;
1 change: 1 addition & 0 deletions scripts/verify-managed-schema.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ async function main() {

const expectedTables = [
'auth.users',
'auth.identities',
'auth.refresh_tokens',
'auth.one_time_tokens',
'auth.oauth_clients',
Expand Down
4 changes: 2 additions & 2 deletions server/api/billing/webhook/[provider].post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ export default defineEventHandler(async (event) => {
// grant up: no second included trial after cancel-and-resubscribe.
// Idempotent — whichever of created/updated arrives first marks it.
if (result.migrateGrantId) {
await db.markMigrateGrantRedeemed(result.migrateGrantId, result.subscriptionId ?? null)
await redeemMigrateGrant(provider, result.migrateGrantId, result.subscriptionId ?? null)
}
// First 'trialing' observation consumes the workspace's one-time
// trial, so a later re-checkout (after cancel/expiry) gets a paid
Expand Down Expand Up @@ -429,7 +429,7 @@ export default defineEventHandler(async (event) => {
// grant up: no second included trial after cancel-and-resubscribe.
// Idempotent — whichever of created/updated arrives first marks it.
if (result.migrateGrantId) {
await db.markMigrateGrantRedeemed(result.migrateGrantId, result.subscriptionId ?? null)
await redeemMigrateGrant(provider, result.migrateGrantId, result.subscriptionId ?? null)
}

const workspaceUpdate: Record<string, unknown> = {}
Expand Down
17 changes: 12 additions & 5 deletions server/api/migrate/account-state.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,16 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, message: errorMessage('migrate.s2s_invalid') })
}

const response = await resolveMigrateAccountState(request.github_user_id, request.plan)
// Fail closed on our own answer: Migrate prices from it.
if (!validateMigrateAccountStateResponse(response, { requested: request.plan }).ok)
throw createError({ statusCode: 500, message: errorMessage('migrate.s2s_invalid') })
return response
try {
const response = await resolveMigrateAccountState(request.github_user_id, request.plan)
// Fail closed on our own answer: Migrate prices from it.
if (!validateMigrateAccountStateResponse(response, { requested: request.plan }).ok)
throw createError({ statusCode: 500, message: errorMessage('migrate.s2s_invalid') })
return response
}
catch (err) {
// Our failure, not Migrate's: its retry of the same request must not be refused as a replay.
await useDatabaseProvider().releaseMigrateS2sJti(request.jti).catch(() => {})
throw err
}
})
3 changes: 3 additions & 0 deletions server/api/migrate/claim.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ export default defineEventHandler(async (event) => {

if (grant.user_id !== session.user.id)
throw createError({ statusCode: 409, message: errorMessage('migrate.claim_taken') })
// The order was bought as a bundle: its Studio year is on the order, there is no trial to claim.
if (grant.kind === 'bundle')
throw createError({ statusCode: 409, message: errorMessage('migrate.grant_bundle') })

const existing = await useDatabaseProvider().getMigrateCommentsExportState(grant.id as string)
// Not awaited: a slow or failing export never holds the claim up. It never throws.
Expand Down
3 changes: 3 additions & 0 deletions server/api/migrate/grants/[grantId]/checkout.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export default defineEventHandler(async (event) => {

if (grant.redeemed_at)
throw createError({ statusCode: 409, message: errorMessage('migrate.grant_used') })
// A bundle grant is paid through Migrate's checkout, never opened as an included trial.
if (grant.kind === 'bundle')
throw createError({ statusCode: 409, message: errorMessage('migrate.grant_bundle') })
if (grant.bound_at && grant.workspace_id !== workspaceId)
throw createError({ statusCode: 409, message: errorMessage('migrate.grant_bound_elsewhere') })

Expand Down
55 changes: 55 additions & 0 deletions server/api/migrate/provision.post.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* POST /api/migrate/provision
*
* Migrate asks, server to server, for the Studio side of a "Migrate with
* Studio" bundle: the customer's Studio account and grant, and the one Polar
* checkout that charges the whole quote. Body `{ token }`: a claim v2 signed
* with Migrate's key (`MigrateStudioClaimV2`, `@contentrain/types`), single-use
* by `jti`. Not a user surface: no session. See `provisionMigrateBundle`.
*/
import { validateMigrateStudioClaimV2 } from '@contentrain/types'
import { migrateClaimPublicKey } from '../../utils/migrate-grant'
import { provisionMigrateBundle } from '../../utils/migrate-provision'
import { MigrateS2sError, verifyMigrateS2sRequest } from '../../utils/migrate-s2s'

export default defineEventHandler(async (event) => {
const publicKey = migrateClaimPublicKey()
if (!publicKey) throw createError({ statusCode: 404, message: errorMessage('migrate.unavailable') })

const body = await readBody<{ token?: unknown }>(event)
if (typeof body?.token !== 'string' || body.token.length === 0 || body.token.length > 8192)
throw createError({ statusCode: 400, message: errorMessage('migrate.s2s_invalid') })

let claim
try {
claim = await verifyMigrateS2sRequest(
body.token,
publicKey,
'provision',
(payload, now) => {
const checked = validateMigrateStudioClaimV2(payload, { now })
return checked.ok ? { ok: true, value: checked.claim } : checked
},
(jti, purpose, expiresAt) => useDatabaseProvider().claimMigrateS2sJti(jti, purpose, expiresAt),
)
}
catch (err) {
if (err instanceof MigrateS2sError) {
if (err.reason === 'expired') throw createError({ statusCode: 410, message: errorMessage('migrate.claim_expired') })
if (err.reason === 'replayed') throw createError({ statusCode: 409, message: errorMessage('migrate.s2s_replayed') })
}
throw createError({ statusCode: 400, message: errorMessage('migrate.s2s_invalid') })
}

try {
return await provisionMigrateBundle(claim)
}
catch (err) {
// Only our own failures give the `jti` back, so Migrate's retry of the same request is not
// refused as a replay. A refusal (4xx: quote changed, state unsupported, ...) is an answer,
// and a failure after a checkout exists is persisted on the grant, so a retry finds it.
const status = (err as { statusCode?: number }).statusCode
if (!status || status >= 500) await useDatabaseProvider().releaseMigrateS2sJti(claim.jti).catch(() => {})
throw err
}
})
35 changes: 35 additions & 0 deletions server/plugins/migrate-bundle-reconciler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Migrate bundle reconciler — Nitro plugin.
*
* Every 6 hours, retries the move of bundle subscriptions to the yearly list
* product that the billing webhook could not complete, and raises the alarm
* for those within 30 days of renewal (`reconcileMigrateBundles`). Does nothing
* when the deployment has no payment provider or no bundle grants.
*/
import { reconcileMigrateBundles } from '../utils/migrate-bundle-subscription'
import { useDatabaseProvider, usePaymentProvider } from '../utils/providers'

const INTERVAL_MS = 6 * 60 * 60 * 1000

export default defineNitroPlugin((nitroApp) => {
setTimeout(() => runReconcile().catch(logFailure), 60_000)
const interval = setInterval(() => {
runReconcile().catch(logFailure)
}, INTERVAL_MS)
nitroApp.hooks.hook('close', () => clearInterval(interval))
})

function logFailure(err: unknown) {
// eslint-disable-next-line no-console -- scheduled background job; failure must surface somewhere
console.error('[migrate-bundle] Scheduled reconcile failed:', err)
}

async function runReconcile(): Promise<void> {
const payment = usePaymentProvider()
if (!payment) return
// Cheap guard first: no bundle grant waiting, no provider call.
if ((await useDatabaseProvider().listPendingMigrateBundles(1)).length === 0) return
const summary = await reconcileMigrateBundles(payment)
// eslint-disable-next-line no-console -- scheduled job summary
console.info('[migrate-bundle] reconcile', summary)
}
22 changes: 22 additions & 0 deletions server/providers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,21 @@ export interface AuthProvider {
*/
getUserByProviderAccount: (provider: 'github' | 'google', accountId: string) => Promise<AuthUser | null>

/**
* Find the user behind an OAuth account, or create one for it without a
* sign-in (a Migrate customer who pays before ever opening Studio). The
* email must be one the provider verified: it links an existing user with
* that email (their stored profile is left as it is), or names the new
* one. Creating fires the same bootstrap as a first sign-in (profile and
* personal workspace). Throws `IdentityConflictError` when the email's
* user already has a different account of that provider.
*/
ensureUserForProviderAccount: (input: {
provider: 'github'
accountId: string
email: string
}) => Promise<AuthUser>

/**
* Delete a user account permanently.
* Cascades to profiles, workspaces (owned), memberships, etc.
Expand All @@ -146,3 +161,10 @@ export interface AuthProvider {
*/
revokeSession?: (refreshToken: string) => Promise<void>
}

/** The email's user already signed in with another account of the same provider. */
export class IdentityConflictError extends Error {
constructor() {
super('Email belongs to a user with a different provider account')
}
}
Loading
Loading