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 | nobody (Cloud Function only) | client SDK |
stories/{id} | admin claim | admin claim | admin claim | admin clients (builder) |
partners/{uid} | owner or admin claim | 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 only | owner with a Days+ tier claim | nobody (write empty instead) | client SDK |
feedback_limits/{uid} | nobody | nobody | nobody | Admin SDK (Function) |
email_quota/{doc} | 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, write: if request.auth != null && request.auth.uid == userId;
allow delete: if false;
Reads and 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.
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 request.auth != null && request.auth.token.admin == true;
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: the admin: true custom claim on the auth token, set out-of-band via
the Admin SDK — there is no in-app path to grant it.
partners/{userId} — pending-only client writes
allow read: if request.auth != null
&& (request.auth.uid == userId || request.auth.token.admin == true);
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).