Imported from 44Billion/ez-vault (
AGENTS.md). Install upstream withnpx skills add 44Billion/ez-vault. Copyright stays with the author.
AGENTS.md
This document is the primary brief for AI assistants working on this repository. Read it in full before making changes.
Project Overview
This project is a Nostr signer implemented as a Single Page Application. It is designed to be loaded inside an <iframe> by a host app launcher (the parent window), and it hosts the user's Nostr private keys (nsec) so that the launcher and any client apps never touch them directly.
Because the signer custodies private keys, the overriding design principle is auditability: any reasonably-experienced developer should be able to read the source and convince themselves it is safe. Without that trust, no one will store their nsec with us.
Core Constraints
- Vanilla JavaScript. No framework, no TypeScript, no transpilation.
- The sole allowed
thenameisfsurface is the signal-freethenameisf/i18nutility. Do not import the package root, reactive APIs orthenameisf/components; author UI as native Custom Elements and inline SVG markup directly in their templates. - esbuild bundles the app (
npm run build, seebin/build.js); the source modules insrc/remain the audit contract — tests run on the source, and the bundle is unminified so the shipped artifact stays readable. - Nostr primitives come from the sibling
libp2r2ppackage. Everything else is standard Web APIs or an explicitly listed focused dependency. Do not add new runtime dependencies without explicit approval. - Local
nip44v3_*commands accept/return attachedArrayBufferplaintext, including Double DH and shared-key contexts. Dispatch snapshots input before asynchronous key selection and uses the signers' byte methods. Decrypt returns only the plaintext byte range, never a backing buffer containing padding. Only bunker adapters convert local plaintext to/from standard Base64 for remote NIP-46. Keep Base64 signer methods for private-channel/private-messenger consumers; activity logs represent buffers as Base64 inside sealed JSON. - Local signer success/error replies never await activity-log persistence.
messenger-log.appendInBackgroundsnapshots each complete entry, including binary fields, before the reply; encryption still respects the vault transition barrier and persists only sealed params/results. Pending writes are best-effort on page termination.flushwaits for already queued background work, and explicit log clearing/account-log removal drains that work before deleting it. Audit write/snapshot failures cannot change the signer result. Keep disclosure methods unlogged and preserve the existing reply/error protocol and context.node ../44billion/bin/run-browser-tests.js -- node tests/browser/signer-log.jsverifies real binary signer success/error replies while audit writes are held, followed by complete encrypted records after the transition barrier releases. - Activity-log writes serialize preparation/commit, while metadata backfills use one coalescing worker per app. A metadata read failure must not prevent new log entries from committing or notifying the UI. Storage failures use independent global-writer/per-app-retention/per-app-metadata cooldowns of 5/10/20/40/60 seconds, driven by new activity without recurring timers. During writer cooldown, skip entries before cloning when possible and count the audit gap; do not retain an unbounded retry queue. Existing queued writes check the gate again. A different app's successful write must not reset a failed app-retention gate or announce its recovery. Shared append, usage and global-retention failures still use the global writer gate. Explicit clear or account-log removal resets cooldowns. Never delete a record on arbitrary read errors. The specific large-value recovery policy below may discard unreadable audit entries during retention/removal and recount. JSON diagnostics identify phase, appKey when known, database/store, operation, stage and known record ID, plus retry/skipped counts. Allowlist error names/messages; never emit params/results, ciphertext, raw scope, arbitrary exception text or stack/cause. Successful writes remain complete and sealed. Flush/removal must still drain pending background writes and metadata workers before deleting data.
- Keep the activity-log schema unchanged. For the specific Chromium
UnknownError: Failed to read large IndexedDB value, an ID selected by FIFO/explicit removal may be deleted without reading its payload.idb.run()aborts on request failure: retry the transaction under the same selection rules, delete that selected ID by key, and sum surviving entries to rebuild the exact usage counter before commit. Additional audit entries failing with the same error during recount are also discarded by ID. Other errors roll back admission, deletions and bookkeeping; never guess sizes or purge healthy entries. Keep this limited to the audit store, never account/secret data. Emit a bounded removal diagnostic only after commit, including count and up to ten public IDs/stages. - Small, readable surface area. Prefer straightforward code over clever abstractions — the code is the documentation for our security claims.
Hosting & Entry Point
- The SPA is published by GitHub Actions to GitHub Pages using
.github/workflows/pages.yml.npm run buildgeneratesdist/fromsrc/; the workflow uploads its contents as a Pages artifact. Generated output is ignored by Git and must never be committed.docs/is reserved for authored documentation. src/index.htmlis the only authored application HTML file and must stay minimal; the build copies it todist/index.htmland emitsdist/app.js,dist/chunks/(hashed),dist/sw.jsanddist/styles/.- Pages must use GitHub Actions as its publishing source. The workflow deploys only
main, on relevant source/build changes or manual dispatch. - A
<link>tag per CSS file (currentlystyles/reset.cssandstyles/global.css— there is noicons.cssyet). - A final
<script type="module" src="./app.js">.
Service Worker & Updates
- The service worker source is
src/sw.src.js, built todist/sw.js. The build injectsLAUNCHER_SW_VERSION(content hash of the worker logic → cache name) andLAUNCHER_DEPLOY_HASH(content hash of the vault sources and package-lock.json → dependency-only deploys also announce an update). Keep the two hashes independent. - Entries (
index.html,app.js) are network-first; hashed chunks are cache-first. GitHub Pages serves every file withCache-Control: max-age=600(10 min); the worker fetches with revalidation headers (legacy 44b-vault mitigation), and a first load shortly after a deploy may serve the previous — coherent — version for up to ~10 minutes (accepted). - Updates are never applied automatically: once a new worker is waiting, a non-dismissible banner (main UI) and compact indicators (lock/create overlays) stay visible until the user clicks "Update" (
SKIP_WAITING→ one guarded reload). The vault'slauncherPorthandshake re-runs on that reload, so the connection to 44billion recovers automatically. - Register with
updateViaCache: 'none'and check hourly + onvisibilitychange(seesrc/services/sw-manager.js).
Communication Model
The vault is an iframe signer that talks to the parent window (app launcher) directly using window.postMessage. An app requests a permission to the app launcher that, if granted, talks to the vault on behalf of the app. Manual locking must call secrets.lock(), immediately publish SET_ACCOUNTS_STATE with locked account snapshots, and only then request CLOSE_VAULT_VIEW; do not add a separate protocol message for locking.
VAULT_READY carries the complete initial account snapshot. Subsequent
SET_ACCOUNTS_STATE messages are deduplicated by their launcher-facing
payload, and every vault-requested close synchronously flushes the latest
committed snapshot before posting CLOSE_VAULT_VIEW.
Nostr Relays & Event Discovery (NIP-65)
Not all relays are equal. We distinguish two relay roles:
- Seed relays — a small, well-known list of indexer-style relays that reliably hold users' NIP-65 relay-list events (kind
10002). We use seed relays only to discover which relays a given user actually publishes to. They are not a general broadcast target. - Free relays — a short fallback list of open write-accepting relays. We use them:
- as the initial write-relay set for a brand-new account (picked as the
writetags in the account's first kind:10002 event), and - as a fallback when we cannot find or reach a user's own write relays.
- as the initial write-relay set for a brand-new account (picked as the
Discovering a user's write relays (NIP-65)
To fetch any user-authored event (e.g. kind:0 profile, kind:30023 long-form, etc.):
- Query kind:10002 for
authors: [pubkey]from seed relays. - Pick the event with the highest
created_atacross whichever relays responded. - Parse its
rtags — the entries without a marker or taggedwriteare the user's write relays. - Fetch the user-authored event from those write relays. If no write relays were found (or the lookup failed), fall back to the free-relay list.
Account bootstrap (when creating a new account)
- Pick the first two entries from the free-relay list as the initial write set.
- Sign a kind:10002 event tagging those two URLs as both read and write.
- Publish kind:10002 to the seed relays. This is what makes the account discoverable via NIP-65.
- Sign the kind:0 profile event.
- Publish kind:0 to the user's write relays (the two free relays chosen in step 1).
- Only persist the account locally if both publishes succeed.
Reuse this same bootstrap for a Pomegranate account only when /account
initially returned 404 and EZ Vault created the account with its ephemeral
nsec. Never republish kind:10002 or kind:0 merely when importing an existing
Pomegranate account returned by the Central.
Multi-relay fetch timing
When querying multiple relays for the same filter, do not wait for every relay to EOSE or time out:
- Collect events as they arrive from any relay.
- As soon as the first relay sends EOSE, start a 500ms grace timer. When it fires, close all subscriptions and return the best event collected so far (typically the one with the highest
created_at). - Keep an outer hard timeout (~5s) as a safety net in case no relay ever EOSEs.
This 500ms-after-first-EOSE pattern is the default for every multi-relay read in this codebase. Re-use the shared helper in src/services/relays.js rather than reimplementing it.
Layout & UX Shape
The vault is always embedded in a vertical drawer that slides in from the right of the host page. Design every screen around that shape:
- Tall, narrow, one-column by default. Think "vertical strip", not "page".
- On portrait-mode mobile the drawer takes most of the viewport width.
- On landscape or desktop the drawer stays roughly the same narrow width — it does not expand to fill extra horizontal space. Do not design desktop-specific wide layouts.
- Column count depends on item type. Aside from case-by-case exceptions, items should span one to four (virtual) columns so that all columns are filled per row.
- Configuration uses accordions, not pages. Each config section is a collapsible accordion panel on the same screen. This keeps every setting one tap away.
- No client-side router. The SPA is effectively always on
/— there are no route transitions to design for. State lives in the DOM (open accordion, selected account, etc.), not in the URL path. - Query-string parameters on the iframe
srcare allowed for the initial handshake / configuration passed in by the launcher. Read them fromlocation.searchat startup; do not mutate them afterwards.
When in doubt about a layout decision, ask: "does this still work as a tall vertical strip?" If the answer is no, the design is wrong for this app.
Key Storage
- Account secrets are encrypted with a vault key obtained from the WebAuthn
PRF extension. The passkey unlocks that key; it does not itself hold account
secrets. Persistence is adaptive and recorded under
ez-vault:passkey:storage-policy:idbmeans assertion PRF plus authoritative ciphertext atez-vault:passkey:blob;largeblobmeans a compatibility PRF backup in IDB plus authoritative authenticatorlargeBlob;idb-compatmeans both values in IDB becauselargeBlobwas unavailable. Inlargeblobmode, a local ciphertext is a newer write fallback and always wins until it is synced to the authenticator. - If registration is unavailable or refused before any passkey exists, the
user may explicitly enter an unprotected local mode. A random vault key is
stored in clear at
ez-vault:passkey:local-keybeside the ordinary ciphertexts; this is equivalent to plaintext against an IndexedDB reader. User-initiated account creation/import and trusted-device creation retry registration. In local mode, copying or removing an existing account, and removing a trusted device, do not retry registration. Destructive local removals use a textual browser confirmation. Once a passkey exists, deliberate secret copying, account removal and trusted-device removal require a fresh assertion instead. Frequent background writes such as activity logs, content-key rotation and bunker drift do not open WebAuthn. Promotion reciphers the vault blob, content keys, trusted signers and sealed activity-log fields under PRF in onestate+messengerLogtransaction, then removes the local key.ez-vault:passkey:upgrade-pendingmakes the short staged window recoverable; once present it must be completed with that passkey and must not fall back to local unlock. Manual locking is unavailable until local mode is promoted: use the strict passkey requirement path, never show the local-fallback dialog, and leave the vault open and unlocked when registration is cancelled or unsupported. In both the generic fallback and Pomegranate protection-choice dialogs, Escape or a backdrop click selects local mode; component teardown must still cancel without starting persistence work. - Raw
nsec, bunker handler pubkeys, bunker client keys, content-key secret keys and decrypted activity payloads must never be persisted literally in plaintext. The explicit local mode is the sole equivalent-security exception because its decryption key is co-resident in IndexedDB. Bunker account records keep only normalized relay URLs publicly; the encrypted TLV repeats the account pubkey as its deterministic local index and carrieshandlerPubkey || clientKey. The create-time PRF compatibility backup is the sole exception. Every registration must perform a follow-upget()withuserVerification: 'discouraged'; when that assertion returns PRF, do not persist a plaintext backup. If it is cancelled or omits PRF, some authenticators require the creation-time output to be stored in IDB. RequestlargeBlob: { support: 'preferred' }during creation and use the reported support to keep that backup separate from ciphertext where possible. A future assertion PRF must match the backup and decrypt the selected ciphertext before one IDB transaction stores the blob, switches toidb, and removes the backup. This promotion is irreversible; never recreate the backup afterward. IflargeBlobwas unavailable, backup and ciphertext may remain together indefinitely, which is a conscious compatibility tradeoff. - Normal secret writes go directly to IDB in
idbandidb-compatmodes. Inlargeblobmode they use a PRF +largeBlob.writeassertion withuserVerification: 'discouraged';NotAllowedErrorandwritten !== trueretain the new ciphertext as the authoritative IDB fallback, while other failures roll the whole account mutation back. If an existing fallback is present, update it before WebAuthn so interruption cannot resurrect stale secrets. Once assertion PRF works, promote monotonically toidband erase the obsoletelargeBlobbest-effort on a later required assertion. - Pairing (
nostrpair://) is the recovery and cross-device transfer mechanism. WebAuthn exposes no RP option that prohibits authenticator/vendor sync.residentKey,credProps.rk, backup eligibility (BE) and backup state (BS) describe distinct properties; do not treat any of them as proof that a credential is unsynced or add UI/policy based on them. - Registration orders the advisory WebAuthn
hintsasclient-device,hybrid, thensecurity-key, but does not requireauthenticatorAttachment: 'platform'. Security keys and hybrid phone authentication must remain available for browsers without a platform authenticator. Persist the result ofresponse.getTransports()(including an empty array meaning unknown) and use it in future allow-credential descriptors; only legacy registrations, which were platform-only, may default to['internal']. Do not put a client-device hint on assertions for a specific credential because it can contradict its stored transports. - Bunker backup URLs use a strictly validated local
#client_key=<64 lowercase hex>fragment. Strip the fragment before any NIP-46 connection, never retain a consumed one-usesecret, and require a freshpasskey.openSecrets()before copying the backup in passkey-backed modes; local mode deliberately discloses its already-unlocked entry without registering a passkey. New encrypted bunker TLVs are 96 bytes (accountPubkey || handlerPubkey || clientKey); continue to decode legacy 64-byte (accountPubkey || clientKey) records and migrate them only after a normal secret write has committed.switch_relayschanges only public relay metadata and must not reseal secrets. - “Continue with Google” is fixed to
https://auth.njump.meand creates new Pomegranate accounts as FROST 2-of-4 acrosspo.coracle.social,po.f7z.io,po.jumble.socialandpo.njump.me. OAuth tokens, e-mail, setup nsec and shards are ephemeral and must not be logged or persisted. Validate the popup window/origin, signed kind-20443 token, returned account configuration and bunker-reported pubkey. This flow explicitly trusts the Central as the OAuth intermediary and trusts that fewer than two operators collude. The OAuth popup has a 10-minute deadline; on expiry, close the script-opened window, release the shared busy operation and show the localized timeout. OAuth completion has no usable transient activation for cross-origin iframe WebAuthn: after read-only Central lookup, an unprotected vault must show the explicit passkey-recommended/local choice and use its fresh click directly forcredentials.create(). Do not perform account/profile creation or the first bunker connection before that choice has settled. - IndexedDB database
ez-vaultis the durable store for account records, encrypted vault/sidecars, passkey metadata, sync state and the bounded activity log. Do not add newlocalStorageorsessionStoragepersistence. - A development build answers
LOCAL_DEV_WIPEfrom the launcher (after theVAULT_READYhandshake, so only the trusted parent can ask): it closes the storage connection, deletes every database in this origin, clears localStorage/sessionStorage, cache storage and OPFS, replies with the report and reloads. Production builds ignore the message and never reply, so a shipped launcher cannot wipe a shipped vault. Keep this path development-only and keep the launcher's full reset the only caller. - Use
libp2r2p/idb.run()for IndexedDB requests. Attach transaction completion/error handlers immediately, awaitoncomplete, and publish cache updates or notifications only after commit. - Inside an active IndexedDB transaction, await only requests issued against that same transaction. Perform crypto, hashing, network requests, WebAuthn, timers and external callbacks before opening the transaction or after it has committed; otherwise browsers may auto-commit the transaction early.
- IndexedDB unavailability is fatal for bootstrap. Request persistent storage best-effort after passkey registration and successful unlock.
Folder Structure
/ (repo root)
├── src/ # Authored sources (audit contract) — components / helpers / services
│ ├── index.html # Single authored entry — copied by the build to dist/
│ ├── index.js # Module entry — imports components / helpers / services
│ ├── components/ # Custom Elements (Web Components)
│ ├── helpers/ # Pure helper functions (e.g. string.js → string helpers)
│ ├── services/ # Service classes, singletons, or method-bag objects
│ │ # encapsulating a specific feature area
│ ├── styles/ # CSS sources (reset.css, global.css, plus feature stylesheets)
│ └── sw.src.js # Service worker source — built to dist/sw.js
├── dist/ # Generated production build — Pages artifact, ignored by Git
│ ├── index.html # Single entry point
│ ├── app.js # Bundled entry
│ ├── chunks/ # Hashed, immutable chunks
│ ├── sw.js # Bundled service worker
│ └── styles/ # Copied CSS
├── .dev/ # Generated development build, ignored by Git
├── docs/ # Authored documentation (created as needed)
├── tests/ # Automated tests run with the Node built-in test runner
├── server.py # Local dev server
└── package.json # npm scripts: `start` (dev server), `end` (kill server),
# `test` (node --test), `build` / `build:watch` (esbuild)
Development-only tooling and scripts live outside src/ (e.g. bin/, tests/) so they are never served to the browser.
Where new code goes
- Custom element? →
src/components/ - Pure function (string/array/date/etc. util)? →
src/helpers/ - Feature logic, API client, stateful singleton, or a bag of related methods? →
src/services/ - Styling? →
src/styles/— either extend an existing file or add a new one and link it fromindex.html.
Services that grow auxiliary files
A service typically lives in a single file (e.g. src/services/foo.js). When a service grows enough that it wants to ship alongside auxiliary files (fixtures, JSON data, helper modules used only by that service), promote it to its own folder:
src/services/foo/
├── index.js # the public entry — same exports as the old foo.js
├── fixtures.json # auxiliary data, only loaded when needed
└── ... # additional internal modules, each kept small
Importers should write from './services/foo/index.js'. Keep the index file as the only public surface — internal modules are not imported from outside the folder. See src/services/messenger-log/ for the canonical example.
Styling Rules
The CSS reset in src/styles/reset.css sets html { font-size: 0.0625em } so that 1rem ≈ 1px. This is a deliberate trick with two consequences:
- Use
remforfont-sizeonly. For example, writefont-size: 16rem;where you would normally writefont-size: 16px;. This keeps the text responsive to the user's browser-level font-size zoom (an accessibility feature we care about). - Use
pxfor everything else —width,height,padding,margin,border-width, etc. Do not assume the usual "1rem = 16px" outside offont-size.
Other style conventions:
- Colors come from
src/styles/theme.css. Every token is alight-dark(<light>, <dark>)pair resolved natively fromprefers-color-scheme(viacolor-scheme: light dark). Do not author color literals (oklch(...),#hex,rgb(...),light-dark(...)) outsidetheme.css(or theinverted-colorsaccessibility rule inreset.css) — consume tokens viavar()references. Relative color syntax (oklch(from <token> l c h / 0.5)) is allowed when genuinely useful, e.g. translucent states of a token. UGC media (avatar photos, images, video) must never receive theme inversion filters — with tokens it stays untouched by construction. - Loading indicators: prefer the existing
.pulsateanimation defined inglobal.css. The typical pattern is: on click,disabledthe button and add thepulsateclass while the async work runs; remove both on completion. Reach for spinners only when pulsate is genuinely unsuitable.- When the button has its own background color (e.g. a colored pill wrapping an icon), apply
.pulsateto the inner icon/label element rather than the button itself — animating opacity on the whole button makes the background go transparent, which looks wrong. Wrap the glyph in a dedicated inner element so you have something to target.
- When the button has its own background color (e.g. a colored pill wrapping an icon), apply
- Icons: use Tabler outline icons. Icons already copied into the project live under
icons/; grab any missing icon from the Tabler repo and drop the.svgin there. Inline the SVG markup directly in the template (HTML or JS template literal) sostroke="currentColor"inherits the host text color — loading via<img src>isolates the SVG from CSS and breaks theming. - Prefer
:activeover:hover. This app is primarily used on touch devices where hover does not exist (and, when it does, it fires on tap-and-hold, which is a poor UX). Style the pressed state on:activeso mobile users get visible feedback. Only add:hoverwhen there is a specific desktop affordance worth highlighting — never as the sole feedback mechanism.
Component-scoped styles
Page-level and cross-component styles live under src/styles/ (reset.css, global.css, index.css). Styles that only concern a single Web Component should live inside that component's .js file so the component is self-contained.
Pattern (using light DOM — we deliberately avoid Shadow DOM so :root theme tokens inherit consistently and the 1rem = 1px font-size trick keeps working):
import { injectComponentStyles } from '../helpers/dom.js'
const STYLES = /* css */`
account-avatar { /* ... tag-prefixed to avoid collisions ... */ }
account-avatar .avatar-btn { /* ... */ }
`
export class AccountAvatar extends HTMLElement {
connectedCallback () {
injectComponentStyles('account-avatar', STYLES)
// ...
}
}
Rules for component CSS:
- Always prefix selectors with the component's tag name (e.g.
account-avatar .avatar-btn). The styles live in the global stylesheet scope, so the tag prefix is the scoping mechanism. - Use
injectComponentStyles(id, css)fromsrc/helpers/dom.js. It is idempotent — safe to call from everyconnectedCallback. - Tag the template literal with a leading
/* css */comment so editors / extensions that opt into tag-based highlighting syntax-highlight the CSS inside. Same idea applies to any HTML template literal (/* html */). - Keep theme color tokens in
src/styles/theme.css(single source,light-dark()pairs). Spacing and other shared tokens live inglobal.css. Consume them via CSS custom properties.
JavaScript Module Conventions
- Import local modules with relative paths (
./,../). Bare specifiers (libp2r2p/*, Noble,thenameisf) are resolved by esbuild fromnode_modulesat build time — there is no runtime importmap or CDN dependency. - Keep
src/index.jsas the single SPA entry. It wires up components and kicks off any needed service initialization. - Custom elements (Web Components) are the default UI primitive. Register them from their file in
src/components/and use them declaratively in markup.
Testing & Local Development
- Build:
npm run build(ornpm run build:watchfor development). - Run tests:
npm test(executesnode --test 'tests/**/*.test.js'). - Start dev server:
npm start(runsbin/dev.js: esbuild watch +server.pyserving the untracked.dev/output with no-cache — the watcher never touchesdist/or documentation indocs/).npm run start:peerserves the same.dev/on a second port — it assumes the watcher fromnpm startis running.npm run buildwrites the untrackeddist/(GitHub Pages artifact).python3 server.pyservesdist/by default for a production preview. - Stop dev server:
npm run end. - Add new automated tests under
tests/using Node's built-in test runner — no extra framework. - Dev dependencies may be added when they materially improve testing or are needed by the esbuild bundle. Keep them in
devDependencies/package-lock.json; they are bundled at build time and never shipped as a runtime CDN dependency. - Small manual-check scripts and other developer-facing files may live at the repo root; authored documentation belongs in
docs/. Only generateddist/contents are shipped to GitHub Pages.
Security Mindset (Read Before Every Change)
Because this app holds users' Nostr private keys, every change should be evaluated against these questions:
- Does this add a new runtime dependency or network origin? (Default answer: no.)
- Does this make the code harder to audit by a careful human reader?
- Does this enlarge the attack surface around
nsecstorage, reading, or transport? - Does this bypass or weaken the NIP-46 boundary?
If the answer to any of these is "yes", the change needs an explicit justification and should be discussed with the user before proceeding.
Personal-copy (kind:1006) double signing for local nsec accounts uses an
existing or newly persisted encrypted content key without relay discovery or
publication. This allows offline event-store writes; the ordinary content-key
sidecar and device synchronization retain the key. Public double-signed events
still require a published content key. Account locks, read-only restrictions
and signer permissions are unchanged. Bunker accounts delegate to their remote
signer and therefore do not gain offline signing.
Content-key selection belongs to secrets.getLatestContentKeySigner: greatest secret createdAt, then smallest pubkey. Bind that selection explicitly into the NsecSigner; never infer it from Map insertion order or a relay announcement. Local dispatch pins the selected signer through discovery/publication and crypto; the internal optional signer argument is not part of the launcher/app wire API. Local rotations use createContentKeySecret, advancing past the prior creation timestamp and retaining historical secrets in the encrypted sidecar. Prune only historical keys older than 45 days, protecting the same deterministically selected active key. Imports preserve their timestamps. Public announcement checks and periodic maintenance advertise this local selection, including when another device advertises an unavailable secret. Personal copies remain offline-capable. Do not persist announcements in NostrDB or introduce a separate active pointer. Preserve best-effort encryption and publication-required public signing on errors.
The Python development server resolves its output directory explicitly instead of changing its working directory. Rebuilding generated output can replace the directory without invalidating the running server's working directory.
Activity-log rendering reads/decrypts one page at a time and coalesces concurrent notifications. Collapsed rows contain a bounded 512-character preview and an empty JSON body. Materialize the complete record with textContent only when its details opens; release that text on collapse and retained entries on disconnect. Keep full audit data and Copy available on expansion; do not truncate storage or weaken sealed fields to reduce rendering memory.
Private-messenger delivery contract
-
Consume the prepared 0.11.22 library patch: automatic legacy-index migration, separate bounded pending-decryption FIFO and event-driven retries. Keep vault sync seed storage local. Only committed subscribeContentKeys notifications request retries and forward CONTENT_KEYS_CHANGED to the trusted launcher port; never include key inventory or secrets. Generic handler/storage failures retain their ACK/NACK behavior. Successful content-key publication remembers the authenticated event in the shared library cache; failed sends never do.
-
Messenger init/update default to local readiness in 0.11.20. Hold inbox drain while applying local channels and publish the corresponding account/channel snapshot before processing initial callbacks. Do not await remote recovery to enable sync; background errors never imply account unavailability. Preserve ACK/NACK, local seed storage, lock generations and queued initial deliveries.
nextMessage() returns { message, ack, nack } in libp2r2p 0.10.20. Sync must
finish its handler before acknowledging, and nack failed or superseded work.
Never destructively drain a reservation on lock, shutdown or handler failure.
Private-messenger uses the coordinated libp2r2p 0.11 router (p recipient,
without legacy router r fallback). Do not rename inner sync control tags.
Outgoing seeds no longer depend on relay echo. Consumer apps can persist recovery
seeds and file grants as personal copies through libp2r2p/private-messenger/event-store.
The vault's own synchronization messenger MUST retain its local default seed
storage: synchronizing seeds of messages that synchronize seeds recursively
creates more synchronization data. Do not inject that event-store adapter here.
NostrDB push batches are scoped to their queue and messenger lifetime. On stop or replacement, stale in-flight results must not report shutdown errors, send the next chunk or rearm the trailing cooldown. Active messenger/storage errors still reach onError; lifecycle suppression must not become a blanket error filter.
Launcher relay bridge
-
Use the coordinated libp2r2p 0.11.22 release, pinned exactly in the dev dependency and lockfile. Its live readers stop definitive per-route refusals and recover transient errors through bounded offline-aware backoff/cooldowns. Keep the existing facade/protocol and standalone fallback. Validate sync, NIP-46 and bridge regressions, then validate the production build in untracked
dist/. The private-sync browser fixture must answer connectivity probes at the HTTP boundary while keeping the real monitor and recovery code active. -
src/services/launcher-relay-pool.jsis only a WebSocket facade over the dedicated launcher port; registry, routing and physical sockets stay in the launcher. Standalone/unsupported launchers retain native sockets. -
Preserve FIFO when an outgoing queue exists, even if a newer small frame fits the remaining credit. Keep the outgoing 64-frame/256-KiB credit window and 256-frame/1-MiB queue limits, including the existing string-length accounting.
-
Return trailing receive credits in a microtask. Close releases local buffers and suppresses pending refunds; an overflow also sends
RELAY_CLOSEso the launcher releases its attachment. LateRELAY_ATTACHEDmust not reopen a closing socket. Receive-queue diagnostics belong to the launcher and must not include message contents or account keys. -
Cover ordering, credit return and overflow cleanup in
tests/launcher-relay-pool.test.js; validate the production build in untrackeddist/. -
Receive refunds echo optional
sequenceasthrough, withreceivedAtandreturnedAtfrom performance.timeOrigin + performance.now(). The launcher owns incoming budgets, credit validation and bounded latency diagnostics; these timestamps acknowledge port handling, not downstream async persistence. Remain compatible with frames that carry no sequence or timing metadata. -
NostrDB reply handlers must propagate database/publication failures so the parent delivery drain nacks them. Pushes are serialized per owner and retain failed/unsent batches for the next cooldown while their runtime stays active. Sync JSONL batches target 128 KiB UTF-8 plus the 100-record cap; an indivisible oversized event travels alone. Keep native errors visible and never raise browser storage quotas or acknowledge an unsent final response.
Account presentation (name, picture, about) may come from pairing before
signed metadata is available. Intake never fabricates Nostr profile events.
Metadata sent to or received from the launcher must have a valid signature,
matching account author and expected kind (0/10002). Legacy unsigned timestamp-0
placeholders remain readable for presentation/export only; no bulk migration or
storage reset is required. A valid signed timestamp-0 event is acceptable.
Sync overflow warnings include only bounded operational queue diagnostics.
-
Forward Nostr frames, including optional absolute
@<Unix milliseconds>params, unchanged. Never recompute cooldown at delivery or add properties to WebSocket events. Outgoing overflow may report operational facts over the internal bridge; the launcher treats consumer reports as unverified. Remote origin/policy fields do not supply trusted local-failure attribution. -
The 0.11.20 private messenger exposes readStatus/onStateChanged and recovers internally owned network/storage pauses. Preserve explicit vault lock ownership, durable ACK/NACK ordering and local sync seed storage. Session factory state observation is mandatory; do not introduce polling compatibility or custom WebSocket properties. Rebuild dist/ from the published lockfile; never commit it.
-
Consume the published 0.11.20 patch for cancellable shared publications and optional early fallback. Vault sync retains fallbackDelayMs: null, durable ACK/NACK ordering and existing lock/pause ownership; no bridge or signer API changes. Regenerate dist/ only from the registry-resolved dependency; never commit it.
-
The private-sync browser fixture yields between separately granted app API calls, allowing the native permission dialog close event to finish. Keep real UI permission grants and account/store boundaries; never bypass permissions or change launcher modal behavior to make this regression pass.
Prepared relay package validation
Before registry publication, validate and rebuild with the exact prepared 0.11.22 tarball through public imports. Final lockfiles retain npm URLs and its real integrity; publish that same archive. The facade preserves params unchanged.
Import kind 18716 as CONTENT_KEY_ANNOUNCEMENT from libp2r2p/kind; content-key
event helpers no longer export a separate kind constant. Do not add a legacy alias
to the vault's content-key service.
