From 206ec7fd73c3b57f63aa48f1ce7dc612f55116d0 Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:23:03 +0300 Subject: [PATCH 1/3] feat(migrate): bundle money guards and identity check (S3 slice 1) Pin @contentrain/types 1.45.0 (renewal_cents) and drop the local extra type. Supabase auth refuses to hand an email account with another GitHub identity to a stranger. The webhook carries the checkout id: a second subscription for a grant, or a payment from a stale checkout, raises an ALARM (Polar cannot expire a checkout); a redeemed bundle without a subscription id alarms in the reconciler. --- package.json | 2 +- pnpm-lock.yaml | 10 +++--- server/api/billing/webhook/[provider].post.ts | 4 +-- server/providers/payment/plugins/polar.ts | 2 ++ server/providers/payment/types.ts | 2 ++ server/providers/supabase-auth.ts | 8 ++++- server/utils/migrate-bundle-subscription.ts | 30 ++++++++++++++-- .../billing-webhook.integration.test.ts | 2 +- .../unit/migrate-bundle-subscription.test.ts | 35 +++++++++++++++++++ tests/unit/supabase-auth-provider.test.ts | 26 ++++++++++++++ 10 files changed, 109 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 1e630a56..1351a846 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "@aws-sdk/client-s3": "^3.1076.0", "@contentrain/mcp": "3.9.0", "@contentrain/query": "7.4.0", - "@contentrain/types": "1.44.0", + "@contentrain/types": "1.45.0", "@gitbeaker/rest": "^43.8.0", "@nuxt/eslint": "1.16.0", "@nuxt/image": "2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9dd49dc6..96294b07 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,8 +29,8 @@ importers: specifier: 7.4.0 version: 7.4.0 '@contentrain/types': - specifier: 1.44.0 - version: 1.44.0 + specifier: 1.45.0 + version: 1.45.0 '@gitbeaker/rest': specifier: ^43.8.0 version: 43.8.0 @@ -713,8 +713,8 @@ packages: '@contentrain/types@1.30.0': resolution: {integrity: sha512-inJhFqAY25wIw4NvpqDXOn24PRbVEh43s6GGFQSRyBbbmGiWu+xD67D2UEqaEllaFNLSVQ0j2DpOjDj+P6ezQA==} - '@contentrain/types@1.44.0': - resolution: {integrity: sha512-77d1Sf5rUKd65MxtINltBvs9wkH7z7m6NnUkbM3xH6dhiytfwRtXAWJvozgk4u0qgmU0btPanc6uZKx2OxB2MQ==} + '@contentrain/types@1.45.0': + resolution: {integrity: sha512-VTkKNxXGJxwme1Ln1CTafZo8RwQx5Q1vUur6R1JcfndYgM4ULrJpJt6LJJV72j15xjo8BOVYNyBk08WS4Istzw==} '@conventional-changelog/git-client@3.1.2': resolution: {integrity: sha512-jZqwnJwf7nboIlAcw/mkOjVa6DexCcUOgT2oOQgkoi3z9vR8tGFkcMy2BFcYwjhL9sYcDDXkRQDayiDieCoW7A==} @@ -7938,7 +7938,7 @@ snapshots: '@contentrain/types@1.30.0': {} - '@contentrain/types@1.44.0': {} + '@contentrain/types@1.45.0': {} '@conventional-changelog/git-client@3.1.2(conventional-commits-parser@7.1.2)': dependencies: diff --git a/server/api/billing/webhook/[provider].post.ts b/server/api/billing/webhook/[provider].post.ts index bc2a7871..90e9d562 100644 --- a/server/api/billing/webhook/[provider].post.ts +++ b/server/api/billing/webhook/[provider].post.ts @@ -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 redeemMigrateGrant(provider, result.migrateGrantId, result.subscriptionId ?? null) + await redeemMigrateGrant(provider, result.migrateGrantId, result.subscriptionId ?? null, result.checkoutId ?? null) } // First 'trialing' observation consumes the workspace's one-time // trial, so a later re-checkout (after cancel/expiry) gets a paid @@ -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 redeemMigrateGrant(provider, result.migrateGrantId, result.subscriptionId ?? null) + await redeemMigrateGrant(provider, result.migrateGrantId, result.subscriptionId ?? null, result.checkoutId ?? null) } const workspaceUpdate: Record = {} diff --git a/server/providers/payment/plugins/polar.ts b/server/providers/payment/plugins/polar.ts index e2c7da7c..18811df4 100644 --- a/server/providers/payment/plugins/polar.ts +++ b/server/providers/payment/plugins/polar.ts @@ -100,6 +100,7 @@ interface PolarSubscriptionLike { status: string customerId: string productId: string + checkoutId?: string | null currentPeriodStart: Date | string | null currentPeriodEnd: Date | string | null trialEnd: Date | string | null @@ -154,6 +155,7 @@ function subscriptionToResult( plan: planFromProductId(sub.productId, productMap) ?? planFromMeta, productId: sub.productId, subscriptionId: sub.id, + ...(sub.checkoutId ? { checkoutId: sub.checkoutId } : {}), customerId: sub.customerId, subscriptionStatus: sub.status, currentPeriodStart: isoOrUndefined(sub.currentPeriodStart), diff --git a/server/providers/payment/types.ts b/server/providers/payment/types.ts index ea0d7773..f1ebe262 100644 --- a/server/providers/payment/types.ts +++ b/server/providers/payment/types.ts @@ -89,6 +89,8 @@ export interface WebhookResult { workspaceId?: string plan?: string subscriptionId?: string + /** The checkout that created the subscription (Polar `checkoutId`), when the provider reports it. */ + checkoutId?: string customerId?: string /** Provider-normalised status: trialing, active, past_due, canceled, unpaid, incomplete. */ subscriptionStatus?: string diff --git a/server/providers/supabase-auth.ts b/server/providers/supabase-auth.ts index 60ab2749..7472c740 100644 --- a/server/providers/supabase-auth.ts +++ b/server/providers/supabase-auth.ts @@ -1,4 +1,5 @@ import type { AuthProvider, AuthSession, AuthTokens, AuthUser, OAuthRedirectResult, ProviderTokens } from './auth' +import { IdentityConflictError } from './auth' import { createSupabaseAdminClient, createSupabaseAuthFlowClient } from './supabase-client' /** @@ -273,7 +274,12 @@ export function createSupabaseAuthProvider(): AuthProvider { // returned as is, and GoTrue links the GitHub identity at their first GitHub sign-in // (same verified email). A new user is created confirmed; the bootstrap trigger fires. const byEmail = await this.getUserByEmail(input.email) - if (byEmail) return byEmail + if (byEmail) { + // The account behind this email already signed in with a different GitHub account: linking + // ours would hand it to a stranger (same guard as the managed pair). + if (byEmail.providerAccountId && byEmail.providerAccountId !== input.accountId) throw new IdentityConflictError() + return byEmail + } const admin = createSupabaseAdminClient() const { data, error } = await admin.auth.admin.createUser({ email: input.email, diff --git a/server/utils/migrate-bundle-subscription.ts b/server/utils/migrate-bundle-subscription.ts index 6fd16974..7270dff9 100644 --- a/server/utils/migrate-bundle-subscription.ts +++ b/server/utils/migrate-bundle-subscription.ts @@ -57,7 +57,13 @@ export async function reconcileMigrateBundles(payment: PaymentProvider, now: Dat const summary: BundleReconcileSummary = { checked: pending.length, applied: 0, stillPending: 0, alarms: 0 } for (const grant of pending) { const subscriptionId = grant.redeemed_subscription_id as string | null - if (!subscriptionId) continue + if (!subscriptionId) { + // Paid (redeemed) but no subscription id was recorded: nothing can be moved, and the renewal would charge the ad-hoc price. + summary.alarms++ + // eslint-disable-next-line no-console -- the alarm: watched by the platform's log alert + console.error(`[migrate-bundle] ALARM grant ${String(grant.id)} is redeemed without a subscription id; its ad-hoc price cannot be moved`) + continue + } const result = await applyBundleListProduct(payment, grant, subscriptionId) if (result === 'applied') { summary.applied++ @@ -82,8 +88,28 @@ export async function reconcileMigrateBundles(payment: PaymentProvider, now: Dat * grant's subscription moves to its list product. Idempotent: whichever of * `subscription.created` / `.updated` arrives first does the work. */ -export async function redeemMigrateGrant(payment: PaymentProvider, grantId: string, subscriptionId: string | null): Promise { +export async function redeemMigrateGrant( + payment: PaymentProvider, + grantId: string, + subscriptionId: string | null, + checkoutId: string | null = null, +): Promise { const db = useDatabaseProvider() + const before = await db.getMigrateGrantById(grantId) + // Polar cannot expire a checkout, so an old one can still be paid after a re-quote or beside the current one. + // That is money: say so loudly, and never let a second payment pass as the grant's subscription. + if (before?.kind === 'bundle' && subscriptionId) { + const known = before.redeemed_subscription_id as string | null + if (known && known !== subscriptionId) { + // eslint-disable-next-line no-console -- the alarm: watched by the platform's log alert + console.error(`[migrate-bundle] ALARM duplicate payment: grant ${grantId} already has subscription ${known}, subscription ${subscriptionId} (checkout ${checkoutId ?? 'unknown'}) came from another checkout; refund it`) + return + } + if (checkoutId && before.checkout_id && before.checkout_id !== checkoutId) { + // eslint-disable-next-line no-console -- the alarm: watched by the platform's log alert + console.error(`[migrate-bundle] ALARM stale checkout paid: grant ${grantId} subscription ${subscriptionId} came from checkout ${checkoutId}, the current one is ${String(before.checkout_id)}; check the amount paid against the quote`) + } + } await db.markMigrateGrantRedeemed(grantId, subscriptionId) if (!subscriptionId) return const grant = await db.getMigrateGrantById(grantId) diff --git a/tests/integration/billing-webhook.integration.test.ts b/tests/integration/billing-webhook.integration.test.ts index 63391cd6..cb7320b2 100644 --- a/tests/integration/billing-webhook.integration.test.ts +++ b/tests/integration/billing-webhook.integration.test.ts @@ -202,7 +202,7 @@ describe('billing webhook integration', () => { const handler = await mockPluginAndLoadHandler() await handler({ context: {} } as never) expect(markMigrateGrantRedeemed).toHaveBeenCalledWith('grant-1', 'sub_123') - expect(redeemMigrateGrant).toHaveBeenCalledWith(expect.anything(), 'grant-1', 'sub_123') + expect(redeemMigrateGrant).toHaveBeenCalledWith(expect.anything(), 'grant-1', 'sub_123', null) // The trial cap tells a Migrate trial apart by this mark. expect(upsertPaymentAccount).toHaveBeenCalledWith(expect.objectContaining({ pluginMetadata: expect.objectContaining({ trial_origin: 'migrate' }), diff --git a/tests/unit/migrate-bundle-subscription.test.ts b/tests/unit/migrate-bundle-subscription.test.ts index 4b8e5393..a1817c99 100644 --- a/tests/unit/migrate-bundle-subscription.test.ts +++ b/tests/unit/migrate-bundle-subscription.test.ts @@ -78,7 +78,42 @@ describe('bundle subscription: move to the list product', () => { expect(payment.moveBundleSubscriptionToList).not.toHaveBeenCalled() }) + describe('money guards on redeem', () => { + const alarms = () => errorLog.mock.calls.map(call => String(call[0])).filter(line => line.includes('ALARM')) + + it('a second subscription for a grant that already has one raises an alarm and is not moved', async () => { + db.getMigrateGrantById.mockResolvedValue(bundleGrant({ redeemed_subscription_id: 'sub_1', checkout_id: 'co_new' })) + const { redeemMigrateGrant } = await load() + await redeemMigrateGrant(payment as never, 'grant-1', 'sub_2', 'co_old') + expect(alarms()).toEqual([expect.stringContaining('duplicate payment')]) + expect(db.markMigrateGrantRedeemed).not.toHaveBeenCalled() + expect(payment.moveBundleSubscriptionToList).not.toHaveBeenCalled() + }) + + it('the same subscription arriving twice (created, then updated) is not a duplicate', async () => { + db.getMigrateGrantById.mockResolvedValue(bundleGrant({ redeemed_subscription_id: 'sub_1', checkout_id: 'co_new' })) + const { redeemMigrateGrant } = await load() + await redeemMigrateGrant(payment as never, 'grant-1', 'sub_1', 'co_new') + expect(alarms()).toEqual([]) + }) + + it('a paid checkout that is not the current one is still honoured, with an alarm to check the amount', async () => { + db.getMigrateGrantById.mockResolvedValue(bundleGrant({ redeemed_subscription_id: null, checkout_id: 'co_new' })) + const { redeemMigrateGrant } = await load() + await redeemMigrateGrant(payment as never, 'grant-1', 'sub_1', 'co_old') + expect(alarms()).toEqual([expect.stringContaining('stale checkout paid')]) + expect(db.markMigrateGrantRedeemed).toHaveBeenCalledWith('grant-1', 'sub_1') + }) + }) + describe('reconciler', () => { + it('alarms on a redeemed bundle that has no subscription id', async () => { + db.listPendingMigrateBundles.mockResolvedValue([bundleGrant({ redeemed_subscription_id: null })]) + const { reconcileMigrateBundles } = await load() + expect(await reconcileMigrateBundles(payment as never, now)).toMatchObject({ checked: 1, alarms: 1 }) + expect(errorLog.mock.calls.some(call => String(call[0]).includes('without a subscription id'))).toBe(true) + }) + it('retries pending moves and counts those it fixed', async () => { db.listPendingMigrateBundles.mockResolvedValue([bundleGrant(), bundleGrant({ id: 'grant-2', redeemed_subscription_id: 'sub_2' })]) const { reconcileMigrateBundles } = await load() diff --git a/tests/unit/supabase-auth-provider.test.ts b/tests/unit/supabase-auth-provider.test.ts index 8bcca7ef..2ff39355 100644 --- a/tests/unit/supabase-auth-provider.test.ts +++ b/tests/unit/supabase-auth-provider.test.ts @@ -273,4 +273,30 @@ describe('supabase auth provider', () => { }, }) }) + + describe('ensureUserForProviderAccount', () => { + const load = async () => { + const { createSupabaseAuthProvider } = await import('../../server/providers/supabase-auth') + const { IdentityConflictError } = await import('../../server/providers/auth') + return { provider: createSupabaseAuthProvider(), IdentityConflictError } + } + const input = { provider: 'github' as const, accountId: '4242', email: 'owner@example.com' } + + it('refuses to hand an email account that signed in with another GitHub account to a stranger', async () => { + const { provider, IdentityConflictError } = await load() + vi.spyOn(provider, 'getUserByProviderAccount').mockResolvedValue(null) + vi.spyOn(provider, 'getUserByEmail').mockResolvedValue({ id: 'u1', email: input.email, avatarUrl: null, provider: 'github', providerAccountId: '9999' }) + await expect(provider.ensureUserForProviderAccount(input)).rejects.toBeInstanceOf(IdentityConflictError) + }) + + it('returns an email account with no GitHub identity yet (GoTrue links it at first sign-in) or the same one', async () => { + const { provider } = await load() + vi.spyOn(provider, 'getUserByProviderAccount').mockResolvedValue(null) + const byEmail = vi.spyOn(provider, 'getUserByEmail') + byEmail.mockResolvedValue({ id: 'u1', email: input.email, avatarUrl: null, provider: 'email' as never, providerAccountId: null }) + expect((await provider.ensureUserForProviderAccount(input)).id).toBe('u1') + byEmail.mockResolvedValue({ id: 'u2', email: input.email, avatarUrl: null, provider: 'github', providerAccountId: '4242' }) + expect((await provider.ensureUserForProviderAccount(input)).id).toBe('u2') + }) + }) }) From 0464111d5c3c07c367301c17fd0115b33dc810a9 Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:36:40 +0300 Subject: [PATCH 2/3] fix(migrate): a duplicate bundle payment never becomes the active account subscription.created/updated ask first (isDuplicateBundleSubscription) and skip every account write for a second subscription on a bundle grant, so refunding it cannot cancel the valid plan. Supabase identity check reads the user identities instead of the last sign-in provider metadata. --- server/api/billing/webhook/[provider].post.ts | 6 ++++ server/providers/supabase-auth.ts | 11 +++++-- server/utils/migrate-bundle-subscription.ts | 21 ++++++++++---- .../billing-webhook.integration.test.ts | 22 ++++++++++++++ .../unit/migrate-bundle-subscription.test.ts | 13 +++++++++ tests/unit/supabase-auth-provider.test.ts | 29 +++++++++---------- 6 files changed, 78 insertions(+), 24 deletions(-) diff --git a/server/api/billing/webhook/[provider].post.ts b/server/api/billing/webhook/[provider].post.ts index 90e9d562..88128f5b 100644 --- a/server/api/billing/webhook/[provider].post.ts +++ b/server/api/billing/webhook/[provider].post.ts @@ -279,6 +279,10 @@ export default defineEventHandler(async (event) => { case 'subscription.created': { // Fresh subscription — upsert the active account for this workspace. if (!result.workspaceId || !result.customerId) break + // A second payment for a bundle (an old checkout) must not replace the valid subscription as the + // workspace's account: its refund would cancel the plan. Logged as an ALARM; ops refunds it. + if (result.migrateGrantId && result.subscriptionId + && await isDuplicateBundleSubscription(result.migrateGrantId, result.subscriptionId, result.checkoutId ?? null)) break const overageLock = await planOverageLock(db, { workspaceId: result.workspaceId, billableMeters: result.billableMeters, @@ -341,6 +345,8 @@ export default defineEventHandler(async (event) => { case 'subscription.updated': { if (!result.workspaceId || !result.customerId) break + if (result.migrateGrantId && result.subscriptionId + && await isDuplicateBundleSubscription(result.migrateGrantId, result.subscriptionId, result.checkoutId ?? null)) break // Read the existing account BEFORE upsert so we can detect the // transitions worth emailing on (trial→active, payment failed or // recovered, cancellation scheduled). diff --git a/server/providers/supabase-auth.ts b/server/providers/supabase-auth.ts index 7472c740..5a6c1303 100644 --- a/server/providers/supabase-auth.ts +++ b/server/providers/supabase-auth.ts @@ -275,9 +275,14 @@ export function createSupabaseAuthProvider(): AuthProvider { // (same verified email). A new user is created confirmed; the bootstrap trigger fires. const byEmail = await this.getUserByEmail(input.email) if (byEmail) { - // The account behind this email already signed in with a different GitHub account: linking - // ours would hand it to a stranger (same guard as the managed pair). - if (byEmail.providerAccountId && byEmail.providerAccountId !== input.accountId) throw new IdentityConflictError() + // The account behind this email already has a different GitHub identity: linking ours would hand + // it to a stranger (same guard as the managed pair). Read from `auth.identities` (the user's + // `identities`), not from `user_metadata.provider_id`, which only names the last sign-in provider. + const { data: full, error: fullError } = await createSupabaseAdminClient().auth.admin.getUserById(byEmail.id) + if (fullError) throw fullError + const other = full?.user?.identities?.find(identity => identity.provider === input.provider) + const otherId = other ? String(other.identity_data?.provider_id ?? other.identity_data?.sub ?? other.id ?? '') : '' + if (other && otherId !== input.accountId) throw new IdentityConflictError() return byEmail } const admin = createSupabaseAdminClient() diff --git a/server/utils/migrate-bundle-subscription.ts b/server/utils/migrate-bundle-subscription.ts index 7270dff9..0a34eeeb 100644 --- a/server/utils/migrate-bundle-subscription.ts +++ b/server/utils/migrate-bundle-subscription.ts @@ -88,6 +88,20 @@ export async function reconcileMigrateBundles(payment: PaymentProvider, now: Dat * grant's subscription moves to its list product. Idempotent: whichever of * `subscription.created` / `.updated` arrives first does the work. */ +/** + * A subscription that is not the one a bundle grant already has came from another checkout (Polar cannot + * expire the old one): a duplicate payment. It must not become the workspace's active account, or refunding + * it would cancel and drop the valid bundle's plan. The webhook asks first and skips every account write. + */ +export async function isDuplicateBundleSubscription(grantId: string, subscriptionId: string, checkoutId: string | null = null): Promise { + const grant = await useDatabaseProvider().getMigrateGrantById(grantId) + const known = grant?.kind === 'bundle' ? (grant.redeemed_subscription_id as string | null) : null + if (!known || known === subscriptionId) return false + // eslint-disable-next-line no-console -- the alarm: watched by the platform's log alert + console.error(`[migrate-bundle] ALARM duplicate payment: grant ${grantId} already has subscription ${known}, subscription ${subscriptionId} (checkout ${checkoutId ?? 'unknown'}) came from another checkout; refund it`) + return true +} + export async function redeemMigrateGrant( payment: PaymentProvider, grantId: string, @@ -99,12 +113,7 @@ export async function redeemMigrateGrant( // Polar cannot expire a checkout, so an old one can still be paid after a re-quote or beside the current one. // That is money: say so loudly, and never let a second payment pass as the grant's subscription. if (before?.kind === 'bundle' && subscriptionId) { - const known = before.redeemed_subscription_id as string | null - if (known && known !== subscriptionId) { - // eslint-disable-next-line no-console -- the alarm: watched by the platform's log alert - console.error(`[migrate-bundle] ALARM duplicate payment: grant ${grantId} already has subscription ${known}, subscription ${subscriptionId} (checkout ${checkoutId ?? 'unknown'}) came from another checkout; refund it`) - return - } + if (await isDuplicateBundleSubscription(grantId, subscriptionId, checkoutId)) return if (checkoutId && before.checkout_id && before.checkout_id !== checkoutId) { // eslint-disable-next-line no-console -- the alarm: watched by the platform's log alert console.error(`[migrate-bundle] ALARM stale checkout paid: grant ${grantId} subscription ${subscriptionId} came from checkout ${checkoutId}, the current one is ${String(before.checkout_id)}; check the amount paid against the quote`) diff --git a/tests/integration/billing-webhook.integration.test.ts b/tests/integration/billing-webhook.integration.test.ts index cb7320b2..f8d139d2 100644 --- a/tests/integration/billing-webhook.integration.test.ts +++ b/tests/integration/billing-webhook.integration.test.ts @@ -25,6 +25,7 @@ describe('billing webhook integration', () => { // The real util (auto-imported in Nitro) marks the grant, then moves a bundle's subscription; the // move itself is covered in migrate-bundle-subscription.test.ts, here only what the webhook hands it. let redeemMigrateGrant: ReturnType + let isDuplicateBundleSubscription: ReturnType beforeEach(() => { vi.resetModules() @@ -33,6 +34,8 @@ describe('billing webhook integration', () => { await (globalThis as unknown as { useDatabaseProvider: () => { markMigrateGrantRedeemed: (g: string, s: string | null) => Promise } }).useDatabaseProvider().markMigrateGrantRedeemed(grantId, subscriptionId) }) vi.stubGlobal('redeemMigrateGrant', redeemMigrateGrant) + isDuplicateBundleSubscription = vi.fn().mockResolvedValue(false) + vi.stubGlobal('isDuplicateBundleSubscription', isDuplicateBundleSubscription) vi.stubGlobal('defineEventHandler', (handler: unknown) => handler) vi.stubGlobal('createError', createErrorLike) vi.stubGlobal('readRawBody', vi.fn().mockResolvedValue('{}')) @@ -175,6 +178,25 @@ describe('billing webhook integration', () => { })) }) + it('a duplicate bundle payment never becomes the active account, so refunding it leaves the valid plan alone', async () => { + isDuplicateBundleSubscription.mockResolvedValue(true) + const created = { event: 'subscription.created', workspaceId: 'ws-1', plan: 'pro', customerId: 'cus_123', subscriptionId: 'sub_dup', checkoutId: 'co_old', subscriptionStatus: 'active', migrateGrantId: 'grant-1' } + handleWebhookMock.mockResolvedValueOnce(created) + const handler = await mockPluginAndLoadHandler() + await handler({ context: {} } as never) + expect(isDuplicateBundleSubscription).toHaveBeenCalledWith('grant-1', 'sub_dup', 'co_old') + expect(upsertPaymentAccount).not.toHaveBeenCalled() + expect(redeemMigrateGrant).not.toHaveBeenCalled() + expect(updateWorkspace).not.toHaveBeenCalled() + + // The refund of the duplicate: the workspace's valid subscription is another one, so the ending is ignored. + getActivePaymentAccount.mockResolvedValue({ subscription_id: 'sub_valid', plan: 'pro' }) + handleWebhookMock.mockResolvedValueOnce({ ...created, event: 'subscription.canceled', subscriptionStatus: 'canceled' }) + await handler({ context: {} } as never) + expect(archiveActivePaymentAccount).not.toHaveBeenCalled() + expect(updateWorkspace).not.toHaveBeenCalled() + }) + it('uses up the Migrate grant a subscription was started from', async () => { const markMigrateGrantRedeemed = vi.fn().mockResolvedValue(undefined) vi.stubGlobal('useDatabaseProvider', vi.fn().mockReturnValue({ diff --git a/tests/unit/migrate-bundle-subscription.test.ts b/tests/unit/migrate-bundle-subscription.test.ts index a1817c99..02fc752e 100644 --- a/tests/unit/migrate-bundle-subscription.test.ts +++ b/tests/unit/migrate-bundle-subscription.test.ts @@ -78,6 +78,19 @@ describe('bundle subscription: move to the list product', () => { expect(payment.moveBundleSubscriptionToList).not.toHaveBeenCalled() }) + describe('isDuplicateBundleSubscription', () => { + it('is true only for a bundle that already has a different subscription', async () => { + const { isDuplicateBundleSubscription } = await load() + db.getMigrateGrantById.mockResolvedValue(bundleGrant({ redeemed_subscription_id: 'sub_1' })) + expect(await isDuplicateBundleSubscription('grant-1', 'sub_2')).toBe(true) + expect(await isDuplicateBundleSubscription('grant-1', 'sub_1')).toBe(false) + db.getMigrateGrantById.mockResolvedValue(bundleGrant({ redeemed_subscription_id: null })) + expect(await isDuplicateBundleSubscription('grant-1', 'sub_2')).toBe(false) + db.getMigrateGrantById.mockResolvedValue(bundleGrant({ kind: 'trial', redeemed_subscription_id: 'sub_1' })) + expect(await isDuplicateBundleSubscription('grant-1', 'sub_2')).toBe(false) + }) + }) + describe('money guards on redeem', () => { const alarms = () => errorLog.mock.calls.map(call => String(call[0])).filter(line => line.includes('ALARM')) diff --git a/tests/unit/supabase-auth-provider.test.ts b/tests/unit/supabase-auth-provider.test.ts index 2ff39355..85e521dc 100644 --- a/tests/unit/supabase-auth-provider.test.ts +++ b/tests/unit/supabase-auth-provider.test.ts @@ -275,28 +275,27 @@ describe('supabase auth provider', () => { }) describe('ensureUserForProviderAccount', () => { - const load = async () => { + const input = { provider: 'github' as const, accountId: '4242', email: 'owner@example.com' } + const load = async (identities: Array>) => { + providerState.adminClient = { auth: { admin: { getUserById: vi.fn().mockResolvedValue({ data: { user: { id: 'u1', identities } }, error: null }) } } } const { createSupabaseAuthProvider } = await import('../../server/providers/supabase-auth') const { IdentityConflictError } = await import('../../server/providers/auth') - return { provider: createSupabaseAuthProvider(), IdentityConflictError } + const provider = createSupabaseAuthProvider() + vi.spyOn(provider, 'getUserByProviderAccount').mockResolvedValue(null) + vi.spyOn(provider, 'getUserByEmail').mockResolvedValue({ id: 'u1', email: input.email, avatarUrl: null, provider: 'google' as never, providerAccountId: 'g-1' }) + return { provider, IdentityConflictError } } - const input = { provider: 'github' as const, accountId: '4242', email: 'owner@example.com' } - it('refuses to hand an email account that signed in with another GitHub account to a stranger', async () => { - const { provider, IdentityConflictError } = await load() - vi.spyOn(provider, 'getUserByProviderAccount').mockResolvedValue(null) - vi.spyOn(provider, 'getUserByEmail').mockResolvedValue({ id: 'u1', email: input.email, avatarUrl: null, provider: 'github', providerAccountId: '9999' }) + it('refuses an email account that already has another GitHub identity', async () => { + const { provider, IdentityConflictError } = await load([{ provider: 'github', identity_data: { provider_id: '9999' } }]) await expect(provider.ensureUserForProviderAccount(input)).rejects.toBeInstanceOf(IdentityConflictError) }) - it('returns an email account with no GitHub identity yet (GoTrue links it at first sign-in) or the same one', async () => { - const { provider } = await load() - vi.spyOn(provider, 'getUserByProviderAccount').mockResolvedValue(null) - const byEmail = vi.spyOn(provider, 'getUserByEmail') - byEmail.mockResolvedValue({ id: 'u1', email: input.email, avatarUrl: null, provider: 'email' as never, providerAccountId: null }) - expect((await provider.ensureUserForProviderAccount(input)).id).toBe('u1') - byEmail.mockResolvedValue({ id: 'u2', email: input.email, avatarUrl: null, provider: 'github', providerAccountId: '4242' }) - expect((await provider.ensureUserForProviderAccount(input)).id).toBe('u2') + it('does not mistake the last sign-in provider for a GitHub identity: Google-only or the same GitHub account passes', async () => { + const googleOnly = await load([{ provider: 'google', identity_data: { sub: 'g-1' } }]) + expect((await googleOnly.provider.ensureUserForProviderAccount(input)).id).toBe('u1') + const same = await load([{ provider: 'google', identity_data: { sub: 'g-1' } }, { provider: 'github', identity_data: { provider_id: '4242' } }]) + expect((await same.provider.ensureUserForProviderAccount(input)).id).toBe('u1') }) }) }) From 8bc3508df637f6c91a088f0a3ba5e4d04359a81c Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:13:08 +0300 Subject: [PATCH 3/3] feat(migrate): grants revoke endpoint and revoked grant state POST /api/migrate/grants/revoke (S2S, purpose 'revoke'): cancels only the subscription the grant is bound to, then marks the grant revoked (migration 045: revoked_at, revoked_reason). Idempotent; a Polar failure leaves the grant live (502) and gives the jti back so Migrate can retry. A refund of a payment that is not the bound subscription (duplicate checkout) never revokes: the request carries no payment id and nothing else is cancelled. Revoked grants answer state 'revoked' on status, refuse checkout/provision (409 migrate.grant_revoked) and treat later payments as alarmed duplicates. @contentrain/types 1.46.0. --- .../content/system/error-messages/en.json | 1 + package.json | 2 +- pnpm-lock.yaml | 10 +- .../migrate/grants/[grantId]/checkout.post.ts | 2 + server/api/migrate/grants/revoke.post.ts | 29 ++++ server/api/migrate/grants/status.post.ts | 4 +- server/providers/database.ts | 6 + .../providers/postgres-db/migrate-grants.ts | 17 ++ server/providers/postgres-db/types.ts | 2 + .../providers/supabase-db/migrate-grants.ts | 13 ++ server/utils/migrate-bundle-subscription.ts | 9 +- server/utils/migrate-grant-status.ts | 6 +- server/utils/migrate-provision.ts | 2 + server/utils/migrate-revoke.ts | 49 ++++++ .../migrations/045_migrate_grant_revoked.sql | 17 ++ .../contract/migrate-grants.contract.test.ts | 12 ++ .../unit/migrate-bundle-subscription.test.ts | 9 ++ tests/unit/migrate-grant-routes.test.ts | 6 + .../unit/migrate-grant-status-routes.test.ts | 9 ++ tests/unit/migrate-provision.test.ts | 2 + tests/unit/migrate-revoke-route.test.ts | 146 ++++++++++++++++++ 21 files changed, 341 insertions(+), 12 deletions(-) create mode 100644 server/api/migrate/grants/revoke.post.ts create mode 100644 server/utils/migrate-revoke.ts create mode 100644 supabase/migrations/045_migrate_grant_revoked.sql create mode 100644 tests/unit/migrate-revoke-route.test.ts diff --git a/.contentrain/content/system/error-messages/en.json b/.contentrain/content/system/error-messages/en.json index d1d4c10c..9af26796 100644 --- a/.contentrain/content/system/error-messages/en.json +++ b/.contentrain/content/system/error-messages/en.json @@ -253,6 +253,7 @@ "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_not_ready": "Studio is not ready for GitHub yet. Finish the Studio plan step in Migrate first.", + "migrate.grant_revoked": "This Studio offer was withdrawn with its Migrate order.", "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.", diff --git a/package.json b/package.json index 1351a846..a6d2c718 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "@aws-sdk/client-s3": "^3.1076.0", "@contentrain/mcp": "3.9.0", "@contentrain/query": "7.4.0", - "@contentrain/types": "1.45.0", + "@contentrain/types": "1.46.0", "@gitbeaker/rest": "^43.8.0", "@nuxt/eslint": "1.16.0", "@nuxt/image": "2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96294b07..7bf2d8c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,8 +29,8 @@ importers: specifier: 7.4.0 version: 7.4.0 '@contentrain/types': - specifier: 1.45.0 - version: 1.45.0 + specifier: 1.46.0 + version: 1.46.0 '@gitbeaker/rest': specifier: ^43.8.0 version: 43.8.0 @@ -713,8 +713,8 @@ packages: '@contentrain/types@1.30.0': resolution: {integrity: sha512-inJhFqAY25wIw4NvpqDXOn24PRbVEh43s6GGFQSRyBbbmGiWu+xD67D2UEqaEllaFNLSVQ0j2DpOjDj+P6ezQA==} - '@contentrain/types@1.45.0': - resolution: {integrity: sha512-VTkKNxXGJxwme1Ln1CTafZo8RwQx5Q1vUur6R1JcfndYgM4ULrJpJt6LJJV72j15xjo8BOVYNyBk08WS4Istzw==} + '@contentrain/types@1.46.0': + resolution: {integrity: sha512-ZbCBNvcpcnIMzjq7AvD12uW3ZGVuLZY0sDPhkGLme8AsZPw4kB+wFVreLSSb+oJ2Te5regwLyS5fpT4mzy3Pmg==} '@conventional-changelog/git-client@3.1.2': resolution: {integrity: sha512-jZqwnJwf7nboIlAcw/mkOjVa6DexCcUOgT2oOQgkoi3z9vR8tGFkcMy2BFcYwjhL9sYcDDXkRQDayiDieCoW7A==} @@ -7938,7 +7938,7 @@ snapshots: '@contentrain/types@1.30.0': {} - '@contentrain/types@1.45.0': {} + '@contentrain/types@1.46.0': {} '@conventional-changelog/git-client@3.1.2(conventional-commits-parser@7.1.2)': dependencies: diff --git a/server/api/migrate/grants/[grantId]/checkout.post.ts b/server/api/migrate/grants/[grantId]/checkout.post.ts index 9baadd5f..02be2ed7 100644 --- a/server/api/migrate/grants/[grantId]/checkout.post.ts +++ b/server/api/migrate/grants/[grantId]/checkout.post.ts @@ -43,6 +43,8 @@ export default defineEventHandler(async (event) => { ) if (!workspace) throw createError({ statusCode: 403, message: errorMessage('auth.forbidden') }) + if (grant.revoked_at) + throw createError({ statusCode: 409, message: errorMessage('migrate.grant_revoked') }) 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. diff --git a/server/api/migrate/grants/revoke.post.ts b/server/api/migrate/grants/revoke.post.ts new file mode 100644 index 00000000..8502f269 --- /dev/null +++ b/server/api/migrate/grants/revoke.post.ts @@ -0,0 +1,29 @@ +/** + * POST /api/migrate/grants/revoke + * + * Migrate asks, server to server, to withdraw an order's Studio grant (refund or + * failed delivery). Body `{ token }`: a request signed with Migrate's key + * (`MigrateRevokeRequest`, `@contentrain/types`), single-use by `jti`, keyed by + * `order_id`. Not a user surface: no session. The subscription is cancelled in + * Polar and the grant marked `revoked`; see `revokeMigrateGrant`. An order Studio + * holds no grant for is a 404. + */ +import { validateMigrateRevokeRequest } from '@contentrain/types' +import { readMigrateS2sRequest } from '../../../utils/migrate-s2s-route' +import { revokeMigrateGrant } from '../../../utils/migrate-revoke' + +export default defineEventHandler(async (event) => { + const request = await readMigrateS2sRequest(event, 'revoke', validateMigrateRevokeRequest) + const db = useDatabaseProvider() + try { + const grant = await db.getMigrateGrantByOrderId(request.order_id) + if (!grant) throw createError({ statusCode: 404, message: errorMessage('migrate.grant_not_found') }) + return await revokeMigrateGrant(grant, request.reason) + } + catch (err) { + // The jti is single-use; give it back only when Studio itself failed, so Migrate can retry the same request. + const status = (err as { statusCode?: number }).statusCode + if (status === undefined || status >= 500) await db.releaseMigrateS2sJti(request.jti) + throw err + } +}) diff --git a/server/api/migrate/grants/status.post.ts b/server/api/migrate/grants/status.post.ts index c0f02632..bab02fcb 100644 --- a/server/api/migrate/grants/status.post.ts +++ b/server/api/migrate/grants/status.post.ts @@ -22,8 +22,8 @@ export default defineEventHandler(async (event) => { const state = migrateGrantStateOf(grant) const { installed } = await migrateGrantInstallation(grant) - // An install only counts once the subscription ran (the contract refuses it earlier). - const response = { state, installed: installed && state === 'redeemed' } + // An install only counts once the subscription ran (the contract refuses it earlier); a revoked grant keeps one made before. + const response = { state, installed: installed && (state === 'redeemed' || state === 'revoked') } // Fail closed on our own answer: Migrate shows it to a customer. if (!validateMigrateGrantStatusResponse(response).ok) throw createError({ statusCode: 500, message: errorMessage('migrate.s2s_invalid') }) diff --git a/server/providers/database.ts b/server/providers/database.ts index 91bc4127..894710e3 100644 --- a/server/providers/database.ts +++ b/server/providers/database.ts @@ -1160,6 +1160,12 @@ export interface DatabaseProvider { */ markMigrateGrantRedeemed: (grantId: string, subscriptionId: string | null) => Promise + /** + * Withdraw a grant (Migrate's revoke): records when and why. Only the first call counts, so the + * reason of the first revocation stays. Returns the grant as it stands afterwards. + */ + markMigrateGrantRevoked: (grantId: string, reason: string) => Promise + /** * The signed origin of the grant behind a workspace's project: the newest * grant bound to `workspaceId` for `repoFullName` (owner/name, any case) diff --git a/server/providers/postgres-db/migrate-grants.ts b/server/providers/postgres-db/migrate-grants.ts index a4b5aa84..45b8a93e 100644 --- a/server/providers/postgres-db/migrate-grants.ts +++ b/server/providers/postgres-db/migrate-grants.ts @@ -18,6 +18,7 @@ type MigrateGrantMethods = Pick< | 'getMigrateGrantForUser' | 'bindMigrateGrantWorkspace' | 'markMigrateGrantRedeemed' + | 'markMigrateGrantRevoked' | 'getMigrateGrantOrigin' | 'claimMigrateS2sJti' | 'releaseMigrateS2sJti' @@ -256,6 +257,22 @@ export function migrateGrantMethods(): MigrateGrantMethods { } }, + async markMigrateGrantRevoked(grantId, reason) { + try { + await getAdmin() + .updateTable('migrate_grants') + .set(eb => ({ revoked_at: eb.fn('now', []), revoked_reason: reason })) + .where('id', '=', grantId) + .where('revoked_at', 'is', null) + .execute() + const row = await getAdmin().selectFrom('migrate_grants').selectAll().where('id', '=', grantId).executeTakeFirst() + return (row as DatabaseRow | undefined) ?? null + } + catch (error) { + throwDbError(error) + } + }, + async getMigrateGrantOrigin(workspaceId, repoFullName) { const [owner, name] = repoFullName.toLowerCase().split('/') if (!owner || !name) return null diff --git a/server/providers/postgres-db/types.ts b/server/providers/postgres-db/types.ts index e13f7b55..7d16e1e5 100644 --- a/server/providers/postgres-db/types.ts +++ b/server/providers/postgres-db/types.ts @@ -133,6 +133,8 @@ export interface MigrateGrantsTable { amount_cents: number | null bundle_target_product_id: string | null bundle_applied_at: string | null + revoked_at: string | null + revoked_reason: string | null created_at: Generated } diff --git a/server/providers/supabase-db/migrate-grants.ts b/server/providers/supabase-db/migrate-grants.ts index 1e2d55ce..77d0db49 100644 --- a/server/providers/supabase-db/migrate-grants.ts +++ b/server/providers/supabase-db/migrate-grants.ts @@ -18,6 +18,7 @@ type MigrateGrantMethods = Pick< | 'getMigrateGrantForUser' | 'bindMigrateGrantWorkspace' | 'markMigrateGrantRedeemed' + | 'markMigrateGrantRevoked' | 'getMigrateGrantOrigin' | 'claimMigrateS2sJti' | 'releaseMigrateS2sJti' @@ -233,6 +234,18 @@ export function migrateGrantMethods(): MigrateGrantMethods { if (error) fail(error.message) }, + async markMigrateGrantRevoked(grantId, reason) { + const { error } = await getAdmin() + .from('migrate_grants') + .update({ revoked_at: new Date().toISOString(), revoked_reason: reason }) + .eq('id', grantId) + .is('revoked_at', null) + if (error) fail(error.message) + const { data, error: readError } = await getAdmin().from('migrate_grants').select('*').eq('id', grantId).maybeSingle() + if (readError) fail(readError.message) + return (data as DatabaseRow | null) ?? null + }, + async getMigrateGrantOrigin(workspaceId, repoFullName) { const [owner, name] = repoFullName.split('/') if (!owner || !name) return null diff --git a/server/utils/migrate-bundle-subscription.ts b/server/utils/migrate-bundle-subscription.ts index 0a34eeeb..b0b272c2 100644 --- a/server/utils/migrate-bundle-subscription.ts +++ b/server/utils/migrate-bundle-subscription.ts @@ -95,7 +95,14 @@ export async function reconcileMigrateBundles(payment: PaymentProvider, now: Dat */ export async function isDuplicateBundleSubscription(grantId: string, subscriptionId: string, checkoutId: string | null = null): Promise { const grant = await useDatabaseProvider().getMigrateGrantById(grantId) - const known = grant?.kind === 'bundle' ? (grant.redeemed_subscription_id as string | null) : null + if (grant?.kind !== 'bundle') return false + // Withdrawn (refund or failed delivery): nothing paid after that may start or restate a plan. + if (grant.revoked_at) { + // eslint-disable-next-line no-console -- the alarm: watched by the platform's log alert + console.error(`[migrate-bundle] ALARM payment after revoke: grant ${grantId} was revoked, subscription ${subscriptionId} (checkout ${checkoutId ?? 'unknown'}) arrived; refund it`) + return true + } + const known = grant.redeemed_subscription_id as string | null if (!known || known === subscriptionId) return false // eslint-disable-next-line no-console -- the alarm: watched by the platform's log alert console.error(`[migrate-bundle] ALARM duplicate payment: grant ${grantId} already has subscription ${known}, subscription ${subscriptionId} (checkout ${checkoutId ?? 'unknown'}) came from another checkout; refund it`) diff --git a/server/utils/migrate-grant-status.ts b/server/utils/migrate-grant-status.ts index 1e838ec7..c22da8cd 100644 --- a/server/utils/migrate-grant-status.ts +++ b/server/utils/migrate-grant-status.ts @@ -1,13 +1,13 @@ /** * Where a Migrate grant stands, for Migrate's status call and its install-URL - * gate. Lifecycle: migration 031 (claimed → bound → redeemed). `revoked` is - * part of the contract; no stored status maps to it until the revoke column - * exists. + * gate. Lifecycle: migration 031 (claimed → bound → redeemed), and `revoked` + * (migration 045) from any of them, which wins over the rest. */ import type { MigrateGrantState } from '@contentrain/types' import type { DatabaseRow } from '../providers/database' export function migrateGrantStateOf(grant: DatabaseRow): MigrateGrantState { + if (grant.revoked_at) return 'revoked' return grant.redeemed_at ? 'redeemed' : grant.bound_at ? 'bound' : 'claimed' } diff --git a/server/utils/migrate-provision.ts b/server/utils/migrate-provision.ts index 47371876..a5f04082 100644 --- a/server/utils/migrate-provision.ts +++ b/server/utils/migrate-provision.ts @@ -89,6 +89,8 @@ export async function provisionMigrateBundle(claim: MigrateStudioClaimV2, now: D }) // The order belongs to another account, or was opened as something else: never reuse it. if (grant.user_id !== user.id || grant.kind !== 'bundle') fail(409, 'migrate.claim_taken') + // Withdrawn after a refund or a failed delivery: a repeated provision must not reopen it. + if (grant.revoked_at) fail(409, 'migrate.grant_revoked') if (grant.redeemed_at) fail(409, 'migrate.grant_used') const bound = await db.bindMigrateGrantWorkspace(String(grant.id), workspace.id) if (!bound) fail(409, 'migrate.grant_bound_elsewhere') diff --git a/server/utils/migrate-revoke.ts b/server/utils/migrate-revoke.ts new file mode 100644 index 00000000..3b23f3c5 --- /dev/null +++ b/server/utils/migrate-revoke.ts @@ -0,0 +1,49 @@ +/** + * Withdraw a Migrate grant (`POST /api/migrate/grants/revoke`): Migrate's half of a + * refund or a failed delivery. The money is refunded in Polar by an operator; this + * stops Studio from continuing a year nobody pays for. + * + * - Only the subscription the grant is BOUND to (`redeemed_subscription_id`) is + * cancelled. A second payment that came from a stale checkout never became the + * grant's subscription (see `isDuplicateBundleSubscription`): its refund is a + * Polar-side matter and must not revoke the grant, so nothing here reads it. + * - The cancel happens before the grant is marked, so a Polar failure leaves the + * grant live and Migrate can call again (idempotent). A repeated call on a + * revoked grant answers `revoked` and cancels nothing. + * - The cancellation reaches the billing webhook as `subscription.canceled`, which + * drops the workspace plan the usual way. + */ +import type { MigrateRevokeReason, MigrateRevokeResponse } from '@contentrain/types' +import { validateMigrateRevokeResponse } from '@contentrain/types' +import type { DatabaseRow } from '../providers/database' +import { migrateGrantInstallation } from './migrate-grant-status' + +export async function revokeMigrateGrant(grant: DatabaseRow, reason: MigrateRevokeReason): Promise { + const db = useDatabaseProvider() + const { installed } = await migrateGrantInstallation(grant) + + let canceled = false + if (!grant.revoked_at) { + const subscriptionId = grant.redeemed_subscription_id as string | null + if (subscriptionId) { + const payment = usePaymentProvider() + if (!payment) throw createError({ statusCode: 503, message: errorMessage('generic.server_error') }) + try { + await payment.cancelSubscription(subscriptionId) + canceled = true + } + catch (err) { + // eslint-disable-next-line no-console -- ops visibility: Migrate retries the call + console.error(`[migrate-revoke] cancelling subscription ${subscriptionId} for grant ${String(grant.id)} failed:`, err) + throw createError({ statusCode: 502, message: errorMessage('billing.provider_unavailable') }) + } + } + await db.markMigrateGrantRevoked(String(grant.id), reason) + } + + const response: MigrateRevokeResponse = { state: 'revoked', installed, subscription_canceled: canceled } + // Fail closed on our own answer: Migrate acts on it. + if (!validateMigrateRevokeResponse(response).ok) + throw createError({ statusCode: 500, message: errorMessage('migrate.s2s_invalid') }) + return response +} diff --git a/supabase/migrations/045_migrate_grant_revoked.sql b/supabase/migrations/045_migrate_grant_revoked.sql new file mode 100644 index 00000000..23f1e5a9 --- /dev/null +++ b/supabase/migrations/045_migrate_grant_revoked.sql @@ -0,0 +1,17 @@ +-- 045: a withdrawn Migrate grant (S3 revoke). +-- +-- Migrate asks Studio to revoke an order's grant when the order is refunded or its +-- delivery failed (`POST /api/migrate/grants/revoke`). The grant keeps WHEN and WHY, so +-- support can read it and Migrate's status answer says `revoked`. The reason is one of +-- the contract's (`@contentrain/types` MIGRATE_REVOKE_REASONS). Revoking never deletes +-- the row: the order still has to be explainable. Service-role only like the rest of the table. + +ALTER TABLE public.migrate_grants + ADD COLUMN revoked_at timestamp with time zone, + ADD COLUMN revoked_reason text; + +ALTER TABLE public.migrate_grants + ADD CONSTRAINT migrate_grants_revoked_shape CHECK ( + (revoked_at IS NULL AND revoked_reason IS NULL) + OR (revoked_at IS NOT NULL AND revoked_reason IN ('refund_before_delivery', 'refund_after_delivery', 'delivery_failed', 'ops')) + ); diff --git a/tests/contract/migrate-grants.contract.test.ts b/tests/contract/migrate-grants.contract.test.ts index 8d6a6ee5..76de2d6d 100644 --- a/tests/contract/migrate-grants.contract.test.ts +++ b/tests/contract/migrate-grants.contract.test.ts @@ -77,6 +77,18 @@ describe('postgres-db migrate-grants (contract)', () => { expect(row!.redeemed_subscription_id).toBe('sub_first') }) + it('marks a grant revoked once: the first reason stays, the grant is never un-revoked', async () => { + const { grant } = await claim(owner.userId) + expect(grant.revoked_at).toBeNull() + const first = await methods.markMigrateGrantRevoked(grant.id as string, 'refund_before_delivery') + expect(first).toMatchObject({ revoked_reason: 'refund_before_delivery' }) + expect(first!.revoked_at).not.toBeNull() + const again = await methods.markMigrateGrantRevoked(grant.id as string, 'ops') + expect(again).toMatchObject({ revoked_reason: 'refund_before_delivery' }) + expect(String(again!.revoked_at)).toBe(String(first!.revoked_at)) + expect(await methods.markMigrateGrantRevoked(`00000000-0000-0000-0000-000000000000`, 'ops')).toBeNull() + }) + it('keeps the signed origin: taken once, never replaced, found by workspace and repo', async () => { const order = `${orderId}-origin` const claimSite = (origin?: string) => methods.claimMigrateGrant({ diff --git a/tests/unit/migrate-bundle-subscription.test.ts b/tests/unit/migrate-bundle-subscription.test.ts index 02fc752e..f0ee2b12 100644 --- a/tests/unit/migrate-bundle-subscription.test.ts +++ b/tests/unit/migrate-bundle-subscription.test.ts @@ -91,6 +91,15 @@ describe('bundle subscription: move to the list product', () => { }) }) + describe('payment after a revoke', () => { + it('counts as a duplicate with an alarm: nothing paid for a withdrawn grant starts a plan', async () => { + const { isDuplicateBundleSubscription } = await load() + db.getMigrateGrantById.mockResolvedValue(bundleGrant({ redeemed_subscription_id: null, revoked_at: '2026-10-03T11:00:00Z', revoked_reason: 'ops' })) + expect(await isDuplicateBundleSubscription('grant-1', 'sub_9', 'co_9')).toBe(true) + expect(errorLog.mock.calls.map(call => String(call[0])).some(line => line.includes('ALARM payment after revoke'))).toBe(true) + }) + }) + describe('money guards on redeem', () => { const alarms = () => errorLog.mock.calls.map(call => String(call[0])).filter(line => line.includes('ALARM')) diff --git a/tests/unit/migrate-grant-routes.test.ts b/tests/unit/migrate-grant-routes.test.ts index eecd90fc..9724279b 100644 --- a/tests/unit/migrate-grant-routes.test.ts +++ b/tests/unit/migrate-grant-routes.test.ts @@ -209,6 +209,12 @@ describe('Migrate grant routes', () => { expect(result).toEqual({ url: 'https://checkout.polar.sh/c/test' }) }) + it('opens no checkout for a withdrawn grant', async () => { + db.getMigrateGrantForUser!.mockResolvedValue({ ...grantRow, workspace_id: 'ws-1', bound_at: '2026-09-23T12:00:00Z', revoked_at: '2026-10-03T11:00:00Z', revoked_reason: 'ops' }) + await expect((await checkoutRoute())({} as never)).rejects.toMatchObject({ statusCode: 409, message: 'migrate.grant_revoked' }) + expect(createCheckoutSession).not.toHaveBeenCalled() + }) + it('opens no checkout once the grant has been used', async () => { db.getMigrateGrantForUser!.mockResolvedValue({ ...grantRow, workspace_id: 'ws-1', bound_at: '2026-09-23T12:00:00Z', redeemed_at: '2026-09-23T12:05:00Z' }) await expect((await checkoutRoute())({} as never)).rejects.toMatchObject({ statusCode: 409, message: 'migrate.grant_used' }) diff --git a/tests/unit/migrate-grant-status-routes.test.ts b/tests/unit/migrate-grant-status-routes.test.ts index b85b26aa..6257dd41 100644 --- a/tests/unit/migrate-grant-status-routes.test.ts +++ b/tests/unit/migrate-grant-status-routes.test.ts @@ -86,6 +86,15 @@ describe('Migrate grant status and install-url routes', () => { expect(await call('status')).toEqual({ state, installed: false }) }) + it('reads a withdrawn grant as revoked and keeps the installed fact', async () => { + db.getMigrateGrantByOrderId.mockResolvedValue(grant({ revoked_at: '2026-10-03T11:00:00Z', revoked_reason: 'ops' })) + db.getWorkspaceById.mockResolvedValue({ id: 'ws-1', github_installation_id: 4242 }) + await status() + expect(await call('status')).toEqual({ state: 'revoked', installed: true }) + await status() + await expect(call('install-url')).rejects.toMatchObject({ statusCode: 409, message: 'migrate.grant_not_ready' }) + }) + it('is a 404 for an order Studio holds no grant for', async () => { db.getMigrateGrantByOrderId.mockResolvedValue(null) await status() diff --git a/tests/unit/migrate-provision.test.ts b/tests/unit/migrate-provision.test.ts index a224ad36..62d49f17 100644 --- a/tests/unit/migrate-provision.test.ts +++ b/tests/unit/migrate-provision.test.ts @@ -168,6 +168,8 @@ describe('provisionMigrateBundle', () => { expect(await refused()).toEqual({ status: 409, key: 'migrate.claim_taken' }) db.claimMigrateGrant.mockResolvedValue({ grant: bundleRow({ redeemed_at: '2026-10-09T00:00:00Z' }), created: false }) expect(await refused()).toEqual({ status: 409, key: 'migrate.grant_used' }) + db.claimMigrateGrant.mockResolvedValue({ grant: bundleRow({ revoked_at: '2026-10-09T00:00:00Z', revoked_reason: 'refund_before_delivery' }), created: false }) + expect(await refused()).toEqual({ status: 409, key: 'migrate.grant_revoked' }) db.claimMigrateGrant.mockResolvedValue({ grant: bundleRow(), created: false }) db.bindMigrateGrantWorkspace.mockResolvedValue(null) expect(await refused()).toEqual({ status: 409, key: 'migrate.grant_bound_elsewhere' }) diff --git a/tests/unit/migrate-revoke-route.test.ts b/tests/unit/migrate-revoke-route.test.ts new file mode 100644 index 00000000..f354eca9 --- /dev/null +++ b/tests/unit/migrate-revoke-route.test.ts @@ -0,0 +1,146 @@ +import { exportSPKI, generateKeyPair, SignJWT } from 'jose' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { MIGRATE_STUDIO_CLAIM_AUDIENCE, MIGRATE_STUDIO_CLAIM_ISSUER } from '@contentrain/types' + +vi.mock('../../server/utils/deployment', () => ({ resolveDeployment: () => ({ planSource: 'subscription' }) })) + +let privateKey: CryptoKey +let publicPem: string +const nowSec = () => Math.floor(Date.now() / 1000) +let counter = 0 + +const sign = (body: Record = {}, key = privateKey) => { + const iat = nowSec() + return new SignJWT({ + iss: MIGRATE_STUDIO_CLAIM_ISSUER, aud: MIGRATE_STUDIO_CLAIM_AUDIENCE, jti: `jti-${++counter}`, iat, exp: iat + 300, order_id: 'ord_123', reason: 'refund_before_delivery', ...body, + }).setProtectedHeader({ alg: 'EdDSA' }).sign(key) +} + +const grant = (over: Record = {}) => ({ + id: 'grant-1', order_id: 'ord_123', kind: 'bundle', user_id: 'user-1', workspace_id: 'ws-1', + bound_at: '2026-10-03T10:00:00Z', redeemed_at: '2026-10-03T10:05:00Z', redeemed_subscription_id: 'sub_bound', + revoked_at: null, revoked_reason: null, ...over, +}) + +beforeAll(async () => { + const pair = await generateKeyPair('EdDSA', { extractable: true }) + privateKey = pair.privateKey + publicPem = await exportSPKI(pair.publicKey) +}) + +describe('POST /api/migrate/grants/revoke', () => { + let db: Record> + let payment: { cancelSubscription: ReturnType } | null + let body: unknown + const taken = new Set() + const config = { migrate: { claimPublicKey: '' } } + + const call = async () => ((await import('../../server/api/migrate/grants/revoke.post')).default as (e: unknown) => Promise)({}) + const request = async (over?: Record) => { + body = { token: await sign(over) } + } + + beforeEach(() => { + vi.resetModules() + taken.clear() + config.migrate.claimPublicKey = publicPem + payment = { cancelSubscription: vi.fn().mockResolvedValue(undefined) } + db = { + getMigrateGrantByOrderId: vi.fn().mockResolvedValue(grant()), + getWorkspaceById: vi.fn().mockResolvedValue({ id: 'ws-1', github_installation_id: null }), + markMigrateGrantRevoked: vi.fn().mockResolvedValue(null), + releaseMigrateS2sJti: vi.fn().mockResolvedValue(undefined), + claimMigrateS2sJti: vi.fn(async (jti: string, purpose: string) => { + if (taken.has(`${purpose}:${jti}`)) return false + taken.add(`${purpose}:${jti}`) + return true + }), + } + vi.stubGlobal('defineEventHandler', (h: unknown) => h) + vi.stubGlobal('readBody', () => Promise.resolve(body)) + vi.stubGlobal('useRuntimeConfig', () => config) + vi.stubGlobal('useDatabaseProvider', () => db) + vi.stubGlobal('usePaymentProvider', () => payment) + vi.stubGlobal('errorMessage', (key: string) => key) + }) + + it('cancels the subscription the grant is bound to, marks the grant, and keeps the installed fact', async () => { + db.getWorkspaceById.mockResolvedValue({ id: 'ws-1', github_installation_id: 4242 }) + await request() + expect(await call()).toEqual({ state: 'revoked', installed: true, subscription_canceled: true }) + expect(payment!.cancelSubscription).toHaveBeenCalledOnce() + expect(payment!.cancelSubscription).toHaveBeenCalledWith('sub_bound') + expect(db.markMigrateGrantRevoked).toHaveBeenCalledWith('grant-1', 'refund_before_delivery') + }) + + it('records the reason Migrate gave', async () => { + await request({ reason: 'delivery_failed' }) + await call() + expect(db.markMigrateGrantRevoked).toHaveBeenCalledWith('grant-1', 'delivery_failed') + }) + + it('never reads a payment id from the request: a refunded duplicate payment cannot revoke or cancel anything else', async () => { + // A second checkout's subscription never became the grant's (isDuplicateBundleSubscription); + // an id smuggled into the request is ignored, only the bound subscription is cancelled. + await request({ subscription_id: 'sub_duplicate', payment_id: 'pay_duplicate' }) + await call() + expect(payment!.cancelSubscription).toHaveBeenCalledTimes(1) + expect(payment!.cancelSubscription).toHaveBeenCalledWith('sub_bound') + }) + + it('revokes a grant that never ran a subscription without calling Polar', async () => { + db.getMigrateGrantByOrderId.mockResolvedValue(grant({ redeemed_at: null, redeemed_subscription_id: null })) + await request() + expect(await call()).toEqual({ state: 'revoked', installed: false, subscription_canceled: false }) + expect(payment!.cancelSubscription).not.toHaveBeenCalled() + expect(db.markMigrateGrantRevoked).toHaveBeenCalledOnce() + }) + + it('is idempotent: a repeat on a revoked grant cancels nothing and answers revoked', async () => { + db.getMigrateGrantByOrderId.mockResolvedValue(grant({ revoked_at: '2026-10-03T11:00:00Z', revoked_reason: 'ops' })) + await request() + expect(await call()).toEqual({ state: 'revoked', installed: false, subscription_canceled: false }) + expect(payment!.cancelSubscription).not.toHaveBeenCalled() + expect(db.markMigrateGrantRevoked).not.toHaveBeenCalled() + }) + + it('leaves the grant live and gives the token back when Polar fails, so Migrate can retry', async () => { + payment!.cancelSubscription.mockRejectedValue(new Error('polar down')) + vi.spyOn(console, 'error').mockImplementation(() => {}) + await request() + await expect(call()).rejects.toMatchObject({ statusCode: 502, message: 'billing.provider_unavailable' }) + expect(db.markMigrateGrantRevoked).not.toHaveBeenCalled() + expect(db.releaseMigrateS2sJti).toHaveBeenCalledOnce() + }) + + it('does not give the token back for a 404', async () => { + db.getMigrateGrantByOrderId.mockResolvedValue(null) + await request() + await expect(call()).rejects.toMatchObject({ statusCode: 404, message: 'migrate.grant_not_found' }) + expect(db.releaseMigrateS2sJti).not.toHaveBeenCalled() + }) + + it('refuses an unsigned, foreign-signed, replayed or reasonless request, and is off without Migrate\'s key', async () => { + body = { token: 'x.y.z' } + await expect(call()).rejects.toMatchObject({ statusCode: 400 }) + body = { token: await sign({}, (await generateKeyPair('EdDSA')).privateKey) } + await expect(call()).rejects.toMatchObject({ statusCode: 400 }) + await request({ reason: 'because' }) + await expect(call()).rejects.toMatchObject({ statusCode: 400 }) + + await request() + await call() + await expect(call()).rejects.toMatchObject({ statusCode: 409 }) + + config.migrate.claimPublicKey = '' + await request() + await expect(call()).rejects.toMatchObject({ statusCode: 404 }) + expect(payment!.cancelSubscription).toHaveBeenCalledTimes(1) + }) + + it('spends the token under its own purpose', async () => { + await request() + await call() + expect([...taken]).toEqual([expect.stringMatching(/^revoke:/)]) + }) +})