Skip to main content

Auth and Users

See also: Data Model · Firestore and Security Rules · Pricing and Subscriptions · Page Builder · Mobile App · Chrome Extension

Firebase Auth for identity, a single Firestore document per user for data, client-side encryption for sensitive story answers, and Cloud Functions only where Security Rules can't reach (deletions needing re-auth).

Identity layer​

PieceWhereWhat it does
AuthUserlibs/stretched-types/src/auth/auth.tsThe subset of the Firebase Auth user the app consumes (uid, email, displayName, photoURL, emailVerified, isAnonymous).
AuthServiceapps/stretched/src/app/core/services/auth.service.tsThin wrapper: currentUser$ (authState) + isAdmin(), which awaits authStateReady() before reading claims so a cold page load doesn't report false prematurely.
UserServicelibs/stretched-components/src/services/user.service.tsThe shared signal-based auth surface: currentUser / isLoggedIn / displayName / email / uid computeds, loginWithEmail / register / Google sign-in / signOut, plus user-doc preferences (loadPreferences, savePreferred* — Firestore resolved lazily via Injector so Storybook's Firestore-less hosts keep working).
UserLoginlibs/stretched-components/src/organisms/user/user-login/user-login.tssc-user-login — the sign-in/register organism used by /account, /partners, and the mobile login page. isGoogleHidden input exists because signInWithPopup can't run in a Capacitor WebView.

Google sign-in is a seam: UserService injects the GOOGLE_SIGN_IN_FN token (libs/stretched-components/src/tokens/google-sign-in-fn.token.ts); web uses the popup, mobile provides a native implementation — see Mobile App.

Storybook substitutes a mock auth layer (apps/storybook/.storybook/storybook-auth.ts) so login-dependent organisms render without Firebase.

Admin and staff roles​

The first production admin is still granted out-of-band via the Admin SDK (there is no bootstrap UI that can mint the first admin). After that, staff manage the roster at /admin/users.

To bootstrap an existing account, copy its UID from Firebase Authentication and run from the repository root:

bun run firebase:grant-admin <uid>
# Or select the production project explicitly:
bun run firebase:grant-admin <uid> --project production

The script uses the current Firebase CLI account (firebase login / firebase login:use) and active project (firebase use), falling back to the .firebaserc default project (stretched-dev). --project accepts an alias or project ID. An existing GOOGLE_APPLICATION_CREDENTIALS setting takes precedence over the CLI login; otherwise Application Default Credentials are used when no CLI login is available. The credentials need permission to update Firebase Auth users and write the user's Firestore document.

It calls applyAccountClaims, retaining existing supported roles and claims, adding admin, synchronizing admin: true, and bumping claimsVersion. Sign out and back in, then open /admin/users. Subsequent role changes should use that page so they are recorded in the staff audit log; the bootstrap script does not create an audit entry.

To remove admin with the same credentials and project selection:

bun run firebase:remove-admin <uid>
bun run firebase:remove-admin <uid> --project production

This removes the admin role and legacy admin: true flag, preserves any auditor role and other supported claims, and bumps claimsVersion. It revokes refresh tokens before changing claims; a revocation failure leaves claims unchanged so the command can be retried. Removing admin from an account that already lacks it is a no-op. Disabled accounts can also have admin removed. Like the bootstrap command, this operator command does not write a staff audit entry. Revoked sessions must sign in again; existing ID tokens remain usable until expiry on surfaces that do not check revocation.

  • Roles (stackable, on the roles claim): admin, auditor.
  • Tier stays the tier claim — not a role — so CONTENT permissions stay faithful when an admin previews a plan.
  • Company type is a dedicated mutually exclusive claim (unclassified | sponsor | affiliate), omitted when unset.
  • Legacy admin: true is kept in sync with the admin role by applyAccountClaims.

Client UX:

  • adminGuard gates /admin/story-builder and /admin/partners.
  • permissionGuard('users.read') gates /admin/users (admins and auditors).
  • The sidebar Admin group lists only the pages the session can actually open (auditors see Users; admins see Users, Partners, and Story Builder).

Server enforcement: firestore.rules isAdmin() (legacy boolean or roles containing 'admin') for stories / partners; roster mutations and the audit log go through the users Cloud Function (GET /roster, PATCH /:uid/claims, GET /audit). Clients never write claims.

The tier custom claim (subscription level) rides the same token — see Pricing and Subscriptions.

ACL: claims are the facts, @stretched/types is the policy​

The workspace-wide access-control model (2026-07-17), built so per-tier and per-role gating works identically in the web app, mobile app, chrome extension, Cloud Functions, and firestore.rules:

  • Facts on the account — auth-token custom claims: tier (one access level per tier = its rank), roles (admin, auditor), optional companyType (unclassified | sponsor | affiliate), sparse grants overrides for permissions with a tier path. Staff-only permissions (minAccessLevel: null) require a listed role; direct grants cannot unlock them. Legacy admin: true is honored as the admin role and kept in sync on write. Claims have a 1,000-byte budget — carry roles, never expanded permission lists.
  • Policy in code — libs/stretched-types/src/acl/acl.ts: the PERMISSIONS registry (each key unlocked by min access level and/or role) and can(claims, key); claimsFromToken normalizes any decoded token.
  • CONTENT vs FUNCTION (2026-07-30) — the registry is split, and the split is spec-enforced. Content permissions (the tier ladder compare/imagine/…/adviser/legacy/gathering .access, plus worth-it.access, news.analysis, insights.spendable, stories.deep) are gated by minAccessLevel only — never roles, so an admin previewing a plan via /account's switcher sees exactly what that plan's members see. Function permissions (stories.manage, partners.review, feedback.feature-request, users.read, users.manage, audit.read) keep roles: staff need them on any previewed plan. users.read / audit.read also list auditor. Before this split every tier permission carried roles: ['admin'], so can() short-circuited on the role, the tier claim never mattered, and the secret Decades/Generations pages stayed visible on every tier. To unlock everything in a test world, grant the generations tier claim, not the admin role. Per-tier limits live next door in entitlements/entitlements.ts (TIER_ENTITLEMENTS, first consumer: the layaway item cap) with a drift spec pinning the firestore.rules mirror.
  • One writer — apps/firebase-functions/src/shared/claims.ts applyAccountClaims(uid, updates): merges + normalizes claims, syncs the legacy admin boolean with the admin role, writes the server-controlled users/{uid}.staffRoles and accessTier authorization markers before minting, and bumps users/{uid}.claimsVersion so clients watching their own doc know to force-refresh the ID token (claims otherwise lag up to ~1h). Owners cannot create or change either server auth field in Firestore rules — only the Admin SDK may. Firestore admin rules require the token role and the current markers, so revocation does not depend on cooperative refresh. Data-only deletion recreates a minimal marker document for the surviving Auth account. On login, ClaimsSyncService detects an absent legacy user document and calls POST /users/authorization; the Function reads current Firebase Auth claims and recreates the marker under the same account lock. A failed claimsVersion bump is retried three times with short backoff. If delivery still fails, the API returns 503, preserves the minted claims and audit rows, and retains a private operation journal for recovery. The subscription route, staff API, and admin CLI tools all use the shared writer. Staff mutations append one audit_logs/{id} document per changed field before the Auth write. Audit rows, subscription changes, and recovery details commit in one Firestore transaction. A definitive Auth validation rejection rolls back the documents; an uncertain Auth transport failure retains the journal and locks so recovery can inspect the actual outcome. Removing admin or auditor revokes refresh tokens both before and after changing claims, closing the window in which a concurrent sign-in could mint a newer privileged token. The required post-mint revoke is journaled before the claims write and retained for recovery if its outcome is uncertain. Staff Function routes verify with checkRevoked and recheck the actor's current Auth claims after acquiring ownership. A failed mutation may require the target to sign in again even if their claims did not change.
  • Coordinated account operations — a Firestore transaction acquires private account_locks/{sha256(uid)} records for every participant. Staff edits lock both actor and target; subscription updates, admin CLI commands, and both deletion routes use the same coordinator. Conflicts return 409 without waiting or partially acquiring locks. Ownership spans the fresh claims read, revocation, audit/subscription writes, Auth mint, refresh notification, and rollback. Independent accounts can proceed concurrently. Locks and the account_operations/{id} journal are deleted on completion. Auth and Firestore remain separate services; interrupted operations stay locked for explicit recovery. There is no automatic lease expiry or timeout takeover. Clients cannot read or write these collections or audit_logs.
  • Live claim refresh — shared ClaimsSyncService, started by the web and mobile app initializers, watches the signed-in user's claimsVersion. It refreshes cached claims on the first versioned snapshot and each changed version, and serializes token requests including retries. A failed refresh gets up to three attempts (one- and two-second backoff) without requiring another Firestore snapshot. Exhaustion is logged; a subsequent snapshot can start another attempt. Account changes, logout, and app destruction cancel pending retries and discard in-flight results from the previous session. The web AuthService.claimsRevision includes these refreshes so navigation and access effects re-read claims. Revoked sessions sign out and require authentication again. Audit reload failures preserve loaded history and pagination; the page shows an error and retry control instead of reporting an empty history. The active /admin/users page rechecks permissions on claim revisions and account changes. Adding admin to an auditor enables the open editor; losing roster access clears data and drafts and redirects home. Responses from old sessions are discarded. A missing user document also starts a bounded authorization-backfill request, covering accounts reset before server-owned markers were introduced. The editor PATCH includes only fields changed from its loaded account; unchanged roles are compared as a set and omitted, just like tier and company type. An unchanged editor sends no request. Successful saves reset its drafts to the server response so a later save cannot inadvertently restore stale roles. Deliberate role edits still replace the role list.
  • The extension reads claims without the SDK by decoding its stored ID token's JWT payload (storedAccountClaims in the account feature).
  • The web app reads them through AuthService.accountClaims() (claimsFromToken over a fresh ID token; isAdmin() rides it). First tier-gated UX consumer: the /stories paid gate via stories.deep — see Story Engine § the public surface. The /worth-it/:slug teasers use worth-it.access the same way (paid/admin sees "you're covered" instead of the upsell CTA).

The user document — users/{uid}​

Owner-only read/write via the client SDK, except claimsVersion, staffRoles, and accessTier, which are Function-only (see Firestore and Security Rules); delete is blocked in rules entirely (see below). Two shapes currently describe it — two rival answers models that have not been merged (a known data decision, flagged in libs/stretched-types/CLAUDE.md; don't add a third):

  1. User (libs/stretched-types/src/user/user.ts) — the "map" model: profile, consents, preferences, subscription (tier, billing, Lemon Squeezy ids), and stories keyed storyKey → year → UserStoryEntry. Answers link to a Story only by a bare string key (story_REF), not a typed reference.
  2. UserDataPayload (libs/stretched-types/src/user/user-data.ts) — the flat-array model used by the settings UI: a discriminated union over encryption state:
type UserDataPayload =
| { encrypted: true; payload: string } // AES-GCM ciphertext of the whole UserData
| { encrypted: false; payload: UserData }; // { stories: StoryEntry[]; encryption?: EncryptionMeta }

Date fields are the in-app shape; Firestore round-trips them as Timestamp — convert at the boundary. See Data Model for the generated ERD.

Encryption model​

Client-side, password-derived; the server never sees a usable key.

  • EncryptionMeta (stored top-level in the user doc when a password is set): salt (random base64, fed into PBKDF2) and check (AES-GCM ciphertext of the sentinel "stretched-verify-v1", so a password can be verified without storing it).
  • EncryptionService (libs/stretched-components/src/services/encryption.service.ts): PBKDF2-HMAC-SHA256 at 600,000 iterations (OWASP 2023 minimum) → 256-bit AES-GCM key with 96-bit IVs. setup() mints meta and unlocks; unlock() verifies against the sentinel; lock() drops the in-memory key; verify() checks a password without mutating state.
  • Individual entries carry their own flag (StoryEntry.encrypted / UserStoryEntry.isEncrypted) — data is a ciphertext string when encrypted, a plain record otherwise.
  • Mobile adds biometric unlock and an unlock route on top of this service — see Mobile App.

Account export and deletion​

Reads and writes go straight through the SDK + rules. Deletes go through the users Cloud Function (apps/firebase-functions/src/users/users.ts). Destructive actions require re-authentication (token issued within 5 minutes, assertRecentAuth). DELETE /users/data and GET /users/export also refuse a token whose auth_time is from an earlier Auth user on this uid (boundAccountCreation, which reads the live Auth user's creation time). Creation and auth_time are compared as whole seconds, so a matching acctNonce still accepts an account created later in that same second. Staff routes (GET /roster, GET /audit, and claims changes) run the same check, then hold the actor's account lock and run it again before they read or write. Roster and audit also reload the actor's Auth claims under that lock, so a demotion that landed first does not return the staff data. Admin plan changes run the same generation check while their lock is held. Both routes repeat that check after the account-operation lock is held, and a token that lost the race receives the same refusal before the replacement account's data is deleted or exported. After deletion, Firestore rules reject that token when its auth_time is before the gate's deletedAtSec, unless it is the same second and acctNonce matches. A createdAtSec from beforeUserCreated is used instead when the blocking event actually included a creation time:

EndpointEffect
DELETE /users/dataDeletes uid-keyed documents except billing_accounts (the subscription keeps charging), including expenses/{uid}, and anonymizes the caller’s audit identifiers.
DELETE /users/accountAnonymizes audit identifiers and deletes uid-keyed data before the Auth account; failure leaves the account available for retry.

GET /users/export includes normalized service-managed Auth claims (tier, roles, grants, legacy admin, companyType, and hasLemonBilling) with the account identity, the billing_accounts document when one exists, and expenses/{uid} when one exists. Its auditLog section contains events where the caller is the actor or target, deduplicating self-edits and converting timestamps to ISO strings. Other participants’ UID/email fields are omitted from this personal export. Audit lookup failures fail the export rather than return incomplete data. A 409 from minting the account nonce refreshes the ID token once and retries the export; a second 409 still fails.

Both deletion endpoints clear the caller’s actor/target UID (empty string) and email (null) in audit_logs, preserving timestamps, changes, and the other participant’s identity. This follows the published delete-or-anonymize retention policy. Queries page through the complete history, with at most 250 updates per batch; failed cleanup can be retried safely before the Auth account is deleted. Both deletion routes acquire account-operation ownership before erasure, so neither can race an unfinished staff or subscription write. Account deletion also closes a private account_deletions/{sha256(uid)} gate. Audit and refresh transactions check this gate and fresh Auth existence. The gate remains closed on deletion failure for retry. Successful deletion clears deleting, records deletedAtSec, and drops the retired account's createdAtSec / acctNonce, so Firestore rules can tell a new session from the previous account's token. Signup stores that nonce in the same transaction that still sees the gate open, and account deletion also removes checkout_nonces/{uid}. The document stays so a later charge can be told from the next account. Successful deletion still removes the operation journal and participant locks.

Recovering an interrupted account operation​

A 409 means another operation owns the account; retry once it completes. A 503 that requests recovery means the operation's outcome or refresh delivery needs attention. Journals retain the target UID, normalized before/after claims, audit document paths, and any previous subscription while recovery is pending; they are private operational data and are removed after reconciliation. Audit rows from an uncertain Auth call must be treated as provisional until that operation is reconciled. No scheduled worker silently retries Auth writes.

  1. Inspect with bun tools/recover-account-operation.ts --project <project-id> --uid <uid>. This prints the journal without changing it. Use an actual Firebase project ID, not a CLI alias. Credentials follow the admin CLI tools.
  2. Stop/drain the original writers, including Functions revisions and any admin CLI processes. A timeout, old timestamp, or expired HTTP connection does not prove an Auth request has stopped. Confirm outstanding Auth calls have settled before continuing. Do not run recovery alongside live writers.
  3. Run bun tools/recover-account-operation.ts --project <project-id> --uid <uid> --recover --confirm-writers-stopped. Recovery atomically takes ownership, compares the stored claims with Auth, and either delivers the refresh and any pending token revocation for a completed mint or rolls back documents for an unminted change. It also completes interrupted deletion. A journal still in cancel-billing retries the Lemon Squeezy cancellation, so that recovery needs LEMON_SQUEEZY_API_KEY in the environment. A journal already in delete does not call Lemon again. Recovery writes that delete phase before it removes the account, so a crash after the deletion is recorded releases the journal instead of starting another generation. Unexpected Auth state remains locked for manual reconciliation; the error identifies the new operation ID, and inspection by UID still finds it.
  4. Re-run inspection to verify no lock remains, then resume writers. Verify the account's claims or deletion and the corresponding audit history.

Do not delete locks by hand or use an outdated admin script: both bypass the coordination guarantee. Recovery never mints claims based on a stale journal.

Contracts: UserApi in libs/stretched-types/src/user/user-data.ts. Correspondingly, firestore.rules has allow delete: if false on users/{userId} — the function's Admin SDK bypasses rules, making its re-auth check the only guard.

Emulator & tests​

bun run emulate seeds test@test.com / testtest with the admin: true custom claim (re-asserted every start, merged into its other claims); the e2e suites run auth flows against a fresh emulator (bun run e2e:emulated) — see Local Development and Testing.

The Chrome extension's auth (REST, no SDK)​

The Chrome Extension signs into the same Firebase Auth project, but over plain REST (identitytoolkit + securetoken endpoints) because it bundles no Firebase SDK: email/password sign-in, registration with optional display name, password-reset emails, and Google via chrome.identity.launchWebAuthFlow -> signInWithIdp. Sessions and refresh tokens live in chrome.storage.local; the resulting ID tokens hit Firestore REST for the layaways/{uid} document (see Firestore and Security Rules). Rules treat REST traffic identically to SDK traffic - no special-casing anywhere.