From 37d86c498f6f0cc7d7effe0ad8e659cf34f0c51b Mon Sep 17 00:00:00 2001 From: BigManly4 Date: Mon, 31 Aug 2026 00:39:19 -0700 Subject: [PATCH] docs: add crypto invariant, nonce lifecycle, treasury/wallet and hooks references Adds four reference documents: - apps/backend/docs/concepts-crypto-invariants.md covering lib/ciphertextInvariant.ts and lib/signalInvariants.ts, the forbidden field lists, the pre-database rejection ordering in the WebSocket handlers, why the Zod schemas are .strict(), and the security-ci regression job that keeps all of it enforced. - apps/backend/docs/concepts-nonce-lifecycle.md covering lib/nonce.ts: creation, single-use consumption and TTL for both the sign-in and the device-link challenge, why the two use separate namespaces, where the stores live across restarts and multiple nodes, and the replay resistance this provides and its limits. - apps/web/docs/components-treasury-wallet.md documenting ProposalCard, ProposeWithdrawalModal and WalletConnectButton with props, expected data, which actions hit the backend REST API versus Freighter and in what order, the wallet states a user can be in, and cross-links to the contracts docs for on-chain semantics. - apps/web/docs/hooks.md documenting useSocket, useInboundPipeline, useMessageHistory, useLocalSearch, useMessageSearchIndex and usePushSubscription with arguments, return shapes, side effects and cleanup, the single-owner versus multi-mount rules, the ordering dependency between useSocket and useInboundPipeline, and the SSR constraint on hooks touching window, IndexedDB or WebCrypto. Documentation only. No source or configuration changes. --- .../docs/concepts-crypto-invariants.md | 140 ++++++++ apps/backend/docs/concepts-nonce-lifecycle.md | 161 +++++++++ apps/web/docs/components-treasury-wallet.md | 202 +++++++++++ apps/web/docs/hooks.md | 331 ++++++++++++++++++ 4 files changed, 834 insertions(+) create mode 100644 apps/backend/docs/concepts-crypto-invariants.md create mode 100644 apps/backend/docs/concepts-nonce-lifecycle.md create mode 100644 apps/web/docs/components-treasury-wallet.md create mode 100644 apps/web/docs/hooks.md diff --git a/apps/backend/docs/concepts-crypto-invariants.md b/apps/backend/docs/concepts-crypto-invariants.md new file mode 100644 index 0000000..ae07e22 --- /dev/null +++ b/apps/backend/docs/concepts-crypto-invariants.md @@ -0,0 +1,140 @@ +# Server-side crypto invariants + +This note documents the guards that keep the backend from ever accepting or storing material it must not hold: [src/lib/ciphertextInvariant.ts](../src/lib/ciphertextInvariant.ts), [src/lib/signalInvariants.ts](../src/lib/signalInvariants.ts), the `.strict()` Zod schemas that enforce the same rule on the REST surface, and the [Security CI](../../../.github/workflows/security-ci.yml) job that regression-tests all of it. + +## 1. The invariants + +The backend is a relay and a durable queue for material it cannot read. Two rules follow from that, and both are enforced in code rather than left as a convention: + +1. **The server stores ciphertext only.** Message bodies are persisted as opaque ciphertext plus per-device envelopes. There is no column, schema field, or code path that accepts a plaintext message body. +2. **The server never accepts session state, ratchet state, or private keys on any inbound payload.** Double-Ratchet session state, root/chain/sender keys, and private key halves are client-local. Every client derives its own session state; the only key material that crosses the wire is _public_ — identity keys, prekeys, signed prekeys, and MLS key packages. + +The second rule is deliberately stronger than "we don't read it". Accepting a private key and ignoring it would still put it in a request log, an error report, or a crash dump. The guards reject the request outright so the value never reaches anything that persists. + +Related reading: [E2EE onboarding](e2ee-onboarding.md), [Signal migration](signal-migration.md), [Security hardening](security-hardening.md), and the repository [threat model](../../../docs/threat-model.md). + +## 2. `lib/signalInvariants.ts` + +### `FORBIDDEN_SESSION_STATE_FIELDS` + +The rejected field names are: + +| Field | Why it is forbidden | +| ---------------------- | -------------------------------------------------------- | +| `sessionState` | Serialized Signal session — client-local, never uploaded | +| `ratchetState` | Double-Ratchet state, including message-key chains | +| `rootKey` | Ratchet root key | +| `chainKey` | Sending or receiving chain key | +| `senderKey` | Group sender key | +| `privateKey` | Any private key half | +| `identityPrivateKey` | Long-term identity private key | +| `signedPreKeyPrivate` | Private half of a signed prekey | +| `oneTimePreKeyPrivate` | Private half of a one-time prekey | + +The list is exported as a `const` tuple and `ForbiddenSessionStateField` is its union type, so adding a name to the list is the only change needed to extend the guard. + +### `findForbiddenSessionStateField(payload)` + +Returns the name of the first forbidden field found, or `null` when the payload is clean. It checks two levels: + +- the top level of `payload`, and +- every entry of `payload.envelopes` when that is an array, because the per-device envelope array is the other place a client could attach key material. + +Two details matter: + +- It uses `Object.prototype.hasOwnProperty` rather than `in` or a truthiness check. Only own-enumerable keys are user-controlled input, and a field that is present but set to `null` or `""` is still a rejection — the presence of the key is the signal, not its value. +- It returns the field _name_, which the caller echoes back in the error so a client bug stays diagnosable without the server ever logging the value. + +### Where it runs, and when + +The guard is the enforcement point for the WebSocket paths, which parse the raw socket payload by hand rather than through Zod. It runs as the **first statement** of both handlers in [src/socket/messaging.ts](../src/socket/messaging.ts): + +- `send_message` — [messaging.ts:117](../src/socket/messaging.ts#L117) +- `edit_message` — [messaging.ts:321](../src/socket/messaging.ts#L321) + +```ts +dispatcher.register('send_message', async (payload) => { + const forbiddenField = findForbiddenSessionStateField(payload); + if (forbiddenField) { + socket.emit('error', { + event: 'send_message', + code: 400, + message: `Field "${forbiddenField}" is not permitted: the server never stores session or private-key state`, + }); + return; + } + // ... destructure payload, check membership, write to the database +}); +``` + +**Rejection happens before any database lookup.** The handler returns before it destructures the payload, before the conversation-membership check, and before any `db.query` call. This ordering is intentional and is asserted by [signalInvariants.socket.test.ts](../src/__tests__/signalInvariants.socket.test.ts). It matters for three reasons: + +- A forbidden payload never touches the database, so a rejected request cannot be used as an oracle for whether a conversation or a message id exists. +- Rejection cost is constant and independent of database state. +- Nothing partially validated is written, so there is no window in which a forbidden field is held inside a transaction. + +The client sees a `400` socket `error` event naming the offending field. There is no partial-accept mode: the message is not stored, not fanned out, and not acknowledged. + +## 3. `lib/ciphertextInvariant.ts` + +A second, broader list covering _stored or uploaded_ payloads — `FORBIDDEN_PERSISTED_OR_UPLOADED_FIELDS`. It extends the session-state list with plaintext body names (`content`, `body`, `plaintext`), MLS secrets (`mlsSecret`, `mlsSecrets`), and message keys (`messageKey`, `messageKeys`), and it carries both camelCase and snake_case spellings. + +Field names are compared after normalization — `field.replace(/[-_]/g, '').toLowerCase()` — so `ratchet_state`, `ratchetState`, `Ratchet-State`, and `RATCHETSTATE` all match the same entry. The explicit snake_case entries in the exported list keep the list itself readable; normalization is what makes the check spelling-insensitive. + +Two functions: + +- `findForbiddenCiphertextFields(payload): string[]` — returns every offending key on a plain object. Non-objects, `null`, and arrays return `[]`. +- `assertCiphertextOnlyPayload(payload): void` — throws when that list is non-empty, with the offending field names (not values) in the message. + +Covered by [ciphertextInvariant.test.ts](../src/__tests__/ciphertextInvariant.test.ts). + +## 4. Why the Zod schemas are `.strict()` + +REST endpoints do not call the guard functions directly. They get the same protection from Zod, but **only because the schemas are `.strict()`** — and this is the single most important detail in this document. + +Zod's default object mode **strips** unknown keys. A non-strict schema handed a payload carrying `ratchetState` would parse successfully, silently drop the field, and return a clean object. The request would be accepted, the client would receive `200`, and nobody would learn that a client had just tried to upload ratchet state. A regression that reintroduced a plaintext or key-bearing field would be invisible, because the schema would quietly absorb it forever. + +`.strict()` inverts that: an unrecognized key is a validation failure, and the request is rejected with `400` before the handler runs. Silent stripping hides a client bug; strict rejection surfaces it. + +The strict schemas that gate crypto-relevant input: + +| Schema | File | Gates | +| ------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------- | +| `EnvelopeSchema` | [schemas/message.schemas.ts](../src/schemas/message.schemas.ts) | Each per-device message envelope | +| `SendMessageSchema` | [schemas/message.schemas.ts](../src/schemas/message.schemas.ts) | `POST /messages` body | +| `VerifySchema` | [schemas/auth.schemas.ts](../src/schemas/auth.schemas.ts) | `POST /auth/verify` sign-in body | +| `DeviceLinkVerifySchema` | [schemas/auth.schemas.ts](../src/schemas/auth.schemas.ts) | `POST /devices/link/verify` — the only device-registration path | +| `PreKeyEntrySchema` | [lib/keys.ts](../src/lib/keys.ts) | Uploaded one-time prekeys | +| `SignedPreKeyEntrySchema` | [lib/keys.ts](../src/lib/keys.ts) | Uploaded signed prekeys | + +Note that `SendMessageSchema` has no plaintext field at all: the body carries `ciphertext` and `envelopes`, never a readable message. Content-type rules beyond the schema shape live in [lib/validateMessagePayload.ts](../src/lib/validateMessagePayload.ts), which rejects a text message that has no per-device envelopes. + +**When adding a schema that accepts client input touching keys, messages, or devices, make it `.strict()`.** Building a composite with `.extend()` produces a new schema whose strictness must be re-declared, which is why `SignedPreKeyEntrySchema` and `DeviceLinkVerifySchema` both re-apply `.strict()` explicitly. + +## 5. The Security CI regression job + +[.github/workflows/security-ci.yml](../../../.github/workflows/security-ci.yml) runs on every pull request and on every push to `main`. It has two jobs. + +### Job `regression` — "Ciphertext-only guard + secret-field scan" + +Runs [src/\_\_tests\_\_/security.regression.test.ts](../src/__tests__/security.regression.test.ts) from `apps/backend` with `pnpm test -- security.regression.test.ts`. Two suites: + +**Ciphertext-only guard.** Drives `validateMessagePayload` and asserts that a text message carrying a `plaintext` field is rejected, that ciphertext without per-device envelopes is rejected, that a message with envelopes is accepted, and that `SendMessageSchema.shape` contains neither `plaintext` nor `plainText`. + +**Secret-field source scan.** Walks every `.ts` file under `apps/backend/src` (skipping `__tests__` and `node_modules`) and fails if any file _declares_ a field named `plaintext`, `plainText`, `privateKey`, `private_key`, `sessionState`, `session_state`, `signalSession`, `identityPrivateKey`, or `preKeyPrivate`. The match pattern is `(^|[^A-Za-z0-9_])\s*[:?]\s*[^,]`, which catches Zod object keys and TypeScript interface or type members while leaving prose in comments alone. + +This scan is what makes the invariant durable. The unit tests prove the guards work on the paths that call them; the scan fails the build the moment _any_ schema, route, type, or service anywhere in the backend grows a field that could carry a private key or a session blob — including on a path nobody remembered to guard. + +Two supporting suites also live in the tree and run under the normal backend test job: [signalInvariants.messages.test.ts](../src/__tests__/signalInvariants.messages.test.ts) and [signalInvariants.devices.test.ts](../src/__tests__/signalInvariants.devices.test.ts) cover the REST message and device paths, and [signalInvariants.socket.test.ts](../src/__tests__/signalInvariants.socket.test.ts) covers the WebSocket handlers described above. + +### Job `dependency-audit` — "Crypto dependency CVE audit" + +Runs [scripts/audit-crypto-deps.mjs](../../../scripts/audit-crypto-deps.mjs), which scopes `pnpm audit` to the crypto-relevant backend dependencies (`ioredis`, `jsonwebtoken`, `web-push`, `@stellar/stellar-sdk`, `drizzle-orm`, `socket.io`) so CVEs in those surface without the job failing on advisories in unrelated transitive packages. + +### Changing the invariants + +If a change legitimately requires touching this surface: + +1. Update `FORBIDDEN_SESSION_STATE_FIELDS` or `FORBIDDEN_PERSISTED_OR_UPLOADED_FIELDS` rather than adding an ad-hoc check at a call site. +2. Keep new client-input schemas `.strict()`. +3. Expect the source scan to fail loudly on a new forbidden field name. That failure is the feature working — treat it as a design question, not as a test to relax. diff --git a/apps/backend/docs/concepts-nonce-lifecycle.md b/apps/backend/docs/concepts-nonce-lifecycle.md new file mode 100644 index 0000000..27dd37e --- /dev/null +++ b/apps/backend/docs/concepts-nonce-lifecycle.md @@ -0,0 +1,161 @@ +# Nonce and challenge store lifecycle + +This note documents [src/lib/nonce.ts](../src/lib/nonce.ts): how challenge nonces are minted, stored, consumed, and expired, for both the wallet sign-in challenge and the separate device-link challenge. + +Both flows follow the same shape — the server mints a random value, the client proves wallet ownership by signing a message containing it, and the server burns the value while verifying. What differs is the keyspace, the TTL, and what a successful verification grants. + +See also: [Auth API](api-auth.md), [Devices & Prekeys API](api-devices.md), [JWT auth contract](contracts-jwt-auth.md), and [Rate limits](../../../docs/security/rate-limits.md). + +## 1. The two nonce kinds at a glance + +| | Sign-in challenge | Device-link challenge | +| --------------------- | ------------------------------------ | --------------------------------------- | +| Mint | `createNonce(walletAddress)` | `createDeviceLinkNonce(userId)` | +| Consume | `consumeNonce(walletAddress, nonce)` | `consumeDeviceLinkNonce(userId, nonce)` | +| Store | `store` map | `deviceLinkStore` map | +| Key | Wallet address (`G...`) | `userId` | +| TTL | 5 minutes (`TTL_MS`) | 2 minutes (`DEVICE_LINK_TTL_MS`) | +| Issued by | `POST /auth/challenge` | `POST /devices/link/challenge` | +| Burned by | `POST /auth/verify` | `POST /devices/link/verify` | +| Caller authenticated? | No — this is the pre-login step | Yes — requires a valid JWT | +| Grants on success | A session (JWT) | Registration of one new device | + +## 2. Creation + +Both kinds are minted by the same internal helper: + +```ts +function issue(target, key, ttlMs) { + const nonce = randomBytes(16).toString('hex'); + target.set(key, { nonce, expiresAt: Date.now() + ttlMs }); + return nonce; +} +``` + +- **Value.** 16 bytes from Node's `crypto.randomBytes`, hex-encoded to a 32-character string. 128 bits of CSPRNG output — guessing a live nonce is not a practical attack, and collisions between concurrently outstanding nonces are not a concern. +- **Storage.** One entry per key, holding the nonce and an absolute `expiresAt` timestamp in epoch milliseconds. +- **Overwrite semantics.** `Map.set` replaces any existing entry for that key. Requesting a second challenge for the same wallet (or the same user) invalidates the first — only the most recently issued nonce for a key is live. A client that fires two challenge requests and then signs the first will fail verification. + +### Sign-in + +`POST /auth/challenge` ([routes/auth.ts](../src/routes/auth.ts)) takes a wallet address, mints a nonce, and returns it embedded in the message the client must sign: + +```text +Sign in to Clicked +Wallet: +Nonce: +``` + +The endpoint is unauthenticated — the wallet address is the only identity available at this point, and it is a public value. It is rate-limited per IP (`auth_challenge`: 10/minute). + +### Device link + +`POST /devices/link/challenge` ([routes/devices.ts](../src/routes/devices.ts)) requires a valid JWT, resolves the account's primary wallet, and mints a nonce keyed by `userId`: + +```text +Link device to Clicked +User: +Nonce: +``` + +The response carries the message, the nonce, and the wallet address the signature must come from. Rate-limited as `device_link_challenge` (10/minute). + +This is a re-authentication step, not a login: the caller already holds a session. The point is to prove wallet ownership _now_, because a JWT alone must not be enough to add a device to an account. + +## 3. Consumption + +Both kinds are burned by the same helper: + +```ts +function consume(target, key, nonce) { + const entry = target.get(key); + if (!entry) return false; + target.delete(key); // burned on read, valid or not + if (Date.now() > entry.expiresAt) return false; + return entry.nonce === nonce; +} +``` + +Three properties follow, and all three are deliberate: + +- **Single use.** The entry is deleted on read, whether or not it turns out to be valid. A second `consume` for the same key returns `false` because there is nothing left to read. +- **Burned even on failure.** A wrong nonce still deletes the stored entry. This is what stops an attacker from guessing repeatedly against one live challenge: each attempt destroys the target, so a guessing run must interleave a fresh `/challenge` call for every attempt, which puts it under the challenge rate limit rather than the (much cheaper) verify path. +- **Expiry checked after deletion.** An expired entry is removed and rejected in the same call. + +`consume` returns a plain boolean; the routes translate a `false` into `401` without revealing whether the nonce was missing, wrong, or expired. + +### Ordering at the call sites + +Both verify routes consume the nonce **before** doing any signature work: + +- `POST /auth/verify` calls `consumeNonce(walletAddress, nonce)` first, records an `auth_failed` audit event with reason `invalid_or_expired_nonce` on failure, and returns `401` before touching `Keypair.verify` or the database. +- `POST /devices/link/verify` calls `consumeDeviceLinkNonce(userId, body.nonce)` first, before resolving the wallet, before verifying the signature, and before any device lookup. + +Consuming first means a replayed request always fails on its second submission, and a caller cannot use the endpoint to run signature verifications against a nonce it keeps alive. + +## 4. Why device linking uses a separate namespace + +The two stores are separate maps, keyed differently on purpose. The header comment in `nonce.ts` states it directly: sharing the login store would let a device-link challenge and a login challenge for the same wallet silently overwrite each other. + +Concretely, with a shared store: + +- A user signed in on device A requests a device-link challenge. If that challenge landed in the sign-in store under the same key, a concurrent sign-in attempt from device B would overwrite it — and vice versa. Whichever flow finished second would fail with "invalid or expired nonce" for reasons the user could not see or fix. +- Because `Map.set` overwrites unconditionally and `consume` burns on any read, an attacker who can reach the unauthenticated `/auth/challenge` endpoint for a known wallet address could repeatedly overwrite the victim's outstanding device-link nonce, making device linking permanently fail. Separate namespaces mean the unauthenticated flow has no handle on the authenticated flow's state at all. + +The separation is reinforced by the key type: the sign-in store is keyed by wallet address, the device-link store by `userId`. Even a string that happens to be valid as both cannot cross over. [nonce.test.ts](../src/__tests__/nonce.test.ts) pins this with a test that issues both kinds under the identical key string and asserts that neither nonce satisfies the other's `consume`. + +The same "separate buckets" reasoning is applied one layer up, in [config/rateLimits.ts](../src/config/rateLimits.ts): `device_link_challenge` and `device_link_verify` mirror the `auth_challenge` and `auth_verify` limits but count in their own buckets, so hammering the device-link flow cannot exhaust a user's sign-in budget, and hammering sign-in cannot lock out device linking. Namespace isolation at the store level and bucket isolation at the rate-limit level are two halves of the same guarantee. + +## 5. TTLs + +- **Sign-in: 5 minutes.** Long enough to cover a wallet-extension prompt the user has to find, unlock, and approve — possibly on a phone. +- **Device link: 2 minutes.** Tighter, because the caller is already signed in and actively performing the flow; there is no unlock-from-cold path to accommodate. A shorter window narrows the period in which a captured challenge could be replayed after a signature is obtained by other means. + +Expiry is lazy. There is no sweeper: an entry sits in the map until someone calls `consume` for that key, at which point it is deleted and rejected. The practical consequences: + +- An abandoned challenge (user requests one and never verifies) leaves a ~90-byte entry in memory until the same key is used again. +- Memory is bounded in practice by the challenge rate limits and by the fact that there is at most one entry per key. It is not bounded by the TTL. On a long-running node under sustained challenge traffic from many distinct wallet addresses, the map grows with the number of distinct keys seen, not with the number of live nonces. If that ever becomes a concern, it is an argument for moving the stores to Redis with native key expiry rather than for adding a sweeper. + +## 6. Where nonces live: restarts and multi-node deployments + +Both stores are **in-process `Map` instances in the Node heap**. They are not in Postgres and not in Redis, unlike the rate-limit counters, which do use Redis so budgets are shared across gateway nodes. + +Two consequences follow, and both are operationally important: + +### Across a restart + +Every outstanding nonce is lost when the process restarts, is redeployed, or crashes. Any client that has a challenge in flight gets `401 Invalid or expired nonce` when it submits the signature. This fails safe — no nonce survives to be replayed against a new process — but it is user-visible: a deploy during a sign-in attempt makes that attempt fail. Clients should treat a nonce rejection as "request a fresh challenge and retry", not as a terminal auth error. + +### Across multiple nodes + +**The challenge and the verification must be served by the same process.** A nonce minted on node A does not exist on node B. In a multi-node deployment this means one of the following must hold: + +- the load balancer pins a client's `/auth/challenge` and `/auth/verify` (and the two `/devices/link/*` calls) to the same backend instance — sticky sessions or connection reuse; or +- the deployment runs a single gateway instance; or +- the stores are moved to a shared backend before scaling out. + +If none holds, sign-in and device linking fail intermittently at a rate that rises with the node count, and the failures look like spurious "invalid or expired nonce" errors rather than like a routing problem. This is the first thing to check when nonce rejections appear after a horizontal scale-out. + +Migrating the stores to Redis (see [lib/redis.ts](../src/lib/redis.ts)) would remove both limitations: `SET key value NX PX ` for `issue` and an atomic `GETDEL`-style consume preserve exactly the burn-on-read semantics described above, with expiry handled natively. + +## 7. Replay resistance: what this buys, and what it does not + +### What it provides + +- **Signature replay is prevented within a flow.** A captured `/auth/verify` body cannot be resubmitted: the nonce it carries was burned by the first submission. The same holds for `/devices/link/verify`. +- **Cross-flow replay is prevented.** The signed messages differ in prefix (`Sign in to Clicked` vs `Link device to Clicked`) and in body (wallet address vs user id), and the nonces live in separate keyspaces, so a signature captured from one flow is not valid for the other. +- **Freshness is bounded.** A signature is only useful inside the nonce's TTL — 5 minutes for sign-in, 2 for device linking. +- **Online guessing is expensive.** 128 bits of entropy, burn-on-read on a wrong guess, and rate limits on both the challenge and the verify endpoints. +- **Wallet signatures cannot be harvested for other purposes.** The signed string is scoped to this application and to a server-issued value, so a signature obtained elsewhere for a different message does not verify here. + +### What it does not provide + +- **No protection against an attacker who controls the wallet.** The nonce proves _freshness_ of a signature, not that the signer is the legitimate account holder. A compromised or malicious wallet passes every check here. Device linking is the mitigation for the account-level version of this: linking a new device requires a fresh wallet signature and is audited, and existing devices can be revoked. +- **No protection against a same-session in-flight attacker.** Anyone who can read the challenge response _and_ get the user to sign it (a malicious page, a compromised client, an attacker holding the wallet unlock) can complete the flow once. Single-use only stops the _second_ use. +- **Nothing is bound to a transport or a client.** The nonce is not tied to an IP, a TLS session, or a device fingerprint. A nonce obtained by client X can be verified by client Y if Y can produce the signature. Transport-level binding is handled separately — see [Transport security and pinning](../../../docs/security/tls-and-pinning.md). +- **No cross-node or cross-restart guarantee**, per section 6. The replay guarantee is per-process. It is not weakened by a restart (state is lost, not duplicated), but availability is. +- **The device-link nonce does not prove the new device is trustworthy.** It proves the wallet approved adding _a_ device at that moment. Verifying the identity of the device itself is the safety-number / fingerprint job — see [E2EE onboarding](e2ee-onboarding.md). + +## 8. Tests + +[src/\_\_tests\_\_/nonce.test.ts](../src/__tests__/nonce.test.ts) covers both stores: format (`/^[0-9a-f]{32}$/`), successful consume, single-use, wrong-nonce rejection, unknown-key rejection, cross-user rejection, keyspace separation between the two stores, and TTL boundaries on both sides using fake timers (just-before-expiry accepted, just-after rejected). diff --git a/apps/web/docs/components-treasury-wallet.md b/apps/web/docs/components-treasury-wallet.md new file mode 100644 index 0000000..f682c5e --- /dev/null +++ b/apps/web/docs/components-treasury-wallet.md @@ -0,0 +1,202 @@ +# Treasury and wallet components + +Reference for the components that surface on-chain state and initiate signed operations: + +- [src/components/treasury/ProposalCard.tsx](../src/components/treasury/ProposalCard.tsx) +- [src/components/treasury/ProposeWithdrawalModal.tsx](../src/components/treasury/ProposeWithdrawalModal.tsx) +- [src/components/wallet/WalletConnectButton.tsx](../src/components/wallet/WalletConnectButton.tsx) + +All three are client components (`'use client'`). For the flow-level view of how wallet auth, backend persistence, and Soroban invocation fit together, see [Wallet integration, treasury UI, and proposal flow](concepts-wallet-treasury-ui.md) and [Soroban contract client usage](api-soroban-client.md). For the on-chain semantics behind proposals and disbursement, see the contract docs cross-linked in [section 5](#5-on-chain-semantics-contract-docs). + +## 1. `ProposalCard` + +Renders one treasury proposal and lets the viewer cast an approve or reject vote. + +### Props + +| Prop | Type | Required | Description | +| ---------- | --------------------------------------------------- | -------- | ------------------------------------------------------------------------------------- | +| `proposal` | `Proposal` | yes | The row to render. See the shape below. | +| `onVoted` | `(id: string, vote: 'approve' \| 'reject') => void` | no | Called after the backend accepts a vote, so the parent can refetch or patch its list. | + +### Data it expects + +`Proposal` is exported from the same module and mirrors what `GET /treasury/proposals` returns: + +```ts +interface Proposal { + id: string; // backend row id — used in the vote URL + proposalId: string; // on-chain / display proposal number + status: 'active' | 'approved' | 'rejected' | 'executed' | 'expired'; + approvalsCount: number; + rejectionsCount: number; + recipient: string | null; + amount: string | null; + token: string | null; + threshold: number; // approvals needed + hasVoted: boolean; // whether the current user already voted + myVote: 'approve' | 'reject' | null; +} +``` + +Note the two distinct identifiers: `id` addresses the backend row (it is what the vote request is posted to), while `proposalId` is the number displayed in the header and the value that goes into the signed message. + +Nullable fields render as placeholders — `recipient` becomes `—` via `truncateAddress`, and `amount`/`token` fall back to `—` and an empty string. The card never throws on an incomplete row. + +`approvalsCount / threshold` drives the progress bar, clamped to 100%. + +### Action it triggers: casting a vote + +`castVote(type)` runs when **Approve** or **Reject** is clicked. It is **Freighter first, then the backend REST API** — both, in that order: + +1. **Freighter (local signing, no chain interaction).** ``signWalletMessage(`${type}:${proposal.proposalId}`)`` from [src/lib/freighter.ts](../src/lib/freighter.ts) asks the extension to sign the string `approve:14` or `reject:14`. This is a `signMessage` call, not a transaction — nothing is submitted to Soroban and no fee is paid. +2. **Backend REST.** `POST /treasury/proposals/:id/{approve|reject}` with `{ signature }` and the bearer token from `useAuth()`. The backend verifies the signature and records the vote. + +If step 1 throws, step 2 never runs — the component returns early with the toast `Freighter signing was cancelled or failed`. There is no fallback path that posts an unsigned vote. + +The card does **not** call the treasury contract directly. Vote recording is off-chain in the current backend (see [Treasury API](../../backend/docs/api-treasury.md)); the contract docs describe the on-chain model the backend is designed to mirror. + +### Local state and disabling + +| State | Meaning | +| ----------- | ------------------------------------------------------------------------------------------------ | +| `voting` | `'approve' \| 'reject' \| null` — which button is mid-flight. Buttons read `Signing…` while set. | +| `localVote` | Initialized from `proposal.myVote`; set optimistically after a successful POST. | + +Both buttons are disabled when `proposal.hasVoted`, when `localVote` is non-null, when `proposal.status !== 'active'`, or while any vote is in flight. `voting` is cleared in a `finally`, so a failed request re-enables the buttons. + +`localVote` is set only after a `res.ok`, so a rejected vote does not leave the card falsely showing a cast vote. The component does not refetch on its own — the parent page ([src/app/app/treasury/page.tsx](../src/app/app/treasury/page.tsx)) owns the list and also patches counts live from the `treasury_proposal_updated` socket event. + +### Feedback + +Toasts via `useToast()`: success on a recorded vote, error on a signing failure or on a non-OK response (the backend's `error` field is surfaced when present, otherwise `Failed to approve proposal` / `Failed to reject proposal`). + +## 2. `ProposeWithdrawalModal` + +A controlled modal form that submits a new withdrawal proposal. + +### Props + +| Prop | Type | Required | Description | +| ----------- | ------------ | -------- | ------------------------------------------------------------------------------------------------------------ | +| `isOpen` | `boolean` | yes | Visibility, forwarded to [`Modal`](../src/components/ui/Modal.tsx). | +| `onClose` | `() => void` | yes | Called on dismiss and after a successful submit. | +| `onSuccess` | `() => void` | yes | Called after a successful submit, before `onClose` — the treasury page uses it to refetch the proposal list. | + +The component is fully uncontrolled internally: the parent supplies no form values. + +### Data it collects + +| Field | Control | Validation | +| ----------- | -------------------- | -------------------------------------------------------------------------------------------------------------- | +| `amount` | `input[type=number]` | `min="0.0000001"`, `step="any"`, required; re-checked as `parseFloat(amount) > 0` before submit | +| `token` | `select` | One of `XLM`, `USDC`, `AQUA` | +| `recipient` | `input[type=text]` | `/^G[A-Z2-7]{55}$/` — Stellar public-key format, checked on blur, on submit, and live once an error is showing | +| `ttl` | `select` | `24h`, `72h`, or `7d` | + +The recipient regex is a format check only. It does not confirm the account exists, is funded, or has a trustline for the selected token — those are chain-side concerns; see [TTL and proposal semantics](#5-on-chain-semantics-contract-docs). + +### Action it triggers: submitting a proposal + +`handleSubmit` is **backend REST only — no Freighter call, no contract call, no signature**: + +1. Validate `recipient`, then `amount`. +2. Read the JWT from `window.localStorage.getItem('clicked.jwt')`, guarded by a `typeof window !== 'undefined'` check. +3. `POST /treasury/propose` with `{ amount, token, recipient, ttl }` and the bearer token. +4. On success: success toast, `onSuccess()`, `onClose()`, then reset every field to its default. + +Creating a proposal therefore costs nothing on-chain and requires no wallet interaction — only voting does. If the wallet is disconnected, this form still works as long as the session JWT is valid. + +A non-OK response surfaces the backend's `error` field (falling back to `Failed to submit proposal`) and leaves the form populated so the user can correct and resubmit. A thrown `fetch` yields `Network error — please try again`. `loading` disables the submit button and is cleared in a `finally`. + +Note that this component reads the token from `localStorage` directly rather than through `useAuth()` as `ProposalCard` does. Both resolve to the same session token; see [Auth and session contract](contracts-auth-session.md). + +## 3. `WalletConnectButton` + +The connect/disconnect control plus a small account menu. Takes **no props** — everything comes from `useWallet()` ([src/contexts/WalletContext.tsx](../src/contexts/WalletContext.tsx)), so it must be rendered inside a `WalletProvider` or the hook throws. + +### Data it consumes + +| From `useWallet()` | Type | Use | +| ------------------ | ----------------------- | ------------------------------------------------------ | +| `publicKey` | `string \| null` | Non-null means connected; drives which branch renders. | +| `connect` | `() => Promise` | Calls `requestWalletAccess()` and stores the address. | +| `disconnect` | `() => void` | Clears the cached address. | + +### Actions it triggers + +| Control | Target | +| --------------------- | -------------------------------------------------------------------------------------- | +| **Connect Wallet** | Freighter only — `requestAccess()` via `connect()`. No backend call, no contract call. | +| **Copy address** | `navigator.clipboard.writeText(publicKey)`, then a browser `alert`. Local only. | +| **Edit profile** | `router.push('/app/profile')`. Local navigation. | +| **Disconnect wallet** | `disconnect()` — clears in-memory context state only. | + +Connecting is purely a wallet handshake: it does **not** sign a challenge and does **not** create a backend session. Wallet connection and app authentication are separate steps in this codebase — see [Auth and device lifecycle](concepts-auth-device-lifecycle.md). + +Disconnecting only clears React state. Freighter still considers the site authorized, so a subsequent **Connect Wallet** typically resolves without a fresh extension prompt. + +### Local state and cleanup + +`isConnecting` disables the button and shows `Connecting…`; `error` renders the failure message beneath the button; `isDropdownOpen` gates the menu. + +The dropdown registers `mousedown` and `keydown` (Escape) listeners on `document` while open, inside a `useEffect` keyed on `isDropdownOpen`. The cleanup removes both listeners unconditionally, so no handler outlives the component. `dropdownRef` scopes the outside-click test. + +### Mounting note + +This component is not currently rendered by the live app — the sidebar in [src/app/app/layout.tsx](../src/app/app/layout.tsx) duplicates the same connect logic inline. It is documented here as the canonical standalone control; see the note in [Soroban contract client usage](api-soroban-client.md). + +## 4. User-visible states + +These are the states a user can land in across the three components, and what each one actually does today. + +### Freighter not installed + +`requestWalletAccess()` calls `@stellar/freighter-api`'s `requestAccess()`. With no extension present the call rejects or returns a response with no `address`/`publicKey`, and the helper throws `Unable to read Freighter public key`. + +- **`WalletConnectButton`** catches it, renders the message under the button, and clears `isConnecting`. The button stays available for a retry. +- **`ProposalCard`** catches any signing failure and shows `Freighter signing was cancelled or failed` — it does not distinguish "not installed" from "declined". +- **`ProposeWithdrawalModal`** is unaffected; it never touches the wallet. + +There is no install prompt or extension-detection banner in these components. `transferToken` in [src/lib/soroban.ts](../src/lib/soroban.ts) does a real `isConnected()` probe and throws `Freighter not installed or not connected`, but that path is used by the chat token-transfer flow, not by these components. + +### Not connected + +`publicKey` is `null`, so `WalletConnectButton` renders the **Connect Wallet** branch. + +`ProposalCard` does **not** check `publicKey` before voting. Clicking Approve on a disconnected wallet calls `signWalletMessage` anyway, which causes Freighter to surface its own connect/unlock prompt — approving there completes the flow, dismissing it produces the generic signing-failed toast. If the extension is locked, the same prompt appears. + +Proposal creation works while disconnected, since it is authenticated by the session JWT rather than by the wallet. + +### Wrong network + +`signWalletMessage` is a message signature, not a transaction, so it carries no network passphrase and **cannot fail on a network mismatch**. Voting therefore succeeds regardless of which network Freighter is pointed at; the backend verifies the signature against the wallet's public key, which is network-independent. + +Network selection matters only for actual contract submission. `transferToken` builds against `NEXT_PUBLIC_NETWORK_PASSPHRASE` (defaulting to `Networks.TESTNET`) and `NEXT_PUBLIC_SOROBAN_RPC_URL` (defaulting to the public testnet RPC). If Freighter is on a different network than the passphrase the transaction was built with, the extension rejects the signature and the error surfaces through that flow's own handling. + +None of the three components documented here render a network badge or a "switch network" prompt. A user on the wrong network sees no warning until they attempt a real on-chain transfer. + +### Signature rejected or cancelled + +The user dismisses the Freighter prompt, or the extension rejects the request. + +- **`ProposalCard`**: `signWalletMessage` rejects, the inner `try/catch` fires `Freighter signing was cancelled or failed`, `castVote` returns before any network request, and `voting` is cleared in the outer `finally`. The buttons re-enable and no vote is recorded anywhere. Retrying is safe — the whole flow re-runs from the signature. +- **`WalletConnectButton`**: a declined `requestAccess()` rejects, and the message renders under the button. `publicKey` stays `null`. + +Because the signature is obtained before the POST, a rejection can never leave a half-recorded vote. The failure modes are ordered so that everything cancellable happens before anything durable. + +### Backend rejects the request + +For both voting and proposal creation, a non-OK response is surfaced as a toast using the backend's `error` field when present. `ProposalCard` leaves `localVote` untouched so the buttons stay live; the modal leaves the form filled. Neither component retries automatically. + +## 5. On-chain semantics: contract docs + +The components above are the UI surface. The rules they are surfacing — how proposals are created, approved, expire, and disburse — live in the Soroban contracts: + +- [Proposal lifecycle](../../../contracts/docs/concepts-proposal-lifecycle.md) — states a proposal moves through, approval thresholds, and how expiry is decided on-chain. This is the authority for what `status`, `threshold`, and `approvalsCount` mean; `ProposalCard` only renders them. +- [Proposals API](../../../contracts/docs/api-proposals.md) — the contract entry points for creating a proposal and recording votes. +- [Token transfer flow](../../../contracts/docs/concepts-token-transfer-flow.md) and [Token transfer API](../../../contracts/docs/api-token-transfer.md) — how an approved withdrawal actually moves funds. +- [Token transfer storage](../../../contracts/docs/contracts-token-transfer-storage.md) — on-chain storage layout and TTL/rent behaviour. +- [Deployment and invocation](../../../contracts/docs/api-deployment-invocation.md) — deploying the contracts and the network/RPC configuration the frontend env vars must match. + +Backend-side counterparts: [Treasury API](../../backend/docs/api-treasury.md) covers the REST endpoints these components call, including the TTL-to-ledger conversion behind the `24h` / `72h` / `7d` options in the modal, and the current off-chain-only status of vote recording. diff --git a/apps/web/docs/hooks.md b/apps/web/docs/hooks.md new file mode 100644 index 0000000..9f79b10 --- /dev/null +++ b/apps/web/docs/hooks.md @@ -0,0 +1,331 @@ +# React hooks reference + +Reference for the hooks in [src/hooks/](../src/hooks/): + +| Hook | Purpose | Owner model | +| ------------------------------------------------- | -------------------------------------------------------------- | ------------------------ | +| [`useSocket`](#usesocket) | Opens and owns one Socket.IO connection | One per connection | +| [`useInboundPipeline`](#useinboundpipeline) | Decrypts inbound envelopes into renderable messages | Single owner | +| [`useMessageHistory`](#usemessagehistory) | Paginated message list over the `message_history` socket event | Single owner | +| [`useLocalSearch`](#uselocalsearch) | Debounced query state over the local encrypted search index | Safe to mount repeatedly | +| [`useMessageSearchIndex`](#usemessagesearchindex) | Decrypts and indexes messages into the local search store | Single owner | +| [`usePushSubscription`](#usepushsubscription) | Service-worker registration and Web Push subscription | Effectively single owner | + +Every hook here is in a `'use client'` module. See [Message pipeline](concepts-message-pipeline.md), [Local search](concepts-local-search.md), [Push subscription](concepts-push-subscription.md), and [WebSocket client](api-websocket-client.md) for the surrounding architecture. + +--- + +## `useSocket` + +[src/hooks/useSocket.ts](../src/hooks/useSocket.ts) + +Creates a Socket.IO connection, drives resume/sync on connect, and acknowledges delivery of inbound envelopes. + +### Arguments + +| Argument | Type | Notes | +| -------- | ---------------- | ------------------------------------------------------------------------------ | +| `token` | `string \| null` | The session JWT. `null` yields `null` — the hook is safe to call before login. | + +### Returns + +`Socket | null` — the live client, or `null` while `token` is `null`. + +The socket is built in a `useMemo` keyed on `token`. A changed token tears down the old socket and builds a new one; a re-render with the same token returns the same instance. + +Connection options: `auth: { token, deviceId }` where `deviceId` comes from `getRealtimeDeviceId(token)`, `transports: ['websocket']`, `reconnection: true`. The URL is `NEXT_PUBLIC_SOCKET_URL`, falling back to `NEXT_PUBLIC_BACKEND_URL`, then `http://localhost:3001`. + +### Side effects + +On `connect` (and immediately if the socket is already connected), it runs `resumeThenSync()`: + +1. Emits `resume` with `{ lastEventId: getResumeCursor(token) }`. +2. Awaits `runSocketSync(socket, token)`. + +It also registers three listeners: + +| Event | Handler | +| ------------------ | ---------------------------------------------------------------------------------------------------------------- | +| `resume_complete` | Stores `lastEventId` as the new resume cursor; re-runs sync when `syncRequired` is set. | +| `ephemeral_replay` | Advances the resume cursor and re-dispatches the replayed event locally via `replaySocketEvent`. | +| `message_envelope` | Emits `message_delivered` back to the server with the conversation, message, envelope, and sequence identifiers. | + +Cursors are persisted through [src/lib/realtime.ts](../src/lib/realtime.ts), which reads and writes `localStorage` under `clicked.socket.*` keys. + +### Cleanup + +The effect sets a `closed` flag (so an in-flight `resumeThenSync` stops), removes all four listeners, and calls `socket.disconnect()`. Unmounting therefore closes the connection. + +### Mounting + +**Each call to `useSocket` owns its own connection.** There is no module-level singleton and no context — two components calling `useSocket(token)` open two independent WebSockets to the gateway, each with its own resume cycle and each acknowledging delivery separately. The app does this today: [treasury/page.tsx](../src/app/app/treasury/page.tsx), [`conversations/[id]/page.tsx`](../src/app/app/conversations/%5Bid%5D/page.tsx), and [ConversationListSidebar.tsx](../src/components/conversations/ConversationListSidebar.tsx) each mount one. + +That works, but it is not free: connection count scales with mount count, and because all instances share the same `localStorage` resume cursor, concurrent sockets can advance the cursor past events another instance has not processed. Prefer passing an existing socket down as a prop over mounting a second `useSocket` in the same subtree. + +--- + +## `useInboundPipeline` + +[src/hooks/useInboundPipeline.ts](../src/hooks/useInboundPipeline.ts) + +The inbound decryption and render pipeline: receives envelopes live or through catch-up sync, decrypts and verifies them, and exposes messages ordered by sequence number. + +### Arguments + +Single options object: + +| Field | Type | Notes | +| ---------------- | ---------------- | ------------------------------------------------------------------------------------------------------- | +| `socket` | `Socket \| null` | Normally the return value of `useSocket`. `null` is tolerated — live listeners are simply not attached. | +| `token` | `string \| null` | Session JWT, used for the sync fetch and for key lookup. | +| `conversationId` | `string` | Every inbound event for a different conversation is ignored. | + +### Returns + +```ts +{ messages: InboundMessage[]; syncing: boolean } +``` + +`messages` is memoized and sorted by `sequenceNumber`. `syncing` is `true` while the catch-up fetch loop is running. + +A message can appear with `status: 'pending'` (metadata arrived, ciphertext has not), `status: 'unavailable'` with `unavailableReason: 'pre-link'` (sent before this device was linked, so it is undecryptable by design), or fully decrypted. + +### Side effects + +**Live delivery.** While `socket` is non-null, it listens for: + +- `message_envelope` — a complete envelope; decrypted immediately. +- `device_envelope` — ciphertext only; held in a `pendingCiphertext` ref until matching metadata arrives. +- `new_message` — metadata only; held in a `pendingMeta` ref, then joined with any pending ciphertext. + +The two-ref join exists because ciphertext and metadata arrive as separate events with no ordering guarantee. Whichever lands second triggers the decrypt. + +**Catch-up sync.** A second effect runs `runSync()` whenever `token` or `conversationId` changes: it reads the E2EE device id from the token and pages `GET /sync?deviceId=…&sinceSequence=…` until `hasMore` is false, feeding each envelope for this conversation through the same decrypt path. The cursor lives in a ref and survives re-renders. + +**De-duplication.** A `processing` ref holds message ids currently being decrypted, so the same message arriving twice (live and again via sync) is decrypted once. Results are merged into a `Map` keyed by message id via `mergeInboundMessage`. + +Decryption runs in [src/lib/crypto/processEnvelope.ts](../src/lib/crypto/processEnvelope.ts) and touches WebCrypto and IndexedDB-backed key stores. + +### Cleanup + +The live-delivery effect removes all three listeners on teardown or when `socket`/`conversationId` changes. It does **not** disconnect the socket — that belongs to `useSocket`. + +The sync effect has **no cancellation**. An in-flight `runSync` for a previous `conversationId` keeps paging after the id changes; per-envelope conversation filtering keeps stale results out of state, but the requests continue. Rapidly switching conversations can leave several overlapping sync loops running. + +### Mounting + +**Single owner per conversation.** Each instance keeps its own message map and its own sync cursor, so two instances mounted on the same `conversationId` decrypt everything twice, run two independent paging loops against `/sync`, and duplicate all the crypto work. Mount it once, at the component that owns the thread, and pass `messages` down. + +--- + +## Ordering: `useSocket` before `useInboundPipeline` + +`useInboundPipeline` does not create a connection — it attaches listeners to one it is handed. The two hooks must therefore be called in that order in the same component: + +```tsx +const socket = useSocket(token); // 1. must come first +const { messages } = useInboundPipeline({ + socket, // 2. consumes what step 1 produced + token, + conversationId, +}); +``` + +This is what [`conversations/[id]/page.tsx`](../src/app/app/conversations/%5Bid%5D/page.tsx) does. Three things depend on the ordering: + +**Data dependency.** `socket` is an argument to `useInboundPipeline`. Calling the pipeline first means passing `null`, and the pipeline attaches no listeners at all until a later render supplies the socket. + +**Effect ordering within the component.** React runs effects in the order the hooks were called. `useSocket`'s effect runs first and registers the `connect` handler; `useInboundPipeline`'s effect runs immediately after and registers `message_envelope`, `device_envelope`, and `new_message`. Because the socket is created fresh in `useMemo` and connects asynchronously, the `connect` event — and therefore the `resume` emit and the server's replay of buffered events — cannot fire until after the pipeline's listeners are attached. Reversing the order would mean the pipeline attaches its listeners on a socket that may already be mid-replay, and replayed envelopes would be dropped. + +**Teardown ordering.** React runs cleanups in the same order. `useSocket`'s cleanup disconnects; the pipeline's cleanup only detaches handlers, so it does not matter that it runs after. The pipeline must never call `disconnect()` itself — doing so would close a connection other consumers of the same socket still hold. + +The same rule applies to `useMessageHistory`, which likewise consumes a socket it does not own. + +--- + +## `useMessageHistory` + +[src/hooks/useMessageHistory.ts](../src/hooks/useMessageHistory.ts) + +Client side of the backend `message_history` socket event: keeps a paginated, oldest-first message list and appends live arrivals. + +### Arguments + +| Field | Type | Notes | +| ---------------- | ---------------- | ------------------------------------------------ | +| `socket` | `Socket \| null` | Not owned by this hook. | +| `conversationId` | `string` | Changing it resets all state for the new thread. | + +### Returns + +```ts +{ + messages: ChatMessage[]; // oldest-first + loadingOlder: boolean; + hasReachedStart: boolean; // server returned an empty page + loadOlder: () => void; // fetch one page older than the current oldest +} +``` + +The return object is memoized, so it is stable between renders when nothing changed. + +### Side effects + +- Listens for `message_history` acks, ignoring those for other conversations. New pages are filtered against the ids already in state, sorted oldest-first, and prepended. An empty page or `done: true` sets `hasReachedStart`. +- Listens for `new_message` and appends, skipping ids already present. `content` is taken from `ciphertext` when present, falling back to `content`. +- `loadOlder()` emits `message_history` with `{ conversationId, before: }`. It no-ops while `loadingOlder` is set or once `hasReachedStart` is true. +- Calls `useMessageSearchIndex(messages)` internally, so every message it holds is decrypted and pushed into the local search index. **This is a side effect of mounting the hook**, and it is why mounting it twice is expensive. + +All per-conversation state lives in one state object, reset by comparing `conversationId` against the previous render's value during render — React's documented pattern for resetting state on a prop change, no effect involved. De-duplication is keyed on message id, which is what lets consumers use `id` as a React key safely. + +### Cleanup + +Both listener effects remove their handlers on teardown or when `socket`/`conversationId` changes. There is no cleanup for `loadOlder` — a request in flight when the hook unmounts simply has no listener left to receive its ack. + +### Mounting + +**Single owner per conversation.** Two instances both listen for `message_history` and both handle every ack, so one `loadOlder()` call fills both lists; more importantly, both re-index the same messages through `useMessageSearchIndex`, duplicating decryption work. + +--- + +## `useLocalSearch` + +[src/hooks/useLocalSearch.ts](../src/hooks/useLocalSearch.ts) + +Debounced query state over the local encrypted search index. + +### Arguments + +Options object, all optional: + +| Field | Type | Default | Notes | +| ---------------- | -------- | ------- | ------------------------------------------------ | +| `conversationId` | `string` | — | Scopes results; omit to search everything. | +| `debounceMs` | `number` | `180` | Delay between the last keystroke and the search. | +| `minQueryLength` | `number` | `2` | Shorter queries clear results without searching. | + +### Returns + +```ts +{ + query: string; + setQuery: (q: string) => void; + hits: SearchHit[]; + total: number; + loading: boolean; + error: string | null; + clear: () => void; +} +``` + +Note that the returned object is **not** memoized — it is a fresh object every render. Consumers should destructure rather than pass it whole into a dependency array. + +### Side effects + +An effect debounces `query` with `setTimeout` and calls `search()` from [src/lib/search/searchClient.ts](../src/lib/search/searchClient.ts), which posts to the search Web Worker. The worker is created lazily by the search client on first use. + +Out-of-order results are handled with a monotonic run counter (`abortRef`): each run takes an id, and a resolved search whose id is no longer current is discarded. The request itself is not aborted — only its result is ignored. + +### Cleanup + +The debounce effect clears its timeout, so a pending search is cancelled when the query changes or the component unmounts. Nothing else needs teardown. + +### Mounting + +**Safe to mount more than once.** State is entirely local, it owns no connection and no listener, and the Web Worker behind `searchClient` is a module-level singleton shared by all callers. Several independent search boxes can coexist; each keeps its own query and results. + +--- + +## `useMessageSearchIndex` + +[src/hooks/useMessageSearchIndex.ts](../src/hooks/useMessageSearchIndex.ts) + +Decrypts messages and writes them into the local search store. Called internally by `useMessageHistory`. + +### Arguments + +`messages: IndexableMessage[]` — id, conversation, sender, optional `senderDeviceId` / `senderIdentityPublicKey`, `ciphertext` (or legacy `content`), `contentType`, `createdAt`, and `sequenceNumber`. + +### Returns + +Nothing. It exists purely for its side effect. + +### Side effects + +For each message it calls `decryptMessageText(ciphertext, senderDeviceId, senderIdentityPublicKey)` from [src/lib/crypto/messageCrypto.ts](../src/lib/crypto/messageCrypto.ts), skipping anything that does not decrypt, then hands the batch to `indexMessages()`. That writes the rows encrypted-at-rest into IndexedDB and updates the Web Worker's inverted index. Failures are logged with `console.warn` and swallowed — indexing never breaks rendering. + +The effect is keyed on the `messages` array **identity**, not its contents. A caller that rebuilds the array on every render re-decrypts the whole list every render; `useMessageHistory` avoids this by keeping the array stable in state. + +### Cleanup + +A `cancelled` flag set in the cleanup prevents a completed batch from being written after unmount. Decryption already in progress is not aborted — the results are just discarded. + +### Mounting + +**Single owner per message set.** Mounting it twice over the same messages doubles the decryption work for an identical result. `indexMessages` is idempotent, so the outcome is correct; the cost is not. + +--- + +## `usePushSubscription` + +[src/hooks/usePushSubscription.ts](../src/hooks/usePushSubscription.ts) + +Registers the service worker and manages the Web Push subscription. + +### Arguments + +`token: string | null` — the session JWT. `null` skips the VAPID fetch and disables subscribing. + +### Returns + +```ts +{ + permission: NotificationPermission; // 'default' | 'granted' | 'denied' + subscribed: boolean; // true once posted to the server + requestSubscription: () => Promise; +} +``` + +`requestSubscription` is safe to call repeatedly: it reuses an existing `PushSubscription` when one exists rather than creating a second. + +### Side effects + +Three effects, each guarded: + +1. **Service worker registration** — bails out when `window` is undefined or when `serviceWorker`/`PushManager` are unavailable, then registers `/sw.js` and stores the registration. +2. **VAPID key fetch** — with a token, calls `GET /push/vapid-public-key` through `fetchVapidPublicKey` (also exported standalone). The public key comes from the backend rather than a build-time env var so it cannot drift from the private key the backend signs with. Returns `null` on any failure, which leaves push registration skipped rather than broken. +3. **Existing-subscription reuse** — once registration, token, and VAPID key are all present and permission is already `granted`, it fetches any existing subscription, marks `subscribed`, and re-POSTs it to `/push/subscriptions` (idempotent), so the server is re-synced after a reinstall or a database restore. + +`requestSubscription()` calls `Notification.requestPermission()`, returns early unless the result is `granted`, then reuses or creates a subscription with `applicationServerKey` derived from the base64url VAPID key, POSTs it, and sets `subscribed`. + +### Cleanup + +Each effect uses an `active` flag cleared on teardown, so a resolved promise cannot `setState` after unmount. Nothing is unregistered or unsubscribed — the service worker and the push subscription intentionally outlive the component, which is the point of push. + +### Mounting + +**Effectively single owner.** Every instance registers `/sw.js` (the browser deduplicates this, so it is not harmful) and each keeps its own `permission`/`subscribed` state, which then diverges: one instance calling `requestSubscription` does not update another's `subscribed`. Multiple instances can also race to POST the same subscription — harmless, since the endpoint is idempotent, but wasteful. Mount it once, near the root; [PushPermissionPrompt.tsx](../src/components/PushPermissionPrompt.tsx) is the single consumer today. + +--- + +## SSR constraint + +Every hook in this directory is in a `'use client'` module, but in the Next.js App Router that only means the component hydrates on the client — it is still **pre-rendered on the server**. Render-phase code runs in Node, where `window`, `navigator`, `localStorage`, `IndexedDB`, `Worker`, and `crypto.subtle` are absent or behave differently. + +The rule this codebase follows: + +> Anything touching `window`, IndexedDB, WebCrypto, or a Web Worker belongs in an effect, or behind an explicit `typeof window !== 'undefined'` guard. Effects never run during server render, which is what makes them safe. + +How each hook satisfies it: + +| Hook | Browser API | How it is kept off the server | +| ----------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `useInboundPipeline` | WebCrypto, IndexedDB, `fetch` | All decryption and sync run inside effects and callbacks. | +| `useMessageHistory` | Socket listeners | Listener registration and emits are inside effects and callbacks. | +| `useLocalSearch` | Web Worker | The worker is created lazily on the first search, from an effect. | +| `useMessageSearchIndex` | IndexedDB, WebCrypto | The whole body is inside a `useEffect`. | +| `usePushSubscription` | `navigator.serviceWorker`, `Notification` | Effects, plus a `typeof window !== 'undefined' && 'Notification' in window` guard on the lazy `useState` initializer, which _does_ run during render. | + +`useSocket` is the exception worth knowing about: it constructs the Socket.IO client inside a `useMemo`, which runs during render — on the server too, when `token` is non-null. In practice server renders have no token, so the memo short-circuits to `null` and no connection is attempted. **Do not pass a server-resolved token into `useSocket`.** If a route ever needs one, gate the subtree behind a mounted flag or a `next/dynamic` import with `ssr: false`; moving the connection into an effect would be the more robust fix. + +The same caution applies to `getRealtimeDeviceId`, which reads `localStorage`. It guards with `typeof window === 'undefined'` and falls back to the device id decoded from the JWT claims, so it is safe to call in either environment — but it returns different values on server and client, which is a hydration-mismatch source if its result is ever rendered.