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
1 change: 1 addition & 0 deletions .contentrain/content/system/error-messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
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.45.0",
"@contentrain/types": "1.46.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.

2 changes: 2 additions & 0 deletions server/api/migrate/grants/[grantId]/checkout.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 29 additions & 0 deletions server/api/migrate/grants/revoke.post.ts
Original file line number Diff line number Diff line change
@@ -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
}
})
4 changes: 2 additions & 2 deletions server/api/migrate/grants/status.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') })
Expand Down
6 changes: 6 additions & 0 deletions server/providers/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1160,6 +1160,12 @@ export interface DatabaseProvider {
*/
markMigrateGrantRedeemed: (grantId: string, subscriptionId: string | null) => Promise<void>

/**
* 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<DatabaseRow | null>

/**
* The signed origin of the grant behind a workspace's project: the newest
* grant bound to `workspaceId` for `repoFullName` (owner/name, any case)
Expand Down
17 changes: 17 additions & 0 deletions server/providers/postgres-db/migrate-grants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type MigrateGrantMethods = Pick<
| 'getMigrateGrantForUser'
| 'bindMigrateGrantWorkspace'
| 'markMigrateGrantRedeemed'
| 'markMigrateGrantRevoked'
| 'getMigrateGrantOrigin'
| 'claimMigrateS2sJti'
| 'releaseMigrateS2sJti'
Expand Down Expand Up @@ -256,6 +257,22 @@ export function migrateGrantMethods(): MigrateGrantMethods {
}
},

async markMigrateGrantRevoked(grantId, reason) {
try {
await getAdmin()
.updateTable('migrate_grants')
.set(eb => ({ revoked_at: eb.fn<string>('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
Expand Down
2 changes: 2 additions & 0 deletions server/providers/postgres-db/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>
}

Expand Down
13 changes: 13 additions & 0 deletions server/providers/supabase-db/migrate-grants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type MigrateGrantMethods = Pick<
| 'getMigrateGrantForUser'
| 'bindMigrateGrantWorkspace'
| 'markMigrateGrantRedeemed'
| 'markMigrateGrantRevoked'
| 'getMigrateGrantOrigin'
| 'claimMigrateS2sJti'
| 'releaseMigrateS2sJti'
Expand Down Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion server/utils/migrate-bundle-subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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`)
Expand Down
6 changes: 3 additions & 3 deletions server/utils/migrate-grant-status.ts
Original file line number Diff line number Diff line change
@@ -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'
}

Expand Down
2 changes: 2 additions & 0 deletions server/utils/migrate-provision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
49 changes: 49 additions & 0 deletions server/utils/migrate-revoke.ts
Original file line number Diff line number Diff line change
@@ -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<MigrateRevokeResponse> {
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
}
17 changes: 17 additions & 0 deletions supabase/migrations/045_migrate_grant_revoked.sql
Original file line number Diff line number Diff line change
@@ -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'))
);
12 changes: 12 additions & 0 deletions tests/contract/migrate-grants.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
9 changes: 9 additions & 0 deletions tests/unit/migrate-bundle-subscription.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'))

Expand Down
6 changes: 6 additions & 0 deletions tests/unit/migrate-grant-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand Down
9 changes: 9 additions & 0 deletions tests/unit/migrate-grant-status-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions tests/unit/migrate-provision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand Down
Loading
Loading