AGENTS.md · diff
git:20260924.123a73e to git:20260929.61b38e7
314 added, 61 removed. Audit A to A.
# AGENTS.md
Guidance for AI agents and contributors working on this repository.
## Project
gupt is a self-hosted, end-to-end encrypted messenger built with Vue 3 + Vite.
There is no backend — encryption, caching, and publishing all happen in the
browser, and messages are stored on user-chosen Nostr relays as encrypted
events. It ships as a PWA with a service worker, plus Flatpak packaging.
Core concepts:
- **Not a Nostr app** — Nostr relays are used as dumb transport only. We are
not bound to follow NIP specs; if our own implementation achieves something
better, prefer it (e.g. self-addressed encrypted stream tags, NIP-40-style
expirations tuned to our own lifetimes).
- **Identity** — a secp256k1 keypair derived from memory anchors (password,
PIN, dates, and other remembered values) via SHA-256 + Argon2id, or restored
from a pasted hex key / Gupt backup / secret. The private key never leaves
the browser (`src/lib/secureKey.js`).
- **Messaging** — encrypted DMs, group chats, and peer-to-peer WebRTC
calls (`src/lib/webrtc/`), with NIP-40 expirations and read receipts.
- **Streams** — self-addressed encrypted Kind-1 items (bookmarks,
passwords, notes) stored under a `gupt_*` tag; ciphertext sits in a tag
so relay metadata stays public while content stays private.
- **Cache** — an IndexedDB database (`src/lib/idb.js`) with TTL rows, read
cache-first and refreshed from relays, backed by a background replication
worker.
## Repository layout
```
src/
lib/ domain logic (crypto, idb, relay, sendQueue, stream libs, webrtc)
- lib/relay/ Nostr pool, publish, subscribe, selection, outcomes
- stores/ Pinia stores (identity, messenger, calls, settings)
+ lib/relay/ Nostr pool, publish, subscribe, selection, outcomes, hints, health
+ lib/webrtc/ call session, peer plumbing, SAS, constants
+ stores/ Pinia stores (identity, messenger, calls, replication, settings, theme, profiles)
views/ route-level pages
- components/ shared + feature components (chatv2/, settings/, …)
- composables/ shared composables (e.g. useReplicationWorker)
+ components/ shared + feature components (chat/, chatv2/, settings/, home/)
+ composables/ shared composables (useReplicationWorker, useConversations, useCall*)
config/ static config (servers, retention)
router/ vue-router routes
sw.js PWA service worker entry
bin/ static server for dist/
flatpak/ Flatpak manifests
```
Module imports use the `@/` alias → `./src/` (see `jsconfig.json`).
+ Routing is hash-based (`createWebHashHistory`) and every route-level view is
+ lazy-loaded. `src/lib/sync.js` is a thin indirection over the messenger store
+ (`startAppSync` → `messenger.start`, `reconcileFromRelays` → `messenger.reconcile`);
+ views should not reach into `stores/messenger.js` for sync orchestration.
+
## Commands
```sh
npm run dev # format src/ with oxfmt, then start Vite dev server
npm run build # vite build (+ PWA service worker)
npm run build:flatpak
npm start # node bin/gupt.js (static server for dist/)
```
Formatting is enforced via `oxfmt` — run `npx oxfmt <file>` (or the whole
`src/`) before committing. There is no separate linter. Note: `npm run dev`
reformats `src/` automatically on startup. `dist/` is gitignored; build
artifacts are never committed.
## Code style
- JavaScript, ES modules (`type: "module"`), no semicolons.
- No comments unless they carry real context (JSDoc for exported helpers is OK).
- Follow the pattern of neighboring files: domain logic in `src/lib`, state in
`src/stores` (Pinia), UI in `src/views` + `src/components`, relay code under
`src/lib/relay/`.
- UI is Tailwind CSS v4 utility classes driven by CSS variables like
`--app-text`, `--app-primary`, `--app-border` (defined in `src/index.css`);
icons come from `@lucide/vue`.
+ - `vite.config.js` strips `console.log` / `console.debug` in production but
+ keeps `console.warn` / `console.error`. A no-op `log()` stub is the codebase's
+ pattern for disabled logging (see `src/lib/sendQueue.js`).
## UI and layout guidelines
To keep the application uniform across all pages and viewports:
- **Large screens (>= 1024px / desktop)**:
- All route-level views (`src/views/`) and primary navigation (`AppNavbar`)
must be constrained to `max-w-6xl` (`72rem` / `1152px`) and horizontally
centered with `mx-auto`.
- Stream/item list views (Bookmarks, Notes, Passwords) use a single-column card list layout
inside `mx-auto w-full max-w-6xl px-4 py-6 sm:px-6 lg:px-8`, with dedicated
subroutes for creating or editing items (`/bookmarks/new`, `/bookmarks/:id`,
`/notes/new`, `/notes/:id`, `/passwords/new`, `/passwords/:id`).
- Chat uses a uniform single-column route flow inside `mx-auto max-w-6xl w-full h-full`:
`/chat` shows the conversation inbox, and `/chat/:conversationId` shows the active conversation (with navigation back to the inbox via the navbar).
- Standalone/form/dashboard views use `mx-auto w-full max-w-6xl px-4 py-6 sm:px-6 lg:px-8`
as their outer page container. Form/card columns within the page should center
appropriately (e.g. `mx-auto max-w-2xl space-y-5`).
- **Mobile screens (< 1024px / < 640px)**:
- Layouts must be mobile-optimized: full-width (`w-full`), single-column or
single active panel views (switching between list and conversation/editor)
instead of cramped side-by-side panes.
- Safe mobile viewport sizing: use `min-h-dvh` or `h-dvh`, prevent horizontal
overflow (`overflow-x-hidden`), and avoid double vertical scrollbars.
- Touch-friendly targets: buttons and interactive controls should have
comfortable tap areas (minimum `h-10 w-10` / `h-11 w-11` for icon buttons,
`rounded-xl` or `rounded-2xl`).
- Mobile padding: standard header padding `px-4 py-3 sm:px-6` and body padding
`px-4 py-6 sm:px-6 lg:px-8`.
- **Design tokens**:
- Use semantic CSS variables (`--app-bg`, `--app-surface`, `--app-surface-soft`,
`--app-surface-hover`, `--app-border`, `--app-text`, `--app-text-soft`,
`--app-muted`, `--app-primary`, `--nav-bg`) and `@lucide/vue` icons.
+ ## Identity and keys
+
+ `src/lib/crypto.js` derives keys three ways; `src/lib/secureKey.js` holds the
+ live session; `src/stores/identity.js` wires them to storage.
+
+ - **Memory anchors** (`derivePrivkeyFromBrainFactors`) — 2–7 of
+ `{ passphrase, pin, specialDate, secretPerson, favoriteCountry, firstPet,
+ firstCar }`. Each value is normalized, SHA-256 hashed, deduped, **sorted A→Z
+ by hash**, joined with `\0`, then run through Argon2id (64 MiB, t=3). The sort
+ is load-bearing: it makes the identity independent of which slot a memory was
+ typed into, so the same memories in a different order on another device
+ reproduce the same key. Changing this changes every anchor-derived identity —
+ bump the salt (`gupt-brain-kdf-v4`) if you ever must.
+ - **Pasted secret** (`derivePrivkeyFromSecret`) — single Argon2id pass, salt
+ `gupt-secret-kdf-v1`.
+ - **Hex key / Gupt backup JSON** — `classifyPastedIdentitySecret` returns
+ `{kind:"hex"}` and the key is used verbatim.
+ - **Session precedence** (`identity.init()`): sessionStorage session →
+ localStorage `gupt_privkey` (mode `account`) → brand-new random keypair
+ (mode `ephemeral`). Switching identities calls `clearAllCaches()` first.
+ - **Crypto scheme** is custom, not NIP-44: ECDH x-only → `sha256(sharedX)` →
+ AES-256-GCM, serialized `v1:<b64 nonce>:<b64 ciphertext>`. Room ids are
+ `sha256(sorted([pubkeyA, pubkeyB]).join(""))` (`dmRoomId`). Event signing is
+ standard BIP-340 Schnorr.
+ - **Names/avatars** are derived, never uploaded: `pubkeyName()` builds
+ `adjective-noun-NNN` from pubkey bytes, `roboHashUrl()` renders a jdenticon
+ SVG data URL.
+
+ ## Chat payload protocol
+
+ The encrypted plaintext of every kind-4 / ephemeral event is a JSON payload.
+ Shared fields are `type` and `ts`, plus `text` / `media` / `replyTo` /
+ `replyExcerpt` / `emoji` / `replaces` / `lastReadTs`. `media` is
+ `{ key, nonce, mime, name, size, cid }`.
+
+ | `type` | Persisted | Semantics |
+ | --- | --- | --- |
+ | `text` | yes | plain message, `MAX_DM_TEXT_CHARS = 8000` |
+ | `media` | yes | attachment; `key`/`nonce` are per-file |
+ | `voice` | yes | voice note, adds `durationMs` |
+ | `react` | yes | reaction anchored via `replyTo`; legacy `like` normalizes to ❤️ |
+ | `edit` | yes | anchors via **`replaces`** (not `replyTo`), last-write-wins by `ts` |
+ | `read` | yes | DM: one row per message (`{replyTo: msgId}`); group: `{lastReadTs}` watermark |
+ | `call-request` / `call-event` | **no** | call log rows, but share the `call-` prefix so they publish as ephemeral kind 20004 |
+ | `call-*` signals | no | see Calls below |
+ | `typing` | no | kind 21004 |
+
+ Type registries live next to their consumers: `CHAT_TYPES`,
+ `PREVIEWABLE_TYPES`, `TRUST_ADVANCING_TYPES` in `stores/messenger.js`;
+ `CHAT_ROW_TYPES` / `RECEIPT_CHAT_TYPES` in `lib/chatListUtils.js`;
+ `CALL_SIGNAL_TYPES` in `lib/webrtc/constants.js`. Add new types to the right
+ set — an unlisted type is filtered out and will silently never render.
+
+ Mentions are **plain text only** — no payload field, no notification. The
+ composer autocompletes `@handle`; `ChatMessageBubble` regex-matches the handle
+ to tint the bubble. There is no un-react path, and edits are DM-only in the UI.
+
+ ## Groups (stateless)
+
+ `src/lib/groups.js`. There is no shared group key, admin, or roster.
+
+ - A group is a `t=gupt:group-msg` tag on an ordinary kind-4 DM.
+ - `groupId = sha256(normalizedName + ":" + sortedUniqueMembers.join(","))`.
+ Membership is immutable because changing it changes the hash — that is the
+ whole membership-control model.
+ - Every message fans out to all members **plus a self→self copy**, so your own
+ record survives a device wipe. `fanOut()` prepares all events up front and
+ returns the self→self event id, which is what the optimistic row uses so the
+ pending row, the relay echo, and the confirmation share one id.
+ - `applyGroupFromMessage` re-derives the hash on ingest and rejects a group
+ whose id does not match the shipped name+members, so you cannot be silently
+ added to a roster. Keep that check.
+ - De-duplication is layered in three places: the live subscription skips
+ self fan-out copies to *other* members (`stores/messenger.js`), the event-level
+ `isSelfFanOutCopy()` guard in `syncAll`/`syncGroup`/local decryption, and the
+ shared-id optimistic merge. Removing one layer reintroduces duplicate bubbles.
+ - Only create / learn / leave exist. There is no invite event and no admin role.
+
+ ## Calls
+
+ `src/lib/webrtc/` + `src/stores/calls.js`. Signaling rides the normal DM
+ channel — any `type` starting with `call-` becomes an ephemeral **kind 20004**
+ with no NIP-40 expiration, and signals are sent with `api.postDirectMessage`
+ directly, deliberately **bypassing the send queue** (a ring timeout must not sit
+ in a retry lane).
+
+ - Handshake is request → accept → offer: the caller publishes a `call-request`
+ chat row and waits 60s; the callee's Accept sends `call-accept`, which is what
+ actually triggers `startOutgoingCall()`. The callee parks the offer and only
+ acquires media on accept.
+ - ICE is coalesced into 1.5s batches (`call-ice` / `call-ice-batch`) to avoid
+ relay floods. Ring timeout 45s, 2 ICE-restart attempts, 8s grace on
+ `disconnected`.
+ - **Trusted-contact gate**: inbound call signals are dropped in the DM live
+ subscription unless you have sent ≥7 `TRUST_ADVANCING_TYPES` messages to that
+ peer. Reactions, edits, and receipts do **not** count. This is the
+ authoritative check — the UI merely hides the buttons.
+ - **SAS** (`lib/webrtc/sas.js`) is derived from the two SDP fingerprints
+ (sorted, so it is symmetric) hashed with the `callId`. It is display-only:
+ there is no persisted safety-number store and no automatic comparison.
+ - ICE servers are **STUN-only, no TURN** (`config/servers.js`), so NAT
+ traversal is best-effort.
+
+ ## Relay event kinds (allowed set only)
+
+ Only these Nostr event kinds may be published or subscribed to. Do not add
+ any other kind:
+
+ | Kind | Purpose |
+ | ------- | ----------------------------------------------------------------------------------- |
+ | `0` | Public profiles (metadata) |
+ | `1` | Secure share, invites, and the encrypted stream items (passwords, notes, bookmarks) |
+ | `4` | End-to-end encrypted DMs (groups are a tag on kind-4 DMs) |
+ | `20004` | Ephemeral encrypted WebRTC signaling |
+ | `21004` | Ephemeral encrypted typing indicators |
+
+ Constants live in `src/lib/api.js` (`DM_KIND`, `EPHEMERAL_DM_KIND`,
+ `EPHEMERAL_TYPING_KIND`); kinds `0` and `1` appear inline. Anything else needs
+ a `gupt_*` tag namespace and must not rely on a new kind.
+
+ The kind is derived from the payload `type` in `api.prepareDirectMessage`:
+ `typing` → 21004, any `call-*` → 20004, everything else → 4. The store also
+ filters out any row whose `kind` is in 20000–29999, so ephemeral traffic can
+ never be persisted.
+
+ ## Relay pool and health
+
+ `src/lib/relay/` is a hand-rolled WebSocket Nostr client (no nostr-tools).
+ `pool.js` owns sockets/subscriptions; the other modules are layered on top.
+
+ - `readRelays()` is an **exploit/explore bandit**: `EXPLOIT_SLOTS = 50`
+ best-scoring known relays, plus `EXPLORE_SLOTS = 16` randomly shuffled
+ untested ones. Add a relay and it gets explored before it is trusted.
+ - `getRelayRanking()` in `idb.js` scores relays from EWMA latency + ok-rate per
+ operation (`connect` / `query` / `publish`); `recordOutcomes()` in
+ `relay/outcomes.js` is the single write path. Replication publishes pass
+ `silent: true` and are intentionally excluded from scoring.
+ - `evictWorstRelays()` batch-evicts hint relays at 100 known relays, and drops
+ any scoring ≤ 0.15 with ≥3 samples. Configured and user-added custom relays
+ are protected from auto-deletion.
+ - `startNetworkDiscoveryLoop()` runs every 60s: 25% chance, and only when
+ `getAvgActiveRelayScore() < 0.6`, to scrape `p`-tag hints off recent public
+ GUPT DMs.
+ - Peer relay hints ride in the `p` tag's third element (`pickRelayHint()`),
+ are mirrored into `peerRelayHints`, and are used to bias both publish and
+ query relay sets. Hints older than 30 days are ignored.
+ - `publish()` succeeds on **at least one** relay OK, but `pool.publish()`
+ internally waits for `minOk = 2` when more than one is available.
+
## Dexie cache (IndexedDB)
All local persistence goes through one Dexie database, `src/lib/idb.js`:
- - DB name `gupt_app_cache_v3`; the Dexie schema version is derived from the
- app version string's leading integer (`__APP_VERSION__`), so a major release
- recreates the cache.
+ - DB name `gupt_app_cache_v3`; the Dexie schema version is `DB_VERSION`, derived
+ from `__APP_VERSION__`'s leading integer. **The name is what recreates the
+ cache** — bumping the version only runs an in-place additive upgrade.
- Tables: `mediaCache`, `roomMeta`, `groups`, `profiles`, `syncCursors`,
`messageSearch`, `sendTimings`, `relayStats`, `peerRelayHints`, `rawEvents`.
- - Every row carries `expiresAt` (TTL). Reads go through `getFresh`-style
- helpers that return `null` and delete the row once it has expired.
- - `rawEvents` stores full Nostr events — it backs the bookmark/password/note
- streams and the replication worker. `putRawEvent` upserts by `event.id` and
- preserves `lastReplicatedAt` on re-put; `getRawEventsByOrigin` reads a
- stream's cached events.
+ - Freshness is normally **recomputed from activity**, not read from the stored
+ `expiresAt` (see `getEntryExpiryTimestamp`): activity timestamp + 400 days,
+ except `profiles` (24h ± jitter), staged uploads (24h), `sendTimings` and
+ `relayStats` (90d). `syncCursors` and `peerRelayHints` have no `expiresAt` at
+ all — hints are swept on a 30-day `updatedAt` rule instead.
+ - `rawEvents` stores full Nostr events — it backs chat history, the
+ bookmark/password/note streams, and the replication worker. `putRawEvent`
+ upserts by `event.id` and preserves `lastReplicatedAt` on re-put;
+ `getRawEventsByOrigin` reads a stream's cached events. `expiresAt` comes from
+ the event's NIP-40 `expiration` tag, and is `Number.MAX_SAFE_INTEGER` for
+ stream events that carry none — the 400-day window is a *relay* concern, not a
+ local-eviction concern.
- Streams read cache-first, then refresh from relays: the views load with
`getBookmarksCached`/`fetchBookmarks`, `getNotesCached`/`fetchNotes`, and
`getPasswordsCached`/`fetchPasswords` (see the stream libs under `src/lib/`).
- - `startCacheMaintenance()` purges expired rows on a 6-hour interval; new
- helpers should follow the same `expiresAt` + `getFresh` pattern.
+ - `startCacheMaintenance()` purges expired rows on a 6-hour interval;
+ `purgeOversizeCache()` evicts least-recently-active `mediaCache`/`rawEvents`
+ rows above `RETENTION_MAX_BYTES` (10 GB). New helpers should follow the same
+ `expiresAt` + `getFresh` pattern (`getFresh` is private; mirror it per table).
## Send queue
All relay writes go through the in-memory send queue (`src/lib/sendQueue.js`)
so a transient relay failure retries instead of dropping the write:
- `enqueueSend({ id, fn, onFailed, onSuccess, meta })` appends a task to a
lane (keyed by `meta.conversationId`, so writes to one conversation are
serialized), dedupes by `id`, bumps `pendingCount`, and drains. Returns
`true` when accepted.
- Retry policy: exponential backoff from 1s up to 3min, `MAX_ATTEMPTS = 8`,
with a 1200ms global throttle between sends. On permanent failure the task
is dropped and `onFailed` fires.
- `pendingCount` drives the navbar badge (`/queue` links when > 1 pending).
- A `window.online` listener flushes queued lanes when connectivity returns.
- Stream items (bookmarks, passwords, notes) publish via
`enqueuePublish({ id, kind, result, fn })` — it accepts the task, returns
`result` optimistically, and the write retries in the background. Chat
messages/receipts use `enqueueSend` directly.
- Tasks live in memory only — they are not persisted across page reloads.
+ - Call signaling intentionally does **not** use the queue.
- ## Relay event kinds (allowed set only)
+ ## Stream renewal
- Only these Nostr event kinds may be published or subscribed to. Do not add
- any other kind:
+ `src/lib/streamRenewal.js` holds the shared policy for all three Kind-1 streams;
+ per-stream `renew*` functions supply the republish.
- | Kind | Purpose |
- | ------- | ----------------------------------------------------------------------------------- |
- | `0` | Public profiles (metadata) |
- | `1` | Secure share, invites, and the encrypted stream items (passwords, notes, bookmarks) |
- | `4` | End-to-end encrypted DMs (groups are a tag on kind-4 DMs) |
- | `20004` | Ephemeral encrypted WebRTC signaling |
- | `21004` | Ephemeral encrypted typing indicators |
+ - Selection is by **item age, not proximity to expiry**: oldest live,
+ non-deleted items not written in the last `RETENTION_DAYS / 2` (200 days),
+ capped at `STREAM_RENEWAL_LIMIT = 3` per tick.
+ - Renewal republishes with the same logical `id`, a fresh 400-day NIP-40
+ `expiration`, and `prevEventId` pointing at the event it supersedes.
+ - `stores/replication.js` runs it after every tick, reading **only from the
+ Dexie cache** — it never fetches from relays. Items whose expiry has already
+ lapsed are dropped during decryption and are therefore unrecoverable; an app
+ unopened for >400 days loses the item on every relay.
+ - Tombstones are never renewed; they carry a 10-year expiry.
- Constants live in `src/lib/api.js` (`DM_KIND`, `EPHEMERAL_DM_KIND`,
- `EPHEMERAL_TYPING_KIND`); kinds `0` and `1` appear inline. Anything else needs
- a `gupt_*` tag namespace and must not rely on a new kind.
+ ## Replication worker
+ `src/stores/replication.js` + `src/lib/replication.js`, started from `App.vue`
+ once identity is ready and gated on `settings.replicationEnabled`.
+
+ - 15s base tick with ±25% jitter, backing off to 120s when >80% of publish
+ outcomes fail, halving back on any success, ×3 under `navigator.connection.saveData`.
+ - Ticks are skipped while `document.hidden` or `pendingCount > 0`; extra ticks
+ fire on `visibilitychange` → visible and on `window.online`.
+ - Each tick samples never-replicated `rawEvents` (walked by `lastReplicatedAt`,
+ kinds 1 and 4, still unexpired), takes 5 (3 on data-saver), and republishes
+ to a fresh health-ranked relay set, silently. Any single relay OK marks the
+ event replicated.
+
+ ## Media upload and retrieval
+
+ Attachments use AES-256-GCM with a per-file key; only ciphertext goes to
+ Originless servers, while `key`/`nonce`/`cid` travel inside the E2EE envelope.
+ Encryption happens in the callers (`useConversationCompose`, `share.js`), not in
+ `upload.js`; ciphertext is staged in IndexedDB first and cleared in `finally`.
+
+ Upload (`src/lib/upload.js`):
+
+ - `uploadFile` runs a **shuffled** then health-ranked hedged foreground
+ (1 active, hedged to `ORIGINLESS_FG_MAX_ACTIVE = 2` after
+ `ORIGINLESS_FG_HEDGE_DELAY_MS = 1500ms`) plus a background replication queue
+ at concurrency 1 for the remaining servers. First `cid` resolves the primary
+ and dismisses the upload bar.
+ - Server health lives in `src/lib/originlessHealth.js`: fail-then-latency
+ ranking, `ORIGINLESS_BAN_MS` 5min on failure, transient-vs-permanent
+ classifier, `2s/8s/20s` backoff. The third entry is unreachable — the
+ background loop exits at `ORIGINLESS_BG_MAX_ATTEMPTS = 3`.
+ - The first `cid` resolves the primary immediately and the upload bar
+ dismisses at `cid`. Background progress events carry `background: true` and
+ must be ignored by foreground progress UI (`useConversationCompose`,
+ `share.js` already guard on this).
+ - Background per-server policy: up to 3 attempts, per-attempt
+ `min(12min, calcTimeoutMs)`, 10s stall, transient errors only; a `cid`
+ mismatch against the primary counts as failure.
+ - Do not fan out to all servers at once and do not let background replication
+ hold the foreground bar — that regresses to the stuck-upload bug.
+ - The background queue's promise is never awaited by callers, so replication
+ failures are invisible by design.
+
+ Retrieval (`src/lib/mediaDecrypt.js`, `src/lib/verifiedFetch.js`):
+
+ - The message carries **only `cid`**. `resolveMediaSources()` returns a single
+ `ipfs://<cid>` source, fetched through `@helia/verified-fetch` (bitswap, 8
+ attempts, 500ms·2ⁿ capped at 30s). There is **no explicit SHA-256 check in
+ `src/`** — integrity comes from Helia's CID verification during block
+ assembly, plus the AES-GCM auth tag at decrypt time.
+ - The per-server URLs and `redundancyCount` that `uploadFile` computes are not
+ used for retrieval. Upload redundancy is not download redundancy; do not
+ assume the message envelope carries a server list.
+ - `IPFS_GATEWAY` (`config/servers.js`) builds human-openable links
+ (`https://inbrowser.link/ipfs/<cid>`) for the "open attachment" affordance.
+ - Two Dexie caches back the path: ciphertext keyed `ipfs://<cid>`, plaintext
+ keyed `dec:<messageEventId>`. Both 400 days. Note that **plaintext media is
+ persisted locally** — the ciphertext-only-at-rest rule holds on the wire and
+ on Originless, not in IndexedDB.
+
+ ## Security invariants (do not break)
+
+ - All cryptography uses `@noble/*` (`@noble/ciphers`, `@noble/hashes`,
+ `@noble/secp256k1`). Never hand-roll crypto.
+ - Private keys are handled via `src/lib/secureKey.js` / the identity store and
+ must never be logged or persisted in plaintext outside that flow.
+ - User-supplied text rendered as HTML must go through DOMPurify
+ (see NotesView for the markdown pattern). Plain interpolation `{{ }}` is safe.
+ - URLs opened via `window.open` must use `"noopener,noreferrer"` and only
+ `http(s)` after `normalizeBookmarkUrl` validation.
+ - Secrets (passwords, TOTP secrets) must stay inside the ciphertext payload —
+ relay event tags/`content` are public metadata.
+ - `createVerifiedFetch` is created with `allowInsecure: true, allowLocal: true`
+ because the CID is attacker-supplied (it arrives inside a DM). Do not relax
+ that decision without re-reading `verifiedFetch.js`.
+
+ ## Known footguns
+
+ Behaviors that are true today and will silently break a plausible change:
+
+ - **`getCacheSummary()` (the `/cache` view) deletes data.** It recomputes
+ expiry as `activity + 400d` and ignores the stored `expiresAt`, so viewing
+ cache analytics purges 10-year stream tombstones and any stream event stored
+ with `expiresAt = Number.MAX_SAFE_INTEGER`. Fix `isEntryExpired` to honor
+ stored expiry for `rawEvents` before touching the analytics view.
+ - **Dexie version bumps do not recreate the cache.** Only changing
+ `APP_CACHE_DB_NAME` does. There is no `.upgrade()` and only a single
+ `version()` block, so dropped stores/indexes are never removed.
+ - **Background upload replication poisons latency stats.**
+ `recordOriginlessSuccess(server, 0)` writes `latencyMs: 0`, the minimum, so a
+ server that wins one background race permanently ranks first.
+ - **`isOriginlessBanned` is exported but never imported.** Banned servers are
+ still attempted by the foreground race.
+ - **Kind 21004 has no senders.** The full receive path exists in `api.js` and
+ `stores/messenger.js`; no component publishes `{type:"typing"}`. Wire it up
+ or delete it — do not assume it is live.
+ - **`call-request` / `call-event` are ephemeral.** They share the `call-` prefix
+ in `api.prepareDirectMessage`, so they publish as kind 20004 and are skipped by
+ `putRawEvent`. They render in-session and are indexed for search, but do not
+ survive a reload from cache.
+ - **Stream cache helpers hard-code `fresh: false`**, so every list view
+ unconditionally refetches from relays on mount.
+ - **Saves trigger a full relay query first.** The detail views do not pass
+ `existingItems`, so every save calls `fetch*` before writing. Offline that
+ path throws "Note not found." for notes/passwords and silently creates a
+ duplicate bookmark (dedupe is by `id`, not by URL).
+ - **A service-worker takeover wipes local state.** The `controllerchange`
+ handler in `main.js` runs `resetPersistedStateForPwaUpdate()` (deletes the
+ IndexedDB database and clears localStorage except `gupt_privkey`), so stream
+ data is refetched from relays on every SW update.
+ - `recordSendTiming` collapses kind to `dm`/`group` only, so stream publishes
+ land in `sendTimings` as `kind: "dm"` with a `stream:*` conversation id. The
+ Stats view reads accordingly.
+ - Dead code you can delete freely: `isFresh`, `hydrateMessageText`,
+ `indexGroupMessage`, `getAllPeerRelayHints`, the `stagedUploads` branch of
+ `getEntryExpiryTimestamp`, the `gupt_vault` tag bucket, `leaveGroup` /
+ `deleteGroup`, and `fetchAndDecryptFromSources` / `sortSourcesByPreference`
+ (with its unread `rememberSourcePreference` localStorage writes).
+
## Working in this repo
1. Read the neighboring files first — new code should mirror the existing
patterns (helpers, naming, error handling).
2. After each edit, bump the npm version with a patch release:
`npm version patch` (the build stamps `__APP_VERSION__`/`__APP_BUILD_TIME__`).
3. After changes, run `npm run build` to confirm nothing broke.
4. Run `npx oxfmt <changed files>` so formatting matches.
5. Commit with a Conventional Commit message (see below); scope prefixes are
used but short (e.g. `fix:`, `feat:`, `refactor:`, `style:`, `chore:`,
`docs:`).
## Commits
Conventional Commits, lowercase subject, short scope:
```
feat: add encrypted Markdown notes stream
fix: route stream publishes through send queue
refactor: share hybrid stream renewal across streams
style: keep bookmark row actions always visible
chore: bump version to 3.0.0
```
-
- ## Security invariants (do not break)
-
- - All cryptography uses `@noble/*` (`@noble/ciphers`, `@noble/hashes`,
- `@noble/secp256k1`). Never hand-roll crypto.
- - Private keys are handled via `src/lib/secureKey.js` / the identity store and
- must never be logged or persisted in plaintext outside that flow.
- - User-supplied text rendered as HTML must go through DOMPurify
- (see NotesView for the markdown pattern). Plain interpolation `{{ }}` is safe.
- - URLs opened via `window.open` must use `"noopener,noreferrer"` and only
- `http(s)` after `normalizeBookmarkUrl` validation.
- - Secrets (passwords, TOTP secrets) must stay inside the ciphertext payload —
- relay event tags/`content` are public metadata.
-
- ## Media upload
-
- Attachments are AES-256-GCM per-file keys; only the ciphertext bytes go to
- Originless servers, while `key`/`nonce`/`cid` travel inside the E2EE envelope:
-
- - `uploadFile` (`src/lib/upload.js`) runs a ranked hedged foreground (best
- server first, 2nd after 1.5s, max 2 active) plus a background replication
- queue (concurrency 1) for the remaining servers.
- - Server health lives in `src/lib/originlessHealth.js`: fail/latency ranking,
- 5min ban on failure, transient-vs-permanent classifier, `2s/8s/20s` backoff.
- - The first `cid` resolves the primary immediately and the upload bar
- dismisses at `cid`. Background progress events carry `background: true` and
- must be ignored by foreground progress UI (`useConversationCompose`,
- `share.js` already guard on this).
- - Background per-server policy: up to 3 attempts, per-attempt
- `min(12min, calcTimeoutMs)`, 10s stall, transient errors only; a `cid`
- mismatch against the primary counts as failure.
- - Do not fan out to all servers at once and do not let background replication
- hold the foreground bar — that regresses to the stuck-upload bug.
## Testing
There is no test suite in this repo (`test/` was removed as bloat).
Verify changes with `npm run build` and manual exercise in `npm run dev`.