Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
fe6ce10
chore(staging): record pilot project setup (O3–O6)
Bogdan0708 Sep 23, 2026
4e0bc20
chore(staging): record Places key and member password setup
Bogdan0708 Sep 23, 2026
4aacea9
docs(spec): add §3.7 Plan 4 design (shortlist, visited, notes)
Bogdan0708 Sep 24, 2026
6ad9c23
docs(spec): amend §3.7 after the Plan 4 design review (F1–F3)
Bogdan0708 Sep 24, 2026
0dafaac
docs(plan): Plan 4 implementation plan — shortlist, visited, notes
Bogdan0708 Sep 24, 2026
b9c6a7f
feat(rules): shortlist/visited state and authored notes
Bogdan0708 Sep 24, 2026
1d4f61c
feat(records): deletion completion gate and convergent sweeps
Bogdan0708 Sep 24, 2026
1f7f044
feat(records): shortlist/visited and notes data layer
Bogdan0708 Sep 24, 2026
5b2f79c
feat(saved): shortlist filter, visited labels and joined read states
Bogdan0708 Sep 24, 2026
8f016aa
feat(records): shortlist and visited controls on the restaurant page
Bogdan0708 Sep 24, 2026
c7b41e2
feat(records): authored notes on the restaurant page
Bogdan0708 Sep 24, 2026
ff8217b
feat(auth): reset every tab when its user signs out or changes
Bogdan0708 Sep 24, 2026
bc7dd91
feat(settings): change password with policy-aware errors
Bogdan0708 Sep 24, 2026
d778cd9
test(e2e): shortlist, visited, notes, deletion gate and note conflicts
Bogdan0708 Sep 24, 2026
1e420d8
test(e2e): cross-tab reset and change password; README and suite budget
Bogdan0708 Sep 24, 2026
a6451a7
test(e2e): stabilise discover supersede wait and sign-in reload waits
Bogdan0708 Sep 24, 2026
308a8eb
fix(records,auth): show live state after a conflicted date save; requ…
Bogdan0708 Sep 24, 2026
74bf161
docs,test(functions): document deploy order; align deletion mirror guard
Bogdan0708 Sep 24, 2026
47646ea
docs(audit): Plan 4 execution ledger
Bogdan0708 Sep 24, 2026
19d8a0a
docs(audit): add Plan 4 implementation audit and reproduction probes
Bogdan0708 Sep 24, 2026
b09ddbd
fix(records): keep a visit-date draft on the version it was opened at
Bogdan0708 Sep 24, 2026
c84f931
fix(records): lock a note's text box while its save is in flight
Bogdan0708 Sep 24, 2026
638eaa9
fix(auth): report an uncertain outcome when a password change fails a…
Bogdan0708 Sep 24, 2026
9290496
test(auth),docs: prove no reload on password change; refresh test counts
Bogdan0708 Sep 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .firebaserc
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"projects": {
"default": "demo-safebite"
"default": "demo-safebite",
"staging": "safebite-pilot-urfs3v"
}
}
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ npm --prefix web run dev # terminal 2: http://127.0.0.1:5173

> Discovery (the Discover tab) calls the `searchDestination` / `searchNearby` functions, which read the `PLACES_API_KEY` secret. Locally the emulator reads `functions/.secret.local` (gitignored); the `emu:*` scripts create it from `functions/.secret.local.example` (`PLACES_API_KEY=fixture`) when it is missing, which selects a fixture provider with twelve invented venues and the magic queries `__empty__`, `__unavailable__`, `__quota__`, `__delayed__` (3 s) and `__slow__` (25 s). To try real results locally, put a key restricted to Places API (New) in `.secret.local` — never commit it. Search is off until `config/discovery` exists (`{ enabled: true, dailySearchCap: 50 }`, written by `npm run emu:seed`).

Saved shows the household's shortlist by default; "All records" shows everything. Each restaurant has shortlist and visited controls plus "Our notes": notes are personal and never evidence. Deleting a restaurant also deletes both members' notes and its shortlist state. Settings has "Change password".

Emulator UI: http://127.0.0.1:4000
Records live under households/home/restaurants in the emulator; `npm run emu:e2e` clears them before each scenario via the emulator's REST API.

