Skip to content

Server-signed wave accept (design) โ€‹

Date: 2026-07-30 Issue: #740 (successor to #691 item 1 via decision D2 of the analytics hardening plan) Status: design approved by prior operator direction (greenlight 2026-07-30); implementation in the same branch Skills consulted: privacy-architecture (retention seeding, no-crypto-at-create, disclosure posture), planning; full surface map by codebase trace

1. The problem โ€‹

acceptWave is the last client-side Firestore mutation in the wave lifecycle: the browser validates, creates the connections doc, and flips the wave to accepted. Three consequences:

  1. wave_accepted analytics are forgeable. No server producer exists, so the admin overview deliberately counts unfiltered rows and the accept-rate metric is spoofable (#691, #596).
  2. The rules carry a whole client-trust apparatus (connectionMatchesLiveWave, participant pins, the pushState create-ban) that exists only because a browser is the writer.
  3. No block enforcement on accept. sendWaveByPin checks isBlockedEitherDirection; the client accept path never did, so a sender who blocks the recipient after waving can still end up in a connection with them.

2. Design โ€‹

POST /lanterns/wave/:waveId/accept on lanterns-api (auth: App Check + Firebase token; rate bucket light). The service performs, inside one Firestore transaction:

  1. Read wave; 404 WAVE_NOT_FOUND if missing.
  2. 403 NOT_RECIPIENT unless wave.recipientUserId == uid.
  3. Status checks: accepted with an existing live connection returns that connection (idempotent, matches today's double-tap contract); otherwise 409 WAVE_NOT_PENDING. Expired-by-clock: flip to expired and 410 WAVE_EXPIRED (same behavior the client had).
  4. Block check (new): isBlockedEitherDirection fails as 404 WAVE_NOT_FOUND, indistinguishable from a purged wave, matching the invisibility posture of blocks everywhere else.
  5. Create the connection (sender=user1, recipient=user2, display names from publicProfiles resolved independently, matchColor random, createdAt/lastActivityAt/expiresAt(+4h), status:'active', NO pushState). lastActivityAt seeding is a retention invariant (the 90-day idle purge hangs off it, privacy-architecture skill).
  6. Flip the wave: status:'accepted', respondedAt, matchColor, connectionId. Same doc-update shape as before, so onWaveAcceptedPush fires unchanged.
  7. Emit wave_accepted with serviceId:'lanterns-api' (the unforgeable trust marker), entityId: waveId, metadata: { connectionId, venueId }.

Response: { wave: {...}, connection: { id, ...doc } }, byte-compatible with the client's current return shape (the Dashboard consumes the full connection doc; returning the peer uid is safe post-accept since the accepter can already read the connection doc).

The transaction is a strict improvement over the client flow: the old create-then-flip had a partial-failure window (connection created, wave never flipped) that the browser could not close.

3. Rules tightening (the payoff) โ€‹

  • connections: allow create: if false. The client is no longer a creator; connectionMatchesLiveWave and its get() die with it.
  • waves update allowlist shrinks from ['status','respondedAt','matchColor','connectionId'] to status-only writes of 'expired' (the client-side auto-expiry sweeps remain the one legitimate client write).
  • pushState create-ban becomes moot at create (rule is false) but stays on update.

4. Key decisions โ€‹

#DecisionRationale
A1One transaction for connection create + wave flip + idempotency re-checkThe Admin SDK has no rules backstop; a retry or double-tap must not mint two connections. Transactional re-read of wave status + existing-connection probe closes it, and fixes the old partial-failure window for free
A2Blocked accept returns 404 WAVE_NOT_FOUNDA block must stay invisible (the #144/#731 posture); any distinct error would disclose it. A purged wave and a blocked wave look identical
A3MATCH_COLOR_IDS moves to @lantern/shared (ui re-exports)lanterns-api's image only ships packages/shared + packages/forge; duplicating the list would drift. The ui package keeps its export path so no client import changes
A4Client flash.track('wave_accepted') is REMOVED with the server emissionOtherwise the unfiltered lens double-counts (#733's single-layer rule applies across the client/server boundary too). logActivity (device-local) stays
A5waves_accepted overview counter gains service_id IS NOT NULL; wave_declined stays unfiltered and documentedDecline has no server producer (and by #731/D14 design never will in the same way: declines are recipient-private markers). The comment must say so honestly
A6Old clients break on accept until they reloadThe tightened rules deny the legacy client write. Dev-only environment, auto-updating PWA; acceptable transition, no dual-path
A7No crypto workVerified: connection creation does zero E2EE; OlmMachine engages lazily at first message, keyed by connectionId + participant uids, both preserved

5. Test plan โ€‹

  • lanterns-api: new accept service tests on the existing mock harness (extended with connection add + doc update): happy path, not-recipient, not-pending, expired-flips-and-410s, idempotent re-accept, block-check-as-404, transaction shape.
  • analyticsContracts: add the wave_accepted server-event row.
  • bqMetrics: assertion update for the new filter + rewritten comment test.
  • rules tests: connections create denied for everyone (replacing the 8 liveness tests); waves update allowlist rejects accepted writes, still allows expired.
  • web waveService: acceptWave becomes an API-client wrapper; tests mock authRequest (same harness as sendWave).

Built with VitePress