Summary
Deploy the M-1 Firestore Security Rules (bind reporter/author to the caller on signal & comment create, block anonymous creation server-side, add basic size/type validation). The rules are written, reviewed, and validated, but deployment is blocked on a client change that must ship in an app release first.
Prod (help-a-paw-dev) currently runs the pre-M-1 (H-1) rules. M-1 was briefly deployed and rolled back on 2026-07-23 after an on-device test exposed the regression below.
Background: the regression that blocks this
M-1 gates create on request.auth.token.email_verified == true (chosen over sign_in_provider != 'anonymous', which is unreliable because the app upgrades anonymous accounts in place via linkWithCredential — the token's sign_in_provider stays 'anonymous' after linking).
email_verified is baked into the ID token at mint time. A user who signs up + verifies (or does an in-place anon→email/Google upgrade) in one session keeps a stale email_verified: false token, so the rule denies their first creates with PERMISSION_DENIED until the token is re-minted. user.reload() does not refresh it, and an app restart within ~1h reuses the still-valid cached token. Device-confirmed: create denied just-after-verify and after restart; succeeded only after sign-out/sign-in.
✅ Blocked by: client token-refresh fix (ship in an app release first)
Force await user.getIdToken(true) after any account-state change that flips email_verified, so the token Firestore reads is current. Two spots:
1. lib/src/widgets/email_verification_page.dart — in _checkEmailVerified(), after verification is detected:
if (refreshedUser?.emailVerified ?? false) {
// reload() updates the User object but NOT the cached ID token that
// Firestore rules read. Force-refresh so email_verified flips to true
// immediately; otherwise the freshly-verified user's first writes are
// denied until the token organically refreshes (~1h) or they re-login.
await refreshedUser!.getIdToken(true);
_timer?.cancel();
if (mounted) {
context.go(Routes.completeProfile);
}
}
2. lib/src/widgets/sign_in_page.dart — in _onCredentialLinked(), the Google branch (skips the verify screen, goes straight into the app):
} else {
// Linked Google (already verified) — user goes straight into the app. The
// in-place link keeps the anonymous-minted ID token, whose email_verified
// claim is still false; force-refresh it so Firestore rules don't deny this
// real user until the token organically refreshes (~1h) or they re-login.
await user.getIdToken(true);
if (context.mounted) context.push(Routes.completeProfile);
}
Then: deploy these M-1 rules
Full firestore.rules to deploy (only after the client fix is in a released build):
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// DocumentReference to the caller's own user doc. Single source of truth
// for every "is this the caller" identity check below.
function userDoc() {
return /databases/$(database)/documents/users/$(request.auth.uid);
}
// True only for durably authenticated callers with a verified email
// (email/password once verified, or Google, which is auto-verified), NOT the
// automatic anonymous sessions the app opens at startup.
//
// We gate on email_verified rather than sign_in_provider because the app
// upgrades anonymous accounts IN PLACE (linkWithCredential, same UID). That
// links a new provider but does not re-authenticate, so the session token's
// `sign_in_provider` stays 'anonymous' after the upgrade — using it here
// would lock out every upgraded (i.e. real) user. `email_verified` is
// derived from account state at token-mint, so it reflects the upgrade and
// also mirrors the app's router gate that blocks unverified users from the
// create UI.
function isNotAnonymous() {
return request.auth != null
&& request.auth.token.email_verified == true;
}
// The signal's reporter (owner), compared by DocumentReference path.
// Used by both the prod and test signal update/delete rules so the
// security-critical ownership check lives in exactly one place.
function isSignalReporter() {
return resource.data.reporter == userDoc();
}
// The caller is the reporter of the parent signal at {coll}/{signalId}.
// Backs the comment-delete cascade for both the prod and test collections,
// so that ownership check lives in one place instead of being inlined twice.
function isParentSignalReporter(coll, signalId) {
return get(/databases/$(database)/documents/$(coll)/$(signalId)).data.reporter
== userDoc();
}
// Validates a signal create (M-1): non-anonymous caller, reporter pinned to
// the caller (no impersonation), and basic type/size bounds on the
// content fields to curb abuse and read/storage-cost inflation.
function isSignalCreate() {
return isNotAnonymous()
&& request.resource.data.reporter == userDoc()
&& request.resource.data.title is string
&& request.resource.data.title.size() > 0
&& request.resource.data.title.size() <= 300
&& request.resource.data.description is string
&& request.resource.data.description.size() <= 10000
&& request.resource.data.signalType is int
&& request.resource.data.signalType >= 0
&& request.resource.data.signalType <= 6;
}
// Validates a comment create (M-1): non-anonymous caller and author pinned
// to the caller. Covers both shapes — user text comments and the
// status_change system comments (which carry no `text`), so the text-size
// cap only applies when a `text` field is present.
function isCommentCreate() {
return isNotAnonymous()
&& request.resource.data.author == userDoc()
&& (
!('text' in request.resource.data)
|| (request.resource.data.text is string
&& request.resource.data.text.size() > 0
&& request.resource.data.text.size() <= 2000)
);
}
// Non-reporters may ONLY advance a signal's status (the volunteer flow),
// and must self-stamp lastUpdatedBy so it can't be spoofed to another user.
function isStatusOnlyUpdate() {
return request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['status', 'lastUpdatedBy'])
&& request.resource.data.lastUpdatedBy == userDoc()
&& request.resource.data.status is int
&& request.resource.data.status >= 0
&& request.resource.data.status <= 2;
}
// Users collection - private profile (tokens, location, prefs, phone).
// Owner-only: never expose to other users.
match /users/{userId} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
// User live location - kept separate from the user doc so high-frequency
// location writes don't trigger the token-dedupe Cloud Function. Owner-only;
// the notification fan-out reads it via the Admin SDK (bypasses rules).
match /userLocations/{userId} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
// Public profiles - just the display name, readable by any signed-in user
// (incl. anonymous) so reporter/comment-author names resolve for everyone.
// Writable only by the owner. On account deletion this is overwritten with
// "Deleted user" so erasure propagates to all signals/comments dynamically.
match /publicProfiles/{userId} {
allow read: if request.auth != null;
allow write: if request.auth != null && request.auth.uid == userId;
}
// Signals collection - public read, authenticated write
match /signals/{signalId} {
allow read: if true; // Public read - signals are public data
allow create: if isSignalCreate();
// Reporter may edit any field; anyone else signed in may only advance the
// status (self-stamping lastUpdatedBy). Prevents non-reporters from
// rewriting reporter/title/phone/photos or taking over a signal.
allow update: if request.auth != null
&& (isSignalReporter() || isStatusOnlyUpdate());
// Only the signal's reporter may delete it
allow delete: if request.auth != null && isSignalReporter();
// Comments subcollection
match /comments/{commentId} {
allow read: if true; // Public read
allow create: if isCommentCreate();
// The signal's reporter may delete comments (enables delete-signal cascade)
allow delete: if request.auth != null
&& isParentSignalReporter('signals', signalId);
}
}
// Test signals collection - same rules as signals
match /signals_test/{signalId} {
allow read: if true;
allow create: if isSignalCreate();
// Same rules as prod signals (see helpers above).
allow update: if request.auth != null
&& (isSignalReporter() || isStatusOnlyUpdate());
allow delete: if request.auth != null && isSignalReporter();
match /comments/{commentId} {
allow read: if true;
allow create: if isCommentCreate();
allow delete: if request.auth != null
&& isParentSignalReporter('signals_test', signalId);
}
}
// Allow collection group queries for comments
match /{path=**}/comments/{commentId} {
allow read: if request.auth != null;
}
// Feedback collection - anyone can submit, only admins can read
match /feedback/{feedbackId} {
allow create: if request.resource.data.message is string
&& request.resource.data.message.size() > 0
&& request.resource.data.message.size() <= 1000
&& request.resource.data.type in ['general', 'bug', 'feature', 'other']
&& (!('email' in request.resource.data) || request.resource.data.email == null || request.resource.data.email is string);
allow read, update, delete: if false; // Only accessible via Admin SDK/Console
}
}
}
Deploy & verify
- Confirm the client
getIdToken(true) fix is in the released app build (existing installs won't get it otherwise).
firebase deploy --only firestore:rules --project help-a-paw-dev (⚠️ help-a-paw-dev is production).
- Device-verify in test mode (tap the app title 7× → writes to
signals_test):
- Fresh anon → blocked from creating (client dialog).
- Register a new account via
helpapaw.qa+<alias>@gmail.com (true in-place anon→email link), verify email → create a signal → succeeds (no PERMISSION_DENIED).
- Google in-place upgrade → create → succeeds.
- Commit
firestore.rules to git (the M-1 change is currently uncommitted).
Notes / non-blocking follow-ups (from code review)
- Size caps have no matching client
maxLength → long-but-legit input gets an opaque PERMISSION_DENIED. Consider adding maxLength on the create form.
- Size validation is on
create only; the reporter update path is unbounded (bypassable). Acceptable for now (self-owned docs).
- Empty comment now rejected server-side but the send button has no empty-text guard.
signalType <= 6 hardcodes the enum bound; bump if a 7th type is added.
Summary
Deploy the M-1 Firestore Security Rules (bind
reporter/authorto the caller on signal & commentcreate, block anonymous creation server-side, add basic size/type validation). The rules are written, reviewed, and validated, but deployment is blocked on a client change that must ship in an app release first.Prod (
help-a-paw-dev) currently runs the pre-M-1 (H-1) rules. M-1 was briefly deployed and rolled back on 2026-07-23 after an on-device test exposed the regression below.Background: the regression that blocks this
M-1 gates create on
request.auth.token.email_verified == true(chosen oversign_in_provider != 'anonymous', which is unreliable because the app upgrades anonymous accounts in place vialinkWithCredential— the token'ssign_in_providerstays'anonymous'after linking).email_verifiedis baked into the ID token at mint time. A user who signs up + verifies (or does an in-place anon→email/Google upgrade) in one session keeps a staleemail_verified: falsetoken, so the rule denies their first creates withPERMISSION_DENIEDuntil the token is re-minted.user.reload()does not refresh it, and an app restart within ~1h reuses the still-valid cached token. Device-confirmed: create denied just-after-verify and after restart; succeeded only after sign-out/sign-in.✅ Blocked by: client token-refresh fix (ship in an app release first)
Force
await user.getIdToken(true)after any account-state change that flipsemail_verified, so the token Firestore reads is current. Two spots:1.
lib/src/widgets/email_verification_page.dart— in_checkEmailVerified(), after verification is detected:2.
lib/src/widgets/sign_in_page.dart— in_onCredentialLinked(), the Google branch (skips the verify screen, goes straight into the app):Then: deploy these M-1 rules
Full
firestore.rulesto deploy (only after the client fix is in a released build):Deploy & verify
getIdToken(true)fix is in the released app build (existing installs won't get it otherwise).firebase deploy --only firestore:rules --project help-a-paw-dev(help-a-paw-devis production).signals_test):helpapaw.qa+<alias>@gmail.com(true in-place anon→email link), verify email → create a signal → succeeds (noPERMISSION_DENIED).firestore.rulesto git (the M-1 change is currently uncommitted).Notes / non-blocking follow-ups (from code review)
maxLength→ long-but-legit input gets an opaquePERMISSION_DENIED. Consider addingmaxLengthon the create form.createonly; the reporterupdatepath is unbounded (bypassable). Acceptable for now (self-owned docs).signalType <= 6hardcodes the enum bound; bump if a 7th type is added.