Expand All @@ -202,7 +204,7 @@ npm run typecheck # both packages
npm run test:unit # web unit tests (no emulator)
npm run emu:test # functions + Firestore rules tests (starts emulators)
npm run emu:e2e # Playwright browser tests (starts emulators, seeds, runs Vite)
npm run emu:e2e:stress # 29 browser scenarios × 3 repeats, retries disabled (flakiness gate)
npm run emu:e2e:stress # 42 browser scenarios × 3 repeats, retries disabled (flakiness gate)
npm --prefix web run build:check # compile-only build (no Firebase config needed)
npm --prefix web run build:e2e # builds the three synthetic bundles: dist-preview, dist-preview-v2, dist-boot-guard (fixtures in web/.env.preview, .env.preview-v2, .env.boot-guard)
npm --prefix web run e2e:boot-guard # compile-only bundle with demo values refuses to start (Chromium, no emulators)
Expand All @@ -211,11 +213,12 @@ npm --prefix web run e2e:upgrade # same-origin release upgrades and the upd
npm --prefix web run icons # re-render the PNG icon set from web/assets/safebite-mark.svg
```

`npm run test:unit` currently reports 270 tests.
`npm run test:unit` currently reports 361 tests. `npm run emu:test` currently reports 352 tests (functions + rules). `npm run emu:e2e` currently reports 42 browser scenarios.

### Guardrails

- Local work targets the emulator-only project `demo-safebite`. Nothing here deploys.
- Deploy order (spec §3.7): Firestore rules and functions first, then hosting; the deletion completion gate protects older cached clients.
- Membership (`users/{uid}`, `households/{hid}`) is written only with the Admin SDK; there is no sign-up.
- Never reuse the legacy seed data from git history; its safety claims were invented.
- `npm --prefix web run build` (used by `firebase deploy`) refuses missing, blank, demo-, or legacy-project Firebase values; the resulting bundle also refuses to start against them.
Expand Down
105 changes: 97 additions & 8 deletions firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ service cloud.firestore {
return signedIn() && request.auth.uid in household(hid).data.memberIds;
}

// The caller's own users/{uid} document. Rules get() is not subject to the read rules, so no
// peer-user read is introduced. Used wherever a stored display name must be the writer's own.
function callerName() {
return get(/databases/$(database)/documents/users/$(request.auth.uid)).data.displayName;
}

// ---- field validators (limits mirror web/src/records/validation.ts LIMITS) ----
function nonBlankString(v, max) {
return v is string && v.trim() != '' && v.size() <= max;
Expand All @@ -39,7 +45,7 @@ service cloud.firestore {
}

function validRestaurant(data) {
return data.keys().hasOnly(['name', 'address', 'phone', 'website', 'lat', 'lng', 'googlePlaceId', 'createdBy', 'createdAt', 'updatedAt', 'version', 'deleting'])
return data.keys().hasOnly(['name', 'address', 'phone', 'website', 'lat', 'lng', 'googlePlaceId', 'createdBy', 'createdAt', 'updatedAt', 'version', 'deleting', 'cleanupDone'])
&& data.keys().hasAll(['name', 'address', 'createdBy', 'createdAt', 'updatedAt', 'version', 'deleting'])
&& nonBlankString(data.name, 120)
&& nonBlankString(data.address, 300)
Expand All @@ -51,7 +57,8 @@ service cloud.firestore {
&& data.createdAt is timestamp
&& data.updatedAt is timestamp
&& data.version is int
&& data.deleting is bool;
&& data.deleting is bool
&& (!('cleanupDone' in data) || data.cleanupDone is bool);
}

// Deletion protocol step 1 (spec §3.5): the mark changes only these keys.
Expand All @@ -60,6 +67,16 @@ service cloud.firestore {
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['deleting', 'version', 'updatedAt']);
}

// Deletion protocol, completion gate (spec §3.7, audit F1): set only after the client's claim
// and note sweeps returned empty from the server and the collection document is gone. Nothing
// can be created under a marked restaurant, so the flag cannot go stale.
function isMarkingCleanupDone() {
return resource.data.deleting == true
&& resource.data.get('cleanupDone', false) == false
&& request.resource.data.get('cleanupDone', false) == true
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['cleanupDone', 'version', 'updatedAt']);
}

// Calendar dates are timestamps at 00:00:00 UTC (spec §3.5, audit F3).
function utcMidnight(ts) {
return ts is timestamp && ts == ts.date();
Expand Down Expand Up @@ -108,25 +125,32 @@ service cloud.firestore {

allow create: if isMember(hid)
&& validRestaurant(request.resource.data)
&& !('cleanupDone' in request.resource.data)
&& request.resource.data.createdBy == request.auth.uid
&& request.resource.data.version == 1
&& request.resource.data.deleting == false
&& request.resource.data.createdAt == request.time
&& request.resource.data.updatedAt == request.time;

// Optimistic concurrency: exactly the next version, server-stamped. Once deleting is true
// nothing may change until the document is removed (so a claim sweep cannot be undercut).
// the only permitted change is marking cleanup done (so a claim sweep cannot be undercut).
allow update: if isMember(hid)
&& validRestaurant(request.resource.data)
&& resource.data.deleting == false
&& request.resource.data.createdBy == resource.data.createdBy
&& request.resource.data.createdAt == resource.data.createdAt
&& request.resource.data.updatedAt == request.time
&& request.resource.data.version == resource.data.version + 1
&& (request.resource.data.deleting == false || isMarkingDeleting());

// Step 3 of the protocol: only a marked restaurant can go.
allow delete: if isMember(hid) && resource.data.deleting == true;
&& ((resource.data.deleting == false
&& !('cleanupDone' in request.resource.data)
&& (request.resource.data.deleting == false || isMarkingDeleting()))
|| isMarkingCleanupDone());

// Final step: only a marked restaurant whose cleanup is done and whose collection
// document is gone. A client that skips the note/state sweep cannot pass this.
allow delete: if isMember(hid)
&& resource.data.deleting == true
&& resource.data.get('cleanupDone', false) == true
&& !exists(/databases/$(database)/documents/households/$(hid)/collection/$(rid));

match /claims/{cid} {
function parentRestaurant() {
Expand All @@ -153,6 +177,71 @@ service cloud.firestore {
allow update: if false;
allow delete: if isMember(hid);
}

// Authored notes (spec §3.7). Only the author edits; the author deletes at any time, and
// any member may delete once the restaurant is marked deleting (deletion sweep).
match /notes/{nid} {
function noteParent() {
return get(/databases/$(database)/documents/households/$(hid)/restaurants/$(rid));
}

allow read: if isMember(hid);

allow create: if isMember(hid)
&& exists(/databases/$(database)/documents/households/$(hid)/restaurants/$(rid))
&& noteParent().data.deleting == false
&& request.resource.data.keys().hasOnly(['text', 'authorUid', 'authorName', 'createdAt', 'updatedAt', 'version'])
&& request.resource.data.keys().hasAll(['text', 'authorUid', 'authorName', 'createdAt', 'updatedAt', 'version'])
&& nonBlankString(request.resource.data.text, 2000)
&& request.resource.data.authorUid == request.auth.uid
&& request.resource.data.authorName == callerName()
&& request.resource.data.createdAt == request.time
&& request.resource.data.updatedAt == request.time
&& request.resource.data.version == 1;

allow update: if isMember(hid)
&& resource.data.authorUid == request.auth.uid
&& noteParent().data.deleting == false
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['text', 'updatedAt', 'version'])
&& nonBlankString(request.resource.data.text, 2000)
&& request.resource.data.updatedAt == request.time
&& request.resource.data.version == resource.data.version + 1;

// Author first: `||` short-circuits, so an author can delete even if the parent is gone.
allow delete: if isMember(hid)
&& (resource.data.authorUid == request.auth.uid || noteParent().data.deleting == true);
}
}

// Shortlist and visited state, one document per restaurant, id = restaurant id (spec §3.7).
match /collection/{rid} {
function stateParentPath() {
return /databases/$(database)/documents/households/$(hid)/restaurants/$(rid);
}
function liveParent() {
return exists(stateParentPath()) && get(stateParentPath()).data.deleting == false;
}
function validState(data) {
return data.keys().hasOnly(['shortlisted', 'visited', 'visitedOn', 'updatedBy', 'updatedByName', 'updatedAt', 'version'])
&& data.keys().hasAll(['shortlisted', 'visited', 'updatedBy', 'updatedByName', 'updatedAt', 'version'])
&& data.shortlisted is bool
&& data.visited is bool
&& data.visited == ('visitedOn' in data)
&& (!('visitedOn' in data)
|| (utcMidnight(data.visitedOn) && data.visitedOn <= request.time + duration.value(1, 'd')))
&& data.updatedBy == request.auth.uid
&& data.updatedByName == callerName()
&& data.updatedAt == request.time
&& data.version is int;
}

allow read: if isMember(hid);
allow create: if isMember(hid) && liveParent() && validState(request.resource.data)
&& request.resource.data.version == 1;
allow update: if isMember(hid) && liveParent() && validState(request.resource.data)
&& request.resource.data.version == resource.data.version + 1;
// Deletion sweep only: the restaurant must exist and be marked deleting.
allow delete: if isMember(hid) && exists(stateParentPath()) && get(stateParentPath()).data.deleting == true;
}
}
}
Expand Down
Loading
Loading