From 7c5756179ab940e1205e1e7564913a1fdf7877ff Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 15:10:35 -0600 Subject: [PATCH 01/64] Add a deleteAccount function that deletes an account and everything under 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 --- AGENTS.md | 7 +- docs/README.md | 2 + docs/account-deletion.md | 107 +++++ docs/billing.md | 2 +- firebase.json | 3 + functions/jest.emulator.config.js | 2 +- functions/package.json | 2 +- functions/src/account-deletion.test.ts | 35 ++ functions/src/account-deletion.ts | 284 ++++++++++++ functions/src/index.ts | 1 + functions/src/stripe-functions.ts | 7 +- functions/src/stripe.ts | 16 + .../test/emulator/account-deletion.test.ts | 407 ++++++++++++++++++ functions/test/emulator/check-ports.mjs | 2 +- functions/test/emulator/harness.ts | 25 +- 15 files changed, 889 insertions(+), 13 deletions(-) create mode 100644 docs/account-deletion.md create mode 100644 functions/src/account-deletion.test.ts create mode 100644 functions/src/account-deletion.ts create mode 100644 functions/test/emulator/account-deletion.test.ts diff --git a/AGENTS.md b/AGENTS.md index b2a83c8..c575ac5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,9 +111,10 @@ npm ci && node --check index.js && npm test ``` `npm run test:rules` and `npm run test:emulator` run against the Firestore and Storage -emulators, through the root `firebase.json`. They need a Java runtime and the Firebase CLI, -and the emulator ports free (8080 and 9199): a run refuses to start otherwise, naming the -port, and one run cannot share another's emulators, so run them one after the other. The +emulators, through the root `firebase.json`; the functions' suites start the Auth emulator +too. They need a Java runtime and the Firebase CLI, and the emulator ports free (8080 and +9199, and 9099 for Auth): a run refuses to start otherwise, naming the port, and one run +cannot share another's emulators, so run them one after the other. The functions' suites deliver signed events to the webhook over HTTP and point `STRIPE_API_URL` at a stand-in for Stripe's API, so nothing reaches Stripe. diff --git a/docs/README.md b/docs/README.md index b40f080..12c7698 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,5 +13,7 @@ house style ([AGENTS.md](../AGENTS.md)). and what stays manual. - [Billing](billing.md): the plan, the Stripe accounts, and how the functions and the rules enforce it. +- [Account deletion](account-deletion.md): what deleting an account removes, the callable and + its second purge, and what is kept for how long. - [End-to-end encryption](e2ee.md): the specification for encrypting pushes, which every client implements and the rules require. diff --git a/docs/account-deletion.md b/docs/account-deletion.md new file mode 100644 index 0000000..54e61ed --- /dev/null +++ b/docs/account-deletion.md @@ -0,0 +1,107 @@ +# Account deletion + +Anyone with an account can delete it, with everything stored under it, from inside each +app and from a public page, [pushthat.link/delete-account](https://pushthat.link/delete-account). +Google Play requires both of any app that lets people make an account, and requires that +deleting the account deletes its data: an account that is only switched off does not count. + +## What an account is, and where it lives + +Everything the project holds about an account is keyed by its Firebase uid: + +| Where | What | +| ---------------------------------------- | ----------------------------------------------------------------------------------------------- | +| Firebase Authentication | The email address, the password hash, the sessions | +| `users/{uid}` | Email, device notification tokens, the wrapped account key and recovery wrap, usage, plan, Stripe ids | +| `pushes` where `userId` is the uid | The encrypted links and file records, with their search tokens and device ids | +| Storage, `uploads/{uid}/` | The encrypted files | +| `stripeCustomers`, `billingEvents` | Which Stripe customer is the account's, and the webhook events applied to it | +| Stripe | The customer (email) and its subscription | + +`functions/src/account-deletion.ts` empties all of them. Its emulator suite +(`functions/test/emulator/account-deletion.test.ts`) does not list those places: it seeds +an account, deletes it, and then walks the whole database, the whole bucket and the Auth +emulator for the uid. A collection added later that is keyed by the account and missed by +the purge fails that suite without anyone remembering to add it. + +## The `deleteAccount` callable + +A Firebase callable, `https://us-central1-push-that.cloudfunctions.net/deleteAccount`, +called with the account's ID token and an empty payload. It answers `{ deleted: true }`. + +**It needs a sign-in no older than five minutes** (`FRESH_SIGN_IN_S`, read from the token's +`auth_time`). Every client asks for the password and signs in again right before the call. +The check is in the function because one made only in a client is one a modified client +skips. The function is not given the password itself: Firebase's password for an account +is a key derived from it (see [end-to-end encryption](e2ee.md)), and the sign-in that +proves it is the one every client already has. + +In order: + +1. **Stripe.** Open checkouts are ended, any subscription still running is cancelled, and + the customer is deleted. This goes first because it is the one step that can refuse, + and a deleted account that is still charged at its renewal has no page left to cancel + from: if the subscription cannot be cancelled, nothing has been deleted yet and the + person is told to try again. A customer that will not delete does not stop the rest; + it is logged to be removed by hand. No refund is made here: the refund policy is a + separate request (the terms, Refunds). +2. **A marker**, `accountDeletions/{uid}`, holding two times and nothing else (see below). +3. **The refresh tokens are revoked**, so no device can renew its session. +4. **The data**: the pushes, everything under `uploads/{uid}/`, the billing rows, and + `users/{uid}` with anything under it. +5. **The Auth user**, last. It is what lets the person ask again, so if a step before it + fails they can still sign in, and the next call picks up where this one stopped. + +Every step is content to find its work already done, so the call is idempotent: a second +call, or one for an account already deleted (its last ID token can still reach the function +for an hour), succeeds and changes nothing. + +## Signing out the other devices, and the second purge + +Deleting the Auth user signs a device out when its ID token next refreshes, which is up to +an hour away. Until then that token still passes the Firestore and Storage rules, so a +phone left signed in could write its notification token back to `users/{uid}`, and a push +made in the same second as the deletion can still be counted by `onNewPushCreated` +afterwards, which makes the user document it does not find. + +Refusing those writes in the rules would mean reading a second document on every push for +the life of the project. Instead `finishAccountDeletions` runs every hour and, for each +marker older than 70 minutes (an ID token's hour, and ten minutes for a slow clock or a +late trigger), empties the same places again and removes the marker. So the first purge is +immediate, and the second is what makes "nothing is left" true without depending on what +the clients do. The marker is the only thing kept about a deleted account in our own +database, for at most about two hours. + +## What cannot be deleted at once + +This is the list the public page and the Play Data safety form are written from. If one of +these changes (a backup turned on, a log retention raised), change the page with it. + +| What | Kept for | Why | +| ------------------------------------------------- | ------------------------------- | -------------------------------------------------------------------- | +| The marker, `accountDeletions/{uid}` | About two hours | The second purge, above | +| Server logs (Cloud Logging) | 30 days, Cloud Logging's default | The functions log the uid and stored file paths, never an email or push content | +| Google's backups of Authentication, Firestore and Storage | Up to 180 days | Google's own disaster-recovery copies, which expire on its schedule and cannot be read out per account | +| Payment records at Stripe | As long as tax law requires | Stripe is the seller of record and keeps its own books | +| Google Analytics for Firebase events (Android, iOS) | Up to 14 months | Not keyed to the account or email, only to an installation id, which the app resets on deletion | + +Three of these depend on settings outside the repository, which nothing here can check: + +- **Firestore backups and point-in-time recovery are off**, and must stay off or be added + to the table: with either on, a deleted account's documents would be restorable for the + backup's retention. What a restore could bring back is ciphertext whose key is gone, + apart from the email and the device tokens, which are plaintext. +- **The Storage bucket's soft delete.** A bucket made since 2024 keeps deleted objects for + seven days by default. Turn it off for `push-that.firebasestorage.app` + (`gcloud storage buckets update gs://push-that.firebasestorage.app --clear-soft-delete`), + or add seven days to the table and the page. The objects are ciphertext either way. +- **Analytics retention** is set in the Analytics property (Admin, Data retention): two + months or fourteen. The page says "up to 14 months", which holds for both. + +## Local data on each device + +After the function answers, the client that asked clears what it holds and signs out: the +account key and any unconfirmed recovery key, the decrypted file cache, and the queue of +pushes waiting to be retried. On Android and iOS it also resets the installation's +Analytics id. The device id stays, as it does at sign-out: it belongs to the install, not +the account. diff --git a/docs/billing.md b/docs/billing.md index 1f4118b..312d4e0 100644 --- a/docs/billing.md +++ b/docs/billing.md @@ -84,4 +84,4 @@ A file is uploaded before its push is made, so an upload whose push never comes Locally, the three secrets go in `functions/.secret.local` (ignored by git) as `NAME=value` lines, and `stripe listen` forwards the sandbox's events to the emulator as above. `APP_URL` may be set in the emulator's environment to send the checkout back to a local web app. -The rules are tested on their own with `npm test` (`plan.test.ts`, `stripe.test.ts`, `stripe-events.test.ts`). The glue around them, the webhook's refusals and its event record, the stamp's guard in `applyPatch`, the order deliveries land in, the refresh, the checkout, the count a push adds and the limit applied again at it, and the expiry sweep and backfill, is tested against the Firestore emulator with `npm run test:emulator` (`functions/test/emulator`, a Java runtime and the Firebase CLI needed), and so is the orphan sweep's listing, paging, cap and deletes, against the Storage emulator beside it. A run needs the emulator ports free (8080 and 9199) and refuses to start otherwise, naming the port: one run cannot share another's emulators. The emulators load the rules the root `firebase.json` names, but the suites drive the functions through the Admin SDK, which rules never apply to, so they say nothing about what the clients may do: the rules live in `apps/web` and are tested there with `npm run test:rules`. Those suites deliver signed events to the webhook over HTTP and point `STRIPE_API_URL` at a stand-in for Stripe's API, so nothing reaches Stripe. +The rules are tested on their own with `npm test` (`plan.test.ts`, `stripe.test.ts`, `stripe-events.test.ts`). The glue around them, the webhook's refusals and its event record, the stamp's guard in `applyPatch`, the order deliveries land in, the refresh, the checkout, the count a push adds and the limit applied again at it, and the expiry sweep and backfill, is tested against the Firestore emulator with `npm run test:emulator` (`functions/test/emulator`, a Java runtime and the Firebase CLI needed), and so is the orphan sweep's listing, paging, cap and deletes, against the Storage emulator beside it. A run needs the emulator ports free (8080 and 9199, and 9099 for the Auth emulator that account deletion's suite uses) and refuses to start otherwise, naming the port: one run cannot share another's emulators. The emulators load the rules the root `firebase.json` names, but the suites drive the functions through the Admin SDK, which rules never apply to, so they say nothing about what the clients may do: the rules live in `apps/web` and are tested there with `npm run test:rules`. Those suites deliver signed events to the webhook over HTTP and point `STRIPE_API_URL` at a stand-in for Stripe's API, so nothing reaches Stripe. diff --git a/firebase.json b/firebase.json index 94faaf3..2bd80ca 100644 --- a/firebase.json +++ b/firebase.json @@ -40,6 +40,9 @@ "functions": { "port": 5001 }, + "auth": { + "port": 9099 + }, "firestore": { "port": 8080 }, diff --git a/functions/jest.emulator.config.js b/functions/jest.emulator.config.js index fa89241..e0b9fc4 100644 --- a/functions/jest.emulator.config.js +++ b/functions/jest.emulator.config.js @@ -1,4 +1,4 @@ -// The suites that run against the Firestore and Storage emulators (test/emulator): `npm run test:emulator`. +// The suites that run against the Auth, Firestore and Storage emulators (test/emulator): `npm run test:emulator`. module.exports = { preset: 'ts-jest', testEnvironment: 'node', diff --git a/functions/package.json b/functions/package.json index 0d85bbf..180e26f 100644 --- a/functions/package.json +++ b/functions/package.json @@ -11,7 +11,7 @@ "logs": "firebase functions:log", "test": "jest", "pretest:emulator": "node test/emulator/check-ports.mjs", - "test:emulator": "firebase emulators:exec --only firestore,storage --project demo-pushthat 'jest --config jest.emulator.config.js --silent'", + "test:emulator": "firebase emulators:exec --only auth,firestore,storage --project demo-pushthat 'jest --config jest.emulator.config.js --silent'", "test:watch": "jest --watch" }, "engines": { diff --git a/functions/src/account-deletion.test.ts b/functions/src/account-deletion.test.ts new file mode 100644 index 0000000..173dea6 --- /dev/null +++ b/functions/src/account-deletion.test.ts @@ -0,0 +1,35 @@ +import { FRESH_SIGN_IN_S, signedInRecently } from './account-deletion'; + +/** + * The one rule in account deletion that is arithmetic: how old a sign-in may be. What a + * deletion removes is tested against the emulators (test/emulator/account-deletion.test.ts). + */ + +const NOW = new Date('2026-11-12T12:00:00Z'); +const seconds = (date: Date) => Math.floor(date.getTime() / 1000); + +describe('signedInRecently', () => { + test('a sign-in a moment ago passes', () => { + expect(signedInRecently(seconds(NOW) - 5, NOW)).toBe(true); + }); + + test('a sign-in at the edge passes, and one past it does not', () => { + expect(signedInRecently(seconds(NOW) - FRESH_SIGN_IN_S, NOW)).toBe(true); + expect(signedInRecently(seconds(NOW) - FRESH_SIGN_IN_S - 1, NOW)).toBe(false); + }); + + test('a session from yesterday does not pass', () => { + expect(signedInRecently(seconds(NOW) - 24 * 60 * 60, NOW)).toBe(false); + }); + + test('a clock a few seconds ahead passes, and a sign-in far in the future does not', () => { + expect(signedInRecently(seconds(NOW) + 10, NOW)).toBe(true); + expect(signedInRecently(seconds(NOW) + 3600, NOW)).toBe(false); + }); + + test('a token with no sign-in time does not pass', () => { + expect(signedInRecently(undefined, NOW)).toBe(false); + expect(signedInRecently('1700000000', NOW)).toBe(false); + expect(signedInRecently(NaN, NOW)).toBe(false); + }); +}); diff --git a/functions/src/account-deletion.ts b/functions/src/account-deletion.ts new file mode 100644 index 0000000..16937d4 --- /dev/null +++ b/functions/src/account-deletion.ts @@ -0,0 +1,284 @@ +import { getAuth } from 'firebase-admin/auth'; +import { Firestore, Timestamp, getFirestore } from 'firebase-admin/firestore'; +import { getStorage } from 'firebase-admin/storage'; +import * as logger from 'firebase-functions/logger'; +import { HttpsError, onCall } from 'firebase-functions/v2/https'; +import { onSchedule } from 'firebase-functions/v2/scheduler'; + +import { + ENDED_SUBSCRIPTION, + StripeConfig, + StripeError, + deleteCustomer, + ensureSubscriptionCanceled, + expireOpenCheckouts, + subscriptionsOf, +} from './stripe'; +import { secrets, stripeConfig, uidOf } from './stripe-functions'; + +/** + * Deleting an account (docs/account-deletion.md): the `deleteAccount` callable every + * client and the public page at /delete-account call, and the sweep that finishes the + * job an hour later. + * + * Everything the project holds about an account is keyed by its uid, in these places, + * and all of them are emptied: + * + * - `users/{uid}` and anything under it: the email, the device tokens, the wrapped + * account key and recovery wrap (`e2ee`), the month's usage, the plan and Stripe ids. + * - `pushes` where `userId` is the uid: the encrypted links and file records. + * - Storage under `uploads/{uid}/`: the encrypted files, whether or not a push names them. + * - `stripeCustomers` and `billingEvents` rows that name the uid. + * - The Firebase Authentication user: the email, the password hash and every session. + * - At Stripe, the customer, after its subscription is ended, so a deleted account is + * never charged again. + * + * It is a delete, not a flag: nothing is kept to restore from, and the account key going + * is what makes the pushes unrecoverable even from a backup of their ciphertext. + * + * Deleting the Auth user signs a device out only when its ID token next refreshes, which + * is up to an hour away, and until then that token still passes the rules: a phone left + * signed in could write its notification token back to `users/{uid}`, or a push made in + * the same second could be counted by `onNewPushCreated` after the purge. Refusing those + * in the rules would cost every push a second document read for the life of the project. + * Instead a marker, `accountDeletions/{uid}`, is left holding only the time, and + * `finishAccountDeletions` purges the same places again once every token the account + * ever had has expired, then removes the marker. So the first purge is immediate and the + * second is what makes "nothing is left" true without depending on the clients. + */ + +/** + * How recent the sign-in must be, in seconds. The clients ask for the password and sign + * in again right before the call, so five minutes is time for the round trip and no + * more: a session left open on a shared computer cannot delete the account, which is + * what asking for the password is for. Checked here, from the token's `auth_time`, + * because a check made only in a client is one a modified client skips. + */ +export const FRESH_SIGN_IN_S = 5 * 60; + +/** + * How long after a deletion the second purge waits: an ID token lives an hour, and ten + * minutes on top covers a clock that is off and a trigger that started late. + */ +export const DELETION_SETTLE_MS = 70 * 60 * 1000; + +/** How many documents one batch deletes: Firestore's limit on a commit. */ +const DELETE_BATCH = 500; + +/** The marker a deletion leaves until the second purge: when, and nothing else. */ +const deletionRef = (db: Firestore, uid: string) => db.collection('accountDeletions').doc(uid); + +/** The two calls made to Firebase Authentication, so the suites can watch them. */ +export interface AuthAdmin { + revokeRefreshTokens(uid: string): Promise; + deleteUser(uid: string): Promise; +} + +export interface DeletionDeps { + db?: Firestore; + auth?: AuthAdmin; + /** Billing, when it is on. */ + stripe?: StripeConfig; + now?: Date; +} + +/** Whether the token's sign-in (`auth_time`, Unix seconds) is recent enough at `now`. */ +export function signedInRecently(authTime: unknown, now: Date, maxAgeS = FRESH_SIGN_IN_S): boolean { + if (typeof authTime !== 'number' || !Number.isFinite(authTime)) return false; + const age = now.getTime() / 1000 - authTime; + // A minute of clock skew the other way is let through; a sign-in from the future is not. + return age <= maxAgeS && age >= -60; +} + +/** Deletes every document `query` matches, a commit's worth at a time; returns how many. */ +async function deleteMatching(db: Firestore, query: FirebaseFirestore.Query): Promise { + let deleted = 0; + for (;;) { + const page = await query.limit(DELETE_BATCH).get(); + if (page.empty) return deleted; + const batch = db.batch(); + for (const doc of page.docs) batch.delete(doc.ref); + await batch.commit(); + deleted += page.size; + } +} + +export interface PurgeReport { + pushes: number; + billingRows: number; +} + +/** + * Empties every place the project keeps something under `uid`, except the Auth user and + * Stripe. Safe to run again: each step deletes what is there and is content with nothing. + * + * The stored files are deleted by prefix rather than left to `onPushRecordDeleted`, which + * removes a file push's object as its document goes: that trigger only sees an object a + * push names, and an upload whose push never came would wait for the orphan sweep's + * seven days. The trigger still fires for each push and finds its object already gone. + * + * A push made in the moment before this ran may still be on its way through + * `onNewPushCreated`, whose count makes the user document it does not find. No order of + * the steps here closes that, which is one of the things the second purge is for. + */ +export async function purgeAccountData(db: Firestore, uid: string): Promise { + const pushes = await deleteMatching(db, db.collection('pushes').where('userId', '==', uid)); + await getStorage() + .bucket() + .deleteFiles({ prefix: `uploads/${uid}/` }); + const billingRows = + (await deleteMatching(db, db.collection('billingEvents').where('userId', '==', uid))) + + (await deleteMatching(db, db.collection('stripeCustomers').where('userId', '==', uid))); + // Recursive, for whatever is under the document: `usage` was a subcollection once. + await db.recursiveDelete(db.collection('users').doc(uid)); + return { pushes, billingRows }; +} + +/** The Stripe customers the account has: the one its document names, and any row that names the account. */ +async function customersOf(db: Firestore, uid: string): Promise { + const [user, rows] = await Promise.all([ + db.collection('users').doc(uid).get(), + db.collection('stripeCustomers').where('userId', '==', uid).get(), + ]); + const fromDoc = (user.data() as { billing?: { customerId?: unknown } } | undefined)?.billing?.customerId; + return [...new Set([...(typeof fromDoc === 'string' && fromDoc ? [fromDoc] : []), ...rows.docs.map((d) => d.id)])]; +} + +/** + * Ends what the account has at Stripe. The subscription is what must not survive: an + * account that no longer exists would be charged again at its renewal, with no page left + * to cancel from. So a subscription Stripe will not cancel stops the deletion before + * anything has been deleted, and the person can try again. Open checkouts are ended + * first, so one left in another tab cannot be paid afterwards. + * + * Deleting the customer itself is not allowed to stop it. By then nothing can charge the + * account, and refusing to delete someone's data because a record at the payment + * processor would not go is the worse failure. It is logged with the customer's id, to + * be removed by hand in the dashboard. + */ +async function endBilling(stripe: StripeConfig, customerIds: string[], uid: string): Promise { + for (const customerId of customerIds) { + await expireOpenCheckouts(stripe, customerId); + for (const sub of await subscriptionsOf(stripe, customerId)) { + if (!ENDED_SUBSCRIPTION.has(sub.status)) await ensureSubscriptionCanceled(stripe, sub.id); + } + try { + await deleteCustomer(stripe, customerId); + } catch (err) { + logger.error( + `[account] user=${uid}: Stripe would not delete customer ${customerId}; delete it by hand: ${(err as Error).message}` + ); + } + } +} + +const isUserNotFound = (err: unknown) => (err as { code?: string } | null)?.code === 'auth/user-not-found'; + +/** Runs `step`, taking "there is no such user" as done: a second call finds the user already gone. */ +async function unlessGone(step: () => Promise): Promise { + try { + await step(); + } catch (err) { + if (!isUserNotFound(err)) throw err; + } +} + +/** + * Deletes the account `uid`, for good. In order: Stripe (the one step that can refuse, + * so it goes while nothing is deleted yet), the marker for the second purge, the refresh + * tokens (so no device can renew its session from here on), the data, and last the Auth + * user. The user goes last because it is what lets the person ask again: if a step in + * between fails, they can still sign in and the next call picks up where this one + * stopped. Every step is content to find its work already done, so a call for an account + * already deleted succeeds and changes nothing. + */ +export async function deleteAccountOf( + uid: string, + { db = getFirestore(), auth = getAuth(), stripe, now = new Date() }: DeletionDeps = {} +): Promise { + const customerIds = await customersOf(db, uid); + if (customerIds.length > 0) { + if (stripe) { + await endBilling(stripe, customerIds, uid); + } else { + // Billing off is the emulator's case (stripe-functions.ts); a deployed instance has it on. + logger.warn(`[account] user=${uid}: billing is off, so nothing was asked of Stripe`); + } + } + + await deletionRef(db, uid).set({ + deletedAt: Timestamp.fromDate(now), + settleAt: Timestamp.fromMillis(now.getTime() + DELETION_SETTLE_MS), + }); + await unlessGone(() => auth.revokeRefreshTokens(uid)); + const report = await purgeAccountData(db, uid); + await unlessGone(() => auth.deleteUser(uid)); + logger.info( + `[account] deleted user=${uid}: ${report.pushes} push${report.pushes === 1 ? '' : 'es'}, ${report.billingRows} billing row${report.billingRows === 1 ? '' : 's'}` + ); +} + +/** + * The second purge (see the top of this file): for every deletion whose tokens have all + * expired by `now`, empties the same places again and removes the marker. Returns the + * accounts finished. One that fails keeps its marker and is tried again the next hour. + */ +export async function finishDeletions( + now: Date, + { db = getFirestore(), auth = getAuth() }: Pick = {} +): Promise { + const due = await db.collection('accountDeletions').where('settleAt', '<=', Timestamp.fromDate(now)).get(); + const finished: string[] = []; + for (const marker of due.docs) { + const uid = marker.id; + try { + const report = await purgeAccountData(db, uid); + // Already gone unless the first call stopped before it got there. + await unlessGone(() => auth.deleteUser(uid)); + await marker.ref.delete(); + finished.push(uid); + if (report.pushes > 0) { + logger.info(`[account] user=${uid}: ${report.pushes} push(es) written after the deletion, removed`); + } + } catch (err) { + logger.error(`[account] user=${uid}: the second purge failed and will be tried again`, err); + } + } + return finished; +} + +/** + * Deletes the caller's account. The caller must have signed in within the last five + * minutes (`FRESH_SIGN_IN_S`): every client asks for the password and signs in again + * before calling, and this is where that is enforced. Answers `{ deleted: true }`, also + * for an account already deleted, whose last ID token can still reach here for an hour. + * + * Five minutes of timeout rather than the default one: an account with thousands of + * pushes is deleted five hundred at a time, and a deletion cut off halfway is finished by + * the next call or by the sweep, but the person would have seen an error. + */ +export const deleteAccount = onCall({ cors: true, secrets, timeoutSeconds: 300 }, async (request) => { + const uid = uidOf(request); + const now = new Date(); + if (!signedInRecently(request.auth?.token.auth_time, now)) { + throw new HttpsError('failed-precondition', 'Enter your password again to delete the account.'); + } + try { + await deleteAccountOf(uid, { stripe: stripeConfig(), now }); + } catch (err) { + if (!(err instanceof StripeError)) throw err; + logger.error(`[account] user=${uid}: ending the subscription was refused, nothing deleted: ${err.message}`); + throw new HttpsError( + 'unavailable', + 'Your subscription could not be cancelled right now, so nothing was deleted. Try again shortly.' + ); + } + return { deleted: true }; +}); + +export const finishAccountDeletions = onSchedule({ schedule: 'every 60 minutes', timeZone: 'Etc/UTC' }, async () => { + const finished = await finishDeletions(new Date()); + if (finished.length > 0) { + logger.info(`finishAccountDeletions: finished ${finished.length} deletion${finished.length === 1 ? '' : 's'}`); + } +}); diff --git a/functions/src/index.ts b/functions/src/index.ts index 2df8b49..9f3ff7f 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -31,6 +31,7 @@ export { refreshSubscription, stripeWebhook, } from './stripe-functions'; +export { deleteAccount, finishAccountDeletions } from './account-deletion'; export { expireFilePushes } from './expiry'; export { deleteOrphanedUploads } from './orphans'; diff --git a/functions/src/stripe-functions.ts b/functions/src/stripe-functions.ts index a0bdbff..bc5da41 100644 --- a/functions/src/stripe-functions.ts +++ b/functions/src/stripe-functions.ts @@ -63,7 +63,8 @@ import { const STRIPE_SECRET_KEY = defineSecret('STRIPE_SECRET_KEY'); const STRIPE_WEBHOOK_SECRET = defineSecret('STRIPE_WEBHOOK_SECRET'); const STRIPE_PRO_PRICE_ID = defineSecret('STRIPE_PRO_PRICE_ID'); -const secrets = [STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRO_PRICE_ID]; +/** Exported for `deleteAccount` (account-deletion.ts), which ends the account's subscription. */ +export const secrets = [STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRO_PRICE_ID]; /** Where the checkout and the portal come back to. The emulator may point it elsewhere. */ const APP_URL = (process.env.APP_URL || 'https://pushthat.link').replace(/\/$/, ''); @@ -72,7 +73,7 @@ function planPageUrl(query?: string): string { return `${APP_URL}/plan${query ? `?${query}` : ''}`; } -function stripeConfig(): StripeConfig | undefined { +export function stripeConfig(): StripeConfig | undefined { return stripeOf({ STRIPE_SECRET_KEY: STRIPE_SECRET_KEY.value(), STRIPE_WEBHOOK_SECRET: STRIPE_WEBHOOK_SECRET.value(), @@ -82,7 +83,7 @@ function stripeConfig(): StripeConfig | undefined { } /** The signed-in account, or the refusal a callable gives without one. */ -function uidOf(request: CallableRequest): string { +export function uidOf(request: CallableRequest): string { if (!request.auth) throw new HttpsError('unauthenticated', 'Sign in first.'); return request.auth.uid; } diff --git a/functions/src/stripe.ts b/functions/src/stripe.ts index a7457ca..4a52507 100644 --- a/functions/src/stripe.ts +++ b/functions/src/stripe.ts @@ -181,6 +181,22 @@ export async function createCustomer( const CUSTOMER_KEY_WINDOW_MS = 10 * 60 * 1000; +/** + * Deletes the customer, for an account being deleted (account-deletion.ts): Stripe drops + * the customer and the email it was made with from the account's customer list, and ends + * any subscription still running. The payments already made stay with Stripe, which is + * the seller of record and keeps them for its own books. A customer already gone is what + * this wants, so a second call for the same account is not a refusal. + */ +export async function deleteCustomer(stripe: StripeConfig, customerId: string): Promise { + try { + await stripeCall(stripe, 'DELETE', `/v1/customers/${encodeURIComponent(customerId)}`); + } catch (err) { + if (err instanceof StripeError && (err.status === 404 || err.code === 'resource_missing')) return; + throw err; + } +} + export interface StripeCheckoutSession { id: string; /** Where to send the person: Stripe's hosted page. Null once the session is complete or expired. */ diff --git a/functions/test/emulator/account-deletion.test.ts b/functions/test/emulator/account-deletion.test.ts new file mode 100644 index 0000000..777e083 --- /dev/null +++ b/functions/test/emulator/account-deletion.test.ts @@ -0,0 +1,407 @@ +import { getAuth } from 'firebase-admin/auth'; +import { Timestamp } from 'firebase-admin/firestore'; +import { getStorage } from 'firebase-admin/storage'; +import { HttpsError } from 'firebase-functions/v2/https'; + +import { + DELETION_SETTLE_MS, + FRESH_SIGN_IN_S, + deleteAccount, + deleteAccountOf, + finishDeletions, +} from '../../src/account-deletion'; +import { stripeOf } from '../../src/stripe'; +import { + PRICE_ID, + StripeStandIn, + WEBHOOK_SECRET, + billingOff, + billingOn, + call, + clearAuth, + clearFirestore, + closeFirestore, + db, + list, + stripeRefusal, + subscription, +} from './harness'; + +/** + * Account deletion against the Auth, Firestore and Storage emulators and a stand-in for + * Stripe: that every place the project keeps something under an account is empty + * afterwards, that another account is untouched, and that the call can be made twice. + * + * "Every place" is checked by walking the whole database and the whole bucket for the + * account's id, not by naming collections, so a collection added later that is keyed by + * the account and missed by the purge fails here without anyone remembering to list it. + */ + +const UID = 'user_gone'; +const OTHER = 'user_staying'; +const CUSTOMER = 'cus_gone'; +const OTHER_CUSTOMER = 'cus_staying'; +const NOW = new Date('2026-11-12T12:00:00Z'); + +const stripe = new StripeStandIn(); +const bucket = () => getStorage().bucket(); + +beforeAll(() => stripe.start()); + +afterAll(async () => { + await stripe.stop(); + await clearBucket(); + await closeFirestore(); +}); + +beforeEach(async () => { + await Promise.all([clearFirestore(), clearAuth(), clearBucket()]); + stripe.reset(); + billingOn(stripe); +}); + +async function clearBucket(): Promise { + const [files] = await bucket().getFiles(); + await Promise.all(files.map((file) => file.delete({ ignoreNotFound: true }))); +} + +/** Everything a real account accumulates, in every collection and in Storage. */ +async function seedAccount(uid: string, customerId: string): Promise { + await getAuth().createUser({ uid, email: `${uid}@example.com`, password: 'a password for the emulator' }); + const firestore = db(); + const batch = firestore.batch(); + batch.set(firestore.collection('users').doc(uid), { + email: `${uid}@example.com`, + createdAt: Timestamp.fromDate(NOW), + fcmTokens: [`token-of-${uid}-phone`, `token-of-${uid}-browser`], + e2ee: { + v: 1, + kdf: 'pbkdf2-sha256-600000', + keyVersion: 1, + wraps: { + wrapPassword: { by: 'password', n: 'n'.repeat(16), ct: 'c'.repeat(64) }, + wrapRecovery: { by: 'recovery', n: 'n'.repeat(16), ct: 'c'.repeat(64) }, + }, + }, + usageRecords: { '2026-11': { url: 3, file: 1 } }, + subscription: { type: 'pro', status: 'active', expiresAt: Timestamp.fromMillis(NOW.getTime() + 1000) }, + billing: { customerId, subscriptionId: `sub_${uid}`, subscriptionStatus: 'active' }, + }); + // A subcollection the rules still name, though nothing writes it now. + batch.set(firestore.collection('users').doc(uid).collection('usage').doc('2026-10'), { url: 1 }); + batch.set(firestore.collection('stripeCustomers').doc(customerId), { userId: uid, createdAt: NOW }); + batch.set(firestore.collection('billingEvents').doc(`evt_${uid}`), { + eventId: `evt_${uid}`, + type: 'invoice.paid', + userId: uid, + receivedAt: NOW, + }); + const enc = { v: 1, n: 'n'.repeat(16), ct: 'ciphertext' }; + batch.set(firestore.collection('pushes').doc(), { userId: uid, type: 'url', createdAt: NOW, keyVersion: 1, enc }); + batch.set(firestore.collection('pushes').doc(), { + userId: uid, + type: 'file', + createdAt: NOW, + keyVersion: 1, + enc, + objectPath: `uploads/${uid}/RandomObjectId0000000A`, + }); + // A push from before encryption, which names its file by its title. + batch.set(firestore.collection('pushes').doc(), { + userId: uid, + type: 'file', + createdAt: NOW, + title: 'Report (final) v2.pdf', + }); + await batch.commit(); + await Promise.all( + [ + `uploads/${uid}/RandomObjectId0000000A`, + `uploads/${uid}/Report (final) v2.pdf`, + // An upload whose push never came: no document names it. + `uploads/${uid}/RandomObjectId0000000B`, + ].map((path) => bucket().file(path).save(Buffer.from('x'))) + ); +} + +/** Every document in the database, whatever collection it is in and however deep. */ +async function everyDocument(): Promise<{ path: string; data: unknown }[]> { + const found: { path: string; data: unknown }[] = []; + const walk = async (collections: FirebaseFirestore.CollectionReference[]): Promise => { + for (const collection of collections) { + // `listDocuments` also gives a document that is only the parent of a subcollection. + for (const ref of await collection.listDocuments()) { + const snapshot = await ref.get(); + if (snapshot.exists) found.push({ path: ref.path, data: snapshot.data() }); + await walk(await ref.listCollections()); + } + } + }; + await walk(await db().listCollections()); + return found; +} + +/** Where `uid` still appears: a document's path or contents, a stored object, the Auth user. */ +async function tracesOf(uid: string): Promise { + const traces = (await everyDocument()) + .filter((doc) => doc.path.includes(uid) || JSON.stringify(doc.data).includes(uid)) + .map((doc) => doc.path); + const [files] = await bucket().getFiles(); + traces.push(...files.map((file) => file.name).filter((name) => name.includes(uid))); + const user = await getAuth() + .getUser(uid) + .catch(() => null); + if (user) traces.push(`auth:${uid}`); + return traces.sort(); +} + +function stripeHasCustomer(customerId: string, subscriptionId: string, status = 'active'): void { + stripe.route('GET /v1/subscriptions', (req) => ({ + body: list( + req.query.get('customer') === customerId + ? [subscription(subscriptionId, { customer: customerId, status, created: 1 })] + : [] + ), + })); + stripe.route(`DELETE /v1/subscriptions/${subscriptionId}`, { id: subscriptionId, status: 'canceled' }); + stripe.route(`DELETE /v1/customers/${customerId}`, { id: customerId, deleted: true }); +} + +const config = () => + stripeOf({ + STRIPE_SECRET_KEY: 'sk_test_emulator', + STRIPE_WEBHOOK_SECRET: WEBHOOK_SECRET, + STRIPE_PRO_PRICE_ID: PRICE_ID, + STRIPE_API_URL: stripe.url, + }); + +const settled = (from: Date) => new Date(from.getTime() + DELETION_SETTLE_MS + 1000); + +describe('deleteAccountOf', () => { + test('leaves nothing under the account but the marker, and the second purge removes that', async () => { + await seedAccount(UID, CUSTOMER); + stripeHasCustomer(CUSTOMER, `sub_${UID}`); + // The seed itself must be findable, or an empty result below would prove nothing. + expect((await tracesOf(UID)).length).toBeGreaterThan(8); + + await deleteAccountOf(UID, { stripe: config(), now: NOW }); + + expect(await tracesOf(UID)).toEqual([`accountDeletions/${UID}`]); + // The marker holds two times and nothing about the person. + const marker = (await db().collection('accountDeletions').doc(UID).get()).data()!; + expect(Object.keys(marker).sort()).toEqual(['deletedAt', 'settleAt']); + + expect(await finishDeletions(settled(NOW))).toEqual([UID]); + expect(await tracesOf(UID)).toEqual([]); + }); + + test('another account keeps everything', async () => { + await seedAccount(UID, CUSTOMER); + await seedAccount(OTHER, OTHER_CUSTOMER); + stripeHasCustomer(CUSTOMER, `sub_${UID}`); + const before = await tracesOf(OTHER); + + await deleteAccountOf(UID, { stripe: config(), now: NOW }); + await finishDeletions(settled(NOW)); + + expect(await tracesOf(OTHER)).toEqual(before); + expect(stripe.made(`DELETE /v1/customers/${OTHER_CUSTOMER}`)).toHaveLength(0); + }); + + test('the subscription is cancelled and the customer deleted at Stripe', async () => { + await seedAccount(UID, CUSTOMER); + stripeHasCustomer(CUSTOMER, `sub_${UID}`); + + await deleteAccountOf(UID, { stripe: config(), now: NOW }); + + expect(stripe.made(`DELETE /v1/subscriptions/sub_${UID}`)).toHaveLength(1); + expect(stripe.made(`DELETE /v1/customers/${CUSTOMER}`)).toHaveLength(1); + }); + + test('a subscription already over is not cancelled again', async () => { + await seedAccount(UID, CUSTOMER); + stripeHasCustomer(CUSTOMER, `sub_${UID}`, 'canceled'); + + await deleteAccountOf(UID, { stripe: config(), now: NOW }); + + expect(stripe.made(`DELETE /v1/subscriptions/sub_${UID}`)).toHaveLength(0); + expect(stripe.made(`DELETE /v1/customers/${CUSTOMER}`)).toHaveLength(1); + }); + + test('a subscription Stripe will not cancel stops the deletion with everything still there', async () => { + await seedAccount(UID, CUSTOMER); + stripeHasCustomer(CUSTOMER, `sub_${UID}`); + stripe.route(`DELETE /v1/subscriptions/sub_${UID}`, () => stripeRefusal(500, 'try again')); + stripe.route(`GET /v1/subscriptions/sub_${UID}`, subscription(`sub_${UID}`, { customer: CUSTOMER, created: 1 })); + const before = await tracesOf(UID); + + await expect(deleteAccountOf(UID, { stripe: config(), now: NOW })).rejects.toThrow(/Stripe answered 500/); + + expect(await tracesOf(UID)).toEqual(before); + }); + + test('a customer Stripe will not delete does not stop it', async () => { + await seedAccount(UID, CUSTOMER); + stripeHasCustomer(CUSTOMER, `sub_${UID}`); + stripe.route(`DELETE /v1/customers/${CUSTOMER}`, () => stripeRefusal(500, 'try again')); + + await deleteAccountOf(UID, { stripe: config(), now: NOW }); + + expect(await tracesOf(UID)).toEqual([`accountDeletions/${UID}`]); + }); + + test('an account that never subscribed asks nothing of Stripe', async () => { + await getAuth().createUser({ uid: UID, email: `${UID}@example.com` }); + await db().collection('users').doc(UID).set({ email: `${UID}@example.com`, fcmTokens: ['t'] }); + + await deleteAccountOf(UID, { stripe: config(), now: NOW }); + + expect(stripe.requests).toHaveLength(0); + expect(await tracesOf(UID)).toEqual([`accountDeletions/${UID}`]); + }); + + test('a second call for the same account succeeds and changes nothing', async () => { + await seedAccount(UID, CUSTOMER); + stripeHasCustomer(CUSTOMER, `sub_${UID}`); + await deleteAccountOf(UID, { stripe: config(), now: NOW }); + const later = new Date(NOW.getTime() + 60_000); + + await expect(deleteAccountOf(UID, { stripe: config(), now: later })).resolves.toBeUndefined(); + + expect(await tracesOf(UID)).toEqual([`accountDeletions/${UID}`]); + // The customer's row went with the first call, so Stripe is not asked a second time. + expect(stripe.made(`DELETE /v1/customers/${CUSTOMER}`)).toHaveLength(1); + }); + + test('a call that stopped halfway is finished by the next', async () => { + await seedAccount(UID, CUSTOMER); + stripeHasCustomer(CUSTOMER, `sub_${UID}`); + const failing = { + revokeRefreshTokens: () => Promise.reject(new Error('the Auth service is down')), + deleteUser: () => Promise.resolve(), + }; + await expect(deleteAccountOf(UID, { stripe: config(), now: NOW, auth: failing })).rejects.toThrow(/is down/); + // Stopped after Stripe and before the data: the person can still sign in and ask again. + expect(await tracesOf(UID)).toContain(`auth:${UID}`); + + await deleteAccountOf(UID, { stripe: config(), now: NOW }); + + expect(await tracesOf(UID)).toEqual([`accountDeletions/${UID}`]); + }); + + test('every session is ended: the refresh tokens are revoked before the user is deleted', async () => { + await seedAccount(UID, CUSTOMER); + stripeHasCustomer(CUSTOMER, `sub_${UID}`); + const calls: string[] = []; + const auth = { + revokeRefreshTokens: async (uid: string) => void calls.push(`revoke ${uid}`), + deleteUser: async (uid: string) => { + calls.push(`delete ${uid}`); + await getAuth().deleteUser(uid); + }, + }; + + await deleteAccountOf(UID, { stripe: config(), now: NOW, auth }); + + expect(calls).toEqual([`revoke ${UID}`, `delete ${UID}`]); + }); +}); + +describe('finishDeletions', () => { + test('removes what a device still signed in wrote after the deletion', async () => { + await seedAccount(UID, CUSTOMER); + stripeHasCustomer(CUSTOMER, `sub_${UID}`); + await deleteAccountOf(UID, { stripe: config(), now: NOW }); + // An ID token lives up to an hour past the deletion, and the rules still take it. + await db() + .collection('users') + .doc(UID) + .set({ fcmTokens: ['token-written-late'] }); + await db().collection('pushes').add({ userId: UID, type: 'url', createdAt: NOW }); + await bucket().file(`uploads/${UID}/late`).save(Buffer.from('x')); + + expect(await finishDeletions(settled(NOW))).toEqual([UID]); + + expect(await tracesOf(UID)).toEqual([]); + }); + + test('leaves a deletion whose tokens may still be alive', async () => { + await seedAccount(UID, CUSTOMER); + stripeHasCustomer(CUSTOMER, `sub_${UID}`); + await deleteAccountOf(UID, { stripe: config(), now: NOW }); + + expect(await finishDeletions(new Date(NOW.getTime() + 30 * 60 * 1000))).toEqual([]); + + expect(await tracesOf(UID)).toEqual([`accountDeletions/${UID}`]); + }); +}); + +describe('deleteAccount', () => { + const nowSeconds = () => Math.floor(Date.now() / 1000); + + /** The refusal `promise` ends in, as `code: message`. */ + async function refusal(promise: Promise): Promise { + try { + await promise; + } catch (err) { + if (err instanceof HttpsError) return `${err.code}: ${err.message}`; + throw err; + } + throw new Error('expected a refusal'); + } + + test('refuses a caller who is not signed in', async () => { + expect(await refusal(call(deleteAccount, null))).toMatch(/^unauthenticated/); + }); + + test('refuses a session whose sign-in is old, and deletes nothing', async () => { + await seedAccount(UID, CUSTOMER); + const before = await tracesOf(UID); + + const stale = { auth_time: nowSeconds() - FRESH_SIGN_IN_S - 60 }; + expect(await refusal(call(deleteAccount, UID, {}, stale))).toMatch(/^failed-precondition: Enter your password/); + // A token with no sign-in time at all is refused the same way. + expect(await refusal(call(deleteAccount, UID))).toMatch(/^failed-precondition/); + + expect(await tracesOf(UID)).toEqual(before); + expect(stripe.requests).toHaveLength(0); + }); + + test('deletes the account of a caller who has just signed in, and answers the same a second time', async () => { + await seedAccount(UID, CUSTOMER); + await seedAccount(OTHER, OTHER_CUSTOMER); + stripeHasCustomer(CUSTOMER, `sub_${UID}`); + const fresh = { auth_time: nowSeconds() - 3 }; + + expect(await call(deleteAccount, UID, {}, fresh)).toEqual({ deleted: true }); + expect(await tracesOf(UID)).toEqual([`accountDeletions/${UID}`]); + expect(await tracesOf(OTHER)).not.toEqual([]); + + // The deleted account's last ID token is good for up to an hour more. + expect(await call(deleteAccount, UID, {}, fresh)).toEqual({ deleted: true }); + }); + + test('says so, and deletes nothing, when the subscription cannot be cancelled', async () => { + await seedAccount(UID, CUSTOMER); + stripeHasCustomer(CUSTOMER, `sub_${UID}`); + stripe.route(`DELETE /v1/subscriptions/sub_${UID}`, () => stripeRefusal(500, 'try again')); + stripe.route(`GET /v1/subscriptions/sub_${UID}`, subscription(`sub_${UID}`, { customer: CUSTOMER, created: 1 })); + const before = await tracesOf(UID); + + expect(await refusal(call(deleteAccount, UID, {}, { auth_time: nowSeconds() }))).toMatch( + /^unavailable: Your subscription could not be cancelled/ + ); + + expect(await tracesOf(UID)).toEqual(before); + }); + + test('with billing off it deletes the account and asks nothing of Stripe', async () => { + billingOff(); + await seedAccount(UID, CUSTOMER); + + expect(await call(deleteAccount, UID, {}, { auth_time: nowSeconds() })).toEqual({ deleted: true }); + + expect(stripe.requests).toHaveLength(0); + expect(await tracesOf(UID)).toEqual([`accountDeletions/${UID}`]); + }); +}); diff --git a/functions/test/emulator/check-ports.mjs b/functions/test/emulator/check-ports.mjs index e113105..351a454 100644 --- a/functions/test/emulator/check-ports.mjs +++ b/functions/test/emulator/check-ports.mjs @@ -6,7 +6,7 @@ import { readFileSync } from 'node:fs'; import { createServer } from 'node:net'; const config = JSON.parse(readFileSync(new URL('../../../firebase.json', import.meta.url), 'utf8')); -const ports = ['firestore', 'storage'].map((name) => [name, config.emulators?.[name]?.port]).filter(([, p]) => p); +const ports = ['auth', 'firestore', 'storage'].map((name) => [name, config.emulators?.[name]?.port]).filter(([, p]) => p); const taken = []; for (const [name, port] of ports) { diff --git a/functions/test/emulator/harness.ts b/functions/test/emulator/harness.ts index 0685888..5ef6eb6 100644 --- a/functions/test/emulator/harness.ts +++ b/functions/test/emulator/harness.ts @@ -49,6 +49,17 @@ export async function clearFirestore(): Promise { if (!res.ok) throw new Error(`clearing the emulator answered ${res.status}`); } +/** + * Deletes every user in the Auth emulator, which `test:emulator` starts beside the other + * two and the Admin SDK reaches through `FIREBASE_AUTH_EMULATOR_HOST`. + */ +export async function clearAuth(): Promise { + const host = process.env.FIREBASE_AUTH_EMULATOR_HOST; + if (!host) throw new Error('FIREBASE_AUTH_EMULATOR_HOST is not set: run these with `npm run test:emulator`.'); + const res = await fetch(`http://${host}/emulator/v1/projects/${PROJECT}/accounts`, { method: 'DELETE' }); + if (!res.ok) throw new Error(`clearing the Auth emulator answered ${res.status}`); +} + /** Billing on, against `stripe`. */ export function billingOn(stripe: StripeStandIn): void { process.env.STRIPE_SECRET_KEY = 'sk_test_emulator'; @@ -231,13 +242,21 @@ export async function deliver( // --- Callables ------------------------------------------------------------------------------ -/** Calls an `onCall` function as the signed-in `uid`, or signed out with `uid` null. */ -export function call(fn: CallableFunction>, uid: string | null, data: unknown = {}): Promise { +/** + * Calls an `onCall` function as the signed-in `uid`, or signed out with `uid` null. + * `claims` are further fields of the ID token, such as `auth_time`. + */ +export function call( + fn: CallableFunction>, + uid: string | null, + data: unknown = {}, + claims: Record = {} +): Promise { const request = { data, rawRequest: {}, acceptsStreaming: false, - ...(uid ? { auth: { uid, token: { uid, email: `${uid}@example.com` }, rawToken: 'token' } } : {}), + ...(uid ? { auth: { uid, token: { uid, email: `${uid}@example.com`, ...claims }, rawToken: 'token' } } : {}), } as unknown as CallableRequest; return fn.run(request) as Promise; } From 7386109066754dc52a267c72c74691238b279f47 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 15:21:28 -0600 Subject: [PATCH 02/64] Ask the linked site for its icon on the web instead of Google 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 --- apps/web/src/components/PushesList.tsx | 61 ++++++++++++-------------- 1 file changed, 29 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/PushesList.tsx b/apps/web/src/components/PushesList.tsx index c6f1ff7..1b16c98 100644 --- a/apps/web/src/components/PushesList.tsx +++ b/apps/web/src/components/PushesList.tsx @@ -45,14 +45,25 @@ const ICONS = [ 'apk.svg', ]; -const faviconOf = (url: string | undefined): string => { - let host = ''; +/** + * The linked site's own icon, asked of the site itself: `/favicon.ico` at the link's origin. + * It used to come from Google's favicon service, which meant telling Google the domain of + * every link in the list, for pushes whose whole point is that nobody but their owner can + * read them. A proxy of our own would move the same list to our logs. So the only party + * asked is the site the link already points at, and when it has no icon there the row + * falls back to ours. + * + * `/favicon.ico` and not the page's ``, which the apps read: a browser + * will draw another origin's image but will not let a script read its HTML. Undefined for + * anything that is not a web address. + */ +const faviconOf = (url: string | undefined): string | undefined => { try { - host = url ? new URL(url).hostname : ''; - } catch (error) { - console.error('Error parsing URL:', error); + const { protocol, origin } = new URL(url ?? ''); + return protocol === 'https:' || protocol === 'http:' ? `${origin}/favicon.ico` : undefined; + } catch { + return undefined; } - return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=256`; }; /** @@ -86,7 +97,8 @@ export default function PushesList({ selectedPushes, onSelectedPushesChange, }: PushesListProps) { - const [lowResImages, setLowResImages] = useState>(new Set()); + // The pushes whose site gave no icon (none at `/favicon.ico`, or not an image). + const [failedIcons, setFailedIcons] = useState>(new Set()); const [opening, setOpening] = useState(undefined); // What opening a row has to say (a locked push, a file that did not decrypt), shown above // the list as the app shows its other errors, not in a browser dialog. @@ -130,20 +142,7 @@ export default function PushesList({ }); }; - const handleImageLoad = (event: React.SyntheticEvent, pushId: string) => { - const image = event.currentTarget; - if (image.naturalHeight === 16) { - setLowResImages((prev) => new Set(prev).add(pushId)); - } - }; - - const getFallbackImageSrc = (push: Push, icon: string | undefined) => { - // If this push has a low-res image, use the PushThat logo as fallback - if (lowResImages.has(push.id)) { - return '/pushthat-mono.svg'; - } - return null; - }; + const handleIconError = (pushId: string) => setFailedIcons((prev) => new Set(prev).add(pushId)); if (loading) { return
Loading...
; @@ -168,10 +167,11 @@ export default function PushesList({ {pushes.map((push) => { const icon = ICONS.find((icon) => push.ext === icon.split('.')[0]); const isSelected = selectedPushes.has(push.id); - // A locked push, or a file with no icon of its own, has no host to ask for a - // favicon (and an empty one would still ask Google). + // A locked push, or a file with no icon of its own, has no site to ask for one, and + // neither does a link whose site had none: those get ours. + const favicon = icon ? undefined : faviconOf(push.url); const fallbackSrc = - push.locked || (!icon && !push.url) ? '/pushthat-mono.svg' : getFallbackImageSrc(push, icon); + push.locked || (!icon && !favicon) || failedIcons.has(push.id) ? '/pushthat-mono.svg' : null; const content = ( <>
) : icon ? ( - handleImageLoad(e, push.id)} - /> + ) : ( + // No referrer: the site learns that its icon was asked for, not by which page. handleImageLoad(e, push.id)} + onError={() => handleIconError(push.id)} /> )}
From 8d83348892599b91ca12872b3ec5c609d1412da0 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 15:21:28 -0600 Subject: [PATCH 03/64] Add a public page for deleting an account, and a menu item that leads 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 --- apps/web/src/App.tsx | 3 + apps/web/src/Layout.tsx | 3 + apps/web/src/pages/DeleteAccount.module.css | 80 ++++++ apps/web/src/pages/DeleteAccount.tsx | 254 ++++++++++++++++++++ apps/web/src/pages/Privacy.tsx | 21 +- apps/web/src/pages/Support.tsx | 6 +- apps/web/src/pages/Terms.tsx | 6 +- apps/web/src/services/account.ts | 51 ++++ apps/web/src/services/signIn.ts | 26 +- 9 files changed, 439 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/pages/DeleteAccount.module.css create mode 100644 apps/web/src/pages/DeleteAccount.tsx create mode 100644 apps/web/src/services/account.ts diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 051c7cd..9d1d631 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -7,6 +7,7 @@ import { AuthProvider } from './contexts/AuthContext'; import { useAuth } from './contexts/AuthContext'; import { KeysProvider } from './contexts/KeysContext'; import { ThemeProvider } from './contexts/ThemeContext'; +import { DeleteAccount } from './pages/DeleteAccount'; import { IndexPage } from './pages/Index'; import Links from './pages/Links'; import Login from './pages/Login'; @@ -76,6 +77,8 @@ function App() { } /> } /> } /> + {/* Public, like the legal pages: someone deleting may have no session, or no app. */} + } /> }> } /> } /> diff --git a/apps/web/src/Layout.tsx b/apps/web/src/Layout.tsx index b980ca1..cb5bc35 100644 --- a/apps/web/src/Layout.tsx +++ b/apps/web/src/Layout.tsx @@ -57,6 +57,9 @@ export default function Layout({ children }: LayoutProps) { {isMenuOpen && (
+ {/* Last, under Logout, so the destructive item is not the first thing a thumb finds. + It leads to the public page, which asks for the password before anything goes. */} +
)} diff --git a/apps/web/src/pages/DeleteAccount.module.css b/apps/web/src/pages/DeleteAccount.module.css new file mode 100644 index 0000000..91b2080 --- /dev/null +++ b/apps/web/src/pages/DeleteAccount.module.css @@ -0,0 +1,80 @@ +.root { + h5 { + font-style: italic; + text-decoration: underline; + } + + ol { + margin: 0 0 1em; + padding-left: 1em; + } +} + +.form { + max-width: 400px; + margin: 1.5em 0 2em; + padding: 16px; + border: 1px solid var(--color-error); + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.875rem; +} + +.input { + padding: 8px 12px; + border: 1px solid var(--color-border); + border-radius: 4px; + font-size: 0.875rem; + background-color: var(--color-surface); + color: var(--color-text); +} + +.input:focus { + outline: none; + border-color: var(--color-primary); +} + +.confirm { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 0.875rem; +} + +.confirm input { + margin-top: 4px; +} + +.error { + color: var(--color-error); +} + +.deleteButton { + padding: 10px 16px; + border: 1px solid var(--color-error); + border-radius: 4px; + background: var(--color-error); + color: #1a1a1a; + font-weight: 700; + cursor: pointer; +} + +.deleteButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.done { + margin: 1.5em 0 2em; + padding: 16px; + border: 1px solid var(--color-border); + border-radius: 8px; +} diff --git a/apps/web/src/pages/DeleteAccount.tsx b/apps/web/src/pages/DeleteAccount.tsx new file mode 100644 index 0000000..b7f3bfa --- /dev/null +++ b/apps/web/src/pages/DeleteAccount.tsx @@ -0,0 +1,254 @@ +import { useState } from 'react'; +import { Helmet } from 'react-helmet'; +import { Link } from 'react-router'; + +import LegalContainer from '@/components/LegalContainer'; +import { LIMITS, REFUND_DAYS, SUPPORT_EMAIL } from '@/config/plan'; +import { useAuth } from '@/contexts/AuthContext'; +import { deleteAccount } from '@/services/account'; +import { errorText } from '@/services/plan'; +import { WrongPasswordError } from '@/services/signIn'; + +import styles from './DeleteAccount.module.css'; + +/** + * Delete account, `/delete-account`: what deleting removes, what is kept and for how long, + * and the form that does it. This is the address given to Google Play as the app's account + * deletion page, so it is public and it names the app and the developer: the person + * reading it may have uninstalled the app, and Play's reviewer has no account at all. + * + * It is also served as static HTML (scripts/prerender.mjs), so everything except the form + * reads without JavaScript. Keep the facts in the text itself for that reason, not behind + * state or an effect. What is kept and for how long is recorded, with the reasons, in + * docs/account-deletion.md at the repository root; the two change together. + * + * The form asks for the password even from a signed-in browser. The function refuses a + * sign-in older than five minutes, so that an open session on a shared computer cannot + * delete the account, and the password is the proof. + */ + +/** + * Whether an account was deleted from this page since it loaded. Outside the component + * because deleting from a signed-in browser ends its session, `Layout` then swaps its + * signed-in frame for the signed-out one, and the page is mounted anew: state held in the + * component was lost at exactly the moment it had something to say, and the person was + * shown the empty form again with no word that anything had happened. + */ +let deletedHere = false; + +export function DeleteAccount() { + const { user } = useAuth(); + const sessionEmail = user?.email ?? ''; + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [understood, setUnderstood] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [done, setDone] = useState(deletedHere); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (busy || !understood) return; + setBusy(true); + setError(''); + try { + await deleteAccount(sessionEmail || email.trim(), password, () => { + deletedHere = true; + }); + setPassword(''); + setDone(true); + } catch (err) { + setError( + err instanceof WrongPasswordError + ? 'The email or password is not right. Nothing was deleted.' + : errorText(err, 'The account could not be deleted. Nothing was removed; try again shortly.') + ); + } finally { + setBusy(false); + } + }; + + return ( + + + Delete your PushThat account + + +

Delete your PushThat account

+ + {/* At the top: the page has just been mounted anew, scrolled to its start (see `deletedHere`). */} + {done && ( +
+

+ Your account is deleted. Everything listed below under “What is deleted” is gone, and this + browser is signed out. Your other devices sign out within the hour. You can uninstall the app. +

+
+ )} + +

+ PushThat is an app by MakeRocketGoNow LLC for sending links and files between your phone and your other devices. + This page explains how to delete your PushThat account and the data stored with it, and lets you do it right + here. Deleting is permanent and takes effect at once: the account is removed, not switched off, and there is no + waiting period and no way to restore it. +

+ +
HOW TO DELETE YOUR ACCOUNT
+ +

In the app:

+
    +
  1. + Open PushThat and open its menu: the three dots at the top of the list on Android, the account menu on iPhone + and Mac, and the menu at the top right on the website and in the Chrome extension. +
  2. +
  3. + Choose Delete account. +
  4. +
  5. Enter your password to confirm, and press Delete.
  6. +
+ +

Without the app, on this page:

+
    +
  1. + Enter the email address and password of the account in the form below. +
  2. +
  3. Tick the box to confirm that you understand it is permanent.
  4. +
  5. + Press Delete my account. It is deleted while you wait, and the page says so when it is done. +
  6. +
+ +

+ If you have forgotten your password, reset it first from the sign-in page (Forgot your + password?), then come back and use the new one. You do not need your recovery key to delete the account. If you + cannot sign in at all, or this page does not work for you, write to{' '} + {SUPPORT_EMAIL} from the email address on the account and we will delete + it for you. +

+ +
WHAT IS DELETED
+ +

All of this is deleted when you press the button:

+
    +
  • Your account: your email address, your sign-in and your account ID.
  • +
  • + Every push: the links, page titles and file records, which are stored encrypted, and the search tokens made + from them. +
  • +
  • Every file you pushed that is still stored, in its encrypted form.
  • +
  • + Your encryption keys as we hold them: the copy locked by your password and the copy locked by your recovery + key. Without them your encrypted pushes cannot be read by anyone, so nothing can be recovered afterwards, by + you or by us. +
  • +
  • Your device records: the notification tokens and device IDs of the devices you signed in on.
  • +
  • Your monthly usage counts and the record of your plan.
  • +
  • + Your Pro subscription, if you have one: it is cancelled at once and you are not charged again, and your + customer record at Stripe, our payment provider, is deleted. Deleting does not refund a payment by itself; a + charge made in the last {REFUND_DAYS} days is refunded if you ask (see the{' '} + refund policy). +
  • +
  • + Every session: you are signed out on this device at once, and on every other device within an hour, as each + one next checks in. +
  • +
+ +
WHAT IS KEPT, AND FOR HOW LONG
+ +
    +
  • + About two hours: a marker holding only the deleted account’s ID and the time. A device that + is still signed in can go on sending for up to an hour; the marker is how anything it sends in that time is + found and deleted too. Then the marker is deleted. +
  • +
  • + 30 days: our server logs, which record the account ID and the storage paths of deleted files + against the actions our servers took. They never hold your email address or the content of a push. A file + pushed before end-to-end encryption was stored under its own name, so its name can appear in a path. +
  • +
  • + Up to 180 days: backup copies that Google, which runs our servers, keeps of its own systems. + We cannot read an account out of them, and they expire on Google’s schedule. +
  • +
  • + As long as the law requires: if you ever paid for Pro, Stripe keeps its records of those + payments (receipts and invoices, with the email address they were sent to) for tax and accounting. Stripe was + the seller of those payments and holds these records itself. +
  • +
  • + Up to 14 months: usage statistics from the Android and iPhone apps (for example that the app + was opened, and on what kind of device), collected by Google Analytics for Firebase. They are tied to an + identifier for an installation of the app, not to your account or your email address, and the app resets that + identifier when you delete your account from it. +
  • +
  • Email you have sent us stays in our mailbox until you ask us to delete it too.
  • +
+ +

+ Apart from deleting your account, a pushed file is always deleted on its own {LIMITS.free.keepDays} days after + it was pushed on the free plan and {LIMITS.pro.keepDays} days on Pro, and you can delete any push from the list + at any time. The privacy policy describes everything PushThat stores. +

+ +
DELETE YOUR ACCOUNT NOW
+ + {done ? ( +

Done: the account is deleted. There is nothing more to do here.

+ ) : ( +
+ + {sessionEmail ? ( +

+ Signed in as {sessionEmail}. This deletes that account. +

+ ) : ( + + )} + + + {error && ( +

+ {error} +

+ )} + +
+ )} +
+ ); +} diff --git a/apps/web/src/pages/Privacy.tsx b/apps/web/src/pages/Privacy.tsx index 1d8379d..837654e 100644 --- a/apps/web/src/pages/Privacy.tsx +++ b/apps/web/src/pages/Privacy.tsx @@ -15,7 +15,7 @@ export default function Privacy() {

PushThat Privacy Policy

-

Last updated: October 27, 2026

+

Last updated: November 12, 2026

This Privacy Policy describes how your personal information is collected, used, and shared when you visit or @@ -138,6 +138,12 @@ export default function Privacy() { never include Push Content.

+

+ To show an icon beside a link in your list, the App asks the linked website itself for its icon, from your + device. That website sees the request, as it would if you opened the link; no one else is told which websites + your links are on, and the request does not pass through our servers. +

+

Finally, we may also share your Personal Information to comply with applicable laws and regulations, to respond to a subpoena, search warrant or other lawful request for information we receive, or to otherwise protect our @@ -169,12 +175,19 @@ export default function Privacy() {

When you create a subscription through the App, we will maintain your Subscription Information for our records - unless and until you ask us to delete this information. + unless and until you delete your account or ask us to delete this information. Stripe keeps its own records of + your payments for as long as tax and accounting law requires. +

+ +

+ You can delete your account, with everything stored under it, at any time: from the App’s menu, or at{' '} + {'https://pushthat.link/delete-account'}, which also lists exactly what is + deleted, the little that is kept afterwards (server logs, backups and payment records), and for how long.

- A link push is kept until you delete it, or until you ask us to delete your account. A file push, with its - record, is deleted when the retention period of the plan your account was on when you pushed it ends ( + A link push is kept until you delete it, or until you delete your account. A file push, with its record, is + deleted when the retention period of the plan your account was on when you pushed it ends ( {LIMITS.free.keepDays} days on the free plan, {LIMITS.pro.keepDays} days on Pro).

diff --git a/apps/web/src/pages/Support.tsx b/apps/web/src/pages/Support.tsx index 08775b4..42d291e 100644 --- a/apps/web/src/pages/Support.tsx +++ b/apps/web/src/pages/Support.tsx @@ -69,7 +69,11 @@ export default function Support() { largest file is {formatMb(LIMITS.free.fileBytes)} on the free plan and {formatMb(LIMITS.pro.fileBytes)} on Pro.

-

To close your account, write to us from its address and we will delete it, with everything pushed from it.

+

+ To close your account, use Delete account, here or in the app’s menu: it + deletes the account at once, with everything pushed from it. If you cannot sign in, write to us from the + account’s address and we will delete it for you. +

BUGS AND IDEAS
diff --git a/apps/web/src/pages/Terms.tsx b/apps/web/src/pages/Terms.tsx index 679349f..8ab6ab7 100644 --- a/apps/web/src/pages/Terms.tsx +++ b/apps/web/src/pages/Terms.tsx @@ -20,7 +20,7 @@ export default function Terms() { SERVICES, YOU HEREBY AGREE TO BE BOUND BY THE TERMS AND ALL TERMS INCORPORATED HEREIN BY REFERENCE. IT IS THE RESPONSIBILITY OF YOU, THE USER, CUSTOMER, OR PROSPECTIVE CUSTOMER TO READ THE TERMS AND CONDITIONS BEFORE PROCEEDING TO USE THIS SITE. IF YOU DO NOT EXPRESSLY AGREE TO ALL OF THE TERMS AND CONDITIONS, THEN PLEASE DO - NOT ACCESS OR USE OUR SITES OR OUR SERVICES. THIS TERMS OF SERVICE AGREEMENT IS EFFECTIVE AS OF 10/27/2026 + NOT ACCESS OR USE OUR SITES OR OUR SERVICES. THIS TERMS OF SERVICE AGREEMENT IS EFFECTIVE AS OF 11/12/2026

ACCEPTANCE OF TERMS

@@ -358,7 +358,9 @@ export default function Terms() {

As a member of {'https://pushthat.link'}, you may cancel or terminate your account, associated email address - and/or access to our Services by submitting a cancellation or termination request to terminate@pushthat.link. + and/or access to our Services by submitting a cancellation or termination request to terminate@pushthat.link, or + by deleting your account yourself at {'https://pushthat.link/delete-account'} or from the menu of our + applications.

diff --git a/apps/web/src/services/account.ts b/apps/web/src/services/account.ts new file mode 100644 index 0000000..1203b01 --- /dev/null +++ b/apps/web/src/services/account.ts @@ -0,0 +1,51 @@ +import { deleteApp, initializeApp } from 'firebase/app'; +import { inMemoryPersistence, initializeAuth, signOut } from 'firebase/auth'; +import { getFunctions, httpsCallable } from 'firebase/functions'; + +import { app, auth } from '@/config/firebase'; + +import { clearAccountKey } from './keyStore'; +import { proveByPassword } from './signIn'; + +/** + * Deletes the account that `email` and `password` sign in to, for good (docs/account-deletion.md + * at the repository root): the `deleteAccount` function removes the account and everything + * stored under it, and then this browser forgets its key and its session. + * + * The function needs a sign-in made in the last five minutes, so the password is always + * asked for, whether or not the browser is signed in. That sign-in is made on a second + * Firebase app that lives only for this call, with its session in memory, and not on the + * site's own: + * + * - The page is public, and the person deleting may not be signed in here, or may have + * uninstalled every app. Signing them in on the site's own `auth` would flip the site + * into its signed-in frame partway through (`Layout`), remount the page that asked, and + * tell the extension's worker that an account had arrived, all for a session that is + * gone a second later. + * - If the function refuses (a subscription that will not cancel), nobody has been signed + * in to this browser as a side effect. + * + * `onDeleted` is called once the function has answered, before this browser's own session + * is ended: ending it remounts the page that asked (see above), so a caller that waited + * for this to resolve would be setting state on a component that is already gone. + * + * Throws `WrongPasswordError` for a wrong email or password, and whatever the function + * refused with (`errorText` in plan.ts reads it). + */ +export async function deleteAccount(email: string, password: string, onDeleted: () => void): Promise { + const scratch = initializeApp(app.options, `delete-account-${Date.now()}`); + try { + const user = await proveByPassword(initializeAuth(scratch, { persistence: inMemoryPersistence }), email, password); + await httpsCallable(getFunctions(scratch), 'deleteAccount')(); + onDeleted(); + // The site's own session, if it is this account's, is over on the server already. + // Its key goes first, as at any sign-out (KeysContext clears it too, once the session + // ends; doing it here does not wait on that). + if (auth.currentUser?.uid === user.uid) { + await clearAccountKey().catch((error) => console.error('Could not clear the account key:', error)); + await signOut(auth); + } + } finally { + await deleteApp(scratch).catch(() => undefined); + } +} diff --git a/apps/web/src/services/signIn.ts b/apps/web/src/services/signIn.ts index 3887edc..6715ad4 100644 --- a/apps/web/src/services/signIn.ts +++ b/apps/web/src/services/signIn.ts @@ -1,5 +1,11 @@ import { FirebaseError } from 'firebase/app'; -import { type User, createUserWithEmailAndPassword, signInWithEmailAndPassword, updatePassword } from 'firebase/auth'; +import { + type Auth, + type User, + createUserWithEmailAndPassword, + signInWithEmailAndPassword, + updatePassword, +} from 'firebase/auth'; import { getFirestore } from 'firebase/firestore'; import { app, auth } from '@/config/firebase'; @@ -70,21 +76,33 @@ const isWrongPassword = (error: unknown) => error instanceof FirebaseError && WR * in this way and then moves the account back to `authKey`. It can go only once a reset * sets `authKey` itself, which needs the reset email's action URL unlocked. */ -async function signInEitherWay(email: string, password: string, keys: DerivedKeys) { +async function signInEitherWay(email: string, password: string, keys: DerivedKeys, on: Auth = auth) { try { - const { user } = await signInWithEmailAndPassword(auth, email, keys.firebasePassword); + const { user } = await signInWithEmailAndPassword(on, email, keys.firebasePassword); return { user, legacy: false }; } catch (error) { if (!isWrongPassword(error)) throw error; } try { - const { user } = await signInWithEmailAndPassword(auth, email, password); + const { user } = await signInWithEmailAndPassword(on, email, password); return { user, legacy: true }; } catch (error) { throw isWrongPassword(error) ? new WrongPasswordError('wrong email or password') : error; } } +/** + * Proves the password and nothing more: a sign-in on `on` that reads and writes no keys. + * For deleting the account (account.ts), which needs a fresh sign-in and must not make + * keys or a recovery key for an account that is about to go. The legacy fallback applies + * here as at any sign-in, so an account whose password was just reset can still be + * deleted. Throws `WrongPasswordError` for a wrong email or password. + */ +export async function proveByPassword(on: Auth, email: string, password: string): Promise { + const keys = await deriveKeys(email, password); + return (await signInEitherWay(email, password, keys, on)).user; +} + /** * Signs in (or, for a session that has no key on this device, signs in again) and opens * the account key. About a second of PBKDF2 first. From abbae1b692599a7873d02e030075803fb172cc0f Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 15:21:29 -0600 Subject: [PATCH 04/64] Serve the deletion page, the terms and the privacy policy as static HTML 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 --- apps/web/package.json | 2 +- apps/web/scripts/prerender.mjs | 55 ++++++++++++++++++++++++++++++++ apps/web/src/prerender.tsx | 57 ++++++++++++++++++++++++++++++++++ docs/account-deletion.md | 15 +++++++++ docs/web.md | 31 ++++++++++++++++++ firebase.json | 12 +++++++ 6 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 apps/web/scripts/prerender.mjs create mode 100644 apps/web/src/prerender.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 5013451..840ce72 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,7 +6,7 @@ "scripts": { "deploy": "npm run test:rules && npm run build && firebase deploy --only hosting,firestore,storage", "dev": "vite", - "build": "tsc -b && vite build", + "build": "tsc -b && vite build && vite build --ssr src/prerender.tsx --outDir dist-ssr && node scripts/prerender.mjs", "build:chrome": "vite build -c vite.chrome.config.ts", "lint": "eslint .", "preview": "vite preview", diff --git a/apps/web/scripts/prerender.mjs b/apps/web/scripts/prerender.mjs new file mode 100644 index 0000000..d3b0c19 --- /dev/null +++ b/apps/web/scripts/prerender.mjs @@ -0,0 +1,55 @@ +// Writes the static copies of the pages that must read without JavaScript (src/prerender.tsx +// says which, and why): `dist/delete-account.html`, `dist/terms.html`, `dist/privacy.html`. +// Run by `npm run build`, after the site's build and the server build of src/prerender.tsx. +// The root firebase.json serves each at its own address (`/delete-account`), ahead of the +// rewrite that sends every other address to the app. +// +// Each copy is the built index.html with the page's markup inside the root element, so it +// carries the same stylesheet and the same script: with JavaScript the app starts over +// that markup and takes the page over, and without it the markup is the page. +import { readFile, rm, writeFile } from 'node:fs/promises'; + +const dist = new URL('../dist/', import.meta.url); +const ssr = new URL('../dist-ssr/', import.meta.url); +const { paths, render } = await import(new URL('prerender.js', ssr).href); + +const shell = await readFile(new URL('index.html', dist), 'utf8'); +const ROOT = '

'; +if (!shell.includes(ROOT)) throw new Error(`dist/index.html has no ${ROOT} to fill`); + +const escape = (text) => text.replace(/&/g, '&').replace(/${html}`) + .replace(/[^<]*<\/title>/, `<title>${escape(title || 'PushThat')}`) + // The front page's description and card would otherwise describe this page. + .replace(/(', ` \n `); + await writeFile(new URL(`${path.slice(1)}.html`, dist), page); + console.log(`prerendered ${path} (${html.length} bytes of markup)`); +} + +// Only this script reads the server build. +await rm(ssr, { recursive: true, force: true }); + +// Said outright: importing the pages starts Firebase (config/firebase.ts), whose Auth keeps +// a timer alive, and Node would otherwise sit here until the build was killed. +process.exit(0); diff --git a/apps/web/src/prerender.tsx b/apps/web/src/prerender.tsx new file mode 100644 index 0000000..abcd64f --- /dev/null +++ b/apps/web/src/prerender.tsx @@ -0,0 +1,57 @@ +import { renderToString } from 'react-dom/server'; +import { Helmet } from 'react-helmet'; +import { StaticRouter } from 'react-router'; + +import Layout from './Layout'; +import { DeleteAccount } from './pages/DeleteAccount'; +import Privacy from './pages/Privacy'; +import Terms from './pages/Terms'; + +/** + * The pages that are also served as static HTML (scripts/prerender.mjs runs this after + * the build). The site is rendered in the browser, so a reader that runs no JavaScript + * gets an empty page from every address, and for these three that reader matters: Google + * Play's review opens the account deletion page and the privacy policy, a store listing + * links to the terms, and someone on a locked-down browser must still be able to read how + * to delete their account. + * + * Each is rendered inside `Layout` as a signed-out visitor sees it. No `AuthProvider`: + * it renders nothing until Firebase has answered, which on a server is never, and without + * it `useAuth` gives its signed-out default, which is the page a stranger gets. In the + * browser `main.tsx` then draws the app over this markup as it does over an empty root, + * so nothing here has to match what the client renders. + * + * A page added here must read whole without effects or state: nothing in it may be + * fetched after the first render. + */ +const PAGES: Record JSX.Element; description: string }> = { + '/delete-account': { + page: () => , + description: + 'How to delete your PushThat account and its data, what is deleted, what is kept and for how long. PushThat is an app by MakeRocketGoNow LLC.', + }, + '/terms': { + page: () => , + description: 'The terms of service for PushThat, an app by MakeRocketGoNow LLC.', + }, + '/privacy': { + page: () => , + description: 'What PushThat stores, what it cannot read, who it is shared with and how long it is kept.', + }, +}; + +export const paths = Object.keys(PAGES); + +export function render(path: string): { html: string; title: string; description: string } { + const { page, description } = PAGES[path]; + const html = renderToString( + + {page()} + + ); + // Read, and with that emptied: Helmet collects every title rendered since the last read. + const title = Helmet.renderStatic() + .title.toString() + .replace(/<[^>]+>/g, ''); + return { html, title, description }; +} diff --git a/docs/account-deletion.md b/docs/account-deletion.md index 54e61ed..326a302 100644 --- a/docs/account-deletion.md +++ b/docs/account-deletion.md @@ -105,3 +105,18 @@ account key and any unconfirmed recovery key, the decrypted file cache, and the pushes waiting to be retried. On Android and iOS it also resets the installation's Analytics id. The device id stays, as it does at sign-out: it belongs to the install, not the account. + +## The public page + +`/delete-account` (`apps/web/src/pages/DeleteAccount.tsx`) names the app and the developer, +gives the steps, lists what is deleted and what is kept, and has the form. It is served as +static HTML as well ([the web app](web.md#pages-that-read-without-javascript)), so all of +it but the form reads without JavaScript, and it is reachable signed out: the person may +have uninstalled every app. The form signs in on a second Firebase app that lives only for +the call (`services/account.ts` says why), so it works the same signed in or out. Someone +who has forgotten their password resets it first; someone who cannot sign in at all writes +to support. + +The website's and the extension's menu item leads to this page. The Android, iOS and macOS +apps ask for the password in a dialog of their own and call the function directly. + diff --git a/docs/web.md b/docs/web.md index c3c19aa..ef67d68 100644 --- a/docs/web.md +++ b/docs/web.md @@ -39,6 +39,37 @@ The extension logs to its own console only (the service worker's, under "Inspect ids to a third party the privacy policy did not name, so that was removed rather than disclosed. +## Pages that read without JavaScript + +The site is rendered in the browser, so every address answers with the same empty shell +until the script runs. Three pages are also written out as static HTML at build time, +because the reader that matters most for them may run no script: `/delete-account` (the +account deletion page Google Play is given, see [account deletion](account-deletion.md)), +`/terms` and `/privacy`. + +`npm run build` builds the site, then builds `src/prerender.tsx` for Node, and +`scripts/prerender.mjs` renders each page into a copy of the built `index.html` +(`dist/delete-account.html` and so on). The root `firebase.json` rewrites each address to +its file, ahead of the rewrite that sends everything else to the app. Each copy still +loads the app's script, which then draws over the markup as it would over an empty root, +so with JavaScript nothing changes. The script fails the build if a copy is missing the +text it exists to carry. + +Rewrites rather than `cleanUrls`, which would also redirect `/index.html` to `/`: the list +links to `/index.html?view=...` to show an image push, in the site as in the extension. + +To add a page, add it to `PAGES` in `src/prerender.tsx` and a rewrite in `firebase.json`. +It must render whole on the first pass, with nothing behind an effect. + +## Favicons + +The list shows each link's icon by asking the linked site itself for `/favicon.ico`. It +used to ask Google's favicon service, which told Google the domain of every link pushed; +a proxy of our own would have put the same list in our logs, for pushes that are otherwise +end-to-end encrypted. A site with no icon there gets ours. The apps go one step further +and read the page's ``, which a browser will not let a script do across +origins. + ## Rules, tests and deploying `npm run test:rules` runs the rules tests against the Firestore and Storage emulators (a diff --git a/firebase.json b/firebase.json index 2bd80ca..961659d 100644 --- a/firebase.json +++ b/firebase.json @@ -7,6 +7,18 @@ "**/node_modules/**" ], "rewrites": [ + { + "source": "/delete-account", + "destination": "/delete-account.html" + }, + { + "source": "/terms", + "destination": "/terms.html" + }, + { + "source": "/privacy", + "destination": "/privacy.html" + }, { "source": "**", "destination": "/index.html" From da0a1133487422c428f4e490d685dc18cc5366e1 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 15:26:05 -0600 Subject: [PATCH 05/64] Ask the linked site for its icon on Android instead of Google 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 `` 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 --- .../com/makerocketgonow/pushthat/Favicons.kt | 134 ++++++++++++++++++ .../pushthat/SwivelCheckView.kt | 76 +++++++--- .../makerocketgonow/pushthat/e2ee/SignOut.kt | 8 +- .../makerocketgonow/pushthat/FaviconsTest.kt | 83 +++++++++++ 4 files changed, 281 insertions(+), 20 deletions(-) create mode 100644 apps/android/app/src/main/java/com/makerocketgonow/pushthat/Favicons.kt create mode 100644 apps/android/app/src/test/java/com/makerocketgonow/pushthat/FaviconsTest.kt diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/Favicons.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/Favicons.kt new file mode 100644 index 0000000..0a4b5ff --- /dev/null +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/Favicons.kt @@ -0,0 +1,134 @@ +package com.makerocketgonow.pushthat + +import android.net.Uri +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import okhttp3.OkHttpClient +import okhttp3.Request +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import java.io.ByteArrayInputStream +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit + +/** + * Where a pushed link's icon is: found by asking the linked site itself, from this phone. + * + * The list used to load `google.com/s2/favicons?domain=` for every row, which told + * Google the domain of every link pushed, for pushes that are end-to-end encrypted so that + * nobody but their owner knows what they are. Asking through a server of our own would + * only move that list into our logs. The one party that may learn a link was pushed is the + * site the link already points at, so that is who is asked: the site's front page is read + * for `` (and `apple-touch-icon`, which is usually the sharper one), and + * when it names none the icon is `/favicon.ico`, where browsers look. A site with neither + * gets the app's placeholder (`SwivelCheckView`). + * + * Per site, not per link, and kept in memory only. Written to disk, the answers would be a + * plaintext list of the sites an account pushes, on a phone whose pushes are otherwise held + * as ciphertext; in memory they go with the process, and [clear] drops them at sign-out. + * The cost is one small request per site per start of the app. + */ +object Favicons { + /** As much of a front page as is read: the `` is at the top (the title scraper reads the same). */ + private const val MAX_PAGE_BYTES = 256L * 1024 + + /** What an `apple-touch-icon` with no `sizes` is taken to be: the size Apple asks for. */ + private const val APPLE_TOUCH_DEFAULT = 180 + + private const val TAG = "Favicons" + + // No cookie jar and no cache: the site is told nothing but that its front page was read. + // Short timeouts, since a row's icon is not worth waiting on a slow site for. + private val http = OkHttpClient.Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .callTimeout(8, TimeUnit.SECONDS) + .build() + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + /** One lookup per site, shared by every row that links to it, running or done. */ + private val lookups = ConcurrentHashMap>>() + + /** `scheme://host[:port]` for a web address, or null for anything else (a `mailto:`, a typo). */ + fun originOf(url: String?): String? { + val uri = url?.let { runCatching { Uri.parse(it) }.getOrNull() } ?: return null + val scheme = uri.scheme?.lowercase() + val host = uri.host + if ((scheme != "https" && scheme != "http") || host.isNullOrEmpty()) return null + return if (uri.port == -1) "$scheme://$host" else "$scheme://$host:${uri.port}" + } + + /** The icons already found for [origin], if its lookup has finished: lets a row draw without a flash. */ + fun known(origin: String): List? = + lookups[origin]?.takeIf { it.isCompleted && !it.isCancelled }?.let { runCatching { it.getCompleted() }.getOrNull() } + + /** + * The addresses to try for [origin]'s icon, best first: what its front page names, then + * `/favicon.ico`. Never empty and never throws: a site that cannot be read still has + * `/favicon.ico` to try. + */ + suspend fun candidates(origin: String): List = + lookups.getOrPut(origin) { scope.async { lookUp(origin) } }.await() + + /** Forgets every site looked up. At sign-out (`forgetSession`): the next account's list is its own. */ + fun clear() = lookups.clear() + + private fun lookUp(origin: String): List { + val fallback = "$origin/favicon.ico" + val named = try { + val request = Request.Builder().url("$origin/").header("User-Agent", "Mozilla/5.0").build() + http.newCall(request).execute().use { response -> + val body = response.body + val type = body.contentType() + if (!response.isSuccessful || type == null || !type.subtype.lowercase().contains("html")) { + emptyList() + } else { + val source = body.source() + source.request(MAX_PAGE_BYTES) + val bytes = source.buffer.readByteArray(minOf(source.buffer.size, MAX_PAGE_BYTES)) + // Resolved against where the request ended up, after any redirect. + iconsIn(Jsoup.parse(ByteArrayInputStream(bytes), type.charset()?.name(), response.request.url.toString())) + } + } + } catch (e: Exception) { + // The exception's type only: its message can name the site, which is what a + // pushed link's host is and what the log must not hold. + Log.d(TAG, "No front page to read an icon from: ${e.javaClass.simpleName}") + emptyList() + } + return (named + fallback).distinct() + } + + /** + * The icons a page names, largest first, at most three. Visible for the unit test. + * + * SVG and Safari's `mask-icon` are left out, since Glide draws neither, and so is an + * icon inlined as a `data:` address. A size comes from `sizes` ("32x32", or the largest + * of several); an `apple-touch-icon` without one is 180, and anything else without one + * is the smallest, since it is usually the 16 or 32 pixel `.ico`. + */ + internal fun iconsIn(page: Document): List = + page.select("link[rel][href]") + .mapNotNull { link -> + val rel = link.attr("rel").lowercase().split(Regex("\\s+")) + val apple = rel.any { it.startsWith("apple-touch-icon") } + if (!apple && "icon" !in rel) return@mapNotNull null + val href = link.absUrl("href") + val web = href.startsWith("https://") || href.startsWith("http://") + val svg = link.attr("type").contains("svg", ignoreCase = true) || + href.substringBefore('?').substringBefore('#').endsWith(".svg", ignoreCase = true) + if (!web || svg) return@mapNotNull null + val declared = Regex("(\\d+)x\\d+", RegexOption.IGNORE_CASE) + .findAll(link.attr("sizes")).mapNotNull { it.groupValues[1].toIntOrNull() }.maxOrNull() + href to (declared ?: if (apple) APPLE_TOUCH_DEFAULT else 0) + } + .sortedByDescending { it.second } + .map { it.first } + .distinct() + .take(3) +} diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/SwivelCheckView.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/SwivelCheckView.kt index 1fc908f..8e9a0cc 100644 --- a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/SwivelCheckView.kt +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/SwivelCheckView.kt @@ -2,7 +2,7 @@ package com.makerocketgonow.pushthat import android.content.Context import android.graphics.Camera -import android.net.Uri +import android.graphics.drawable.Drawable import android.util.AttributeSet import android.util.Log import android.view.View @@ -13,7 +13,11 @@ import android.widget.ImageView import android.widget.ViewFlipper import androidx.interpolator.view.animation.FastOutSlowInInterpolator import com.bumptech.glide.Glide +import com.bumptech.glide.RequestBuilder import com.bumptech.glide.request.target.Target +import kotlinx.coroutines.Job +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch class SwivelCheckView @JvmOverloads constructor( context: Context, @@ -31,6 +35,11 @@ class SwivelCheckView @JvmOverloads constructor( private var onCheckedChangeListener: ((SwivelCheckView, Boolean) -> Unit)? = null + // The lookup of the linked site's icon this view is waiting on, and the site it is for. + // Views are recycled, so both are reset on every bind (loadFavicon). + private var iconLookup: Job? = null + private var iconOrigin: String? = null + init { View.inflate(context, R.layout.merge_swivel_check, this) frontImageView = getChildAt(0) as ImageView @@ -71,11 +80,16 @@ class SwivelCheckView @JvmOverloads constructor( override fun toggle() = setChecked(!isChecked) /** - * The icon for [fileExt] when there is one, else [url]'s favicon. A push with neither - * (an encrypted file of an unknown type, a locked push) gets the placeholder: there is - * no host to ask for, and a locked push's URL is not known at all. + * The icon for [fileExt] when there is one, else the icon of the site [url] is on, asked + * of that site and nobody else (see [Favicons]). A push with neither (an encrypted file + * of an unknown type, a locked push) gets the placeholder: there is no site to ask, and + * a locked push's URL is not known at all. */ fun loadFavicon(url: String?, fileExt: String? = null) { + // Whatever this view was waiting on belongs to the row it showed before. + iconLookup?.cancel() + iconLookup = null + iconOrigin = null // Check if there's a file extension and it matches our supported icons if (!fileExt.isNullOrBlank()) { Log.d("SwivelCheckView", "File extension is not null or blank") @@ -96,26 +110,48 @@ class SwivelCheckView @JvmOverloads constructor( } } - // If no file extension or unsupported, fall back to favicon - val host = url?.let { Uri.parse(it).host } - if (host.isNullOrEmpty()) { + // No icon for the file type: the linked site's own icon (Favicons), or the + // placeholder when there is no site to ask. + backImageView.setImageResource(R.drawable.ic_check_circle) + backImageView.setColorFilter(context.getColor(R.color.checkmark_color)) + val origin = Favicons.originOf(url) + if (origin == null) { Glide.with(context).clear(frontImageView) frontImageView.setImageResource(R.drawable.default_favicon) - backImageView.setImageResource(R.drawable.ic_check_circle) - backImageView.setColorFilter(context.getColor(R.color.checkmark_color)) return } - val faviconUrl = "https://www.google.com/s2/favicons?domain=${Uri.encode(host)}&sz=256" + // Looked up before: drawn at once, so a row scrolled back into view does not blink + // through the placeholder. + Favicons.known(origin)?.let { return showSiteIcon(it) } + Glide.with(context).clear(frontImageView) + frontImageView.setImageResource(R.drawable.default_favicon) + iconLookup = iconScope.launch { + val candidates = Favicons.candidates(origin) + // Still this row's site: a recycled view's lookup was cancelled above, and this + // guards the moment between the answer and the cancel. + if (iconOrigin == origin) showSiteIcon(candidates) + } + iconOrigin = origin + } - Glide.with(context) - .load(faviconUrl) - .fitCenter() - .override(Target.SIZE_ORIGINAL) - .error(R.drawable.default_favicon) - .into(frontImageView) + /** + * Draws the first of [candidates] that loads, and the placeholder when none does: each + * is the `error` request of the one before, which is how Glide chains fallbacks. + */ + private fun showSiteIcon(candidates: List) { + val requests = Glide.with(context) + var chain: RequestBuilder? = null + for (candidate in candidates.asReversed()) { + val request = requests.load(candidate).fitCenter().override(Target.SIZE_ORIGINAL) + chain = if (chain == null) request.error(R.drawable.default_favicon) else request.error(chain) + } + chain?.into(frontImageView) ?: frontImageView.setImageResource(R.drawable.default_favicon) + } - backImageView.setImageResource(R.drawable.ic_check_circle) - backImageView.setColorFilter(context.getColor(R.color.checkmark_color)) + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + // A lookup for a row that has left the screen has nowhere to draw. + iconLookup?.cancel() } fun setOnCheckedChangeListener(listener: (SwivelCheckView, Boolean) -> Unit) { @@ -123,6 +159,10 @@ class SwivelCheckView @JvmOverloads constructor( } companion object { + // One scope for every row's lookup: each view cancels its own job, and the work + // itself runs in Favicons' scope, so nothing here outlives a row by more than a hop. + private val iconScope = MainScope() + private val SUPPORTED_FILE_ICONS = listOf( "pproj.png", "zip.png", "indd.png", "csv.png", "css.png", "pdf.png", "aac.png", "png.png", "xls.png", "avi.png", "txt.png", "mp3.png", diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/e2ee/SignOut.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/e2ee/SignOut.kt index 5d2ae79..fd5b116 100644 --- a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/e2ee/SignOut.kt +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/e2ee/SignOut.kt @@ -4,6 +4,7 @@ import android.content.Context import com.google.firebase.Firebase import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.auth +import com.makerocketgonow.pushthat.Favicons import com.makerocketgonow.pushthat.PushFiles /** @@ -21,10 +22,13 @@ fun signOut(context: Context, auth: FirebaseAuth = Firebase.auth) { /** * What a session leaves on this install, dropped when it ends however it ends (Logout, or - * Firebase ending it on its own): the account key, and the last file decrypted with it, - * which sits in plaintext in the cache until the next file is opened. + * Firebase ending it on its own): the account key, the last file decrypted with it, which + * sits in plaintext in the cache until the next file is opened, and the list of sites + * whose icons were looked up. */ fun forgetSession(context: Context) { AccountKeyStore.clear(context) PushFiles.clear(context) + // The sites whose icons were looked up for this account's list, held in memory. + Favicons.clear() } diff --git a/apps/android/app/src/test/java/com/makerocketgonow/pushthat/FaviconsTest.kt b/apps/android/app/src/test/java/com/makerocketgonow/pushthat/FaviconsTest.kt new file mode 100644 index 0000000..e97618d --- /dev/null +++ b/apps/android/app/src/test/java/com/makerocketgonow/pushthat/FaviconsTest.kt @@ -0,0 +1,83 @@ +package com.makerocketgonow.pushthat + +import org.jsoup.Jsoup +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Which of a page's icons [Favicons] picks. The fetch around it needs a network and is not + * tested here; the choice is the part with rules in it. + */ +class FaviconsTest { + private fun icons(head: String, base: String = "https://example.com/") = + Favicons.iconsIn(Jsoup.parse("$head", base)) + + @Test + fun `a page with no icon names none`() { + assertEquals(emptyList(), icons("""""")) + } + + @Test + fun `a relative address is resolved against the page`() { + assertEquals( + listOf("https://example.com/static/icon.png"), + icons("""""") + ) + assertEquals( + listOf("https://cdn.example.com/icon.png"), + icons("""""") + ) + } + + @Test + fun `the largest declared size comes first`() { + assertEquals( + listOf("https://example.com/192.png", "https://example.com/apple.png", "https://example.com/32.png"), + icons( + """ + + + + """ + ) + ) + } + + @Test + fun `an icon with no size comes after the sized ones`() { + assertEquals( + listOf("https://example.com/48.png", "https://example.com/favicon.ico"), + icons("""""") + ) + } + + @Test + fun `what cannot be drawn is left out`() { + assertEquals( + listOf("https://example.com/icon.png"), + icons( + """ + + + + + + """ + ) + ) + } + + @Test + fun `no more than three are tried`() { + val many = (1..6).joinToString("") { """""" } + assertEquals( + listOf("https://example.com/6.png", "https://example.com/5.png", "https://example.com/4.png"), + icons(many) + ) + } + + @Test + fun `rel is matched whatever its case`() { + assertEquals(listOf("https://example.com/i.png"), icons("""""")) + } +} From 95122a96f18effef2b67ecf048ef04798922af5d Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 15:26:05 -0600 Subject: [PATCH 06/64] Add Delete account to the Android menu 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 --- .../pushthat/AccountDeletion.kt | 120 ++++++++++++++++++ .../pushthat/KeySettingsViewModel.kt | 23 +++- .../makerocketgonow/pushthat/MainActivity.kt | 41 ++++++ .../pushthat/data/PendingPushDao.kt | 6 + .../pushthat/e2ee/AccountKeys.kt | 30 +++++ .../main/res/drawable/ic_delete_account.xml | 6 + .../main/res/layout/dialog_delete_account.xml | 35 +++++ .../app/src/main/res/menu/overflow_menu.xml | 5 + .../app/src/main/res/values/strings.xml | 10 ++ apps/android/store/whatsnew/whatsnew-en-US | 6 +- 10 files changed, 276 insertions(+), 6 deletions(-) create mode 100644 apps/android/app/src/main/java/com/makerocketgonow/pushthat/AccountDeletion.kt create mode 100644 apps/android/app/src/main/res/drawable/ic_delete_account.xml create mode 100644 apps/android/app/src/main/res/layout/dialog_delete_account.xml diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/AccountDeletion.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/AccountDeletion.kt new file mode 100644 index 0000000..04d55f7 --- /dev/null +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/AccountDeletion.kt @@ -0,0 +1,120 @@ +package com.makerocketgonow.pushthat + +import android.content.Context +import android.util.Log +import com.bumptech.glide.Glide +import com.google.firebase.Firebase +import com.google.firebase.analytics.FirebaseAnalytics +import com.google.firebase.auth.auth +import com.google.firebase.messaging.FirebaseMessaging +import com.makerocketgonow.pushthat.data.AppDatabase +import com.makerocketgonow.pushthat.e2ee.AccountKeys +import com.makerocketgonow.pushthat.e2ee.signOut +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import java.io.IOException +import java.util.concurrent.TimeUnit + +/** + * "Delete account" from the list's menu (docs/account-deletion.md at the repository root): + * the password is proved with a fresh sign-in, the `deleteAccount` function deletes the + * account and everything stored under it, and then this install forgets what it held. + * + * The function is called over plain HTTPS as a Firebase callable, as `Plan` calls + * `getPlan`, so no further SDK is needed. + */ +object AccountDeletion { + private const val DELETE_ACCOUNT_URL = "https://us-central1-push-that.cloudfunctions.net/deleteAccount" + + /** + * The function said no, in [message]: a sentence it wrote for the person (a + * subscription that could not be cancelled, say), or null when it gave none. + */ + class RefusedException(message: String?) : Exception(message) + + // A long read: the function deletes an account's pushes five hundred at a time before + // it answers, and an answer lost to a timeout would leave this install signed in to an + // account that is already gone. + private val http = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .build() + + /** + * Deletes the signed-in account. Throws what the sign-in throws for a wrong password + * (`FirebaseAuthInvalidCredentialsException`), [RefusedException] when the function + * refuses, and `IOException` when it could not be reached; in each case nothing on + * this install has changed and the session is as it was. + */ + suspend fun delete(context: Context, password: String) { + val app = context.applicationContext + val uid = Firebase.auth.currentUser?.uid ?: throw IllegalStateException("not signed in") + // The function refuses a sign-in older than five minutes, so the token is one made + // by this proof of the password, not the one the session has been using. + val token = AccountKeys(app).freshTokenFor(password) + callDeleteAccount(token) + forget(app, uid) + } + + private suspend fun callDeleteAccount(token: String) = withContext(Dispatchers.IO) { + val request = Request.Builder() + .url(DELETE_ACCOUNT_URL) + .header("Authorization", "Bearer $token") + .post("{\"data\":{}}".toRequestBody("application/json".toMediaType())) + .build() + http.newCall(request).execute().use { response -> + val body = runCatching { JSONObject(response.body.string()) }.getOrNull() + if (response.isSuccessful && body?.optJSONObject("result")?.optBoolean("deleted") == true) return@use + // A callable's refusal is `{"error": {"status", "message"}}`. A bare status + // repeated as the message ("INTERNAL") is not a sentence for anyone. + val error = body?.optJSONObject("error") + val message = error?.optString("message")?.takeIf { it.isNotBlank() && it != error.optString("status") } + if (error == null && !response.isSuccessful) throw IOException("deleteAccount answered ${response.code}") + throw RefusedException(message) + } + } + + /** + * What this install held for the account, beyond what any sign-out drops. Each step is + * tried on its own: the account is already gone, so one that fails must not keep the + * rest, or the sign-out, from happening. + * + * - The pushes queued for a retry. Sign-out keeps them, for an account that will sign + * in again; this one never will. + * - The notification token, so Firebase stops addressing this install as that account's + * device. The copy in the user document went with the document. + * - The Analytics data and the installation's Analytics id, so what this install + * reports from here on is not joined to what it reported for the deleted account. + * - The icons cached for the account's links, which name the sites it pushed. + * + * Then the sign-out every button uses, which drops the account key, the last decrypted + * file and the session. The server has deleted the user, so there is nothing to ask of + * Firebase but to forget it here. + */ + private suspend fun forget(app: Context, uid: String) { + withContext(Dispatchers.IO) { + attempt("queued pushes") { AppDatabase.getDatabase(app).pendingPushDao().deleteAllFor(uid) } + attempt("notification token") { FirebaseMessaging.getInstance().deleteToken().await() } + attempt("icon cache") { Glide.get(app).clearDiskCache() } + } + attempt("analytics") { FirebaseAnalytics.getInstance(app).resetAnalyticsData() } + attempt("icon memory") { Glide.get(app).clearMemory() } + signOut(app) + } + + private inline fun attempt(what: String, step: () -> Unit) { + try { + step() + } catch (e: Exception) { + Log.w(TAG, "Could not clear the $what after deleting the account", e) + } + } + + private const val TAG = "AccountDeletion" +} diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/KeySettingsViewModel.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/KeySettingsViewModel.kt index 93cbbf1..ac0ee70 100644 --- a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/KeySettingsViewModel.kt +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/KeySettingsViewModel.kt @@ -6,6 +6,7 @@ import androidx.annotation.StringRes import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.google.firebase.FirebaseException +import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException import com.makerocketgonow.pushthat.e2ee.AccountKeyStore import com.makerocketgonow.pushthat.e2ee.AccountKeys import com.makerocketgonow.pushthat.e2ee.KeysChangedException @@ -14,12 +15,14 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import java.io.IOException /** - * "Change password" and "Show a new recovery key" from the list's menu. Outside the - * activity for the same reason as `LoginViewModel`: a new recovery key replaces the old - * one's wrap before it is shown, so a rotation that cancelled the step in between would - * leave a key that works and that nobody saw. + * "Change password", "Show a new recovery key" and "Delete account" from the list's menu. + * Outside the activity for the same reason as `LoginViewModel`: a new recovery key replaces + * the old one's wrap before it is shown, so a rotation that cancelled the step in between + * would leave a key that works and that nobody saw. A deletion cancelled between the + * server's answer and the sign-out would leave an install signed in to no account. */ class KeySettingsViewModel(application: Application) : AndroidViewModel(application) { private val keys = AccountKeys(application) @@ -29,6 +32,8 @@ class KeySettingsViewModel(application: Application) : AndroidViewModel(applicat class Working(@param:StringRes val message: Int) : State class RecoveryKey(val recoveryKey: String) : State class Done(@param:StringRes val message: Int) : State + /** The account is gone and this install is signed out: only the sign-in screen is left. */ + data object AccountDeleted : State class Failed(val message: String) : State } @@ -44,6 +49,11 @@ class KeySettingsViewModel(application: Application) : AndroidViewModel(applicat State.RecoveryKey(keys.replaceRecoveryKey()) } + fun deleteAccount(password: String) = run(R.string.deleting_account) { + AccountDeletion.delete(getApplication(), password) + State.AccountDeleted + } + /** The result has been seen. */ fun acknowledge() { if (_state.value !is State.Working) _state.value = State.Idle @@ -74,6 +84,11 @@ class KeySettingsViewModel(application: Application) : AndroidViewModel(applicat private fun messageFor(e: Exception): String { val app = getApplication() return when (e) { + // Before FirebaseException, whose own message for this is about a "credential". + is FirebaseAuthInvalidCredentialsException -> app.getString(R.string.wrong_password) + // The function's own sentence, written for the person, when it gave one. + is AccountDeletion.RefusedException -> e.message ?: app.getString(R.string.delete_account_failed) + is IOException -> app.getString(R.string.delete_account_failed) // Retrying cannot help: the key this install holds is not the account's. is KeysChangedException, is AccountKeys.NoAccountKeyException -> app.getString(R.string.keys_changed_here) // Firebase's own message for its errors: a wrong current password, no network. diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/MainActivity.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/MainActivity.kt index 95e3ad8..2facf94 100644 --- a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/MainActivity.kt +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/MainActivity.kt @@ -535,6 +535,10 @@ class MainActivity : AppCompatActivity() { .show() true } + R.id.nav_delete_account -> { + showDeleteAccount() + true + } R.id.action_delete -> { showDeleteConfirmation() true @@ -630,6 +634,36 @@ class MainActivity : AppCompatActivity() { } } + /** + * What deleting does, said before anything is asked, and the password that confirms it. + * One dialog rather than a warning and then a prompt: the password is the confirmation, + * and the function refuses without a sign-in made in the last five minutes. As with the + * password change, the button is wired after `show()` so an empty field keeps it open. + */ + private fun showDeleteAccount() { + val view = layoutInflater.inflate(R.layout.dialog_delete_account, null) + val password = view.findViewById(R.id.deleteAccountPasswordInput) + val dialog = AlertDialog.Builder(this, R.style.DeleteDialogStyle) + .setTitle(R.string.delete_account_title) + .setView(view) + .setPositiveButton(R.string.delete_account, null) + .setNegativeButton(R.string.cancel, null) + .show() + dialog.getButton(AlertDialog.BUTTON_POSITIVE).apply { + setTextColor(ContextCompat.getColor(this@MainActivity, R.color.color_error)) + setOnClickListener { + // Not trimmed: the keys are derived from the password exactly as typed. + val typed = password.text.toString() + if (typed.isEmpty()) { + password.error = getString(R.string.password_required) + } else { + dialog.dismiss() + keySettings.deleteAccount(typed) + } + } + } + } + /** * The settings' progress, result and recovery key, one dialog at a time, shown again * from the view model's state after a rotation. The recovery key cannot be dismissed @@ -640,6 +674,13 @@ class MainActivity : AppCompatActivity() { val builder = AlertDialog.Builder(this, R.style.DeleteDialogStyle) keySettingsDialog = when (state) { KeySettingsViewModel.State.Idle -> null + KeySettingsViewModel.State.AccountDeleted -> { + // The view model has signed this install out; there is no list to go back to. + Toast.makeText(this, R.string.account_deleted, Toast.LENGTH_LONG).show() + startActivity(Intent(this, LoginActivity::class.java)) + finish() + null + } is KeySettingsViewModel.State.Working -> builder .setMessage(state.message) .setCancelable(false) diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/data/PendingPushDao.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/data/PendingPushDao.kt index 81f27ab..9abda26 100644 --- a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/data/PendingPushDao.kt +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/data/PendingPushDao.kt @@ -26,4 +26,10 @@ interface PendingPushDao { @Delete fun delete(pendingPush: PendingPush): Int + + // For an account that has been deleted (AccountDeletion): its queued pushes have no + // account left to be written to. A row from before rows named their account has no + // user_id, and stays for whoever signs in next, as it always has. + @Query("DELETE FROM pending_pushes WHERE user_id = :userId") + fun deleteAllFor(userId: String): Int } \ No newline at end of file diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/e2ee/AccountKeys.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/e2ee/AccountKeys.kt index 7a9996c..f791326 100644 --- a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/e2ee/AccountKeys.kt +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/e2ee/AccountKeys.kt @@ -243,6 +243,36 @@ class AccountKeys( ?: throw if (AccountKeyStore.hasKey(appContext, uid)) KeyUnavailableException() else NoAccountKeyException() } + /** + * Proves [password] with a fresh sign-in and returns the ID token it gives, for the one + * call that needs a recent sign-in: deleting the account (`AccountDeletion`), which the + * function refuses from a session older than five minutes. Reads and writes no keys. + * + * The same passwords in the same order as [signInToFirebase], so an account that still + * has its raw password in Firebase (made before encryption, or straight after a reset) + * can be deleted without first being moved to `authKey`. Throws + * [FirebaseAuthInvalidCredentialsException] when none of them is the password. + */ + suspend fun freshTokenFor(password: String): String { + val user = auth.currentUser ?: throw IllegalStateException("not signed in") + val email = user.email ?: throw IllegalStateException("the account has no email") + val keys = derive(email, password) + var refusal: FirebaseAuthInvalidCredentialsException? = null + for (candidate in listOf(keys.firebasePassword, password, password.trim()).distinct()) { + if (candidate.isEmpty()) continue + try { + user.reauthenticate(EmailAuthProvider.getCredential(email, candidate)).await() + refusal = null + break + } catch (e: FirebaseAuthInvalidCredentialsException) { + refusal = refusal ?: e + } + } + refusal?.let { throw it } + // Forced: the token in hand carries the old sign-in's time, not this one's. + return user.getIdToken(true).await().token ?: throw IllegalStateException("no ID token after signing in") + } + /** * The spec's order: a new password wrap first, then the Firebase password, then the old * wrap goes. A crash between any two leaves an account that opens with either password, diff --git a/apps/android/app/src/main/res/drawable/ic_delete_account.xml b/apps/android/app/src/main/res/drawable/ic_delete_account.xml new file mode 100644 index 0000000..01799ae --- /dev/null +++ b/apps/android/app/src/main/res/drawable/ic_delete_account.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/apps/android/app/src/main/res/layout/dialog_delete_account.xml b/apps/android/app/src/main/res/layout/dialog_delete_account.xml new file mode 100644 index 0000000..13151f9 --- /dev/null +++ b/apps/android/app/src/main/res/layout/dialog_delete_account.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + diff --git a/apps/android/app/src/main/res/menu/overflow_menu.xml b/apps/android/app/src/main/res/menu/overflow_menu.xml index 774a5c2..d818863 100644 --- a/apps/android/app/src/main/res/menu/overflow_menu.xml +++ b/apps/android/app/src/main/res/menu/overflow_menu.xml @@ -22,6 +22,11 @@ android:icon="@drawable/ic_key" android:title="@string/new_recovery_key" android:theme="@style/PopupMenuTextStyle" /> + Making a new recovery key… That did not work. Please try again. Your encryption keys were changed on another device. Sign out and sign in again to use them here. + + Delete account + Delete your account? + This is permanent. Your account, your email address, every link and file you have pushed, and your encryption keys and recovery key are deleted, and every device is signed out.\n\nYour pushes are encrypted with those keys, so once they are gone nobody can recover them, including us. A Pro subscription is cancelled.\n\nEnter your password to delete your account. + Deleting your account… + Your account is deleted. + Your account could not be deleted. Nothing was removed. Please try again. + That is not your password. Nothing was deleted. Enter your password again to finish. diff --git a/apps/android/store/whatsnew/whatsnew-en-US b/apps/android/store/whatsnew/whatsnew-en-US index 145c21d..179fb61 100644 --- a/apps/android/store/whatsnew/whatsnew-en-US +++ b/apps/android/store/whatsnew/whatsnew-en-US @@ -1,3 +1,5 @@ -Your pushes are now end-to-end encrypted: only devices you sign in on can read them. Signing in sets up your keys from your password and shows a recovery key. Save it, because it is the only way to read your pushes again if you forget your password. Change password and Show a new recovery key are in the menu. +Your pushes are now end-to-end encrypted: only devices you sign in on can read them. Signing in sets up your keys from your password and shows a recovery key. Save it: it is the only way to read your pushes if you forget your password. -Notifications and search work on encrypted pushes. A share that cannot be confirmed is kept and retried instead of dropped, and a long press copies a file push's name. +New in the menu: Delete account, beside Change password and Show a new recovery key. Link icons now come straight from the linked site, not through Google. + +Notifications and search work on encrypted pushes, and a share that fails is kept and retried. From 4a81a0c49f2b55d0b99e423c7450ac0b01887982 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 15:27:59 -0600 Subject: [PATCH 07/64] Add Delete account and site-fetched link icons to the iOS and macOS apps 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 `` 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 --- apps/ios/PushThat.xcodeproj/project.pbxproj | 4 + apps/ios/PushThat/Models/PendingPush.swift | 18 + .../PushThat/Services/FaviconResolver.swift | 317 ++++++++++++++++++ .../PushThat/ViewModels/AuthViewModel.swift | 101 ++++++ apps/ios/PushThat/Views/AccountSettings.swift | 98 +++++- apps/ios/PushThat/Views/MainView.swift | 8 +- apps/ios/PushThat/Views/PushRowView.swift | 19 +- apps/ios/PushThat/Views/SwivelCheckView.swift | 25 +- apps/macos/PushThat.xcodeproj/project.pbxproj | 4 + apps/macos/PushThat/Models/PendingPush.swift | 18 + .../PushThat/Services/FaviconResolver.swift | 317 ++++++++++++++++++ .../PushThat/ViewModels/AuthViewModel.swift | 99 ++++++ .../PushThat/Views/AccountSettings.swift | 98 +++++- apps/macos/PushThat/Views/MainView.swift | 8 +- apps/macos/PushThat/Views/PushRowView.swift | 19 +- .../PushThat/Views/SwivelCheckView.swift | 25 +- 16 files changed, 1136 insertions(+), 42 deletions(-) create mode 100644 apps/ios/PushThat/Services/FaviconResolver.swift create mode 100644 apps/macos/PushThat/Services/FaviconResolver.swift diff --git a/apps/ios/PushThat.xcodeproj/project.pbxproj b/apps/ios/PushThat.xcodeproj/project.pbxproj index bc82056..dae0855 100644 --- a/apps/ios/PushThat.xcodeproj/project.pbxproj +++ b/apps/ios/PushThat.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ A1000005226B52E900000001 /* SearchTokenGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000005226B52E900000002 /* SearchTokenGenerator.swift */; }; A1000005226B52E900000005 /* FileUploadUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000005226B52E900000007 /* FileUploadUtils.swift */; }; A1000005226B52E900000003 /* TitleScraperService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000005226B52E900000004 /* TitleScraperService.swift */; }; + A1000030226B52E900000001 /* FaviconResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000030226B52E900000002 /* FaviconResolver.swift */; }; A1000006226B52E900000001 /* RetryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000006226B52E900000002 /* RetryManager.swift */; }; A1000007226B52E900000001 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000007226B52E900000002 /* Colors.swift */; }; A1000007226B52E900000003 /* FileTypeIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000007226B52E900000004 /* FileTypeIcons.swift */; }; @@ -127,6 +128,7 @@ A1000004226B52E900000004 /* PushListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushListViewModel.swift; sourceTree = ""; }; A1000005226B52E900000002 /* SearchTokenGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTokenGenerator.swift; sourceTree = ""; }; A1000005226B52E900000004 /* TitleScraperService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitleScraperService.swift; sourceTree = ""; }; + A1000030226B52E900000002 /* FaviconResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaviconResolver.swift; sourceTree = ""; }; A1000005226B52E900000007 /* FileUploadUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUploadUtils.swift; sourceTree = ""; }; A1000006226B52E900000002 /* RetryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryManager.swift; sourceTree = ""; }; A1000007226B52E900000002 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = ""; }; @@ -303,6 +305,7 @@ A1000005226B52E900000007 /* FileUploadUtils.swift */, A1000005226B52E900000002 /* SearchTokenGenerator.swift */, A1000005226B52E900000004 /* TitleScraperService.swift */, + A1000030226B52E900000002 /* FaviconResolver.swift */, A1000020226B52E900000002 /* E2eeCrypto.swift */, A1000020226B52E900000005 /* AccountKeyStore.swift */, A1000020226B52E900000007 /* AccountKeyService.swift */, @@ -559,6 +562,7 @@ A1000005226B52E900000001 /* SearchTokenGenerator.swift in Sources */, A1000005226B52E900000005 /* FileUploadUtils.swift in Sources */, A1000005226B52E900000003 /* TitleScraperService.swift in Sources */, + A1000030226B52E900000001 /* FaviconResolver.swift in Sources */, A1000006226B52E900000001 /* RetryManager.swift in Sources */, A1000007226B52E900000001 /* Colors.swift in Sources */, A1000007226B52E900000003 /* FileTypeIcons.swift in Sources */, diff --git a/apps/ios/PushThat/Models/PendingPush.swift b/apps/ios/PushThat/Models/PendingPush.swift index d9d6df4..080e793 100644 --- a/apps/ios/PushThat/Models/PendingPush.swift +++ b/apps/ios/PushThat/Models/PendingPush.swift @@ -59,6 +59,24 @@ final class PendingPush { prepared.flatMap { try? JSONDecoder().decode(PreparedPush.self, from: $0) } } + /// On account deletion: the account's queued pushes, which would otherwise wait for an + /// account that no longer exists. Opened the way `RetryManager` opens the store (the + /// default store `PushThatApp` also uses), and filtered here rather than in a + /// `#Predicate`, since `userId` is optional and the queue is a handful of rows at most. + /// A failure is logged and not thrown: the account is gone by the time this runs, and + /// the rows cannot be written anywhere without it. + static func deleteAll(for uid: String) { + do { + let context = ModelContext(try ModelContainer(for: PendingPush.self)) + for pending in try context.fetch(FetchDescriptor()) where pending.userId == uid { + context.delete(pending) + } + try context.save() + } catch { + print("Queued pushes were not removed after the account was deleted: \(Swift.type(of: error))") + } + } + /// A push queued before the encryption update, sealed under a new id with `keys`. /// /// A file push of that time names its plaintext upload by its title diff --git a/apps/ios/PushThat/Services/FaviconResolver.swift b/apps/ios/PushThat/Services/FaviconResolver.swift new file mode 100644 index 0000000..308fca4 --- /dev/null +++ b/apps/ios/PushThat/Services/FaviconResolver.swift @@ -0,0 +1,317 @@ +import Foundation +import SwiftUI +#if canImport(UIKit) +import UIKit +typealias PlatformImage = UIImage +#else +import AppKit +typealias PlatformImage = NSImage +#endif + +/// The icon for a pushed link, fetched by this device from the linked site itself. +/// +/// The list used to load `google.com/s2/favicons?domain=` for every link, which told +/// Google the domain of everything a user pushed. Pushes are end-to-end encrypted, so that +/// lookup undid part of the point. Now the icon comes from the site the user is about to +/// open anyway: the origin's root page is read for a `` (or +/// `apple-touch-icon`), and `/favicon.ico` stands in when it names none. Nothing goes +/// through our server or any third party. +/// +/// Results are kept per origin, in memory only, for as long as the process runs, so a row +/// that scrolls back or re-renders shows its icon at once and never fetches it again. Not +/// on disk: a plaintext list of the hosts someone has pushed would sit beside an encrypted +/// push list. `clear()` drops it on sign-out and account deletion. +/// +/// This file is the same in `apps/ios` and `apps/macos`. +final class FaviconResolver: @unchecked Sendable { + static let shared = FaviconResolver() + + /// A decoded icon. A class so it can cross from the fetching task to the view: `NSImage` + /// is not `Sendable`, and the image is never changed once made. + final class Icon: @unchecked Sendable { + let image: PlatformImage + init(_ image: PlatformImage) { self.image = image } + } + + private enum Entry { + case icon(Icon) + /// Looked up and nothing loaded: the placeholder, without asking again. + case none + } + + private let lock = NSLock() + private var resolved: [String: Entry] = [:] + private var inFlight: [String: Task] = [:] + /// Moved on by `clear()`, so a lookup that finishes after a sign-out does not put the + /// previous account's host back into the cache. + private var generation = 0 + + /// No cookies, no stored credentials, no disk cache: the site learns that this device + /// wants its icon and nothing that ties the request to a session there. Short timeouts, + /// since an icon is decoration and a slow site should fall to the placeholder quickly. + private let session: URLSession = { + let config = URLSessionConfiguration.ephemeral + config.httpCookieAcceptPolicy = .never + config.httpShouldSetCookies = false + config.urlCredentialStorage = nil + config.timeoutIntervalForRequest = 5 + config.timeoutIntervalForResource = 10 + return URLSession(configuration: config) + }() + + /// How much of the root page is read for its `` tags, which sit in the ``. + private static let maxPageBytes = 256 * 1024 + /// An icon larger than this is not an icon worth holding in memory for a 40 pt square. + private static let maxIconBytes = 1024 * 1024 + /// Declared icons tried before `/favicon.ico`, largest first. + private static let maxCandidates = 3 + + private init() {} + + /// `scheme://host[:port]/` for an http or https link; nil for anything else, which + /// keeps the placeholder. + static func origin(of url: URL) -> URL? { + guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https", + let host = url.host, !host.isEmpty + else { return nil } + var parts = URLComponents() + parts.scheme = scheme + parts.host = host.lowercased() + parts.port = url.port + parts.path = "/" + return parts.url + } + + /// The icon already resolved for this link's origin, without waiting: what a row shows + /// on its first frame, so that one re-created by the list does not flash the placeholder. + func cached(for url: URL) -> Icon? { + guard let key = Self.origin(of: url)?.absoluteString else { return nil } + lock.lock() + defer { lock.unlock() } + if case let .icon(icon) = resolved[key] { return icon } + return nil + } + + /// The icon for this link's origin, fetched once per origin however many rows ask at + /// the same time. Nil when the site offers none that loads. + func icon(for url: URL) async -> Icon? { + guard let origin = Self.origin(of: url) else { return nil } + let key = origin.absoluteString + let (task, generation) = startOrJoin(key: key, origin: origin) + guard let task else { return cached(for: url) } + let icon = await task.value + finish(key: key, icon: icon, generation: generation) + return icon + } + + /// Forgets every origin. On sign-out and account deletion, so the next account on this + /// device does not inherit the hosts the last one pushed. + func clear() { + lock.lock() + defer { lock.unlock() } + generation += 1 + inFlight.values.forEach { $0.cancel() } + inFlight.removeAll() + resolved.removeAll() + } + + // MARK: - Bookkeeping + + /// The running lookup for `key`, started if there is none; nil once it has resolved. + private func startOrJoin(key: String, origin: URL) -> (Task?, Int) { + lock.lock() + defer { lock.unlock() } + if resolved[key] != nil { return (nil, generation) } + if let running = inFlight[key] { return (running, generation) } + let task = Task { [session] in await Self.resolve(origin: origin, session: session) } + inFlight[key] = task + return (task, generation) + } + + private func finish(key: String, icon: Icon?, generation: Int) { + lock.lock() + defer { lock.unlock() } + guard generation == self.generation else { return } + inFlight[key] = nil + if resolved[key] == nil { + resolved[key] = icon.map(Entry.icon) ?? Entry.none + } + } + + // MARK: - Fetching + + private static func resolve(origin: URL, session: URLSession) async -> Icon? { + for candidate in await declaredIcons(origin: origin, session: session).prefix(maxCandidates) { + if Task.isCancelled { return nil } + if let icon = await loadIcon(candidate, session: session) { return icon } + } + guard !Task.isCancelled, let fallback = URL(string: "/favicon.ico", relativeTo: origin)?.absoluteURL else { return nil } + return await loadIcon(fallback, session: session) + } + + /// No Referer is ever set (URLSession adds none of its own), so the request says which + /// site it is for and nothing about where the user found the link. + private static func request(_ url: URL) -> URLRequest { + var request = URLRequest(url: url) + request.cachePolicy = .reloadIgnoringLocalCacheData + // A browser's agent string: some sites answer the default CFNetwork one with an + // error page, which would hide the icon they serve to everyone else. + #if os(iOS) + request.setValue("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", forHTTPHeaderField: "User-Agent") + #else + request.setValue("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", forHTTPHeaderField: "User-Agent") + #endif + return request + } + + /// Reads at most `limit` bytes of a 200 response, streamed, so that a site answering + /// with something huge costs no more than the cap. + private static func read(_ url: URL, limit: Int, session: URLSession, accept: (HTTPURLResponse) -> Bool) async -> (Data, HTTPURLResponse)? { + do { + let (bytes, response) = try await session.bytes(for: request(url)) + guard let http = response as? HTTPURLResponse, http.statusCode == 200, accept(http), + isWeb(http.url ?? url) + else { return nil } + var data = Data() + for try await byte in bytes { + data.append(byte) + if data.count >= limit { break } + } + return (data, http) + } catch { + // Nothing logged: the error would carry the site's address. + return nil + } + } + + private static func isWeb(_ url: URL) -> Bool { + let scheme = url.scheme?.lowercased() + return scheme == "http" || scheme == "https" + } + + /// The icons the root page declares, largest first, resolved against the address the + /// page was finally served from (a redirect to `www.` moves relative paths with it). + private static func declaredIcons(origin: URL, session: URLSession) async -> [URL] { + guard let (data, http) = await read(origin, limit: maxPageBytes, session: session, accept: { + $0.mimeType?.lowercased().contains("html") == true + }) else { return [] } + // Lossy on purpose, unlike the title scraper: only ASCII markup is wanted here, and a + // page cut mid-character at the cap still has its intact. + let html = String(decoding: data, as: UTF8.self) + let base = http.url ?? origin + return iconLinks(in: html) + .sorted { $0.size > $1.size } + .compactMap { link in + guard let url = URL(string: link.href, relativeTo: base)?.absoluteURL, isWeb(url) else { return nil } + return url + } + } + + private static func loadIcon(_ url: URL, session: URLSession) async -> Icon? { + guard let (data, _) = await read(url, limit: maxIconBytes, session: session, accept: { response in + // Some servers label icons loosely; refuse only what is plainly a page. + !(response.mimeType?.lowercased().contains("html") ?? false) + }), data.count < maxIconBytes, let image = PlatformImage(data: data), image.size.width > 0 else { return nil } + return Icon(image) + } + + // MARK: - Parsing + + struct IconLink: Equatable { + let href: String + /// The largest edge `sizes` declares; 0 when it declares none. + let size: Int + } + + private static let linkTag = try! NSRegularExpression(pattern: "]*>", options: .caseInsensitive) + private static let attribute = try! NSRegularExpression( + pattern: "([a-zA-Z_:-]+)\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s\"'>]+))", + options: [] + ) + + /// `` tags whose `rel` holds `icon` (which covers "shortcut icon") or + /// `apple-touch-icon`. A regex, as the title scraper's is, rather than an HTML parser: + /// `` is a void tag with only attributes, which a pattern reads well enough, and a + /// miss costs no more than the `/favicon.ico` fallback. SVG is skipped, since the + /// platform images cannot draw it; so is `mask-icon`, a one-colour Safari tab shape. + static func iconLinks(in html: String) -> [IconLink] { + let whole = NSRange(html.startIndex..., in: html) + return linkTag.matches(in: html, range: whole).compactMap { match in + guard let tagRange = Range(match.range, in: html) else { return nil } + let tag = String(html[tagRange]) + var attributes: [String: String] = [:] + for found in attribute.matches(in: tag, range: NSRange(tag.startIndex..., in: tag)) { + guard let nameRange = Range(found.range(at: 1), in: tag) else { continue } + let value = (2...4).lazy.compactMap { Range(found.range(at: $0), in: tag) }.first.map { String(tag[$0]) } ?? "" + attributes[tag[nameRange].lowercased()] = value + } + let rel = Set((attributes["rel"] ?? "").lowercased().split(whereSeparator: \.isWhitespace).map(String.init)) + let touch = rel.contains("apple-touch-icon") || rel.contains("apple-touch-icon-precomposed") + guard rel.contains("icon") || touch else { return nil } + guard let href = attributes["href"]?.trimmingCharacters(in: .whitespacesAndNewlines).decodingHTMLEntities(), + !href.isEmpty + else { return nil } + if attributes["type"]?.lowercased().contains("svg") == true || href.lowercased().hasSuffix(".svg") { return nil } + // An apple-touch-icon with no sizes is 180 px by convention; a plain icon with + // none is usually a 16 or 32 px favicon, so it loses to any declared size. + let declared = largestEdge(attributes["sizes"]) + return IconLink(href: href, size: declared ?? (touch ? 180 : 0)) + } + } + + /// "32x32 64x64" is 64; "any" (a scalable icon) counts as large. Nil when absent. + private static func largestEdge(_ sizes: String?) -> Int? { + guard let sizes = sizes?.lowercased(), !sizes.isEmpty else { return nil } + if sizes.contains("any") { return 1024 } + let edges = sizes.split(whereSeparator: \.isWhitespace).compactMap { size -> Int? in + let parts = size.split(separator: "x") + guard parts.count == 2, let w = Int(parts[0]), let h = Int(parts[1]) else { return nil } + return max(w, h) + } + return edges.max() + } +} + +/// A pushed link's site icon, or the placeholder until (and unless) one loads. +struct SiteIconView: View { + let site: URL + @ViewBuilder let placeholder: () -> Placeholder + + @State private var icon: FaviconResolver.Icon? + + init(site: URL, @ViewBuilder placeholder: @escaping () -> Placeholder) { + self.site = site + self.placeholder = placeholder + // Read synchronously, so a resolved icon is there on the first frame. + _icon = State(initialValue: FaviconResolver.shared.cached(for: site)) + } + + var body: some View { + Group { + if let icon { + platformImage(icon.image) + .resizable() + .aspectRatio(contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + placeholder() + } + } + // Keyed by origin, not the full URL: two links to one site share the lookup. + .task(id: FaviconResolver.origin(of: site)) { + if let cached = FaviconResolver.shared.cached(for: site) { + icon = cached + } else { + icon = await FaviconResolver.shared.icon(for: site) + } + } + } + + private func platformImage(_ image: PlatformImage) -> Image { + #if canImport(UIKit) + Image(uiImage: image) + #else + Image(nsImage: image) + #endif + } +} diff --git a/apps/ios/PushThat/ViewModels/AuthViewModel.swift b/apps/ios/PushThat/ViewModels/AuthViewModel.swift index 50b7d80..b2fe6a9 100644 --- a/apps/ios/PushThat/ViewModels/AuthViewModel.swift +++ b/apps/ios/PushThat/ViewModels/AuthViewModel.swift @@ -1,5 +1,6 @@ import Foundation import FirebaseAuth +import FirebaseAnalytics import FirebaseFirestore import FirebaseMessaging @@ -57,6 +58,7 @@ class AuthViewModel: ObservableObject { // Signed out while the app was not running (see `sessionChanged`). AccountKeyStore.clearAccountKey() PushFileOpener.clear() + FaviconResolver.shared.clear() } // Listen for auth state changes @@ -81,6 +83,7 @@ class AuthViewModel: ObservableObject { if auth.currentUser == nil { AccountKeyStore.clearAccountKey() PushFileOpener.clear() + FaviconResolver.shared.clear() } isAuthenticated = false keyGate = .ready @@ -464,13 +467,111 @@ class AuthViewModel: ObservableObject { } } + // MARK: - Delete account + + /// The `deleteAccount` callable (functions/), called over plain HTTPS the way `getPlan` + /// is (`PlanService`), so no FirebaseFunctions SDK is needed. + private static let deleteAccountURL = URL(string: "https://us-central1-push-that.cloudfunctions.net/deleteAccount")! + + static let deleteAccountFailed = "Could not delete the account. Try again." + + /// Deletes the account on the server, then everything this device keeps for it, and + /// signs out. Throws with a sentence to show, and leaves the session as it was, when the + /// password is wrong or the server refuses. + /// + /// The server deletes the Firebase user itself, so `user.delete()` is never called: by + /// the time the callable answers there is no user left to delete. + func deleteAccount(password: String) async throws { + guard let user = auth.currentUser, let email = user.email else { throw FileUploadError.notAuthenticated } + guard !password.isEmpty else { throw Problem("Enter your password.") } + + try await reauthenticate(user: user, email: email, password: password) + // The server refuses a sign-in older than five minutes. Re-authenticating does not + // by itself replace the ID token the SDK holds, so one is minted now that carries + // this sign-in's time. + let token = try await user.getIDTokenResult(forcingRefresh: true).token + try await Self.callDeleteAccount(idToken: token) + + let uid = user.uid + // Sign-out does not empty the retry queue, since a queued push waits for its own + // account to sign in again. This account will not, and its rows would wait forever. + PendingPush.deleteAll(for: uid) + // A fresh analytics id for this install, so nothing collected from here on joins + // what was collected while the deleted account was signed in. + Analytics.resetAnalyticsData() + signOut() + } + + /// Re-authenticates as "Change password" does, with the Firebase password derived from + /// the password (the same `derive` every sign-in uses), and then, only on a wrong + /// password, with the raw password for an account that has not moved yet, as + /// `signInWithKeys` does. Change password never meets such an account (it needs the + /// key, which the move writes first), but deleting one should not be refused. + private func reauthenticate(user: User, email: String, password: String) async throws { + let keys = try await Self.derive(email: email, password: password) + do { + _ = try await user.reauthenticate(with: EmailAuthProvider.credential(withEmail: email, password: keys.firebasePassword)) + } catch let first where Self.isWrongPassword(first) { + do { + _ = try await user.reauthenticate(with: EmailAuthProvider.credential(withEmail: email, password: password)) + } catch let second where Self.isWrongPassword(second) { + throw Problem("That is not your password.") + } catch { + // Any other failure says nothing about the raw password: the first is the + // one that describes what went wrong. + throw first + } + } + } + + /// POSTs the callable's empty request. Success is a 200 with `{"result":{"deleted":true}}`; + /// a failure carries `{"error":{"status","message"}}`, whose message the server writes + /// for people, so it is shown as it is. + private static func callDeleteAccount(idToken: String) async throws { + var request = URLRequest(url: deleteAccountURL) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(idToken)", forHTTPHeaderField: "Authorization") + request.httpBody = Data("{\"data\":{}}".utf8) + // Longer than the default: the server deletes every push and file before it answers. + request.timeoutInterval = 120 + + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch { + throw Problem(deleteAccountFailed) + } + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + + struct Success: Decodable { + struct Result: Decodable { let deleted: Bool } + let result: Result + } + struct Failure: Decodable { + struct Body: Decodable { let message: String? } + let error: Body + } + if status == 200, (try? JSONDecoder().decode(Success.self, from: data))?.result.deleted == true { + return + } + if let message = (try? JSONDecoder().decode(Failure.self, from: data))?.error.message, + !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + throw Problem(message) + } + throw Problem(deleteAccountFailed) + } + // MARK: - Sign out /// Clears the account key and the last decrypted file first, so that a sign-out that /// fails partway never leaves either behind for whoever signs in next. The device id stays: it is the install's. + /// The in-memory favicon cache goes too, since it is a list of the hosts this account pushed. func signOut() { AccountKeyStore.clearAccountKey() PushFileOpener.clear() + FaviconResolver.shared.clear() pendingRecovery = nil do { try auth.signOut() diff --git a/apps/ios/PushThat/Views/AccountSettings.swift b/apps/ios/PushThat/Views/AccountSettings.swift index 2844ffa..139a4ca 100644 --- a/apps/ios/PushThat/Views/AccountSettings.swift +++ b/apps/ios/PushThat/Views/AccountSettings.swift @@ -1,14 +1,15 @@ import SwiftUI -/// "Change password" and "Show a new recovery key", from the main screen's menu beside -/// Plan (docs/e2ee.md, "The recovery key"; the web app has no settings page, so these are -/// on the apps only). This file is the same in `apps/ios` and `apps/macos`. +/// "Change password", "Show a new recovery key" and "Delete account", from the main +/// screen's menu beside Plan (docs/e2ee.md, "The recovery key"). This file is the same in +/// `apps/ios` and `apps/macos`. extension View { - func accountSettings(authViewModel: AuthViewModel, showChangePassword: Binding, confirmNewRecoveryKey: Binding) -> some View { + func accountSettings(authViewModel: AuthViewModel, showChangePassword: Binding, confirmNewRecoveryKey: Binding, showDeleteAccount: Binding) -> some View { modifier(AccountSettingsModifier( authViewModel: authViewModel, showChangePassword: showChangePassword, - confirmNewRecoveryKey: confirmNewRecoveryKey + confirmNewRecoveryKey: confirmNewRecoveryKey, + showDeleteAccount: showDeleteAccount )) } } @@ -23,6 +24,7 @@ private struct AccountSettingsModifier: ViewModifier { @ObservedObject var authViewModel: AuthViewModel @Binding var showChangePassword: Bool @Binding var confirmNewRecoveryKey: Bool + @Binding var showDeleteAccount: Bool @State private var shownRecoveryKey: ShownRecoveryKey? @State private var problem: String? /// While a new recovery key is being made. Each run replaces every recovery wrap, so a @@ -48,6 +50,13 @@ private struct AccountSettingsModifier: ViewModifier { .frame(minWidth: 380, minHeight: 380) #endif } + .sheet(isPresented: $showDeleteAccount) { + DeleteAccountView() + .environmentObject(authViewModel) + #if os(macOS) + .frame(minWidth: 420, minHeight: 460) + #endif + } .sheet(item: $shownRecoveryKey) { shown in RecoveryKeyView(recoveryKey: shown.key) { authViewModel.recoveryKeySaved() @@ -168,6 +177,85 @@ struct ChangePasswordView: View { } } +/// What deleting the account takes with it, then the password. A sheet rather than an +/// alert: the consequences need more than an alert's line, the password needs a field, and +/// a wrong password or a refusal from the server has to be shown where it can be retried. +/// +/// On success the session ends (`AuthViewModel.deleteAccount`), which replaces the main +/// screen, and this sheet with it, by the sign-in screen. +struct DeleteAccountView: View { + @EnvironmentObject var authViewModel: AuthViewModel + @Environment(\.dismiss) private var dismiss + @State private var password = "" + @State private var isWorking = false + @State private var problem: String? + + var body: some View { + NavigationStack { + Form { + Section { + Text("This permanently deletes your PushThat account and its email address, every push and file, and your encryption keys and recovery key. Every device is signed out.") + Text("Your pushes are encrypted, so once they are deleted nobody can recover them, including us.") + Text("A Pro subscription is cancelled.") + } + Section { + SecureField("Password", text: $password) + .textContentType(.password) + .disabled(isWorking) + } footer: { + Text("Enter your password to confirm.") + } + if let problem { + Section { + Text(problem) + .foregroundColor(.red) + } + } + Section { + Button(role: .destructive) { delete() } label: { + HStack { + Text(isWorking ? "Deleting account…" : "Delete account") + if isWorking { + Spacer() + ProgressView() + } + } + } + .disabled(isWorking || password.isEmpty) + } + } + .navigationTitle("Delete account") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + .disabled(isWorking) + } + } + // Not while it runs: the deletion carries on on the server either way, and a + // closed sheet would hide whether it finished. + .interactiveDismissDisabled(isWorking) + } + } + + private func delete() { + // A second tap while the first runs would re-authenticate and call the server again. + guard !isWorking else { return } + isWorking = true + problem = nil + Task { + do { + try await authViewModel.deleteAccount(password: password) + } catch { + problem = AuthViewModel.message(for: error) + } + isWorking = false + } + } +} + private extension View { /// Lets iOS offer a strong password and save it. macOS 13 has no such content type. @ViewBuilder diff --git a/apps/ios/PushThat/Views/MainView.swift b/apps/ios/PushThat/Views/MainView.swift index 17ba2ca..7ca6182 100644 --- a/apps/ios/PushThat/Views/MainView.swift +++ b/apps/ios/PushThat/Views/MainView.swift @@ -10,6 +10,7 @@ struct MainView: View { @State private var showPlan = false @State private var showChangePassword = false @State private var confirmNewRecoveryKey = false + @State private var showDeleteAccount = false var body: some View { NavigationStack { @@ -48,7 +49,8 @@ struct MainView: View { .accountSettings( authViewModel: authViewModel, showChangePassword: $showChangePassword, - confirmNewRecoveryKey: $confirmNewRecoveryKey + confirmNewRecoveryKey: $confirmNewRecoveryKey, + showDeleteAccount: $showDeleteAccount ) .alert("Error", isPresented: $viewModel.showError) { Button("OK") {} @@ -171,6 +173,10 @@ struct MainView: View { Label("Show a new recovery key", systemImage: "lock.rotation") } + Button(role: .destructive, action: { showDeleteAccount = true }) { + Label("Delete account", systemImage: "person.crop.circle.badge.xmark") + } + Button(action: { authViewModel.signOut() }) { Label("Logout", systemImage: "rectangle.portrait.and.arrow.right") } diff --git a/apps/ios/PushThat/Views/PushRowView.swift b/apps/ios/PushThat/Views/PushRowView.swift index 52e4872..8494360 100644 --- a/apps/ios/PushThat/Views/PushRowView.swift +++ b/apps/ios/PushThat/Views/PushRowView.swift @@ -16,7 +16,7 @@ struct PushRowView: View { // Swivel checkbox / favicon SwivelCheckView( isChecked: isSelected, - imageURL: faviconURL, + icon: iconSource, fallbackIcon: item.type == .file ? "doc.fill" : "link.circle.fill" ) .frame(width: 40, height: 40) @@ -126,18 +126,21 @@ struct PushRowView: View { return date.relativeFormatted() } - private var faviconURL: URL? { + private var iconSource: SwivelCheckView.IconSource? { // A locked push shows the placeholder: there is no URL or name to show one for. if item.isLocked { return nil } - // Check for file extension icon first - if let ext = item.ext, FileTypeIcons.supported.contains(ext.lowercased()) { - return URL(string: "https://pushthat.link/files/\(ext.lowercased()).png") + // A file type we draw an icon for: ours, from pushthat.link. + if let ext = item.ext, FileTypeIcons.supported.contains(ext.lowercased()), + let url = URL(string: "https://pushthat.link/files/\(ext.lowercased()).png") { + return .firstParty(url) } - // Fall back to Google favicon service - guard let urlHost = URL(string: item.url)?.host else { return nil } - return URL(string: "https://www.google.com/s2/favicons?domain=\(urlHost)&sz=256") + // Otherwise the linked site's own icon, fetched by this device from that site. + // Never a favicon service: Google's, used before, learned the domain of every + // link pushed (see `FaviconResolver`). + guard let url = URL(string: item.url), FaviconResolver.origin(of: url) != nil else { return nil } + return .site(url) } } diff --git a/apps/ios/PushThat/Views/SwivelCheckView.swift b/apps/ios/PushThat/Views/SwivelCheckView.swift index 18740d8..96d209c 100644 --- a/apps/ios/PushThat/Views/SwivelCheckView.swift +++ b/apps/ios/PushThat/Views/SwivelCheckView.swift @@ -3,8 +3,16 @@ import SwiftUI /// Custom 3D flip checkbox that shows a favicon on the front and a checkmark on the back /// Matches the Android SwivelCheckView behavior struct SwivelCheckView: View { + /// What the front shows. + enum IconSource: Equatable { + /// An image of ours, loaded as it is (the file-type icons on pushthat.link). + case firstParty(URL) + /// A pushed link: its site's own icon, found on the device (`FaviconResolver`). + case site(URL) + } + let isChecked: Bool - let imageURL: URL? + let icon: IconSource? var fallbackIcon: String = "link.circle.fill" @State private var rotation: Double = 0 @@ -40,7 +48,10 @@ struct SwivelCheckView: View { private var frontSide: some View { Group { - if let url = imageURL { + switch icon { + case .site(let site): + SiteIconView(site: site) { fallbackIconView } + case .firstParty(let url): AsyncImage(url: url) { phase in switch phase { case .empty: @@ -57,7 +68,7 @@ struct SwivelCheckView: View { fallbackIconView } } - } else { + case nil: fallbackIconView } } @@ -86,21 +97,21 @@ struct SwivelCheckView: View { // Unchecked state SwivelCheckView( isChecked: false, - imageURL: URL(string: "https://www.google.com/s2/favicons?domain=github.com&sz=256") + icon: .site(URL(string: "https://example.com")!) ) .frame(width: 40, height: 40) // Checked state SwivelCheckView( isChecked: true, - imageURL: URL(string: "https://www.google.com/s2/favicons?domain=github.com&sz=256") + icon: .site(URL(string: "https://example.com")!) ) .frame(width: 40, height: 40) // With file icon SwivelCheckView( isChecked: false, - imageURL: URL(string: "https://pushthat.link/files/pdf.png"), + icon: .firstParty(URL(string: "https://pushthat.link/files/pdf.png")!), fallbackIcon: "doc.fill" ) .frame(width: 40, height: 40) @@ -118,7 +129,7 @@ struct SwivelCheckViewDemo: View { VStack { SwivelCheckView( isChecked: isChecked, - imageURL: URL(string: "https://www.google.com/s2/favicons?domain=apple.com&sz=256") + icon: .site(URL(string: "https://example.org")!) ) .frame(width: 64, height: 64) .onTapGesture { diff --git a/apps/macos/PushThat.xcodeproj/project.pbxproj b/apps/macos/PushThat.xcodeproj/project.pbxproj index 720a84a..5e426a2 100644 --- a/apps/macos/PushThat.xcodeproj/project.pbxproj +++ b/apps/macos/PushThat.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ A1000004226B52E900000003 /* PushListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000004226B52E900000004 /* PushListViewModel.swift */; }; A1000005226B52E900000001 /* SearchTokenGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000005226B52E900000002 /* SearchTokenGenerator.swift */; }; A1000005226B52E900000003 /* TitleScraperService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000005226B52E900000004 /* TitleScraperService.swift */; }; + A1000030226B52E900000001 /* FaviconResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000030226B52E900000002 /* FaviconResolver.swift */; }; A1000005226B52E900000005 /* FileUploadUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000005226B52E900000007 /* FileUploadUtils.swift */; }; A1000007226B52E900000001 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000007226B52E900000002 /* Colors.swift */; }; A1000007226B52E900000003 /* FileTypeIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000007226B52E900000004 /* FileTypeIcons.swift */; }; @@ -107,6 +108,7 @@ A1000004226B52E900000004 /* PushListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushListViewModel.swift; sourceTree = ""; }; A1000005226B52E900000002 /* SearchTokenGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTokenGenerator.swift; sourceTree = ""; }; A1000005226B52E900000004 /* TitleScraperService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitleScraperService.swift; sourceTree = ""; }; + A1000030226B52E900000002 /* FaviconResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaviconResolver.swift; sourceTree = ""; }; A1000005226B52E900000007 /* FileUploadUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUploadUtils.swift; sourceTree = ""; }; A1000007226B52E900000002 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = ""; }; A1000007226B52E900000004 /* FileTypeIcons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTypeIcons.swift; sourceTree = ""; }; @@ -268,6 +270,7 @@ A1000005226B52E900000007 /* FileUploadUtils.swift */, A1000005226B52E900000002 /* SearchTokenGenerator.swift */, A1000005226B52E900000004 /* TitleScraperService.swift */, + A1000030226B52E900000002 /* FaviconResolver.swift */, A1000020226B52E900000002 /* E2eeCrypto.swift */, A1000020226B52E900000005 /* AccountKeyStore.swift */, A1000020226B52E900000007 /* AccountKeyService.swift */, @@ -470,6 +473,7 @@ A1000005226B52E900000001 /* SearchTokenGenerator.swift in Sources */, A1000005226B52E900000005 /* FileUploadUtils.swift in Sources */, A1000005226B52E900000003 /* TitleScraperService.swift in Sources */, + A1000030226B52E900000001 /* FaviconResolver.swift in Sources */, A1000007226B52E900000001 /* Colors.swift in Sources */, A1000007226B52E900000003 /* FileTypeIcons.swift in Sources */, A1000007226B52E900000005 /* RelativeTimeFormatter.swift in Sources */, diff --git a/apps/macos/PushThat/Models/PendingPush.swift b/apps/macos/PushThat/Models/PendingPush.swift index d9d6df4..080e793 100644 --- a/apps/macos/PushThat/Models/PendingPush.swift +++ b/apps/macos/PushThat/Models/PendingPush.swift @@ -59,6 +59,24 @@ final class PendingPush { prepared.flatMap { try? JSONDecoder().decode(PreparedPush.self, from: $0) } } + /// On account deletion: the account's queued pushes, which would otherwise wait for an + /// account that no longer exists. Opened the way `RetryManager` opens the store (the + /// default store `PushThatApp` also uses), and filtered here rather than in a + /// `#Predicate`, since `userId` is optional and the queue is a handful of rows at most. + /// A failure is logged and not thrown: the account is gone by the time this runs, and + /// the rows cannot be written anywhere without it. + static func deleteAll(for uid: String) { + do { + let context = ModelContext(try ModelContainer(for: PendingPush.self)) + for pending in try context.fetch(FetchDescriptor()) where pending.userId == uid { + context.delete(pending) + } + try context.save() + } catch { + print("Queued pushes were not removed after the account was deleted: \(Swift.type(of: error))") + } + } + /// A push queued before the encryption update, sealed under a new id with `keys`. /// /// A file push of that time names its plaintext upload by its title diff --git a/apps/macos/PushThat/Services/FaviconResolver.swift b/apps/macos/PushThat/Services/FaviconResolver.swift new file mode 100644 index 0000000..308fca4 --- /dev/null +++ b/apps/macos/PushThat/Services/FaviconResolver.swift @@ -0,0 +1,317 @@ +import Foundation +import SwiftUI +#if canImport(UIKit) +import UIKit +typealias PlatformImage = UIImage +#else +import AppKit +typealias PlatformImage = NSImage +#endif + +/// The icon for a pushed link, fetched by this device from the linked site itself. +/// +/// The list used to load `google.com/s2/favicons?domain=` for every link, which told +/// Google the domain of everything a user pushed. Pushes are end-to-end encrypted, so that +/// lookup undid part of the point. Now the icon comes from the site the user is about to +/// open anyway: the origin's root page is read for a `` (or +/// `apple-touch-icon`), and `/favicon.ico` stands in when it names none. Nothing goes +/// through our server or any third party. +/// +/// Results are kept per origin, in memory only, for as long as the process runs, so a row +/// that scrolls back or re-renders shows its icon at once and never fetches it again. Not +/// on disk: a plaintext list of the hosts someone has pushed would sit beside an encrypted +/// push list. `clear()` drops it on sign-out and account deletion. +/// +/// This file is the same in `apps/ios` and `apps/macos`. +final class FaviconResolver: @unchecked Sendable { + static let shared = FaviconResolver() + + /// A decoded icon. A class so it can cross from the fetching task to the view: `NSImage` + /// is not `Sendable`, and the image is never changed once made. + final class Icon: @unchecked Sendable { + let image: PlatformImage + init(_ image: PlatformImage) { self.image = image } + } + + private enum Entry { + case icon(Icon) + /// Looked up and nothing loaded: the placeholder, without asking again. + case none + } + + private let lock = NSLock() + private var resolved: [String: Entry] = [:] + private var inFlight: [String: Task] = [:] + /// Moved on by `clear()`, so a lookup that finishes after a sign-out does not put the + /// previous account's host back into the cache. + private var generation = 0 + + /// No cookies, no stored credentials, no disk cache: the site learns that this device + /// wants its icon and nothing that ties the request to a session there. Short timeouts, + /// since an icon is decoration and a slow site should fall to the placeholder quickly. + private let session: URLSession = { + let config = URLSessionConfiguration.ephemeral + config.httpCookieAcceptPolicy = .never + config.httpShouldSetCookies = false + config.urlCredentialStorage = nil + config.timeoutIntervalForRequest = 5 + config.timeoutIntervalForResource = 10 + return URLSession(configuration: config) + }() + + /// How much of the root page is read for its `` tags, which sit in the ``. + private static let maxPageBytes = 256 * 1024 + /// An icon larger than this is not an icon worth holding in memory for a 40 pt square. + private static let maxIconBytes = 1024 * 1024 + /// Declared icons tried before `/favicon.ico`, largest first. + private static let maxCandidates = 3 + + private init() {} + + /// `scheme://host[:port]/` for an http or https link; nil for anything else, which + /// keeps the placeholder. + static func origin(of url: URL) -> URL? { + guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https", + let host = url.host, !host.isEmpty + else { return nil } + var parts = URLComponents() + parts.scheme = scheme + parts.host = host.lowercased() + parts.port = url.port + parts.path = "/" + return parts.url + } + + /// The icon already resolved for this link's origin, without waiting: what a row shows + /// on its first frame, so that one re-created by the list does not flash the placeholder. + func cached(for url: URL) -> Icon? { + guard let key = Self.origin(of: url)?.absoluteString else { return nil } + lock.lock() + defer { lock.unlock() } + if case let .icon(icon) = resolved[key] { return icon } + return nil + } + + /// The icon for this link's origin, fetched once per origin however many rows ask at + /// the same time. Nil when the site offers none that loads. + func icon(for url: URL) async -> Icon? { + guard let origin = Self.origin(of: url) else { return nil } + let key = origin.absoluteString + let (task, generation) = startOrJoin(key: key, origin: origin) + guard let task else { return cached(for: url) } + let icon = await task.value + finish(key: key, icon: icon, generation: generation) + return icon + } + + /// Forgets every origin. On sign-out and account deletion, so the next account on this + /// device does not inherit the hosts the last one pushed. + func clear() { + lock.lock() + defer { lock.unlock() } + generation += 1 + inFlight.values.forEach { $0.cancel() } + inFlight.removeAll() + resolved.removeAll() + } + + // MARK: - Bookkeeping + + /// The running lookup for `key`, started if there is none; nil once it has resolved. + private func startOrJoin(key: String, origin: URL) -> (Task?, Int) { + lock.lock() + defer { lock.unlock() } + if resolved[key] != nil { return (nil, generation) } + if let running = inFlight[key] { return (running, generation) } + let task = Task { [session] in await Self.resolve(origin: origin, session: session) } + inFlight[key] = task + return (task, generation) + } + + private func finish(key: String, icon: Icon?, generation: Int) { + lock.lock() + defer { lock.unlock() } + guard generation == self.generation else { return } + inFlight[key] = nil + if resolved[key] == nil { + resolved[key] = icon.map(Entry.icon) ?? Entry.none + } + } + + // MARK: - Fetching + + private static func resolve(origin: URL, session: URLSession) async -> Icon? { + for candidate in await declaredIcons(origin: origin, session: session).prefix(maxCandidates) { + if Task.isCancelled { return nil } + if let icon = await loadIcon(candidate, session: session) { return icon } + } + guard !Task.isCancelled, let fallback = URL(string: "/favicon.ico", relativeTo: origin)?.absoluteURL else { return nil } + return await loadIcon(fallback, session: session) + } + + /// No Referer is ever set (URLSession adds none of its own), so the request says which + /// site it is for and nothing about where the user found the link. + private static func request(_ url: URL) -> URLRequest { + var request = URLRequest(url: url) + request.cachePolicy = .reloadIgnoringLocalCacheData + // A browser's agent string: some sites answer the default CFNetwork one with an + // error page, which would hide the icon they serve to everyone else. + #if os(iOS) + request.setValue("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", forHTTPHeaderField: "User-Agent") + #else + request.setValue("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", forHTTPHeaderField: "User-Agent") + #endif + return request + } + + /// Reads at most `limit` bytes of a 200 response, streamed, so that a site answering + /// with something huge costs no more than the cap. + private static func read(_ url: URL, limit: Int, session: URLSession, accept: (HTTPURLResponse) -> Bool) async -> (Data, HTTPURLResponse)? { + do { + let (bytes, response) = try await session.bytes(for: request(url)) + guard let http = response as? HTTPURLResponse, http.statusCode == 200, accept(http), + isWeb(http.url ?? url) + else { return nil } + var data = Data() + for try await byte in bytes { + data.append(byte) + if data.count >= limit { break } + } + return (data, http) + } catch { + // Nothing logged: the error would carry the site's address. + return nil + } + } + + private static func isWeb(_ url: URL) -> Bool { + let scheme = url.scheme?.lowercased() + return scheme == "http" || scheme == "https" + } + + /// The icons the root page declares, largest first, resolved against the address the + /// page was finally served from (a redirect to `www.` moves relative paths with it). + private static func declaredIcons(origin: URL, session: URLSession) async -> [URL] { + guard let (data, http) = await read(origin, limit: maxPageBytes, session: session, accept: { + $0.mimeType?.lowercased().contains("html") == true + }) else { return [] } + // Lossy on purpose, unlike the title scraper: only ASCII markup is wanted here, and a + // page cut mid-character at the cap still has its intact. + let html = String(decoding: data, as: UTF8.self) + let base = http.url ?? origin + return iconLinks(in: html) + .sorted { $0.size > $1.size } + .compactMap { link in + guard let url = URL(string: link.href, relativeTo: base)?.absoluteURL, isWeb(url) else { return nil } + return url + } + } + + private static func loadIcon(_ url: URL, session: URLSession) async -> Icon? { + guard let (data, _) = await read(url, limit: maxIconBytes, session: session, accept: { response in + // Some servers label icons loosely; refuse only what is plainly a page. + !(response.mimeType?.lowercased().contains("html") ?? false) + }), data.count < maxIconBytes, let image = PlatformImage(data: data), image.size.width > 0 else { return nil } + return Icon(image) + } + + // MARK: - Parsing + + struct IconLink: Equatable { + let href: String + /// The largest edge `sizes` declares; 0 when it declares none. + let size: Int + } + + private static let linkTag = try! NSRegularExpression(pattern: "]*>", options: .caseInsensitive) + private static let attribute = try! NSRegularExpression( + pattern: "([a-zA-Z_:-]+)\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s\"'>]+))", + options: [] + ) + + /// `` tags whose `rel` holds `icon` (which covers "shortcut icon") or + /// `apple-touch-icon`. A regex, as the title scraper's is, rather than an HTML parser: + /// `` is a void tag with only attributes, which a pattern reads well enough, and a + /// miss costs no more than the `/favicon.ico` fallback. SVG is skipped, since the + /// platform images cannot draw it; so is `mask-icon`, a one-colour Safari tab shape. + static func iconLinks(in html: String) -> [IconLink] { + let whole = NSRange(html.startIndex..., in: html) + return linkTag.matches(in: html, range: whole).compactMap { match in + guard let tagRange = Range(match.range, in: html) else { return nil } + let tag = String(html[tagRange]) + var attributes: [String: String] = [:] + for found in attribute.matches(in: tag, range: NSRange(tag.startIndex..., in: tag)) { + guard let nameRange = Range(found.range(at: 1), in: tag) else { continue } + let value = (2...4).lazy.compactMap { Range(found.range(at: $0), in: tag) }.first.map { String(tag[$0]) } ?? "" + attributes[tag[nameRange].lowercased()] = value + } + let rel = Set((attributes["rel"] ?? "").lowercased().split(whereSeparator: \.isWhitespace).map(String.init)) + let touch = rel.contains("apple-touch-icon") || rel.contains("apple-touch-icon-precomposed") + guard rel.contains("icon") || touch else { return nil } + guard let href = attributes["href"]?.trimmingCharacters(in: .whitespacesAndNewlines).decodingHTMLEntities(), + !href.isEmpty + else { return nil } + if attributes["type"]?.lowercased().contains("svg") == true || href.lowercased().hasSuffix(".svg") { return nil } + // An apple-touch-icon with no sizes is 180 px by convention; a plain icon with + // none is usually a 16 or 32 px favicon, so it loses to any declared size. + let declared = largestEdge(attributes["sizes"]) + return IconLink(href: href, size: declared ?? (touch ? 180 : 0)) + } + } + + /// "32x32 64x64" is 64; "any" (a scalable icon) counts as large. Nil when absent. + private static func largestEdge(_ sizes: String?) -> Int? { + guard let sizes = sizes?.lowercased(), !sizes.isEmpty else { return nil } + if sizes.contains("any") { return 1024 } + let edges = sizes.split(whereSeparator: \.isWhitespace).compactMap { size -> Int? in + let parts = size.split(separator: "x") + guard parts.count == 2, let w = Int(parts[0]), let h = Int(parts[1]) else { return nil } + return max(w, h) + } + return edges.max() + } +} + +/// A pushed link's site icon, or the placeholder until (and unless) one loads. +struct SiteIconView: View { + let site: URL + @ViewBuilder let placeholder: () -> Placeholder + + @State private var icon: FaviconResolver.Icon? + + init(site: URL, @ViewBuilder placeholder: @escaping () -> Placeholder) { + self.site = site + self.placeholder = placeholder + // Read synchronously, so a resolved icon is there on the first frame. + _icon = State(initialValue: FaviconResolver.shared.cached(for: site)) + } + + var body: some View { + Group { + if let icon { + platformImage(icon.image) + .resizable() + .aspectRatio(contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + placeholder() + } + } + // Keyed by origin, not the full URL: two links to one site share the lookup. + .task(id: FaviconResolver.origin(of: site)) { + if let cached = FaviconResolver.shared.cached(for: site) { + icon = cached + } else { + icon = await FaviconResolver.shared.icon(for: site) + } + } + } + + private func platformImage(_ image: PlatformImage) -> Image { + #if canImport(UIKit) + Image(uiImage: image) + #else + Image(nsImage: image) + #endif + } +} diff --git a/apps/macos/PushThat/ViewModels/AuthViewModel.swift b/apps/macos/PushThat/ViewModels/AuthViewModel.swift index 9ecf3cc..525c104 100644 --- a/apps/macos/PushThat/ViewModels/AuthViewModel.swift +++ b/apps/macos/PushThat/ViewModels/AuthViewModel.swift @@ -56,6 +56,7 @@ class AuthViewModel: ObservableObject { // Signed out while the app was not running (see `sessionChanged`). AccountKeyStore.clearAccountKey() PushFileOpener.clear() + FaviconResolver.shared.clear() } // Listen for auth state changes @@ -80,6 +81,7 @@ class AuthViewModel: ObservableObject { if auth.currentUser == nil { AccountKeyStore.clearAccountKey() PushFileOpener.clear() + FaviconResolver.shared.clear() } isAuthenticated = false keyGate = .ready @@ -463,13 +465,110 @@ class AuthViewModel: ObservableObject { } } + // MARK: - Delete account + + /// The `deleteAccount` callable (functions/), called over plain HTTPS the way `getPlan` + /// is (`PlanService`), so no FirebaseFunctions SDK is needed. + private static let deleteAccountURL = URL(string: "https://us-central1-push-that.cloudfunctions.net/deleteAccount")! + + static let deleteAccountFailed = "Could not delete the account. Try again." + + /// Deletes the account on the server, then everything this device keeps for it, and + /// signs out. Throws with a sentence to show, and leaves the session as it was, when the + /// password is wrong or the server refuses. + /// + /// The server deletes the Firebase user itself, so `user.delete()` is never called: by + /// the time the callable answers there is no user left to delete. + func deleteAccount(password: String) async throws { + guard let user = auth.currentUser, let email = user.email else { throw FileUploadError.notAuthenticated } + guard !password.isEmpty else { throw Problem("Enter your password.") } + + try await reauthenticate(user: user, email: email, password: password) + // The server refuses a sign-in older than five minutes. Re-authenticating does not + // by itself replace the ID token the SDK holds, so one is minted now that carries + // this sign-in's time. + let token = try await user.getIDTokenResult(forcingRefresh: true).token + try await Self.callDeleteAccount(idToken: token) + + let uid = user.uid + // Sign-out does not empty the retry queue, since a queued push waits for its own + // account to sign in again. This account will not, and its rows would wait forever. + PendingPush.deleteAll(for: uid) + // No `Analytics.resetAnalyticsData()` here, unlike iOS: FirebaseAnalytics is not + // linked into the macOS app, so there is no analytics id to reset. + signOut() + } + + /// Re-authenticates as "Change password" does, with the Firebase password derived from + /// the password (the same `derive` every sign-in uses), and then, only on a wrong + /// password, with the raw password for an account that has not moved yet, as + /// `signInWithKeys` does. Change password never meets such an account (it needs the + /// key, which the move writes first), but deleting one should not be refused. + private func reauthenticate(user: User, email: String, password: String) async throws { + let keys = try await Self.derive(email: email, password: password) + do { + _ = try await user.reauthenticate(with: EmailAuthProvider.credential(withEmail: email, password: keys.firebasePassword)) + } catch let first where Self.isWrongPassword(first) { + do { + _ = try await user.reauthenticate(with: EmailAuthProvider.credential(withEmail: email, password: password)) + } catch let second where Self.isWrongPassword(second) { + throw Problem("That is not your password.") + } catch { + // Any other failure says nothing about the raw password: the first is the + // one that describes what went wrong. + throw first + } + } + } + + /// POSTs the callable's empty request. Success is a 200 with `{"result":{"deleted":true}}`; + /// a failure carries `{"error":{"status","message"}}`, whose message the server writes + /// for people, so it is shown as it is. + private static func callDeleteAccount(idToken: String) async throws { + var request = URLRequest(url: deleteAccountURL) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(idToken)", forHTTPHeaderField: "Authorization") + request.httpBody = Data("{\"data\":{}}".utf8) + // Longer than the default: the server deletes every push and file before it answers. + request.timeoutInterval = 120 + + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch { + throw Problem(deleteAccountFailed) + } + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + + struct Success: Decodable { + struct Result: Decodable { let deleted: Bool } + let result: Result + } + struct Failure: Decodable { + struct Body: Decodable { let message: String? } + let error: Body + } + if status == 200, (try? JSONDecoder().decode(Success.self, from: data))?.result.deleted == true { + return + } + if let message = (try? JSONDecoder().decode(Failure.self, from: data))?.error.message, + !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + throw Problem(message) + } + throw Problem(deleteAccountFailed) + } + // MARK: - Sign out /// Clears the account key and the last decrypted file first, so that a sign-out that /// fails partway never leaves either behind for whoever signs in next. The device id stays: it is the install's. + /// The in-memory favicon cache goes too, since it is a list of the hosts this account pushed. func signOut() { AccountKeyStore.clearAccountKey() PushFileOpener.clear() + FaviconResolver.shared.clear() pendingRecovery = nil do { try auth.signOut() diff --git a/apps/macos/PushThat/Views/AccountSettings.swift b/apps/macos/PushThat/Views/AccountSettings.swift index 2844ffa..139a4ca 100644 --- a/apps/macos/PushThat/Views/AccountSettings.swift +++ b/apps/macos/PushThat/Views/AccountSettings.swift @@ -1,14 +1,15 @@ import SwiftUI -/// "Change password" and "Show a new recovery key", from the main screen's menu beside -/// Plan (docs/e2ee.md, "The recovery key"; the web app has no settings page, so these are -/// on the apps only). This file is the same in `apps/ios` and `apps/macos`. +/// "Change password", "Show a new recovery key" and "Delete account", from the main +/// screen's menu beside Plan (docs/e2ee.md, "The recovery key"). This file is the same in +/// `apps/ios` and `apps/macos`. extension View { - func accountSettings(authViewModel: AuthViewModel, showChangePassword: Binding, confirmNewRecoveryKey: Binding) -> some View { + func accountSettings(authViewModel: AuthViewModel, showChangePassword: Binding, confirmNewRecoveryKey: Binding, showDeleteAccount: Binding) -> some View { modifier(AccountSettingsModifier( authViewModel: authViewModel, showChangePassword: showChangePassword, - confirmNewRecoveryKey: confirmNewRecoveryKey + confirmNewRecoveryKey: confirmNewRecoveryKey, + showDeleteAccount: showDeleteAccount )) } } @@ -23,6 +24,7 @@ private struct AccountSettingsModifier: ViewModifier { @ObservedObject var authViewModel: AuthViewModel @Binding var showChangePassword: Bool @Binding var confirmNewRecoveryKey: Bool + @Binding var showDeleteAccount: Bool @State private var shownRecoveryKey: ShownRecoveryKey? @State private var problem: String? /// While a new recovery key is being made. Each run replaces every recovery wrap, so a @@ -48,6 +50,13 @@ private struct AccountSettingsModifier: ViewModifier { .frame(minWidth: 380, minHeight: 380) #endif } + .sheet(isPresented: $showDeleteAccount) { + DeleteAccountView() + .environmentObject(authViewModel) + #if os(macOS) + .frame(minWidth: 420, minHeight: 460) + #endif + } .sheet(item: $shownRecoveryKey) { shown in RecoveryKeyView(recoveryKey: shown.key) { authViewModel.recoveryKeySaved() @@ -168,6 +177,85 @@ struct ChangePasswordView: View { } } +/// What deleting the account takes with it, then the password. A sheet rather than an +/// alert: the consequences need more than an alert's line, the password needs a field, and +/// a wrong password or a refusal from the server has to be shown where it can be retried. +/// +/// On success the session ends (`AuthViewModel.deleteAccount`), which replaces the main +/// screen, and this sheet with it, by the sign-in screen. +struct DeleteAccountView: View { + @EnvironmentObject var authViewModel: AuthViewModel + @Environment(\.dismiss) private var dismiss + @State private var password = "" + @State private var isWorking = false + @State private var problem: String? + + var body: some View { + NavigationStack { + Form { + Section { + Text("This permanently deletes your PushThat account and its email address, every push and file, and your encryption keys and recovery key. Every device is signed out.") + Text("Your pushes are encrypted, so once they are deleted nobody can recover them, including us.") + Text("A Pro subscription is cancelled.") + } + Section { + SecureField("Password", text: $password) + .textContentType(.password) + .disabled(isWorking) + } footer: { + Text("Enter your password to confirm.") + } + if let problem { + Section { + Text(problem) + .foregroundColor(.red) + } + } + Section { + Button(role: .destructive) { delete() } label: { + HStack { + Text(isWorking ? "Deleting account…" : "Delete account") + if isWorking { + Spacer() + ProgressView() + } + } + } + .disabled(isWorking || password.isEmpty) + } + } + .navigationTitle("Delete account") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + .disabled(isWorking) + } + } + // Not while it runs: the deletion carries on on the server either way, and a + // closed sheet would hide whether it finished. + .interactiveDismissDisabled(isWorking) + } + } + + private func delete() { + // A second tap while the first runs would re-authenticate and call the server again. + guard !isWorking else { return } + isWorking = true + problem = nil + Task { + do { + try await authViewModel.deleteAccount(password: password) + } catch { + problem = AuthViewModel.message(for: error) + } + isWorking = false + } + } +} + private extension View { /// Lets iOS offer a strong password and save it. macOS 13 has no such content type. @ViewBuilder diff --git a/apps/macos/PushThat/Views/MainView.swift b/apps/macos/PushThat/Views/MainView.swift index 94ca0c8..a4c82e3 100644 --- a/apps/macos/PushThat/Views/MainView.swift +++ b/apps/macos/PushThat/Views/MainView.swift @@ -10,6 +10,7 @@ struct MainView: View { @State private var showPlan = false @State private var showChangePassword = false @State private var confirmNewRecoveryKey = false + @State private var showDeleteAccount = false var body: some View { NavigationStack { @@ -44,7 +45,8 @@ struct MainView: View { .accountSettings( authViewModel: authViewModel, showChangePassword: $showChangePassword, - confirmNewRecoveryKey: $confirmNewRecoveryKey + confirmNewRecoveryKey: $confirmNewRecoveryKey, + showDeleteAccount: $showDeleteAccount ) .alert("Error", isPresented: $viewModel.showError) { Button("OK") {} @@ -209,6 +211,10 @@ struct MainView: View { Label("Show a new recovery key", systemImage: "lock.rotation") } + Button(role: .destructive, action: { showDeleteAccount = true }) { + Label("Delete account", systemImage: "person.crop.circle.badge.xmark") + } + Button(action: { authViewModel.signOut() }) { Label("Logout", systemImage: "rectangle.portrait.and.arrow.right") } diff --git a/apps/macos/PushThat/Views/PushRowView.swift b/apps/macos/PushThat/Views/PushRowView.swift index 4fce0e4..610c1c6 100644 --- a/apps/macos/PushThat/Views/PushRowView.swift +++ b/apps/macos/PushThat/Views/PushRowView.swift @@ -16,7 +16,7 @@ struct PushRowView: View { // Swivel checkbox / favicon SwivelCheckView( isChecked: isSelected, - imageURL: faviconURL, + icon: iconSource, fallbackIcon: item.type == .file ? "doc.fill" : "link.circle.fill" ) .frame(width: 40, height: 40) @@ -123,18 +123,21 @@ struct PushRowView: View { return date.relativeFormatted() } - private var faviconURL: URL? { + private var iconSource: SwivelCheckView.IconSource? { // A locked push shows the placeholder: there is no URL or name to show one for. if item.isLocked { return nil } - // Check for file extension icon first - if let ext = item.ext, FileTypeIcons.supported.contains(ext.lowercased()) { - return URL(string: "https://pushthat.link/files/\(ext.lowercased()).png") + // A file type we draw an icon for: ours, from pushthat.link. + if let ext = item.ext, FileTypeIcons.supported.contains(ext.lowercased()), + let url = URL(string: "https://pushthat.link/files/\(ext.lowercased()).png") { + return .firstParty(url) } - // Fall back to Google favicon service - guard let urlHost = URL(string: item.url)?.host else { return nil } - return URL(string: "https://www.google.com/s2/favicons?domain=\(urlHost)&sz=256") + // Otherwise the linked site's own icon, fetched by this device from that site. + // Never a favicon service: Google's, used before, learned the domain of every + // link pushed (see `FaviconResolver`). + guard let url = URL(string: item.url), FaviconResolver.origin(of: url) != nil else { return nil } + return .site(url) } } diff --git a/apps/macos/PushThat/Views/SwivelCheckView.swift b/apps/macos/PushThat/Views/SwivelCheckView.swift index 18740d8..96d209c 100644 --- a/apps/macos/PushThat/Views/SwivelCheckView.swift +++ b/apps/macos/PushThat/Views/SwivelCheckView.swift @@ -3,8 +3,16 @@ import SwiftUI /// Custom 3D flip checkbox that shows a favicon on the front and a checkmark on the back /// Matches the Android SwivelCheckView behavior struct SwivelCheckView: View { + /// What the front shows. + enum IconSource: Equatable { + /// An image of ours, loaded as it is (the file-type icons on pushthat.link). + case firstParty(URL) + /// A pushed link: its site's own icon, found on the device (`FaviconResolver`). + case site(URL) + } + let isChecked: Bool - let imageURL: URL? + let icon: IconSource? var fallbackIcon: String = "link.circle.fill" @State private var rotation: Double = 0 @@ -40,7 +48,10 @@ struct SwivelCheckView: View { private var frontSide: some View { Group { - if let url = imageURL { + switch icon { + case .site(let site): + SiteIconView(site: site) { fallbackIconView } + case .firstParty(let url): AsyncImage(url: url) { phase in switch phase { case .empty: @@ -57,7 +68,7 @@ struct SwivelCheckView: View { fallbackIconView } } - } else { + case nil: fallbackIconView } } @@ -86,21 +97,21 @@ struct SwivelCheckView: View { // Unchecked state SwivelCheckView( isChecked: false, - imageURL: URL(string: "https://www.google.com/s2/favicons?domain=github.com&sz=256") + icon: .site(URL(string: "https://example.com")!) ) .frame(width: 40, height: 40) // Checked state SwivelCheckView( isChecked: true, - imageURL: URL(string: "https://www.google.com/s2/favicons?domain=github.com&sz=256") + icon: .site(URL(string: "https://example.com")!) ) .frame(width: 40, height: 40) // With file icon SwivelCheckView( isChecked: false, - imageURL: URL(string: "https://pushthat.link/files/pdf.png"), + icon: .firstParty(URL(string: "https://pushthat.link/files/pdf.png")!), fallbackIcon: "doc.fill" ) .frame(width: 40, height: 40) @@ -118,7 +129,7 @@ struct SwivelCheckViewDemo: View { VStack { SwivelCheckView( isChecked: isChecked, - imageURL: URL(string: "https://www.google.com/s2/favicons?domain=apple.com&sz=256") + icon: .site(URL(string: "https://example.org")!) ) .frame(width: 64, height: 64) .onTapGesture { From 35e5061ca9ee8defe519eb04c4d214a140380e48 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 15:34:51 -0600 Subject: [PATCH 08/64] Give the deletion sweep nine minutes instead of the default one `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 --- functions/src/account-deletion.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/functions/src/account-deletion.ts b/functions/src/account-deletion.ts index 16937d4..e46cedc 100644 --- a/functions/src/account-deletion.ts +++ b/functions/src/account-deletion.ts @@ -276,9 +276,19 @@ export const deleteAccount = onCall({ cors: true, secrets, timeoutSeconds: 300 } return { deleted: true }; }); -export const finishAccountDeletions = onSchedule({ schedule: 'every 60 minutes', timeZone: 'Etc/UTC' }, async () => { - const finished = await finishDeletions(new Date()); - if (finished.length > 0) { - logger.info(`finishAccountDeletions: finished ${finished.length} deletion${finished.length === 1 ? '' : 's'}`); +/** + * Nine minutes, the most a scheduled function may have, not the default one: this is + * also what finishes a deletion the callable was cut off in the middle of, which is by + * definition an account too large for five minutes. On the default, each hourly run + * would spend its minute on that one marker and be killed, and every deletion queued + * behind it would wait an hour per run, past the two hours the public page promises. + */ +export const finishAccountDeletions = onSchedule( + { schedule: 'every 60 minutes', timeZone: 'Etc/UTC', timeoutSeconds: 540 }, + async () => { + const finished = await finishDeletions(new Date()); + if (finished.length > 0) { + logger.info(`finishAccountDeletions: finished ${finished.length} deletion${finished.length === 1 ? '' : 's'}`); + } } -}); +); From f6a282c036d974104f299f5a07aba89380ee690d Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 15:35:21 -0600 Subject: [PATCH 09/64] Refuse to delete an account with a Stripe customer when billing is off 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 --- functions/src/account-deletion.ts | 33 +++++++++++-- .../test/emulator/account-deletion.test.ts | 47 +++++++++++++++---- 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/functions/src/account-deletion.ts b/functions/src/account-deletion.ts index e46cedc..d46ff41 100644 --- a/functions/src/account-deletion.ts +++ b/functions/src/account-deletion.ts @@ -79,6 +79,8 @@ export interface DeletionDeps { auth?: AuthAdmin; /** Billing, when it is on. */ stripe?: StripeConfig; + /** Refuse, rather than skip Stripe, when billing is off and the account has a customer. */ + billingRequired?: boolean; now?: Date; } @@ -172,6 +174,17 @@ async function endBilling(stripe: StripeConfig, customerIds: string[], uid: stri } } +/** + * The account has a Stripe customer and there is no Stripe configuration to end its + * subscription with. Thrown before anything is deleted. + */ +export class BillingOffError extends Error { + constructor() { + super('billing is off, and the account has a Stripe customer'); + this.name = 'BillingOffError'; + } +} + const isUserNotFound = (err: unknown) => (err as { code?: string } | null)?.code === 'auth/user-not-found'; /** Runs `step`, taking "there is no such user" as done: a second call finds the user already gone. */ @@ -194,14 +207,19 @@ async function unlessGone(step: () => Promise): Promise { */ export async function deleteAccountOf( uid: string, - { db = getFirestore(), auth = getAuth(), stripe, now = new Date() }: DeletionDeps = {} + { db = getFirestore(), auth = getAuth(), stripe, billingRequired = false, now = new Date() }: DeletionDeps = {} ): Promise { const customerIds = await customersOf(db, uid); if (customerIds.length > 0) { if (stripe) { await endBilling(stripe, customerIds, uid); + } else if (billingRequired) { + // The purge deletes `billing.customerId` and the `stripeCustomers` row, the only + // things that say which Stripe customer was this account's. Going on without + // Stripe would leave a subscription renewing with nothing left that points at it. + throw new BillingOffError(); } else { - // Billing off is the emulator's case (stripe-functions.ts); a deployed instance has it on. + // The emulator's case (stripe-functions.ts), where no customer is a real one. logger.warn(`[account] user=${uid}: billing is off, so nothing was asked of Stripe`); } } @@ -264,9 +282,16 @@ export const deleteAccount = onCall({ cors: true, secrets, timeoutSeconds: 300 } throw new HttpsError('failed-precondition', 'Enter your password again to delete the account.'); } try { - await deleteAccountOf(uid, { stripe: stripeConfig(), now }); + // A deployed instance always has billing on (a deploy refuses a function whose + // secret does not exist), but a secret set to an empty value reads as off. Only the + // emulator may skip Stripe. + await deleteAccountOf(uid, { + stripe: stripeConfig(), + billingRequired: process.env.FUNCTIONS_EMULATOR !== 'true', + now, + }); } catch (err) { - if (!(err instanceof StripeError)) throw err; + if (!(err instanceof StripeError) && !(err instanceof BillingOffError)) throw err; logger.error(`[account] user=${uid}: ending the subscription was refused, nothing deleted: ${err.message}`); throw new HttpsError( 'unavailable', diff --git a/functions/test/emulator/account-deletion.test.ts b/functions/test/emulator/account-deletion.test.ts index 777e083..14b2edf 100644 --- a/functions/test/emulator/account-deletion.test.ts +++ b/functions/test/emulator/account-deletion.test.ts @@ -395,13 +395,44 @@ describe('deleteAccount', () => { expect(await tracesOf(UID)).toEqual(before); }); - test('with billing off it deletes the account and asks nothing of Stripe', async () => { - billingOff(); - await seedAccount(UID, CUSTOMER); - - expect(await call(deleteAccount, UID, {}, { auth_time: nowSeconds() })).toEqual({ deleted: true }); - - expect(stripe.requests).toHaveLength(0); - expect(await tracesOf(UID)).toEqual([`accountDeletions/${UID}`]); + describe('with billing off', () => { + const emulator = process.env.FUNCTIONS_EMULATOR; + afterEach(() => { + if (emulator === undefined) delete process.env.FUNCTIONS_EMULATOR; + else process.env.FUNCTIONS_EMULATOR = emulator; + }); + + test('on the emulator it deletes the account and asks nothing of Stripe', async () => { + billingOff(); + process.env.FUNCTIONS_EMULATOR = 'true'; + await seedAccount(UID, CUSTOMER); + + expect(await call(deleteAccount, UID, {}, { auth_time: nowSeconds() })).toEqual({ deleted: true }); + + expect(stripe.requests).toHaveLength(0); + expect(await tracesOf(UID)).toEqual([`accountDeletions/${UID}`]); + }); + + test('deployed, it refuses an account with a customer, and deletes nothing', async () => { + billingOff(); + delete process.env.FUNCTIONS_EMULATOR; + await seedAccount(UID, CUSTOMER); + const before = await tracesOf(UID); + + expect(await refusal(call(deleteAccount, UID, {}, { auth_time: nowSeconds() }))).toMatch( + /^unavailable: Your subscription could not be cancelled/ + ); + + expect(await tracesOf(UID)).toEqual(before); + }); + + test('deployed, an account that never subscribed is still deleted', async () => { + billingOff(); + delete process.env.FUNCTIONS_EMULATOR; + await getAuth().createUser({ uid: UID, email: `${UID}@example.com` }); + await db().collection('users').doc(UID).set({ email: `${UID}@example.com` }); + + expect(await call(deleteAccount, UID, {}, { auth_time: nowSeconds() })).toEqual({ deleted: true }); + }); }); }); From 6fa646e5d632331cc4338ce01d4f1a799c179236 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 15:35:40 -0600 Subject: [PATCH 10/64] Format the account deletion suite with Prettier 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 --- functions/test/emulator/account-deletion.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/functions/test/emulator/account-deletion.test.ts b/functions/test/emulator/account-deletion.test.ts index 14b2edf..46a29b8 100644 --- a/functions/test/emulator/account-deletion.test.ts +++ b/functions/test/emulator/account-deletion.test.ts @@ -252,7 +252,10 @@ describe('deleteAccountOf', () => { test('an account that never subscribed asks nothing of Stripe', async () => { await getAuth().createUser({ uid: UID, email: `${UID}@example.com` }); - await db().collection('users').doc(UID).set({ email: `${UID}@example.com`, fcmTokens: ['t'] }); + await db() + .collection('users') + .doc(UID) + .set({ email: `${UID}@example.com`, fcmTokens: ['t'] }); await deleteAccountOf(UID, { stripe: config(), now: NOW }); @@ -430,7 +433,10 @@ describe('deleteAccount', () => { billingOff(); delete process.env.FUNCTIONS_EMULATOR; await getAuth().createUser({ uid: UID, email: `${UID}@example.com` }); - await db().collection('users').doc(UID).set({ email: `${UID}@example.com` }); + await db() + .collection('users') + .doc(UID) + .set({ email: `${UID}@example.com` }); expect(await call(deleteAccount, UID, {}, { auth_time: nowSeconds() })).toEqual({ deleted: true }); }); From 1ccfb832f3673e3e948829811c33d5a9e4cef7d9 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 15:35:45 -0600 Subject: [PATCH 11/64] Let a retried deletion pass a customer Stripe has already deleted 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 --- functions/src/account-deletion.ts | 19 ++++++++++++++++--- .../test/emulator/account-deletion.test.ts | 16 ++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/functions/src/account-deletion.ts b/functions/src/account-deletion.ts index d46ff41..49d142a 100644 --- a/functions/src/account-deletion.ts +++ b/functions/src/account-deletion.ts @@ -146,6 +146,9 @@ async function customersOf(db: Firestore, uid: string): Promise { return [...new Set([...(typeof fromDoc === 'string' && fromDoc ? [fromDoc] : []), ...rows.docs.map((d) => d.id)])]; } +const isMissingAtStripe = (err: unknown) => + err instanceof StripeError && (err.status === 404 || err.code === 'resource_missing'); + /** * Ends what the account has at Stripe. The subscription is what must not survive: an * account that no longer exists would be charged again at its renewal, with no page left @@ -157,12 +160,22 @@ async function customersOf(db: Firestore, uid: string): Promise { * account, and refusing to delete someone's data because a record at the payment * processor would not go is the worse failure. It is logged with the customer's id, to * be removed by hand in the dashboard. + * + * A customer Stripe no longer has is one an earlier call already deleted, before it + * failed at a later step: the rows that name it are only removed by the purge, so a + * retry meets it again. Stripe may answer a list filtered by a deleted customer with + * "no such customer" rather than an empty list, and that must read as nothing left to + * end, not as a subscription that could not be cancelled. */ async function endBilling(stripe: StripeConfig, customerIds: string[], uid: string): Promise { for (const customerId of customerIds) { - await expireOpenCheckouts(stripe, customerId); - for (const sub of await subscriptionsOf(stripe, customerId)) { - if (!ENDED_SUBSCRIPTION.has(sub.status)) await ensureSubscriptionCanceled(stripe, sub.id); + try { + await expireOpenCheckouts(stripe, customerId); + for (const sub of await subscriptionsOf(stripe, customerId)) { + if (!ENDED_SUBSCRIPTION.has(sub.status)) await ensureSubscriptionCanceled(stripe, sub.id); + } + } catch (err) { + if (!isMissingAtStripe(err)) throw err; } try { await deleteCustomer(stripe, customerId); diff --git a/functions/test/emulator/account-deletion.test.ts b/functions/test/emulator/account-deletion.test.ts index 46a29b8..3c126ba 100644 --- a/functions/test/emulator/account-deletion.test.ts +++ b/functions/test/emulator/account-deletion.test.ts @@ -292,6 +292,22 @@ describe('deleteAccountOf', () => { expect(await tracesOf(UID)).toEqual([`accountDeletions/${UID}`]); }); + test('a retry after Stripe has already deleted the customer goes through', async () => { + await seedAccount(UID, CUSTOMER); + // What Stripe says of a customer it no longer has, for the lists and the delete alike. + const missing = () => ({ + status: 404, + body: { error: { type: 'invalid_request_error', code: 'resource_missing', message: 'No such customer' } }, + }); + stripe.route('GET /v1/checkout/sessions', missing); + stripe.route('GET /v1/subscriptions', missing); + stripe.route(`DELETE /v1/customers/${CUSTOMER}`, missing); + + await deleteAccountOf(UID, { stripe: config(), now: NOW }); + + expect(await tracesOf(UID)).toEqual([`accountDeletions/${UID}`]); + }); + test('every session is ended: the refresh tokens are revoked before the user is deleted', async () => { await seedAccount(UID, CUSTOMER); stripeHasCustomer(CUSTOMER, `sub_${UID}`); From 2b463a534673f7bbbdb55eb5956904c5102df3a7 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 15:36:59 -0600 Subject: [PATCH 12/64] Keep a deleted account from buying a subscription with its last token 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 --- functions/src/account-deletion.ts | 13 ++++++-- functions/src/stripe-functions.ts | 7 +++++ .../test/emulator/account-deletion.test.ts | 31 +++++++++++++++++++ .../test/emulator/stripe-callables.test.ts | 7 +++++ 4 files changed, 55 insertions(+), 3 deletions(-) diff --git a/functions/src/account-deletion.ts b/functions/src/account-deletion.ts index 49d142a..dd056fa 100644 --- a/functions/src/account-deletion.ts +++ b/functions/src/account-deletion.ts @@ -253,16 +253,23 @@ export async function deleteAccountOf( * The second purge (see the top of this file): for every deletion whose tokens have all * expired by `now`, empties the same places again and removes the marker. Returns the * accounts finished. One that fails keeps its marker and is tried again the next hour. + * + * Stripe is asked again too, before the purge removes the rows that say which customer + * to ask about. `createCheckoutSession` refuses an account with a marker, but a checkout + * opened in the second before the marker was written could still have been paid since, + * and that subscription would renew for an account that does not exist. */ export async function finishDeletions( now: Date, - { db = getFirestore(), auth = getAuth() }: Pick = {} + { db = getFirestore(), auth = getAuth(), stripe }: Pick = {} ): Promise { const due = await db.collection('accountDeletions').where('settleAt', '<=', Timestamp.fromDate(now)).get(); const finished: string[] = []; for (const marker of due.docs) { const uid = marker.id; try { + const customerIds = await customersOf(db, uid); + if (stripe && customerIds.length > 0) await endBilling(stripe, customerIds, uid); const report = await purgeAccountData(db, uid); // Already gone unless the first call stopped before it got there. await unlessGone(() => auth.deleteUser(uid)); @@ -322,9 +329,9 @@ export const deleteAccount = onCall({ cors: true, secrets, timeoutSeconds: 300 } * behind it would wait an hour per run, past the two hours the public page promises. */ export const finishAccountDeletions = onSchedule( - { schedule: 'every 60 minutes', timeZone: 'Etc/UTC', timeoutSeconds: 540 }, + { schedule: 'every 60 minutes', timeZone: 'Etc/UTC', timeoutSeconds: 540, secrets }, async () => { - const finished = await finishDeletions(new Date()); + const finished = await finishDeletions(new Date(), { stripe: stripeConfig() }); if (finished.length > 0) { logger.info(`finishAccountDeletions: finished ${finished.length} deletion${finished.length === 1 ? '' : 's'}`); } diff --git a/functions/src/stripe-functions.ts b/functions/src/stripe-functions.ts index bc5da41..3bf3027 100644 --- a/functions/src/stripe-functions.ts +++ b/functions/src/stripe-functions.ts @@ -223,6 +223,13 @@ export const createCheckoutSession = onCall({ cors: true, secrets }, async (requ const uid = uidOf(request); const db = getFirestore(); const now = new Date(); + // A deleted account's last ID token still gets here for up to an hour (docs/account-deletion.md), + // and a checkout made with it would be a customer and a subscription for an account + // that is gone. The marker is the deletion's own (account-deletion.ts); read here by + // its path because that file imports this one. + if ((await db.collection('accountDeletions').doc(uid).get()).exists) { + throw new HttpsError('failed-precondition', 'This account has been deleted.'); + } const user = await readUser(db, uid); const billing = user?.billing; // The comp first, so it gets its own sentence; then the one test the report's diff --git a/functions/test/emulator/account-deletion.test.ts b/functions/test/emulator/account-deletion.test.ts index 3c126ba..d5e3c5f 100644 --- a/functions/test/emulator/account-deletion.test.ts +++ b/functions/test/emulator/account-deletion.test.ts @@ -344,6 +344,37 @@ describe('finishDeletions', () => { expect(await tracesOf(UID)).toEqual([]); }); + test('ends a subscription bought with a token that outlived the deletion', async () => { + await getAuth().createUser({ uid: UID, email: `${UID}@example.com` }); + await db() + .collection('users') + .doc(UID) + .set({ email: `${UID}@example.com` }); + await deleteAccountOf(UID, { stripe: config(), now: NOW }); + // A checkout that slipped in and was paid: a customer row for the deleted account. + await db().collection('stripeCustomers').doc('cus_late').set({ userId: UID, createdAt: NOW }); + stripeHasCustomer('cus_late', 'sub_late'); + + expect(await finishDeletions(settled(NOW), { stripe: config() })).toEqual([UID]); + + expect(stripe.made('DELETE /v1/subscriptions/sub_late')).toHaveLength(1); + expect(stripe.made('DELETE /v1/customers/cus_late')).toHaveLength(1); + expect(await tracesOf(UID)).toEqual([]); + }); + + test('keeps the marker, to try again, when that subscription cannot be cancelled', async () => { + await getAuth().createUser({ uid: UID, email: `${UID}@example.com` }); + await deleteAccountOf(UID, { stripe: config(), now: NOW }); + await db().collection('stripeCustomers').doc('cus_late').set({ userId: UID, createdAt: NOW }); + stripeHasCustomer('cus_late', 'sub_late'); + stripe.route('DELETE /v1/subscriptions/sub_late', () => stripeRefusal(500, 'try again')); + stripe.route('GET /v1/subscriptions/sub_late', subscription('sub_late', { customer: 'cus_late', created: 1 })); + + expect(await finishDeletions(settled(NOW), { stripe: config() })).toEqual([]); + + expect(await tracesOf(UID)).toContain(`accountDeletions/${UID}`); + }); + test('leaves a deletion whose tokens may still be alive', async () => { await seedAccount(UID, CUSTOMER); stripeHasCustomer(CUSTOMER, `sub_${UID}`); diff --git a/functions/test/emulator/stripe-callables.test.ts b/functions/test/emulator/stripe-callables.test.ts index ca501be..7c272e2 100644 --- a/functions/test/emulator/stripe-callables.test.ts +++ b/functions/test/emulator/stripe-callables.test.ts @@ -191,6 +191,13 @@ describe('refreshSubscription', () => { }); describe('createCheckoutSession', () => { + test('an account that has been deleted cannot open one, though its token still works', async () => { + await db().collection('accountDeletions').doc(UID).set({ deletedAt: new Date(), settleAt: new Date() }); + + expect(await refusal(call(createCheckoutSession, UID))).toBe('failed-precondition: This account has been deleted.'); + expect(stripe.requests).toHaveLength(0); + }); + const checkout = (uid: string | null = UID) => call(createCheckoutSession, uid) as Promise<{ url: string }>; /** Stripe as a new customer finds it: no subscriptions, and a session made on asking. */ From dfbeb187c52cf882e7b1de73bed8d3c9e29a5109 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 15:37:01 -0600 Subject: [PATCH 13/64] Test that the sweep deletes an Auth user the first call left behind 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 --- functions/test/emulator/account-deletion.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/functions/test/emulator/account-deletion.test.ts b/functions/test/emulator/account-deletion.test.ts index d5e3c5f..9fd1219 100644 --- a/functions/test/emulator/account-deletion.test.ts +++ b/functions/test/emulator/account-deletion.test.ts @@ -375,6 +375,22 @@ describe('finishDeletions', () => { expect(await tracesOf(UID)).toContain(`accountDeletions/${UID}`); }); + test('deletes the Auth user when the first call stopped before it got there', async () => { + await seedAccount(UID, CUSTOMER); + stripeHasCustomer(CUSTOMER, `sub_${UID}`); + const stopsAtTheUser = { + revokeRefreshTokens: (uid: string) => getAuth().revokeRefreshTokens(uid), + deleteUser: () => Promise.reject(new Error('the Auth service is down')), + }; + await expect(deleteAccountOf(UID, { stripe: config(), now: NOW, auth: stopsAtTheUser })).rejects.toThrow(/is down/); + // Nobody asks again: the marker alone is what gets the rest done. + expect(await tracesOf(UID)).toEqual([`accountDeletions/${UID}`, `auth:${UID}`]); + + expect(await finishDeletions(settled(NOW), { stripe: config() })).toEqual([UID]); + + expect(await tracesOf(UID)).toEqual([]); + }); + test('leaves a deletion whose tokens may still be alive', async () => { await seedAccount(UID, CUSTOMER); stripeHasCustomer(CUSTOMER, `sub_${UID}`); From 9fe61c5c4d5ae1bedccec2ff7f5b95f27af8c8d2 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 15:37:31 -0600 Subject: [PATCH 14/64] Wait for the delete function on the web, and stop saying nothing was 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 --- apps/web/src/pages/DeleteAccount.tsx | 14 +++++-- apps/web/src/services/account.ts | 55 +++++++++++++++++++++++++--- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/apps/web/src/pages/DeleteAccount.tsx b/apps/web/src/pages/DeleteAccount.tsx index b7f3bfa..f4343ab 100644 --- a/apps/web/src/pages/DeleteAccount.tsx +++ b/apps/web/src/pages/DeleteAccount.tsx @@ -36,6 +36,13 @@ import styles from './DeleteAccount.module.css'; */ let deletedHere = false; +/** + * For a call that ended with no answer (`OutcomeUnknownError`, or anything unexpected). + * The same words on every client. + */ +const UNCONFIRMED = + 'We could not confirm that your account was deleted. It may still be finishing. Try again in a few minutes: if your password is then refused, the account is gone.'; + export function DeleteAccount() { const { user } = useAuth(); const sessionEmail = user?.email ?? ''; @@ -59,9 +66,10 @@ export function DeleteAccount() { setDone(true); } catch (err) { setError( - err instanceof WrongPasswordError - ? 'The email or password is not right. Nothing was deleted.' - : errorText(err, 'The account could not be deleted. Nothing was removed; try again shortly.') + // Neither says "nothing was deleted": only the function's own refusals can promise + // that, and they come with their own sentence. A wrong password on a second try + // may be the first try having worked. + err instanceof WrongPasswordError ? 'The email or password is not right.' : errorText(err, UNCONFIRMED) ); } finally { setBusy(false); diff --git a/apps/web/src/services/account.ts b/apps/web/src/services/account.ts index 1203b01..4d8e90c 100644 --- a/apps/web/src/services/account.ts +++ b/apps/web/src/services/account.ts @@ -1,10 +1,11 @@ -import { deleteApp, initializeApp } from 'firebase/app'; -import { inMemoryPersistence, initializeAuth, signOut } from 'firebase/auth'; +import { FirebaseError, deleteApp, initializeApp } from 'firebase/app'; +import { type User, inMemoryPersistence, initializeAuth, signOut } from 'firebase/auth'; import { getFunctions, httpsCallable } from 'firebase/functions'; import { app, auth } from '@/config/firebase'; import { clearAccountKey } from './keyStore'; +import { errorText } from './plan'; import { proveByPassword } from './signIn'; /** @@ -29,14 +30,58 @@ import { proveByPassword } from './signIn'; * is ended: ending it remounts the page that asked (see above), so a caller that waited * for this to resolve would be setting state on a component that is already gone. * - * Throws `WrongPasswordError` for a wrong email or password, and whatever the function - * refused with (`errorText` in plan.ts reads it). + * Throws `WrongPasswordError` for a wrong email or password, whatever the function + * refused with in its own words (`errorText` in plan.ts reads it), and + * `OutcomeUnknownError` when the call ended without an answer and the account is still + * there. */ + +/** + * How long the call may take: a little more than the function's own five minutes + * (`timeoutSeconds` in functions/src/account-deletion.ts), so the function always answers + * first. The SDK's default is 70 seconds, and an account with tens of thousands of pushes + * takes longer than that to delete: the page gave up, said nothing had been removed, and + * the server went on to delete everything. + */ +const CALL_TIMEOUT_MS = 310_000; + +/** + * The call ended with no answer from the function (a timeout, a dropped connection, a + * crash on the server) and the account still exists. Nothing can be said about how far + * the deletion got: once the function is past Stripe it has left a marker, and the sweep + * finishes what it started within about two hours whether or not anyone asks again. + */ +export class OutcomeUnknownError extends Error {} + +/** + * Whether the account `user` signed in to no longer exists, asked of Firebase. The Auth + * user is the last thing the function deletes, so gone means the deletion finished. + */ +async function isGone(user: User): Promise { + try { + await user.reload(); + return false; + } catch (error) { + return ( + error instanceof FirebaseError && + (error.code === 'auth/user-not-found' || error.code === 'auth/user-token-expired') + ); + } +} + export async function deleteAccount(email: string, password: string, onDeleted: () => void): Promise { const scratch = initializeApp(app.options, `delete-account-${Date.now()}`); try { const user = await proveByPassword(initializeAuth(scratch, { persistence: inMemoryPersistence }), email, password); - await httpsCallable(getFunctions(scratch), 'deleteAccount')(); + try { + await httpsCallable(getFunctions(scratch), 'deleteAccount', { timeout: CALL_TIMEOUT_MS })(); + } catch (error) { + // A sentence from the function is a refusal made before anything was deleted (an old + // sign-in, a subscription that would not cancel). Anything else is no answer at + // all, and the answer is then read off the account itself. + if (errorText(error, '')) throw error; + if (!(await isGone(user))) throw new OutcomeUnknownError(); + } onDeleted(); // The site's own session, if it is this account's, is over on the server already. // Its key goes first, as at any sign-out (KeysContext clears it too, once the session From eaff0b704ee21153a95e2169fce44b2e494195f5 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 15:37:52 -0600 Subject: [PATCH 15/64] Show "Your account is deleted" once, not for the rest of the tab 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 --- apps/web/src/pages/DeleteAccount.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/web/src/pages/DeleteAccount.tsx b/apps/web/src/pages/DeleteAccount.tsx index f4343ab..a7767f0 100644 --- a/apps/web/src/pages/DeleteAccount.tsx +++ b/apps/web/src/pages/DeleteAccount.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Helmet } from 'react-helmet'; import { Link } from 'react-router'; @@ -33,6 +33,10 @@ import styles from './DeleteAccount.module.css'; * signed-in frame for the signed-out one, and the page is mounted anew: state held in the * component was lost at exactly the moment it had something to say, and the person was * shown the empty form again with no word that anything had happened. + * + * It is for that one remount and no longer: the page that mounts next reads it and puts + * it back, so a later visit in the same tab (the next person at a shared computer, or the + * same one signed in to another account) gets the form and not someone else's result. */ let deletedHere = false; @@ -52,6 +56,11 @@ export function DeleteAccount() { const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const [done, setDone] = useState(deletedHere); + // On mount only, so the page that started the deletion does not clear it before the one + // mounted in its place has read it. + useEffect(() => { + deletedHere = false; + }, []); const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); @@ -60,7 +69,9 @@ export function DeleteAccount() { setError(''); try { await deleteAccount(sessionEmail || email.trim(), password, () => { - deletedHere = true; + // Only a signed-in browser is remounted; signed out, this page stays and shows + // the result itself, and a flag left set would greet the next visit with it. + deletedHere = !!sessionEmail; }); setPassword(''); setDone(true); From 16ca0e242d39ecb68a327475b32b408385daa971 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 15:37:53 -0600 Subject: [PATCH 16/64] Name the apps' button as it is labelled on the deletion page 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 --- apps/web/src/pages/DeleteAccount.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/pages/DeleteAccount.tsx b/apps/web/src/pages/DeleteAccount.tsx index a7767f0..fee2c54 100644 --- a/apps/web/src/pages/DeleteAccount.tsx +++ b/apps/web/src/pages/DeleteAccount.tsx @@ -123,7 +123,9 @@ export function DeleteAccount() {
  • Choose Delete account.
  • -
  • Enter your password to confirm, and press Delete.
  • +
  • + Enter your password to confirm, and press Delete account. +
  • Without the app, on this page:

    From eb08482cffc1b68ef060057570e9574923fbd1b8 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 15:37:54 -0600 Subject: [PATCH 17/64] Use the theme's background color for the delete button's text 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 --- apps/web/src/pages/DeleteAccount.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/pages/DeleteAccount.module.css b/apps/web/src/pages/DeleteAccount.module.css index 91b2080..e89f010 100644 --- a/apps/web/src/pages/DeleteAccount.module.css +++ b/apps/web/src/pages/DeleteAccount.module.css @@ -62,7 +62,7 @@ border: 1px solid var(--color-error); border-radius: 4px; background: var(--color-error); - color: #1a1a1a; + color: var(--color-background); font-weight: 700; cursor: pointer; } From 38d3c21f8531b448d70d7b8565773c529b90a2cd Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 15:37:54 -0600 Subject: [PATCH 18/64] Put Delete account above Logout on the web, as in the apps 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 --- apps/web/src/Layout.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/Layout.tsx b/apps/web/src/Layout.tsx index cb5bc35..2e88360 100644 --- a/apps/web/src/Layout.tsx +++ b/apps/web/src/Layout.tsx @@ -56,10 +56,10 @@ export default function Layout({ children }: LayoutProps) { {isMenuOpen && (
    - - {/* Last, under Logout, so the destructive item is not the first thing a thumb finds. - It leads to the public page, which asks for the password before anything goes. */} + {/* Above Logout, where the Android, iOS and macOS menus have it. It leads to the + public page, which asks for the password before anything goes. */} +
    )} From c68b77f9853827e7899858a1de68f355f1955b28 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 15:38:21 -0600 Subject: [PATCH 19/64] Keep a prerendered page's text on screen until the session is known 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 --- apps/web/src/main.tsx | 27 ++++++++++++++++++++------- docs/web.md | 5 +++-- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index fb06b31..e1fd491 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -6,11 +6,24 @@ import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router'; import App from './App.tsx'; +import { auth } from './config/firebase'; -createRoot(document.getElementById('root')!).render( - - - - - -); +const root = document.getElementById('root')!; + +const start = () => + createRoot(root).render( + + + + + + ); + +// A page served as static HTML (scripts/prerender.mjs) arrives with its text already in the +// root. React replaces that on its first render, and `AuthProvider` renders nothing until +// Firebase has read the session back, so starting at once showed the text, then a blank +// page, then the text again. Such a page keeps its markup until the session is known; a +// failure to read it starts the app all the same, signed out. Every other address arrives +// with an empty root and starts at once, as before. +if (root.hasChildNodes()) auth.authStateReady().then(start, start); +else start(); diff --git a/docs/web.md b/docs/web.md index ef67d68..ccd7477 100644 --- a/docs/web.md +++ b/docs/web.md @@ -51,8 +51,9 @@ account deletion page Google Play is given, see [account deletion](account-delet `scripts/prerender.mjs` renders each page into a copy of the built `index.html` (`dist/delete-account.html` and so on). The root `firebase.json` rewrites each address to its file, ahead of the rewrite that sends everything else to the app. Each copy still -loads the app's script, which then draws over the markup as it would over an empty root, -so with JavaScript nothing changes. The script fails the build if a copy is missing the +loads the app's script, which draws over the markup once Firebase has read the session +back (`main.tsx` waits for that on these pages, or the text would blank out and come back), +so with JavaScript the page ends up as it would have from an empty root. The script fails the build if a copy is missing the text it exists to carry. Rewrites rather than `cleanUrls`, which would also redirect `/index.html` to `/`: the list From 0ddfd4f3a2662f816c8c02a23d6eb8fa343c40fe Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 15:39:12 -0600 Subject: [PATCH 20/64] Stop Change password on Android from saying "Nothing was deleted" 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 --- .../pushthat/KeySettingsViewModel.kt | 24 +++++++++++++++---- .../app/src/main/res/values/strings.xml | 2 +- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/KeySettingsViewModel.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/KeySettingsViewModel.kt index ac0ee70..5f2de39 100644 --- a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/KeySettingsViewModel.kt +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/KeySettingsViewModel.kt @@ -49,9 +49,26 @@ class KeySettingsViewModel(application: Application) : AndroidViewModel(applicat State.RecoveryKey(keys.replaceRecoveryKey()) } + /** + * Its failures are worded here and not in [messageFor], which all three settings share: + * what is true of a deletion that failed ("nothing was deleted") is not true of a + * password change, and was being said to someone who had only mistyped their current + * password there. + */ fun deleteAccount(password: String) = run(R.string.deleting_account) { - AccountDeletion.delete(getApplication(), password) - State.AccountDeleted + val app = getApplication() + try { + AccountDeletion.delete(app, password) + State.AccountDeleted + } catch (e: FirebaseAuthInvalidCredentialsException) { + State.Failed(app.getString(R.string.wrong_password)) + } catch (e: AccountDeletion.RefusedException) { + // The function's own sentence, written for the person, when it gave one. + State.Failed(e.message ?: app.getString(R.string.delete_account_failed)) + } catch (e: IOException) { + Log.w(TAG, "The account could not be deleted", e) + State.Failed(app.getString(R.string.delete_account_failed)) + } } /** The result has been seen. */ @@ -86,9 +103,6 @@ class KeySettingsViewModel(application: Application) : AndroidViewModel(applicat return when (e) { // Before FirebaseException, whose own message for this is about a "credential". is FirebaseAuthInvalidCredentialsException -> app.getString(R.string.wrong_password) - // The function's own sentence, written for the person, when it gave one. - is AccountDeletion.RefusedException -> e.message ?: app.getString(R.string.delete_account_failed) - is IOException -> app.getString(R.string.delete_account_failed) // Retrying cannot help: the key this install holds is not the account's. is KeysChangedException, is AccountKeys.NoAccountKeyException -> app.getString(R.string.keys_changed_here) // Firebase's own message for its errors: a wrong current password, no network. diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 9fc80a8..757a888 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -106,7 +106,7 @@ Deleting your account… Your account is deleted. Your account could not be deleted. Nothing was removed. Please try again. - That is not your password. Nothing was deleted. + That is not your password. Enter your password again to finish. From eb8e797e6f1bb55f39cdc4d6aa9268364a1ec625 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 15:39:38 -0600 Subject: [PATCH 21/64] Wait for the delete function on Android, and stop saying nothing was 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 --- .../pushthat/AccountDeletion.kt | 81 +++++++++++++++---- .../pushthat/KeySettingsViewModel.kt | 15 ++-- .../app/src/main/res/values/strings.xml | 5 +- 3 files changed, 77 insertions(+), 24 deletions(-) diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/AccountDeletion.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/AccountDeletion.kt index 04d55f7..f92db95 100644 --- a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/AccountDeletion.kt +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/AccountDeletion.kt @@ -4,7 +4,10 @@ import android.content.Context import android.util.Log import com.bumptech.glide.Glide import com.google.firebase.Firebase +import com.google.firebase.FirebaseException import com.google.firebase.analytics.FirebaseAnalytics +import com.google.firebase.auth.FirebaseAuthInvalidUserException +import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.auth import com.google.firebase.messaging.FirebaseMessaging import com.makerocketgonow.pushthat.data.AppDatabase @@ -33,35 +36,75 @@ object AccountDeletion { private const val DELETE_ACCOUNT_URL = "https://us-central1-push-that.cloudfunctions.net/deleteAccount" /** - * The function said no, in [message]: a sentence it wrote for the person (a - * subscription that could not be cancelled, say), or null when it gave none. + * The function said no before deleting anything, in [message]: a sentence it wrote for + * the person (a subscription that could not be cancelled, say). */ - class RefusedException(message: String?) : Exception(message) + class RefusedException(message: String) : Exception(message) - // A long read: the function deletes an account's pushes five hundred at a time before - // it answers, and an answer lost to a timeout would leave this install signed in to an - // account that is already gone. + /** + * The call ended with no answer from the function (a timeout, a dropped connection, a + * crash on the server) and the account still exists. Nothing can be said about how far + * the deletion got: once the function is past Stripe it has left a marker, and the + * server finishes what it started within about two hours whether or not anyone asks + * again. So the message for this never says that nothing was deleted. + */ + class OutcomeUnknownException(cause: Exception) : Exception(cause) + + // A little more than the five minutes the function allows itself (`timeoutSeconds` in + // functions/src/account-deletion.ts), so the function always answers first. It deletes + // an account's pushes five hundred at a time before it answers and sends nothing + // meanwhile; at two minutes this gave up on a large account that the server then went + // on to delete, and told its owner nothing had been removed. private val http = OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(120, TimeUnit.SECONDS) + .readTimeout(310, TimeUnit.SECONDS) .build() + /** The statuses the function refuses with before it has deleted anything. */ + private val REFUSALS = setOf("FAILED_PRECONDITION", "UNAVAILABLE", "UNAUTHENTICATED") + /** * Deletes the signed-in account. Throws what the sign-in throws for a wrong password - * (`FirebaseAuthInvalidCredentialsException`), [RefusedException] when the function - * refuses, and `IOException` when it could not be reached; in each case nothing on - * this install has changed and the session is as it was. + * (`FirebaseAuthInvalidCredentialsException`) or no network, [RefusedException] when + * the function refuses, and [OutcomeUnknownException] when the call ended with no + * answer; in each case nothing on this install has changed and the session is as it + * was. + * + * An account found to be gone already counts as deleted, whichever step finds it: an + * earlier try whose answer was lost has finished on the server, and what is left to do + * is here. */ suspend fun delete(context: Context, password: String) { val app = context.applicationContext - val uid = Firebase.auth.currentUser?.uid ?: throw IllegalStateException("not signed in") + val user = Firebase.auth.currentUser ?: throw IllegalStateException("not signed in") + val uid = user.uid // The function refuses a sign-in older than five minutes, so the token is one made // by this proof of the password, not the one the session has been using. - val token = AccountKeys(app).freshTokenFor(password) - callDeleteAccount(token) + val token = try { + AccountKeys(app).freshTokenFor(password) + } catch (e: FirebaseAuthInvalidUserException) { + forget(app, uid) + return + } + try { + callDeleteAccount(token) + } catch (e: IOException) { + // The Auth user is the last thing the function deletes, so gone means done. + if (!isGone(user)) throw OutcomeUnknownException(e) + } forget(app, uid) } + private suspend fun isGone(user: FirebaseUser): Boolean = try { + user.reload().await() + false + } catch (e: FirebaseAuthInvalidUserException) { + true + } catch (e: FirebaseException) { + // Could not ask (no network): not known to be gone. + false + } + private suspend fun callDeleteAccount(token: String) = withContext(Dispatchers.IO) { val request = Request.Builder() .url(DELETE_ACCOUNT_URL) @@ -71,11 +114,15 @@ object AccountDeletion { http.newCall(request).execute().use { response -> val body = runCatching { JSONObject(response.body.string()) }.getOrNull() if (response.isSuccessful && body?.optJSONObject("result")?.optBoolean("deleted") == true) return@use - // A callable's refusal is `{"error": {"status", "message"}}`. A bare status - // repeated as the message ("INTERNAL") is not a sentence for anyone. + // A callable's refusal is `{"error": {"status", "message"}}`, and the function + // makes its own before it deletes anything, each with a sentence for the + // person. Any other failure (INTERNAL, a gateway's error page) says nothing + // about how far the deletion got, and is treated like an answer that never came. val error = body?.optJSONObject("error") - val message = error?.optString("message")?.takeIf { it.isNotBlank() && it != error.optString("status") } - if (error == null && !response.isSuccessful) throw IOException("deleteAccount answered ${response.code}") + val message = error?.optString("message")?.takeIf { it.isNotBlank() } + if (error == null || message == null || error.optString("status") !in REFUSALS) { + throw IOException("deleteAccount answered ${response.code}") + } throw RefusedException(message) } } diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/KeySettingsViewModel.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/KeySettingsViewModel.kt index 5f2de39..8c7cf69 100644 --- a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/KeySettingsViewModel.kt +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/KeySettingsViewModel.kt @@ -6,6 +6,7 @@ import androidx.annotation.StringRes import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.google.firebase.FirebaseException +import com.google.firebase.FirebaseNetworkException import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException import com.makerocketgonow.pushthat.e2ee.AccountKeyStore import com.makerocketgonow.pushthat.e2ee.AccountKeys @@ -15,7 +16,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import java.io.IOException /** * "Change password", "Show a new recovery key" and "Delete account" from the list's menu. @@ -63,11 +63,14 @@ class KeySettingsViewModel(application: Application) : AndroidViewModel(applicat } catch (e: FirebaseAuthInvalidCredentialsException) { State.Failed(app.getString(R.string.wrong_password)) } catch (e: AccountDeletion.RefusedException) { - // The function's own sentence, written for the person, when it gave one. - State.Failed(e.message ?: app.getString(R.string.delete_account_failed)) - } catch (e: IOException) { - Log.w(TAG, "The account could not be deleted", e) - State.Failed(app.getString(R.string.delete_account_failed)) + // The function's own sentence, written for the person. + State.Failed(e.message ?: app.getString(R.string.delete_account_unconfirmed)) + } catch (e: AccountDeletion.OutcomeUnknownException) { + Log.w(TAG, "The deletion ended without an answer", e) + State.Failed(app.getString(R.string.delete_account_unconfirmed)) + } catch (e: FirebaseNetworkException) { + // The sign-in that proves the password never reached Firebase: nothing was asked. + State.Failed(app.getString(R.string.delete_account_offline)) } } diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 757a888..712cb5d 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -105,7 +105,10 @@ This is permanent. Your account, your email address, every link and file you have pushed, and your encryption keys and recovery key are deleted, and every device is signed out.\n\nYour pushes are encrypted with those keys, so once they are gone nobody can recover them, including us. A Pro subscription is cancelled.\n\nEnter your password to delete your account. Deleting your account… Your account is deleted. - Your account could not be deleted. Nothing was removed. Please try again. + + We could not confirm that your account was deleted. It may still be finishing. Try again in a few minutes: if your password is then refused, the account is gone. + Your account was not deleted: PushThat could not reach the server. Check your connection and try again. That is not your password. Enter your password again to finish. - We could not confirm that your account was deleted. It may still be finishing. Try again in a few minutes: if your password is then refused, the account is gone. + It must not say that nothing was deleted, because by then something may have been. + Nor that a refused password means the account is gone: it says "the right password", + because a mistyped one is refused too, and that is not a deletion. --> + We could not confirm that your account was deleted. It may still be finishing. Try again in a few minutes. If signing in with the right password no longer works, the account is gone. Your account was not deleted: PushThat could not reach the server. Check your connection and try again. That is not your password. Enter your password again to finish. diff --git a/apps/ios/PushThat/ViewModels/AuthViewModel.swift b/apps/ios/PushThat/ViewModels/AuthViewModel.swift index 69a6dc9..1aabc9a 100644 --- a/apps/ios/PushThat/ViewModels/AuthViewModel.swift +++ b/apps/ios/PushThat/ViewModels/AuthViewModel.swift @@ -473,7 +473,7 @@ class AuthViewModel: ObservableObject { /// For a call that ended with no answer. The same words as the web page and the Android /// app, and never "nothing was deleted": by then something may have been. - static let deleteAccountUnconfirmed = "We could not confirm that your account was deleted. It may still be finishing. Try again in a few minutes: if your password is then refused, the account is gone." + static let deleteAccountUnconfirmed = "We could not confirm that your account was deleted. It may still be finishing. Try again in a few minutes. If signing in with the right password no longer works, the account is gone." /// The call ended with no answer from the function: a timeout, a dropped connection, or /// a failure that is not one of the function's own refusals. Nothing can be said about diff --git a/apps/macos/PushThat/ViewModels/AuthViewModel.swift b/apps/macos/PushThat/ViewModels/AuthViewModel.swift index d61fb62..7c78023 100644 --- a/apps/macos/PushThat/ViewModels/AuthViewModel.swift +++ b/apps/macos/PushThat/ViewModels/AuthViewModel.swift @@ -471,7 +471,7 @@ class AuthViewModel: ObservableObject { /// For a call that ended with no answer. The same words as the web page and the Android /// app, and never "nothing was deleted": by then something may have been. - static let deleteAccountUnconfirmed = "We could not confirm that your account was deleted. It may still be finishing. Try again in a few minutes: if your password is then refused, the account is gone." + static let deleteAccountUnconfirmed = "We could not confirm that your account was deleted. It may still be finishing. Try again in a few minutes. If signing in with the right password no longer works, the account is gone." /// The call ended with no answer from the function: a timeout, a dropped connection, or /// a failure that is not one of the function's own refusals. Nothing can be said about diff --git a/apps/web/src/components/DeleteAccountForm.tsx b/apps/web/src/components/DeleteAccountForm.tsx index cea3b67..a771dac 100644 --- a/apps/web/src/components/DeleteAccountForm.tsx +++ b/apps/web/src/components/DeleteAccountForm.tsx @@ -40,7 +40,7 @@ const NOT_ASKED = /** For a call that ended with no answer (`OutcomeUnknownError`). The same words on every client. */ const UNCONFIRMED = - 'We could not confirm that your account was deleted. It may still be finishing. Try again in a few minutes: if your password is then refused, the account is gone.'; + 'We could not confirm that your account was deleted. It may still be finishing. Try again in a few minutes. If signing in with the right password no longer works, the account is gone.'; export function DeleteAccountForm() { const { user } = useAuth(); diff --git a/docs/account-deletion.md b/docs/account-deletion.md index 8a5a24d..7d3ab46 100644 --- a/docs/account-deletion.md +++ b/docs/account-deletion.md @@ -134,8 +134,8 @@ connection, a crash on the server), the client does not know how far the deletio must not say "nothing was deleted": past Stripe the marker is written and the sweep will finish the job regardless. It asks Firebase whether the account still exists. The Auth user is the last thing deleted, so gone means done, and the client clears its local data and -signs out; otherwise it says the outcome could not be confirmed and that a refused password -on the next try means the account is gone. A retry that finds the account already gone is treated as the deletion having succeeded, on +signs out; otherwise it says the outcome could not be confirmed, and that the account is gone +once signing in with the right password no longer works. A retry that finds the account already gone is treated as the deletion having succeeded, on the apps. With email enumeration protection on, Firebase refuses a deleted account's password exactly as it would a wrong one, so the apps ask about the session they already hold (`reload`) and count only "no such user". A revoked session does not count there: a From 1f2bcf2be6e29b40d9d3bd34d927353db998e4a7 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 16:34:33 -0600 Subject: [PATCH 54/64] Say "That is not your password" to a signed-in browser 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 --- apps/web/src/components/DeleteAccountForm.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/DeleteAccountForm.tsx b/apps/web/src/components/DeleteAccountForm.tsx index a771dac..2ece15a 100644 --- a/apps/web/src/components/DeleteAccountForm.tsx +++ b/apps/web/src/components/DeleteAccountForm.tsx @@ -83,7 +83,10 @@ export function DeleteAccountForm() { // password does not say "nothing was deleted": on a second try it may be the // first try having worked. err instanceof WrongPasswordError - ? 'The email or password is not right.' + ? // Signed in there is no email field, so only the password can be what was wrong. + sessionEmail + ? 'That is not your password.' + : 'The email or password is not right.' : err instanceof OutcomeUnknownError ? UNCONFIRMED : err instanceof FirebaseError && err.code === 'auth/too-many-requests' From 12782e59618982a17852e416eadc27a8ff7de270 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 16:36:00 -0600 Subject: [PATCH 55/64] Ask people to keep the Apple apps open while an account is deleted 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 --- apps/ios/PushThat/Views/AccountSettings.swift | 8 ++++++++ apps/macos/PushThat/Views/AccountSettings.swift | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/apps/ios/PushThat/Views/AccountSettings.swift b/apps/ios/PushThat/Views/AccountSettings.swift index 139a4ca..cfd0c15 100644 --- a/apps/ios/PushThat/Views/AccountSettings.swift +++ b/apps/ios/PushThat/Views/AccountSettings.swift @@ -222,6 +222,14 @@ struct DeleteAccountView: View { } } .disabled(isWorking || password.isEmpty) + } footer: { + // The call stays open until the server has deleted everything, which + // for a large account is minutes. iOS suspends an app soon after it is + // put away and the call is lost with it: the account is still deleted, + // but this sheet can then only say it could not confirm that. + if isWorking { + Text("Keep PushThat open until this finishes. It can take a few minutes.") + } } } .navigationTitle("Delete account") diff --git a/apps/macos/PushThat/Views/AccountSettings.swift b/apps/macos/PushThat/Views/AccountSettings.swift index 139a4ca..cfd0c15 100644 --- a/apps/macos/PushThat/Views/AccountSettings.swift +++ b/apps/macos/PushThat/Views/AccountSettings.swift @@ -222,6 +222,14 @@ struct DeleteAccountView: View { } } .disabled(isWorking || password.isEmpty) + } footer: { + // The call stays open until the server has deleted everything, which + // for a large account is minutes. iOS suspends an app soon after it is + // put away and the call is lost with it: the account is still deleted, + // but this sheet can then only say it could not confirm that. + if isWorking { + Text("Keep PushThat open until this finishes. It can take a few minutes.") + } } } .navigationTitle("Delete account") From 8840abd814729faad4987c6feeb902252d22e035 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 16:36:24 -0600 Subject: [PATCH 56/64] Make the deletion page's in-app steps match what each client shows 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 --- apps/web/src/pages/DeleteAccount.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/web/src/pages/DeleteAccount.tsx b/apps/web/src/pages/DeleteAccount.tsx index 057933f..6e1ce98 100644 --- a/apps/web/src/pages/DeleteAccount.tsx +++ b/apps/web/src/pages/DeleteAccount.tsx @@ -76,14 +76,17 @@ export function DeleteAccount() {

    In the app:

    1. - Open PushThat and open its menu: the three dots at the top of the list on Android, the account menu on iPhone - and Mac, and the menu at the top right on the website and in the Chrome extension. + Open PushThat and open its menu: the three dots at the top of the list on Android, the circled three dots at + the top of the list on iPhone and Mac, and the menu at the top right on the website and in the Chrome + extension.
    2. - Choose Delete account. + Choose Delete account. On the website and in the extension this opens this page: use the{' '} + form below.
    3. - Enter your password to confirm, and press Delete account. + In the Android, iPhone and Mac apps, enter your password to confirm, and press Delete account + .
    From a04a73239f3bec92804c1d045c58e5ef992a75dd Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 16:36:30 -0600 Subject: [PATCH 57/64] Move `errorText` out of the plan service to a file of its own `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 --- apps/web/src/components/DeleteAccountForm.tsx | 2 +- apps/web/src/services/account.ts | 4 ++-- apps/web/src/services/callable.ts | 15 +++++++++++++++ apps/web/src/services/plan.ts | 17 +++-------------- 4 files changed, 21 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/services/callable.ts diff --git a/apps/web/src/components/DeleteAccountForm.tsx b/apps/web/src/components/DeleteAccountForm.tsx index 2ece15a..ef61ebb 100644 --- a/apps/web/src/components/DeleteAccountForm.tsx +++ b/apps/web/src/components/DeleteAccountForm.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'; import { useAuth } from '@/contexts/AuthContext'; import { OutcomeUnknownError, deleteAccount } from '@/services/account'; -import { errorText } from '@/services/plan'; +import { errorText } from '@/services/callable'; import { WrongPasswordError } from '@/services/signIn'; import styles from './DeleteAccountForm.module.css'; diff --git a/apps/web/src/services/account.ts b/apps/web/src/services/account.ts index 9bcfb6d..1a06c40 100644 --- a/apps/web/src/services/account.ts +++ b/apps/web/src/services/account.ts @@ -4,8 +4,8 @@ import { getFunctions, httpsCallable } from 'firebase/functions'; import { app, auth } from '@/config/firebase'; +import { errorText } from './callable'; import { clearAccountKey } from './keyStore'; -import { errorText } from './plan'; import { proveByPassword } from './signIn'; /** @@ -82,7 +82,7 @@ async function endSession(): Promise { * for this to resolve would be setting state on a component that is already gone. * * Throws `WrongPasswordError` for a wrong email or password, whatever the function - * refused with in its own words (`errorText` in plan.ts reads it), and + * refused with in its own words (`errorText` in callable.ts reads it), and * `OutcomeUnknownError` when the call ended without an answer and the account is still * there. */ diff --git a/apps/web/src/services/callable.ts b/apps/web/src/services/callable.ts new file mode 100644 index 0000000..d86f3aa --- /dev/null +++ b/apps/web/src/services/callable.ts @@ -0,0 +1,15 @@ +import { FunctionsError } from 'firebase/functions'; + +/** + * The message a callable's refusal carries, or `fallback` when the SDK has only the + * bare code: a network failure, a timeout or an uncaught server error leaves + * `message` as "internal" or "deadline-exceeded", which is nothing to show anyone. + * A refusal the function threw itself (`HttpsError` with a sentence) comes through + * whole, colons and all. + */ +export function errorText(err: unknown, fallback: string): string { + if (!(err instanceof FunctionsError) || !err.message) return fallback; + const code = err.code.replace(/^functions\//, ''); + const bareCode = err.message.toLowerCase().replace(/_/g, '-') === code; + return code === 'internal' || bareCode ? fallback : err.message; +} diff --git a/apps/web/src/services/plan.ts b/apps/web/src/services/plan.ts index 1cde9ea..93767f3 100644 --- a/apps/web/src/services/plan.ts +++ b/apps/web/src/services/plan.ts @@ -1,4 +1,4 @@ -import { FunctionsError, getFunctions, httpsCallable } from 'firebase/functions'; +import { getFunctions, httpsCallable } from 'firebase/functions'; import type { TierLimits } from '@/config/plan'; @@ -30,16 +30,5 @@ export const planApi = { call<{ flow?: 'payment' }, { url: string }>('createPortalSession')({ flow }).then((r) => r.data), }; -/** - * The message a callable's refusal carries, or `fallback` when the SDK has only the - * bare code: a network failure, a timeout or an uncaught server error leaves - * `message` as "internal" or "deadline-exceeded", which is nothing to show anyone. - * A refusal the function threw itself (`HttpsError` with a sentence) comes through - * whole, colons and all. - */ -export function errorText(err: unknown, fallback: string): string { - if (!(err instanceof FunctionsError) || !err.message) return fallback; - const code = err.code.replace(/^functions\//, ''); - const bareCode = err.message.toLowerCase().replace(/_/g, '-') === code; - return code === 'internal' || bareCode ? fallback : err.message; -} +// Lives in callable.ts, since it is not about plans; exported from here too for the plan pages that always took it from here. +export { errorText } from './callable'; From 107562a8eee8b74dc007138949b2831ed53a05e7 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 16:36:31 -0600 Subject: [PATCH 58/64] Drop the Terms page's heading style from the deletion page 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 --- apps/web/src/pages/DeleteAccount.module.css | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/web/src/pages/DeleteAccount.module.css b/apps/web/src/pages/DeleteAccount.module.css index dfb359a..654131d 100644 --- a/apps/web/src/pages/DeleteAccount.module.css +++ b/apps/web/src/pages/DeleteAccount.module.css @@ -1,9 +1,4 @@ .root { - h5 { - font-style: italic; - text-decoration: underline; - } - ol { margin: 0 0 1em; padding-left: 1em; From fe801349be9faa39c83bd36f7eb0417fba2e84fd Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 16:36:50 -0600 Subject: [PATCH 59/64] Tidy the deletion doc's prose, and note what a bulk delete does to the 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 --- AGENTS.md | 6 +++--- docs/account-deletion.md | 45 ++++++++++++++++++++++------------------ 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c575ac5..5918c1e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,9 +114,9 @@ npm ci && node --check index.js && npm test emulators, through the root `firebase.json`; the functions' suites start the Auth emulator too. They need a Java runtime and the Firebase CLI, and the emulator ports free (8080 and 9199, and 9099 for Auth): a run refuses to start otherwise, naming the port, and one run -cannot share another's emulators, so run them one after the other. The -functions' suites deliver signed events to the webhook over HTTP and point -`STRIPE_API_URL` at a stand-in for Stripe's API, so nothing reaches Stripe. +cannot share another's emulators, so run them one after the other. The functions' suites +deliver signed events to the webhook over HTTP and point `STRIPE_API_URL` at a stand-in for +Stripe's API, so nothing reaches Stripe. CI (`.github/workflows/ci.yml`) runs the functions' unit tests, the web app's lint, key vectors and builds, the Android unit tests and the encryption reference's tests on every diff --git a/docs/account-deletion.md b/docs/account-deletion.md index 7d3ab46..7f2b636 100644 --- a/docs/account-deletion.md +++ b/docs/account-deletion.md @@ -52,6 +52,12 @@ In order: 5. **The Auth user**, last. It is what lets the person ask again, so if a step before it fails they can still sign in, and the next call picks up where this one stopped. +Deleting the pushes starts `onPushRecordDeleted` once per push, link pushes included: for +those it logs a line and returns. An account with tens of thousands of pushes therefore +costs that many short invocations and log lines when it is deleted. That is the trigger as +it has always been, and account deletion is the first thing to delete in bulk; it is noted +here so the burst in the logs after a large deletion is recognised for what it is. + Every step is content to find its work already done, so the call is idempotent: a second call, or one for an account already deleted (its last ID token can still reach the function for an hour), succeeds and changes nothing. @@ -107,7 +113,7 @@ Three of these depend on settings outside the repository, which nothing here can ## Local data on each device After the function answers, the client that asked clears what it holds and signs out. What -"what it holds" is differs by client, because what each one keeps differs: +each client holds differs, because each keeps different things: | | Web and extension | Android | iOS | macOS | | --- | --- | --- | --- | --- | @@ -129,24 +135,24 @@ addressed when the user document goes, and is replaced at the next sign-in. ## When the client hears nothing back The function may run for five minutes on a large account, so every client waits a little -longer than that (310 seconds). If the call still ends with no answer (a dropped -connection, a crash on the server), the client does not know how far the deletion got, and -must not say "nothing was deleted": past Stripe the marker is written and the sweep will -finish the job regardless. It asks Firebase whether the account still exists. The Auth user -is the last thing deleted, so gone means done, and the client clears its local data and -signs out; otherwise it says the outcome could not be confirmed, and that the account is gone -once signing in with the right password no longer works. A retry that finds the account already gone is treated as the deletion having succeeded, on -the apps. With email enumeration protection on, Firebase refuses a deleted account's -password exactly as it would a wrong one, so the apps ask about the session they already -hold (`reload`) and count only "no such user". A revoked session does not count there: a -password change or reset on another device revokes every session too, and reading that as -"deleted" told someone who had mistyped that their account was gone when the server had not -been asked. It counts only straight after the deletion's own sign-in, when nothing but the -function can have revoked it. The web cannot tell the two apart (Firebase answers both with -"token expired" there), so a retry on the web reads "The email or password is not right", -and the unconfirmed message says what that means. Only the function's own refusals (an old -sign-in, a subscription that would not cancel), which are made before anything is deleted, -are shown as refusals. +longer than that (310 seconds). If the call still ends with no answer (a dropped connection, +a crash on the server), the client does not know how far the deletion got, and must not say +"nothing was deleted": past Stripe the marker is written and the sweep will finish the job +regardless. It asks Firebase whether the account still exists. The Auth user is the last +thing deleted, so gone means done, and the client clears its local data and signs out; +otherwise it says the outcome could not be confirmed, and that the account is gone once +signing in with the right password no longer works. A retry that finds the account already +gone is treated as the deletion having succeeded, on the apps. With email enumeration +protection on, Firebase refuses a deleted account's password exactly as it would a wrong +one, so the apps ask about the session they already hold (`reload`) and count only "no such +user". A revoked session does not count there: a password change or reset on another device +revokes every session too, and reading that as "deleted" told someone who had mistyped that +their account was gone when the server had not been asked. It counts only straight after the +deletion's own sign-in, when nothing but the function can have revoked it. The web cannot +tell the two apart (Firebase answers both with "token expired" there), so a retry on the web +reads "The email or password is not right", and the unconfirmed message says what that +means. Only the function's own refusals (an old sign-in, a subscription that would not +cancel), which are made before anything is deleted, are shown as refusals. ## The public page @@ -163,4 +169,3 @@ to support. The website's and the extension's menu item leads to this page. The Android, iOS and macOS apps ask for the password in a dialog of their own and call the function directly. - From 8fa3ed7aea1d3c65256796d32bf2a4c33cda837e Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 18:50:11 -0600 Subject: [PATCH 60/64] Let the push-delete trigger find its file already gone `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 --- functions/src/index.ts | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/functions/src/index.ts b/functions/src/index.ts index 9f3ff7f..5c8b2f4 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -65,21 +65,12 @@ export const onPushRecordDeleted = onDocumentDeleted('pushes/{pushId}', async (e } storagePath = path; - // Get storage reference - const storage = getStorage(); - const bucket = storage.bucket(); - const file = bucket.file(storagePath); - - // Check if file exists before attempting to delete - const [exists] = await file.exists(); - if (!exists) { - logger.info(`File ${storagePath} does not exist in storage`); - return; - } - - // Delete the file - await file.delete(); - logger.info(`Successfully deleted file: ${storagePath}`); + // `ignoreNotFound`, and no look first to see whether it is there: the account purge + // (account-deletion.ts) and the orphan sweep delete these objects too, and one that + // went between a look and the delete made this throw and log an error for a file that + // was exactly where it should be, gone. + await getStorage().bucket().file(storagePath).delete({ ignoreNotFound: true }); + logger.info(`Deleted stored file, or found it already gone: ${storagePath}`); } catch (error) { logger.error(`Error deleting ${storagePath} from storage:`, error); throw error; From 11bb1005706a7cdddb5da1499340f008e68eba18 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 18:50:12 -0600 Subject: [PATCH 61/64] Say which Android case the non-cancellable cleanup covers, and which 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 --- .../makerocketgonow/pushthat/AccountDeletion.kt | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/AccountDeletion.kt b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/AccountDeletion.kt index 75b677b..3e02aaa 100644 --- a/apps/android/app/src/main/java/com/makerocketgonow/pushthat/AccountDeletion.kt +++ b/apps/android/app/src/main/java/com/makerocketgonow/pushthat/AccountDeletion.kt @@ -101,10 +101,16 @@ object AccountDeletion { return } // From here on, not cancelled with the screen. The call can take five minutes, and - // someone who swipes the app away meanwhile ends the view model's scope: the - // request still finished on its thread and the server still deleted the account, - // but the cancellation was rethrown on the way back and `forget` never ran, leaving - // an install signed in to nothing with the deleted account's queue and token. + // leaving the screen meanwhile (back, or the activity finishing) clears the view + // model's scope: the request still finished on its thread and the server still + // deleted the account, but the cancellation was rethrown on the way back and + // `forget` never ran, leaving an install signed in to nothing with the deleted + // account's queue and token. + // + // Swiping the app out of Recents is not that: it kills the process, which nothing + // here survives. The session then ends on its own once the revoked token is + // refused, and `forgetSession` drops the key, but the queued pushes and the + // notification token stay until this install is next signed out. withContext(NonCancellable) { deleteAndForget(app, user, token) } } From 69a75d6536be620fc140ccf53ab95c9ffad3a349 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 18:50:12 -0600 Subject: [PATCH 62/64] Give the Mac's reason too in the comment on the keep-open hint 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 --- apps/ios/PushThat/Views/AccountSettings.swift | 5 +++-- apps/macos/PushThat/Views/AccountSettings.swift | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/ios/PushThat/Views/AccountSettings.swift b/apps/ios/PushThat/Views/AccountSettings.swift index cfd0c15..f4458cf 100644 --- a/apps/ios/PushThat/Views/AccountSettings.swift +++ b/apps/ios/PushThat/Views/AccountSettings.swift @@ -225,8 +225,9 @@ struct DeleteAccountView: View { } footer: { // The call stays open until the server has deleted everything, which // for a large account is minutes. iOS suspends an app soon after it is - // put away and the call is lost with it: the account is still deleted, - // but this sheet can then only say it could not confirm that. + // put away, and on a Mac quitting the app or the Mac sleeping does the + // same: the call is lost, the account is still deleted, and this sheet + // can then only say it could not confirm that. if isWorking { Text("Keep PushThat open until this finishes. It can take a few minutes.") } diff --git a/apps/macos/PushThat/Views/AccountSettings.swift b/apps/macos/PushThat/Views/AccountSettings.swift index cfd0c15..f4458cf 100644 --- a/apps/macos/PushThat/Views/AccountSettings.swift +++ b/apps/macos/PushThat/Views/AccountSettings.swift @@ -225,8 +225,9 @@ struct DeleteAccountView: View { } footer: { // The call stays open until the server has deleted everything, which // for a large account is minutes. iOS suspends an app soon after it is - // put away and the call is lost with it: the account is still deleted, - // but this sheet can then only say it could not confirm that. + // put away, and on a Mac quitting the app or the Mac sleeping does the + // same: the call is lost, the account is still deleted, and this sheet + // can then only say it could not confirm that. if isWorking { Text("Keep PushThat open until this finishes. It can take a few minutes.") } From ba1afbd6e83984ec578fddd31be10342b875c44b Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 18:50:34 -0600 Subject: [PATCH 63/64] Record the Android gap and the web's two retry messages in the deletion 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 --- docs/account-deletion.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/account-deletion.md b/docs/account-deletion.md index 7f2b636..a0f7f4c 100644 --- a/docs/account-deletion.md +++ b/docs/account-deletion.md @@ -124,6 +124,11 @@ each client holds differs, because each keeps different things: | The notification (FCM) token | not held by the page | yes | yes | no FCM | | The Analytics installation id | no Analytics | yes | yes | no Analytics | +On Android, a process killed during the call (the app swiped out of Recents) never gets to +this cleanup: the session ends on its own once its revoked token is refused and the key +goes with it, but the queued pushes and the notification token stay until that install is +next signed out. + The decoded icons are the one thing here that outlives the sign-out: each app keeps a bounded cache of the icons it has drawn, in memory only, which neither sign-out nor deletion empties. They go with the process. @@ -150,7 +155,8 @@ revokes every session too, and reading that as "deleted" told someone who had mi their account was gone when the server had not been asked. It counts only straight after the deletion's own sign-in, when nothing but the function can have revoked it. The web cannot tell the two apart (Firebase answers both with "token expired" there), so a retry on the web -reads "The email or password is not right", and the unconfirmed message says what that +reports a refused password ("That is not your password." when the browser is signed in, "The +email or password is not right." when it is not), and the unconfirmed message says what that means. Only the function's own refusals (an old sign-in, a subscription that would not cancel), which are made before anything is deleted, are shown as refusals. From fb54db7e7c7a0e8f57ad9c730cb87c9b5cb9274f Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 18:50:38 -0600 Subject: [PATCH 64/64] Import `errorText` from where it lives in the plan pages 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 --- apps/web/src/hooks/usePlan.ts | 3 ++- apps/web/src/pages/Plan.tsx | 3 ++- apps/web/src/services/plan.ts | 3 --- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/web/src/hooks/usePlan.ts b/apps/web/src/hooks/usePlan.ts index 4768032..6c1e494 100644 --- a/apps/web/src/hooks/usePlan.ts +++ b/apps/web/src/hooks/usePlan.ts @@ -1,7 +1,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useAuth } from '@/contexts/AuthContext'; -import { PlanReport, errorText, planApi } from '@/services/plan'; +import { errorText } from '@/services/callable'; +import { PlanReport, planApi } from '@/services/plan'; /** * The signed-in account's plan, read once per mount and again on demand. `plan` is diff --git a/apps/web/src/pages/Plan.tsx b/apps/web/src/pages/Plan.tsx index 3b03527..4a818d4 100644 --- a/apps/web/src/pages/Plan.tsx +++ b/apps/web/src/pages/Plan.tsx @@ -5,8 +5,9 @@ import { Link, useNavigate, useSearchParams } from 'react-router'; import Button from '@/components/Button'; import { LIMITS, formatMb } from '@/config/plan'; import { usePlan } from '@/hooks/usePlan'; +import { errorText } from '@/services/callable'; import { deviceId } from '@/services/keyStore'; -import { PlanReport, errorText, planApi } from '@/services/plan'; +import { PlanReport, planApi } from '@/services/plan'; import styles from './Plan.module.css'; diff --git a/apps/web/src/services/plan.ts b/apps/web/src/services/plan.ts index 93767f3..61b6a17 100644 --- a/apps/web/src/services/plan.ts +++ b/apps/web/src/services/plan.ts @@ -29,6 +29,3 @@ export const planApi = { createPortalSession: (flow?: 'payment') => call<{ flow?: 'payment' }, { url: string }>('createPortalSession')({ flow }).then((r) => r.data), }; - -// Lives in callable.ts, since it is not about plans; exported from here too for the plan pages that always took it from here. -export { errorText } from './callable';