Skip to content
This repository was archived by the owner on Aug 12, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
108 changes: 60 additions & 48 deletions docs/plans/delete-my-account.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand All @@ -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.
56 changes: 56 additions & 0 deletions src/accounts/auth-activity.ts
Original file line number Diff line number Diff line change
@@ -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 (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High accounts/auth-activity.ts:38

stampAuthActivity issues a bare UPDATE "Account" SET "lastAuthAt" = now() without locking the row first, so a concurrent transfer settlement that runs SELECT ... FOR UPDATE on the account can acquire the lock before this UPDATE, read the stale lastAuthAt, and commit the transfer while this authenticated request is still waiting on the UPDATE. The stamp commits only after settlement has already made its veto decision, so a real owner act during the contest window fails to cancel the pending transfer. Awaiting the UPDATE does not close the read-to-write race. The stamp needs to acquire the account row lock (e.g., SELECT id FROM "Account" WHERE id = ... FOR UPDATE) before the now() write so it serializes before settlement's read — or settlement must re-check lastAuthAt after any competing stamp.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/accounts/auth-activity.ts around line 38:

`stampAuthActivity` issues a bare `UPDATE "Account" SET "lastAuthAt" = now()` without locking the row first, so a concurrent transfer settlement that runs `SELECT ... FOR UPDATE` on the account can acquire the lock before this UPDATE, read the stale `lastAuthAt`, and commit the transfer while this authenticated request is still waiting on the UPDATE. The stamp commits only after settlement has already made its veto decision, so a real owner act during the contest window fails to cancel the pending transfer. Awaiting the UPDATE does not close the read-to-write race. The stamp needs to acquire the account row lock (e.g., `SELECT id FROM "Account" WHERE id = ... FOR UPDATE`) before the `now()` write so it serializes before settlement's read — or settlement must re-check `lastAuthAt` after any competing stamp.

accountId: string,
knownLastAuthAt: Date | null,
): Promise<void> => {
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
`;
};
9 changes: 8 additions & 1 deletion src/accounts/deletion/outbox.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -324,7 +325,13 @@ export const runDeletionOutboxSweep = async (): Promise<void> => {
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 (
Expand Down
Loading
Loading