Skip to content

Add account deletion: a function, a public page, and a menu item on every client - #23

Open
cdeutsch wants to merge 65 commits into
mainfrom
claude/pushthat-account-deletion-1b2bde
Open

cdeutsch wants to merge 65 commits into
mainfrom
claude/pushthat-account-deletion-1b2bde

Conversation

@cdeutsch

@cdeutsch cdeutsch commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Google Play's User Data policy requires an app that lets people make an account to let them delete it, with its data, from inside the app and from a public web page. Until now closing an account meant writing to support.

Merging deploys the functions and the site. See "Before merging" below.

This branch first also replaced the Google favicon lookups and prerendered the public pages. Both landed on main by a different design while it was in review (#20, #21), so the merge of main takes those and drops this branch's versions. What is left is account deletion only.

What it adds

  • deleteAccount function. A callable that needs a sign-in no older than five minutes, so every client asks for the password again. It ends the Stripe subscription and deletes the customer first (the one step that can refuse), revokes the refresh tokens, deletes the pushes, the stored files, the billing rows and the user document, and deletes the Auth user last so a call that stopped halfway can be made again. A second call succeeds and changes nothing.
  • A second purge. A device left signed in keeps a working ID token for up to an hour, so finishAccountDeletions runs hourly and empties the same places again 70 minutes after a deletion, from a marker that holds only two times. It also asks Stripe again, and createCheckoutSession refuses an account that has a marker.
  • A public page, /delete-account. It names the app and the developer, gives the steps, lists what is deleted and what is kept and for how long, and has a form that works signed in or out, so someone with no app installed can use it. It is one of the static pages, so the text reads without JavaScript; the form, which needs Firebase, is a component the page imports from an effect.
  • Clients. "Delete account" is in the menu on Android, iOS, macOS, the website and the Chrome extension. Each says that it is permanent and that encrypted pushes cannot be recovered, asks for the password, calls the function, then clears its local data and signs out.
  • When no answer comes back. Clients wait 310 seconds, longer than the function can run. If the call still ends unanswered they ask Firebase whether the account exists rather than claim nothing was deleted.

docs/account-deletion.md is the design record: where an account's data lives, the order of the steps, what cannot be deleted at once and for how long, and what each client clears.

Also changed

  • The terms name the deletion page beside the termination email address, and their effective date moves. The privacy policy describes deletion and what Stripe keeps. Worth a read by whoever owns the legal text.
  • npm run test:emulator now starts the Auth emulator too (port 9099).
  • The Android release note gains the Delete account line.

Testing

After the merge of main:

  • Functions: lint, build, 346 unit tests, 93 emulator tests. The deletion suite walks the whole database, the bucket and the Auth emulator for the account's id after a deletion instead of naming collections, so a collection added later and missed by the purge fails it.
  • Web: lint, 72 key tests, both builds, 172 rules tests. The static page was loaded through the hosting emulator and the form appears.
  • Android: unit tests and the debug build. iOS builds. macOS builds with the deployment target raised on the command line; the documented command still fails on the existing SwiftData error.

Not tested:

  • Stripe was only ever a stand-in. Cancelling a subscription and deleting a customer have not run against the real API.
  • The whole delete flow was run in a browser against local emulators before the merge (wrong password, signed in, signed out). It has not been run again since the form moved into its own component.
  • No app was run on a device.
  • The paths for a deletion call that gets no answer are covered by build and type-check only.

The branch went through three rounds of independent review before the merge and one after it; no blocker is open.

Before merging

  • Confirm Firestore backups and point-in-time recovery are off, or add their retention to the page and the doc.
  • Check soft delete on the Storage bucket. Buckets made since 2024 keep deleted objects seven days by default; turn it off or add seven days to the page.
  • Check the Analytics retention setting. The page says "up to 14 months", which holds for either value.

🤖 Generated with Claude Code

cdeutsch and others added 30 commits November 12, 2026 15:10
…nder it

Google Play requires an app that lets people make an account to let them delete it, with its data, and until now closing an account meant writing to support.

`deleteAccount` is a callable that needs a sign-in no older than five minutes, checked from the token's `auth_time`, so every client asks for the password again before calling it. It cancels the Stripe subscription and deletes the customer first (the one step that can refuse, and a deleted account must never be charged again), revokes the refresh tokens, deletes the pushes, the stored files, the billing rows and the user document, and deletes the Auth user last so a call that stopped halfway can be made again. A second call succeeds and changes nothing.

A device left signed in keeps a working ID token for up to an hour, so `finishAccountDeletions` purges the same places again 70 minutes later from a marker that holds only two times, then removes the marker.

The emulator suite walks the whole database, the bucket and the Auth emulator for the account's id after a deletion rather than naming collections, so one added later and missed by the purge fails it. That needs the Auth emulator, which `npm run test:emulator` now starts on port 9099.

docs/account-deletion.md records what is kept and for how long.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The list drew each link's icon from Google's favicon service, which told Google the domain of every link pushed. Pushes are end-to-end encrypted so that nobody but their owner knows what they are, and Google Play's User Data policy would have that sharing declared.

The icon now comes from `/favicon.ico` at the link's own origin, with no referrer, and a site that has none there gets the PushThat icon. A proxy of our own was ruled out: it would move the same list of domains into our logs.

The rule that swapped a 16-pixel image for our icon is gone with it: it existed to hide Google's default globe, and a site's real favicon is often that size.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
… to it

Google Play requires a web address where someone can delete their account without the app installed, naming the app and the developer and saying what is deleted and what is kept.

`/delete-account` gives the steps, lists what is deleted, what is kept and for how long, and has the form: email (or the signed-in account), password and a confirmation that it is permanent. The password is always asked for, since the function refuses a sign-in older than five minutes. That sign-in is made on a second Firebase app that lives only for the call, so a signed-out visitor is never signed in to the site as a side effect and the page is not remounted halfway.

The menu gains "Delete account" under Logout, on the site and in the extension.

Support no longer says to write in to close an account, the terms name the page beside the email address, and the privacy policy describes deletion, what Stripe keeps, and that link icons are now asked of the linked site.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…TML too

The site is rendered in the browser, so a reader that runs no JavaScript got an empty page at every address. For these three that reader matters: Play's review opens the deletion page and the privacy policy, and someone must be able to read how to delete their account on any browser.

`npm run build` now renders each of them into a copy of the built `index.html`, and `firebase.json` rewrites `/delete-account`, `/terms` and `/privacy` to those files ahead of the catch-all. With JavaScript the app starts over the markup as before. The build fails if a copy is missing the text it is there to carry.

Rewrites rather than `cleanUrls`, which would redirect `/index.html`, the address an image push is shown at.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Each row of the list loaded its icon from Google's favicon service, which told Google the domain of every link pushed. Pushes are end-to-end encrypted so that nobody but their owner knows what they are, and Google Play's User Data policy would have that sharing declared.

`Favicons` now reads the linked site's front page, on the phone, for `<link rel="icon">` or `apple-touch-icon`, and falls back to `/favicon.ico` and then to the placeholder. Nothing goes through a server of ours, which would only move the list of domains into our logs.

The answers are kept per site and in memory only, and dropped at sign-out: on disk they would be a plaintext list of the sites an account pushes.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Google Play requires an app that lets people make an account to let them delete it from inside the app.

"Delete account" sits in the menu after "Show a new recovery key". Its dialog says what is deleted, that it is permanent and that encrypted pushes cannot be recovered afterwards, and asks for the password. The password is proved with a fresh sign-in (`AccountKeys.freshTokenFor`), because the `deleteAccount` function refuses a session older than five minutes.

Once the function answers, the app drops the pushes queued for a retry, its notification token, the Analytics id and the cached link icons, then signs out as any sign-out does and returns to the sign-in screen.

The release note says so, with the favicon change.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The same two changes as the Android app, made in both `apps/ios` and `apps/macos`, which share this behavior.

"Delete account" joins "Change password" and "Show a new recovery key" in the account menu. Its sheet says what is deleted and that encrypted pushes cannot be recovered, asks for the password, re-authenticates with it and calls the `deleteAccount` function with the fresh ID token, since the function refuses a sign-in older than five minutes. Afterwards the app removes the account's queued pushes, resets the Analytics id (iOS only: the Mac app does not link Analytics) and signs out.

Link icons no longer come from Google's favicon service, which was told the domain of every link pushed. `FaviconResolver` asks the linked site itself, reading its front page for `<link rel="icon">` and falling back to `/favicon.ico`, on an ephemeral session with no cookies. Its answers are held in memory only and cleared at sign-out.

One commit for both, because `AuthViewModel` clears the resolver at sign-out and the two do not build apart.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
`finishAccountDeletions` had no timeout set, so it ran under the default 60 seconds. It is also what finishes a deletion the callable's own five minutes cut off, which is an account with tens of thousands of pushes: each hourly run would spend its minute on that marker and be killed, and every deletion behind it would wait another hour per run.

It now has 540 seconds, the most a scheduled function is allowed.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
With no Stripe configuration, `deleteAccount` logged a warning and carried on. The purge then deleted `billing.customerId` and the `stripeCustomers` row, the only records of which customer was the account's, so a live subscription would have gone on renewing with nothing left that pointed at it. A deploy cannot be missing a secret, but one set to an empty value reads the same as off.

The callable now refuses in that case, before anything is deleted, unless it is running in the emulator. An account with no customer is deleted either way.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The suite was committed without a Prettier pass; this is that pass, with no change to what it tests.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A call that deleted the Stripe customer and then failed at a later step left the rows naming that customer in place, since only the purge removes them. The retry then listed the checkouts and subscriptions of a customer that no longer exists. If Stripe answers that with "no such customer" rather than an empty list, every retry told someone whose subscription was already gone that it could not be cancelled and nothing was deleted.

`endBilling` now reads that answer as nothing left to end. The stand-in in the suite answers as a deleted customer does, which the earlier retry test did not model.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
An ID token outlives the deletion by up to an hour and still passed `createCheckoutSession`, which would have made a Stripe customer and, once paid, a subscription for an account that no longer exists. The second purge removed the Firestore rows and never asked Stripe, so that subscription would have renewed with nothing pointing at it.

`createCheckoutSession` now refuses an account that has a deletion marker. `finishDeletions` also ends whatever the account has at Stripe before it purges, for a checkout opened in the moment before the marker was written; if Stripe refuses, the marker stays and the next hour tries again.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The marker is what finishes a deletion nobody retries, and the one path that proves it was untested: a first call that purges the data and then fails before deleting the Auth user.

The new test stops the first call there and checks that `finishDeletions` leaves nothing under the account, the user included.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…removed

The page called `deleteAccount` with the SDK's default 70 second timeout while the function allows itself five minutes for a large account. Past 70 seconds the page reported "Nothing was removed" and the server went on to delete everything. A retry then failed at sign-in with "The email or password is not right. Nothing was deleted", which was also untrue.

The call now waits 310 seconds, longer than the function can run. When it still ends without an answer, the page asks Firebase whether the account exists: gone means the deletion finished and is treated as success, and otherwise the page says the outcome could not be confirmed and how to tell. "Nothing was deleted" is left to the function's own refusals, which are made before anything is.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The flag that carries the result across the page's remount was never put back. After one deletion, every later visit to `/delete-account` in the same tab said "Your account is deleted" and showed no form: the next person at a shared computer, or the same person signed in to another account, could not delete anything without a reload.

The page that mounts after the deletion now reads the flag and clears it, and the flag is only set at all when the browser was signed in, which is the only case that remounts.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The in-app steps said to "press Delete", but the button in the Android, iOS and macOS apps reads "Delete account". Someone following the page word for word was looking for a button that is not there.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The button's text was a literal `#1a1a1a`, which is the value of `--color-background`. Written as the variable it follows the theme instead of matching it by coincidence.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The web menu had it below Logout while the Android, iOS and macOS menus have it above. One order everywhere, so the item is where someone who has used another client expects it.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The static copies of `/delete-account`, `/terms` and `/privacy` showed their text, went blank, and showed it again: React replaces the root's markup on its first render, and `AuthProvider` renders nothing until Firebase has read the session back from IndexedDB.

On a page that arrives with markup in the root, the app now starts once `authStateReady` settles, so the static text stays until there is something to put in its place. Other addresses start at once, as before. docs/web.md said that with JavaScript nothing changes, which was not true of the blank frame; it now says what happens.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Adding Delete account put its failure wording into `messageFor`, which Change password and Show a new recovery key share. Someone who mistyped their current password while changing it was told "That is not your password. Nothing was deleted", and a network error in either setting read "Your account could not be deleted".

Deletion's failures are now worded in `deleteAccount` itself, and the wrong-password sentence says only that, which is true of every setting that asks for the password.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…removed

The app gave the `deleteAccount` call two minutes while the function allows itself five for a large account. Past two minutes the app showed "Your account could not be deleted. Nothing was removed", the server went on to delete everything, and the local cleanup and sign-out never ran. A retry then failed at re-authentication with Firebase's raw message.

The read timeout is now 310 seconds, longer than the function can run. When the call still ends without an answer, the app asks Firebase whether the account exists: gone means the deletion finished, and the cleanup and sign-out run. So does a retry that finds the account already gone. Otherwise the message says the outcome could not be confirmed and how to tell, in the words the web page uses.

Only the function's own refusals, made before anything is deleted, are shown as refusals. A failure to reach Firebase for the sign-in gets its own sentence, since then nothing was asked at all.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
After the server has deleted the account, the app deleted its FCM token before signing out, with no time limit, behind a progress dialog that cannot be dismissed. On a connection that dropped at that moment the person was left looking at a spinner for an account that no longer exists.

The token delete now gets five seconds. It is a courtesy: the copy the server used to address the device went with the user document.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…droid

`freshTokenFor` promised "the same passwords in the same order as `signInToFirebase`" and kept that promise with a second hand-written list. A change to the legacy fallback in one would have left the re-authentication before a deletion quietly trying different passwords from the sign-in.

Both now take them from `legacyPasswords`.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…ached it

A front page that could not be read (no network, a timeout) was cached like any other answer, as "try `/favicon.ico`". A site whose icon is only named in its page then stayed on the fallback or the placeholder until the process died, because the list was once opened without a connection.

A lookup the site did not answer is now served that once and forgotten, so the next row to show the site asks again.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Site icons were loaded with `override(Target.SIZE_ORIGINAL)`, copied from the bundled file icons. The lookup tries the largest icon a site declares first, so a 512 pixel touch icon was decoded and held whole, about a megabyte of bitmap, to fill a 36 dp square.

Glide now sizes them to the view.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The view cancelled its lookup in `onDetachedFromWindow`. RecyclerView reattaches a row from its view cache without binding it again, so a row that left the screen mid-lookup came back showing the placeholder, with nothing left to replace it.

The lookup is now cancelled only when the view is bound to another row, which `loadFavicon` already did.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The comment said the answers are kept in memory only, which is true of the map of sites and reads as if nothing about them reaches the disk. Glide caches the downloaded images there, under hashed names, as it did before this change, and only account deletion clears that cache.

The comment now says so, so nobody relies on a guarantee the code does not make.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
`known` reads a finished lookup with `getCompleted`, which Kotlin flags as experimental, and the build warned on it. The opt-in is declared where it is used, with the reason it is safe there.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…tcome from a refusal

Both apps gave the `deleteAccount` call two minutes while the function allows itself five for a large account. Past two minutes they showed "Could not delete the account", the server went on to delete everything, and the local cleanup and sign-out never ran. A retry then failed at re-authentication.

The timeout is now 310 seconds, longer than the function can run. When the call still ends without an answer, the app asks Firebase whether the account exists: gone means the deletion finished, and the cleanup and sign-out run, as they do for a retry that finds the account already gone. Otherwise the sheet says the outcome could not be confirmed, in the words the web page and Android use. Only the function's own refusals, made before anything is deleted, are shown as its message.

The iOS app also deletes its notification token after a deletion, as Android does; the Mac app has none. Made in both apps, which share this flow.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…eached nobody

Two things about how long `FaviconResolver` keeps what it finds, which matter most on the Mac, where the app runs for weeks.

A lookup that failed because the network was down was remembered as "this site has no icon", so a list drawn as a Mac woke, before Wi-Fi returned, showed placeholders until the app was quit. A site is now remembered as having no icon only when it answered; a lookup that reached nobody is forgotten and the row asks again.

Icons were decoded at full size and kept in a dictionary that never shrank, and the largest icon a site declares is tried first: a 512 pixel one is a megabyte of bitmap for a 40 point square. They are now decoded at 120 pixels through ImageIO and held in an `NSCache` of 200, which the system empties under memory pressure.

Both in one commit because they rewrite the same bookkeeping. The file is the same in `apps/ios` and `apps/macos`.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@github-actions github-actions Bot added functions Cloud Functions (functions) android The Android app (apps/android) ios The iOS app (apps/ios) macos The macOS app (apps/macos) labels Nov 12, 2026
cdeutsch and others added 6 commits November 12, 2026 16:16
… branch's

While this branch was in review, main gained both of the things it did besides account deletion, each by a different design: a link's icon now travels inside the encrypted push and the file icons are bundled with each app, and the public pages are prerendered from `src/staticPages.tsx`. Two icon systems and two prerender steps cannot both stand, and main's are the ones already merged and written into the spec.

So this merge keeps main's side of every file the two share and removes what this branch had built for the same purpose: `Favicons` and its test on Android, `FaviconResolver` on iOS and macOS, the `/favicon.ico` lookup on the web, and this branch's prerender script, its `main.tsx` and its `AuthContext` changes.

Account deletion is kept and fitted to main:

- `/delete-account` joins `STATIC_PAGES`, with its rewrite in `firebase.json`. A static page may not import Firebase or hold state, so the form moves to `components/DeleteAccountForm.tsx`, which the page imports from an effect. Not `lazy`: rendering a `lazy` component starts its import in the build's render too, which loaded Firebase in Node and hung the build.
- Android's deletion no longer clears a Glide cache, since main removed Glide, and the Apple apps no longer clear an icon cache at sign-out.
- The privacy policy's sentence about asking the linked site for its icon is gone, since main's policy already says what happens now. docs/web.md keeps main's account of the static pages, with the deletion page and the effect-import rule added.
- The Android release note is main's with the Delete account line added.
- `firebase.json` keeps the Auth emulator this branch's suite needs.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Making room for the Delete account line, the merge dropped main's clause that an unconfirmed share is retried and not dropped, which is a change someone holding the phone would notice. The icon sentence is shortened instead, and the note is 485 characters.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…vaScript

The static copy of the page holds the line shown while the form loads, under the notice that the form needs JavaScript. With JavaScript off that line is permanent, and it told the reader to reload the page, which can never help them.

It now also says to write to support, which can.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Since main's change a push carries its page's icon, and the privacy policy counts it as push content and points here for "exactly what is deleted". This list still named only links, titles and file records.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…the app closes

Before the merge every app cleared its in-memory icon lookups at sign-out and deletion, and the doc's table said so. Main's design keeps a bounded cache of decoded icons instead, which nothing empties, and the merge removed both the clearing and the row.

The row is back, saying what is true now: the icons stay in the process's memory until it ends.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The lines added for the deletion page left one line of 106 characters and another broken short. Rewrapped to the file's width, with no change to the words.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@cdeutsch cdeutsch changed the title Add account deletion and fetch link icons from the linked site Add account deletion: a function, a public page, and a menu item on every client Nov 12, 2026
cdeutsch and others added 19 commits November 12, 2026 16:31
The purge deleted the push documents and then the files under the account's prefix. Every document deleted starts `onPushRecordDeleted`, which deletes that push's object, so the trigger and the purge were deleting the same objects at once. The SDK's `deleteFiles` stops at the first object that is already gone, which failed the whole deletion before the Auth user was removed: the client then showed "could not confirm", and the likelihood rose with every file the account had. The emulator suite runs no triggers and could not see it.

The files now go first, so each trigger finds its object gone and stops, and they are listed and deleted with `ignoreNotFound`, so an object removed by anything else in the meantime is not an error either.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
`createCheckoutSession` read the marker by a path written out a second time, because the file that owns the marker imports the one the checkout is in. Renaming the collection in one and not the other would have quietly reopened the checkout to a deleted account's last token.

The marker's references move to `deletion-marker.ts`, which both files import.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
`account-deletion.ts` and `deleteCustomer` each spelled out the same check of a `StripeError`'s status and code. What Stripe's errors look like is `stripe.ts`'s to know, so the check is exported from there and used in both.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The file comment said the marker holds "only the time"; it holds two, as the doc, the page and the test say. And one log line wrote `push(es)` where every other line in the functions builds the plural.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
When the password was refused, every client asked Firebase about the session it already held and took "token expired" to mean the account was gone. A password change or a reset revokes every session too, and the deletion page tells people who forgot their password to reset it first. So someone who then opened Delete account on a device with an older session and mistyped was signed out and told "Your account is deleted", when the server had never been asked and nothing would ever be deleted.

On Android, iOS and macOS a revoked session now counts as gone only straight after the deletion's own sign-in, when nothing but the function can have revoked it; on the retry path only "no such user" counts. On the web Firebase gives the same answer for both cases, so the page no longer asks its session at all: a refused password is reported as that, and the unconfirmed message says what it means.

docs/account-deletion.md is corrected to match. Made in both Apple apps.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
… gone

The deletion ran in the view model's scope, which a rotation survives and swiping the app away does not. The call can last five minutes. Cancelled in that time, the request still completed and the server still deleted the account, but the cancellation was rethrown on the way back and the local cleanup never ran: the install stayed signed in to a deleted account, with its queued pushes, notification token and Analytics id.

Once the server is asked, the call and the cleanup now run without being cancellable.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The app decides from the response alone between "the function refused, nothing was deleted" and "no answer, something may have been", and nothing tested that. A drift there (a new status on the server, a gateway's HTML error page) tells someone the wrong thing about their account.

The reading moves out of the request into `answerOf`, a pure function, with tests for a deletion, each refusal, an internal error, an unknown status, a refusal with no sentence and a body that is not JSON.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…deleted"

The message for a deletion whose outcome is unknown ended "if your password is then refused, the account is gone". A mistyped password is refused too. Someone who retried, mistyped and saw "That is not your password" had just been taught to read that as their account being gone, while it still existed.

It now says the account is gone "if signing in with the right password no longer works", which is true on every client. The same words in the web page and the Android, iOS and macOS apps.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A signed-in browser shows no email field on the deletion form, yet a wrong password read "The email or password is not right", naming something the person never typed. It now says what the apps say in the same case. The signed-out form keeps its wording, since there either could be wrong.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The call to delete stays open until the server has removed everything, which for a large account takes minutes. iOS suspends an app soon after it is put away, and the call is lost with it: the account is still deleted, but the sheet could then only say it could not confirm that, and the cleanup waited for a second try.

While it works, the sheet now says to keep PushThat open and that it can take a few minutes. Made in both apps, which share the sheet; a Mac does not suspend the app, and the sentence is as true there.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The steps sent iPhone and Mac users to "the account menu", which neither app has: the menu there is an unlabeled circled three dots. They also told everyone to enter a password and press "Delete account", but on the website and in the extension that menu item opens this page, where the button reads "Delete my account" behind a checkbox. Someone following the website's steps word for word, Play's reviewer included, would not find the button named.

The steps now name each menu as it looks, say that the website and extension lead to the form on this page, and keep the password step for the three apps.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
`errorText` reads the sentence out of any callable's refusal, and account deletion was importing the plan service to get it, for something that has nothing to do with plans. It moves to `services/callable.ts`. The plan service still exports it, so the plan pages are untouched.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The page's headings were italic and underlined, a rule copied from the terms, which are a legal document and look like one. This page sits beside Support and Security, the plain-language pages, which use the shared headings as they are, and now does the same.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…e push trigger

Three leftovers from editing: a sentence in the deletion doc that tripped over itself ("What "what it holds" is differs"), paragraphs there and in AGENTS.md that broke short after edits, and a stray blank line at the doc's end.

The doc also gains a note that deleting an account starts `onPushRecordDeleted` once per push, so a large deletion shows as a burst of short invocations and log lines.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
`onPushRecordDeleted` looked whether the object existed and then deleted it. Account deletion is now a second thing that deletes those objects in bulk, so a push removed by something else while a purge ran (the expiry sweep, a delete from another device) could pass the look and then lose the object before its delete, which threw and logged an error for a file that was already gone, as intended.

The trigger now deletes with `ignoreNotFound`, as the purge and the orphan sweep do, which also saves the look.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…it cannot

The comment on the cleanup said it covered someone swiping the app away during the call. It covers leaving the screen while the process lives. Swiping an app out of Recents kills the process, which nothing in it survives: the session then ends on its own and the key goes, but the queued pushes and the notification token stay until that install is next signed out.

The comment now says so, and the doc's account of what each client clears names the gap. The doc's line about the web's retry message is also corrected, since a signed-in browser now says "That is not your password."

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The comment explained the hint by iOS suspending an app that is put away, in a file that is the same in both apps, and a Mac does not do that. There the call is lost by quitting the app or the Mac sleeping. One comment now gives both reasons, so the file stays identical in `apps/ios` and `apps/macos`.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…on doc

The previous commit's message says the doc was updated with it; the doc change did not make it into that commit, and this is it.

The doc now says that an Android process killed during the call never reaches the local cleanup, and what is left behind. It also corrects the web's retry message, which since a signed-in browser says "That is not your password" is one of two sentences, not one.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Moving `errorText` to `services/callable.ts` left a re-export in the plan service so its two importers need not change, which kept two paths to a helper that is not about plans, for good. The plan hook and page now import it from `callable`, and the re-export is gone.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

android The Android app (apps/android) functions Cloud Functions (functions) ios The iOS app (apps/ios) macos The macOS app (apps/macos) web The web app and the Chrome extension (apps/web)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant