Commit Graph

24 Commits

Author SHA1 Message Date
null 8a9bd41fe5 fix(functions): rebuild stale dist + predeploy build hook (FUNCTIONS-DIST-STALE)
The phrase-change 🔑 alert never fired live because the deployed artifact
didn't contain it — functions/dist is committed and is what deploys, and I
changed functions/src without rebuilding it. My own gates were green and
irrelevant: tsc --noEmit emits nothing, ts-jest compiles in memory. The user
deployed twice, faithfully, and shipped my stale artifact both times. "git
status clean" is not "deployable current".

Two fixes: dist rebuilt (diff confirms exactly one stale function,
onCoupleKeyRotated — the phrase edge), and firebase.json now carries a
predeploy hook (npm run build) so every functions deploy compiles first —
the mistake class is structurally gone rather than remembered. Landmine
written up as FUNCTIONS-DIST-STALE in the manual: when verifying "is X
deployed", compare against dist/, not src/ — src is the intent, dist is
the truth.

Functions tests green (108).
2026-07-16 01:29:20 -05:00
null 2c44cc6ff2 feat(crypto): couple-key rotation, phase 1 — rotate forward (Future.md security #3)
A couple-key compromise currently exposes everything, forever, because the key
never changes. This adds the rotation ceremony: a fresh AES-256-GCM key becomes
the keyset's primary while the old keys stay for reads. The keyset is a Tink
keyring and every enc:v1: blob carries its key-id internally, so all history
keeps decrypting with zero wire-format changes — none of the 25 isCiphertext
rule sites move. Phase 1 protects FUTURE content only (a stolen keyset still
contains the old key); forward secrecy for history is phase 2, which builds on
the keyGeneration plumbing laid here. The Security-screen copy says so plainly.

The ceremony (CoupleRepositoryImpl.rotateCoupleKey): read the couple fresh so
concurrent rotations collide at the rules instead of overwriting each other →
prepareRotation builds the rotated keyset and re-wraps it under the SAME phrase
(fail-closed with a typed error when this device lacks keyset or phrase; nothing
persisted anywhere) → ONE merge write lands the new wrap + a strictly-increasing
keyGeneration atomically, so the partner can never observe a bumped generation
pointing at the old wrap → only then commitRotation stores locally. A failed
server write leaves the device coherent on the old key; a crash after it
self-heals through the same adoption path as the partner.

Adoption (CoupleEncryptionManager.adoptRotationIfNeeded, hooked into Home's
healing block, synchronously before the screen settles — until the rotated
keyset is stored, new content renders locked): couple.keyGeneration ahead of the
local generation → unwrap the published wrap with the locally-stored phrase →
replace the keyset. Replaced only on success, never deleted on failure, so old
content survives anything. No phrase on this device → needsRecovery, and both
recovery flows already deliver the rotated keyset for free (phrase entry unwraps
the current wrap; partner-assist exports the current keyset). Same phrase both
sides is the entire distribution trick — no new ceremony, no partner action.

Server: onCoupleKeyRotated (couples/{id} update, pure isKeyGenerationIncrease
edge guard so streak/rhythm/re-wrap updates never fire it, and a rules-forbidden
downgrade or redelivered stale event never alerts) sends both members the 🔑
security alert through the house pipeline, bypassing quiet hours like the
restore self-alerts. The push is also functional: the partner's closed app can't
read new-key content until it next loads Home — the tap takes them there.

Rules: isUpdatingRecoveryWrap admits keyGeneration, strictly increasing
(monotonic like encryptionVersion), untouched for plain phrase re-wraps.

Tests (real Tink, mocks stop at storage): history readable after rotation + NEW
writes unreadable by the old keyset — mutation-checked by dropping setPrimary,
which kills exactly that test (a rotation that forgets setPrimary passes
everything else while protecting nothing) — same-phrase unwrap reads both eras
(the partner's whole adoption, proven), prepare persists nothing until commit,
fail-closed without phrase/keyset, adoption state machine incl. corrupt-wrap.
Android suite green, assembleDebug clean, functions 105/105, tsc clean.

Deploy (scoped): firebase deploy --only firestore:rules and
--only functions:onCoupleKeyRotated. Live verify follows deploys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 02:49:50 -05:00
null eb4bab0b90 perf(functions): B6b bound the unbounded couple scanners (#10)
assignDailyQuestion and aggregateOutcomeStats previously did an unbounded
db.collection('couples').get() (loading every couple into memory), and aggregate did a
serial outcomes.get() per couple (O(couples) round-trips). Both now paginate the couple
scan (orderBy __name__ + startAfter, 300/200 per page — no custom index needed):

- assignDailyQuestion: each page's create() writes fan out with the burst bounded to a page
  instead of all couples at once; ALREADY_EXISTS stays the idempotent no-op.
- aggregateOutcomeStats: reads each page's outcomes in parallel instead of serially.
  couples.length still counts every couple, so the aggregate windows + totalCouples are
  unchanged (pure aggregate() helper and its tests untouched).

This is the one behavior-touching improvement flagged in the plan; the 512MiB/300s resource
options from B2 remain the safety net. Build clean; 70 tests green. dist rebuilt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 00:05:32 -05:00
null e0c2d67373 refactor(functions): B3 migrate 12 callables to v2 + harden
Migrate all callables off functions.https.onCall to firebase-functions/v2/https onCall:
createInvite, acceptInvite, leaveCouple, submitOutcome, sendGentleReminder,
sendThinkingOfYou, checkDeviceIntegrity, syncEntitlement, assignDailyQuestionCallable,
wrapReleaseKey. context.auth/app → request.auth/app, data arg → request.data. The 8
client-hardcoded callable names are preserved verbatim (verified via emulator discovery).
The manual `if (!request.app)` App Check check is a 1:1 port (no enforceAppCheck switch).

Hardening folded in:
- acceptInviteCallable: await the previously fire-and-forget partner_joined push (gen 2
  freezes the instance after the response) — still swallows push errors so a failed push
  never fails the accept.
- checkDeviceIntegrity: 10s timeout on the Play Integrity client.request so a hung upstream
  can't pin the instance (fail-closed catch already handles the throw); memory 512MiB.
- wrapReleaseKey: memory 512MiB (tink); HttpsError swapped to v2; lazy tink require + graceful
  failure preserved.
- Error mapping with `if (e instanceof HttpsError) throw e` re-throw guard around the risky
  DB sections in acceptInvite, leaveCouple, submitOutcome, sendGentleReminder,
  sendThinkingOfYou — raw errors map to a clean 'internal' without masking intentional codes
  (resource-exhausted rate limits, permission-denied, etc.). leaveCouple's best-effort
  recursiveDelete sweep now swallows errors (the transactional leave already succeeded).
- Adopt shared sendPushToUser()/logger; remove copied token readers + plaintext token logging.

Delete dead placeholder callables notifications/reminders.ts (sendDailyQuestionReminder,
sendPartnerAnsweredNotification) — no client caller; wrote sent:false rows nothing consumed.

Build clean; 70 tests green; discovery loads all callables as v2 in us-central1. dist rebuilt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:58:32 -05:00
null 6681bf1518 refactor(functions): B2 migrate 8 scheduled functions to v2 + harden
Migrate all scheduled jobs off functions.pubsub.schedule().onRun() to
firebase-functions/v2/scheduler onSchedule({ schedule, timeZone, ...opts }, handler):
sendChallengeDayReminders, unlockDueMemoryCapsules, sendDailyQuestionProactiveReminder,
sendStreakReminder, sendReengagementReminder, assignDailyQuestion (scheduled export),
aggregateOutcomeStats, scheduledOutcomesReminder.

Hardening folded in:
- Fan-out isolation: Promise.all → Promise.allSettled in dailyQuestionReminder (outer+inner),
  reengagement, gameRetention (both jobs), scheduledOutcomesReminder — one bad couple can no
  longer abort a whole run. streakReminder / assignDailyQuestion already isolated.
- Resource options: assignDailyQuestion + aggregateOutcomeStats memory 512MiB + timeout 300s
  (they iterate all couples); the four fan-out reminders get timeout 180s.
- Adopt shared sendPushToUser()/logger everywhere; remove five copied getUserTokens() and the
  copied send/prune blocks (no plaintext token logging remains here).
- Consolidate duplicated date/time helpers into notifications/time.ts (chicagoDateKey, toMillis),
  replacing streakReminder's + scheduledOutcomesReminder's per-file copies.

assignDailyQuestion.ts callable export stays v1 for now (migrates in B3); its tested CST helpers
are untouched. Scanner pagination for assignDailyQuestion/aggregateOutcomes is deferred to B6.

Build clean; 70 tests green (tested pure helpers preserved). dist rebuilt. Still on v5.1.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:50:59 -05:00
null 4040abbf28 refactor(functions): B1 migrate 13 Firestore triggers to v2 + harden
Migrate all Firestore triggers off the v1 API to firebase-functions/v2/firestore
(onDocumentCreated/Updated/Written); context.params→event.params, snap→event.data,
change.before/after→event.data.before/after. Region stays us-central1 (global option).

Hardening folded in (all reuse in-repo patterns):
- Adopt the shared sendPushToUser()/getUserTokens() helper in every trigger, removing
  ~7 copied token readers and the copied send/prune blocks. FCM tokens are no longer
  logged in plaintext anywhere here (redacted inside push.ts).
- console.* → firebase-functions/logger (structured).
- Idempotency: new claimOnce() (atomic create-if-absent marker under
  couples/{id}/notif_marks) dedupes at-least-once redelivery on the non-idempotent
  senders (onAnswerWritten/Revealed, onMessageWritten, onCoupleLeave, onEntitlementChanged,
  onDateReflectionWritten/Revealed, onDateHistoryCreated). Fail-open. onGameSessionUpdate/
  onGamePartFinished/notifyOnDateMatch already had transactional claim-flags — preserved.
- onRestoreRequested: the plan's "claim" is implemented as the existing 60s time-WINDOW
  (lastRestorePartnerAlertAt), not a permanent recipientUid marker — a permanent marker
  would wrongly block legitimate re-requests (restore docs are deleted+recreated by design).

Faithful port of onGameSessionUpdate/onGamePartFinished (broad wildcard + allowlist kept);
the trigger split and read reorder are deferred to B6 as separate commits.

Build clean; 70 tests green (+ new idempotency.test.ts, push token/prune tests). Emulator
discovery loads all triggers as v2 in us-central1. firebase-functions still v5.1.1 (v6 bump
deferred to B6). dist rebuilt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:46:17 -05:00
null 89cbd7cb55 refactor(functions): type callable payloads instead of `any`
Change the 5 https.onCall handlers from `data: any` to
`data: Record<string, unknown>`, so payload fields are `unknown` and must go
through the existing validators rather than being implicitly-typed. No behavior
change (every field was already validated); tsc + 53 function tests green.

Left as-is deliberately: `catch (err: unknown)` narrowing (churn, marginal) and
the untyped Tink handles in wrapReleaseKeyCallable (the crypto lib ships no types).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:06:45 -05:00
null eeac3c9383 build(functions): compile dist for outcome aggregation
Tracked dist output rebuilt to match src (aggregateOutcomeStats export +
aggregateOutcomes module), keeping the deployed bundle in sync with source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:10:02 -05:00
null d8408a2c44 chore(functions): rebuild dist with pruneDeadTokens wired into all 19 push sites 2026-06-30 23:45:42 -05:00
null e74b6f59af feat(cloud-functions): onEntitlementChanged, acceptInviteCallable, onGameSessionUpdate, onAnswerRevealed, onMessageWritten — FirestoreUserDataSource E2EE, AppMessagingService, EditProfileScreen, iOS plan 2026-06-30 02:38:31 -05:00
null 2a5c40508e feat(notifications): QuietHoursManager + NotificationSettingsScreen rewrite, Cloud Functions (streakReminder, quietHours, reengagement, gameRetention), UserRepository E2EE wiring, SettingsDataStore, firestore rules, wiring-scan 2026-06-30 00:38:06 -05:00
null 765916a8ef build(functions): recompile dist for E-OBS + E-003 results-ready changes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:40:38 -05:00
null 29beff1702 build(functions): rebuild dist from source — revenueCatWebhook ack-after-process, acceptInvite strict E2EE, onMessageWritten conversations path, onGameSessionUpdate both-partner notify 2026-06-24 16:15:30 -05:00
null 7d3b47b3ba fix(firestore): handle Timestamp type for lastAnsweredAt/createdAt in Android + Cloud Function 2026-06-23 12:40:00 -05:00
null 658ead38cd security: App Check enforcement on all callables, fail-closed device integrity, no raw code in logs; release signing config; iOS RevenueCat log level 2026-06-23 10:56:42 -05:00
null 5e16177eb2 feat: code push -- notifications, cloud functions, iOS updates 2026-06-22 08:53:23 -05:00
null af70280daa feat: E2EE recovery flow, iOS parity updates, onboarding + pairing polish 2026-06-21 11:20:48 -05:00
null 0a377ecdda fix(accept-invite): dynamic encryptionVersion, Firestore TTL on attempt docs, wipe recoveryPhrase on accept 2026-06-21 09:13:29 -05:00
null 26419ce08d feat: remove email invite screen, add accept-invite rate limiting, clean up iOS pairing (v0.2.2) 2026-06-21 08:55:43 -05:00
null 57a3e35359 feat(outcomes): add 30/60/90 day check-in flow with baseline + reminders 2026-06-20 23:59:24 -05:00
null 4dad0e774e refactor: update crypto, invite flow, and account screen patterns 2026-06-20 18:09:46 -05:00
null 39255c8733 fix: prevent invite code enumeration via Cloud Function (batch v0.2.18)
- Remove client-side read access to invites (only inviter can read own invite)
- Deny direct client update to invites (server-side only via Admin SDK)
- Add acceptInviteCallable Cloud Function: validates code, creates couple,
  updates user docs, marks invite accepted, returns wrapped key for local decryption
- Update Android client: FirestoreInviteDataSource calls callable function,
  InviteConfirmViewModel uses acceptInvite + unwrapAndStore flow
- Deprecate CoupleRepositoryImpl.createCouple (client-side path removed)
- Update Firestore rules tests: unpaired read now denied, direct update now denied
- 118/118 tests passing
2026-06-19 21:46:12 -05:00
null 6828be72fc feat: Cloud Functions — leaveCoupleCallable, onUserDelete cascade (batch v0.2.8)
- Add leaveCoupleCallable: HTTPS callable that atomically unlinks couple via Admin SDK (clears both user coupleIds, recursiveDelete couple doc)
- Add onUserDelete: Auth deletion trigger that cascades cleanup — unpairs partner, sends FCM notification, deletes Storage objects, recursiveDelete user doc
- Replace client-side batch leaveCouple with callable invocation (Firestore rules prevent cross-user writes)
- Remove CoupleRepository/UserRepository from DeleteAccountViewModel — cleanup now handled by onUserDelete trigger
- Wire new functions into index.ts exports
2026-06-19 20:04:18 -05:00
null c0696cfb80 feat: partner leave notification, real-time couple state sync, in-app unpair alerts 2026-06-18 00:25:52 -05:00