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:/)]) + }) +})