Firestore and Security Rules
See also: Data Model, Auth and Users, Partner Program, Feedback System
All Firebase security rules live in libs/firebase-permissions/ —
firebase.json points there. The root-level firestore.rules is a stub that
says so; never edit it. Document shapes referenced below are defined in
@stretched/types (see Data Model).
The posture is deny by default: a catch-all
match /{document=**} { allow read, write: if false; } closes every collection
not explicitly matched. Add new match blocks above the catch-all — never
widen it.
Collections at a glance
| Collection | Read | Create/Update | Delete | Written by |
|---|---|---|---|---|
users/{uid} | owner only | owner only (not server auth fields) | nobody (Cloud Function only) | client SDK + Admin SDK |
stories/{id} | isAdmin() | isAdmin() | isAdmin() | admin clients (builder) |
partners/{uid} | owner or isAdmin() | owner, and only with status == 'pending' | nobody | client SDK + Admin SDK |
layaways/{uid} | owner only | owner only | nobody (write empty instead) | Chrome extension (REST) |
expenses/{uid} | owner, and not a retired account's token | owner with a Days+ tier claim, same gate as layaways | nobody (write empty instead) | client SDK |
feedback_limits/{uid} | nobody | nobody | nobody | Admin SDK (Function) |
email_quota/{doc} | nobody | nobody | nobody | Admin SDK (Function) |
audit_logs/{id} | nobody | nobody | nobody | Admin SDK (Function) |
account_locks/{key} | nobody | nobody | nobody | Admin SDK (Function/CLI) |
account_operations/{id} | nobody | nobody | nobody | Admin SDK (Function/CLI) |
account_deletions/{key} | nobody | nobody | nobody | Admin SDK (Function) |
| everything else | nobody | nobody | nobody | — |
users/{uid} — owner-only user data
One document per user, keyed by Firebase Auth UID (shape: UserDataPayload /
User — see the two rival models in Data Model).
allow read: if request.auth != null && request.auth.uid == userId;
allow create: if owner && no admin claim
&& no server authorization field is present;
allow update: if owner
&& !request.resource.data.diff(resource.data)
.affectedKeys()
.hasAny(['claimsVersion', 'staffRoles', 'accessTier', 'subscription']);
allow delete: if false;
Reads and most profile writes go directly from the client via the Firebase SDK (the Angular SDK attaches the ID token automatically); no Function wraps them. The document is opaque to the backend — encryption, when enabled, is client-side AES-GCM/PBKDF2.
claimsVersion, staffRoles, accessTier, and subscription are Function-only.
Lemon Squeezy customer and subscription ids used for portal and plan changes
live in billing_accounts/{uid}, which clients cannot read or write. The
subscription field on the user document is the display copy of that state.
applyAccountClaims bumps the version so ClaimsSyncService force-refreshes
the ID token, and writes current role and tier state before minting claims.
Admin, layaway-limit, and expense-import rules require both the token privilege
and current server state, so a demoted account cannot use its cached JWT. Existing
documents without the markers retain legacy access until their next claims
write. Data-only deletion recreates a minimal marker document before returning;
client creation with an admin-bearing token remains blocked so a deleted Auth
account cannot recreate legacy admin state. On login, ClaimsSyncService asks
POST /users/authorization to backfill any document deleted before that repair
shipped; the Function derives markers from the current Auth record. Owners must not change these fields:
poisoning the version (for example writing
Number.MAX_SAFE_INTEGER) can make the next increment leave a non-safe
integer that the client ignores. The protected role marker independently keeps
that stale JWT out of protected Firestore paths while the client refresh catches up.
Why deletes go through a Cloud Function
Client-side delete is blocked outright because Firestore rules cannot inspect
auth_time (when the token was issued), so "recently re-authenticated"
cannot be enforced at the rules layer. Instead:
- The client calls
DELETE /users/data(Firestore doc only) orDELETE /users/account(doc + the Firebase Auth account) — contracts inUserApi(libs/stretched-types/src/user/user-data.ts). - The Function's
assertRecentAuth()(apps/firebase-functions/src/shared/auth.ts) rejects tokens older than 5 minutes (auth_timecheck), forcing an explicit re-login. - It then deletes via the Admin SDK, which bypasses rules — so the Function's re-auth check is the only guard on destructive operations, which is exactly the intent.
See Auth and Users for the re-auth flow.
stories/{storyId} — admin authoring surface
allow read, write: if isAdmin();
Authored story templates (Story in
libs/stretched-types/src/story/story.ts). This is an admin authoring
surface for /admin/story-builder (see Page Builder), not a runtime
read path: published definitions are served as static JSON/CDN, so regular
clients never touch this collection (see Story Engine).
Gate: isAdmin() — the legacy admin: true custom claim or a
roles array containing 'admin', and accountAllowsToken for that uid (the same creation-second and acctNonce check owner rules use, which a missing accessTier or a staff claim does not skip). The first production admin is still
minted out-of-band via the Admin SDK; further grants happen in-app at
/admin/users (see Auth and Users).
Account operation locks and journals
account_locks/{sha256(uid)} contains an operation owner ID.
account_operations/{id} contains its participants' hashed keys, progress,
and the recovery data needed to reconcile claims, audit rows, and subscription
state. Only Admin SDK callers can access either collection. They have no TTL:
an interrupted Auth writer cannot safely be replaced just because time elapsed.
Both records are removed on completion or successful operator recovery.
All account mutation/deletion paths share this coordinator; a conflicting
request receives 409. See Auth and Users for recovery instructions.
account_deletions/{key} — private deletion gate
The users Function writes this document under a lowercase SHA-256 UID key before account
cleanup, and rules lowercase toHexString() so they read that same document. Staff audit transactions read the actor and target gates and verify
that both Auth accounts still exist. A closed gate (deleting) blocks client
writes and audit creation until deletion finishes; failed deletion stays
retryable. Finishing clears deleting and stores deletedAtSec. Rules allow
the uid only when auth_time is after that second, or the same second and
token.acctNonce matches the nonce stored by beforeUserCreated. An allowed
signup always stores that nonce. createdAtSec is written only when the
blocking event actually includes a creation time — the Auth user is not saved
yet, so a missing stamp is not invented. Client reads and writes of the gate itself are denied.
audit_logs/{id} — Function-only claims audit
allow read, write: if false;
One auto-id document per staff mutation of an account's claims (roles.set,
tier.set, companyType.set). Written exclusively by
PATCH /users/:uid/claims; staff read the log through GET /users/audit.
Shape: AuditLog in libs/stretched-types/src/audit/audit.ts.
Personal exports include matching actor/target events without other participants’
identifiers. Data/account deletion anonymizes only the caller’s UID and email
on both sides, retaining the event and the other participant’s history. These
operations use the users Function; direct client access remains denied.
partners/{userId} — pending-only client writes
allow read: if request.auth != null
&& (request.auth.uid == userId || isAdmin());
allow create, update: if request.auth != null
&& request.auth.uid == userId
&& request.resource.data.status == 'pending';
allow delete: if false;
Corporate signups (PartnerAccount, stored at partners/{ownerUid} — see
Partner Program). The trick is the status gate: owners can create and edit
their own application, but every client write must carry
status: 'pending' — so a partner can never self-approve, and any edit to an
approved application knocks it back to pending. approved / rejected are set
out-of-band via the Admin SDK (which bypasses rules), the same pattern as
the admin claim. Admins may read all applications for review.
layaways/{userId} — the extension's "sleep on it" list
allow read: if owner
allow create: if owner && request.resource.data.items.size() <= layawayMaxItems()
allow update: if owner && (request.resource.data.items.size() <= layawayMaxItems()
|| request.resource.data.items.size() <= resource.data.items.size())
allow delete: if false;
One document per user (LayawayDoc in
libs/stretched-types/src/layaway/layaway.ts) holding the prices a user
captured in the Chrome Extension to think over before buying.
Deliberately its own collection rather than a field on users/{uid}:
it's account-tied, but not part of the core profile document — the profile
stays lean, and the extension never needs (or gets) access to profile data.
The item count is tier-capped via the tier custom claim (free 10, paid
200 — the enforcing mirror of TIER_ENTITLEMENTS in @stretched/types
entitlements/; a drift spec in that lib pins the rules text against the
map). Downgrade policy is keep-but-block-adds: an over-limit list may shrink
or hold, never grow. See Auth and Users for the claims/ACL model.
The extension writes it over the Firestore REST API with the user's ID
token (it bundles no Firebase SDK), which the rules treat identically to SDK
traffic. Deletes are blocked; clearing the list means writing an empty
items array.
Feature flags no longer live in Firestore. The former
config/feature-flagsdocument (and its world-readable rule) was retired 2026-07-22 when release flags consolidated ontolibs/feature-flags+ Firebase Remote Config — see Feature Flags.
feedback_limits/{userId} and email_quota/{document} — Admin-SDK-only
allow read, write: if false;
Both are server-side bookkeeping for the Feedback System, written exclusively by the feedback Function via the Admin SDK; no client ever reads or writes them:
feedback_limits/{userId}— per-user rate-limit state.email_quota/global— a single document counting today's Resend sends (internal cap 80/day under the 100/day free tier — see Secrets Management).
Their document shapes are private to apps/firebase-functions — intentionally
absent from @stretched/types and the Data Model diagram.
Other rules files
libs/firebase-permissions/storage.rules— deny-all placeholder.
There is deliberately no Realtime Database rules file (removed 2026-07-31):
RTDB is a separate, legacy product nothing in the repo uses, and wiring it in
firebase.json makes a bare firebase deploy fail against projects with no
RTDB instance. All data goes to Firestore.
Deploying
Rules deploy alongside the app (firebase deploy), or alone:
firebase deploy --only firestore:rules
The local emulator picks them up automatically — see Local Development.
Checklist: adding a collection
- Define the document type in
@stretched/typeswith astored at collection/{id}note (see Data Model). - Add a
matchblock inlibs/firebase-permissions/firestore.rulesabove the catch-all, in the same change. - Regenerate the ER diagram (
bun run types:erd).