docs(manual): review fixes — secure subdoc reveal flow, encryption version accuracy, anchor slug corrections, ToC/how-to updates, helper function list, gitignore case-sensitivity note
This commit is contained in:
parent
9eee3951e9
commit
439ae7ce51
|
|
@ -15,10 +15,10 @@ For each Pass below, before you start, read the relevant section of [`docs/Engin
|
|||
| Pass | Manual section to read first |
|
||||
|---|---|
|
||||
| A — Couple-shared premium | [Premium-gated features and gate pattern](docs/Engineering_Reference_Manual.md#premium-gated-features-and-gate-pattern) · [Billing](docs/Engineering_Reference_Manual.md#billing) |
|
||||
| B — Games lifecycle | [Game session push semantics (idempotent flag-claim)](docs/Engineering_Reference_Manual.md#game-session-push-semantics-idempotent-flag-claim) · [Foreground game-alert banner](docs/Engineering_Reference_Manual.md#foreground-game-alert-banner-r10) · [F-RACE-001](docs/Engineering_Reference_Manual.md#f-race-001--duplicate-game-start-push-on-rapid-partner-update) |
|
||||
| C — Visual (light+dark) | [Daily question lifecycle](docs/Engineering_Reference_Manual.md#daily-question-lifecycle) · [C-NAV-001](docs/Engineering_Reference_Manual.md#c-nav-001--back-from-home-resurfaces-onboardingauth) · [Back-stack gotchas](docs/Engineering_Reference_Manual.md#back-stack-gotchas-c-nav-002-c-nav-003) · [C-HOME-001](docs/Engineering_Reference_Manual.md#home-duplicate-pending-action-card-c-home-001) |
|
||||
| B — Games lifecycle | [Game session push semantics (idempotent flag-claim)](docs/Engineering_Reference_Manual.md#game-session-push-semantics-idempotent-flag-claim) · [Foreground game-alert banner](docs/Engineering_Reference_Manual.md#foreground-game-alert-banner-r10) · [F-RACE-001](docs/Engineering_Reference_Manual.md#f-race-001-duplicate-game-start-push-on-rapid-partner-update) |
|
||||
| C — Visual (light+dark) | [Daily question lifecycle](docs/Engineering_Reference_Manual.md#daily-question-lifecycle) · [C-NAV-001](docs/Engineering_Reference_Manual.md#c-nav-001-back-from-home-resurfaces-onboarding-auth) · [Back-stack gotchas](docs/Engineering_Reference_Manual.md#back-stack-gotchas-c-nav-002-c-nav-003) · [C-HOME-001](docs/Engineering_Reference_Manual.md#home-duplicate-pending-action-card-c-home-001) |
|
||||
| D — Security & encryption | [End-to-end encryption model](docs/Engineering_Reference_Manual.md#end-to-end-encryption-model) · [Firestore security rules](docs/Engineering_Reference_Manual.md#firestore-security-rules) · [Encryption versions](docs/Engineering_Reference_Manual.md#encryption-versions) |
|
||||
| E — Notifications | [Notifications](docs/Engineering_Reference_Manual.md#notifications) · [Notification deep-link routing](docs/Engineering_Reference_Manual.md#notification-deep-link-routing) · [E-GAME-001](docs/Engineering_Reference_Manual.md#e-game-001--notification-deep-link-landed-in-stalefinished-game) · [E-GAME-002](docs/Engineering_Reference_Manual.md#e-game-002--game-start-push-easy-to-miss-when-app-is-foreground) |
|
||||
| E — Notifications | [Notifications](docs/Engineering_Reference_Manual.md#notifications) · [Notification deep-link routing](docs/Engineering_Reference_Manual.md#notification-deep-link-routing) · [E-GAME-001](docs/Engineering_Reference_Manual.md#e-game-001-notification-deep-link-landed-in-stale-finished-game) · [E-GAME-002](docs/Engineering_Reference_Manual.md#e-game-002-game-start-push-easy-to-miss-when-app-is-foreground) |
|
||||
| F — Resilience | [End-to-end encryption model](docs/Engineering_Reference_Manual.md#end-to-end-encryption-model) · [Known limitation: single-device keys](docs/Engineering_Reference_Manual.md#known-limitation-single-device-keys) |
|
||||
| G — Account creation / fake-account | [Authentication and pairing flow](docs/Engineering_Reference_Manual.md#authentication-and-pairing-flow) · [Rate limiting on accept](docs/Engineering_Reference_Manual.md#rate-limiting-on-accept) |
|
||||
| H — Branding & artwork | `ClaudeBrandingReview.md` (this repo) · `docs/brand/visual-identity.md` |
|
||||
|
|
@ -698,7 +698,7 @@ written as ready-to-paste ChatGPT image-generation prompts** — the user genera
|
|||
- Branding *defects* (mis-colored, clipped, off-brand, low-contrast art) are bugs → `ClaudeReport.md`. Pure
|
||||
"works but could be warmer / a feature idea" → `Future.md` `## QA`. New art to create → `ClaudeBrandingReview.md`.
|
||||
|
||||
### Pass I — Performance & route efficiency (jank, redundant reads, caching) [FUTURE.md P14]
|
||||
### Pass I — Performance & route efficiency (jank, redundant reads, caching) [Future.md P14]
|
||||
Before store polish, profile **every top route** and **every high-cardinality list** for jank, repeated Firestore
|
||||
reads, missing cache use, and slow navigation. Drive each route as a user and instrument reads/frames.
|
||||
- **Frame / jank:** scroll every long list (Messages inbox + conversation, Answer History, Question Packs, Past Games,
|
||||
|
|
@ -715,11 +715,11 @@ reads, missing cache use, and slow navigation. Drive each route as a user and in
|
|||
- **Deliverable:** a reusable **route smoke-test checklist** (every top route × {load time · jank · read count}),
|
||||
captured as a runnable script so each round re-checks cheaply.
|
||||
- **Remediation when found:** lazy-load/page large lists; cache local question/category data; dedupe + scope snapshot
|
||||
listeners; skip redundant fetches on tab switches; add skeleton/loading states (cf. FUTURE.md P8) over blocking spinners.
|
||||
listeners; skip redundant fetches on tab switches; add skeleton/loading states (cf. Future.md P8) over blocking spinners.
|
||||
- Findings: real jank/leak/redundant-read = bug → `ClaudeReport.md` (P2; **P1** if it ANRs or leaks listeners, **P0** if
|
||||
it drops data); "could be smoother / add skeletons" → `Future.md` `## QA`.
|
||||
|
||||
### Pass J — Accessibility (font scale · contrast · screen reader · targets · keyboard · reduce-motion) [FUTURE.md P15]
|
||||
### Pass J — Accessibility (font scale · contrast · screen reader · targets · keyboard · reduce-motion) [Future.md P15]
|
||||
Every **primary flow** must be usable with accessibility settings on. Enable each setting and walk the core flows
|
||||
(auth, onboarding, pairing, Home, a full game, daily question + reveal, Messages, Paywall, Settings) end to end.
|
||||
This is the deep home for a11y; the Pass C contrast/font spot-checks feed into it.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ This is the source of truth for Closer's architecture, security model, data mode
|
|||
- Before touching crypto, read [End-to-end encryption model](#end-to-end-encryption-model) and the real files referenced there.
|
||||
- Before adding a Cloud Function, read [Cloud Functions](#cloud-functions) and match the existing module pattern.
|
||||
- Before changing the daily-question flow, read [Daily question lifecycle](#daily-question-lifecycle) and `app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt`.
|
||||
- Before changing notifications, read [Notifications](#notifications) — specifically [Game session push semantics](#game-session-push-semantics-idempotent-flag-claim), [Foreground game-alert banner](#foreground-game-alert-banner-r10), and [Notification deep-link routing](#notification-deep-link-routing).
|
||||
- Before gating a feature on premium, read [Premium-gated features and gate pattern](#premium-gated-features-and-gate-pattern).
|
||||
- Before debugging or changing any area, scan [Known landmines and recent fixes](#known-landmines-and-recent-fixes) — it lists bugs that already cost real debugging time and are easy to re-introduce.
|
||||
- [Where to look first](#where-to-look-first) points new engineers at the most important files.
|
||||
|
||||
---
|
||||
|
|
@ -27,10 +30,15 @@ This is the source of truth for Closer's architecture, security model, data mode
|
|||
8. [Cloud Functions](#cloud-functions)
|
||||
9. [Billing](#billing)
|
||||
10. [Notifications](#notifications)
|
||||
- [Game session push semantics](#game-session-push-semantics-idempotent-flag-claim)
|
||||
- [Foreground game-alert banner](#foreground-game-alert-banner-r10)
|
||||
- [Notification deep-link routing](#notification-deep-link-routing)
|
||||
- [Premium-gated features and gate pattern](#premium-gated-features-and-gate-pattern) (under Billing)
|
||||
11. [iOS-specific notes](#ios-specific-notes)
|
||||
12. [Build and release](#build-and-release)
|
||||
13. [Engineering conventions](#engineering-conventions)
|
||||
14. [Where to look first](#where-to-look-first)
|
||||
14. [Known landmines and recent fixes](#known-landmines-and-recent-fixes)
|
||||
15. [Where to look first](#where-to-look-first)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -311,9 +319,11 @@ iOS does not generate or store a recovery phrase in the current build. iOS coupl
|
|||
|
||||
| Version | Name | Meaning |
|
||||
| --- | --- | --- |
|
||||
| 0 | `PLAINTEXT` | No couple key; answers may be plaintext. Used by iOS couples until E2EE parity ships. |
|
||||
| 1 | `MIGRATING` | A couple key exists but historical content is still being rewritten by both partners. Kept for backwards compatibility with older couples; no new couples should be created at v1. |
|
||||
| 2 | `STRICT` | All answer-bearing paths require encryption. Default for all new Android couples. |
|
||||
| 0 | `PLAINTEXT` | No couple key; answers may be plaintext. **iOS only** — every iOS-originated couple is created at v0 because the iOS port does not implement E2EE today (see [iOS E2EE gap](#ios-e2ee-gap)). No Android couple is ever created at v0. |
|
||||
| 1 | `MIGRATING` | Reserved for a hypothetical in-flight migration. **No couple exists at v1 in production today.** The constant is documented in `EncryptionVersion.kt` for forward-compatibility; do not write v1 from new code without a concrete migration plan. |
|
||||
| 2 | `STRICT` | All answer-bearing paths require encryption. **Default for every Android-originated couple**; the only state `acceptInviteCallable.ts` writes when E2EE fields are present. |
|
||||
|
||||
**What the code actually does**: the Android `EncryptionVersion` object defines only `STRICT = 2` and `NEW_COUPLE_DEFAULT = STRICT`. v0 and v1 are conceptual states that exist on paper for iOS compatibility and migration planning; nothing in the Android source path writes them. Do not change this without auditing iOS cross-platform pairing (a mixed v0/v2 couple is the only combination that the rules tolerate today, and only because Android handles plaintext gracefully when the partner is iOS).
|
||||
|
||||
The Cloud Function `acceptInviteCallable.ts` derives `encryptionVersion` from whether E2EE fields are present: if `wrappedCoupleKey`, `kdfSalt`, and `kdfParams` are all non-null, the couple is created at v2; otherwise v0. This keeps the iOS-and-Android-different-defaults case from breaking.
|
||||
|
||||
|
|
@ -342,9 +352,9 @@ The Argon2id parameters are deliberately chosen to take ~2-3 seconds on a mid-ra
|
|||
|
||||
See [Recovery phrase flow](#recovery-phrase-flow). The recovery phrase is the only human-readable secret. It is never sent to the server in plaintext.
|
||||
|
||||
### Sealed-answer partner-proof mode
|
||||
### Sealed-answer partner-proof mode (schemaVersion 3 only)
|
||||
|
||||
Sealed answers (`schemaVersion = 3`) provide partner-proof privacy: even a malicious or compromised device cannot read the partner's answer until both partners have submitted and released their one-time keys.
|
||||
Sealed answers provide partner-proof privacy: even a malicious or compromised device cannot read the partner's answer until both partners have submitted and released their one-time keys. **This is the schemaVersion 3 path** — thread messages and the legacy answer path. The current daily-answer default is schemaVersion 2 (couple-key, see [Reveal flow](#reveal-flow)), which uses a different gating model.
|
||||
|
||||
Flow:
|
||||
|
||||
|
|
@ -443,7 +453,7 @@ The function uses `CST_OFFSET_HOURS = -6` and does not account for daylight savi
|
|||
- In **summer**, the date key is computed by adding -6h to UTC. The 6 PM cron is at 23:00 UTC, so `date` is correct in summer.
|
||||
- In **winter**, the same 23:00 UTC cron fires at 5 PM local. Adding -6h gives the local date as intended.
|
||||
|
||||
In practice the date key is correct most of the time, but the comment "America/Chicago 6:00 PM == 23:00 UTC" is **only true in CDT**. During CST, the cron actually runs at 5 PM local and the offset is still correct. The fix is to use a proper IANA tz library (e.g. `date-fns-tz`) rather than a hardcoded offset. Track this in `FUTURE.md`.
|
||||
In practice the date key is correct most of the time, but the comment "America/Chicago 6:00 PM == 23:00 UTC" is **only true in CDT**. During CST, the cron actually runs at 5 PM local and the offset is still correct. The fix is to use a proper IANA tz library (e.g. `date-fns-tz`) rather than a hardcoded offset. Track this in `Future.md`.
|
||||
|
||||
### Answer write
|
||||
|
||||
|
|
@ -465,16 +475,27 @@ Token lookup reads both a legacy `fcmToken` field on the user doc and a dedicate
|
|||
|
||||
### Reveal flow
|
||||
|
||||
The reveal happens client-side after both partners have submitted:
|
||||
The reveal path differs by schema version:
|
||||
|
||||
1. Each app checks for the partner's `answers/{partnerId}` doc.
|
||||
2. Each app writes a `releaseKeys/{partnerId}.encryptedAnswerKey` containing the sender's one-time key wrapped to the partner's ECIES public key.
|
||||
3. Each app reads the `releaseKeys/{selfUserId}.encryptedAnswerKey` written by the partner.
|
||||
4. Each app unwraps the key with its private key and decrypts the partner's `encryptedPayload`.
|
||||
5. Each app verifies the decrypted payload's SHA-256 commitment matches `commitmentHash`.
|
||||
6. The reveal screen shows both answers side-by-side.
|
||||
**SchemaVersion 2 (couple-key daily answers) — the current default.** The answer doc at `couples/{coupleId}/daily_question/{date}/answers/{userId}` is **metadata only** — no answer content. The encrypted payload lives in a **read-gated subdoc** at `answers/{userId}/secure/payload` with a single `encryptedPayload` field (`enc:v1:`). The Firestore rules for that subdoc grant read access to the owner unconditionally, AND to the partner **only if** the partner has also submitted their own answer to the same date (checked by `exists(.../answers/{request.auth.uid})` in `firestore.rules`). This is the cryptographic "private until both answer" gate — there is no per-answer key handshake, just a server-side read predicate.
|
||||
|
||||
The reveal state is gated by Firestore rules: only the sender writes the keybox, only the recipient reads it. The sealed payload is created with `answerKeyReleased: false`; the rules only allow the reveal-metadata fields (`isRevealed`, `answerKeyReleased`, `updatedAt`) to change after creation.
|
||||
1. Each app writes its own `answers/{userId}` metadata doc + its own `answers/{userId}/secure/payload` subdoc with `enc:v1:` content.
|
||||
2. Once the partner's metadata doc exists, the rules unlock read access to your `secure/payload` for the partner (and vice versa). Both apps decrypt with the shared couple key.
|
||||
3. The metadata `isRevealed` flag flips after both have read; that's what triggers the `partner_opened_answer` push (see `functions/src/questions/onAnswerRevealed.ts`).
|
||||
|
||||
**SchemaVersion 3 (sealed / partner-proof — used for thread messages).** The original sealed-answer flow:
|
||||
|
||||
1. User composes message.
|
||||
2. App generates a one-time AES-256-GCM key.
|
||||
3. App computes SHA-256 commitment over canonical JSON payload.
|
||||
4. App seals payload with one-time key → writes `encryptedPayload` + `commitmentHash` to Firestore.
|
||||
5. App stores one-time key locally in `PendingAnswerKeyStore`.
|
||||
6. Partner does the same.
|
||||
7. After both messages exist, each app reads partner's public key from `users/{partnerId}/devices/primary`, wraps its own one-time key with ECIES P-256, writes keybox to `releaseKeys/{partnerId}`.
|
||||
8. Each app reads the keybox written for them, unwraps with private key, decrypts partner's sealed payload.
|
||||
9. Each app verifies the decrypted payload's SHA-256 commitment matches `commitmentHash`.
|
||||
|
||||
**Adding a new answer-bearing path**: decide upfront whether schemaVersion 2 (couple-key, simple, current default) or schemaVersion 3 (sealed, partner-proof, more code) is appropriate. New schemas require new `isXxxAnswerCreate` helpers in `firestore.rules` AND new write paths in the data source.
|
||||
|
||||
### Thread questions
|
||||
|
||||
|
|
@ -590,15 +611,24 @@ isSignedIn() request.auth != null
|
|||
isOwner(uid) request.auth.uid == uid
|
||||
isCouplesMember(coupleId) request.auth.uid in couples/{coupleId}.userIds
|
||||
isValidInviteCode(code) matches('^[a-zA-Z0-9]{6}$')
|
||||
isNotAlreadyPaired() caller has no existing coupleId
|
||||
isImmutable(fields) diff(...).affectedKeys().hasOnly(fields)
|
||||
isValidSwipeAction(action) 'love' | 'maybe' | 'skip'
|
||||
isValidDatePlanStatus(status) 'draft' | 'planned' | 'completed'
|
||||
isValidBucketListCategory(category) whitelist of category strings
|
||||
isCiphertext(value) matches('^enc:v1:[A-Za-z0-9+/]+={0,2}$')
|
||||
cipherOrAbsent(data, key) data[key] is ciphertext or absent
|
||||
isDatePlanContentEncrypted(data) date-plan content fields are enc:v1: or absent
|
||||
coupleEncryptionEnabled(coupleId) couple.encryptionVersion >= 1
|
||||
isSealedPayload(value) matches('^sealed:v1:[A-Za-z0-9_-]{80,}$')
|
||||
isKeybox(value) matches('^keybox:v1:[A-Za-z0-9_-]{120,}$')
|
||||
isCommitmentHash(value) matches('^sha256:[A-Za-z0-9_-]{43}$')
|
||||
isSealedAnswerCreate(data) sealed-answer shape + sealed:v1 + sha256:
|
||||
isSealedAnswerUpdate() only reveal-metadata fields
|
||||
isStartingEncryptionMigration() v0/v1 → v1 with empty migration map
|
||||
isCompletingOwnEncryptionMigration() v1 → v1/v2 with self in migration map
|
||||
isSealedAnswerCreate(data) sealed-answer shape + sealed:v1 + sha256: (schemaVersion 3, legacy)
|
||||
isSealedAnswerUpdate() only reveal-metadata fields (schemaVersion 3)
|
||||
isCoupleKeyAnswerCreate(data) metadata-only shape, schemaVersion 2
|
||||
isCoupleKeyAnswerUpdate() only isRevealed/updatedAt flip (schemaVersion 2)
|
||||
isSealedThreadAnswerCreate(data) sealed shape, no answerDate/isRevealed (threads, schemaVersion 3)
|
||||
isSealedThreadAnswerUpdate() only answerKeyReleased/updatedAt flip (threads)
|
||||
isUpdatingRecoveryWrap() only wrappedCoupleKey/kdfSalt/kdfParams
|
||||
isUpdatingCoupleRhythm() only streakCount/lastAnsweredAt
|
||||
```
|
||||
|
|
@ -615,7 +645,7 @@ isUpdatingCoupleRhythm() only streakCount/lastAnsweredAt
|
|||
|
||||
**`couples/{coupleId}/daily_question/...`** — server-only writes. Daily-question assignment and answer-related subcollections are tightly constrained.
|
||||
|
||||
**`couples/{coupleId}/daily_question/{date}/answers/{userId}`** — the answer is private to its author until reveal. The `isSealedAnswerCreate` / `isSealedAnswerUpdate` helpers enforce the sealed-answer shape. Legacy answers (`schemaVersion` ≠ 3) must use `enc:v1:` ciphertext.
|
||||
**`couples/{coupleId}/daily_question/{date}/answers/{userId}`** — the answer is private to its author until reveal. **The metadata doc is content-free** (schemaVersion 2): it holds only `userId`, `questionId`, `answerType`, `schemaVersion`, `answerDate`, `createdAt`, `updatedAt`, `isRevealed` so the partner can see THAT you answered ("your turn" indicator) without leaking the content. The actual `encryptedPayload` lives in a **read-gated subdoc** at `answers/{userId}/secure/payload` and is only readable by the partner once the partner has also submitted (`exists(.../answers/{request.auth.uid})`). The owner always reads their own subdoc. The subdoc shape is `keys().hasOnly(['encryptedPayload'])` and `isCiphertext(encryptedPayload)`. See [Reveal flow](#reveal-flow) for the full path. Legacy schemaVersion 3 answers (sealed:v1:) follow the `isSealedAnswerCreate`/`isSealedAnswerUpdate` helpers and use the `releaseKeys/` subdoc for the ECIES-wrapped keybox.
|
||||
|
||||
**`couples/{coupleId}/daily_question/{date}/answers/{userId}/releaseKeys/{recipientId}`** — create-only by the answer owner, readable only by the named recipient. `keybox:v1:` shape is enforced.
|
||||
|
||||
|
|
@ -981,10 +1011,11 @@ firebase deploy --only functions
|
|||
|
||||
### Files that must never be committed
|
||||
|
||||
Add to every clone's `.gitignore`:
|
||||
The authoritative list lives in `.gitignore` at the repo root. The current entries the agent workflow relies on are:
|
||||
|
||||
```text
|
||||
FUTURE.md
|
||||
# Private project docs (agent-only, never commit) — see .gitignore
|
||||
FUTURE.md # uppercase; legacy name in gitignore
|
||||
HISTORY.md
|
||||
PROJECT.md
|
||||
STRUCTURE.md
|
||||
|
|
@ -996,7 +1027,7 @@ SCRIPTS.md
|
|||
.kotlin/
|
||||
```
|
||||
|
||||
These are agent-only or workspace-only docs and have no place in the public repo.
|
||||
> **Note (2026-06): the gitignore list uses `FUTURE.md` (uppercase) but the tracked file is `Future.md` (mixed case).** Linux filesystems are case-sensitive so these are different paths; the gitignore does not actually block `Future.md`. The ClaudeQA docs (`ClaudeQAPlan.md`, `ClaudeReport.md`, `ClaudeQACoverage.md`, `ClaudeBrandingReview.md`, `ClaudeiOSPlan.md`) and `Future.md` are explicitly tracked. If you create new top-level docs and want them gitignored, either match the existing case in `.gitignore` (`Future.md`) or pick a case and use it consistently. Do not block `Future.md` and then create `future.md` thinking you're safe.
|
||||
|
||||
### Versioning
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue