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
| Piece | Where | What it does |
|---|---|---|
AuthUser | libs/stretched-types/src/auth/auth.ts | The subset of the Firebase Auth user the app consumes (uid, email, displayName, photoURL, emailVerified, isAnonymous). |
AuthService | apps/stretched/src/app/core/services/auth.service.ts | Thin wrapper: currentUser$ (authState) + isAdmin(), which awaits authStateReady() before reading claims so a cold page load doesn't report false prematurely. |
UserService | libs/stretched-components/src/services/user.service.ts | The 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). |
UserLogin | libs/stretched-components/src/organisms/user/user-login/user-login.ts | sc-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
rolesclaim):admin,auditor. - Tier stays the
tierclaim — 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: trueis kept in sync with theadminrole byapplyAccountClaims.
Client UX:
adminGuardgates/admin/story-builderand/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), optionalcompanyType(unclassified|sponsor|affiliate), sparsegrantsoverrides for permissions with a tier path. Staff-only permissions (minAccessLevel: null) require a listed role; direct grants cannot unlock them. Legacyadmin: trueis 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: thePERMISSIONSregistry (each key unlocked by min access level and/or role) andcan(claims, key);claimsFromTokennormalizes 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, plusworth-it.access,news.analysis,insights.spendable,stories.deep) are gated byminAccessLevelonly — neverroles, 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) keeproles: staff need them on any previewed plan.users.read/audit.readalso listauditor. Before this split every tier permission carriedroles: ['admin'], socan()short-circuited on the role, thetierclaim never mattered, and the secret Decades/Generations pages stayed visible on every tier. To unlock everything in a test world, grant thegenerationstier claim, not the admin role. Per-tier limits live next door inentitlements/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.tsapplyAccountClaims(uid, updates): merges + normalizes claims, syncs the legacyadminboolean with theadminrole, writes the server-controlledusers/{uid}.staffRolesandaccessTierauthorization markers before minting, and bumpsusers/{uid}.claimsVersionso 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,ClaimsSyncServicedetects an absent legacy user document and callsPOST /users/authorization; the Function reads current Firebase Auth claims and recreates the marker under the same account lock. A failedclaimsVersionbump 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 oneaudit_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. Removingadminorauditorrevokes 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 withcheckRevokedand 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 theaccount_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 oraudit_logs. - Live claim refresh — shared
ClaimsSyncService, started by the web and mobile app initializers, watches the signed-in user'sclaimsVersion. 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 webAuthService.claimsRevisionincludes 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/userspage 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 (
storedAccountClaimsin the account feature). - The web app reads them through
AuthService.accountClaims()(claimsFromTokenover a fresh ID token;isAdmin()rides it). First tier-gated UX consumer: the/storiespaid gate viastories.deep— see Story Engine § the public surface. The/worth-it/:slugteasers useworth-it.accessthe 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):
User(libs/stretched-types/src/user/user.ts) — the "map" model: profile, consents, preferences,subscription(tier, billing, Lemon Squeezy ids), andstorieskeyedstoryKey → year → UserStoryEntry. Answers link to aStoryonly by a bare string key (story_REF), not a typed reference.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) andcheck(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) —datais 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:
| Endpoint | Effect |
|---|---|
DELETE /users/data | Deletes uid-keyed documents except billing_accounts (the subscription keeps charging), including expenses/{uid}, and anonymizes the caller’s audit identifiers. |
DELETE /users/account | Anonymizes 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.
- 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. - 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.
- 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 incancel-billingretries the Lemon Squeezy cancellation, so that recovery needsLEMON_SQUEEZY_API_KEYin the environment. A journal already indeletedoes not call Lemon again. Recovery writes thatdeletephase 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. - 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.