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:
wave_acceptedanalytics are forgeable. No server producer exists, so the admin overview deliberately counts unfiltered rows and the accept-rate metric is spoofable (#691, #596).- The rules carry a whole client-trust apparatus (
connectionMatchesLiveWave, participant pins, the pushState create-ban) that exists only because a browser is the writer. - No block enforcement on accept.
sendWaveByPinchecksisBlockedEitherDirection; 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:
- Read wave; 404
WAVE_NOT_FOUNDif missing. - 403
NOT_RECIPIENTunlesswave.recipientUserId == uid. - Status checks:
acceptedwith an existing live connection returns that connection (idempotent, matches today's double-tap contract); otherwise 409WAVE_NOT_PENDING. Expired-by-clock: flip toexpiredand 410WAVE_EXPIRED(same behavior the client had). - Block check (new):
isBlockedEitherDirectionfails as 404WAVE_NOT_FOUND, indistinguishable from a purged wave, matching the invisibility posture of blocks everywhere else. - Create the connection (sender=user1, recipient=user2, display names from publicProfiles resolved independently,
matchColorrandom,createdAt/lastActivityAt/expiresAt(+4h),status:'active', NOpushState).lastActivityAtseeding is a retention invariant (the 90-day idle purge hangs off it, privacy-architecture skill). - Flip the wave:
status:'accepted',respondedAt,matchColor,connectionId. Same doc-update shape as before, soonWaveAcceptedPushfires unchanged. - Emit
wave_acceptedwithserviceId:'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;connectionMatchesLiveWaveand itsget()die with it.wavesupdate 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).pushStatecreate-ban becomes moot at create (rule isfalse) but stays on update.
4. Key decisions โ
| # | Decision | Rationale |
|---|---|---|
| A1 | One transaction for connection create + wave flip + idempotency re-check | The 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 |
| A2 | Blocked accept returns 404 WAVE_NOT_FOUND | A block must stay invisible (the #144/#731 posture); any distinct error would disclose it. A purged wave and a blocked wave look identical |
| A3 | MATCH_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 |
| A4 | Client flash.track('wave_accepted') is REMOVED with the server emission | Otherwise the unfiltered lens double-counts (#733's single-layer rule applies across the client/server boundary too). logActivity (device-local) stays |
| A5 | waves_accepted overview counter gains service_id IS NOT NULL; wave_declined stays unfiltered and documented | Decline 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 |
| A6 | Old clients break on accept until they reload | The tightened rules deny the legacy client write. Dev-only environment, auto-updating PWA; acceptable transition, no dual-path |
| A7 | No crypto work | Verified: 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_acceptedserver-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
acceptedwrites, still allowsexpired. - web waveService: acceptWave becomes an API-client wrapper; tests mock
authRequest(same harness as sendWave).