diff --git a/.env.example b/.env.example index e1095fbe..9c3868dd 100644 --- a/.env.example +++ b/.env.example @@ -186,10 +186,17 @@ POSTHOG_API_HOST= POSTHOG_PERSONAL_API_KEY= POSTHOG_PROJECT_ID= -# --- Subscription restoration --- -# Claims of deleted accounts' Apple subscriptions. +# --- Subscription claim (reclaim) launch flags --- +# Tombstone restoration tier (claims of deleted accounts' subscriptions). SUBSCRIPTION_CLAIM_TOMBSTONE_ENABLED=true -# Live ownership transfers and Google claim proof are deferred to a follow-up. +# Live bearer-transfer tier. OFF until security sign-off. +SUBSCRIPTION_CLAIM_LIVE_TRANSFER_ENABLED=false +# Google claim/restoration surface. OFF (Apple-only product today); verify / +# RTDN ingest and the Google money accounting stay on regardless. +SUBSCRIPTION_CLAIM_GOOGLE_ENABLED=false +# Contest window (hours, whole numbers only) for live-tier claims. 0 = +# instant transfer, which requires explicit security acceptance. +CLAIM_CONTEST_WINDOW_HOURS=72 # --- Payments / Credits --- # REQUIRED — All five PAYMENTS_* knobs below are hard-required. Backend diff --git a/prisma/migrations/20260722120000_add_deletion_final_balance/migration.sql b/prisma/migrations/20260722120000_add_deletion_final_balance/migration.sql new file mode 100644 index 00000000..abe18483 --- /dev/null +++ b/prisma/migrations/20260722120000_add_deletion_final_balance/migration.sql @@ -0,0 +1,3 @@ +-- Display-only snapshot of the wallet balance at deletion time (pre-escrow). +-- Nullable: records written before this field existed stay null. +ALTER TABLE "DeletionRecord" ADD COLUMN "finalBalanceCredits" BIGINT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7dfe8b72..b31f545a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -345,6 +345,12 @@ model DeletionRecord { /// Plain string column, validated in app code (see the AuthMethod.type /// comment for why Postgres enums are avoided). status String @default("purging") + /// Display-only snapshot of the wallet balance at deletion time, read + /// inside the teardown transaction BEFORE any escrow debit or wallet + /// teardown (what the user had). Null on records written before the + /// field existed; 0 when the account never had a wallet row + /// (getBalance semantics). + finalBalanceCredits BigInt? requestedAt DateTime @default(now()) completedAt DateTime? /// When the retention job may delete this record (set once the drain diff --git a/src/accounts/auth-activity.ts b/src/accounts/auth-activity.ts new file mode 100644 index 00000000..52722d4a --- /dev/null +++ b/src/accounts/auth-activity.ts @@ -0,0 +1,56 @@ +import { prisma } from "@/utils/prisma"; + +/** + * Record "any authenticated act" on the account. The live-transfer contest + * window uses lastAuthAt strictly as a veto - an old owner who touches any + * authenticated route during the window cancels the pending transfer - so + * the stamp must be reliable exactly when it matters: + * + * - The write is awaited before the request proceeds (a fire-and-forget + * stamp could land after settlement locked and read the row). + * - The timestamp is database now(), the same clock that stamps the pending + * row's createdAt, so app/DB clock skew can never make a later act + * compare as older. + * - Throttling applies only while the account has no pending outgoing + * transfer. With one pending, every authenticated act is stamped + * unconditionally - a suppressed write inside the throttle window would + * otherwise leave lastAuthAt before the pending row and the transfer + * would settle despite real victim activity. + * - Failures propagate (fail closed): a failed stamp must never silently + * cost a veto. Callers fail the request with a 5xx so the client retries; + * the alternative - swallowing the error and proceeding - lets a + * transient DB blip during a contest window hand the subscription to the + * claimant despite real owner activity. An UPDATE matching zero rows + * (account deleted mid-request) is not a failure: there is no veto left + * to preserve. + */ +const STAMP_INTERVAL_MS = 5 * 60 * 1000; + +let stampFailureForTests: Error | null = null; + +/** Test seam: make stamp writes fail with the given error (null clears). */ +export const __setAuthActivityStampFailureForTests = ( + err: Error | null, +): void => { + stampFailureForTests = err; +}; + +export const stampAuthActivity = async ( + accountId: string, + knownLastAuthAt: Date | null, +): Promise => { + const withinThrottle = + knownLastAuthAt !== null && + Date.now() - knownLastAuthAt.getTime() < STAMP_INTERVAL_MS; + if (withinThrottle) { + const pending = await prisma.subscriptionTransfer.findFirst({ + where: { status: "pending", fromAccountId: accountId }, + select: { id: true }, + }); + if (!pending) return; + } + if (stampFailureForTests) throw stampFailureForTests; + await prisma.$executeRaw` + UPDATE "Account" SET "lastAuthAt" = now() WHERE id = ${accountId}::uuid + `; +}; diff --git a/src/accounts/deletion/executors.ts b/src/accounts/deletion/executors.ts index c31267e7..b2cdd2a0 100644 --- a/src/accounts/deletion/executors.ts +++ b/src/accounts/deletion/executors.ts @@ -1,4 +1,5 @@ import { DeleteObjectCommand, S3Client } from "@aws-sdk/client-s3"; +import { Code, ConnectError } from "@connectrpc/connect"; import { z } from "zod"; import type { DeletionTaskKind } from "@/accounts/deletion/service"; import { createComposioService } from "@/api/v2/connections/composio.service"; @@ -138,6 +139,19 @@ export const __setDeletionNotificationClientForTests = ( }; const POSTHOG_FETCH_TIMEOUT_MS = 10_000; +/** + * NotFound and Unimplemented from the notification server both mean the + * installation is already gone: NotFound is the explicit answer, and the + * Connect protocol maps a bare HTTP 404 (route no longer served) to + * `unimplemented`. Either way there is nothing left to purge, so the task + * must complete instead of retrying forever. Genuinely transient failures + * (unavailable, 5xx, timeouts, connection refused) carry other codes and + * keep their retry semantics. + */ +const isInstallationAlreadyAbsent = (err: unknown): err is ConnectError => + err instanceof ConnectError && + (err.code === Code.NotFound || err.code === Code.Unimplemented); + /** Remove one notification-server installation (per ClientIdentifier). */ const executeNotificationInstallation: DeletionExecutor = async (payload) => { const parsed = installationPayloadSchema.parse(payload); @@ -147,11 +161,20 @@ const executeNotificationInstallation: DeletionExecutor = async (payload) => { const result = await withInstallationMutationFence({ installationId: parsed.installationId, expectation: { state: "absent" }, - mutate: () => - notificationClient.deleteInstallation( - { installationId: parsed.installationId }, - notificationMutationCallOptions(), - ), + mutate: async () => { + try { + await notificationClient.deleteInstallation( + { installationId: parsed.installationId }, + notificationMutationCallOptions(), + ); + } catch (err) { + if (!isInstallationAlreadyAbsent(err)) throw err; + logger.info( + { installationId: parsed.installationId, code: Code[err.code] }, + "deletion.notification_installation.already_absent", + ); + } + }, }); if (!result.applied) { logger.info( diff --git a/src/accounts/deletion/outbox.ts b/src/accounts/deletion/outbox.ts index 57434b01..7a329878 100644 --- a/src/accounts/deletion/outbox.ts +++ b/src/accounts/deletion/outbox.ts @@ -1,5 +1,6 @@ import { getDeletionExecutor } from "@/accounts/deletion/executors"; import { PURGE_WINDOW_HOURS } from "@/accounts/deletion/service"; +import { settlePendingTransfers } from "@/subscriptions/claim"; import { runReclaimReconciliationSweep } from "@/subscriptions/reconciliation"; import logger from "@/utils/logger"; import { prisma } from "@/utils/prisma"; @@ -346,7 +347,13 @@ export const runDeletionOutboxSweep = async (): Promise => { logger.error({ err }, "deletion.outbox.expiry_pass_failed"); } try { - // Reclaim reconciliation (quarantine drain + lineage drift) + // Live-tier claim contest windows settle on the same tick. + await settlePendingTransfers(); + } catch (err) { + logger.error({ err }, "deletion.outbox.pending_transfer_pass_failed"); + } + try { + // Reclaim reconciliation (quarantine drain + post-transfer drift) // rides the same tick, self-throttled: it makes provider calls, so it // runs at most once per interval rather than every minute. if ( diff --git a/src/accounts/deletion/service.ts b/src/accounts/deletion/service.ts index 83a230eb..17efebe1 100644 --- a/src/accounts/deletion/service.ts +++ b/src/accounts/deletion/service.ts @@ -227,6 +227,17 @@ const runDeleteAccountTransaction = async (args: { select: { inputs: true }, }); + // Display-only balance snapshot for the DeletionRecord: what the wallet + // held BEFORE the escrow debits and the wallet teardown below. A plain + // read (no ledger mutation) — stable because the Account FOR UPDATE + // lock above fences every concurrent ledger writer. Missing wallet row + // reads as 0, matching getBalance semantics. + const walletRow = await tx.userCredits.findUnique({ + where: { accountId }, + select: { balance: true }, + }); + const finalBalanceCredits = walletRow?.balance ?? 0n; + // Money bookkeeping before the wallet goes: escrow the conservative // remainder of each held custody period (the tombstone snapshot, // released to a future claimant), journal the move, and flip the @@ -331,7 +342,12 @@ const runDeleteAccountTransaction = async (args: { const record = await tx.deletionRecord.upsert({ where: { operationId }, update: {}, - create: { operationId, accountRef, status: "purging" }, + create: { + operationId, + accountRef, + status: "purging", + finalBalanceCredits, + }, }); const tasks: Prisma.DeletionTaskCreateManyInput[] = []; diff --git a/src/api/v2/accounts/handlers/subscription-claim.ts b/src/api/v2/accounts/handlers/subscription-claim.ts index 8e351879..c7b0dff1 100644 --- a/src/api/v2/accounts/handlers/subscription-claim.ts +++ b/src/api/v2/accounts/handlers/subscription-claim.ts @@ -6,33 +6,54 @@ import { BillingProvider, SubscriptionStatus } from "@prisma/client"; import type { NextFunction, Request, Response } from "express"; import { z } from "zod"; import { AccountNotLiveError } from "@/accounts/require-live-account"; +import { createApnsService } from "@/api/v2/notifications/apns-push.service"; +import { createFcmService } from "@/api/v2/notifications/fcm-push.service"; +import type { SubscriptionClaimPendingPayload } from "@/api/v2/notifications/types"; import { APPCHECK_HEADER } from "@/middleware/auth"; import { getSubscriptionStatuses } from "@/subscriptions/apple-server-api"; import { executeClaim, type ClaimSubscriptionSeed, } from "@/subscriptions/claim"; +import { isGoogleClaimEnabled } from "@/subscriptions/claim-flags"; +import { + fetchSubscriptionPurchaseV2, + type SubscriptionPurchaseV2, +} from "@/subscriptions/google-play/play-api"; +import { + deriveStatusFromPurchase, + extractObfuscatedAccountId, + extractPeriodWindow, + extractProductId, +} from "@/subscriptions/google-play/status"; import { verifyAndDecodeRenewalInfo, verifyAndDecodeTransaction, } from "@/subscriptions/jws-verifier"; import { LineageUnresolvedError, + quarantineLineageToken, resolveOrCreateAppleLineage, + resolveOrCreateGoogleLineage, } from "@/subscriptions/lineage"; import { productMapping } from "@/subscriptions/product-mapping"; import { serializeUserSubscription } from "@/subscriptions/repository"; import { deriveSubscriptionStatusFromTransaction } from "@/subscriptions/status"; import { getFirebaseApp } from "@/utils/firebase"; +import logger from "@/utils/logger"; +import { prisma } from "@/utils/prisma"; import { getRuntimeConfig } from "@/utils/runtimeConfig"; /** * POST /v2/accounts/me/subscription/claim. * - * Explicit Apple tombstone restoration. The presented artifact must verify, - * Apple must report the subscription entitled now, and the artifact must be - * the latest transaction. App Check attestation is mandatory, consumed, and - * fail closed. + * Explicit one-time ownership claim: tombstone restoration (deleted owner) + * or live bearer-transfer (flagged; contest window). Proof requirements are + * authoritative: the presented artifact must verify, the provider must say + * the subscription is entitled NOW, and the artifact must be the + * subscription's latest transaction. App Check attestation (limited-use + * token, consumed on verification) is mandatory and fails closed — there is + * no app_attest_enabled bypass on this route. */ // Strict discriminated union — no legacy platform-defaulting preprocess on @@ -50,8 +71,6 @@ const playClaimSchema = z productId: z.string().min(1), }) .strict(); -// Keep the shipped request shape accepted. Google claims fail closed below -// before any provider call. const claimBodySchema = z.discriminatedUnion("platform", [ appleClaimSchema, playClaimSchema, @@ -296,6 +315,188 @@ const verifyAppleProof = async ( }; }; +const verifyPlayProof = async ( + req: Request, + body: z.infer, +): Promise => { + let purchase: SubscriptionPurchaseV2; + try { + purchase = await fetchSubscriptionPurchaseV2(body.purchaseToken); + } catch (error) { + // Unknown/dead token. + req.log.warn({ error }, "subscription.claim.play_fetch_failed"); + return { status: 400 }; + } + const fetchedProductId = extractProductId(purchase); + if (fetchedProductId !== body.productId) return { status: 400 }; + const status = deriveStatusFromPurchase(purchase); + const entitled = + status === SubscriptionStatus.active || + status === SubscriptionStatus.grace || + status === SubscriptionStatus.trial; + if (!entitled) return { status: 409, reason: "not_entitled" }; + if (!purchase.latestOrderId) { + // No funding-event identity: fail closed, same rule as verify/RTDN — + // park for reconciliation and reject retryably. A keyless claim would + // otherwise reach restoration with no exact escrow key. + await quarantineLineageToken( + BillingProvider.googlePlay, + body.purchaseToken, + "missing_latest_order_id", + { source: "claim" }, + ); + req.log.error({}, "subscription.claim.play_missing_order_id_parked"); + return { status: 409, reason: "lineage_unresolved" }; + } + const playOrderId = purchase.latestOrderId; + + let mapping: ReturnType; + try { + mapping = productMapping(fetchedProductId); + } catch (error) { + req.log.warn( + { error, productId: fetchedProductId }, + "subscription.claim.unrecognized_product", + ); + return { status: 400 }; + } + const { tier, period } = mapping; + const window = extractPeriodWindow(purchase); + let lineageId: string; + try { + lineageId = await resolveOrCreateGoogleLineage({ + token: body.purchaseToken, + linkedPurchaseToken: purchase.linkedPurchaseToken, + fetchChain: true, + }); + } catch (error) { + if (error instanceof LineageUnresolvedError) { + return { status: 409, reason: "lineage_unresolved" }; + } + throw error; + } + return { + lineageId, + currentPeriodStart: window.currentPeriodStart, + providerPeriodKey: `play_order_${playOrderId}`, + seed: { + provider: BillingProvider.googlePlay, + productId: fetchedProductId, + tier, + period, + status, + purchaseToken: body.purchaseToken, + linkedPurchaseToken: purchase.linkedPurchaseToken ?? null, + obfuscatedAccountId: extractObfuscatedAccountId(purchase), + startedAt: purchase.startTime + ? new Date(purchase.startTime) + : window.currentPeriodStart, + currentPeriodStart: window.currentPeriodStart, + currentPeriodEnd: window.currentPeriodEnd, + willRenew: + purchase.lineItems?.[0]?.autoRenewingPlan?.autoRenewEnabled !== false, + isInTrial: status === SubscriptionStatus.trial, + }, + proofMetadata: { + purchaseToken: body.purchaseToken, + orderId: playOrderId, + }, + }; +}; + +// --------------------------------------------------------------------------- +// Pending-transfer push notification (contest window) +// --------------------------------------------------------------------------- + +type PendingTransferNotifier = (args: { + oldAccountId: string; + contestEndsAt: Date; + provider: "apple" | "googlePlay"; +}) => Promise; + +/** + * Send the contract's SubscriptionClaimPending push to every registered + * device of the old account — the one notification channel we have, and the + * structural bound on the bearer-theft residual: the legitimate owner learns + * a transfer is pending while any authenticated act still vetoes it. Each + * device send is individually caught; a push failure never fails the claim. + */ +const defaultPendingTransferNotifier: PendingTransferNotifier = async ({ + oldAccountId, + contestEndsAt, + provider, +}) => { + const devices = await prisma.deviceRegistration.findMany({ + where: { + accountId: oldAccountId, + disabled: false, + pushToken: { not: null }, + }, + select: { + deviceId: true, + pushToken: true, + pushTokenType: true, + apnsEnv: true, + }, + }); + logger.warn( + { deviceCount: devices.length, contestEndsAt: contestEndsAt.toISOString() }, + "subscription.claim.pending_transfer_push", + ); + if (devices.length === 0) return; + + const apns = createApnsService(); + const fcm = createFcmService(); + await Promise.all( + devices.map(async (device) => { + const payload: SubscriptionClaimPendingPayload = { + clientId: device.deviceId, + notificationType: "SubscriptionClaimPending", + notificationData: { + contestEndsAt: contestEndsAt.toISOString(), + provider, + }, + }; + const adapted = { ...device, id: device.deviceId }; + try { + const service = device.pushTokenType === "apns" ? apns : fcm; + if (!service) { + logger.warn( + { deviceId: device.deviceId, pushTokenType: device.pushTokenType }, + "subscription.claim.pending_push_service_unavailable", + ); + return; + } + const result = await service.sendPushNotification({ + device: adapted, + notification: payload, + isSilent: false, + }); + if (!result.success) { + logger.warn( + { deviceId: device.deviceId, error: result.error }, + "subscription.claim.pending_push_send_failed", + ); + } + } catch (err) { + logger.warn( + { err, deviceId: device.deviceId }, + "subscription.claim.pending_push_send_error", + ); + } + }), + ); +}; + +let pendingTransferNotifier: PendingTransferNotifier | null = null; + +/** Test seam: inject a notifier; null restores the default. */ +export const __setPendingTransferNotifierForTests = ( + notifier: PendingTransferNotifier | null, +): void => { + pendingTransferNotifier = notifier; +}; + // --------------------------------------------------------------------------- // Handler // --------------------------------------------------------------------------- @@ -315,7 +516,12 @@ export async function subscriptionClaimHandler(req: Request, res: Response) { return; } - if (parsed.data.platform === "googlePlay") { + // Provider scope: the product is Apple-only today, so Google claims and + // restorations ship disabled behind their own flag. Rejected before any + // provider call, with contract not-claimable semantics. Verify/RTDN ingest + // and the Google money accounting stay fully on — only the claim surface + // is gated. + if (parsed.data.platform === "googlePlay" && !isGoogleClaimEnabled()) { req.log.warn({}, "subscription.claim.google_provider_disabled"); res.status(409).json({ error: "Subscription cannot be claimed", @@ -326,7 +532,10 @@ export async function subscriptionClaimHandler(req: Request, res: Response) { } try { - const proof = await verifyAppleProof(req, parsed.data.jwsRepresentation); + const proof = + parsed.data.platform === "apple" + ? await verifyAppleProof(req, parsed.data.jwsRepresentation) + : await verifyPlayProof(req, parsed.data); if ("status" in proof) { req.log.warn( { platform: parsed.data.platform, rejection: proof }, @@ -347,6 +556,7 @@ export async function subscriptionClaimHandler(req: Request, res: Response) { switch (result.kind) { case "restored": + case "transferred": case "replayed": { req.log.info( { kind: result.kind, lineageId: proof.lineageId }, @@ -357,6 +567,24 @@ export async function subscriptionClaimHandler(req: Request, res: Response) { }); return; } + case "pending": { + const notifier = + pendingTransferNotifier ?? defaultPendingTransferNotifier; + try { + await notifier({ + oldAccountId: result.oldAccountId, + contestEndsAt: result.contestEndsAt, + provider: parsed.data.platform, + }); + } catch (error) { + req.log.warn({ error }, "subscription.claim.pending_push_failed"); + } + res.status(202).json({ + status: "pending", + contestEndsAt: result.contestEndsAt.toISOString(), + }); + return; + } case "rejected": { req.log.warn( { reason: result.reason, lineageId: proof.lineageId }, diff --git a/src/api/v2/auth/handlers/generate-token.ts b/src/api/v2/auth/handlers/generate-token.ts index f7fdd935..b45f8664 100644 --- a/src/api/v2/auth/handlers/generate-token.ts +++ b/src/api/v2/auth/handlers/generate-token.ts @@ -1,5 +1,6 @@ import type { Request, Response } from "express"; import { z } from "zod"; +import { stampAuthActivity } from "@/accounts/auth-activity"; import { isIdentityBarred } from "@/accounts/deletion/barrier"; import { IdentityBarredError, @@ -171,6 +172,22 @@ export async function generateToken( } accountId = upserted.accountId; + // Activity stamp: lastAuthAt records the most recent authenticated mint + // for this account (consumed by activity-recency checks such as the + // subscription-claim dead-or-silent gate, and by the contest-window + // veto). Fail closed: a mint that cannot durably stamp fails with a 5xx + // so the client retries - proceeding unstamped could silently cost the + // owner their veto on a pending transfer. The raw UPDATE no-ops (zero + // rows) when the row vanished (deletion racing this mint) - that is not + // a failure, there is no veto left to preserve. + try { + await stampAuthActivity(accountId, null); + } catch (err) { + req.log.error({ err, accountId }, "auth.account.last_auth_stamp_failed"); + res.status(500).json({ error: "Failed to generate token" }); + return; + } + // Best-effort backfill of DeviceRegistration.accountId. // // Runs in its own small transaction, SEPARATE from the upsert diff --git a/src/api/v2/notifications/types.ts b/src/api/v2/notifications/types.ts index 44fbf48c..cbca7b73 100644 --- a/src/api/v2/notifications/types.ts +++ b/src/api/v2/notifications/types.ts @@ -1,7 +1,8 @@ export type NotificationType = | "Protocol" | "InviteJoinRequest" - | "CreditsRefilled"; + | "CreditsRefilled" + | "SubscriptionClaimPending"; export type ProtocolNotificationData = { contentTopic: string; @@ -40,11 +41,20 @@ export type CreditsRefilledNotificationData = { nextRefreshAt: string; // ISO UTC, start of next UTC day }; +// Sent to the OLD owner's devices when a live-tier subscription claim opens +// its contest window: any authenticated act before contestEndsAt cancels the +// pending transfer. +export type SubscriptionClaimPendingNotificationData = { + contestEndsAt: string; // ISO UTC + provider: "apple" | "googlePlay"; +}; + // Mapping from NotificationType to its payload shape export type NotificationTypeToData = { Protocol: ProtocolNotificationData; InviteJoinRequest: InviteJoinRequestNotificationData; CreditsRefilled: CreditsRefilledNotificationData; + SubscriptionClaimPending: SubscriptionClaimPendingNotificationData; }; // Base notification payload with XOR semantics for v1/v2 transition @@ -86,8 +96,17 @@ export type CreditsRefilledPayload = { notificationData: CreditsRefilledNotificationData; }; +// Backend-originated push to the old owner's devices when a live-tier claim +// opens its contest window. Same JWT-less shape as CreditsRefilledPayload. +export type SubscriptionClaimPendingPayload = { + clientId: string; // deviceId, for v2-shaped routing + notificationType: "SubscriptionClaimPending"; + notificationData: SubscriptionClaimPendingNotificationData; +}; + // Union type for push services that can handle both v1 and v2 export type AnyNotificationPayloadWithJWT = | NotificationPayloadWithJWTToken | V2NotificationPayload - | CreditsRefilledPayload; + | CreditsRefilledPayload + | SubscriptionClaimPendingPayload; diff --git a/src/api/v2/subscriptions/handlers/google-play-rtdn.ts b/src/api/v2/subscriptions/handlers/google-play-rtdn.ts index c9bf5c10..354bcd28 100644 --- a/src/api/v2/subscriptions/handlers/google-play-rtdn.ts +++ b/src/api/v2/subscriptions/handlers/google-play-rtdn.ts @@ -128,8 +128,8 @@ export async function googlePlayRtdnHandler(req: Request, res: Response) { } // Voided purchase: compensate the exact voided order's custody holder - // (works whether the value sits with the current owner or in deletion - // escrow), then ack. A void with no orderId or + // (works whether the value sits with the original owner, a claim + // transferee, or in deletion escrow), then ack. A void with no orderId or // no provably matching custody is PARKED for the reconciliation sweep — // never resolved by revoking current entitlement. if (notification.voidedPurchaseNotification) { @@ -287,21 +287,22 @@ export async function googlePlayRtdnHandler(req: Request, res: Response) { notificationType: sub.notificationType, receiptRecorded: result.receiptRecorded, }, - "play.rtdn.subscription_not_applied — acking", + "play.rtdn.unknown_subscription — acking", ); res.status(200).json({ ok: true, applied: false }); return; } if (result.kind === "tombstoned") { - // Deleted-account lineage: acknowledged without creating - // account-linked state. + // The purchase token (or its rotation predecessor) belongs to a + // deleted account. Explicit, counted no-op: ack so Pub/Sub stops + // retrying; never recreate account-linked state. req.log.info( { messageId: message.messageId, notificationType: sub.notificationType, }, - "play.rtdn.subscription_not_applied — acking", + "play.rtdn.tombstoned_noop", ); res.status(200).json({ ok: true, applied: false }); return; diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index f7aee2dc..b0fe6f20 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -1,4 +1,5 @@ import type { NextFunction, Request, Response } from "express"; +import { stampAuthActivity } from "@/accounts/auth-activity"; import { accountIdSchema } from "@/utils/account-id"; import { ADMIN_ACCOUNT_ID } from "@/utils/constants"; import { AppError } from "@/utils/errors"; @@ -32,6 +33,11 @@ const isDeleteReplayCarveOut = (req: Request): boolean => { * channel). No positive caching: fail-closed means every check hits the * database. Returns false after writing the response when the request must * not proceed. + * + * Live requests also stamp lastAuthAt (awaited, fail-closed; throttled only + * while no outgoing transfer is pending): the claim contest window treats + * any authenticated act as a veto, so a request that cannot durably stamp + * fails with a 5xx rather than proceeding unstamped. */ type VerifiedJwtPayload = Awaited>; @@ -45,11 +51,11 @@ const enforceLiveAccountClaim = async ( res.status(401).json({ error: "Unauthorized" }); return false; } - let account: { id: string } | null; + let account: { id: string; lastAuthAt: Date | null } | null; try { account = await prisma.account.findUnique({ where: { id: payload.accountId }, - select: { id: true }, + select: { id: true, lastAuthAt: true }, }); } catch (error) { req.log.error({ error }, "auth.fence.account_lookup_failed"); @@ -61,6 +67,22 @@ const enforceLiveAccountClaim = async ( res.status(401).json({ error: "Unauthorized" }); return false; } + if (!isNotificationExtensionOnlyToken(payload)) { + // Awaited and fail-closed: the contest-window veto depends on this stamp + // being durable before the request proceeds (see stampAuthActivity). A + // stamp failure fails the request - proceeding unstamped could silently + // cost a legitimate owner their veto during a contest window. + try { + await stampAuthActivity(account.id, account.lastAuthAt); + } catch (err) { + req.log.error( + { err, deviceId: payload.deviceId }, + "auth.activity_stamp_failed", + ); + res.status(500).json({ error: "Internal server error" }); + return false; + } + } return true; }; diff --git a/src/payments/types.ts b/src/payments/types.ts index cab6e114..5c6dbd21 100644 --- a/src/payments/types.ts +++ b/src/payments/types.ts @@ -19,8 +19,8 @@ export const LedgerScopeSchema = z.enum([ "daily_refill", // Forfeit adjustment scope (negative subscription clawback). "sub_forfeit", - // Lineage custody moves (subscription restoration/escrow/refund - // compensation), keyed per journal row. + // Lineage custody moves (subscription claim/undo/escrow/refund + // compensation): conservative paired debits/credits keyed per journal row. "sub_transfer", ]); export type LedgerScope = z.infer; diff --git a/src/subscriptions/AGENTS.md b/src/subscriptions/AGENTS.md index f68c8d8a..0b45c8f4 100644 --- a/src/subscriptions/AGENTS.md +++ b/src/subscriptions/AGENTS.md @@ -14,7 +14,9 @@ lineage row is: - the **canonical first lock** for every money path (below); - the **tombstone carrier** — account deletion flips `state` to `tombstoned`; webhooks ack tombstoned lineages as counted no-ops, verify - returns 409 with `claimable: true`, and a claim restores the lineage. + returns 409 with `claimable: true`, and a claim restores the lineage; +- the **cooldown/freeze anchor** for claims (`lastTransferAt`, + `liveTransferFrozenAt`). `LineagePeriodGrant` is the global once-per-funding-event registry (one row per Apple transactionId / Google latestOrderId), and `LineagePeriodCustody` @@ -22,7 +24,7 @@ tracks who currently holds each funded period's remaining value. Custody — not account-scoped `sub_grant` rows — is the source of truth for the remainder after funding; every move debits by `D = min(lockedOwnerBalance, max(0, cap - ownerConsumesSince(custodyStartedAt)))` -and sets `cap := D`, so no chain of escrow/restoration/refund exceeds the +and sets `cap := D`, so no chain of transfer/undo/escrow/refund exceeds the allotment and commingled promo/admin credits never move. ## Global lock order (deadlock-free by construction) @@ -53,7 +55,9 @@ Rules: - Tombstone restoration: escrow release referencing the existing funding row — never a second grant. -- Live lineage and Google claim attempts fail closed; only Apple tombstone - restoration is supported. +- Live transfer: flagged (`SUBSCRIPTION_CLAIM_LIVE_TRANSFER_ENABLED`, off at + launch), 72h contest window by default, per-lineage 30-day cooldown, + one-shot CAS undo for the immediately previous owner (cooldown-exempt, + executes immediately, sets the post-undo freeze). - App Check limited-use attestation is mandatory on the claim route and fails closed — no `app_attest_enabled` bypass. diff --git a/src/subscriptions/claim-eligibility.ts b/src/subscriptions/claim-eligibility.ts index e15acbe0..7dcdf2b2 100644 --- a/src/subscriptions/claim-eligibility.ts +++ b/src/subscriptions/claim-eligibility.ts @@ -1,5 +1,10 @@ import type { BillingProvider } from "@prisma/client"; -import { isTombstoneClaimEnabled } from "@/subscriptions/claim-flags"; +import { + isGoogleClaimEnabled, + isLiveTransferEnabled, + isTombstoneClaimEnabled, + SUBSCRIPTION_CLAIM_COOLDOWN_DAYS, +} from "@/subscriptions/claim-flags"; import { LINEAGE_STATE_TOMBSTONED, resolveLineageId, @@ -9,21 +14,39 @@ import { prisma } from "@/utils/prisma"; /** * Informative `claimable` signal for the verify 409 (additive contract * field): true when POST /v2/accounts/me/subscription/claim may succeed for - * this caller. Only Apple tombstone restoration is claimable; live lineage - * ownership mismatches and Google lineages fail closed. + * this caller — the lineage is tombstoned (restoration tier), or live + * transfer is enabled and the caller is not cooldown/freeze-blocked. The + * claim endpoint always re-evaluates authoritatively; this never grants + * anything. */ export const evaluateClaimable = async (args: { provider: BillingProvider; /** Candidate provider keys (current + rotation predecessor when known). */ keys: Array; }): Promise => { - if (args.provider === "googlePlay" || !isTombstoneClaimEnabled()) + // Provider scope: Google claims ship disabled (Apple-only product today). + if (args.provider === "googlePlay" && !isGoogleClaimEnabled()) { return false; + } const lineageId = await resolveLineageId(prisma, args.provider, args.keys); if (!lineageId) return false; const lineage = await prisma.subscriptionLineage.findUnique({ where: { id: lineageId }, }); if (!lineage) return false; - return lineage.state === LINEAGE_STATE_TOMBSTONED; + if (lineage.state === LINEAGE_STATE_TOMBSTONED) { + // Mirror the claim endpoint's gate: with tombstone restoration disabled + // the claim would 409, so advertising claimable would direct clients + // into a doomed flow. + return isTombstoneClaimEnabled(); + } + if (!isLiveTransferEnabled()) return false; + if (lineage.liveTransferFrozenAt) return false; + if (lineage.lastTransferAt) { + const cooldownMs = SUBSCRIPTION_CLAIM_COOLDOWN_DAYS * 24 * 60 * 60 * 1000; + if (Date.now() - lineage.lastTransferAt.getTime() < cooldownMs) { + return false; + } + } + return true; }; diff --git a/src/subscriptions/claim-flags.ts b/src/subscriptions/claim-flags.ts index 8ca9d5e5..6d92e6da 100644 --- a/src/subscriptions/claim-flags.ts +++ b/src/subscriptions/claim-flags.ts @@ -1,4 +1,11 @@ -/** Subscription-restoration flag, read at call time for runtime control. */ +/** + * Subscription-claim launch flags and constants. Read at call time (not + * module load) so tests and ops can flip them without a restart. Launch + * posture: tombstone restoration ON, live transfer OFF until security + * sign-off; the contest window applies to live-tier claims whenever the + * live flag is enabled (setting it to 0 — instant transfer — requires + * explicit security acceptance). + */ const flag = (name: string, fallback: boolean): boolean => { const raw = process.env[name]?.trim().toLowerCase(); @@ -8,3 +15,35 @@ const flag = (name: string, fallback: boolean): boolean => { export const isTombstoneClaimEnabled = (): boolean => flag("SUBSCRIPTION_CLAIM_TOMBSTONE_ENABLED", true); + +export const isLiveTransferEnabled = (): boolean => + flag("SUBSCRIPTION_CLAIM_LIVE_TRANSFER_ENABLED", false); + +/** + * Provider scope for the claim surface. The product is Apple-only today + * (no Android app), so Google claims/restorations ship DISABLED behind + * their own flag: the endpoint rejects googlePlay bodies with contract + * not-claimable semantics before any provider call, and verify's + * `claimable` signal stays false for Google lineages. Verify/RTDN ingest + * and the Google money accounting (grants, custody, escrow, voids) remain + * fully on so the books stay correct whichever day the flag flips. + */ +export const isGoogleClaimEnabled = (): boolean => + flag("SUBSCRIPTION_CLAIM_GOOGLE_ENABLED", false); + +export const claimContestWindowHours = (): number => { + const raw = process.env.CLAIM_CONTEST_WINDOW_HOURS?.trim(); + if (!raw) return 72; + // Number (not parseInt): parseInt would truncate "0.5" to 0 and silently + // disable the contest window - the exact outcome the module doc says + // requires explicit security acceptance. Only whole non-negative hour + // counts are honored; anything else falls back to the 72h default. + const n = Number(raw); + return Number.isInteger(n) && n >= 0 ? n : 72; +}; + +/** Lineage cooldown between transfers; previous-owner undo is exempt. */ +export const SUBSCRIPTION_CLAIM_COOLDOWN_DAYS = 30; + +/** One-shot undo deadline after a transfer. */ +export const SUBSCRIPTION_CLAIM_UNDO_DEADLINE_DAYS = 30; diff --git a/src/subscriptions/claim.ts b/src/subscriptions/claim.ts index 2f96e714..3853c6e6 100644 --- a/src/subscriptions/claim.ts +++ b/src/subscriptions/claim.ts @@ -1,11 +1,21 @@ import { randomUUID } from "node:crypto"; import type { Prisma, Subscription } from "@prisma/client"; import { requireLiveAccount } from "@/accounts/require-live-account"; -import { isTombstoneClaimEnabled } from "@/subscriptions/claim-flags"; import { + claimContestWindowHours, + isLiveTransferEnabled, + isTombstoneClaimEnabled, + SUBSCRIPTION_CLAIM_COOLDOWN_DAYS, + SUBSCRIPTION_CLAIM_UNDO_DEADLINE_DAYS, +} from "@/subscriptions/claim-flags"; +import { + bootstrapLegacyCustody, CUSTODY_STATE_ESCROW, + CUSTODY_STATE_HELD, exhaustCustody, + findCustodyCovering, releaseCustody, + transferCustody, } from "@/subscriptions/custody"; import { LINEAGE_STATE_LIVE, @@ -18,23 +28,36 @@ import logger from "@/utils/logger"; import { prisma } from "@/utils/prisma"; /** - * Subscription claim execution for tombstone restoration. The caller has - * already verified the provider proof and resolved the lineage; this module - * owns the transactional escrow release and restoration state change. Claims - * against live lineages fail closed. + * Subscription claim execution: tombstone restoration (escrow release) and + * live bearer-transfer with contest window, one-shot undo, cooldown, and + * post-undo freeze. The caller (HTTP handler) has already verified provider + * proof — authoritative entitled-now + latest-transaction match — and + * resolved the lineage; this module owns the transactional state machine. * * Lock order per src/subscriptions/AGENTS.md: lineage -> accounts (sorted) * -> subscription -> wallets (sorted, via custody ops). */ -export type ClaimRejectionReason = "transfer_frozen" | "lineage_unresolved"; +export type ClaimRejectionReason = + | "not_entitled" + | "cooldown" + | "undo_consumed" + | "transfer_frozen" + | "lineage_unresolved" + | "pending_contest"; export type ClaimExecutionResult = | { kind: "restored"; subscription: Subscription; releasedCredits: bigint } + | { kind: "transferred"; subscription: Subscription; conserved: bigint } | { kind: "replayed"; subscription: Subscription } + | { kind: "pending"; contestEndsAt: Date; oldAccountId: string } | { kind: "rejected"; reason: ClaimRejectionReason } | { kind: "not_found" }; +const COOLDOWN_MS = SUBSCRIPTION_CLAIM_COOLDOWN_DAYS * 24 * 60 * 60 * 1000; +const UNDO_DEADLINE_MS = + SUBSCRIPTION_CLAIM_UNDO_DEADLINE_DAYS * 24 * 60 * 60 * 1000; + type TxClient = Prisma.TransactionClient; /** Data used to mint the fresh Subscription row on tombstone restoration. */ @@ -43,35 +66,55 @@ export type ClaimSubscriptionSeed = Omit< "accountId" | "lineageId" >; -const markLineageRestored = async ( +const stampLineage = async ( tx: TxClient, ctx: LineageLockContext, - journalId: string, + args: { journalId: string; freeze?: boolean; state?: string }, ): Promise => { await tx.subscriptionLineage.update({ where: { id: ctx.lineageId }, data: { lastTransferAt: new Date(), - lastTransferJournalId: journalId, - state: LINEAGE_STATE_LIVE, - tombstonedAt: null, - deletedAccountRef: null, + lastTransferJournalId: args.journalId, + ...(args.freeze ? { liveTransferFrozenAt: new Date() } : {}), + ...(args.state === LINEAGE_STATE_LIVE + ? { + state: LINEAGE_STATE_LIVE, + tombstonedAt: null, + deletedAccountRef: null, + } + : {}), }, }); }; +const custodyForSubscription = async ( + tx: TxClient, + ctx: LineageLockContext, + subscription: Subscription, +) => + (await findCustodyCovering(tx, ctx, new Date(), [CUSTODY_STATE_HELD])) ?? + bootstrapLegacyCustody(tx, ctx, { + subscriptionId: subscription.id, + ownerAccountId: subscription.accountId, + periodStart: subscription.currentPeriodStart, + periodEnd: subscription.currentPeriodEnd, + }); + export const executeClaim = async (args: { callerAccountId: string; lineageId: string; /** Provider-verified current period window (authoritative lookup). */ currentPeriodStart: Date; - /** Exact funding-event key of the provider-verified current period. */ + /** Exact funding-event key of the provider-verified current period + * (apple_txn_ / play_order_). + * Restoration releases only this event's escrow. */ providerPeriodKey: string; /** Fresh Subscription row fields for the restoration path. */ subscriptionSeed: ClaimSubscriptionSeed; providerProof: Prisma.InputJsonValue; }): Promise => { - const { lineageId } = args; + const { callerAccountId, lineageId } = args; return withDeadlockRetry( () => @@ -87,15 +130,135 @@ export const executeClaim = async (args: { return restoreTombstonedLineage(tx, ctx, args); } - const subscription = await tx.subscription.findFirst({ - where: { lineageId }, + // Live lineage. + const row = await tx.subscription.findFirst({ where: { lineageId } }); + if (!row) return { kind: "not_found" as const }; + if (row.accountId === callerAccountId) { + return { kind: "replayed" as const, subscription: row }; + } + + // One-shot undo: only the immediately previous owner, only while the + // transfer is unconsumed and inside the deadline. Executes + // immediately (an attacker can never be the previous owner of their + // own theft, and holding the victim's recovery behind a contest + // window would only extend attacker spend), then freezes the lineage. + const lastTransfer = await tx.subscriptionTransfer.findFirst({ + where: { lineageId, kind: "transfer", status: "committed" }, + orderBy: { createdAt: "desc" }, }); - if (!subscription) return { kind: "not_found" as const }; - if (subscription.accountId === args.callerAccountId) { - return { kind: "replayed" as const, subscription }; + const undoTarget = + lastTransfer && + lastTransfer.fromAccountId === callerAccountId && + lastTransfer.undoDeadlineAt !== null && + lastTransfer.undoDeadlineAt.getTime() > Date.now() + ? lastTransfer + : null; + + if (undoTarget) { + if (lineage.liveTransferFrozenAt) { + return { kind: "rejected" as const, reason: "transfer_frozen" }; + } + if (undoTarget.undoneByTransferId !== null) { + // The one-shot undo for this transfer was already spent. + return { kind: "rejected" as const, reason: "undo_consumed" }; + } + const journalId = randomUUID(); + // The one-shot CAS: zero rows updated means another undo consumed it. + const cas = await tx.subscriptionTransfer.updateMany({ + where: { id: undoTarget.id, undoneByTransferId: null }, + data: { undoneByTransferId: journalId }, + }); + if (cas.count === 0) { + return { kind: "rejected" as const, reason: "undo_consumed" }; + } + const conserved = await executeOwnershipMove(tx, ctx, { + journalId, + kind: "undo", + row, + toAccountId: callerAccountId, + undoOfTransferId: undoTarget.id, + providerProof: args.providerProof, + }); + // Post-undo freeze: an executed undo is an abuse tripwire; further + // automated live transfers need an operator. + await stampLineage(tx, ctx, { journalId, freeze: true }); + const updated = await tx.subscription.findUniqueOrThrow({ + where: { id: row.id }, + }); + logger.warn( + { lineageId, journalId, conserved: conserved.toString() }, + "subscription.claim.undo", + ); + return { + kind: "transferred" as const, + subscription: updated, + conserved, + }; } - return { kind: "rejected" as const, reason: "transfer_frozen" }; + // Plain live transfer. + if (!isLiveTransferEnabled() || lineage.liveTransferFrozenAt) { + return { kind: "rejected" as const, reason: "transfer_frozen" }; + } + const pending = await tx.subscriptionTransfer.findFirst({ + where: { lineageId, status: "pending" }, + }); + if (pending) { + return { kind: "rejected" as const, reason: "pending_contest" }; + } + if ( + lineage.lastTransferAt && + Date.now() - lineage.lastTransferAt.getTime() < COOLDOWN_MS + ) { + return { kind: "rejected" as const, reason: "cooldown" }; + } + + const windowHours = claimContestWindowHours(); + if (windowHours > 0) { + const contestEndsAt = new Date( + Date.now() + windowHours * 60 * 60 * 1000, + ); + await tx.subscriptionTransfer.create({ + data: { + lineageId, + kind: "transfer", + status: "pending", + fromAccountId: row.accountId, + toAccountId: callerAccountId, + providerProof: args.providerProof, + contestEndsAt, + }, + }); + return { + kind: "pending" as const, + contestEndsAt, + oldAccountId: row.accountId, + }; + } + + // Contest window disabled (requires explicit security acceptance): + // instant transfer. + const journalId = randomUUID(); + const conserved = await executeOwnershipMove(tx, ctx, { + journalId, + kind: "transfer", + row, + toAccountId: callerAccountId, + providerProof: args.providerProof, + }); + await stampLineage(tx, ctx, { journalId }); + const updated = await tx.subscription.findUniqueOrThrow({ + where: { id: row.id }, + }); + logger.warn( + { lineageId, journalId, conserved: conserved.toString() }, + "subscription.claim.granted", + ); + return { + kind: "transferred" as const, + subscription: updated, + conserved, + }; }, { timeout: 30_000 }, ), @@ -103,6 +266,68 @@ export const executeClaim = async (args: { ); }; +/** Shared committed-move body for transfer and undo. */ +const executeOwnershipMove = async ( + tx: TxClient, + ctx: LineageLockContext, + args: { + journalId: string; + kind: "transfer" | "undo"; + row: Subscription; + toAccountId: string; + undoOfTransferId?: string; + providerProof: Prisma.InputJsonValue; + }, +): Promise => { + // Lock order rule 2: accounts sorted by id. + const accountIds = [args.row.accountId, args.toAccountId].sort(); + for (const accountId of accountIds) { + await requireLiveAccount(tx, accountId); + } + // Lock order rule 3: the subscription row, explicitly, before any wallet + // lock (custody ops take wallets, rule 4). Updating the row only after + // the wallet moves would acquire rule-3 after rule-4. + await tx.$queryRaw` + SELECT id FROM "Subscription" WHERE id = ${args.row.id}::uuid FOR UPDATE + `; + const custody = await custodyForSubscription(tx, ctx, args.row); + const journalData = { + lineageId: ctx.lineageId, + kind: args.kind, + status: "committed", + fromAccountId: args.row.accountId, + toAccountId: args.toAccountId, + providerProof: args.providerProof, + undoOfTransferId: args.undoOfTransferId ?? null, + // Undo journal rows are never themselves undoable: no deadline. + undoDeadlineAt: + args.kind === "transfer" ? new Date(Date.now() + UNDO_DEADLINE_MS) : null, + }; + // A settling pending transfer reuses its journal row (one row per + // transfer); direct claims create a fresh one. + await tx.subscriptionTransfer.upsert({ + where: { id: args.journalId }, + update: journalData, + create: { id: args.journalId, ...journalData }, + }); + const conserved = custody + ? await transferCustody(tx, ctx, { + custody, + toAccountId: args.toAccountId, + journalId: args.journalId, + }) + : 0n; + await tx.subscriptionTransfer.update({ + where: { id: args.journalId }, + data: { conservedCredits: conserved }, + }); + await tx.subscription.update({ + where: { id: args.row.id }, + data: { accountId: args.toAccountId }, + }); + return conserved; +}; + const restoreTombstonedLineage = async ( tx: TxClient, ctx: LineageLockContext, @@ -129,15 +354,19 @@ const restoreTombstonedLineage = async ( if (existing.accountId === args.callerAccountId) { return { kind: "replayed", subscription: existing }; } - return { kind: "rejected", reason: "transfer_frozen" }; + return { kind: "rejected", reason: "pending_contest" }; } // Restoration = escrow release, not a grant: the period's funding-registry // row already exists. Release ONLY the escrow row for the provider-verified - // current funding event, selected by its exact provider period key rather - // than by window arithmetic. The window fallback applies only to custody - // bootstrapped from pre-lineage periods. Stale escrow rows release nothing - // and past ones are exhausted. + // current funding event, selected by its exact provider period key + // (apple_txn_ / play_order_) — never by window + // arithmetic: Google reports the lifetime startTime as the period start, + // so an old period's escrow can "cover" that timestamp while the current + // period's escrow does not. The window fallback applies only to custody + // bootstrapped from pre-lineage periods (legacy_ keys, which no provider + // event can name). Stale escrow rows release nothing and past ones are + // exhausted. const escrows = await tx.lineagePeriodCustody.findMany({ where: { lineageId: ctx.lineageId, state: CUSTODY_STATE_ESCROW }, }); @@ -229,7 +458,7 @@ const restoreTombstonedLineage = async ( providerProof: args.providerProof, }, }); - await markLineageRestored(tx, ctx, journalId); + await stampLineage(tx, ctx, { journalId, state: LINEAGE_STATE_LIVE }); logger.info( { @@ -241,3 +470,193 @@ const restoreTombstonedLineage = async ( ); return { kind: "restored", subscription, releasedCredits: released }; }; + +/** + * Execution-time provider recheck for pending transfers. The proof stored at + * claim time is up to CLAIM_CONTEST_WINDOW_HOURS old by settlement; the + * subscription may have been refunded/revoked in the window, and webhook + * compensation alone cannot close missing or delayed provider events. The + * check asserts entitled-NOW only (not latest-transaction match — a natural + * renewal inside the window is not theft). "unknown" (provider unreachable) + * skips the row this tick rather than cancelling. + */ +export type SettlementEntitlementChecker = ( + providerProof: Prisma.JsonValue | null, +) => Promise<"entitled" | "not_entitled" | "unknown">; + +const ENTITLED_APPLE_STATUSES = new Set([1, 4]); + +const defaultEntitlementChecker: SettlementEntitlementChecker = async ( + providerProof, +) => { + const proof = + providerProof && typeof providerProof === "object" + ? (providerProof as Record) + : {}; + try { + const otx = proof.originalTransactionId; + if (typeof otx === "string" && otx.length > 0) { + const { getSubscriptionStatuses } = + await import("@/subscriptions/apple-server-api"); + const statuses = await getSubscriptionStatuses(otx); + for (const group of statuses.data ?? []) { + for (const item of group.lastTransactions ?? []) { + if ( + item.originalTransactionId === otx && + item.status !== undefined && + ENTITLED_APPLE_STATUSES.has(item.status) + ) { + return "entitled"; + } + } + } + return "not_entitled"; + } + const purchaseToken = proof.purchaseToken; + if (typeof purchaseToken === "string" && purchaseToken.length > 0) { + const { fetchSubscriptionPurchaseV2 } = + await import("@/subscriptions/google-play/play-api"); + const { deriveStatusFromPurchase } = + await import("@/subscriptions/google-play/status"); + const purchase = await fetchSubscriptionPurchaseV2(purchaseToken); + const status = deriveStatusFromPurchase(purchase); + const entitled = + status === "active" || status === "grace" || status === "trial"; + return entitled ? "entitled" : "not_entitled"; + } + // No usable proof identity: fail closed to a veto-style cancel. + return "not_entitled"; + } catch (err) { + logger.warn({ err }, "subscription.claim.settlement_recheck_failed"); + return "unknown"; + } +}; + +let settlementEntitlementChecker: SettlementEntitlementChecker | null = null; + +/** Test seam: inject an entitlement checker; null restores the default. */ +export const __setSettlementEntitlementCheckerForTests = ( + checker: SettlementEntitlementChecker | null, +): void => { + settlementEntitlementChecker = checker; +}; + +/** + * Execute or cancel pending live-tier transfers whose contest window ended. + * An authenticated act by the old account after the pending row was created + * (lastAuthAt, used strictly as a veto, read under the Account row lock so a + * concurrent stamp cannot slip past the read) cancels; so does a lineage + * tombstoned in the meantime (owner deleted — the claimant re-claims via + * restoration) and a provider that no longer reports the subscription + * entitled. A null lastAuthAt is treated as a veto (defensive: post-backfill + * it can only mean an account whose activity we cannot reason about). Runs + * from the deletion outbox sweep tick. + */ +export const settlePendingTransfers = async (): Promise<{ + committed: number; + cancelled: number; +}> => { + const due = await prisma.subscriptionTransfer.findMany({ + where: { status: "pending", contestEndsAt: { lte: new Date() } }, + take: 20, + }); + let committed = 0; + let cancelled = 0; + for (const pendingRow of due) { + try { + // Provider recheck runs outside the transaction (third-party latency + // must not hold locks); the fetch-to-commit TOCTOU residual is the + // same one accepted for the claim path, compensated by webhooks. + const checker = settlementEntitlementChecker ?? defaultEntitlementChecker; + const entitlement = await checker(pendingRow.providerProof ?? null); + if (entitlement === "unknown") { + logger.warn( + { transferId: pendingRow.id }, + "subscription.claim.settlement_deferred_provider_unreachable", + ); + continue; + } + const result = await withDeadlockRetry( + () => + prisma.$transaction( + async (tx) => { + const ctx = await lockLineage(tx, pendingRow.lineageId); + const journal = await tx.subscriptionTransfer.findUnique({ + where: { id: pendingRow.id }, + }); + if (!journal || journal.status !== "pending") return "skipped"; + const lineage = await tx.subscriptionLineage.findUniqueOrThrow({ + where: { id: ctx.lineageId }, + }); + const row = await tx.subscription.findFirst({ + where: { lineageId: ctx.lineageId }, + }); + // Lock order rule 2: both accounts, sorted, FOR UPDATE — the + // veto read below must serialize against a concurrent + // lastAuthAt stamp, and the strong lock must be taken in + // sorted order to stay deadlock-free across settlements. + const accountIds = [journal.fromAccountId, journal.toAccountId] + .filter((id): id is string => id !== null) + .sort(); + const lockedAccounts = new Map(); + for (const accountId of accountIds) { + const rows = await tx.$queryRaw< + Array<{ id: string; lastAuthAt: Date | null }> + >` + SELECT id, "lastAuthAt" FROM "Account" + WHERE id = ${accountId}::uuid FOR UPDATE + `; + if (rows.length > 0) { + lockedAccounts.set(rows[0].id, rows[0].lastAuthAt); + } + } + const oldLastAuthAt = journal.fromAccountId + ? (lockedAccounts.get(journal.fromAccountId) ?? null) + : null; + const vetoed = + oldLastAuthAt === null || + oldLastAuthAt.getTime() > journal.createdAt.getTime(); + if ( + vetoed || + entitlement === "not_entitled" || + lineage.state === LINEAGE_STATE_TOMBSTONED || + lineage.liveTransferFrozenAt || + !row || + row.accountId !== journal.fromAccountId || + !journal.toAccountId || + !lockedAccounts.has(journal.toAccountId) + ) { + await tx.subscriptionTransfer.update({ + where: { id: journal.id }, + data: { status: "cancelled" }, + }); + return "cancelled"; + } + await executeOwnershipMove(tx, ctx, { + journalId: journal.id, + kind: "transfer", + row, + toAccountId: journal.toAccountId, + providerProof: journal.providerProof ?? {}, + }); + await stampLineage(tx, ctx, { journalId: journal.id }); + return "committed"; + }, + { timeout: 30_000 }, + ), + { label: "settle_pending_transfer" }, + ); + if (result === "committed") committed += 1; + if (result === "cancelled") cancelled += 1; + } catch (err) { + logger.error( + { err, transferId: pendingRow.id }, + "subscription.claim.pending_settlement_failed", + ); + } + } + if (committed + cancelled > 0) { + logger.info({ committed, cancelled }, "subscription.claim.pending_settled"); + } + return { committed, cancelled }; +}; diff --git a/src/subscriptions/custody.ts b/src/subscriptions/custody.ts index ae78c087..0f8ab45b 100644 --- a/src/subscriptions/custody.ts +++ b/src/subscriptions/custody.ts @@ -19,9 +19,9 @@ type TxClient = Prisma.TransactionClient; * D = min(lockedOwnerBalance, max(0, cap - ownerConsumesSince(custodyStartedAt))) * * then sets cap := D. Because D <= cap and cap starts at the period - * allotment, no chain of escrow/restoration/refund can ever move more + * allotment, no chain of transfer/undo/escrow/refund can ever move more * value than the period funded, and commingled promo/admin/signup credits - * never move (they are outside cap). After funding, custody — not + * never transfer (they are outside cap). After funding, custody — not * account-scoped sub_grant rows — is the source of truth for the remainder. */ @@ -30,6 +30,25 @@ export const CUSTODY_STATE_ESCROW = "escrow"; export const CUSTODY_STATE_INVALIDATED = "invalidated"; export const CUSTODY_STATE_EXHAUSTED = "exhausted"; +/** + * Ordering guard for the deletion teardown: escrow settlement must run + * BEFORE deleteWalletForAccountWithTx. A held custody's owner always has a + * UserCredits row (funding created it), so a missing row here means the + * teardown already tore the wallet down — proceeding would let the balance + * lock silently recreate a zero wallet (conserving 0 and breaking the + * Account delete on its RESTRICT FK). Fail loudly instead. + */ +export class EscrowWalletMissingError extends Error { + constructor(accountId: string) { + super( + `escrowCustody: UserCredits row missing for holder ${accountId} — ` + + "escrow must settle before the wallet teardown " + + "(deleteWalletForAccountWithTx) in the deletion transaction", + ); + Object.setPrototypeOf(this, EscrowWalletMissingError.prototype); + } +} + export const findCustody = async ( tx: TxClient, ctx: LineageLockContext, @@ -193,6 +212,64 @@ const computeMoveAmount = async ( return unspent < positiveBalance ? unspent : positiveBalance; }; +/** + * Live transfer: debit the current holder by D, credit the new owner by D + * (invariant: the two deltas sum to zero), move custody. + */ +export const transferCustody = async ( + tx: TxClient, + ctx: LineageLockContext, + args: { + custody: LineagePeriodCustody; + toAccountId: string; + journalId: string; + }, +): Promise => { + const { custody } = args; + const fromAccountId = custody.ownerAccountId; + if (!fromAccountId) return 0n; + // Lock-order rule 4: prelock BOTH wallets in sorted account order before + // any read or debit. Without this, an A->B transfer on one lineage and a + // B->A transfer on another lock the two wallets in opposite orders and + // deadlock (40P01). + const walletLockOrder = [fromAccountId, args.toAccountId].sort(); + for (const accountId of walletLockOrder) { + await lockUserCreditsBalance(tx, accountId); + } + const amount = await computeMoveAmount(tx, custody); + if (amount > 0n) { + await applyDeltaWithTx(tx, { + accountId: fromAccountId, + delta: -amount, + reason: LedgerReason.adjust, + idempotencyKey: `sub_transfer_out_${args.journalId}`, + scope: "sub_transfer", + grantKindId: "sub_forfeit", + note: `lineage ${ctx.lineageId} transfer out (journal ${args.journalId})`, + floorCheck: { minBalance: 0n }, + }); + await applyDeltaWithTx(tx, { + accountId: args.toAccountId, + delta: amount, + reason: LedgerReason.grant, + idempotencyKey: `sub_transfer_in_${args.journalId}`, + scope: "sub_transfer", + grantKindId: "sub_grant", + note: `lineage ${ctx.lineageId} transfer in (journal ${args.journalId})`, + }); + } + await tx.lineagePeriodCustody.update({ + where: { id: custody.id }, + data: { + ownerAccountId: args.toAccountId, + remainderCap: amount, + custodyStartedAt: new Date(), + state: CUSTODY_STATE_HELD, + }, + }); + return amount; +}; + /** * Deletion escrow: debit the holder by D into escrow (the tombstone * snapshot, first-class). The wallet is removed later in the same teardown. @@ -205,6 +282,13 @@ export const escrowCustody = async ( const { custody } = args; const fromAccountId = custody.ownerAccountId; if (!fromAccountId) return custody.remainderCap; + // Escrow-before-teardown assertion (see EscrowWalletMissingError): check + // the wallet row exists BEFORE computeMoveAmount's lock upserts one. + const wallet = await tx.userCredits.findUnique({ + where: { accountId: fromAccountId }, + select: { accountId: true }, + }); + if (!wallet) throw new EscrowWalletMissingError(fromAccountId); const amount = await computeMoveAmount(tx, custody); if (amount > 0n) { await applyDeltaWithTx(tx, { @@ -270,8 +354,9 @@ export const releaseCustody = async ( /** * Refund/revoke compensation: claw the conservative remainder back from the - * current holder; escrowed custody is invalidated without any wallet move - * because the value already left at deletion time. + * current holder (works whether they hold sub_grant or sub_transfer_in + * value); escrowed custody is invalidated without any wallet move (the value + * already left at deletion time). */ export const invalidateCustody = async ( tx: TxClient, diff --git a/src/subscriptions/repository.ts b/src/subscriptions/repository.ts index 244a64e4..99284ee8 100644 --- a/src/subscriptions/repository.ts +++ b/src/subscriptions/repository.ts @@ -47,6 +47,7 @@ import { type SubscriptionTier, } from "@/subscriptions/tiers"; import { + absorbTombstoneRotation, findTombstonedLineage, SubscriptionTombstonedError, } from "@/subscriptions/tombstones"; @@ -492,7 +493,15 @@ export const upsertFromVerify = async ( where: { id: lineageId }, }); if (lineage && lineage.state === LINEAGE_STATE_TOMBSTONED) { - throw new SubscriptionTombstonedError(lineage.lineageKey); + // Thrown inside the tx; the rotation absorption happens durably + // in the catch below. + throw new SubscriptionTombstonedError( + input.provider, + lineage.lineageKey, + externalId, + lineage.deletedAccountRef ?? "", + lineage.id, + ); } } @@ -637,7 +646,7 @@ export const upsertFromVerify = async ( // Idempotent per funding event per account and once per provider // funding event globally (lineage registry), so the initial verify, a // re-verify of the same period, an S2S DID_RENEW racing this verify, - // or a verify after restoration all resolve to one funded period. + // or a post-transfer replay all resolve to one funded period. if ( !isStaleVerify && isEntitledSubscriptionStatus(subscription.status) @@ -705,6 +714,26 @@ export const upsertFromVerify = async ( }), ); } catch (err) { + if (err instanceof SubscriptionTombstonedError) { + // Play token rotation onto a tombstoned lineage: record the presented + // token as an alias so future lookups need no chain-walk. Done here, + // outside the rolled-back transaction, so the absorption survives the + // throw. Routed through the atomic conflict-detecting resolver; a + // conflicting alias quarantines (the 409 to the caller is unchanged — + // the claim path re-resolves authoritatively). Apple keys never + // rotate (matchedKey === presentedKey), so this is Play-only. + if (err.matchedKey !== err.presentedKey) { + await absorbTombstoneRotation({ + token: err.presentedKey, + linkedPurchaseToken: + input.provider === BillingProvider.googlePlay + ? input.linkedPurchaseToken + : undefined, + lineageId: err.lineageId, + }); + } + throw err; + } // Route the P2002 by WHICH unique index fired: // - Subscription provider-unique → the documented cold-start race (two // concurrent creates of the same provider sub). Benign idempotent @@ -902,7 +931,8 @@ export type GooglePlayApplyNotificationInput = { /** Lookup key — the purchaseToken from the RTDN payload. */ purchaseToken: string; /** Rotation predecessor from the refreshed Play purchase, when present. - * Used as a candidate when resolving an existing tombstoned lineage. */ + * Used by the deletion-tombstone probe so a rotation onto a tombstoned + * token is absorbed rather than escaping the tombstone. */ linkedPurchaseToken?: string | null; /** Audit transactionId — Google's latestOrderId from the refreshed purchase. */ playOrderId: string; @@ -1053,6 +1083,32 @@ const notificationTombstoneProbe = async ( : [input.purchaseToken, input.linkedPurchaseToken], ); if (!lineage) return null; + const presentedKey = + input.provider === BillingProvider.apple + ? input.originalTransactionId + : input.purchaseToken; + if (lineage.lineageKey !== presentedKey) { + // Play rotation onto a tombstoned lineage: absorb the new token so + // future notifications resolve without chain-walking. Resolution runs + // BEFORE any funding/invalidation effect, through the atomic + // conflict-detecting resolver: a presented token that belongs to a + // different lineage is a two-lineage conflict — quarantined by the + // resolver — and the event must not mutate this lineage. Ack it + // (existing RTDN semantics: quarantined events are acked but + // preserved); the reconciliation sweep picks the row up. + const absorption = await absorbTombstoneRotation({ + token: presentedKey, + linkedPurchaseToken: + input.provider === BillingProvider.googlePlay + ? input.linkedPurchaseToken + : undefined, + lineageId: lineage.id, + }); + if (absorption === "conflict") { + return { kind: "tombstoned" }; + } + } + const { update } = input; const isTerminal = update.status === SubscriptionStatus.expired || @@ -1345,9 +1401,9 @@ const applyNotificationOnce = async ( updated.status === SubscriptionStatus.revoked ) { // Expiry / refund / revoke → bounded clawback of the unused - // subscription portion from the current custody holder. Custody - // also works after restoration, where account-scoped sub_grant - // discovery finds nothing. When the holder is still the original + // subscription portion from the CURRENT custody holder (custody + // works post-transfer, where account-scoped sub_grant discovery + // would find nothing). When the holder is still the original // grantee the debit keeps the legacy sub_forfeit shape // (idempotent per (sub, period)); custody is invalidated either // way so no later move can touch the period again, and an @@ -1390,8 +1446,9 @@ const applyNotificationOnce = async ( } else if (custody.state === CUSTODY_STATE_HELD) { // Prefer the legacy per-subscription forfeit shape when it // applies — it only does when the holder carries the original - // account-scoped sub_grant row. A restored holder has no such - // row, so custody performs the compensation instead. + // account-scoped sub_grant row. A holder who received the + // value via transfer (no sub_grant row on their account: the + // forfeit skips) is compensated through custody instead. const forfeited = await forfeitSubscriptionPeriod(tx, { subscription: updated, periodStart: current.currentPeriodStart, @@ -1560,8 +1617,8 @@ export type VoidedPurchaseCompensation = /** * Play voided-purchase compensation: claw the conservative remainder back - * from whoever currently holds the VOIDED ORDER's custody, or invalidates - * deletion escrow. The voided notification's orderId + * from whoever currently holds the VOIDED ORDER's custody (original owner, + * claim transferee, or deletion escrow). The voided notification's orderId * pins the exact `play_order_` custody row, so a late void for an * old order claws only that period — never the current one. Fail-closed * rule: a void with NO orderId, or whose exact custody row is absent diff --git a/src/subscriptions/tombstones.ts b/src/subscriptions/tombstones.ts index 47d1baea..db9727f3 100644 --- a/src/subscriptions/tombstones.ts +++ b/src/subscriptions/tombstones.ts @@ -5,7 +5,10 @@ import type { } from "@prisma/client"; import { LINEAGE_STATE_TOMBSTONED, + LineageUnresolvedError, + quarantineLineageToken, resolveLineageId, + resolveOrCreateGoogleLineage, } from "@/subscriptions/lineage"; import type { prisma } from "@/utils/prisma"; @@ -15,8 +18,10 @@ type DbClient = Prisma.TransactionClient | typeof prisma; * Tombstone semantics over lineage state. A deleted owner's lineage carries * state "tombstoned": webhooks ack events on it as counted no-ops, verify * grants no entitlement (409 with an eligibility-derived claimable signal), - * and a restoration claim flips the lineage back to "live" when it recreates - * the subscription. + * and Play token rotation is absorbed into the lineage's alias set rather + * than escaping it. A live Subscription row for the key always wins (the + * claim flow restores the lineage to "live" when it re-homes the + * subscription). */ /** @@ -27,8 +32,13 @@ type DbClient = Prisma.TransactionClient | typeof prisma; */ export class SubscriptionTombstonedError extends Error { constructor( + public readonly provider: BillingProvider, /** The lineage's canonical key. */ public readonly matchedKey: string, + /** The key the caller presented (differs from matchedKey on rotation). */ + public readonly presentedKey: string, + public readonly accountRef: string, + public readonly lineageId: string, ) { super("Subscription belongs to a deleted account"); this.name = "SubscriptionTombstonedError"; @@ -50,3 +60,44 @@ export const findTombstonedLineage = async ( if (!lineage || lineage.state !== LINEAGE_STATE_TOMBSTONED) return null; return lineage; }; + +/** + * Absorb a rotated token into the lineage's alias set so future lookups by + * the new token resolve without chain-walking. Routed through the atomic + * conflict-detecting lineage resolver — never a bare alias upsert: a token + * that already belongs to ANOTHER lineage is a genuine two-lineage conflict + * that must quarantine (the resolver writes the LineageQuarantine row), not + * silently no-op and let the event mutate the wrong lineage. + * + * Returns "absorbed" when the token verifiably resolves to the expected + * lineage, "conflict" when it does not (already quarantined; the caller + * must not apply any funding/invalidation effect for the event). + */ +export const absorbTombstoneRotation = async (args: { + token: string; + linkedPurchaseToken?: string | null; + lineageId: string; +}): Promise<"absorbed" | "conflict"> => { + try { + const resolved = await resolveOrCreateGoogleLineage({ + token: args.token, + linkedPurchaseToken: args.linkedPurchaseToken, + }); + if (resolved === args.lineageId) return "absorbed"; + // Consistent chain, but it resolves to a different lineage than the + // tombstone lookup matched: ambiguous attribution — quarantine. + await quarantineLineageToken( + "googlePlay", + args.token, + "tombstone_rotation_mismatch", + { expectedLineageId: args.lineageId, resolvedLineageId: resolved }, + ); + return "conflict"; + } catch (err) { + if (err instanceof LineageUnresolvedError) { + // The resolver already quarantined (alias conflict, loop, depth). + return "conflict"; + } + throw err; + } +}; diff --git a/tests/auth-token-siwe.test.ts b/tests/auth-token-siwe.test.ts index a869409f..bb2bb0be 100644 --- a/tests/auth-token-siwe.test.ts +++ b/tests/auth-token-siwe.test.ts @@ -4,6 +4,7 @@ import { Wallet } from "ethers"; import express from "express"; import request from "supertest"; import { afterEach, beforeAll, describe, expect, test, vi } from "vitest"; +import { __setAuthActivityStampFailureForTests } from "@/accounts/auth-activity"; import { idempotencyKeySchema } from "@/api/v2/accounts/schemas/shared"; import { issueNonce } from "@/api/v2/auth/auth-nonce.repository"; import { authRouter } from "@/api/v2/auth/auth.router"; @@ -39,6 +40,7 @@ async function buildSiwe(nonce: string, deviceId = "test-device-id") { } async function reset() { + __setAuthActivityStampFailureForTests(null); await prisma.deviceRegistration.deleteMany(); await prisma.authMethod.deleteMany(); // CreditLedger + UserCredits hang off Account via FK. Wipe them first so the @@ -106,6 +108,25 @@ describe("POST /auth/token (legacy + SIWE)", () => { expect(clearStr).toContain("Max-Age=0"); }); + test("activity stamp failure returns 500 without minting a JWT", async () => { + const nonce = await issueNonce(); + const cookieValue = signNonce(nonce); + const { messageStr, signature } = await buildSiwe(nonce, "dev-stamp-fail"); + __setAuthActivityStampFailureForTests(new Error("stamp unavailable")); + + const res = await request(makeApp()) + .post("/auth/token") + .set(...APPCHECK) + .set("Cookie", `${NONCE_COOKIE_NAME}=${cookieValue}`) + .send({ + deviceId: "dev-stamp-fail", + siwe: { message: messageStr, signature }, + }); + + expect(res.status).toBe(500); + expect(res.body).not.toHaveProperty("token"); + }); + test("replay: same nonce twice → 401 on second attempt", async () => { const nonce = await issueNonce(); const cookieValue = signNonce(nonce); diff --git a/tests/deletion/adversarial-round3.test.ts b/tests/deletion/adversarial-round3.test.ts index 2a95553b..79212e4c 100644 --- a/tests/deletion/adversarial-round3.test.ts +++ b/tests/deletion/adversarial-round3.test.ts @@ -8,12 +8,17 @@ import { IdentityBarredError, upsertAuthMethodAndAccount, } from "@/accounts/repository"; -import { __setClaimAppCheckVerifierForTests } from "@/api/v2/accounts/handlers/subscription-claim"; +import { + __setClaimAppCheckVerifierForTests, + __setPendingTransferNotifierForTests, +} from "@/api/v2/accounts/handlers/subscription-claim"; import { subscriptionVerifyHandler } from "@/api/v2/accounts/handlers/subscription-verify"; import { googlePlayWebhookRouter } from "@/api/v2/subscriptions/google-play-webhook.router"; import { authMiddleware, requireAccount } from "@/middleware/auth"; import { pinoMiddleware } from "@/middleware/pino"; import { consume, getBalance } from "@/payments"; +import { resetAppleApiClientForTests } from "@/subscriptions/apple-server-api"; +import { settlePendingTransfers } from "@/subscriptions/claim"; import { PlayNotificationType } from "@/subscriptions/google-play/notification-mapping"; import { setPlayApiFixtureForTests, @@ -328,6 +333,138 @@ describe("terminal events while tombstoned invalidate their exact escrow", () => }); }); +describe("one-shot undo under a real race", () => { + test("concurrent undos by the previous owner commit exactly one undo journal", async () => { + process.env.SUBSCRIPTION_CLAIM_LIVE_TRANSFER_ENABLED = "true"; + process.env.CLAIM_CONTEST_WINDOW_HOURS = "0"; + installLocalTestingVerifier(); + __setClaimAppCheckVerifierForTests(() => Promise.resolve()); + const owner = await newAccount(); + const claimer = await newAccount(); + const otx = "7000000000000001"; + await upsertFromVerify(appleInput(owner, otx)); + const jws = await signTransaction(); + installAppleStatusMap({ [otx]: { status: 1, signedLatest: jws } }); + expect((await claimRequest(claimer, jws)).status).toBe(200); + + const [a, b] = await Promise.all([ + claimRequest(owner, jws), + claimRequest(owner, jws), + ]); + // Winner undoes; loser converges as an idempotent replay (owner already + // holds the row) or an undo_consumed rejection — never a second undo. + for (const res of [a, b]) { + expect([200, 409]).toContain(res.status); + } + expect( + await prisma.subscriptionTransfer.count({ where: { kind: "undo" } }), + ).toBe(1); + const transfer = await prisma.subscriptionTransfer.findFirstOrThrow({ + where: { kind: "transfer" }, + }); + expect(transfer.undoneByTransferId).not.toBeNull(); + const row = await prisma.subscription.findFirstOrThrow({ + where: { originalTransactionId: otx }, + }); + expect(row.accountId).toBe(owner); + + // The undo journal row is never itself an undo target: the claimer's + // "undo of the undo" is rejected (post-undo freeze; and undo rows carry + // no undo deadline). + const undoRow = await prisma.subscriptionTransfer.findFirstOrThrow({ + where: { kind: "undo" }, + }); + expect(undoRow.undoDeadlineAt).toBeNull(); + const claimBack = await claimRequest(claimer, jws); + expect(claimBack.status).toBe(409); + expect((claimBack.body as ClaimBody).reason).toBe("transfer_frozen"); + }); +}); + +describe("contest-window settlement rechecks the provider", () => { + test("entitlement revoked during the window cancels the pending transfer", async () => { + process.env.SUBSCRIPTION_CLAIM_LIVE_TRANSFER_ENABLED = "true"; + process.env.CLAIM_CONTEST_WINDOW_HOURS = "72"; + installLocalTestingVerifier(); + __setClaimAppCheckVerifierForTests(() => Promise.resolve()); + __setPendingTransferNotifierForTests(() => Promise.resolve()); + const owner = await newAccount(); + const claimer = await newAccount(); + const otx = "7000000000000001"; + await upsertFromVerify(appleInput(owner, otx)); + const jws = await signTransaction(); + installAppleStatusMap({ [otx]: { status: 1, signedLatest: jws } }); + + expect((await claimRequest(claimer, jws)).status).toBe(202); + + // The provider revokes inside the window; settlement's execution-time + // recheck must cancel instead of executing the stored transfer. + installAppleStatusMap({ [otx]: { status: 2, signedLatest: jws } }); + await prisma.subscriptionTransfer.updateMany({ + where: { status: "pending" }, + data: { contestEndsAt: new Date(Date.now() - 1000) }, + }); + const settled = await settlePendingTransfers(); + expect(settled.cancelled).toBe(1); + expect(settled.committed).toBe(0); + const row = await prisma.subscription.findFirstOrThrow({ + where: { originalTransactionId: otx }, + }); + expect(row.accountId).toBe(owner); + }); + + test("provider unreachable defers settlement (row stays pending)", async () => { + process.env.SUBSCRIPTION_CLAIM_LIVE_TRANSFER_ENABLED = "true"; + process.env.CLAIM_CONTEST_WINDOW_HOURS = "72"; + installLocalTestingVerifier(); + __setClaimAppCheckVerifierForTests(() => Promise.resolve()); + __setPendingTransferNotifierForTests(() => Promise.resolve()); + const owner = await newAccount(); + const claimer = await newAccount(); + const otx = "7000000000000001"; + await upsertFromVerify(appleInput(owner, otx)); + const jws = await signTransaction(); + installAppleStatusMap({ [otx]: { status: 1, signedLatest: jws } }); + expect((await claimRequest(claimer, jws)).status).toBe(202); + + resetAppleApiClientForTests(); // provider calls now fail + await prisma.subscriptionTransfer.updateMany({ + where: { status: "pending" }, + data: { contestEndsAt: new Date(Date.now() - 1000) }, + }); + const settled = await settlePendingTransfers(); + expect(settled).toEqual({ committed: 0, cancelled: 0 }); + expect( + await prisma.subscriptionTransfer.count({ where: { status: "pending" } }), + ).toBe(1); + }); + + test("null lastAuthAt on the old account is a defensive veto", async () => { + process.env.SUBSCRIPTION_CLAIM_LIVE_TRANSFER_ENABLED = "true"; + process.env.CLAIM_CONTEST_WINDOW_HOURS = "72"; + installLocalTestingVerifier(); + __setClaimAppCheckVerifierForTests(() => Promise.resolve()); + __setPendingTransferNotifierForTests(() => Promise.resolve()); + // Owner with NO lastAuthAt (direct create) — settlement must not treat + // the unknown as silence-equals-consent. + const owner = (await prisma.account.create({ data: {} })).id; + const claimer = await newAccount(); + const otx = "7000000000000001"; + await upsertFromVerify(appleInput(owner, otx)); + const jws = await signTransaction(); + installAppleStatusMap({ [otx]: { status: 1, signedLatest: jws } }); + expect((await claimRequest(claimer, jws)).status).toBe(202); + + await prisma.subscriptionTransfer.updateMany({ + where: { status: "pending" }, + data: { contestEndsAt: new Date(Date.now() - 1000) }, + }); + const settled = await settlePendingTransfers(); + expect(settled.cancelled).toBe(1); + expect(settled.committed).toBe(0); + }); +}); + describe("deadlock retry", () => { test("withDeadlockRetry retries bounded on 40P01/40001-shaped failures", async () => { let calls = 0; @@ -369,9 +506,119 @@ describe("deadlock retry", () => { expect(isRetryableTxConflict(new Error("40001"))).toBe(true); expect(isRetryableTxConflict(new Error("boring"))).toBe(false); }); + + test("opposite-direction transfers across two lineages converge (sorted wallet prelock)", async () => { + process.env.SUBSCRIPTION_CLAIM_LIVE_TRANSFER_ENABLED = "true"; + process.env.CLAIM_CONTEST_WINDOW_HOURS = "0"; + installLocalTestingVerifier(); + __setClaimAppCheckVerifierForTests(() => Promise.resolve()); + const accountA = await newAccount(); + const accountB = await newAccount(); + const otx1 = "7000000000000011"; + const otx2 = "7000000000000012"; + await upsertFromVerify( + appleInput(accountA, otx1, "11111111-2222-3333-4444-000000000001"), + ); + await upsertFromVerify( + appleInput(accountB, otx2, "11111111-2222-3333-4444-000000000002"), + ); + const jws1 = await signTransaction({ + transactionId: otx1, + originalTransactionId: otx1, + }); + const jws2 = await signTransaction({ + transactionId: otx2, + originalTransactionId: otx2, + }); + installAppleStatusMap({ + [otx1]: { status: 1, signedLatest: jws1 }, + [otx2]: { status: 1, signedLatest: jws2 }, + }); + + // L1: A -> B while L2: B -> A, concurrently. Without sorted wallet + // prelocks this is the textbook AB-BA wallet deadlock. + const [r1, r2] = await Promise.all([ + claimRequest(accountB, jws1), + claimRequest(accountA, jws2), + ]); + expect( + [r1.status, r2.status], + `${JSON.stringify(r1.body)} / ${JSON.stringify(r2.body)}`, + ).toEqual([200, 200]); + // Conservation: each wallet ends with exactly the other lineage's period. + expect(await getBalance(accountA)).toBe(PERIOD_CREDITS); + expect(await getBalance(accountB)).toBe(PERIOD_CREDITS); + }); }); describe("cumulative custody cap across the full lifecycle", () => { + test("transfer -> spend -> undo -> delete -> restore never exceeds one allotment", async () => { + process.env.SUBSCRIPTION_CLAIM_LIVE_TRANSFER_ENABLED = "true"; + process.env.CLAIM_CONTEST_WINDOW_HOURS = "0"; + installLocalTestingVerifier(); + __setClaimAppCheckVerifierForTests(() => Promise.resolve()); + const owner = await newAccount(); + const claimerB = await newAccount(); + const claimerC = await newAccount(); + const otx = "7000000000000001"; + await upsertFromVerify(appleInput(owner, otx)); + const jws = await signTransaction(); + installAppleStatusMap({ [otx]: { status: 1, signedLatest: jws } }); + + const capAfter = async (): Promise => { + const custody = await prisma.lineagePeriodCustody.findFirstOrThrow({ + orderBy: { periodEnd: "desc" }, + }); + return custody.remainderCap; + }; + + const caps: bigint[] = [await capAfter()]; + + // Transfer to B, B spends 1000, owner undoes (recovers the remainder). + expect((await claimRequest(claimerB, jws)).status).toBe(200); + caps.push(await capAfter()); + await consume({ + accountId: claimerB, + usdCostMicros: 500_000n, + idempotencyKey: `burn_${claimerB}`, + requestId: "burn", + }); + expect((await claimRequest(owner, jws)).status).toBe(200); + caps.push(await capAfter()); + + // Owner deletes (escrow), C restores. + await deleteAccount({ accountId: owner, operationId: randomUUID() }); + caps.push(await capAfter()); + expect((await claimRequest(claimerC, jws)).status).toBe(200); + caps.push(await capAfter()); + + // The custody cap is monotonically non-increasing and bounded by the + // allotment. + for (let i = 1; i < caps.length; i += 1) { + expect(caps[i] <= caps[i - 1]).toBe(true); + } + expect(caps[0]).toBe(PERIOD_CREDITS); + + // Cumulative movement bounded by one allotment: all remaining balances + // plus what B burned equal exactly the single funded period. + const balances = await Promise.all([ + getBalance(claimerB), + getBalance(claimerC), + ]); + expect(balances[0]).toBe(0n); + expect(balances[1]).toBe(PERIOD_CREDITS - 1000n); + // The funding registry never grew past the one funded period (the + // original sub_grant ledger row died with the owner's wallet; the + // registry row is the durable funded-once record). + expect(await prisma.lineagePeriodGrant.count()).toBe(1); + // Restoration was an escrow release, never a second grant. + expect( + await prisma.creditLedger.count({ + where: { idempotencyKey: { startsWith: "sub_escrow_release_" } }, + }), + ).toBe(1); + }); + test("spend -> delete -> restore -> renewal cycles stay within funded allotments", async () => { installLocalTestingVerifier(); __setClaimAppCheckVerifierForTests(() => Promise.resolve()); diff --git a/tests/deletion/adversarial-round4.test.ts b/tests/deletion/adversarial-round4.test.ts index 7b6552b2..d44d6061 100644 --- a/tests/deletion/adversarial-round4.test.ts +++ b/tests/deletion/adversarial-round4.test.ts @@ -4,33 +4,50 @@ import express, { json } from "express"; import request from "supertest"; import { describe, expect, test, vi } from "vitest"; import { deleteAccount } from "@/accounts/deletion/service"; -import { __setClaimAppCheckVerifierForTests } from "@/api/v2/accounts/handlers/subscription-claim"; +import { + __setClaimAppCheckVerifierForTests, + __setPendingTransferNotifierForTests, +} from "@/api/v2/accounts/handlers/subscription-claim"; import { googlePlayWebhookRouter } from "@/api/v2/subscriptions/google-play-webhook.router"; +import { authMiddleware } from "@/middleware/auth"; import { __setClaimCeilingIncrementForTests, makeClaimGlobalCeiling, } from "@/middleware/claimGlobalCeiling"; import { pinoMiddleware } from "@/middleware/pino"; import { getBalance } from "@/payments"; +import { settlePendingTransfers } from "@/subscriptions/claim"; import { evaluateClaimable } from "@/subscriptions/claim-eligibility"; import { setPlayApiFixtureForTests } from "@/subscriptions/google-play/play-api"; import { setPubsubVerifierForTests } from "@/subscriptions/google-play/verifier"; import { LineageUnresolvedError } from "@/subscriptions/lineage"; import { runReclaimReconciliationSweep } from "@/subscriptions/reconciliation"; import { + applyNotification, compensateVoidedPurchase, + SUBSCRIPTION_TIER_PLUS, SubscriptionStatus, upsertFromVerify, + type GooglePlayApplyNotificationInput, } from "@/subscriptions/repository"; import { prisma } from "@/utils/prisma"; import { + appleClaimRequest, + appleInput, + claimApp, + installAppleStatuses, + installLocalTestingVerifier, installReclaimHooks, newAccount, NEXT_PERIOD_END, PERIOD_CREDITS, + PERIOD_START, playClaimRequest, playInput, playPurchase, + PRODUCT_ID, + signTransaction as signReclaimTransaction, + tokenFor, } from "./reclaim-fixtures"; vi.mock("firebase-admin/app"); @@ -44,10 +61,81 @@ const rtdnApp = () => { return app; }; +const OTX = "6000000000000001"; +const signTransaction = (overrides: Record = {}) => + signReclaimTransaction(OTX, overrides); + +/** Google renewal notification with the lifetime startTime (never advances). */ +const playRenewal = ( + token: string, + orderId: string, + periodEnd: Date, +): GooglePlayApplyNotificationInput => ({ + provider: BillingProvider.googlePlay, + purchaseToken: token, + linkedPurchaseToken: null, + playOrderId: orderId, + messageId: `msg-${randomUUID()}`, + notificationType: "PLAY_2", + notificationSubtype: null, + signedPayload: "{}", + update: { + status: SubscriptionStatus.active, + tier: SUBSCRIPTION_TIER_PLUS, + productId: PRODUCT_ID, + currentPeriodStart: PERIOD_START, + currentPeriodEnd: periodEnd, + willRenew: true, + }, +}); + +type ClaimBody = { code?: string; reason?: string }; + installReclaimHooks(); -describe("google provider claims are permanently fail-closed", () => { - test("a schema-valid google claim is rejected before provider lookup", async () => { +describe("unrecognized google product on an entitled purchase", () => { + test("claim returns the contract 400 invalid_claim_proof, not a 500", async () => { + process.env.SUBSCRIPTION_CLAIM_GOOGLE_ENABLED = "true"; + __setClaimAppCheckVerifierForTests(() => Promise.resolve()); + const owner = await newAccount(); + const token = "unknown-product-token-1"; + await upsertFromVerify(playInput(owner, token)); + await deleteAccount({ accountId: owner, operationId: randomUUID() }); + + const unknownProductId = "app.convos.subs.unknown.monthly"; + setPlayApiFixtureForTests(() => ({ + ...playPurchase({ latestOrderId: `GPA.${token}..0` }), + lineItems: [ + { + productId: unknownProductId, + expiryTime: NEXT_PERIOD_END.toISOString(), + autoRenewingPlan: { autoRenewEnabled: true }, + }, + ], + })); + const claimer = await newAccount(); + const res = await request(claimApp()) + .post("/v2/accounts/me/subscription/claim") + .set("X-Convos-AuthToken", await tokenFor(claimer)) + .set("X-Firebase-AppCheck", `limited-${randomUUID()}`) + .send({ + platform: "googlePlay", + purchaseToken: token, + productId: unknownProductId, + }); + expect(res.status).toBe(400); + expect((res.body as ClaimBody).code).toBe("invalid_claim_proof"); + // Nothing restored, nothing granted. + expect(await getBalance(claimer)).toBe(0n); + const lineage = await prisma.subscriptionLineage.findFirstOrThrow({ + where: { provider: BillingProvider.googlePlay }, + }); + expect(lineage.state).toBe("tombstoned"); + }); +}); + +describe("google provider claim gate (Apple-only product)", () => { + test("google claims are rejected while the provider flag is off (default)", async () => { __setClaimAppCheckVerifierForTests(() => Promise.resolve()); const owner = await newAccount(); const token = "gated-token-1"; @@ -68,13 +156,106 @@ describe("google provider claims are permanently fail-closed", () => { }); expect(lineage.state).toBe("tombstoned"); - // Verify never advertises a Google lineage as claimable. + // Verify's claimable signal is false for Google lineages while gated... expect( await evaluateClaimable({ provider: BillingProvider.googlePlay, keys: [token], }), ).toBe(false); + // ...and true again once the provider flag flips. + process.env.SUBSCRIPTION_CLAIM_GOOGLE_ENABLED = "true"; + expect( + await evaluateClaimable({ + provider: BillingProvider.googlePlay, + keys: [token], + }), + ).toBe(true); + }); + + test("apple claims are unaffected by the google gate", async () => { + installLocalTestingVerifier(); + __setClaimAppCheckVerifierForTests(() => Promise.resolve()); + const otx = "6000000000000001"; + const owner = await newAccount(); + await upsertFromVerify(appleInput(owner, otx)); + await deleteAccount({ accountId: owner, operationId: randomUUID() }); + const jws = await signTransaction(); + installAppleStatuses({ otx, status: 1, signedLatest: jws }); + const claimer = await newAccount(); + const res = await appleClaimRequest(claimer, jws); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(await getBalance(claimer)).toBe(PERIOD_CREDITS); + }); +}); + +describe("google restoration releases the exact funding event's escrow", () => { + test("claim during P2 releases P2's escrow, never P1's (lifetime startTime)", async () => { + process.env.SUBSCRIPTION_CLAIM_GOOGLE_ENABLED = "true"; + __setClaimAppCheckVerifierForTests(() => Promise.resolve()); + const owner = await newAccount(); + const token = "restore-token-1"; + const orderP1 = `GPA.${token}..0`; + const orderP2 = `GPA.${token}..1`; + await upsertFromVerify(playInput(owner, token, { playOrderId: orderP1 })); + await deleteAccount({ accountId: owner, operationId: randomUUID() }); + + // Renewal while tombstoned funds P2's escrow. + const renewal = await applyNotification( + playRenewal(token, orderP2, NEXT_PERIOD_END), + ); + expect(renewal.kind).toBe("tombstoned"); + + // The claim presents the current purchase: latestOrderId = P2's order, + // reported period start = lifetime startTime (P1 still "covers" it — + // the old window-covering selection would release P1's escrow). + setPlayApiFixtureForTests(() => + playPurchase({ latestOrderId: orderP2, expiry: NEXT_PERIOD_END }), + ); + const claimer = await newAccount(); + const res = await playClaimRequest(claimer, token); + expect(res.status, JSON.stringify(res.body)).toBe(200); + + // Exactly P2's allotment was released. + expect(await getBalance(claimer)).toBe(PERIOD_CREDITS); + const p1 = await prisma.lineagePeriodCustody.findFirstOrThrow({ + where: { providerPeriodKey: `play_order_${orderP1}` }, + }); + const p2 = await prisma.lineagePeriodCustody.findFirstOrThrow({ + where: { providerPeriodKey: `play_order_${orderP2}` }, + }); + expect(p2.state).toBe("held"); + expect(p2.ownerAccountId).toBe(claimer); + // P1's escrow was NOT released to the claimant (still ownerless). + expect(p1.ownerAccountId).toBeNull(); + expect(["escrow", "exhausted"]).toContain(p1.state); + }); +}); + +describe("keyless google claim fails closed", () => { + test("no latestOrderId -> 409 lineage_unresolved, parked in quarantine", async () => { + process.env.SUBSCRIPTION_CLAIM_GOOGLE_ENABLED = "true"; + __setClaimAppCheckVerifierForTests(() => Promise.resolve()); + const owner = await newAccount(); + const token = "keyless-claim-1"; + await upsertFromVerify(playInput(owner, token)); + await deleteAccount({ accountId: owner, operationId: randomUUID() }); + + setPlayApiFixtureForTests(() => playPurchase({ latestOrderId: null })); + const claimer = await newAccount(); + const res = await playClaimRequest(claimer, token); + expect(res.status).toBe(409); + expect((res.body as ClaimBody).reason).toBe("lineage_unresolved"); + const parked = await prisma.lineageQuarantine.findFirst({ + where: { token, reason: "missing_latest_order_id" }, + }); + expect(parked).not.toBeNull(); + expect(await getBalance(claimer)).toBe(0n); + // The tombstoned lineage was not restored. + const lineage = await prisma.subscriptionLineage.findFirstOrThrow({ + where: { provider: BillingProvider.googlePlay }, + }); + expect(lineage.state).toBe("tombstoned"); }); }); @@ -121,6 +302,130 @@ describe("live funding with a conflicting google alias", () => { }); }); +describe("tombstoned rotation with a conflicting alias", () => { + test("the event quarantines and never funds the tombstoned lineage", async () => { + // L1: tombstoned lineage rooted at Told (real deletion). + const owner = await newAccount(); + const tOld = "conflict-rot-told"; + const tNew = "conflict-rot-tnew"; + await upsertFromVerify(playInput(owner, tOld)); + await deleteAccount({ accountId: owner, operationId: randomUUID() }); + const l1 = await prisma.subscriptionLineage.findFirstOrThrow({ + where: { lineageKey: tOld }, + }); + // L2: a different lineage already owns the presented token as an alias. + const l2 = await prisma.subscriptionLineage.create({ + data: { provider: BillingProvider.googlePlay, lineageKey: "troot-2" }, + }); + await prisma.lineageTokenAlias.create({ + data: { token: tNew, lineageId: l2.id }, + }); + + const grantsBefore = await prisma.lineagePeriodGrant.count(); + const custodyBefore = await prisma.lineagePeriodCustody.count(); + const result = await applyNotification({ + ...playRenewal(tOld, "GPA.conflict..1", NEXT_PERIOD_END), + purchaseToken: tNew, + linkedPurchaseToken: tOld, + }); + // Acked as a counted no-op; the conflict is quarantined for the sweep. + expect(result.kind).toBe("tombstoned"); + const parked = await prisma.lineageQuarantine.findFirst({ + where: { token: tNew }, + }); + expect(parked?.reason).toBe("alias_conflict_between_lineages"); + // No funding effect landed on the tombstoned lineage. + expect(await prisma.lineagePeriodGrant.count()).toBe(grantsBefore); + expect(await prisma.lineagePeriodCustody.count()).toBe(custodyBefore); + // The existing alias was not silently repointed. + const alias = await prisma.lineageTokenAlias.findUniqueOrThrow({ + where: { token: tNew }, + }); + expect(alias.lineageId).toBe(l2.id); + expect(l1.state).toBe("tombstoned"); + }); +}); + +describe("activity veto via a real authenticated request", () => { + const probeApp = () => { + const app = express(); + app.use(pinoMiddleware); + app.use(json()); + app.get("/probe", authMiddleware, (_req, res) => { + res.json({ ok: true }); + }); + return app; + }; + + test("an authenticated act inside the stamp-throttle window still vetoes a pending transfer", async () => { + process.env.SUBSCRIPTION_CLAIM_LIVE_TRANSFER_ENABLED = "true"; + process.env.CLAIM_CONTEST_WINDOW_HOURS = "72"; + installLocalTestingVerifier(); + __setClaimAppCheckVerifierForTests(() => Promise.resolve()); + __setPendingTransferNotifierForTests(() => Promise.resolve()); + const owner = await newAccount(); + const claimer = await newAccount(); + const otx = "6000000000000001"; + await upsertFromVerify(appleInput(owner, otx)); + const jws = await signTransaction(); + installAppleStatuses({ otx, status: 1, signedLatest: jws }); + expect((await appleClaimRequest(claimer, jws)).status).toBe(202); + + // The bypass shape: the owner authenticated moments BEFORE the pending + // row (lastAuthAt recent, inside the 5-minute throttle window), then + // performs a real authenticated act AFTER it. A fire-and-forget + // throttled stamp would suppress the write and settlement would execute + // the theft. + const pendingRow = await prisma.subscriptionTransfer.findFirstOrThrow({ + where: { status: "pending" }, + }); + await prisma.account.update({ + where: { id: owner }, + data: { lastAuthAt: new Date(pendingRow.createdAt.getTime() - 30_000) }, + }); + + const probe = await request(probeApp()) + .get("/probe") + .set("X-Convos-AuthToken", await tokenFor(owner)); + expect(probe.status).toBe(200); + + // The stamp landed (awaited, DB clock) despite the throttle window. + const stamped = await prisma.account.findUniqueOrThrow({ + where: { id: owner }, + }); + expect(stamped.lastAuthAt?.getTime() ?? 0).toBeGreaterThan( + pendingRow.createdAt.getTime(), + ); + + await prisma.subscriptionTransfer.updateMany({ + where: { status: "pending" }, + data: { contestEndsAt: new Date(Date.now() - 1000) }, + }); + const settled = await settlePendingTransfers(); + expect(settled.cancelled).toBe(1); + expect(settled.committed).toBe(0); + const row = await prisma.subscription.findFirstOrThrow({ + where: { originalTransactionId: otx }, + }); + expect(row.accountId).toBe(owner); + }); + + test("without a pending transfer the stamp stays throttled", async () => { + const accountId = await newAccount(new Date(Date.now() - 30_000)); + const before = await prisma.account.findUniqueOrThrow({ + where: { id: accountId }, + }); + const probe = await request(probeApp()) + .get("/probe") + .set("X-Convos-AuthToken", await tokenFor(accountId)); + expect(probe.status).toBe(200); + const after = await prisma.account.findUniqueOrThrow({ + where: { id: accountId }, + }); + expect(after.lastAuthAt?.getTime()).toBe(before.lastAuthAt?.getTime()); + }); +}); + describe("voided purchases fail closed on unmatched orders", () => { test("keyless void: parked, nothing revoked", async () => { const owner = await newAccount(); @@ -281,6 +586,36 @@ describe("reconciliation sweep", () => { expect(await prisma.lineagePeriodGrant.count()).toBe(2); }); + test("post-transfer drift: a provider revocation after settlement is compensated once", async () => { + process.env.SUBSCRIPTION_CLAIM_LIVE_TRANSFER_ENABLED = "true"; + process.env.CLAIM_CONTEST_WINDOW_HOURS = "0"; + installLocalTestingVerifier(); + __setClaimAppCheckVerifierForTests(() => Promise.resolve()); + const owner = await newAccount(); + const claimer = await newAccount(); + const otx = "6000000000000001"; + await upsertFromVerify(appleInput(owner, otx)); + const jws = await signTransaction(); + installAppleStatuses({ otx, status: 1, signedLatest: jws }); + expect((await appleClaimRequest(claimer, jws)).status).toBe(200); + expect(await getBalance(claimer)).toBe(PERIOD_CREDITS); + + // The provider revokes AFTER the transfer committed; the webhook is + // lost. The drift pass re-checks recent transfers and compensates. + installAppleStatuses({ otx, status: 2, signedLatest: jws }); + const first = await runReclaimReconciliationSweep(); + expect(first.driftChecked).toBe(1); + expect(first.driftCompensated).toBe(1); + expect(await getBalance(claimer)).toBe(0n); + const custody = await prisma.lineagePeriodCustody.findFirstOrThrow({}); + expect(custody.state).toBe("invalidated"); + + // Idempotent: nothing further to claw. + const second = await runReclaimReconciliationSweep(); + expect(second.driftCompensated).toBe(0); + expect(await getBalance(claimer)).toBe(0n); + }); + test("keeps an order-resolved event parked until a subscription exists", async () => { const token = "sweep-orphan-1"; await prisma.lineageQuarantine.create({ diff --git a/tests/deletion/adversarial-round5.test.ts b/tests/deletion/adversarial-round5.test.ts index e91899bf..a7487302 100644 --- a/tests/deletion/adversarial-round5.test.ts +++ b/tests/deletion/adversarial-round5.test.ts @@ -4,12 +4,18 @@ import { BillingProvider } from "@prisma/client"; import express, { json } from "express"; import request from "supertest"; import { describe, expect, test, vi } from "vitest"; +import { __setAuthActivityStampFailureForTests } from "@/accounts/auth-activity"; import { deleteAccount } from "@/accounts/deletion/service"; -import { __setClaimAppCheckVerifierForTests } from "@/api/v2/accounts/handlers/subscription-claim"; +import { + __setClaimAppCheckVerifierForTests, + __setPendingTransferNotifierForTests, +} from "@/api/v2/accounts/handlers/subscription-claim"; import { googlePlayWebhookRouter } from "@/api/v2/subscriptions/google-play-webhook.router"; +import { authMiddleware } from "@/middleware/auth"; import { pinoMiddleware } from "@/middleware/pino"; import { getBalance } from "@/payments"; import { setAppleApiClientForTests } from "@/subscriptions/apple-server-api"; +import { settlePendingTransfers } from "@/subscriptions/claim"; import { setPlayApiFixtureForTests } from "@/subscriptions/google-play/play-api"; import { PlaySubscriptionState } from "@/subscriptions/google-play/status"; import { setPubsubVerifierForTests } from "@/subscriptions/google-play/verifier"; @@ -40,6 +46,7 @@ import { playPurchase, PRODUCT_ID, signTransaction as signReclaimTransaction, + tokenFor, } from "./reclaim-fixtures"; vi.mock("firebase-admin/app"); @@ -101,6 +108,36 @@ const createRestoredSubscription = async () => { return { owner, claimer, jws }; }; +const probeApp = () => { + const app = express(); + app.use(pinoMiddleware); + app.use(json()); + app.get("/probe", authMiddleware, (_req, res) => { + res.json({ ok: true }); + }); + return app; +}; + +/** Live 72h Apple claim: owner + claimer + one pending transfer row. */ +const createPendingTransfer = async () => { + process.env.SUBSCRIPTION_CLAIM_LIVE_TRANSFER_ENABLED = "true"; + process.env.CLAIM_CONTEST_WINDOW_HOURS = "72"; + installLocalTestingVerifier(); + __setClaimAppCheckVerifierForTests(() => Promise.resolve()); + __setPendingTransferNotifierForTests(() => Promise.resolve()); + const owner = await newAccount(); + const claimer = await newAccount(); + await upsertFromVerify(appleInput(owner)); + const jws = await signTransaction(); + installAppleStatuses({ status: 1, signedLatest: jws }); + const res = await appleClaimRequest(claimer, jws); + expect(res.status, JSON.stringify(res.body)).toBe(202); + const pendingRow = await prisma.subscriptionTransfer.findFirstOrThrow({ + where: { status: "pending" }, + }); + return { owner, claimer, jws, pendingRow }; +}; + /** Minimal live Apple lineage + subscription for cursor-only drift tests. */ const createDriftFixture = async ( accountId: string, @@ -130,6 +167,99 @@ const createDriftFixture = async ( return lineage.id; }; +describe("activity stamp fails closed", () => { + test("a stamp DB failure during a contest window fails the request; the retry still vetoes", async () => { + const { owner, pendingRow } = await createPendingTransfer(); + // The owner authenticated an hour ago (outside the throttle window), so + // the probe below must attempt the stamp write - which we make fail. + const before = await prisma.account.findUniqueOrThrow({ + where: { id: owner }, + }); + __setAuthActivityStampFailureForTests(new Error("transient stamp failure")); + + const failed = await request(probeApp()) + .get("/probe") + .set("X-Convos-AuthToken", await tokenFor(owner)); + // Fail closed: the act must not succeed unstamped - a swallowed error + // here would let settlement read the stale timestamp and execute the + // transfer despite real owner activity. + expect(failed.status).toBe(500); + const unchanged = await prisma.account.findUniqueOrThrow({ + where: { id: owner }, + }); + expect(unchanged.lastAuthAt?.getTime()).toBe(before.lastAuthAt?.getTime()); + __setAuthActivityStampFailureForTests(null); + + // The owner's retry (the DB recovered) stamps and preserves the veto. + const retried = await request(probeApp()) + .get("/probe") + .set("X-Convos-AuthToken", await tokenFor(owner)); + expect(retried.status).toBe(200); + const stamped = await prisma.account.findUniqueOrThrow({ + where: { id: owner }, + }); + expect(stamped.lastAuthAt?.getTime() ?? 0).toBeGreaterThan( + pendingRow.createdAt.getTime(), + ); + + await prisma.subscriptionTransfer.updateMany({ + where: { status: "pending" }, + data: { contestEndsAt: new Date(Date.now() - 1000) }, + }); + const settled = await settlePendingTransfers(); + expect(settled.cancelled).toBe(1); + expect(settled.committed).toBe(0); + const row = await prisma.subscription.findFirstOrThrow({ + where: { originalTransactionId: OTX }, + }); + expect(row.accountId).toBe(owner); + }); +}); + +describe("drift reconciliation sweeps 72h-contested settlements", () => { + test("a default contest-window transfer settles, then drifts, and IS swept", async () => { + const { owner, claimer, pendingRow } = await createPendingTransfer(); + // Age the pending row to the real 72h shape: created 73 hours ago, + // window just ended, owner silent since before the claim (ghost). + const createdAt = new Date(Date.now() - 73 * HOUR_MS); + await prisma.subscriptionTransfer.update({ + where: { id: pendingRow.id }, + data: { createdAt, contestEndsAt: new Date(Date.now() - 1000) }, + }); + await prisma.account.update({ + where: { id: owner }, + data: { lastAuthAt: new Date(Date.now() - 80 * HOUR_MS) }, + }); + const settled = await settlePendingTransfers(); + expect(settled.committed).toBe(1); + expect(await getBalance(claimer)).toBe(PERIOD_CREDITS); + + const journal = await prisma.subscriptionTransfer.findUniqueOrThrow({ + where: { id: pendingRow.id }, + }); + expect(journal.status).toBe("committed"); + expect(journal.committedAt).not.toBeNull(); + // The exact shape a createdAt-window selection would miss: by settlement + // time the journal's createdAt is 73 hours old. + expect(journal.createdAt.getTime()).toBeLessThan(Date.now() - 72 * HOUR_MS); + + // The provider revokes after settlement; the webhook is lost. + installAppleStatuses({ status: 2, signedLatest: "irrelevant" }); + const counts = await runReclaimReconciliationSweep(); + expect(counts.driftChecked).toBe(1); + expect(counts.driftCompensated).toBe(1); + expect(await getBalance(claimer)).toBe(0n); + const custody = await prisma.lineagePeriodCustody.findFirstOrThrow({}); + expect(custody.state).toBe("invalidated"); + // The Subscription row carries the provider-derived terminal state. + const row = await prisma.subscription.findFirstOrThrow({ + where: { originalTransactionId: OTX }, + }); + expect(row.status).toBe(SubscriptionStatus.expired); + expect(row.willRenew).toBe(false); + }); +}); + describe("durable drift scheduling and rolling safety", () => { test(">50 equal-millisecond schedules are each swept once in one tick", async () => { const owner = await newAccount(); diff --git a/tests/deletion/adversarial.test.ts b/tests/deletion/adversarial.test.ts index 07acee79..d077b4d1 100644 --- a/tests/deletion/adversarial.test.ts +++ b/tests/deletion/adversarial.test.ts @@ -3,13 +3,16 @@ import { BillingProvider } from "@prisma/client"; import { describe, expect, test, vi } from "vitest"; import { deleteAccount } from "@/accounts/deletion/service"; import { __setClaimAppCheckVerifierForTests } from "@/api/v2/accounts/handlers/subscription-claim"; -import { getBalance } from "@/payments"; +import { consume, getBalance } from "@/payments"; import { LineageUnresolvedError, resolveOrCreateGoogleLineage, } from "@/subscriptions/lineage"; import { + applyNotification, compensateVoidedPurchase, + SUBSCRIPTION_TIER_PLUS, + SubscriptionStatus, upsertFromVerify, type GooglePlayVerifyInput, } from "@/subscriptions/repository"; @@ -17,12 +20,15 @@ import { prisma } from "@/utils/prisma"; import { appleClaimRequest, appleInput, + DAY_MS, installAppleStatuses as installAppleStatusesFixture, installLocalTestingVerifier, installReclaimHooks, playInput as makePlayInput, newAccount, PERIOD_CREDITS, + PERIOD_END, + PRODUCT_ID, signTransaction as signReclaimTransaction, } from "./reclaim-fixtures"; @@ -76,6 +82,204 @@ describe("App Check hardening", () => { }); }); +describe("replay against two targets", () => { + test("same JWS claimed for B and C: exactly one transfer commits", async () => { + process.env.SUBSCRIPTION_CLAIM_LIVE_TRANSFER_ENABLED = "true"; + process.env.CLAIM_CONTEST_WINDOW_HOURS = "0"; + installLocalTestingVerifier(); + __setClaimAppCheckVerifierForTests(() => Promise.resolve()); + const owner = await newAccount(); + const otx = "8000000000000001"; + await upsertFromVerify(appleInput(owner, otx)); + const jws = await signTransaction(); + installAppleStatuses({ otx, signedLatest: jws }); + + const b = await newAccount(); + const c = await newAccount(); + const [resB, resC] = await Promise.all([ + claimRequest(b, jws), + claimRequest(c, jws), + ]); + + const statuses = [resB.status, resC.status].sort(); + // One 200 (winner), one 409 (cooldown after the winner's transfer). + expect(statuses).toEqual([200, 409]); + const row = await prisma.subscription.findFirst({ + where: { originalTransactionId: otx }, + }); + expect([b, c]).toContain(row?.accountId); + // Exactly one committed transfer; total credits conserved (one period). + expect( + await prisma.subscriptionTransfer.count({ + where: { kind: "transfer", status: "committed" }, + }), + ).toBe(1); + const balances = await Promise.all([ + getBalance(owner), + getBalance(b), + getBalance(c), + ]); + expect(balances.reduce((a, x) => a + x, 0n)).toBe(PERIOD_CREDITS); + }); +}); + +describe("conservation under spend", () => { + test("undo after attacker spend returns only what remains", async () => { + process.env.SUBSCRIPTION_CLAIM_LIVE_TRANSFER_ENABLED = "true"; + process.env.CLAIM_CONTEST_WINDOW_HOURS = "0"; + installLocalTestingVerifier(); + __setClaimAppCheckVerifierForTests(() => Promise.resolve()); + const owner = await newAccount(); + const attacker = await newAccount(); + const otx = "8000000000000001"; + await upsertFromVerify(appleInput(owner, otx)); + const jws = await signTransaction(); + installAppleStatuses({ otx, signedLatest: jws }); + + expect((await claimRequest(attacker, jws)).status).toBe(200); + // Attacker burns 1000 credits (test env: 1000 credits = $1 => 500_000 + // usd micros at 2.0 markup). + await consume({ + accountId: attacker, + usdCostMicros: 500_000n, + idempotencyKey: `burn_${attacker}`, + requestId: "burn", + }); + expect(await getBalance(attacker)).toBe(PERIOD_CREDITS - 1000n); + + // Victim's undo recovers exactly the unspent remainder. + expect((await claimRequest(owner, jws)).status).toBe(200); + expect(await getBalance(owner)).toBe(PERIOD_CREDITS - 1000n); + expect(await getBalance(attacker)).toBe(0n); + }); + + test("undo is one-shot: a consumed transfer rejects with undo_consumed", async () => { + process.env.SUBSCRIPTION_CLAIM_LIVE_TRANSFER_ENABLED = "true"; + process.env.CLAIM_CONTEST_WINDOW_HOURS = "0"; + installLocalTestingVerifier(); + __setClaimAppCheckVerifierForTests(() => Promise.resolve()); + const owner = await newAccount(); + const claimer = await newAccount(); + const otx = "8000000000000001"; + await upsertFromVerify(appleInput(owner, otx)); + const jws = await signTransaction(); + installAppleStatuses({ otx, signedLatest: jws }); + expect((await claimRequest(claimer, jws)).status).toBe(200); + + // Mark the transfer's undo as already consumed (a raced undo). + await prisma.subscriptionTransfer.updateMany({ + where: { kind: "transfer", status: "committed" }, + data: { undoneByTransferId: randomUUID() }, + }); + const res = await claimRequest(owner, jws); + expect(res.status).toBe(409); + expect((res.body as ClaimBody).reason).toBe("undo_consumed"); + }); +}); + +describe("post-transfer provider events", () => { + test("refund after A->B compensates B (custody), not A", async () => { + process.env.SUBSCRIPTION_CLAIM_LIVE_TRANSFER_ENABLED = "true"; + process.env.CLAIM_CONTEST_WINDOW_HOURS = "0"; + installLocalTestingVerifier(); + __setClaimAppCheckVerifierForTests(() => Promise.resolve()); + const owner = await newAccount(); + const claimer = await newAccount(); + const otx = "8000000000000001"; + await upsertFromVerify(appleInput(owner, otx)); + const jws = await signTransaction(); + installAppleStatuses({ otx, signedLatest: jws }); + expect((await claimRequest(claimer, jws)).status).toBe(200); + expect(await getBalance(claimer)).toBe(PERIOD_CREDITS); + + const result = await applyNotification({ + provider: BillingProvider.apple, + originalTransactionId: otx, + transactionId: `tx-refund-${otx}`, + notificationUUID: randomUUID(), + notificationType: "REVOKE", + signedPayload: "jws", + update: { + status: SubscriptionStatus.revoked, + willRenew: false, + cancelledAt: new Date(), + currentPeriodEnd: PERIOD_END, + }, + }); + expect(result.kind).toBe("applied"); + // The clawback landed on the current holder. + expect(await getBalance(claimer)).toBe(0n); + expect(await getBalance(owner)).toBe(0n); + }); + + test("renewal while tombstoned funds escrow; restoration releases it once", async () => { + installLocalTestingVerifier(); + __setClaimAppCheckVerifierForTests(() => Promise.resolve()); + const owner = await newAccount(); + const otx = "8000000000000001"; + await upsertFromVerify(appleInput(owner, otx)); + await deleteAccount({ accountId: owner, operationId: randomUUID() }); + + // Renewal arrives for the deleted owner's subscription: escrow-funded. + const nextStart = PERIOD_END; + const nextEnd = new Date(PERIOD_END.getTime() + 30 * DAY_MS); + const result = await applyNotification({ + provider: BillingProvider.apple, + originalTransactionId: otx, + transactionId: "renewal-tx-1", + notificationUUID: randomUUID(), + notificationType: "DID_RENEW", + signedPayload: "jws", + update: { + status: SubscriptionStatus.active, + productId: PRODUCT_ID, + tier: SUBSCRIPTION_TIER_PLUS, + currentPeriodStart: nextStart, + currentPeriodEnd: nextEnd, + willRenew: true, + }, + }); + expect(result.kind).toBe("tombstoned"); + const escrows = await prisma.lineagePeriodCustody.findMany({ + where: { state: "escrow" }, + }); + // The deletion escrow (current period) plus the renewal escrow. + expect(escrows.length).toBe(2); + expect(await prisma.lineagePeriodGrant.count()).toBe(2); + + // The stated Apple refund of that renewal arrives while still + // tombstoned: the renewal's escrow is invalidated (cap 0) so no later + // restoration can release refunded value; nothing moves (the value + // already left a wallet at deletion time). + const refund = await applyNotification({ + provider: BillingProvider.apple, + originalTransactionId: otx, + transactionId: "renewal-tx-1", + notificationUUID: randomUUID(), + notificationType: "REVOKE", + signedPayload: "jws", + update: { + status: SubscriptionStatus.revoked, + willRenew: false, + cancelledAt: new Date(), + currentPeriodEnd: nextEnd, + }, + }); + expect(refund.kind).toBe("tombstoned"); + const renewalEscrow = await prisma.lineagePeriodCustody.findFirst({ + where: { providerPeriodKey: "apple_txn_renewal-tx-1" }, + }); + expect(renewalEscrow?.state).toBe("invalidated"); + expect(renewalEscrow?.remainderCap).toBe(0n); + // Late-event isolation: the deletion escrow for the earlier period is + // untouched, and the registry still records exactly one row per event. + expect( + await prisma.lineagePeriodCustody.count({ where: { state: "escrow" } }), + ).toBe(1); + expect(await prisma.lineagePeriodGrant.count()).toBe(2); + }); +}); + describe("tombstoned provider events", () => { test("voided purchase while tombstoned invalidates escrow without a wallet move", async () => { const owner = await newAccount(); diff --git a/tests/deletion/barrier-mint.test.ts b/tests/deletion/barrier-mint.test.ts index 98e0e86e..6142edd3 100644 --- a/tests/deletion/barrier-mint.test.ts +++ b/tests/deletion/barrier-mint.test.ts @@ -103,7 +103,8 @@ describe("deletion barrier at token mint", () => { expect(await isIdentityBarred("SIWE", lower)).toBe(true); }); - test("unbarred mint succeeds", async () => { + test("unbarred mint succeeds and stamps lastAuthAt", async () => { + const before = new Date(); const { res, address } = await mintWithSiwe("dev-live"); expect(res.status).toBe(200); @@ -111,6 +112,13 @@ describe("deletion barrier at token mint", () => { where: { externalKey: address }, }); expect(method).not.toBeNull(); + const account = await prisma.account.findUnique({ + where: { id: method?.accountId }, + }); + expect(account?.lastAuthAt).not.toBeNull(); + expect(account?.lastAuthAt?.getTime()).toBeGreaterThanOrEqual( + before.getTime() - 1000, + ); }); test("barIdentityWithTx is idempotent", async () => { diff --git a/tests/deletion/claim.test.ts b/tests/deletion/claim.test.ts index 5463d964..6c4346e7 100644 --- a/tests/deletion/claim.test.ts +++ b/tests/deletion/claim.test.ts @@ -3,8 +3,12 @@ import { BillingProvider } from "@prisma/client"; import request from "supertest"; import { describe, expect, test, vi } from "vitest"; import { deleteAccount } from "@/accounts/deletion/service"; -import { __setClaimAppCheckVerifierForTests } from "@/api/v2/accounts/handlers/subscription-claim"; -import { getBalance } from "@/payments"; +import { + __setClaimAppCheckVerifierForTests, + __setPendingTransferNotifierForTests, +} from "@/api/v2/accounts/handlers/subscription-claim"; +import { getBalance, grant } from "@/payments"; +import { settlePendingTransfers } from "@/subscriptions/claim"; import { upsertFromVerify } from "@/subscriptions/repository"; import { prisma } from "@/utils/prisma"; import { @@ -337,4 +341,194 @@ describe("live transfer tier", () => { }); expect(row?.accountId).toBe(owner); }); + + test("instant transfer (window 0) conserves credits exactly; promo stays put", async () => { + process.env.SUBSCRIPTION_CLAIM_LIVE_TRANSFER_ENABLED = "true"; + process.env.CLAIM_CONTEST_WINDOW_HOURS = "0"; + const otx = "9000000000000001"; + const owner = await setupLiveOwner(otx); + // Commingle promo credits into the owner wallet. + await grant({ + accountId: owner, + credits: 1000, + kind: "manual", + idempotencyKey: `promo_${owner}`, + note: "promo", + }); + const claimer = await newAccount(); + passAppCheck(); + const jws = await signTransaction({ + transactionId: otx, + originalTransactionId: otx, + }); + installAppleStatuses({ otx, status: 1, signedLatest: jws }); + + const ownerBefore = await getBalance(owner); + const res = await claimRequest(claimer, jws); + expect(res.status).toBe(200); + + const ownerAfter = await getBalance(owner); + const claimerAfter = await getBalance(claimer); + // Conservation: what left the owner landed on the claimer. + expect(ownerBefore - ownerAfter).toBe(claimerAfter); + // The move is the subscription remainder only — promo credits survive. + expect(claimerAfter).toBe(PERIOD_CREDITS); + expect(ownerAfter).toBe(1000n); + + const row = await prisma.subscription.findFirst({ + where: { originalTransactionId: otx }, + }); + expect(row?.accountId).toBe(claimer); + }); + + test("fractional contest window (0.5) never means instant transfer: falls back to 72h pending", async () => { + process.env.SUBSCRIPTION_CLAIM_LIVE_TRANSFER_ENABLED = "true"; + // parseInt would truncate this to 0 (instant transfer, the outcome that + // requires explicit security acceptance); the parser must reject it and + // keep the 72h default. + process.env.CLAIM_CONTEST_WINDOW_HOURS = "0.5"; + const otx = "9000000000000001"; + const owner = await setupLiveOwner(otx); + const claimer = await newAccount(); + passAppCheck(); + __setPendingTransferNotifierForTests(() => Promise.resolve()); + const jws = await signTransaction({ + transactionId: otx, + originalTransactionId: otx, + }); + installAppleStatuses({ otx, status: 1, signedLatest: jws }); + + const res = await claimRequest(claimer, jws); + expect(res.status).toBe(202); + expect(body(res).status).toBe("pending"); + // The fallback window is 72h, not a truncated zero. + const contestEndsAt = new Date(body(res).contestEndsAt ?? "").getTime(); + expect(contestEndsAt).toBeGreaterThan(Date.now() + 71 * 60 * 60 * 1000); + // Ownership untouched while pending. + const row = await prisma.subscription.findFirst({ + where: { originalTransactionId: otx }, + }); + expect(row?.accountId).toBe(owner); + }); + + test("second transfer inside the lineage cooldown: 409 cooldown; previous-owner undo is exempt and one-shot", async () => { + process.env.SUBSCRIPTION_CLAIM_LIVE_TRANSFER_ENABLED = "true"; + process.env.CLAIM_CONTEST_WINDOW_HOURS = "0"; + const otx = "9000000000000001"; + const owner = await setupLiveOwner(otx); + const claimer = await newAccount(); + const third = await newAccount(); + passAppCheck(); + const jws = await signTransaction({ + transactionId: otx, + originalTransactionId: otx, + }); + installAppleStatuses({ otx, status: 1, signedLatest: jws }); + + expect((await claimRequest(claimer, jws)).status).toBe(200); + + // A third account inside the cooldown: rejected. + const thirdRes = await claimRequest(third, jws); + expect(thirdRes.status).toBe(409); + expect(body(thirdRes).reason).toBe("cooldown"); + + // The previous owner's undo is exempt from cooldown and succeeds. + const undoRes = await claimRequest(owner, jws); + expect(undoRes.status).toBe(200); + const row = await prisma.subscription.findFirst({ + where: { originalTransactionId: otx }, + }); + expect(row?.accountId).toBe(owner); + + // Post-undo freeze: the next automated transfer is rejected. + const afterUndo = await claimRequest(claimer, jws); + expect(afterUndo.status).toBe(409); + expect(body(afterUndo).reason).toBe("transfer_frozen"); + }); + + test("contest window: 202 pending, push notifier fires, settlement executes after the window", async () => { + process.env.SUBSCRIPTION_CLAIM_LIVE_TRANSFER_ENABLED = "true"; + process.env.CLAIM_CONTEST_WINDOW_HOURS = "72"; + const otx = "9000000000000001"; + const owner = await setupLiveOwner(otx); + const claimer = await newAccount(); + passAppCheck(); + const notified: string[] = []; + __setPendingTransferNotifierForTests(({ oldAccountId }) => { + notified.push(oldAccountId); + return Promise.resolve(); + }); + const jws = await signTransaction({ + transactionId: otx, + originalTransactionId: otx, + }); + installAppleStatuses({ otx, status: 1, signedLatest: jws }); + + const res = await claimRequest(claimer, jws); + expect(res.status).toBe(202); + expect(body(res).status).toBe("pending"); + expect(new Date(body(res).contestEndsAt ?? "").getTime()).toBeGreaterThan( + Date.now(), + ); + expect(notified).toEqual([owner]); + + // A second claim while pending: 409 pending_contest. + const other = await newAccount(); + const during = await claimRequest(other, jws); + expect(during.status).toBe(409); + expect(body(during).reason).toBe("pending_contest"); + + // Window elapses (backdate) -> settlement executes the transfer. + await prisma.subscriptionTransfer.updateMany({ + where: { status: "pending" }, + data: { contestEndsAt: new Date(Date.now() - 1000) }, + }); + const settled = await settlePendingTransfers(); + expect(settled.committed).toBe(1); + const row = await prisma.subscription.findFirst({ + where: { originalTransactionId: otx }, + }); + expect(row?.accountId).toBe(claimer); + }); + + test("contest veto: authenticated old-account act after the pending row cancels it", async () => { + process.env.SUBSCRIPTION_CLAIM_LIVE_TRANSFER_ENABLED = "true"; + process.env.CLAIM_CONTEST_WINDOW_HOURS = "72"; + const otx = "9000000000000001"; + const owner = await setupLiveOwner(otx); + const claimer = await newAccount(); + passAppCheck(); + __setPendingTransferNotifierForTests(() => Promise.resolve()); + const jws = await signTransaction({ + transactionId: otx, + originalTransactionId: otx, + }); + installAppleStatuses({ otx, status: 1, signedLatest: jws }); + + expect((await claimRequest(claimer, jws)).status).toBe(202); + + // Old account authenticates during the window (lastAuthAt stamp). + // Anchored to the pending row's DB timestamp: the container's DB clock + // can sit ahead of the JS clock, so "new Date()" is not reliably after + // journal.createdAt. + const pendingRow = await prisma.subscriptionTransfer.findFirstOrThrow({ + where: { status: "pending" }, + }); + await prisma.account.update({ + where: { id: owner }, + data: { lastAuthAt: new Date(pendingRow.createdAt.getTime() + 1000) }, + }); + await prisma.subscriptionTransfer.updateMany({ + where: { status: "pending" }, + data: { contestEndsAt: new Date(Date.now() - 1000) }, + }); + + const settled = await settlePendingTransfers(); + expect(settled.cancelled).toBe(1); + expect(settled.committed).toBe(0); + const row = await prisma.subscription.findFirst({ + where: { originalTransactionId: otx }, + }); + expect(row?.accountId).toBe(owner); + }); }); diff --git a/tests/deletion/delete-account.test.ts b/tests/deletion/delete-account.test.ts index cb0be37b..e2e1af76 100644 --- a/tests/deletion/delete-account.test.ts +++ b/tests/deletion/delete-account.test.ts @@ -334,6 +334,9 @@ describe("DELETE /v2/accounts/me", () => { }); expect(record?.status).toBe("purging"); expect(record?.accountRef).toBe(hashAccountRef(accountId)); + // Pre-escrow wallet snapshot: 100 manual + 2500 subscription credits + // (captured before the 2500 escrow debit and the wallet teardown). + expect(record?.finalBalanceCredits).toBe(2600n); const tasks = await prisma.deletionTask.findMany({ where: { operationId }, @@ -390,6 +393,30 @@ describe("DELETE /v2/accounts/me", () => { expect(deletionAudit?.reason).toContain(hashAccountRef(accountId)); }); + test("records a 0 balance snapshot for an account with no wallet", async () => { + const address = `0x${randomUUID().replaceAll("-", "").padEnd(40, "b").slice(0, 40)}`; + const account = await prisma.account.create({ + data: { + authMethods: { create: { type: "SIWE", externalKey: address } }, + }, + }); + const operationId = randomUUID(); + const token = await tokenFor(account.id); + + const res = await request(makeApp()) + .delete("/api/v2/accounts/me") + .set("X-Convos-AuthToken", token) + .send({ operationId }); + expect(res.status).toBe(200); + + // Never had a UserCredits row: the snapshot reads 0 (getBalance + // semantics), not null — null is reserved for pre-field records. + const record = await prisma.deletionRecord.findUnique({ + where: { operationId }, + }); + expect(record?.finalBalanceCredits).toBe(0n); + }); + test("503 fail-closed when the env gate is off, unset, or garbage", async () => { const { accountId } = await populateAccount(); const token = await tokenFor(accountId); @@ -427,14 +454,21 @@ describe("DELETE /v2/accounts/me", () => { // The two replay tests carry the response body and durable DB state in // their assertion messages: a rare flake was once observed here and a // bare status assertion would discard the actual failure body. + // BigInt-safe stringify: DeletionRecord.finalBalanceCredits is a BigInt, + // which plain JSON.stringify refuses to serialize. + const jsonish = (value: unknown): string => + JSON.stringify(value, (_key, v: unknown) => + typeof v === "bigint" ? v.toString() : v, + ); + const replayDiagnostics = async ( label: string, res: request.Response, ): Promise => { const records = await prisma.deletionRecord.findMany(); - return `${label}: status=${res.status} body=${JSON.stringify( + return `${label}: status=${res.status} body=${jsonish( res.body, - )} deletionRecords=${JSON.stringify(records)}`; + )} deletionRecords=${jsonish(records)}`; }; test("replay with the same operationId returns the identical stored record", async () => { diff --git a/tests/deletion/escrow-order-guard.test.ts b/tests/deletion/escrow-order-guard.test.ts new file mode 100644 index 00000000..c50620ee --- /dev/null +++ b/tests/deletion/escrow-order-guard.test.ts @@ -0,0 +1,129 @@ +import { randomUUID } from "node:crypto"; +import { BillingProvider } from "@prisma/client"; +import { afterEach, describe, expect, test } from "vitest"; +import { grant } from "@/payments"; +import { deleteWalletForAccountWithTx } from "@/payments/ledger"; +import { + CUSTODY_STATE_ESCROW, + CUSTODY_STATE_HELD, + escrowCustody, + EscrowWalletMissingError, + findCustodyCovering, +} from "@/subscriptions/custody"; +import { lockLineage } from "@/subscriptions/lineage"; +import { prisma } from "@/utils/prisma"; + +/** + * The deletion teardown must settle escrow BEFORE deleteWalletForAccountWithTx. + * Run the other way round, computeMoveAmount's balance lock would silently + * upsert a fresh zero wallet: escrow conserves 0 and the recreated + * UserCredits row breaks the Account delete on its RESTRICT FK. The guard in + * escrowCustody turns that silent mis-ordering into a loud failure. + */ + +const DAY_MS = 24 * 60 * 60 * 1000; + +const wipe = async () => { + await prisma.subscriptionTransfer.deleteMany(); + await prisma.lineagePeriodCustody.deleteMany(); + await prisma.subscriptionLineage.deleteMany(); + await prisma.creditLedger.deleteMany(); + await prisma.userCredits.deleteMany(); + await prisma.account.deleteMany({ + where: { id: { not: "48a05ef4-4a71-57a0-957f-a3d410992b31" } }, + }); +}; + +afterEach(wipe); + +/** Account with a 500-credit wallet holding a 2500-cap custody period. */ +const buildHeldCustody = async () => { + const account = await prisma.account.create({ data: {} }); + await grant({ + accountId: account.id, + credits: 500, + kind: "manual", + idempotencyKey: `test_grant_${account.id}`, + note: "escrow-order fixture", + }); + const lineage = await prisma.subscriptionLineage.create({ + data: { + provider: BillingProvider.apple, + lineageKey: `otx-${account.id.slice(0, 8)}`, + }, + }); + await prisma.lineagePeriodCustody.create({ + data: { + lineageId: lineage.id, + providerPeriodKey: `period-${randomUUID().slice(0, 8)}`, + ownerAccountId: account.id, + remainderCap: 2500n, + custodyStartedAt: new Date(), + periodStart: new Date(Date.now() - DAY_MS), + periodEnd: new Date(Date.now() + DAY_MS), + state: CUSTODY_STATE_HELD, + }, + }); + return { accountId: account.id, lineageId: lineage.id }; +}; + +describe("escrow-before-wallet-teardown guard", () => { + test("escrow after the wallet teardown fails loudly and rolls back", async () => { + const { accountId, lineageId } = await buildHeldCustody(); + + await expect( + prisma.$transaction(async (tx) => { + const ctx = await lockLineage(tx, lineageId); + // WRONG order: wallet teardown before escrow settlement. + await deleteWalletForAccountWithTx(tx, accountId); + const custody = await findCustodyCovering(tx, ctx, new Date(), [ + CUSTODY_STATE_HELD, + ]); + if (!custody) throw new Error("fixture: custody row missing"); + await escrowCustody(tx, ctx, { custody, journalId: randomUUID() }); + }), + ).rejects.toBeInstanceOf(EscrowWalletMissingError); + + // The transaction rolled back: the wallet survives with its balance, + // custody stays held — nothing was silently conserved as zero and no + // ghost zero-balance UserCredits row was upserted. + const wallet = await prisma.userCredits.findUnique({ + where: { accountId }, + }); + expect(wallet?.balance).toBe(500n); + const custody = await prisma.lineagePeriodCustody.findFirst({ + where: { lineageId }, + }); + expect(custody?.state).toBe(CUSTODY_STATE_HELD); + expect(custody?.remainderCap).toBe(2500n); + expect(custody?.ownerAccountId).toBe(accountId); + }); + + test("the correct order (escrow, then wallet teardown) settles normally", async () => { + const { accountId, lineageId } = await buildHeldCustody(); + + const escrowed = await prisma.$transaction(async (tx) => { + const ctx = await lockLineage(tx, lineageId); + const custody = await findCustodyCovering(tx, ctx, new Date(), [ + CUSTODY_STATE_HELD, + ]); + if (!custody) throw new Error("fixture: custody row missing"); + const amount = await escrowCustody(tx, ctx, { + custody, + journalId: randomUUID(), + }); + await deleteWalletForAccountWithTx(tx, accountId); + return amount; + }); + + // min(balance 500, cap 2500) = 500 conserved into escrow. + expect(escrowed).toBe(500n); + const custody = await prisma.lineagePeriodCustody.findFirst({ + where: { lineageId }, + }); + expect(custody?.state).toBe(CUSTODY_STATE_ESCROW); + expect(custody?.remainderCap).toBe(500n); + expect(custody?.ownerAccountId).toBeNull(); + expect(await prisma.userCredits.count({ where: { accountId } })).toBe(0); + }); +}); diff --git a/tests/deletion/notification-purge-already-absent.test.ts b/tests/deletion/notification-purge-already-absent.test.ts new file mode 100644 index 00000000..e734ae1e --- /dev/null +++ b/tests/deletion/notification-purge-already-absent.test.ts @@ -0,0 +1,110 @@ +import { randomUUID } from "node:crypto"; +import { Code, ConnectError } from "@connectrpc/connect"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { __setDeletionNotificationClientForTests } from "@/accounts/deletion/executors"; +import { + completeDeletionRecords, + drainDeletionTasks, +} from "@/accounts/deletion/outbox"; +import { prisma } from "@/utils/prisma"; + +/** + * The notification-installation purge must classify NotFound/Unimplemented + * (the notification server answering "already gone" — a bare HTTP 404 maps + * to `unimplemented` in the Connect protocol) as SUCCESS, so the task and + * its DeletionRecord complete instead of retrying to terminal failure. + * Genuine transient errors keep their retry semantics. + */ + +const stubClient = (error: ConnectError) => { + const deleteInstallation = vi.fn(() => Promise.reject(error)); + __setDeletionNotificationClientForTests({ deleteInstallation }); + return deleteInstallation; +}; + +const newRecordWithInstallationTask = async () => { + const operationId = randomUUID(); + await prisma.deletionRecord.create({ + data: { operationId, accountRef: `ref-${operationId.slice(0, 8)}` }, + }); + await prisma.deletionTask.create({ + data: { + operationId, + kind: "notification_installation", + // No ClientIdentifier row exists for this id, so the mutation fence + // sees the expected "absent" local state and calls the remote delete. + payload: { installationId: randomUUID() }, + }, + }); + return operationId; +}; + +afterEach(async () => { + __setDeletionNotificationClientForTests(null); + await prisma.deletionTask.deleteMany(); + await prisma.deletionRecord.deleteMany(); +}); + +describe("notification purge with an already-absent installation", () => { + test("NotFound completes the task and the record", async () => { + const operationId = await newRecordWithInstallationTask(); + const deleteInstallation = stubClient( + new ConnectError("installation not found", Code.NotFound), + ); + + await expect(drainDeletionTasks()).resolves.toEqual({ + done: 1, + retried: 0, + failed: 0, + }); + expect(deleteInstallation).toHaveBeenCalledTimes(1); + + await expect(completeDeletionRecords()).resolves.toBe(1); + const record = await prisma.deletionRecord.findUnique({ + where: { operationId }, + }); + expect(record?.status).toBe("completed"); + expect(record?.completedAt).not.toBeNull(); + }); + + test("a bare HTTP 404 (unimplemented) is already-gone, not a retry", async () => { + const operationId = await newRecordWithInstallationTask(); + // Live shape: ConnectError "[unimplemented] HTTP 404" from a + // notification server that no longer serves the route. + stubClient(new ConnectError("HTTP 404", Code.Unimplemented)); + + await expect(drainDeletionTasks()).resolves.toEqual({ + done: 1, + retried: 0, + failed: 0, + }); + await expect(completeDeletionRecords()).resolves.toBe(1); + const record = await prisma.deletionRecord.findUnique({ + where: { operationId }, + }); + expect(record?.status).toBe("completed"); + }); + + test("a transient failure still retries and the record stays purging", async () => { + const operationId = await newRecordWithInstallationTask(); + stubClient(new ConnectError("HTTP 503", Code.Unavailable)); + + await expect(drainDeletionTasks()).resolves.toEqual({ + done: 0, + retried: 1, + failed: 0, + }); + const task = await prisma.deletionTask.findFirst({ + where: { operationId }, + }); + expect(task).toMatchObject({ status: "pending", attempts: 1 }); + expect(task?.lastError).toContain("HTTP 503"); + expect(task?.nextAttemptAt.getTime()).toBeGreaterThan(Date.now()); + + await expect(completeDeletionRecords()).resolves.toBe(0); + const record = await prisma.deletionRecord.findUnique({ + where: { operationId }, + }); + expect(record?.status).toBe("purging"); + }); +}); diff --git a/tests/deletion/reclaim-fixtures.ts b/tests/deletion/reclaim-fixtures.ts index 7b7515e8..1e45cba4 100644 --- a/tests/deletion/reclaim-fixtures.ts +++ b/tests/deletion/reclaim-fixtures.ts @@ -8,8 +8,10 @@ import express, { json } from "express"; import { importPKCS8, SignJWT } from "jose"; import request from "supertest"; import { afterAll, afterEach, beforeAll } from "vitest"; +import { __setAuthActivityStampFailureForTests } from "@/accounts/auth-activity"; import { __setClaimAppCheckVerifierForTests, + __setPendingTransferNotifierForTests, claimAppCheckMiddleware, subscriptionClaimHandler, } from "@/api/v2/accounts/handlers/subscription-claim"; @@ -20,6 +22,7 @@ import { resetAppleApiClientForTests, setAppleApiClientForTests, } from "@/subscriptions/apple-server-api"; +import { __setSettlementEntitlementCheckerForTests } from "@/subscriptions/claim"; import { resetPlayApiClientForTests, setPlayApiFixtureForTests, @@ -273,6 +276,12 @@ export const playClaimRequest = async ( export const wipeReclaimState = async () => { __setClaimAppCheckVerifierForTests(null); __setClaimCeilingIncrementForTests(null); + __setPendingTransferNotifierForTests(null); + __setSettlementEntitlementCheckerForTests(null); + __setAuthActivityStampFailureForTests(null); + delete process.env.SUBSCRIPTION_CLAIM_LIVE_TRANSFER_ENABLED; + delete process.env.SUBSCRIPTION_CLAIM_GOOGLE_ENABLED; + delete process.env.CLAIM_CONTEST_WINDOW_HOURS; resetVerifierForTests(); resetAppleApiClientForTests(); resetPlayApiClientForTests(); diff --git a/tests/deletion/tombstones.test.ts b/tests/deletion/tombstones.test.ts index 82738749..74cbafb0 100644 --- a/tests/deletion/tombstones.test.ts +++ b/tests/deletion/tombstones.test.ts @@ -1,4 +1,4 @@ -import { generateKeyPairSync } from "node:crypto"; +import { generateKeyPairSync, randomUUID } from "node:crypto"; import { Environment, SignedDataVerifier, @@ -8,6 +8,8 @@ import express, { json } from "express"; import { importPKCS8, SignJWT } from "jose"; import request from "supertest"; import { afterEach, beforeAll, describe, expect, test, vi } from "vitest"; +import { expireDeletionRecords } from "@/accounts/deletion/outbox"; +import { deleteAccount } from "@/accounts/deletion/service"; import { accountsMeRouter } from "@/api/v2/accounts/accountsMeRouter"; import { authMiddleware } from "@/middleware/auth"; import { pinoMiddleware } from "@/middleware/pino"; @@ -48,6 +50,10 @@ const newAccount = async () => { const wipe = async () => { delete process.env.SUBSCRIPTION_CLAIM_TOMBSTONE_ENABLED; + await prisma.deletionTask.deleteMany(); + await prisma.deletionRecord.deleteMany(); + await prisma.deletedIdentity.deleteMany(); + await prisma.adminAudit.deleteMany(); await prisma.subscriptionTransfer.deleteMany(); await prisma.lineagePeriodCustody.deleteMany(); await prisma.lineagePeriodGrant.deleteMany(); @@ -132,6 +138,54 @@ const tombstone = (provider: BillingProvider, providerKey: string) => afterEach(wipe); describe("verify against deletion tombstones", () => { + test("30-day record expiry does not lift re-verify dedup (replay abuse)", async () => { + // Macroscope scenario: subscriber deletes, waits out the 30-day + // DeletionRecord audit window, then re-verifies the same OTX from a + // fresh account hoping the dedup state was swept with the record. + const owner = await newAccount(); + await upsertFromVerify(appleInput(owner, "otx-replay")); + const operationId = randomUUID(); + await deleteAccount({ accountId: owner, operationId }); + + // Clock-travel the audit window and run the expiry sweep: the + // deletion-scoped rows (record + outbox tasks) are purged. + await prisma.deletionRecord.update({ + where: { operationId }, + data: { expiresAt: new Date(Date.now() - 1000) }, + }); + expect(await expireDeletionRecords()).toBe(1); + expect(await prisma.deletionRecord.count()).toBe(0); + expect(await prisma.deletionTask.count()).toBe(0); + + // Subscription-scoped rows are on no sweep: tombstoned lineage, + // funding registry, and escrow custody all survive the expiry. + const lineage = await prisma.subscriptionLineage.findFirst({ + where: { provider: BillingProvider.apple, lineageKey: "otx-replay" }, + }); + expect(lineage?.state).toBe("tombstoned"); + expect( + await prisma.lineagePeriodGrant.count({ + where: { lineageId: lineage?.id }, + }), + ).toBe(1); + expect( + await prisma.lineagePeriodCustody.count({ + where: { lineageId: lineage?.id, state: "escrow" }, + }), + ).toBe(1); + + // The replay: still the tombstone 409 — no fresh Subscription row and + // no fresh period grant for the new account. + const attacker = await newAccount(); + await expect( + upsertFromVerify(appleInput(attacker, "otx-replay")), + ).rejects.toBeInstanceOf(SubscriptionTombstonedError); + expect(await prisma.subscription.count()).toBe(0); + expect( + await prisma.creditLedger.count({ where: { accountId: attacker } }), + ).toBe(0); + }); + test("tombstoned Apple key with no live row: throws, creates nothing", async () => { const accountId = await newAccount(); await tombstone(BillingProvider.apple, "otx-dead"); @@ -198,7 +252,7 @@ describe("webhooks against deletion tombstones", () => { expect(await prisma.billingReceipt.count()).toBe(0); }); - test("Play RTDN rotation onto a tombstoned token: counted no-op", async () => { + test("Play RTDN rotation onto a tombstoned token: no-op + absorption", async () => { await tombstone(BillingProvider.googlePlay, "token-old"); const result = await applyNotification({ provider: BillingProvider.googlePlay, @@ -211,6 +265,10 @@ describe("webhooks against deletion tombstones", () => { update: { status: SubscriptionStatus.active }, }); expect(result).toEqual({ kind: "tombstoned" }); + const absorbed = await prisma.lineageTokenAlias.findUnique({ + where: { token: "token-new" }, + }); + expect(absorbed).not.toBeNull(); }); test("unknown key with no tombstone stays unknown_subscription", async () => {