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 onlynobody (Cloud Function only)client SDK
stories/{id}admin claimadmin claimadmin claimadmin clients (builder)
partners/{uid}owner or admin claimowner, and only with status == 'pending'nobodyclient SDK + Admin SDK
layaways/{uid}owner onlyowner onlynobody (write empty instead)Chrome extension (REST)
expenses/{uid}owner onlyowner with a Days+ tier claimnobody (write empty instead)client SDK
feedback_limits/{uid}nobodynobodynobodyAdmin SDK (Function)
email_quota/{doc}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, 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:

  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 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-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).