From ba34b3cb62799c0301b41de5082714ebfbbafa1d Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Thu, 16 Jul 2026 18:57:04 +0200 Subject: [PATCH 1/8] feat(subscriptions): restore live transfer, contest window, undo, and Google claim proof behind launch-off flags Reverts the defer refactor (6226698) and reconciles the restored slice onto the moved base: - Live bearer-transfer tier behind SUBSCRIPTION_CLAIM_LIVE_TRANSFER_ENABLED (off): 72h contest window (202 pending + push to the old owner's devices), any authenticated act by the old account vetoes settlement (awaited fail-closed lastAuthAt stamp at mint and on every authed request), execution-time provider recheck, per-lineage 30-day cooldown, one-shot CAS undo with post-undo freeze, pending settlement on the deletion outbox tick. - Google claim proof path behind SUBSCRIPTION_CLAIM_GOOGLE_ENABLED (off), now documented in .env.example: Play purchase fetch, exact latestOrderId funding-event restoration, keyless fail-close. - Play token-rotation tombstone absorption on the verify and RTDN paths, routed through the atomic conflict-detecting lineage resolver. Reconciliation with the base that moved since the defer: the live-account fence keeps the 500-on-DB-error split while restoring the lastAuthAt select; the mint path keeps the backfill no-op short-circuit; verify keeps the billing-grace renewal-info seeding on the merged claim handler. Revived fixes that died with the deferred code: evaluateClaimable gates tombstoned lineages behind the tombstone flag instead of advertising a doomed claim; claimContestWindowHours rejects fractional or invalid hour counts instead of parseInt-truncating 0.5 to 0 (instant transfer); an entitled but unrecognized Google product claims 400 invalid_claim_proof instead of 500, mirroring the Apple fix. Flags all default off: merging this activates nothing. --- .env.example | 13 +- docs/plans/delete-my-account.md | 108 ++-- src/accounts/auth-activity.ts | 56 +++ src/accounts/deletion/outbox.ts | 9 +- .../accounts/handlers/subscription-claim.ts | 244 ++++++++- src/api/v2/auth/handlers/generate-token.ts | 17 + src/api/v2/notifications/types.ts | 23 +- .../handlers/google-play-rtdn.ts | 31 +- src/middleware/auth.ts | 26 +- src/payments/types.ts | 4 +- src/subscriptions/AGENTS.md | 12 +- src/subscriptions/claim-eligibility.ts | 33 +- src/subscriptions/claim-flags.ts | 41 +- src/subscriptions/claim.ts | 471 +++++++++++++++++- src/subscriptions/custody.ts | 67 ++- src/subscriptions/repository.ts | 77 ++- src/subscriptions/tombstones.ts | 55 +- 17 files changed, 1160 insertions(+), 127 deletions(-) create mode 100644 src/accounts/auth-activity.ts diff --git a/.env.example b/.env.example index 69e48ca6..cca908fc 100644 --- a/.env.example +++ b/.env.example @@ -160,10 +160,17 @@ POSTHOG_PERSONAL_API_KEY= POSTHOG_PROJECT_ID= POSTHOG_API_HOST= -# --- 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/docs/plans/delete-my-account.md b/docs/plans/delete-my-account.md index 3b2b98de..3435589d 100644 --- a/docs/plans/delete-my-account.md +++ b/docs/plans/delete-my-account.md @@ -304,27 +304,30 @@ this section, and the user-facing deletion copy must not promise erasure of Subscription state is keyed by `originalTransactionId` (Apple) and `purchaseToken` (Google), not by `accountId`, and `Subscription.accountId` is -a non-null FK. A deleted subscription therefore needs durable provider-key -state outside the live subscription row. The implementation carries that -state on `SubscriptionLineage`: deletion removes the account-linked -subscription and flips the locked lineage to `tombstoned`. Entitlement -lookups treat a tombstoned key as no entitlement. Recursive Google aliases -remain active for ordinary verify and RTDN accounting; tombstoned token -rotation absorption is deferred. +a non-null FK. Keeping any subscription row therefore requires a shape change: +a dedicated provider-key tombstone (transaction id or purchase token marked as +belonging to a deleted account) rather than an "anonymized subscription row", +which the schema cannot express once the account is gone. Concretely, the +transition is: inside the deletion transaction, the live `Subscription` row +(and its `BillingReceipt` children, per the retention regime) is deleted, and +a tombstone row keyed by provider identity — unique on +`(provider, originalTransactionId | purchaseToken)` — is inserted atomically. +Entitlement lookups treat a tombstoned key as no entitlement; Google token +rotation adds the rotated token to the same tombstone rather than escaping it. The tombstone must define a small state machine covering: - Webhook ingestion: the current Apple and Google handlers update known subscriptions and acknowledge unknown ones; they do not recreate rows on - their own. Post-deletion events for tombstoned keys must be acknowledged - without recreating account-linked state. + their own. Post-deletion events for tombstoned keys must be acknowledged as + an explicit no-op (and counted, for observability). - Verification: the account-linked recreation path is authenticated subscription verify combined with SIWE auto-provisioning. Both the deletion barrier (at mint) and a tombstone check (at verify) are required so a deleted user's still-active store subscription cannot silently rebind. - Google token rotation: purchase tokens rotate and chain to linked tokens. - Recursive alias resolution remains required for verify and RTDN accounting. - Absorbing rotations into a tombstoned lineage is deferred to a follow-up. + The tombstone must absorb rotations of a tombstoned token without + recreating account state. - Concurrency: webhook processing currently looks up the subscription before its transaction. Deletion racing a webhook must converge (in either order) to tombstone-plus-no-op, not to a recreated or orphaned row. This needs @@ -488,10 +491,10 @@ cannot mint tokens". token; tombstone no-op paths; the direct `ClientIdentifier.accountId` sweep, including stale rows pointing at re-registered devices. - Integration tests for: the full transaction against a real database; - webhook replay after deletion (acknowledged, no recreation); - partial-failure resume (kill between database commit and each external - purge, verify the outbox drains on retry, independent of any further - authenticated client request). + webhook replay after deletion (acknowledged, no recreation); Google token + rotation landing on a tombstoned token; partial-failure resume (kill + between database commit and each external purge, verify the outbox drains + on retry, independent of any further authenticated client request). - Race tests, not just replay tests: deletion concurrent with Apple/Google webhook processing; deletion concurrent with subscription verification; a Composio link request completing during deletion; a push registration @@ -506,15 +509,15 @@ cannot mint tokens". ## Risks & Mitigations -| Risk | Impact | Mitigation | -| ------------------------------------------------------------------------------------------------------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -| SIWE auto-provisioning silently recreates a deleted account (retry, paired device, client auto-reauth) | High | Deletion barrier at token mint with a terminal response; barrier checks at verify; fail-closed requireAccount | -| Retention framed as anonymization overpromises erasure | High | Pseudonymized-retention regime with per-class purpose, fields, access, and expiry; honest user-facing copy | -| Store webhooks or verify recreate rows for deleted accounts | Medium | Provider-key tombstones consulted in webhooks and verify; concurrency semantics plus race tests | -| Partial failure strands external data (S3, Composio, notification server) | Medium | Transactional outbox snapshot; drain with retries; purge SLA with alerting and operator remediation | -| Untracked S3 attachments are unenumerable per account | High (blocks the iOS confirmation copy) | Explicit decision: retain-and-disclose or ownership index; bucket lifecycle policy either way | -| Stolen JWT deletes an account | Medium | Fresh-token requirement; rate limiting; audit trail | -| Users expect deletion to stop billing | Medium | Client-side disclosure before deletion (iOS plan); tombstones keep webhook handling sane either way | +| Risk | Impact | Mitigation | +| ------------------------------------------------------------------------------------------------------ | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| SIWE auto-provisioning silently recreates a deleted account (retry, paired device, client auto-reauth) | High | Deletion barrier at token mint with a terminal response; barrier checks at verify; fail-closed requireAccount | +| Retention framed as anonymization overpromises erasure | High | Pseudonymized-retention regime with per-class purpose, fields, access, and expiry; honest user-facing copy | +| Store webhooks or verify recreate rows for deleted accounts | Medium | Provider-key tombstones consulted in webhooks and verify; rotation absorption; concurrency semantics plus race tests | +| Partial failure strands external data (S3, Composio, notification server) | Medium | Transactional outbox snapshot; drain with retries; purge SLA with alerting and operator remediation | +| Untracked S3 attachments are unenumerable per account | High (blocks the iOS confirmation copy) | Explicit decision: retain-and-disclose or ownership index; bucket lifecycle policy either way | +| Stolen JWT deletes an account | Medium | Fresh-token requirement; rate limiting; audit trail | +| Users expect deletion to stop billing | Medium | Client-side disclosure before deletion (iOS plan); tombstones keep webhook handling sane either way | ## Open Questions @@ -563,8 +566,7 @@ open-question resolutions this implementation shipped with: anywhere on this boundary - every check hits the database. - **Verify claimable signal**: ownership-mismatch/tombstone 409s keep code `subscription_account_mismatch` (append-only law) and gain the additive - `claimable` boolean. Live ownership mismatches report `false`; Apple - tombstones report `true`. + `claimable` boolean. - **Barrier**: permanent, keyed hash (HMAC keyed by the dedicated `DELETION_HASH_SECRET`, which must never rotate). - **Fresh-token requirement**: not in v1 (rate limits + audit instead). @@ -600,30 +602,32 @@ open-question resolutions this implementation shipped with: - Apple App Store Review Guideline 5.1.1(v) (account deletion requirement). - Apple developer guidance: "Provide options to delete your app's account". -## Relationship to subscription ownership restoration +## Relationship to subscription ownership reconciliation (as built) -The historical rationale in this plan considered tombstone restoration and live -ownership transfer. This branch ships Apple tombstone restoration only. Live -ownership transfer, its contest and undo machinery, Google claim proof, and -Play tombstone-rotation absorption are deferred to a follow-up. +This section originally proposed tombstone-gated transfer only. The +implementation supersedes it with the subscription-lineage claim design +(adversarially reviewed; see the claim section below). The July 12-13 +incident remains the motivating case: account recreation orphaned +subscriptions, leaving the new account with a verify 409 while renewals kept +enriching the ghost account's wallet. -## Subscription claim +## Subscription claim (as built) One `SubscriptionLineage` row per purchase line (Apple originalTransactionId; Google linkedPurchaseToken chain resolved to its root, rotated tokens kept as aliases) is the canonical first lock for verify, webhooks, claims, and the -deletion teardown, and the tombstone carrier: deletion flips the lineage to -`tombstoned` instead of writing a separate tombstone table. -`LineagePeriodGrant` makes period funding global-once (keyed by the +deletion teardown, the cooldown anchor, and the tombstone carrier: deletion +flips the lineage to `tombstoned` instead of writing a separate tombstone +table. `LineagePeriodGrant` makes period funding global-once (keyed by the provider funding event: Apple transactionId / Google latestOrderId), and `LineagePeriodCustody` tracks each funded period's remaining value; every move debits `D = min(lockedBalance, max(0, cap - consumesSince))` and sets -`cap := D`, so no sequence of deletion, restoration, or refund events can move -more than one period allotment and commingled promo/admin/signup credits never -move. +`cap := D`, so no sequence of delete/claim/undo/refund events can move more +than one period allotment and commingled promo/admin/signup credits never +transfer. -`POST /v2/accounts/me/subscription/claim` is the explicit one-time Apple -restoration act: +`POST /v2/accounts/me/subscription/claim` is the +explicit one-time claim act: - Proof requirements are authoritative: verified artifact, provider-confirmed entitled-now, and latest-transaction match (no signedDate freshness window @@ -633,11 +637,19 @@ restoration act: - Tombstone restoration (deleted owner): the deletion transaction escrowed the conservative remainder into custody; the claim releases the escrow to the claimant (never a second grant) and flips the lineage back to live. - Controlled by `SUBSCRIPTION_CLAIM_TOMBSTONE_ENABLED`, which defaults on. -- Claims against live lineages deterministically fail closed. Google claim - request shapes remain accepted for client compatibility but fail closed - before any provider call. Google verify, RTDN, recursive alias resolution, - grants, custody, escrow, void accounting, and reconciliation remain active. -- Live ownership transfer, contest notifications and settlement, undo, Google - claim proof, and Play tombstone-rotation absorption are deferred to a - follow-up. + Enabled at launch (`SUBSCRIPTION_CLAIM_TOMBSTONE_ENABLED`). +- Live bearer-transfer (owner still exists): behind + `SUBSCRIPTION_CLAIM_LIVE_TRANSFER_ENABLED` (off until security sign-off), + with a 72-hour contest window by default (202 pending; the old account's + devices are push-notified; any authenticated act by the old account before + settlement vetoes), a 30-day per-lineage cooldown, and a one-shot CAS undo + for the immediately previous owner - cooldown-exempt, executes + immediately, and freezes further automated transfers on the lineage + (operator re-home only). Recovery language is honest: the previous owner + can recover once, within 30 days; after the undo is spent, the deadline + passes, or the lineage moves on again, recovery is support-mediated. +- Deviation from the original section: claims work without a deletion + tombstone (bounded bearer-transfer semantics), because the primary heal + class - ghost accounts whose keys are gone - can never produce an + old-owner approval, and the consequences are bounded by conservation, + attestation, cooldown, contest window, undo, journaling, and alerting. 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/outbox.ts b/src/accounts/deletion/outbox.ts index e0840f59..8400eb86 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"; @@ -324,7 +325,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/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 406dff40..3ae81452 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) { @@ -276,18 +276,31 @@ export async function googlePlayRtdnHandler(req: Request, res: Response) { update, }); - if ( - result.kind === "unknown_subscription" || - result.kind === "tombstoned" - ) { - // Unknown and deleted-account lineages are acknowledged without - // creating account-linked state. + if (result.kind === "unknown_subscription") { + // SUBSCRIPTION_PURCHASED before verify, or a notification whose + // purchaseToken our row hasn't been linked to yet. Ack; /verify will + // create or refresh the row. req.log.info( { messageId: message.messageId, notificationType: sub.notificationType, }, - "play.rtdn.subscription_not_applied — acking", + "play.rtdn.unknown_subscription — acking", + ); + res.status(200).json({ ok: true, applied: false }); + return; + } + + if (result.kind === "tombstoned") { + // 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.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 c603f90e..6608806f 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. */ @@ -193,6 +193,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. @@ -270,8 +328,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 fbcffd22..20b80977 100644 --- a/src/subscriptions/repository.ts +++ b/src/subscriptions/repository.ts @@ -45,6 +45,7 @@ import { type SubscriptionTier, } from "@/subscriptions/tiers"; import { + absorbTombstoneRotation, findTombstonedLineage, SubscriptionTombstonedError, } from "@/subscriptions/tombstones"; @@ -460,7 +461,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, + ); } } @@ -510,7 +519,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) @@ -556,6 +565,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 @@ -687,7 +716,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; @@ -778,6 +808,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 || @@ -1044,9 +1100,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 @@ -1083,8 +1139,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, }); @@ -1202,8 +1259,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; + } +}; From 5f6261665188df1a1dbf31d18e53cff8b006f80d Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Thu, 16 Jul 2026 18:57:20 +0200 Subject: [PATCH 2/8] test(deletion): port the live-transfer suite onto the shared reclaim fixtures Restores the test coverage removed with the deferred slice, rewritten against tests/deletion/reclaim-fixtures.ts instead of the old per-file fixture copies: instant transfer conservation, cooldown + one-shot undo (including the concurrent-undo race and undo-after-spend conservation), contest window lifecycle (202 pending, push notifier, settlement, authenticated-act veto, stamp-throttle bypass, stamp-failure fail-close, null-lastAuthAt defensive veto, provider revocation and unreachable provider at settlement), replay against two targets, opposite-direction transfer deadlock convergence, the transfer/undo/delete/restore custody cap lifecycle, post-transfer drift compensation (incl. the 73h contested settlement shape), Google flag-gated claims (flag-off rejection, flag-on exact funding-event escrow release, keyless fail-close), tombstoned rotation absorption and its conflicting-alias quarantine, and the mint activity stamp (success + fail-closed 500). New regression coverage for the revived fixes: a fractional contest window falls back to 72h pending instead of an instant transfer, and an entitled but unrecognized Google product claims 400. The fixture wipe now also resets the claim env flags and the pending notifier / settlement checker / stamp failure seams. --- tests/auth-token-siwe.test.ts | 21 ++ tests/deletion/adversarial-round3.test.ts | 249 +++++++++++++++- tests/deletion/adversarial-round4.test.ts | 343 +++++++++++++++++++++- tests/deletion/adversarial-round5.test.ts | 132 ++++++++- tests/deletion/adversarial.test.ts | 206 ++++++++++++- tests/deletion/barrier-mint.test.ts | 10 +- tests/deletion/claim.test.ts | 198 ++++++++++++- tests/deletion/reclaim-fixtures.ts | 9 + tests/deletion/tombstones.test.ts | 6 +- 9 files changed, 1163 insertions(+), 11 deletions(-) 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/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 6a9a5a6c..8673c136 100644 --- a/tests/deletion/tombstones.test.ts +++ b/tests/deletion/tombstones.test.ts @@ -198,7 +198,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 +211,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 () => { From 91b97f134231b0c53253bc06568e5094da550d19 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Wed, 22 Jul 2026 16:55:40 +0200 Subject: [PATCH 3/8] feat(deletion): snapshot the pre-escrow wallet balance on the deletion record Adds nullable DeletionRecord.finalBalanceCredits (display-only): a plain read of UserCredits.balance taken inside the teardown transaction, before any escrow debit or the wallet teardown, stable under the Account FOR UPDATE lock. Missing wallet rows record 0 (getBalance semantics); records written before the field stay null. --- .../migration.sql | 3 ++ prisma/schema.prisma | 6 +++ src/accounts/deletion/service.ts | 18 ++++++++- tests/deletion/delete-account.test.ts | 38 ++++++++++++++++++- 4 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20260722120000_add_deletion_final_balance/migration.sql 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 24657d9c..d6e0b396 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -339,6 +339,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/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/tests/deletion/delete-account.test.ts b/tests/deletion/delete-account.test.ts index cef002b5..a1c29e39 100644 --- a/tests/deletion/delete-account.test.ts +++ b/tests/deletion/delete-account.test.ts @@ -315,6 +315,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 }, @@ -371,17 +374,48 @@ 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); + }); + // 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 () => { From fca7248c78712290f92b4271626918b59961ec8f Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Wed, 22 Jul 2026 16:55:45 +0200 Subject: [PATCH 4/8] fix(deletion): complete the notification purge when the installation is already gone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notification-installation executor treated every ConnectError as retryable, so a NotFound — or the bare HTTP 404 the Connect protocol maps to unimplemented — retried to terminal failure and pinned the DeletionRecord in purging. Those codes mean the installation is already absent: classify them as success so the task completes; transient codes (unavailable, timeouts, 5xx) keep their retry semantics. --- src/accounts/deletion/executors.ts | 33 +++++- .../notification-purge-already-absent.test.ts | 110 ++++++++++++++++++ 2 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 tests/deletion/notification-purge-already-absent.test.ts diff --git a/src/accounts/deletion/executors.ts b/src/accounts/deletion/executors.ts index 67f28547..5bd5845b 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/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"); + }); +}); From aa6672163cc3ac035cdabdd08f986c4dc45710b9 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Wed, 22 Jul 2026 16:55:51 +0200 Subject: [PATCH 5/8] fix(subscriptions): fail loudly when escrow runs after the wallet teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The escrow-before-wallet-teardown ordering in the deletion transaction was enforced only by a comment: run the other way round, computeMoveAmount's balance lock upserts a fresh zero wallet, escrow silently conserves 0, and the recreated UserCredits row breaks the Account delete on its RESTRICT FK. escrowCustody now throws EscrowWalletMissingError when the holder's wallet row is missing (a held custody's owner always has one — funding created it), turning the mis-ordering into a loud failure. --- src/subscriptions/custody.ts | 26 +++++ tests/deletion/escrow-order-guard.test.ts | 129 ++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 tests/deletion/escrow-order-guard.test.ts diff --git a/src/subscriptions/custody.ts b/src/subscriptions/custody.ts index 6608806f..7107175f 100644 --- a/src/subscriptions/custody.ts +++ b/src/subscriptions/custody.ts @@ -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, @@ -263,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, { 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); + }); +}); From 5d9e7a4d7dbe92606e360bc9e15903af13353a93 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Wed, 22 Jul 2026 17:08:43 +0200 Subject: [PATCH 6/8] docs(delete-account): record agreed design decisions --- docs/plans/delete-my-account.md | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/docs/plans/delete-my-account.md b/docs/plans/delete-my-account.md index 3435589d..4e4b8640 100644 --- a/docs/plans/delete-my-account.md +++ b/docs/plans/delete-my-account.md @@ -653,3 +653,50 @@ explicit one-time claim act: class - ghost accounts whose keys are gone - can never produce an old-owner approval, and the consequences are bounded by conservation, attestation, cooldown, contest window, undo, journaling, and alerting. + +## Design decisions (agreed 2026-07-22) + +Agreed between Louis and Borja after the ledger retention review and the +first end-to-end device deletions. + +- **Immediate deletion on request.** No defer-to-subscription-expiry + variant: Apple 5.1.1(v) and GDPR erasure timing both point at deleting + when asked. The store subscription itself is not cancelled (store-owned, + disclosed client-side). +- **Wallet zeroed at deletion.** The teardown escrows the conservative + subscription remainder (the escrow debit), then hard-deletes the + CreditLedger, UserCredits, and BillingReceipt rows. There is NO + pseudonymized ledger retention window; retention stays the amount-only + tombstone set below. +- **Tombstone model.** What survives: DeletionRecord (keyed accountRef), + the DeletedIdentity barrier (keyed identity hash), and the + SubscriptionLineage tombstone with its custody/registry/journal rows. + Honest note: SubscriptionLineage.lineageKey stores the RAW provider + transaction id (Apple originalTransactionId, Google token-chain root). + Retention basis: active billing contract plus fraud prevention; account + linkage is hash-only (deletedAccountRef). Open nice-to-have: HMAC the + lineageKey too, with verify-time equality via HMAC as the identity + barrier does; check that Google token-alias chaining still resolves + before committing to it. +- **Restore semantics.** Deletion does not cancel the store subscription. + A re-verify from a new accountId hits the lineage tombstone and gets the + 409 with the claimable signal; the one-shot store-proven claim releases + the escrowed remainder, min(remaining balance, unspent period cap), to + the claimant; the next renewal funds the new account normally. +- **Anti-abuse invariant.** Credits released per paid period never exceed + that period's cap, across any number of delete/restore cycles. Defense + in depth: the lineage tombstone blocks silent rebinding at verify AND + the LineagePeriodGrant registry independently suppresses re-funding an + already-funded period. +- **Escrow forfeit.** Unclaimed escrow forfeits at the 30-day tombstone + expiry; the expiry sweep purges the rows. +- **Deferred: non-subscription remainder escrow.** The two-bucket design + (subscription custody vs deletion escrow, with separate refund-clawback + semantics) is deferred. The decision will be data-driven via the new + DeletionRecord.finalBalanceCredits snapshot of the pre-escrow wallet + balance. +- **E2E evidence.** Two device deletions verified end to end on 2026-07-22 + (teardown, writer fencing, external purge, re-onboard). Fixes shipped the + same day: the finalBalanceCredits snapshot, the purge treating provider + NotFound as already-gone, and the escrow-before-wallet-teardown ordering + guard. From a70330c2dc800f3839644bd4b93a2ab9d0621e37 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 24 Jul 2026 15:43:34 +0200 Subject: [PATCH 7/8] =?UTF-8?q?docs(delete-account):=20correct=20retention?= =?UTF-8?q?=20wording=20=E2=80=94=20two=20clocks,=20no=2030-day=20escrow?= =?UTF-8?q?=20forfeit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Escrow forfeit' bullet claimed unclaimed escrow forfeits at the 30-day tombstone expiry and that the expiry sweep purges the rows. The code does neither: expireDeletionRecords deletes only the DeletionRecord and its DeletionTask outbox; SubscriptionLineage, LineagePeriodGrant, and LineagePeriodCustody are on no sweep, so re-verify dedup outlives the record. Documents the two retention clocks and flags the claim-window bound (none in code today; 30d constants are cooldown/undo) as the open product question. --- docs/plans/delete-my-account.md | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/plans/delete-my-account.md b/docs/plans/delete-my-account.md index a2fb3fa8..11813da0 100644 --- a/docs/plans/delete-my-account.md +++ b/docs/plans/delete-my-account.md @@ -689,8 +689,24 @@ first end-to-end device deletions. in depth: the lineage tombstone blocks silent rebinding at verify AND the LineagePeriodGrant registry independently suppresses re-funding an already-funded period. -- **Escrow forfeit.** Unclaimed escrow forfeits at the 30-day tombstone - expiry; the expiry sweep purges the rows. +- **Two retention clocks (no 30-day escrow forfeit).** Only deletion-scoped + rows expire on the 30-day audit clock: the DeletionRecord and its + DeletionTask outbox (`expireDeletionRecords`, + `src/accounts/deletion/outbox.ts`); the identity barrier is permanent. + Subscription-scoped rows — the tombstoned SubscriptionLineage, the + LineagePeriodGrant funding registry, and LineagePeriodCustody escrow — + are on no sweep at all and survive record expiry, so the anti-abuse + invariant above outlives the deletion record: a re-verify after 30+ days + still hits the tombstone 409, and the registry still suppresses + re-funding. Unclaimed escrow is not forfeited on a timer; escrow of + already-ended periods is exhausted lazily when a claim settles the + lineage. +- **Claim-window bound (open question, Borja).** As built, a tombstone + claim has no expiry in code; the 30-day constants are the transfer + cooldown and the undo deadline, and the dedup lifetime above is + independent of any claim window. Whether to bound claims (30 days vs + until the paid period's end) is a product choice, deliberately not + decided in code yet. - **Deferred: non-subscription remainder escrow.** The two-bucket design (subscription custody vs deletion escrow, with separate refund-clawback semantics) is deferred. The decision will be data-driven via the new From 28c81f116a926b08d61abb9f2a2150b400498dd1 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 24 Jul 2026 15:43:34 +0200 Subject: [PATCH 8/8] test(deletion): pin re-verify dedup across the 30-day record expiry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Macroscope replay scenario: delete a subscribed account, expire the DeletionRecord past the audit window, then re-verify the same OTX from a fresh account. Pins that the expiry sweep leaves the tombstoned lineage, funding registry, and escrow custody in place, the re-verify still throws SubscriptionTombstonedError, and no subscription row or period grant is created — so a future sweep widened to subscription-scoped rows fails CI. --- tests/deletion/tombstones.test.ts | 56 ++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/tests/deletion/tombstones.test.ts b/tests/deletion/tombstones.test.ts index 8673c136..85a8b58b 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");