Skip to content

Block functionality: enforcement-gap closure (issue #144) โ€‹

Date: 2026-08-01 (Phases 1 + 2 implemented 2026-08-02) Status: Phase 1 (Chat sever) + Phase 2 (location-leak purge) IMPLEMENTED; Phases 3-4 pending. Operator design decision resolved (sever, see section 5). Issue: #144 (Safety: Block Functionality). Related: #130 (safety education), #523 (privacy hardening tracker). Extends: docs/planning/specs/2026-07-26-block-pattern-detection-design.md (the pattern-detection half, shipped) and docs/features/safety/SAFETY_MECHANICS.md (Feature 1). Skills consulted: planning, privacy-architecture.

1. Why this doc exists โ€‹

A four-agent audit on 2026-08-01 found #144 is roughly three-quarters built and its issue checklist is stale (a pinned owner audit already noted this on 2026-07-26). The block data model, cross-device sync, UI entry points, pattern detection, the moderation queue, security rules, and retention are all shipped. What is NOT uniformly done is the headline safety property: bidirectional invisibility. It is enforced on the discovery/reach surfaces and missing on several post-connection read surfaces. This doc scopes closing those gaps. It is not a rebuild.

2. Current state (audited 2026-08-01) โ€‹

Identity/security prerequisites: settled. Sealed identity is live in dev (Stage A + B both on). Blocks key off userId under the blocker's own tree (users/{uid}/blocks/{blockedUid}), which is sealed-safe (the seal protects phone-to-userId at rest, not a user's normal knowledge of peers). Block lists are owner-only and shape-locked in firestore.rules; moderationSignals are fully server-only; the abuse-signal path uses HMAC event ids and never co-locates a phoneHash with a userId. No identity work blocks this.

A key architectural finding. The already-enforced surfaces do NOT use security rules; they route the read through a lanterns-api Cloud Run endpoint that filters in code via block.service.js (isBlockedEitherDirection, getBlockedSet, getHiddenUids, all Admin SDK, both directions). This is deliberate: Firestore rules cannot hide a document from a list query (a rule that denies one doc fails the whole query), and a block set is peer-unknowable so it cannot be expressed as a query where clause. Rules get()/exists() can read the owner-only block doc, but per-doc exists() calls hit Firestore's per-request document-access cap and add cost. Conclusion: gap closure means API-mediated reads (or hard connection closure), not rule predicates.

Enforced (done, bidirectional, server-side) โ€‹

SurfacePathMechanism
Venue lantern discoveryGET /lanterns/venue/:idgetHiddenUids drops hidden owners
Wave sendPOST /lanterns/waveisBlockedEitherDirection, 404 indistinguishable from "pin gone"
Wave acceptPOST /lanterns/wave/:id/acceptblock-checked
Fren presence (lit status)POST /lanterns/fren-statusgetHiddenUids returns {isLit:false}

Gaps (direct client Firestore reads; leak in one or both directions) โ€‹

GapPathCurrent filterSeverity
Chat messages read/sendmessageService on connections/{id}/messagessend: client, one-direction only; read: noneHigh
Existing connection on blockblockService.blockUserE2EE future keys withheld + client-hidden; doc never closed server-sideHigh
Beacon invitesfrensService on beaconInvitesnoneMedium (location leak: a blocked fren's "I'll be at X")
Incoming/sent waves inboxwaveService incoming + sent wave readssuppressions only, no block filterMedium (a wave pending BEFORE the block stays visible)
Active connections listwaveService.get/subscribeToActiveConnectionsclient-side isVisibleConnection onlyMedium (bypassable)
Archived connectionswaveService.get/subscribeToArchivedConnectionsnoneLow
Frens list rowsfrensService.get/subscribeToFrensnone (presence hidden, row shown)Low (existence, not location)

The chat gap is the sharp one: if A blocks B mid-conversation, B's client is not stopped from sending, B can still read the existing thread, and the connection doc plus messages subcollection are never severed (only future decryption keys are withheld from B, and A's client hides the thread from its own active list). Blocking does not currently end an in-progress conversation server-side.

3. Recommendation โ€‹

Close the gaps server-side, prioritized by severity, reusing the existing block.service.js helpers rather than inventing a second enforcement path. Recommended order:

  1. Chat (High). On block, hard-close the connection server-side (a blockedClosed state or archive set by an API, not the client), so there is no live thread to read or send into, and gate message send/read behind that state. This is the real safety fix and it is the operator's main design call (see section 5).
  2. Beacon invites + pending waves (Medium). Filter these reads through a lanterns-api endpoint applying getHiddenUids, matching the wave/fren-status pattern. Kills the location leaks.
  3. Connection lists (Medium/Low). Promote the active-connections filter from client-only to API-mediated; apply the same to archived.
  4. Frens list rows (Low). Decide whether a blocked fren's row should vanish or just stay presence-suppressed (a product call, not a leak of location).

