Skip to main content

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​

CollectionReadCreate/UpdateDeleteWritten by
users/{uid}owner onlyowner 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'nobodyclient SDK + Admin SDK
layaways/{uid}owner onlyowner onlynobody (write empty instead)Chrome extension (REST)
expenses/{uid}owner, and not a retired account's tokenowner with a Days+ tier claim, same gate as layawaysnobody (write empty instead)client SDK
feedback_limits/{uid}nobodynobodynobodyAdmin SDK (Function)
email_quota/{doc}nobodynobodynobodyAdmin SDK (Function)
audit_logs/{id}nobodynobodynobodyAdmin SDK (Function)
account_locks/{key}nobodynobodynobodyAdmin SDK (Function/CLI)
account_operations/{id}nobodynobodynobodyAdmin SDK (Function/CLI)
account_deletions/{key}nobodynobodynobodyAdmin SDK (Function)
everything elsenobodynobodynobody—

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:

  1. The client calls DELETE /users/data (Firestore doc only) or DELETE /users/account (doc + the Firebase Auth account) — contracts in UserApi (libs/stretched-types/src/user/user-data.ts).
  2. The Function's assertRecentAuth() (apps/firebase-functions/src/shared/auth.ts) rejects tokens older than 5 minutes (auth_time check), forcing an explicit re-login.
  3. 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-flags document (and its world-readable rule) was retired 2026-07-22 when release flags consolidated onto libs/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​

  1. Define the document type in @stretched/types with a stored at collection/{id} note (see Data Model).
  2. Add a match block in libs/firebase-permissions/firestore.rules above the catch-all, in the same change.
  3. Regenerate the ER diagram (bun run types:erd).