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: the admin: true custom claim
Set out-of-band via the Admin SDK — there is no in-app path to grant it.
- Client UX:
adminGuard(apps/stretched/src/app/core/guards/admin.guard.ts) gates/admin/story-builder(see Page Builder); non-admins are redirected home. The guard is the UX layer, not the security boundary. - Server enforcement:
firestore.rulesrequires the same claim for thestoriescollection and for admin reads ofpartners/*— see Firestore and Security Rules and Partner Program.
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(e.g.admin), sparsegrantsoverrides. Legacyadmin: trueis honored as the admin role. 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) keeproles: ['admin']: staff need them on any previewed plan. 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 and bumpsusers/{uid}.claimsVersionso clients watching their own doc know to force-refresh the ID token (claims otherwise lag up to ~1h). The Stripe webhook (pending) and admin tooling call this; nothing else may. - 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 (allow read, write: if request.auth.uid == userId); 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, Stripe 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.
Deletion — the only Function-mediated path
Reads and writes go straight through the SDK + rules; deletes go through the users Cloud Function (apps/firebase-functions/src/users/users.ts) because rules cannot inspect token issue time, and destructive actions require re-authentication (token issued within 5 minutes, assertRecentAuth):
| Endpoint | Effect |
|---|---|
DELETE /users/data | Deletes only the Firestore document. |
DELETE /users/account | Deletes the Firestore document then the Firebase Auth account — data first, so a failed data delete leaves the account intact for retry. |
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.