feat(hutch): self-serve account deletion (App Store 5.1.1(v))#940
Conversation
The iOS app supports in-app account creation, so Apple requires users to be able to initiate account deletion from within the app. Neither the app nor readplace.com offered self-serve deletion; the privacy policy told users to email, which Apple rejects. Add end-to-end deletion. Server pipeline (mirrors the export/cancel-subscription workers): - DeleteAccountCommand + publisher + EventBridge/in-memory wrappers. - delete-account worker: an idempotent, DLQ-backed scrub of every user-owned store — auth row + Gmail claim, sessions, OAuth grants, saved articles, digest queue, reader-ready state, iOS onboarding, inbox emails/links (+ their S3 objects) and forwarding addresses, S3 exports, password-reset tokens, and a live Stripe subscription + customer. Founding-member and trialing accounts are first-class branches with no Stripe call. - Synchronous POST /account/delete teardown (destroy sessions -> revoke every OAuth token -> clear the session cookie -> publish the command -> redirect to the logged-out home) so the account is unusable the instant the user confirms. - Per-store bulk delete/revoke primitives next to each store, mirrored in the in-memory fixtures, unit-tested. Clients: - Siren: a `class` field on SirenAction, a new account resource with a bare destructive `delete-account` action, and an `account` discovery link. - Web: an /account danger zone behind an hx-confirm, and a privacy-policy rewrite pointing at the self-serve path. - iOS: a `delete-account` presentation case, the row destructive-confirm generalized to any destructive collection affordance (keyed on isDestructive, not the name), and forceLogout after a successful account-ending invoke. Also: an injectable no-op Apple-revocation seam (Phase 6 slots in when SIWA is extended to persist a refresh token) and a webhook-race fix so a late SubscriptionCancelled event on a just-deleted account is an idempotent no-op rather than a DLQ poison. Product decisions applied (plan defaults; flag for review): D1 inbox addresses tombstoned (PII stripped, hash kept reserved so it can never be re-minted and leak mail); D2 Stripe customer deleted; D3 raw .eml deleted; D4 password-reset tokens scan-deleted by email (that table has no TTL); D5 native/hx destructive confirm, no forced re-auth; D6 PITR/deletion-protection retention documented out of band. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RkzGzig54ovfhxFHM9FyG3
|
@claude CI has FAILED. Use your Read tool to read the local file Context
Failed Jobs
|
|
Claude finished @FagnerMartinsBrack's task in 16m 31s —— View job CI Failure Fix — Attempt 1 ✅
Root causeCI compiles the PR merged into main's tip ( This PR makes The FixMerged publishExportUserDataCommand: fixture.events.publishExportUserDataCommand,
publishDeleteAccountCommand: fixture.events.publishDeleteAccountCommand, // ← added
publishCancelSubscriptionCommand: fixture.events.publishCancelSubscriptionCommand,I did not weaken Verification
Pushed |
…on-app-store-rasc6i
…xtures Merging origin/main brought in #871's new events fixtures in queue.reader.route.test.ts and view.route.test.ts, which predate this branch's now-required publishDeleteAccountCommand field on EventsBundle. The text merge is clean but the merged tree failed to type-check (TS2741). Add the missing property in its canonical EventsBundle order so the PR-merged-into-main tree that CI compiles passes again. Resolves CI failure (attempt #1). Co-authored-by: Fayner Brack <FagnerMartinsBrack@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@claude Review this PR. Use your Read tool to read the local file Context
|
|
Claude finished @FagnerMartinsBrack's task in 8m 49s —— View job Code Review — self-serve account deletion (App Store 5.1.1(v))Reviewed the server pipeline, the synchronous teardown, the per-store primitives, the infra wiring, and the Siren/iOS/web clients against the repo guidelines. This is a large, carefully-built change: the worker's idempotency model is mostly sound, the webhook-race fix is precise, IAM is tightly scoped, and the test suite is genuinely thorough. Two idempotency gaps in the async scrub are worth addressing because both can leave a deletion stuck or incomplete, which is exactly what this compliance feature must not do. High Priority Issues (Must Fix)None found. Medium Priority Issues1. Inbox
|
Code Review — self-serve account deletion (App Store 5.1.1(v))Reviewed the server pipeline, the synchronous teardown, the per-store primitives, the infra wiring, and the Siren/iOS/web clients against the repo guidelines. This is a large, carefully-built change: the worker's idempotency model is mostly sound, the webhook-race fix is precise, IAM is tightly scoped, and the test suite is genuinely thorough. Two idempotency gaps in the async scrub are worth addressing because both can leave a deletion stuck or incomplete, which is exactly what this compliance feature must not do. High Priority Issues (Must Fix)None found. Medium Priority Issues1. Inbox
|
|
@claude Fix the review issues. Use your Read tool to read the local file Context
Review to AddressSee the review comment above this one for the issues to fix. |
|
Claude finished @FagnerMartinsBrack's task in 28m 0s —— View job Fixing Review Issues — Attempt 1 ✅Both Medium priority issues are fixed and pushed as
M1 — S3 PII no longer orphaned on redrive
Fix: split the email store into two passes and reorder the scrub so the index is deleted last:
A redrive re-reads the surviving rows and re-derives the keys instead of orphaning them. This also closes the same latent gap for link rows (no M2 — Stripe re-cancel DLQ poison eliminatedThe review's suggested reorder (delete customer then cancel) still re-runs Fix (fewer assumptions, provably idempotent): delete the Stripe customer — which cascades the cancellation and detaches every card — and remove the redundant explicit Tests added (the interleavings the review noted were uncovered)
Verification
|
Both fixes harden the delete-account async scrub against at-least-once redrives — the failure mode a compliance feature must converge through. M1 — Inbox .eml/body S3 objects were orphaned on a partial or redriven scrub. The scrub deleted the DynamoDB email rows (which hold the only pointers to the S3 objects; the keys carry no userId) and only then the S3 objects, so a failure in between lost the keys and left the raw message + rendered body in S3 permanently. Split the email store into a read-only listDeletionReferencesByUserId (run first, rows survive) and a void deleteAllEmailsByUserId (run last); the scrub now deletes the S3 objects and link rows before the email rows that index them, so a redrive re-derives the keys from the surviving rows. M2 — A redrive that re-ran the billing block re-cancelled an already- cancelled Stripe subscription, which returns a non-404 and poisoned the queue into the DLQ. Deleting the Stripe customer already cascades the cancellation (and detaches every card) and then blocks all further operations on the customer, so the explicit immediate-cancel was both redundant and the source of the poison. Removed it; deleteCustomer is 404-idempotent, so a redrive converges. cancelImmediately stays defined and unit-tested for future use. Added handler redrive tests proving both: an S3-delete failure re-derives the keys instead of orphaning them, and a local-row-delete failure re-runs the customer delete idempotently with no re-cancel. Co-authored-by: Fayner Brack <FagnerMartinsBrack@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@claude Review this PR. Use your Read tool to read the local file Context
|
|
Claude finished @FagnerMartinsBrack's task in 7m 55s —— View job Code Review — self-serve account deletion (App Store 5.1.1(v)) · re-review at
|
Code Review — self-serve account deletion (App Store 5.1.1(v)) · re-review at
|
|
@claude — I reviewed this PR strictly for App Store 5.1.1(v) compliance completeness and for scope (that it does nothing more than Apple compliance). Verdict: the web deletion path works and satisfies 5.1.1(v) — in-app Account link → authenticated in-app webview → 🔴 Compliance robustness1. Sign in with Apple revocation is a no-op — latent rejection trap.
🟠 Scope — the one thing that does MORE than compliance2. The native iOS
3. No logout after the in-webview deletion completes. 🟡 Data remnants (deletion must actually erase)4. Password-reset scrub misses mixed-case emails — permanent PII leak. 5. 6. (note) 7. (note) The sync route kills sessions/tokens but the identity row is deleted by the async worker, so re-login is briefly possible in the gap (self-healing). Either delete/flag the row synchronously in the route, or soften the "immediately unusable" comment to "existing sessions/tokens killed synchronously; full erasure async." ✅ Confirmed in-scope — do NOT changeThe async Reviewed for 5.1.1(v) completeness + scope only; findings verified against the PR head. Priorities: #1 (fail-closed the Apple trap) and #2 (resolve the dead-vs-web path into one) are the load-bearing ones; #4 is a real permanent-PII bug. |
|
Claude finished @FagnerMartinsBrack's task in 33m 47s —— View job 5.1.1(v) compliance & scope review — addressedVerified every finding against the code and implemented the server-side data/robustness fixes. Pushed as ✅ Implemented
⏭️ Deferred to an iOS-side change — #2 & #3 (with exact steps)Both require editing Swift, which this Linux CI cannot compile or test — a wrong edit ships a broken iOS build silently. I confirmed #2 is real: the queue collection carries only an #2 (preferred, compliance-only) — delete the native apparatus, keep the web-link path:
#3 — logout after in-webview deletion (if keeping the web path): I can apply the #2/#3 changes in a follow-up if you point me at an iOS-side runner ( · branch |
Address the App Store 5.1.1(v) compliance-completeness review:
- Password-reset scrub missed mixed-case emails. Rows were stored with the raw
address (bare z.email()) but the deletion scrub filters the normalized
users-table PK, and that table has no TTL — so a `John@Example.com` reset
survived forever. Normalize on write and in the delete filter.
- Verification tokens were never scrubbed on deletion. Add deleteTokensByUserId,
wire it into the delete-account worker (+ IAM grant + env), mirror the fixture.
- Pending-signup rows (no TTL) kept a deleted user's {email,userId} forever. Add
deleteByUserId, wire it into the worker (+ IAM grant + env), mirror the fixture.
- Add a fail-closed guard coupling "Sign in with Apple reachable by a normal
user" to "Apple refresh_token persisted", so CI turns red the day SIWA is
un-gated without wiring the real Apple /auth/revoke the worker's IdP seam still
no-ops. Strengthen the seam comment and correct the false "server already
revoked everything" Swift comment.
- Soften the /account/delete "immediately unusable" comment to match the async
identity-row erasure.
The iOS/native scope items (dead delete-account apparatus, post-webview logout)
are left for an iOS-side change: they need Swift compiled/tested (this CI can't)
and the dead-vs-web-path resolution is a product decision.
Co-authored-by: Fayner Brack <FagnerMartinsBrack@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@claude Review this PR. Use your Read tool to read the local file Context
|
|
Claude finished @FagnerMartinsBrack's task in 8m 48s —— View job Code Review — self-serve account deletion (App Store 5.1.1(v)) · re-review at
|
Code Review — self-serve account deletion (App Store 5.1.1(v)) · re-review at
|
|
@claude address all low prio issues |
|
Claude finished @FagnerMartinsBrack's task in 28m 1s —— View job Low-priority issues — three fixed, one documented follow-upPushed as
✅ Low #1 — SIWA fail-closed guard now matches its commentReplaced the hard expect(siwaReachableByDefault).toBe(appleRefreshTokenPersisted);
✅ Low #2 — password-reset PII closed for existing rows too (no backfill)The review named the root cause: "this table has no TTL." Rather than a one-off operational backfill (which only a human can run and can't be CI-verified), I enabled DynamoDB TTL on the ✅ Low #3 — removed the dead native apparatus (your preferred, compliance-only direction)You left this as "Preferred: delete the native apparatus… Or: make it live" and said "address all," so I took the stated preference. Deleted (server, verified here): Kept (all live / per your instructions): the web Danger Zone (
⏭️ Low #4 — deterministic logout after in-webview deletion (documented, not landed)I traced the full path: the Exact steps for the iOS-side change (where
Happy to implement #4 as its own commit if you point me at an iOS runner, or confirm you want it landed blind (Swift unverified here). · branch |
- SIWA fail-closed guard (Low #1): couple "SIWA reachable" and "Apple refresh_token persisted" as an equality (toBe) so {false,false} (today) and {true,true} (SIWA live + revocation wired) both pass while the dangerous {true,false} still fails; the comment now matches the assertion. - Password-reset PII (Low #2): enable DynamoDB TTL on the table's existing expiresAt (mirrors its sibling token tables). Pre-normalization mixed-case reset rows the normalized scrub can't match are now erased within ~48h with no one-off backfill; the synchronous scrub still erases new rows at once. - Remove the dead native delete-account apparatus (Low #3, the review's preferred compliance-only direction): delete account-siren.ts (+test) and the unreached /account Siren branch; drop the Swift delete-account presentation case, endsSession, and the two iOS tests. Keep the live web Danger Zone, the account link, the generic isDestructive confirm infra, and SirenAction.class (per "do NOT change"). iOS Swift changes here are deletions only, verified by re-reading; they still need an iOS-side `make test` run (this Linux CI cannot compile Swift). Low #4 (deterministic logout after in-webview deletion) is a documented follow-up: it needs a coordinated iOS+server change (webview marker + a new WKWebView close-and-logout navigation decision) that can't be compiled/tested here, and the current path self-heals via the next-API-call 401. Co-authored-by: Fayner Brack <FagnerMartinsBrack@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@claude apple signin is meant to be released |
|
Claude finished @FagnerMartinsBrack's task in 48m 28s —— View job Apple revocation landed — SIWA stays live, guard green ✅Direction taken per "apple signin is meant to be released": keep SIWA live and satisfy the fail-closed guard the honest way — wire real Apple token revocation into account deletion (not re-gate SIWA, not weaken the guard). CI is now green on the branch (run 28795763543 — success). The guard now sees What happenedWhile I was implementing this, you pushed an equivalent implementation yourself — My independent implementation converged on the same design as yours, which is a good sign the approach is sound:
I did not overwrite your commit — my local duplicate is discarded and the branch stands on your One optional observation (non-blocking)
Nothing further from me — the compliance gap is closed and the PR is unblocked. |
Sign in with Apple shipped to all users (#933) while this branch's Apple-revocation seam was still a no-op, tripping the fail-closed deletion-compliance lock in auth.route.test.ts ({reachable:true, persisted:false}). This wires the revocation the lock demands: 1. The code exchange now requires Apple's refresh_token — an exchange without one fails the login instead of minting an unrevokable account — and returns it as AppleTokenResult.appleRefreshToken. 2. Every Apple login persists the token on the users row (the passwordHash precedent): createAppleUser stores it at signup, and the new saveAppleRefreshToken conditional update stores the freshest grant on each returning login, which also backfills accounts minted during the gap between #933 and this change. Read back via findAppleRefreshTokenByUserId over the userId-index; deleted with the row by closeUserAccount. Mirrored in the in-memory fixture. 3. The delete worker's no-op seam is replaced by a real initRevokeExternalIdpTokens: look up the stored token (absent for password/Google accounts → log-and-return), POST it with a freshly minted ES256 client secret to https://appleid.apple.com/auth/revoke, and throw on non-2xx so the SQS record redrives while the token is still readable (revocation precedes closeUserAccount). Apple returns 200 for already-revoked tokens (RFC 7009), so redrives converge. 4. The worker Lambda gets the four APPLE_LOGIN_* env vars, and the delete-account queue's maxReceiveCount rises 3 → 12 (~3h of retries at the 900s visibility timeout) so an Apple outage rides out in-queue instead of falling to the 14-day DLQ after 45 minutes. The handler-level test now runs the real revoker against the in-memory auth store and a fake Apple endpoint, proving deletion revokes the stored grant end-to-end; route tests pin token persistence on the signup, returning-login, and race-fallback paths. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
@claude Review this PR. Use your Read tool to read the local file Context
|
|
Claude finished @FagnerMartinsBrack's task in 8m 30s —— View job Code Review — Apple grant revocation at account deletion · re-review at
|
Code Review — Apple grant revocation at account deletion · re-review at
|
|
@claude address this low prio issue, do not report the other again: 2. (iOS — needs an iOS-side change, unchanged by this commit) No deterministic logout after in-webview deletion. The prior review's Low #4 remains the only substantive open item: POST /account/delete redirects the webview to / while the app keeps its TokenStore/cached UI until a user-initiated call 401s, so right after confirming deletion the app can still look signed in. This commit is entirely server-side (SIWA is web OAuth opened in Chrome; no Swift touched), so it neither introduces nor resolves that item — carry the corrected "trigger a converge/ping on web-sheet dismissal → 401 → onSessionExpired → forceLogout" spec into the iOS follow-up, verified with make test. |
|
Claude encountered an error —— View job iOS follow-up — deterministic logout after in-webview account deletionImplemented the corrected spec: re-validate the session on collection-web-sheet dismissal → 401 →
Why
|
|
@claude Resolve conflicts. Use your Read tool to read the local file Context
|
|
Claude finished @FagnerMartinsBrack's task in 17m 47s —— View job Merge conflicts resolved ✅Merged
What conflicted and whyThe only advancing change on Resolution — kept both sides everywhere
Auto-merged contracts verified to carry both PRs' symbols: Merge vs. rebaseI used merge, not rebase, deliberately: this branch is already maintained with merge commits from
Verification
|
…on-app-store-rasc6i Reconcile with #939 (pre-expiry trial reminder email), which extended the same subscription-providers store this PR extended for account-deletion scrubbing. Kept both sides' additions across the write provider, its unit test, and the in-memory fixture + test: - markTrialReminderEmailSent (#939) and deleteSubscription (this PR) - split the interleaved describe/it blocks back into complete blocks - corrected the auto-merged "seven"->"eight mutations" doc comment Co-authored-by: Fayner Brack <FagnerMartinsBrack@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@claude Review this PR. Use your Read tool to read the local file Context
|
|
Claude finished @FagnerMartinsBrack's task in 7m 14s —— View job Code Review — self-serve account deletion (App Store 5.1.1(v)) · re-review at
|
High Priority Issues (Must Fix)None found Medium Priority Issues1. Merge gap — the new
|
|
@claude Fix the review issues. Use your Read tool to read the local file Context
Review to AddressSee the review comment above this one for the issues to fix. |
|
Claude finished @FagnerMartinsBrack's task in 17m 28s —— View job Fixing Review Issues — Attempt 3 ✅Pushed as
M1 — the
|
The #939 merge added a fourth per-user EventBridge schedule (trial-reminder-<userId>), created for every trialing user at signup/trial-start, but the delete-account worker still deleted only three (trial-end, deferred-cancellation, trial-feedback). An account deleted mid-trial therefore left an orphaned, user-keyed reminder schedule live at AWS until its natural fire time — the exact kind of user-owned remnant this compliance feature exists to erase — and the handler's own "all three" comment misstated the count. Wire deleteTrialReminderSchedule into the worker alongside the other three deletes (it is ResourceNotFound-idempotent, so a redrive converges), correct the comment to "all four", and cover it in the trialing and founding-member branch tests plus the raw-email redrive test (which exercises the schedule delete on both the failed first delivery and the retry). No IAM change: the worker's scheduler:DeleteSchedule grant is a group-level wildcard that already permits the reminder schedule name. The sibling cancel-subscription worker already deletes this same schedule for a trialing cancel. Co-authored-by: Fayner Brack <FagnerMartinsBrack@users.noreply.github.com>
|
@claude Review this PR. Use your Read tool to read the local file Context
|
|
Claude finished @FagnerMartinsBrack's task in 4m 50s —— View job Code Review — self-serve account deletion (App Store 5.1.1(v)) · re-review at
|
High Priority Issues (Must Fix)None found Medium Priority IssuesNone found Verified — the trial-reminder schedule scrub fix (
Low Priority Suggestions1. (iOS — needs an iOS-side change) Deterministic logout after in-webview deletion is still the one open itemUnchanged by this server-only commit and carried over: (Per the maintainer's 2026-07-06 instruction, not re-reporting the SIWA symmetric-coupling confirm-intent note.) |
…letion The account page's "Delete account" form is gated by hx-confirm, which calls window.confirm() — suppressed (answered false) by WKWebView without a WKUIDelegate, so the button silently did nothing in-app and the 5.1.1(v) deletion flow shipped in #940 was unreachable from the app. - WebDialog: pure panel-kind → native-dialog mapping (unit-tested); runJavaScriptConfirm/AlertPanel present it via the sheet's topmost controller and answer exactly once on every path - account link rel now renders as person.crop.circle in the toolbar - web-sheet dismissal now probes the server (no zero-network guard): against a deleted account it 401s into the existing onSessionExpired → forceLogout, so the app deterministically signs out — the follow-up spec recorded in #940's review comments - readerMarkedRead drops its row instantly and defers reconciliation to the dismissal converge, so the probe and the mark-read path share one fetch and an eventually-consistent GET can't resurrect the read row make test: 217 production + 4 staging-smoke tests, 0 failures.

Why
The iOS app supports in-app account creation, so App Store Guideline 5.1.1(v) requires users to be able to initiate account deletion from within the app. Neither the app nor readplace.com offered self-serve deletion — the privacy policy told users to email, which Apple explicitly rejects. This adds end-to-end self-serve deletion.
What
Server pipeline (mirrors the existing export / cancel-subscription workers):
DeleteAccountCommand+ publisher + EventBridge/in-memory wrappers.delete-accountworker that scrubs every user-owned store: auth row + Gmail uniqueness claim, sessions, OAuth grants (+ paired refresh rows), saved articles, digest queue, reader-ready state, iOS onboarding, inbox emails/links (+ their RAW_EMAIL and CONTENT S3 objects) and forwarding addresses, S3 exports, password-reset tokens, and a live Stripe subscription + customer. Founding-member (no row) and trialing (nosubscriptionId) accounts are first-class branches with no Stripe call. Per-user EventBridge schedules are deleted so none fires at a dead account.POST /account/deleteteardown so the account is unusable the instant the user confirms: destroy every session → revoke every OAuth token → clear the session cookie → publish the command → full-page redirect to the logged-out home.Clients:
classfield onSirenAction, a newaccountresource with a bare destructivedelete-accountaction, and anaccountdiscovery link on the collection./account"Danger zone" behind anhx-confirm, plus a privacy-policy rewrite pointing at the self-serve path.delete-accountpresentation case, the destructive-confirm generalized from row controls to any destructive collection affordance (keyed onisDestructive, never the action name), andforceLogoutafter a successful account-ending invoke.Also:
refresh_tokenon the users row (stored at signup and refreshed on every returning Apple login, which backfills accounts minted before persistence), and the delete worker revokes it viaPOST https://appleid.apple.com/auth/revokewith a freshly minted ES256 client secret — throwing on non-2xx so the SQS record redrives (queuemaxReceiveCountraised to 12 to ride out an Apple outage). Password/Google accounts have no stored token and skip the call.SubscriptionCancelledevent on a just-deleted account is now an idempotent no-op instead of poisoning the queue into the DLQ.Product decisions (plan defaults — please confirm)
.emlaudit trailhx-confirmdestructive confirm; no forced re-auth for v1Testing
DynamoDBDocumentClient) + mirrored in-memory fixtures.batchItemFailureson a store error, IdP-revocation seam.*.route.test.ts(supertest + parseHTML).endsSessiontests. Note: Swift can't be compiled in this Linux CI environment — the iOS changes need an iOS-side (make test) run.🤖 Generated with Claude Code
https://claude.ai/code/session_01RkzGzig54ovfhxFHM9FyG3
Generated by Claude Code