Non-goals (explicit): auto-suspension at 10+ blocks stays deferred (decision D4, human review only); the wave-card block button stays removed (#728, waves are anonymous); no security-rule exists() enforcement (the per-doc cost + list-query semantics rule it out, section 2).

4. Phased plan (one future session) โ€‹

  • Phase 1 (Chat closure): API to close a connection on block; message send/read gated on the closed state; client blockUser calls it; tests (blocked party cannot send or read; blocker's thread severed). Reuse block.service.js.
  • Phase 2 (Location leaks): route beacon-invite and pending-incoming-wave reads through block-filtered endpoints. Tests for both directions.
  • Phase 3 (Lists): API-mediated active + archived connection lists. Tests.
  • Phase 4 (Verify + close): live end-to-end pass with two accounts (Agent Probe + test account) covering every row of the section-2 gap table; tick #144's checklist against reality; close #144.

5. Operator decision (RESOLVED 2026-08-02) โ€‹

Chat on block: SEVER. The operator chose to sever, consistent with prefer-losing-over-leaking: blocking ends the conversation and destroys the server-held thread, so it can't be read even by a bypassed client. Unblock stays possible (block list and conversation are separate); it just doesn't resurrect the old thread. Two refinements settled during implementation:

  • Capture-then-sever. Because chat is E2EE the server never held readable content, so any report or personal copy has always had to come from the reporting device. So the client CAPTURES first (the existing block-and-report flow bundles evidence into the admin-only moderation store, exempt from purge; a plain block offers a local "save a copy" download), THEN the server severs. The block confirmation modal states plainly the chat is permanently deleted and can't be recovered even on unblock.
  • Rules ARE the right tool HERE (a refinement of section 2's blanket "no rule enforcement"). For a single conversation the block check is a FIXED pair, O(1) (two exists()), well within Firestore's per-request rule-access limits for both a get and a single-thread query. So the messages read/create rules now enforce the bidirectional block directly, holding the safety property from the block doc ALONE (independent of the sever API). Section 2's rule-out still stands for the DISCOVERY surfaces (venue/inbox/lists), which are N-candidate scans and stay API-mediated.

Phase 1 as built (2026-08-02) โ€‹

  • firestore.rules: connections/{cid}/messages read + create now deny when either party blocked the other (connectionBlockedEitherWay) or the connection is severed (connectionIsBlockClosed); blockClosed/blockClosedAt are server-write-only (added to the connection-update forbidden-key set).
  • lanterns-api: POST /lanterns/block/sever + severConnectionsBetween() in block.service.js close every shared connection (both orderings) and hard-delete the message threads (batched). Idempotent, openapi-synced.
  • Client: blockService.blockUser calls the sever endpoint after the block doc write (best-effort; the rules already gate). messageService gained buildConversationTranscript + downloadConversationCopy; the block modal offers "save a copy first" when a conversation exists, wired capture-then-sever through BlockReportFlow โ†’ Chat.handleBlock.
  • Tests: rules (10 cases), server helper (5), client blockService (3) + transcript export (7).

Phase 2 as built (2026-08-02): destroy, not hide (a refinement) โ€‹

The scope doc (section 4) framed Phase 2 as "route the beacon-invite and pending-wave reads through block-filtered endpoints." Implementation found a better, sever-consistent shape and took it:

  • Why not API-mediate the reads. Waves stay doc-level readable by both parties in firestore.rules (a list query can't be per-doc block-gated without breaking the whole query), so filtering the read only hides the leak from the normal UI, leaving a bypassed client able to read the raw doc. Beacon invites, meanwhile, have no live UI consumer yet. And new wave sends across a block are already refused server-side.
  • What we did instead: destroy on block. The sever endpoint now purges every wave and beacon invite between the two users (both orderings) in addition to severing connections. That destroys the location-leaking docs outright (consistent with the operator's sever decision) and closes the bypassed-client hole, with no new endpoints or realtime-to-polling rewrites. severBetween() is the single entry point (connections + messages + waves + invites).
  • Plus a create gate. firestore.rules now denies beaconInvites create across a block in either direction (O(1) fixed-pair usersBlockedEitherWay, mirroring the messages gate), so a bypassed client can't create a fresh invite either.
  • Tests: rules (3 beacon-invite create cases), server unit (purge waves, purge invites, full severBetween), and a NEW real-emulator integration test (npm run test:sever:emulator) exercising the Admin SDK against a live Firestore emulator (scenarios 7-10 in the test plan). Test plan: docs/engineering/testing/runs/block-sever-144/README.md.

Phase 3 as built (2026-08-02): archived-list block filter โ€‹

The active connections list already filtered blocked peers (isVisibleConnection + subscribeToBlockedSet); the archived list did not. Closed that one gap by mirroring the active pattern in getArchivedConnections (one-shot block-set read) and subscribeToArchivedConnections (live block-set listener + emit gate). Low severity by design: Phase 1's sever already destroyed the message content, so this only hides the row's existence for UX consistency. No rules/server change (pure client filtering); unit-covered in waveService.test.js. The heavier "API-mediate all list reads to bypass-proof them" is intentionally NOT done: the connection doc is kept for audit (delete: if false), and with content already destroyed the residual (a participant reading their own connection row) is not a content leak.

Hardening (adversarial review, 2026-08-02) โ€‹

A pre-merge adversarial review caught that the sever endpoint destroyed data on the caller's say-so with NO check that a block existed, i.e. a standalone griefing / evidence-wiping primitive (any participant could POST /block/sever to permanently close a shared connection and hard-delete the peer's messages/waves/invites with no block, notice, or moderation record). Fixed: severBetween now gates on the caller's OWN block doc existing (not either-direction, which would leak a reverse block); no matching block is an all-zero no-op. Also: the sever got its own rate-limit bucket (was sharing lantern-lighting's), and the live emulator integration test (test:sever:emulator) is now wired into CI's Firestore-emulator job. Server + emulator tests assert "destroys nothing without a block."

Phase 4 (live two-account verify + close #144) remains and is operator-run (browser, two accounts). Test plan scenarios 11-14.

Built with VitePress