@microtoll/mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,957 @@
1
+ [
2
+ {
3
+ "path": "index.html",
4
+ "url": "https://microtoll.dev/index.html",
5
+ "title": "The locks, pre-built",
6
+ "description": "What the engine is, what it is for, and what it is not.",
7
+ "section": "Start",
8
+ "markdown": "# The locks, pre-built\n\nMicrotoll Engine is the security layer of an end-to-end-encrypted app,\npackaged so the next app does not have to invent it: **sign-in without passwords, keys that\nnever reach the server, sharing by capability, and revocation that actually\ntakes access away.** Browser packages with zero dependencies, a server that\nholds only what it cannot read, a threat model that says what is not\nprotected, and test vectors from the RFCs for every primitive.\n\n## The problem it exists for\n\nMost new code is now written with an AI coding agent, and the four things\nthose agents get wrong are the four things that cannot be got wrong: how a\nperson signs in, where the keys live, who may read what, and what happens\nwhen someone is removed. The code they produce looks right — a bcrypt here,\nan AES call there, a token in local storage — and passes review, because\nsecurity code that is wrong reads exactly like security code that is right.\nThe failures surface a year later, in someone else's inbox.\n\nThe engine's answer is not another library of primitives. It is the\n**finished constructions**: an account with no password and no email; a\nroot key wrapped by a passkey and by a recovery code; a session that\nsurvives a reload and dies on \"sign out everywhere\"; an object whose key is\nsealed to each member and rotated when one is removed; a share link whose\nsecret never reaches the server; a mailbox two people can compute and the\nserver cannot. Each was built for a real app, fixed where an audit found it\nwanting, and comes with the reasoning written down.\n\n## What you get\n\n- **`@microtoll/crypto-core`** — AES-256-GCM, HKDF, PBKDF2, Ed25519,\n P-256 sealing, the versioned wire formats, and an optional hybrid\n post-quantum seal. Web Crypto only; RFC and NIST vectors through the\n public API.\n- **`@microtoll/identity`** — the account root key, passkeys as a PRF and\n recovery codes as the two ways in, the trusted-device session, the\n private settings blob, deletion. Your screens, as callbacks.\n- **`@microtoll/access`** — an object with a key sealed per member, admin\n and read capabilities the server holds only hashes of, share links,\n removal that re-keys everything in one checked transaction, a second\n tier for the sensitive part.\n- **`@microtoll/mailbox`** — direct invitations under labels only the two\n parties can compute.\n- **`@microtoll/blind-store`** — the server: the handshake, the sealed\n tables, the coarse-selector query, live watches, the sweep, a hardened\n Docker deployment. It stores ciphertext, hashes and one coarse selector\n per object, and a test fails if a column that could identify anyone is\n ever added.\n- **`@microtoll/mcp`** — the docs and a scaffold inside Claude Code, Cursor\n or any Model Context Protocol host, so an agent can wire the engine in\n without leaving the editor.\n\n## What it is not\n\n- **Not a hosted service.** You run the server; there is no account with us,\n no dashboard, no telemetry, and nothing financial anywhere in the code.\n- **Not a compliance certificate.** It is a set of constructions with their\n threat model attached. [The honest limits](honest-limits.html) page says\n what is not protected: traffic shape, a compromised device, a script\n injected into your page.\n- **Not \"post-quantum secure\".** The public-key seals are classical unless\n hybrid mode is on for every recipient; the docs say \"hybrid mode\" and mean\n it.\n- **Not finished.** Pre-1.0: function names and options may change between\n minor versions; the bytes it writes never will\n ([formats and stability](formats-and-stability.html)).\n\n## Start\n\n[Start here](start-here.html): `docker compose up` the notes example, read\none file, and then the four packages in the order they build on each other.\n",
9
+ "sections": [
10
+ {
11
+ "heading": "The locks, pre-built",
12
+ "level": 1,
13
+ "text": "Microtoll Engine is the security layer of an end-to-end-encrypted app,\npackaged so the next app does not have to invent it: **sign-in without passwords, keys that\nnever reach the server, sharing by capability, and revocation that actually\ntakes access away.** Browser packages with zero dependencies, a server that\nholds only what it cannot read, a threat model that says what is not\nprotected, and test vectors from the RFCs for every primitive."
14
+ },
15
+ {
16
+ "heading": "The problem it exists for",
17
+ "level": 2,
18
+ "text": "Most new code is now written with an AI coding agent, and the four things\nthose agents get wrong are the four things that cannot be got wrong: how a\nperson signs in, where the keys live, who may read what, and what happens\nwhen someone is removed. The code they produce looks right — a bcrypt here,\nan AES call there, a token in local storage — and passes review, because\nsecurity code that is wrong reads exactly like security code that is right.\nThe failures surface a year later, in someone else's inbox.\n\nThe engine's answer is not another library of primitives. It is the\n**finished constructions**: an account with no password and no email; a\nroot key wrapped by a passkey and by a recovery code; a session that\nsurvives a reload and dies on \"sign out everywhere\"; an object whose key is\nsealed to each member and rotated when one is removed; a share link whose\nsecret never reaches the server; a mailbox two people can compute and the\nserver cannot. Each was built for a real app, fixed where an audit found it\nwanting, and comes with the reasoning written down."
19
+ },
20
+ {
21
+ "heading": "What you get",
22
+ "level": 2,
23
+ "text": "- **`@microtoll/crypto-core`** — AES-256-GCM, HKDF, PBKDF2, Ed25519,\n P-256 sealing, the versioned wire formats, and an optional hybrid\n post-quantum seal. Web Crypto only; RFC and NIST vectors through the\n public API.\n- **`@microtoll/identity`** — the account root key, passkeys as a PRF and\n recovery codes as the two ways in, the trusted-device session, the\n private settings blob, deletion. Your screens, as callbacks.\n- **`@microtoll/access`** — an object with a key sealed per member, admin\n and read capabilities the server holds only hashes of, share links,\n removal that re-keys everything in one checked transaction, a second\n tier for the sensitive part.\n- **`@microtoll/mailbox`** — direct invitations under labels only the two\n parties can compute.\n- **`@microtoll/blind-store`** — the server: the handshake, the sealed\n tables, the coarse-selector query, live watches, the sweep, a hardened\n Docker deployment. It stores ciphertext, hashes and one coarse selector\n per object, and a test fails if a column that could identify anyone is\n ever added.\n- **`@microtoll/mcp`** — the docs and a scaffold inside Claude Code, Cursor\n or any Model Context Protocol host, so an agent can wire the engine in\n without leaving the editor."
24
+ },
25
+ {
26
+ "heading": "What it is not",
27
+ "level": 2,
28
+ "text": "- **Not a hosted service.** You run the server; there is no account with us,\n no dashboard, no telemetry, and nothing financial anywhere in the code.\n- **Not a compliance certificate.** It is a set of constructions with their\n threat model attached. [The honest limits](honest-limits.html) page says\n what is not protected: traffic shape, a compromised device, a script\n injected into your page.\n- **Not \"post-quantum secure\".** The public-key seals are classical unless\n hybrid mode is on for every recipient; the docs say \"hybrid mode\" and mean\n it.\n- **Not finished.** Pre-1.0: function names and options may change between\n minor versions; the bytes it writes never will\n ([formats and stability](formats-and-stability.html))."
29
+ },
30
+ {
31
+ "heading": "Start",
32
+ "level": 2,
33
+ "text": "[Start here](start-here.html): `docker compose up` the notes example, read\none file, and then the four packages in the order they build on each other."
34
+ }
35
+ ]
36
+ },
37
+ {
38
+ "path": "start-here.html",
39
+ "url": "https://microtoll.dev/start-here.html",
40
+ "title": "Start here",
41
+ "description": "From zero to a working end-to-end-encrypted app in one sitting: the example, then the four packages in order.",
42
+ "section": "Start",
43
+ "markdown": "# Start here\n\nOne sitting, no questions: a working end-to-end-encrypted app in front of\nyou, then the packages in the order they build on each other.\n\n## 1. Run the notes example (ten minutes)\n\n```sh\ngit clone https://github.com/microtoll/engine\ncd engine/examples/notes-app\ndocker compose up\n```\n\nOpen <http://localhost:8088>. Sign up with a recovery code, write a note,\nshare it by link, open the link in a private window as a second person,\njoin, then remove that person and watch their copy go stale. The\n[notes example](examples/notes-app.html) page walks through it and says,\nhonestly, what the server learned.\n\nThen read [`notes.js`](https://github.com/microtoll/engine/blob/main/examples/notes-app/notes.js):\nabout two hundred lines, the whole model.\n\n## 2. The packages, in order\n\n1. **[crypto-core](packages/crypto-core.html)** — one call gives you a\n `cryptoCore` bound to your app's namespace. Everything else takes it.\n2. **[identity](packages/identity.html)** — `createIdentitySession` with your\n screens as callbacks: boot, register, unlock, lock, delete.\n3. **[access](packages/access.html)** — `createAccess` with your three choices\n (the fields in a pointer, what goes in the second tier, who is owed it),\n then objects, members, links and rotation.\n4. **[blind-store](packages/blind-store.html)** — the server, as a library\n you mount handlers on or as the reference binary the examples run.\n\nThe [invite example](examples/invite-app.html) adds the fifth,\n**[mailbox](packages/mailbox.html)**: inviting a known person with nothing\nto forward.\n\n## 3. Your own app\n\nEither scaffold it from inside your editor with the\n[MCP server](for-agents.html) (`microtoll_scaffold` writes the notes starter\ninto an empty directory), or copy `examples/notes-app` and change three\nthings: the collection (`BLIND_STORE_COLLECTIONS`), the namespace (the same\nstring in `createCryptoCore` and `BLIND_STORE_NAMESPACE`), and the origins\nthe server accepts.\n\n## 4. Before you ship\n\n- Read [the honest limits](honest-limits.html) and put its list in your own\n \"about\" page. Users are owed it.\n- Deploy with [the kit](deploy.html): the database on the internal network,\n the server as its own database role, the proxy blanking the client's\n address, no access log.\n- Run the schema-conformance test against your database\n (`packages/blind-store/test/schema.test.mjs`) whenever you add a table.\n- Pin the versions. [Formats and stability](formats-and-stability.html)\n says what a version number promises.\n",
44
+ "sections": [
45
+ {
46
+ "heading": "Start here",
47
+ "level": 1,
48
+ "text": "One sitting, no questions: a working end-to-end-encrypted app in front of\nyou, then the packages in the order they build on each other."
49
+ },
50
+ {
51
+ "heading": "1. Run the notes example (ten minutes)",
52
+ "level": 2,
53
+ "text": "```sh\ngit clone https://github.com/microtoll/engine\ncd engine/examples/notes-app\ndocker compose up\n```\n\nOpen <http://localhost:8088>. Sign up with a recovery code, write a note,\nshare it by link, open the link in a private window as a second person,\njoin, then remove that person and watch their copy go stale. The\n[notes example](examples/notes-app.html) page walks through it and says,\nhonestly, what the server learned.\n\nThen read [`notes.js`](https://github.com/microtoll/engine/blob/main/examples/notes-app/notes.js):\nabout two hundred lines, the whole model."
54
+ },
55
+ {
56
+ "heading": "2. The packages, in order",
57
+ "level": 2,
58
+ "text": "1. **[crypto-core](packages/crypto-core.html)** — one call gives you a\n `cryptoCore` bound to your app's namespace. Everything else takes it.\n2. **[identity](packages/identity.html)** — `createIdentitySession` with your\n screens as callbacks: boot, register, unlock, lock, delete.\n3. **[access](packages/access.html)** — `createAccess` with your three choices\n (the fields in a pointer, what goes in the second tier, who is owed it),\n then objects, members, links and rotation.\n4. **[blind-store](packages/blind-store.html)** — the server, as a library\n you mount handlers on or as the reference binary the examples run.\n\nThe [invite example](examples/invite-app.html) adds the fifth,\n**[mailbox](packages/mailbox.html)**: inviting a known person with nothing\nto forward."
59
+ },
60
+ {
61
+ "heading": "3. Your own app",
62
+ "level": 2,
63
+ "text": "Either scaffold it from inside your editor with the\n[MCP server](for-agents.html) (`microtoll_scaffold` writes the notes starter\ninto an empty directory), or copy `examples/notes-app` and change three\nthings: the collection (`BLIND_STORE_COLLECTIONS`), the namespace (the same\nstring in `createCryptoCore` and `BLIND_STORE_NAMESPACE`), and the origins\nthe server accepts."
64
+ },
65
+ {
66
+ "heading": "4. Before you ship",
67
+ "level": 2,
68
+ "text": "- Read [the honest limits](honest-limits.html) and put its list in your own\n \"about\" page. Users are owed it.\n- Deploy with [the kit](deploy.html): the database on the internal network,\n the server as its own database role, the proxy blanking the client's\n address, no access log.\n- Run the schema-conformance test against your database\n (`packages/blind-store/test/schema.test.mjs`) whenever you add a table.\n- Pin the versions. [Formats and stability](formats-and-stability.html)\n says what a version number promises."
69
+ }
70
+ ]
71
+ },
72
+ {
73
+ "path": "packages/crypto-core.html",
74
+ "url": "https://microtoll.dev/packages/crypto-core.html",
75
+ "title": "@microtoll/crypto-core",
76
+ "description": "Primitives, labelled key derivation, versioned wire formats, hybrid post-quantum seal.",
77
+ "section": "Packages",
78
+ "markdown": "# @microtoll/crypto-core\n\nThe primitives and wire formats of the Microtoll Engine. Web Crypto\n(`SubtleCrypto`) only; no runtime dependencies; runs in current browsers and\nNode ≥ 24.\n\n**Status:** pre-release; not published. Every format is pinned by frozen\nfixtures (`test/fixtures/frozen-v1.json`) that each later version must open.\n\n## Five-minute quickstart\n\n```js\nimport { createCryptoCore } from '@microtoll/crypto-core';\n\n// One instance per app. The namespace prefixes every derivation label, so no\n// two apps ever share a key derivation by accident. It is required.\nconst cc = createCryptoCore({ namespace: 'myapp' });\n\n// A 32-byte root secret, and keys derived from it under labelled purposes.\nconst root = cc.generateSymmetricKey();\nconst routingSeed = await cc.deriveBits(root, 'routing'); // HKDF, label \"myapp/routing/v1\"\nconst signingKey = await cc.importEd25519PrivateKeyFromSeed(routingSeed);\nconst ownDataKey = await cc.deriveAesKey(root, 'symm'); // AES-256-GCM, non-extractable\n\n// Symmetric sealing: [0x01][12-byte IV][ciphertext ‖ tag], optional bound context.\nconst sealed = await cc.sealSymmetric(ownDataKey, new TextEncoder().encode('hello'));\nconst opened = await cc.openSymmetric(ownDataKey, sealed);\n\n// Sealing to another person: they hold a P-256 key pair stored as a JWK.\nconst alice = await cc.generateSealingKeyPair(); // { privateKey, publicKeyRaw, jwk }\nconst forAlice = await cc.sealToRecipient(alice.publicKeyRaw, opened);\nconst back = await cc.openWithPrivateKey(alice, forAlice); // needs the pair, not a bare key\n\n// A recovery code a person can write down: 128 bits, Crockford base32, checksum.\nconst { secretBytes, displayString } = await cc.generateRecoveryCode(); // \"ABCD-EFGH-…-XYZ\"\nconst unwrapKey = await cc.deriveAesKeyFromSecret(secretBytes, cc.randomBytes(16)); // PBKDF2, 310,000 iterations\n```\n\nStateless primitives are also exported directly (`hkdfDeriveBits`,\n`sealSymmetric`, `verifyBytes`, the encoders); everything derived under a\nlabel lives on the instance.\n\n## What is here\n\n- **Encoding helpers:** hex, base64url, Crockford base32, and the\n fixed-length frame `context ‖ 0x00 ‖ parts` for bound contexts.\n- **HKDF-SHA-256** (RFC 5869) in one shape: empty salt, the label as info.\n- **Ed25519** from a 32-byte seed through the RFC 8410 PKCS#8 wrapper; sign\n and verify (verify returns `false`, never throws).\n- **The stored P-256 sealing key**: generated once, kept as a private JWK,\n imported non-extractable. Stored rather than derived because Safari has no\n X25519 and Firefox cannot import a P-256 private key from a bare scalar.\n- **AEAD v1:** `[0x01][12-byte IV][AES-256-GCM ciphertext ‖ tag]`, optional\n additional authenticated data.\n- **ECIES v3:** `[0x03][65-byte ephemeral P-256 point][AEAD v1]`, key =\n HKDF(ECDH secret, `\"<ns>/ecies/v3\"` ‖ SHA-256(ephemeral ‖ recipient)),\n version byte authenticated. The retired X25519 v1 format is refused by name.\n- **ECIES v2, hybrid post-quantum:** `[0x02][1120-byte MLKEM768-X25519\n ciphertext][AEAD v1]`, same binding. **Off by default** (`hybridSealing:\n false`): it needs a browser with native `MLKEM768-X25519` (Chrome 154+) and\n has not yet been cross-checked against one. In tests it runs through a\n test-only X-Wing composition verified against the draft's vectors.\n- **PBKDF2-SHA-256** and the **recovery-code format, version 3** (D-46): 16\n bytes in Crockford base32 plus a weighted check character over GF(32)\n (`XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXX`). Every single wrong character and\n every swap of two characters is caught; a random typo, 31 times in 32.\n Version 2 (a SHA-256-derived check) is read only with `{ version: 2 }`.\n- **The label profile:** `createProfile({ namespace })`, `<namespace>/<purpose>/v<n>`;\n retired labels are refused in every namespace. `DEFAULT_PBKDF2_ITERATIONS`\n is 310,000.\n\n## Tests\n\n`npm test` at the repository root. Published vectors run through this\npackage's own API: RFC 5869 (test cases 1 and 3), RFC 8032 (tests 1, 2, 3,\nSHA(abc)), RFC 5903 §8.1, RFC 7914 §11, NIST CAVP AES-256-GCM, and X-Wing\ndraft-10 Appendix C plus the working-group `MLKEM768-X25519` vector. Then\nproperty and tamper tests, and the frozen fixtures.\n\n## Threat model\n\nSee `THREATMODEL.md` §3 at the repository root. In one line: this package\nprotects sealed bytes against the server, the network and strangers; it\nprotects nothing against a compromised device or page, and its classical seal\nis not quantum-safe.\n\n## Notes on the API\n\n- **Labels come from a namespace profile** (DECISIONS.md D-05), never from\n constants an app could edit in place; retired labels are refused in every\n namespace.\n- **The post-quantum switch is an instance option** (`hybridSealing`, off by\n default, D-07); the test seams are `_setHybridSealing` and\n `_resetHybridSupport`.\n- **`pbkdf2DeriveBits`** is exposed so the RFC 7914 vectors run through the\n package; `deriveAesKeyFromSecret` takes its iteration count from the\n profile.\n- **`randomBytes(length)`** is chunked past Web Crypto's 65,536-byte limit;\n `generateSymmetricKey` gives 32 random bytes, and the identity and access\n packages name its uses (root key, capability secret).\n- **`fromHex` rejects non-hex characters** instead of decoding them as zero.\n- **The recovery-code check character is version 3** (D-46): Σ aⁱ⁺¹·sᵢ over\n the 26 data characters in GF(32) = GF(2)[x]/(x⁵ + x² + 1), a = x, and the\n parser refuses non-zero padding bits, so one string names one secret. A\n version-2 code (D-26) looks the same, so it is never tried as a fallback:\n pass `{ version: 2 }` to read one. `formatRecoveryCode` and\n `parseRecoveryCode` stay asynchronous, because version 2 needs SHA-256.\n Errors carry a `code` (`recovery-code-checksum`, …) for the app to word.\n- **Not in this package** (they live in identity, access or mailbox): the\n root-key envelopes, the recovery lookup hash, the URL-token and capability\n helpers, the handshake signature and the mailbox label. Their primitive-level\n fixtures are in `test/fixtures/frozen-v1.json` for those packages' tests.\n",
79
+ "sections": [
80
+ {
81
+ "heading": "@microtoll/crypto-core",
82
+ "level": 1,
83
+ "text": "The primitives and wire formats of the Microtoll Engine. Web Crypto\n(`SubtleCrypto`) only; no runtime dependencies; runs in current browsers and\nNode ≥ 24.\n\n**Status:** pre-release; not published. Every format is pinned by frozen\nfixtures (`test/fixtures/frozen-v1.json`) that each later version must open."
84
+ },
85
+ {
86
+ "heading": "Five-minute quickstart",
87
+ "level": 2,
88
+ "text": "```js\nimport { createCryptoCore } from '@microtoll/crypto-core';\n\n// One instance per app. The namespace prefixes every derivation label, so no\n// two apps ever share a key derivation by accident. It is required.\nconst cc = createCryptoCore({ namespace: 'myapp' });\n\n// A 32-byte root secret, and keys derived from it under labelled purposes.\nconst root = cc.generateSymmetricKey();\nconst routingSeed = await cc.deriveBits(root, 'routing'); // HKDF, label \"myapp/routing/v1\"\nconst signingKey = await cc.importEd25519PrivateKeyFromSeed(routingSeed);\nconst ownDataKey = await cc.deriveAesKey(root, 'symm'); // AES-256-GCM, non-extractable\n\n// Symmetric sealing: [0x01][12-byte IV][ciphertext ‖ tag], optional bound context.\nconst sealed = await cc.sealSymmetric(ownDataKey, new TextEncoder().encode('hello'));\nconst opened = await cc.openSymmetric(ownDataKey, sealed);\n\n// Sealing to another person: they hold a P-256 key pair stored as a JWK.\nconst alice = await cc.generateSealingKeyPair(); // { privateKey, publicKeyRaw, jwk }\nconst forAlice = await cc.sealToRecipient(alice.publicKeyRaw, opened);\nconst back = await cc.openWithPrivateKey(alice, forAlice); // needs the pair, not a bare key\n\n// A recovery code a person can write down: 128 bits, Crockford base32, checksum.\nconst { secretBytes, displayString } = await cc.generateRecoveryCode(); // \"ABCD-EFGH-…-XYZ\"\nconst unwrapKey = await cc.deriveAesKeyFromSecret(secretBytes, cc.randomBytes(16)); // PBKDF2, 310,000 iterations\n```\n\nStateless primitives are also exported directly (`hkdfDeriveBits`,\n`sealSymmetric`, `verifyBytes`, the encoders); everything derived under a\nlabel lives on the instance."
89
+ },
90
+ {
91
+ "heading": "What is here",
92
+ "level": 2,
93
+ "text": "- **Encoding helpers:** hex, base64url, Crockford base32, and the\n fixed-length frame `context ‖ 0x00 ‖ parts` for bound contexts.\n- **HKDF-SHA-256** (RFC 5869) in one shape: empty salt, the label as info.\n- **Ed25519** from a 32-byte seed through the RFC 8410 PKCS#8 wrapper; sign\n and verify (verify returns `false`, never throws).\n- **The stored P-256 sealing key**: generated once, kept as a private JWK,\n imported non-extractable. Stored rather than derived because Safari has no\n X25519 and Firefox cannot import a P-256 private key from a bare scalar.\n- **AEAD v1:** `[0x01][12-byte IV][AES-256-GCM ciphertext ‖ tag]`, optional\n additional authenticated data.\n- **ECIES v3:** `[0x03][65-byte ephemeral P-256 point][AEAD v1]`, key =\n HKDF(ECDH secret, `\"<ns>/ecies/v3\"` ‖ SHA-256(ephemeral ‖ recipient)),\n version byte authenticated. The retired X25519 v1 format is refused by name.\n- **ECIES v2, hybrid post-quantum:** `[0x02][1120-byte MLKEM768-X25519\n ciphertext][AEAD v1]`, same binding. **Off by default** (`hybridSealing:\n false`): it needs a browser with native `MLKEM768-X25519` (Chrome 154+) and\n has not yet been cross-checked against one. In tests it runs through a\n test-only X-Wing composition verified against the draft's vectors.\n- **PBKDF2-SHA-256** and the **recovery-code format, version 3** (D-46): 16\n bytes in Crockford base32 plus a weighted check character over GF(32)\n (`XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXX`). Every single wrong character and\n every swap of two characters is caught; a random typo, 31 times in 32.\n Version 2 (a SHA-256-derived check) is read only with `{ version: 2 }`.\n- **The label profile:** `createProfile({ namespace })`, `<namespace>/<purpose>/v<n>`;\n retired labels are refused in every namespace. `DEFAULT_PBKDF2_ITERATIONS`\n is 310,000."
94
+ },
95
+ {
96
+ "heading": "Tests",
97
+ "level": 2,
98
+ "text": "`npm test` at the repository root. Published vectors run through this\npackage's own API: RFC 5869 (test cases 1 and 3), RFC 8032 (tests 1, 2, 3,\nSHA(abc)), RFC 5903 §8.1, RFC 7914 §11, NIST CAVP AES-256-GCM, and X-Wing\ndraft-10 Appendix C plus the working-group `MLKEM768-X25519` vector. Then\nproperty and tamper tests, and the frozen fixtures."
99
+ },
100
+ {
101
+ "heading": "Threat model",
102
+ "level": 2,
103
+ "text": "See `THREATMODEL.md` §3 at the repository root. In one line: this package\nprotects sealed bytes against the server, the network and strangers; it\nprotects nothing against a compromised device or page, and its classical seal\nis not quantum-safe."
104
+ },
105
+ {
106
+ "heading": "Notes on the API",
107
+ "level": 2,
108
+ "text": "- **Labels come from a namespace profile** (DECISIONS.md D-05), never from\n constants an app could edit in place; retired labels are refused in every\n namespace.\n- **The post-quantum switch is an instance option** (`hybridSealing`, off by\n default, D-07); the test seams are `_setHybridSealing` and\n `_resetHybridSupport`.\n- **`pbkdf2DeriveBits`** is exposed so the RFC 7914 vectors run through the\n package; `deriveAesKeyFromSecret` takes its iteration count from the\n profile.\n- **`randomBytes(length)`** is chunked past Web Crypto's 65,536-byte limit;\n `generateSymmetricKey` gives 32 random bytes, and the identity and access\n packages name its uses (root key, capability secret).\n- **`fromHex` rejects non-hex characters** instead of decoding them as zero.\n- **The recovery-code check character is version 3** (D-46): Σ aⁱ⁺¹·sᵢ over\n the 26 data characters in GF(32) = GF(2)[x]/(x⁵ + x² + 1), a = x, and the\n parser refuses non-zero padding bits, so one string names one secret. A\n version-2 code (D-26) looks the same, so it is never tried as a fallback:\n pass `{ version: 2 }` to read one. `formatRecoveryCode` and\n `parseRecoveryCode` stay asynchronous, because version 2 needs SHA-256.\n Errors carry a `code` (`recovery-code-checksum`, …) for the app to word.\n- **Not in this package** (they live in identity, access or mailbox): the\n root-key envelopes, the recovery lookup hash, the URL-token and capability\n helpers, the handshake signature and the mailbox label. Their primitive-level\n fixtures are in `test/fixtures/frozen-v1.json` for those packages' tests."
109
+ }
110
+ ]
111
+ },
112
+ {
113
+ "path": "packages/identity.html",
114
+ "url": "https://microtoll.dev/packages/identity.html",
115
+ "title": "@microtoll/identity",
116
+ "description": "Root key, unlock methods (passkey PRF, recovery code), sessions, restore, deletion.",
117
+ "section": "Packages",
118
+ "markdown": "# @microtoll/identity\n\nSign-in and accounts without the server ever holding a key: one Account Root\nKey per person, wrapped once per unlock method (a passkey, a recovery code),\na trusted-device session, a private settings blob, step-up for sensitive\nactions, and a deletion order that leaves nothing behind. Web Crypto only;\ndepends only on `@microtoll/crypto-core`.\n\n**Status:** pre-release (M2 complete); not published. Formats are version 2\n(`FORMATS.md`); version 1 is not read.\n\n## What the server learns\n\nThe routing public key (a locker number), one row per unlock method holding\nciphertext and a credential id or lookup hash, the sealed blob, a compare-and-\nswap token and a generation counter. Never a key, a name, or when. The\nserver never verifies a passkey: WebAuthn is used only as a PRF oracle whose\n32-byte output, through HKDF, unwraps the root key.\n\n## Five-minute quickstart\n\n```js\nimport { createCryptoCore } from '@microtoll/crypto-core';\nimport { createIdentitySession, createSessionStore, createKnownAccountStore, createWebAuthn } from '@microtoll/identity';\n\nconst cc = createCryptoCore({ namespace: 'myapp' });\nconst session = createIdentitySession({\n cryptoCore: cc,\n origin: location.origin, // bound into the sign-in signature\n transport: { connect: () => openWebSocket('/ws') }, // your socket factory\n sessionStore: createSessionStore({ cryptoCore: cc }), // IndexedDB \"myapp-session\"\n knownAccounts: createKnownAccountStore({ cryptoCore: cc }),\n webauthn: createWebAuthn({ rpName: 'My app' }),\n ui: {\n askRecoveryCode: async (reason) => promptUser(`Recovery code needed to ${reason}`),\n confirmDeletion: async () => confirmUser('Delete everything?'),\n passkeyName: (identity) => 'My app account',\n },\n hooks: {\n afterUnlock: (state) => showApp(state),\n onLocked: () => showGate(),\n beforeDeleteAccount: ({ ws, identity }) => deleteMyRows(ws, identity), // while the keys still exist\n },\n});\n\n// First visit: browse as a guest, register when something is worth keeping.\nif (!(await session.bootFromTrustedSession()).restored) await session.bootGuest();\nconst { recoveryCode } = await session.registerCurrentIdentity({ passkey: 'platform' });\nshowOnce(recoveryCode); // the second way in; never stored\n\n// Later, on the same device / a new browser / anywhere:\nawait session.unlockWithPasskey();\nawait session.unlockWithDiscoverablePasskey();\nawait session.unlockWithRecoveryCode(codeTyped);\n\n// The private settings blob: read-modify-write under a compare-and-swap.\nawait session.saveIdentityBlob((blob) => ({ ...blob, theme: 'dark' }));\n\n// Sensitive actions ask for a fresh proof of the person (5-minute grace).\nawait session.addPasskey({ label: 'laptop' });\nconst { recoveryCode: newCode } = await session.rotateRecoveryCode();\nawait session.signOutEverywhere();\nawait session.deleteAccount();\n```\n\nLower layers are exported too (`wrapRootKeyWithPrf`, `openIdentityBlob`,\n`createSessionStore`, `authenticateConnection`, …) for apps that need them.\n\nThe screens are the app's (D-25): the package asks through `ui` and `hooks`\nand words nothing itself. Errors carry a `code` (`not-allowed`,\n`prf-unsupported`, `identity-blob-unreadable`, `stale-session`,\n`different-account`, …) and a `diagnostic` that never holds a secret.\nWhatever the host's registration policy needs on the wire, such as terms or\nage acceptance, comes from `hooks.registrationFields` (D-17); the package\nstores no policy of its own. The `every-open` lock interval clears only the\nsession; clearing the app's own offline caches belongs in `hooks.onLocked`.\n\n## The server side\n\nThe package speaks the account protocol of `@microtoll/blind-store` (M4):\n`challenge`/`auth`, `lookup-unlock-method` before sign-in, `register` with\nevery method in one transaction, the unlock-method messages,\n`update-identity-blob` with a compare-and-swap, `bump-session-generation`,\n`delete-account`. `test/tooling/fakeServer.mjs` is an in-process stand-in\nthat keeps the contract; the handshake signature verifies with\n`verifyAuthSignature`.\n\n## Threat model\n\n`THREATMODEL.md` §4 at the repository root. In one line: the server and a\ndatabase copy learn nothing about the person; a compromised device or page\nwins; a trusted-device session is as safe as the unlocked device it sits on;\n\"sign out everywhere\" and the blob's revision are cooperative, not\ncryptographic.\n",
119
+ "sections": [
120
+ {
121
+ "heading": "@microtoll/identity",
122
+ "level": 1,
123
+ "text": "Sign-in and accounts without the server ever holding a key: one Account Root\nKey per person, wrapped once per unlock method (a passkey, a recovery code),\na trusted-device session, a private settings blob, step-up for sensitive\nactions, and a deletion order that leaves nothing behind. Web Crypto only;\ndepends only on `@microtoll/crypto-core`.\n\n**Status:** pre-release (M2 complete); not published. Formats are version 2\n(`FORMATS.md`); version 1 is not read."
124
+ },
125
+ {
126
+ "heading": "What the server learns",
127
+ "level": 2,
128
+ "text": "The routing public key (a locker number), one row per unlock method holding\nciphertext and a credential id or lookup hash, the sealed blob, a compare-and-\nswap token and a generation counter. Never a key, a name, or when. The\nserver never verifies a passkey: WebAuthn is used only as a PRF oracle whose\n32-byte output, through HKDF, unwraps the root key."
129
+ },
130
+ {
131
+ "heading": "Five-minute quickstart",
132
+ "level": 2,
133
+ "text": "```js\nimport { createCryptoCore } from '@microtoll/crypto-core';\nimport { createIdentitySession, createSessionStore, createKnownAccountStore, createWebAuthn } from '@microtoll/identity';\n\nconst cc = createCryptoCore({ namespace: 'myapp' });\nconst session = createIdentitySession({\n cryptoCore: cc,\n origin: location.origin, // bound into the sign-in signature\n transport: { connect: () => openWebSocket('/ws') }, // your socket factory\n sessionStore: createSessionStore({ cryptoCore: cc }), // IndexedDB \"myapp-session\"\n knownAccounts: createKnownAccountStore({ cryptoCore: cc }),\n webauthn: createWebAuthn({ rpName: 'My app' }),\n ui: {\n askRecoveryCode: async (reason) => promptUser(`Recovery code needed to ${reason}`),\n confirmDeletion: async () => confirmUser('Delete everything?'),\n passkeyName: (identity) => 'My app account',\n },\n hooks: {\n afterUnlock: (state) => showApp(state),\n onLocked: () => showGate(),\n beforeDeleteAccount: ({ ws, identity }) => deleteMyRows(ws, identity), // while the keys still exist\n },\n});\n\n// First visit: browse as a guest, register when something is worth keeping.\nif (!(await session.bootFromTrustedSession()).restored) await session.bootGuest();\nconst { recoveryCode } = await session.registerCurrentIdentity({ passkey: 'platform' });\nshowOnce(recoveryCode); // the second way in; never stored\n\n// Later, on the same device / a new browser / anywhere:\nawait session.unlockWithPasskey();\nawait session.unlockWithDiscoverablePasskey();\nawait session.unlockWithRecoveryCode(codeTyped);\n\n// The private settings blob: read-modify-write under a compare-and-swap.\nawait session.saveIdentityBlob((blob) => ({ ...blob, theme: 'dark' }));\n\n// Sensitive actions ask for a fresh proof of the person (5-minute grace).\nawait session.addPasskey({ label: 'laptop' });\nconst { recoveryCode: newCode } = await session.rotateRecoveryCode();\nawait session.signOutEverywhere();\nawait session.deleteAccount();\n```\n\nLower layers are exported too (`wrapRootKeyWithPrf`, `openIdentityBlob`,\n`createSessionStore`, `authenticateConnection`, …) for apps that need them.\n\nThe screens are the app's (D-25): the package asks through `ui` and `hooks`\nand words nothing itself. Errors carry a `code` (`not-allowed`,\n`prf-unsupported`, `identity-blob-unreadable`, `stale-session`,\n`different-account`, …) and a `diagnostic` that never holds a secret.\nWhatever the host's registration policy needs on the wire, such as terms or\nage acceptance, comes from `hooks.registrationFields` (D-17); the package\nstores no policy of its own. The `every-open` lock interval clears only the\nsession; clearing the app's own offline caches belongs in `hooks.onLocked`."
134
+ },
135
+ {
136
+ "heading": "The server side",
137
+ "level": 2,
138
+ "text": "The package speaks the account protocol of `@microtoll/blind-store` (M4):\n`challenge`/`auth`, `lookup-unlock-method` before sign-in, `register` with\nevery method in one transaction, the unlock-method messages,\n`update-identity-blob` with a compare-and-swap, `bump-session-generation`,\n`delete-account`. `test/tooling/fakeServer.mjs` is an in-process stand-in\nthat keeps the contract; the handshake signature verifies with\n`verifyAuthSignature`."
139
+ },
140
+ {
141
+ "heading": "Threat model",
142
+ "level": 2,
143
+ "text": "`THREATMODEL.md` §4 at the repository root. In one line: the server and a\ndatabase copy learn nothing about the person; a compromised device or page\nwins; a trusted-device session is as safe as the unlocked device it sits on;\n\"sign out everywhere\" and the blob's revision are cooperative, not\ncryptographic."
144
+ }
145
+ ]
146
+ },
147
+ {
148
+ "path": "packages/identity-formats.html",
149
+ "url": "https://microtoll.dev/packages/identity-formats.html",
150
+ "title": "@microtoll/identity — formats and the hardening design (M2, D-24)",
151
+ "description": "What the identity layer seals and signs, version 2.",
152
+ "section": "Packages",
153
+ "markdown": "# @microtoll/identity — formats and the hardening design (M2, D-24)\n\n**Status:** decided 2026-09-25 (DECISIONS.md D-28, D-29): the design below is\nwhat M2 builds. Two items moved to version 3 on 2026-09-27 (D-46, D-47; §2.8):\nthe unlock-method label's binding and the recovery code's check character.\nThe package writes those in version 3 and everything else in version 2; it\nreads a version-2 label or code only when a caller asks for it by name.\n\n## 1. What version 1 left unbound\n\nVersion 1 is described here only to explain why version 2 exists and why a\nversion-1 record is not read.\n\n| Item | Where it lives | How it is made | Bound to |\n|---|---|---|---|\n| Wrapped root key | `user_unlock_methods.wrapped_root_key` (one row per unlock method) | AEAD v1 of the 32-byte root key under the method's unwrap key: HKDF(PRF output, `<ns>/envelope/prf/v1`) or PBKDF2(recovery bytes, salt, 310,000) | **nothing** |\n| Unlock-method label | `user_unlock_methods.encrypted_label` | AEAD v1 of the UTF-8 label under `K_master_symm` | nothing |\n| Identity blob | `users.encrypted_identity_blob` | AEAD v1 of JSON under `K_master_symm`; compare-and-swap by a 16-byte random token | nothing |\n| Trusted-device session | IndexedDB record `{v:1, sessionKey, wrappedRootKey, routingPublicKey, unlockedAt, expiresAt, sessionGeneration}` | AEAD v1 of the root key under a fresh non-extractable AES key kept beside it | nothing; the plaintext fields beside it are unauthenticated |\n| Handshake | `auth {routingPublicKey, signature}` | Ed25519 by the routing key over the **bare 32-byte nonce** | nothing: no purpose label, no origin |\n| Recovery lookup | `user_unlock_methods.recovery_lookup_hash` | HKDF(recovery bytes, `<ns>/recovery-lookup/v1`) | — (unchanged) |\n\nConsequences, all within the threat model's A1 (the server or a database\ncopy) and A8 (a copied browser profile):\n\n- A wrapped root key can be moved between unlock-method rows, or a passkey\n row's blob served in answer to a recovery lookup. It still needs the right\n unwrap secret to open, so this is a confusion, not a break.\n- An identity blob or a label from account X could be served to account Y's\n device. It would fail to open (different `K_master_symm`), so again a\n confusion. But a **rolled-back** blob from the same account opens fine and\n is indistinguishable from current.\n- A copied session record's plaintext `routingPublicKey`, `expiresAt` and\n `sessionGeneration` can be edited without the wrapped key noticing: an\n expiry pushed into the future, a generation raised to defeat \"sign out\n everywhere\". Cooperative checks, so the harm is bounded, but the record\n claims more than it proves.\n- The handshake signature is over 32 random bytes with nothing else. If the\n routing key ever signed anything else in another context, or a nonce from\n another site's server were relayed, the signature would be replayable\n across contexts. Today the routing key signs nothing else, so this is the\n least urgent item, and the cheapest to fix.\n\n## 2. The hardening (version 2), item by item\n\nEvery change below adds binding to an existing construction using\nprimitives crypto-core already has (`frameContext`, `sha256`, AEAD v1's\nadditional authenticated data). No primitive, mode or KDF changes. Version\nnumbers are carried in the profile label of each context, so a reader knows\nwhat it is opening and a v1 blob can never be mistaken for v2.\n\n### 2.1 AAD on the wrapped root key\n\n```\ncontext = frameContext(profile.label('aad/unlock-method', 2), methodType, methodId)\nmethodType = 0x01 passkey-prf | 0x02 recovery-code (1 byte)\nmethodId = SHA-256(credentialId) for passkey-prf (32 bytes)\n = recovery lookup hash for recovery-code (32 bytes)\nwrapped = sealSymmetric(unwrapKey, rootKey, context)\n```\n\nBoth identifiers are known **before** unwrapping: the credential id comes\nfrom the device's record or the discoverable assertion, and the lookup hash\nfrom the entered code. The credential id is hashed because it is variable\nlength and `frameContext` takes fixed-length parts only.\n\nEffect: a blob from one row cannot be presented as another, and a passkey\nblob cannot be served on the recovery path or the reverse.\n\n### 2.2 AAD on the identity blob\n\n```\ncontext = frameContext(profile.label('aad/identity-blob', 2), routingPublicKey) (32 bytes)\n```\n\nThe routing key is derived from the root key, so it is known when the blob\nis opened. Effect: a blob cannot be moved between accounts.\n\n**Rollback (optional, recommended):** the blob's plaintext gains a\n`revision` integer that every writer increments. The device keeps the last\nrevision it saw beside its known-account record; a blob that opens with a\nlower revision is refused with `identity-blob-rolled-back`. This is a\nbehaviour addition, not a format change, and it is cooperative (a wiped\ndevice has no memory), but it turns a silent rollback into a loud one on\nevery device that was there. **It is not part of the format decision;** it is\nlisted so the decision is taken knowing AAD alone does not stop rollback.\n\n### 2.3 AAD on the unlock-method label (version 3 since D-47)\n\nVersion 2, read only (`labelContextV2`, `openMethodLabelV2`):\n\n```\ncontext = frameContext(profile.label('aad/unlock-label', 2), routingPublicKey)\n```\n\nEffect: a label cannot be moved between accounts, which the account's own\nkey already ensured. It did **not** stop the server showing one passkey's\nlabel against another passkey of the same account, the case that misleads a\nperson choosing which method to remove. Version 3, written since D-47\n(`labelContext`, `sealMethodLabel`, `openMethodLabel`):\n\n```\ncontext = frameContext(profile.label('aad/unlock-label', 3), methodType, methodId)\nmethodType = 0x01 passkey-prf | 0x02 recovery-code (1 byte)\nmethodId = SHA-256(credentialId) for passkey-prf (32 bytes)\n = nothing for recovery-code\n```\n\nThe binding matches the wrapped root key's (§2.1), except that a recovery\ncode is bound by its type alone: the method listing carries no lookup hash\nto bind to, and an account holds one recovery code at a time. Effect: a\nlabel shown against any method but its own reads as null.\n\n### 2.4 The trusted-device session record, version 2\n\n```\ncontext = frameContext(profile.label('aad/session', 2), routingPublicKey, u64be(expiresAt), u32be(sessionGeneration or 0))\nrecord = { v: 2, sessionKey, wrappedRootKey: sealSymmetric(sessionKey, rootKey, context),\n routingPublicKey, unlockedAt, expiresAt, sessionGeneration }\n```\n\nThe three plaintext fields that decide whether the record may open are now\nauthenticated by the wrapped key. Editing `expiresAt` or `sessionGeneration`\nin the profile makes the record fail to open (and be deleted). On restore the\npackage also derives the routing key from the recovered root key and refuses\na record whose stored key differs, so an edited routing key cannot pass the\nrecord off as another account's. Needs one new crypto-core helper, `u64be`,\nfor the millisecond timestamp.\n\n`unlockedAt` stays unauthenticated: nothing decides on it.\n\n### 2.5 The handshake signature\n\n```\nmessage = frameContext(profile.label('auth', 2), SHA-256(UTF-8(origin)), nonce) (32 + 32 bytes)\nsignature = Ed25519(routingPrivateKey, message)\n```\n\n- `origin` is the web origin the client believes it is talking to\n (`location.origin` in a browser; passed explicitly in Node). The server\n verifies against its configured allowed origins, trying each.\n- The label ties the signature to this purpose and this app; the origin ties\n it to this deployment; the nonce ties it to this connection.\n- **Mirrored:** the client half ships in `@microtoll/identity` (M2); the\n server half in `@microtoll/blind-store` (M4), with a cross-implementation\n test that a browser-side signature verifies under Node. Until M4,\n identity's tests verify the message with crypto-core's own `verifyBytes`.\n\n### 2.6 Non-extractable signing keys (D-24 item 4; already decided)\n\n```\nseed → import pkcs8 (extractable) → export JWK → public key\n → import pkcs8 again, extractable: false → the working key\n```\n\nThe outputs do not change (the fixture signatures still match). The routing\nand identity-signing working keys become non-extractable; the raw root key\nand the HKDF seeds still exist as bytes in JavaScript, which the threat model\nstates. The seed bytes are zeroed after the second import (best effort; a\n`Uint8Array.fill(0)` — JavaScript gives no stronger guarantee).\n\n### 2.7 Unchanged\n\n- The recovery lookup hash and its label (a version-3 code decodes to the\n same 16 bytes as before; only its check character differs, §2.8).\n- PBKDF2 parameters and the PRF derivation label.\n- The `register` message with both unlock methods in one transaction\n (already atomic in version 1).\n- The open, pre-authentication `lookup-unlock-method` (D-20; the server\n caps its answers per connection, so one connection cannot harvest wrapped\n root keys in bulk).\n- Session length (30 days), lock intervals, the step-up grace (5 minutes,\n bound to the account) and the deletion order.\n\n### 2.8 Version 3 (2026-09-27, D-46 and D-47)\n\n- **The unlock-method label** is bound to its own method (§2.3).\n- **The recovery code** a person writes down is crypto-core's version 3\n (D-46): 16 bytes in Crockford base32 (26 characters) and a check\n character Σ aⁱ⁺¹·sᵢ over GF(32) = GF(2)[x]/(x⁵ + x² + 1), a = x. Every\n single wrong character and every swap of two different characters is\n caught before the lookup, and the two unused bits must be zero, so one\n string names one secret. Version 2's check (SHA-256-derived, D-26) caught\n each such error only 31 times in 32, and the rest failed later as \"no such\n account\".\n- **Reading version 2.** A version-2 and a version-3 code have the same\n shape, and a label carries no version byte, so neither can be recognised\n by looking at it; trying version 2 after version 3 would give back what\n version 3 closes. Version 2 is therefore read only when a caller names it:\n `{ recoveryCodeVersion: 2 }` on `lookupHashForEnteredCode` and\n `unwrapRootKeyWithRecoveryCode`, and `openMethodLabelV2`. No version-2 code\n or label exists outside tests.\n- Nothing else changes: the wrapped root key's binding (§2.1), the unwrap\n keys, PBKDF2 and the lookup hash are as in version 2.\n\n## 3. Frozen fixtures\n\nThe crypto-core frozen fixtures (`crypto-core/test/fixtures/frozen-v1.json`)\npin the primitives and derivations underneath: their version-1 envelopes\nstill open, which proves the unwrap keys are unchanged and only the binding\nis new. The identity package's tests pin every version-2 context byte for\nbyte, and `test/fixtures/frozen-v2.json` holds version-2 bytes this package\nwrote (both wrapped root keys, the method labels, two identity-blob\nrevisions, two session records and the handshake message), which every\nlater version must open and reproduce. `test/fixtures/frozen-v3.json` holds\nthe version-3 bytes for the same account (labels bound to two passkeys and\nthe recovery code, and a root key wrapped for a version-3 code), and\ncrypto-core's `test/fixtures/frozen-recovery-v3.json` pins eight version-3\ncodes.\n\n## 4. Package shape (for orientation; the API review is at M2 acceptance)\n\n```js\nimport { createCryptoCore } from '@microtoll/crypto-core';\nimport { createIdentity } from '@microtoll/identity';\n\nconst cc = createCryptoCore({ namespace: 'myapp' });\nconst identity = createIdentity({\n cryptoCore: cc,\n origin: location.origin, // for the handshake binding\n transport, // { connect(): ws } — the app's WebSocket factory\n storage: { session, knownAccount }, // adapters; browser defaults use IndexedDB + localStorage\n webauthn, // the package's own, or a stub in tests\n ui: { askRecoveryCode, confirmDeletion, showRecoveryCode, status },\n hooks: { beforeDeleteAccount, afterUnlock, onLocked, registrationPolicy },\n});\n```\n\nThe package ends at \"an authenticated connection, the `auth-ok` fields, the\nopened blob, the adopted sealing key\" (D-11). Everything after that is the\napp's. Wire message names are fixed (D-27).\n",
154
+ "sections": [
155
+ {
156
+ "heading": "@microtoll/identity — formats and the hardening design (M2, D-24)",
157
+ "level": 1,
158
+ "text": "**Status:** decided 2026-09-25 (DECISIONS.md D-28, D-29): the design below is\nwhat M2 builds. Two items moved to version 3 on 2026-09-27 (D-46, D-47; §2.8):\nthe unlock-method label's binding and the recovery code's check character.\nThe package writes those in version 3 and everything else in version 2; it\nreads a version-2 label or code only when a caller asks for it by name."
159
+ },
160
+ {
161
+ "heading": "1. What version 1 left unbound",
162
+ "level": 2,
163
+ "text": "Version 1 is described here only to explain why version 2 exists and why a\nversion-1 record is not read.\n\n| Item | Where it lives | How it is made | Bound to |\n|---|---|---|---|\n| Wrapped root key | `user_unlock_methods.wrapped_root_key` (one row per unlock method) | AEAD v1 of the 32-byte root key under the method's unwrap key: HKDF(PRF output, `<ns>/envelope/prf/v1`) or PBKDF2(recovery bytes, salt, 310,000) | **nothing** |\n| Unlock-method label | `user_unlock_methods.encrypted_label` | AEAD v1 of the UTF-8 label under `K_master_symm` | nothing |\n| Identity blob | `users.encrypted_identity_blob` | AEAD v1 of JSON under `K_master_symm`; compare-and-swap by a 16-byte random token | nothing |\n| Trusted-device session | IndexedDB record `{v:1, sessionKey, wrappedRootKey, routingPublicKey, unlockedAt, expiresAt, sessionGeneration}` | AEAD v1 of the root key under a fresh non-extractable AES key kept beside it | nothing; the plaintext fields beside it are unauthenticated |\n| Handshake | `auth {routingPublicKey, signature}` | Ed25519 by the routing key over the **bare 32-byte nonce** | nothing: no purpose label, no origin |\n| Recovery lookup | `user_unlock_methods.recovery_lookup_hash` | HKDF(recovery bytes, `<ns>/recovery-lookup/v1`) | — (unchanged) |\n\nConsequences, all within the threat model's A1 (the server or a database\ncopy) and A8 (a copied browser profile):\n\n- A wrapped root key can be moved between unlock-method rows, or a passkey\n row's blob served in answer to a recovery lookup. It still needs the right\n unwrap secret to open, so this is a confusion, not a break.\n- An identity blob or a label from account X could be served to account Y's\n device. It would fail to open (different `K_master_symm`), so again a\n confusion. But a **rolled-back** blob from the same account opens fine and\n is indistinguishable from current.\n- A copied session record's plaintext `routingPublicKey`, `expiresAt` and\n `sessionGeneration` can be edited without the wrapped key noticing: an\n expiry pushed into the future, a generation raised to defeat \"sign out\n everywhere\". Cooperative checks, so the harm is bounded, but the record\n claims more than it proves.\n- The handshake signature is over 32 random bytes with nothing else. If the\n routing key ever signed anything else in another context, or a nonce from\n another site's server were relayed, the signature would be replayable\n across contexts. Today the routing key signs nothing else, so this is the\n least urgent item, and the cheapest to fix."
164
+ },
165
+ {
166
+ "heading": "2. The hardening (version 2), item by item",
167
+ "level": 2,
168
+ "text": "Every change below adds binding to an existing construction using\nprimitives crypto-core already has (`frameContext`, `sha256`, AEAD v1's\nadditional authenticated data). No primitive, mode or KDF changes. Version\nnumbers are carried in the profile label of each context, so a reader knows\nwhat it is opening and a v1 blob can never be mistaken for v2."
169
+ },
170
+ {
171
+ "heading": "2.1 AAD on the wrapped root key",
172
+ "level": 3,
173
+ "text": "```\ncontext = frameContext(profile.label('aad/unlock-method', 2), methodType, methodId)\nmethodType = 0x01 passkey-prf | 0x02 recovery-code (1 byte)\nmethodId = SHA-256(credentialId) for passkey-prf (32 bytes)\n = recovery lookup hash for recovery-code (32 bytes)\nwrapped = sealSymmetric(unwrapKey, rootKey, context)\n```\n\nBoth identifiers are known **before** unwrapping: the credential id comes\nfrom the device's record or the discoverable assertion, and the lookup hash\nfrom the entered code. The credential id is hashed because it is variable\nlength and `frameContext` takes fixed-length parts only.\n\nEffect: a blob from one row cannot be presented as another, and a passkey\nblob cannot be served on the recovery path or the reverse."
174
+ },
175
+ {
176
+ "heading": "2.2 AAD on the identity blob",
177
+ "level": 3,
178
+ "text": "```\ncontext = frameContext(profile.label('aad/identity-blob', 2), routingPublicKey) (32 bytes)\n```\n\nThe routing key is derived from the root key, so it is known when the blob\nis opened. Effect: a blob cannot be moved between accounts.\n\n**Rollback (optional, recommended):** the blob's plaintext gains a\n`revision` integer that every writer increments. The device keeps the last\nrevision it saw beside its known-account record; a blob that opens with a\nlower revision is refused with `identity-blob-rolled-back`. This is a\nbehaviour addition, not a format change, and it is cooperative (a wiped\ndevice has no memory), but it turns a silent rollback into a loud one on\nevery device that was there. **It is not part of the format decision;** it is\nlisted so the decision is taken knowing AAD alone does not stop rollback."
179
+ },
180
+ {
181
+ "heading": "2.3 AAD on the unlock-method label (version 3 since D-47)",
182
+ "level": 3,
183
+ "text": "Version 2, read only (`labelContextV2`, `openMethodLabelV2`):\n\n```\ncontext = frameContext(profile.label('aad/unlock-label', 2), routingPublicKey)\n```\n\nEffect: a label cannot be moved between accounts, which the account's own\nkey already ensured. It did **not** stop the server showing one passkey's\nlabel against another passkey of the same account, the case that misleads a\nperson choosing which method to remove. Version 3, written since D-47\n(`labelContext`, `sealMethodLabel`, `openMethodLabel`):\n\n```\ncontext = frameContext(profile.label('aad/unlock-label', 3), methodType, methodId)\nmethodType = 0x01 passkey-prf | 0x02 recovery-code (1 byte)\nmethodId = SHA-256(credentialId) for passkey-prf (32 bytes)\n = nothing for recovery-code\n```\n\nThe binding matches the wrapped root key's (§2.1), except that a recovery\ncode is bound by its type alone: the method listing carries no lookup hash\nto bind to, and an account holds one recovery code at a time. Effect: a\nlabel shown against any method but its own reads as null."
184
+ },
185
+ {
186
+ "heading": "2.4 The trusted-device session record, version 2",
187
+ "level": 3,
188
+ "text": "```\ncontext = frameContext(profile.label('aad/session', 2), routingPublicKey, u64be(expiresAt), u32be(sessionGeneration or 0))\nrecord = { v: 2, sessionKey, wrappedRootKey: sealSymmetric(sessionKey, rootKey, context),\n routingPublicKey, unlockedAt, expiresAt, sessionGeneration }\n```\n\nThe three plaintext fields that decide whether the record may open are now\nauthenticated by the wrapped key. Editing `expiresAt` or `sessionGeneration`\nin the profile makes the record fail to open (and be deleted). On restore the\npackage also derives the routing key from the recovered root key and refuses\na record whose stored key differs, so an edited routing key cannot pass the\nrecord off as another account's. Needs one new crypto-core helper, `u64be`,\nfor the millisecond timestamp.\n\n`unlockedAt` stays unauthenticated: nothing decides on it."
189
+ },
190
+ {
191
+ "heading": "2.5 The handshake signature",
192
+ "level": 3,
193
+ "text": "```\nmessage = frameContext(profile.label('auth', 2), SHA-256(UTF-8(origin)), nonce) (32 + 32 bytes)\nsignature = Ed25519(routingPrivateKey, message)\n```\n\n- `origin` is the web origin the client believes it is talking to\n (`location.origin` in a browser; passed explicitly in Node). The server\n verifies against its configured allowed origins, trying each.\n- The label ties the signature to this purpose and this app; the origin ties\n it to this deployment; the nonce ties it to this connection.\n- **Mirrored:** the client half ships in `@microtoll/identity` (M2); the\n server half in `@microtoll/blind-store` (M4), with a cross-implementation\n test that a browser-side signature verifies under Node. Until M4,\n identity's tests verify the message with crypto-core's own `verifyBytes`."
194
+ },
195
+ {
196
+ "heading": "2.6 Non-extractable signing keys (D-24 item 4; already decided)",
197
+ "level": 3,
198
+ "text": "```\nseed → import pkcs8 (extractable) → export JWK → public key\n → import pkcs8 again, extractable: false → the working key\n```\n\nThe outputs do not change (the fixture signatures still match). The routing\nand identity-signing working keys become non-extractable; the raw root key\nand the HKDF seeds still exist as bytes in JavaScript, which the threat model\nstates. The seed bytes are zeroed after the second import (best effort; a\n`Uint8Array.fill(0)` — JavaScript gives no stronger guarantee)."
199
+ },
200
+ {
201
+ "heading": "2.7 Unchanged",
202
+ "level": 3,
203
+ "text": "- The recovery lookup hash and its label (a version-3 code decodes to the\n same 16 bytes as before; only its check character differs, §2.8).\n- PBKDF2 parameters and the PRF derivation label.\n- The `register` message with both unlock methods in one transaction\n (already atomic in version 1).\n- The open, pre-authentication `lookup-unlock-method` (D-20; the server\n caps its answers per connection, so one connection cannot harvest wrapped\n root keys in bulk).\n- Session length (30 days), lock intervals, the step-up grace (5 minutes,\n bound to the account) and the deletion order."
204
+ },
205
+ {
206
+ "heading": "2.8 Version 3 (2026-09-27, D-46 and D-47)",
207
+ "level": 3,
208
+ "text": "- **The unlock-method label** is bound to its own method (§2.3).\n- **The recovery code** a person writes down is crypto-core's version 3\n (D-46): 16 bytes in Crockford base32 (26 characters) and a check\n character Σ aⁱ⁺¹·sᵢ over GF(32) = GF(2)[x]/(x⁵ + x² + 1), a = x. Every\n single wrong character and every swap of two different characters is\n caught before the lookup, and the two unused bits must be zero, so one\n string names one secret. Version 2's check (SHA-256-derived, D-26) caught\n each such error only 31 times in 32, and the rest failed later as \"no such\n account\".\n- **Reading version 2.** A version-2 and a version-3 code have the same\n shape, and a label carries no version byte, so neither can be recognised\n by looking at it; trying version 2 after version 3 would give back what\n version 3 closes. Version 2 is therefore read only when a caller names it:\n `{ recoveryCodeVersion: 2 }` on `lookupHashForEnteredCode` and\n `unwrapRootKeyWithRecoveryCode`, and `openMethodLabelV2`. No version-2 code\n or label exists outside tests.\n- Nothing else changes: the wrapped root key's binding (§2.1), the unwrap\n keys, PBKDF2 and the lookup hash are as in version 2."
209
+ },
210
+ {
211
+ "heading": "3. Frozen fixtures",
212
+ "level": 2,
213
+ "text": "The crypto-core frozen fixtures (`crypto-core/test/fixtures/frozen-v1.json`)\npin the primitives and derivations underneath: their version-1 envelopes\nstill open, which proves the unwrap keys are unchanged and only the binding\nis new. The identity package's tests pin every version-2 context byte for\nbyte, and `test/fixtures/frozen-v2.json` holds version-2 bytes this package\nwrote (both wrapped root keys, the method labels, two identity-blob\nrevisions, two session records and the handshake message), which every\nlater version must open and reproduce. `test/fixtures/frozen-v3.json` holds\nthe version-3 bytes for the same account (labels bound to two passkeys and\nthe recovery code, and a root key wrapped for a version-3 code), and\ncrypto-core's `test/fixtures/frozen-recovery-v3.json` pins eight version-3\ncodes."
214
+ },
215
+ {
216
+ "heading": "4. Package shape (for orientation; the API review is at M2 acceptance)",
217
+ "level": 2,
218
+ "text": "```js\nimport { createCryptoCore } from '@microtoll/crypto-core';\nimport { createIdentity } from '@microtoll/identity';\n\nconst cc = createCryptoCore({ namespace: 'myapp' });\nconst identity = createIdentity({\n cryptoCore: cc,\n origin: location.origin, // for the handshake binding\n transport, // { connect(): ws } — the app's WebSocket factory\n storage: { session, knownAccount }, // adapters; browser defaults use IndexedDB + localStorage\n webauthn, // the package's own, or a stub in tests\n ui: { askRecoveryCode, confirmDeletion, showRecoveryCode, status },\n hooks: { beforeDeleteAccount, afterUnlock, onLocked, registrationPolicy },\n});\n```\n\nThe package ends at \"an authenticated connection, the `auth-ok` fields, the\nopened blob, the adopted sealing key\" (D-11). Everything after that is the\napp's. Wire message names are fixed (D-27)."
219
+ }
220
+ ]
221
+ },
222
+ {
223
+ "path": "packages/access.html",
224
+ "url": "https://microtoll.dev/packages/access.html",
225
+ "title": "@microtoll/access",
226
+ "description": "Sealed sharing, capabilities, share links, removal with key rotation, two-tier disclosure.",
227
+ "section": "Packages",
228
+ "markdown": "# @microtoll/access\n\nSharing something with a group without the server being able to read it: one\nrandom key per object, sealed person by person; membership rows the server\nauthorises by capability, never by identity; share links whose secret never\nreaches the server; removal that re-keys everything in one checked\ntransaction; signed rows so nobody can pass for anyone else; and a second\ntier (two-tier disclosure: an address, say) granted only to those the app\nsays. Web Crypto only; depends on `@microtoll/crypto-core` and\n`@microtoll/identity`.\n\n**Status:** pre-release (M3 complete); not published. Formats are version 2\n(`FORMATS.md`).\n\n## The model in one paragraph\n\nAn **object** has `K_object` (32 random bytes). Its **content** is sealed\nunder it. An optional **second tier** is sealed under its own random\n`K_detail`, never derived from `K_object`, and granted per recipient inside\nthe content. Each **member** has a row: a signed envelope under `K_object`\nand a copy of `K_object` sealed to their key. A member's own **pointer**\n(the key, the epoch, their capability secrets) is sealed under their\n`K_master_symm`. **Capabilities** the server holds only hashes of: admin (in\npadded seats inside the content), read (derived from `K_object`), row (random\nper row) and link management. **Share links** carry `K_object` under a key\nderived from a token that stays in the URL fragment. **Removal** rotates\nevery key and re-seals every remaining row; the server refuses the plan\nunless it names every active row at the expected epoch. **Revoking** a link\ndeletes its row only: whoever already redeemed keeps what they hold.\n\n## Five-minute quickstart\n\n```js\nimport { createCryptoCore } from '@microtoll/crypto-core';\nimport { createIdentitySession /* … */ } from '@microtoll/identity';\nimport { createAccess, goingGrantDue, oneOf } from '@microtoll/access';\n\nconst cc = createCryptoCore({ namespace: 'myapp' });\nconst access = createAccess({\n cryptoCore: cc,\n pointerFields: { myStatus: { wire: 'myStatus', ...oneOf(['going', 'interested']) } }, // your fields\n split: (content) => { const { address, ...preview } = content; return { preview, detail: { address } }; },\n merge: (preview, detail) => ({ ...preview, ...(detail || {}) }),\n grantDue: goingGrantDue, // who is owed the second tier\n});\n\n// The owner creates an object (an event, a document, a group) with a hidden address.\nconst created = await access.createObject({ identity: me, content: { title: 'Book club', address: '12 Secret St' }, twoTier: true });\nawait access.createObjectMessage(ws, created, { /* your coarse selector fields */ });\n\n// A share link for the group chat: the secret stays after the '#'.\nconst link = await access.createShareLink({ objectId: created.objectId, kObjectRaw: created.kObjectRaw, maxUses: 10, expiresAt, creator: me, creatorName: 'Ada' });\nawait access.publishShareLink(ws, link);\nconst url = `${location.origin}/#token=${link.token}`;\n\n// Someone opens it: read access, then a first reaction makes them a member.\nconst payload = await access.redeemShareLinkMessage(ws, await access.hashToken(token));\nconst { objectId, kObjectRaw, creatorName } = await access.redeemShareLink(token, payload);\nawait access.joinObject(ws, { objectId, pointer: await access.buildReadOnlyPointer({ identity: me, objectId, kObjectRaw, keyEpoch }) });\nconst first = await access.buildFirstReaction({ identity: me, objectId, kObjectRaw, keyEpoch, content: { status: 'going' } });\nawait access.createMemberRow(ws, { objectId, row: first.row, pointerId, pointer: first.pointer, readCapabilitySecret: first.readCapabilitySecret });\n\n// The roster, with the display rule applied: verified rows show; quiet rows show no identity; unverified rows show nothing.\nconst roster = access.roster(await access.openRows({ kObjectRaw, objectId, rows: await access.fetchMembers(ws, objectId, { readCapabilitySecret }) }));\n\n// Removing someone: a new key for everyone else, in one checked transaction.\nconst plan = await access.buildRotationPlan({ objectId, identity: me, oldKObjectRaw, oldEpoch, content, rows, remove: [rowId], selfRowId, twoTier: { detailOpened: true } });\nawait access.rotateObjectKey(ws, objectId, adminCapabilitySecret, plan);\n```\n\n## What the app supplies\n\n- **`pointerFields`**: the app's own fields in a member's pointer (mirrors\n for badges and lists), each `{ wire, seal, open }`.\n- **`split`** and **`merge`**: what goes in the second tier, and how the two\n parts come back together for someone who holds a grant.\n- **`grantDue`**: which roster entries are owed the second tier.\n `goingGrantDue` is an example rule: a verified or quiet row that says\n \"going\" and has not opted out.\n- **The coarse selector**: the plaintext blind-store indexes objects by\n (`collection`, a fixed-length `selector`, an optional `windowStart` and\n `windowEnd`; for example a region code and dates), passed through\n untouched in `createObjectMessage` and `updateObject`, and queried with\n `queryObjects` — the cover-traffic query: decrypt what you hold keys for,\n discard the rest, never ask by a list of ids. `watchObjects`,\n `watchImminent` and `onLiveObject` are the live half.\n- **The transport** (a WebSocket-like object) and everything on screen.\n\n## Tests\n\n`npm test`: 29 tests, all through the public API, including the adversarial\nsuite the build plan names (`THREATMODEL.md` §5 maps them).\n\n## Formats and scope\n\n- **Formats version 2** (D-31): purpose labels on the member-row and\n share-link signatures; additional authenticated data on content, second\n tier, member rows, pointers and link payloads. The per-member sealed copy\n of `K_object` is an ECIES seal without extra data (stated in the threat\n model). Envelope `v: 3` is the signed row; `v: 2` the quiet row; an\n envelope with any other version, or none, is refused.\n- **Generic names** (D-30): object, member row, owner, `K_object`. Some wire\n message and field names say \"event\" (`create-event`, `eventUserId`): they\n are the server protocol (D-27).\n- **The split, the merge, the grant rule and the pointer's app fields are\n supplied by the app**; the package knows nothing of what the content means.\n- **Admin box label** is `<ns>/object-adminbox/v1` (`FORMATS.md` §3.2).\n- **Direct invites, acknowledgements, contacts and favourites** are not here\n (mailbox, M3b); nor are product features that ride in rows or content.\n- **`openRows` skips a row that will not open** rather than surfacing it;\n the rotation plan is where an unreadable row is reported (set aside).\n",
229
+ "sections": [
230
+ {
231
+ "heading": "@microtoll/access",
232
+ "level": 1,
233
+ "text": "Sharing something with a group without the server being able to read it: one\nrandom key per object, sealed person by person; membership rows the server\nauthorises by capability, never by identity; share links whose secret never\nreaches the server; removal that re-keys everything in one checked\ntransaction; signed rows so nobody can pass for anyone else; and a second\ntier (two-tier disclosure: an address, say) granted only to those the app\nsays. Web Crypto only; depends on `@microtoll/crypto-core` and\n`@microtoll/identity`.\n\n**Status:** pre-release (M3 complete); not published. Formats are version 2\n(`FORMATS.md`)."
234
+ },
235
+ {
236
+ "heading": "The model in one paragraph",
237
+ "level": 2,
238
+ "text": "An **object** has `K_object` (32 random bytes). Its **content** is sealed\nunder it. An optional **second tier** is sealed under its own random\n`K_detail`, never derived from `K_object`, and granted per recipient inside\nthe content. Each **member** has a row: a signed envelope under `K_object`\nand a copy of `K_object` sealed to their key. A member's own **pointer**\n(the key, the epoch, their capability secrets) is sealed under their\n`K_master_symm`. **Capabilities** the server holds only hashes of: admin (in\npadded seats inside the content), read (derived from `K_object`), row (random\nper row) and link management. **Share links** carry `K_object` under a key\nderived from a token that stays in the URL fragment. **Removal** rotates\nevery key and re-seals every remaining row; the server refuses the plan\nunless it names every active row at the expected epoch. **Revoking** a link\ndeletes its row only: whoever already redeemed keeps what they hold."
239
+ },
240
+ {
241
+ "heading": "Five-minute quickstart",
242
+ "level": 2,
243
+ "text": "```js\nimport { createCryptoCore } from '@microtoll/crypto-core';\nimport { createIdentitySession /* … */ } from '@microtoll/identity';\nimport { createAccess, goingGrantDue, oneOf } from '@microtoll/access';\n\nconst cc = createCryptoCore({ namespace: 'myapp' });\nconst access = createAccess({\n cryptoCore: cc,\n pointerFields: { myStatus: { wire: 'myStatus', ...oneOf(['going', 'interested']) } }, // your fields\n split: (content) => { const { address, ...preview } = content; return { preview, detail: { address } }; },\n merge: (preview, detail) => ({ ...preview, ...(detail || {}) }),\n grantDue: goingGrantDue, // who is owed the second tier\n});\n\n// The owner creates an object (an event, a document, a group) with a hidden address.\nconst created = await access.createObject({ identity: me, content: { title: 'Book club', address: '12 Secret St' }, twoTier: true });\nawait access.createObjectMessage(ws, created, { /* your coarse selector fields */ });\n\n// A share link for the group chat: the secret stays after the '#'.\nconst link = await access.createShareLink({ objectId: created.objectId, kObjectRaw: created.kObjectRaw, maxUses: 10, expiresAt, creator: me, creatorName: 'Ada' });\nawait access.publishShareLink(ws, link);\nconst url = `${location.origin}/#token=${link.token}`;\n\n// Someone opens it: read access, then a first reaction makes them a member.\nconst payload = await access.redeemShareLinkMessage(ws, await access.hashToken(token));\nconst { objectId, kObjectRaw, creatorName } = await access.redeemShareLink(token, payload);\nawait access.joinObject(ws, { objectId, pointer: await access.buildReadOnlyPointer({ identity: me, objectId, kObjectRaw, keyEpoch }) });\nconst first = await access.buildFirstReaction({ identity: me, objectId, kObjectRaw, keyEpoch, content: { status: 'going' } });\nawait access.createMemberRow(ws, { objectId, row: first.row, pointerId, pointer: first.pointer, readCapabilitySecret: first.readCapabilitySecret });\n\n// The roster, with the display rule applied: verified rows show; quiet rows show no identity; unverified rows show nothing.\nconst roster = access.roster(await access.openRows({ kObjectRaw, objectId, rows: await access.fetchMembers(ws, objectId, { readCapabilitySecret }) }));\n\n// Removing someone: a new key for everyone else, in one checked transaction.\nconst plan = await access.buildRotationPlan({ objectId, identity: me, oldKObjectRaw, oldEpoch, content, rows, remove: [rowId], selfRowId, twoTier: { detailOpened: true } });\nawait access.rotateObjectKey(ws, objectId, adminCapabilitySecret, plan);\n```"
244
+ },
245
+ {
246
+ "heading": "What the app supplies",
247
+ "level": 2,
248
+ "text": "- **`pointerFields`**: the app's own fields in a member's pointer (mirrors\n for badges and lists), each `{ wire, seal, open }`.\n- **`split`** and **`merge`**: what goes in the second tier, and how the two\n parts come back together for someone who holds a grant.\n- **`grantDue`**: which roster entries are owed the second tier.\n `goingGrantDue` is an example rule: a verified or quiet row that says\n \"going\" and has not opted out.\n- **The coarse selector**: the plaintext blind-store indexes objects by\n (`collection`, a fixed-length `selector`, an optional `windowStart` and\n `windowEnd`; for example a region code and dates), passed through\n untouched in `createObjectMessage` and `updateObject`, and queried with\n `queryObjects` — the cover-traffic query: decrypt what you hold keys for,\n discard the rest, never ask by a list of ids. `watchObjects`,\n `watchImminent` and `onLiveObject` are the live half.\n- **The transport** (a WebSocket-like object) and everything on screen."
249
+ },
250
+ {
251
+ "heading": "Tests",
252
+ "level": 2,
253
+ "text": "`npm test`: 29 tests, all through the public API, including the adversarial\nsuite the build plan names (`THREATMODEL.md` §5 maps them)."
254
+ },
255
+ {
256
+ "heading": "Formats and scope",
257
+ "level": 2,
258
+ "text": "- **Formats version 2** (D-31): purpose labels on the member-row and\n share-link signatures; additional authenticated data on content, second\n tier, member rows, pointers and link payloads. The per-member sealed copy\n of `K_object` is an ECIES seal without extra data (stated in the threat\n model). Envelope `v: 3` is the signed row; `v: 2` the quiet row; an\n envelope with any other version, or none, is refused.\n- **Generic names** (D-30): object, member row, owner, `K_object`. Some wire\n message and field names say \"event\" (`create-event`, `eventUserId`): they\n are the server protocol (D-27).\n- **The split, the merge, the grant rule and the pointer's app fields are\n supplied by the app**; the package knows nothing of what the content means.\n- **Admin box label** is `<ns>/object-adminbox/v1` (`FORMATS.md` §3.2).\n- **Direct invites, acknowledgements, contacts and favourites** are not here\n (mailbox, M3b); nor are product features that ride in rows or content.\n- **`openRows` skips a row that will not open** rather than surfacing it;\n the rotation plan is where an unreadable row is reported (set aside)."
259
+ }
260
+ ]
261
+ },
262
+ {
263
+ "path": "packages/access-formats.html",
264
+ "url": "https://microtoll.dev/packages/access-formats.html",
265
+ "title": "@microtoll/access — the object model and the hardening design (M3)",
266
+ "description": "The object model and what the access layer seals and signs, version 2.",
267
+ "section": "Packages",
268
+ "markdown": "# @microtoll/access — the object model and the hardening design (M3)\n\n**Status:** decided 2026-09-25 (DECISIONS.md D-30, D-31, D-32): the design below is what M3 builds.\n\n## 1. What the access layer is, in one paragraph\n\nAn **object** (an event, a document, a group) has one random symmetric key,\n`K_object`. Its **content** is sealed under that key; an optional **second\ntier** (`K_detail`, an address, say) is sealed under its own random key and\ngranted person by person. **Members** each have a row: a signed envelope\nsealed under `K_object`, and a copy of `K_object` sealed to their sealing key.\nA member's own note about the object (the key, the epoch, their capability\nsecrets) is a **pointer**, sealed under their `K_master_symm`. The server\nauthorises writes by **capability secrets** it holds only the hashes of: admin\n(a random secret carried in a sealed seat), read (derived from `K_object`),\nrow (random per row), and link management (random per link). **Share links**\ncarry `K_object` in a payload sealed under a key derived from a token that\nnever reaches the server. **Removal** rotates every key and re-seals every\nremaining row in one server transaction that checks the epoch and that every\nactive row was named. **Revocation** of a link deletes its row only.\n\n## 2. Decision D-30: the object model\n\nThe object model answers D-13 (a generic collection model) with five rules:\nan epoch on every write, a rotation the server refuses unless it names every\nactive row, an admin capability replaced on every rotation and carried in\npadded seats, rows that cannot have come from an honest client set aside, and\none rule for who gets the second tier. Version 2 adds additional\nauthenticated data to the object-layer seals (§3).\n\n**The parts:**\n\n| Part | Notes |\n|---|---|\n| object, `K_object`, `objectId` | a client-generated UUID |\n| content | JSON the app owns; the package owns `adminSeats`, `adminBox`, `detailGrants`, `ownerSigningKey` |\n| second tier, `K_detail`, `hasDetail` | the **split** is caller-supplied: `split(content) → { preview, detail }`; what goes in the second tier is the app's choice |\n| member row | a signed envelope (named) or an unsigned one (quiet), sealed under `K_object` |\n| `grantDue(row) → boolean` | caller-supplied: who is owed the second tier. The package ships an example rule: \"verified or quiet, going, not opted out\" |\n| pointer | the package owns `objectId`, `kObject`, `keyEpoch`, `rowCapabilitySecret`, `adminCapabilitySecret`, `quietRotationKey`, `quietRotationKemSeed`, `sharedLinks`, `invitedBy`; the app's fields (a status mirror, a notification flag, …) ride through a registered extension table |\n| owner, co-owner | holders of the admin capability |\n| the **coarse selector** | an opaque app value the server indexes (M4); not the package's |\n| admin seats, admin box | `ADMIN_SEAT_BUCKET` 4, seat plaintext 256 bytes, box bucket 1024 bytes |\n| quiet row (`v: 2`) | a per-object P-256 (+ KEM) rotation key in the pointer |\n| share link | token in the URL fragment; hashed token, N uses, expiry, management secret |\n| direct invite, ack | **`@microtoll/mailbox`** (M3b): the bundle formats, and the binding of the mailbox and the recipient into the signature, live there |\n\n**Some wire message names say \"event\"** (`create-event`, `rotate-event-key`,\n…) because they are the server protocol `blind-store` speaks (D-27); the\npackage's function names are generic.\n\n**What the package does not do:** decide who is granted the second tier\n(`grantDue`), split content (`split`), choose link expiry or group sizes, hold\ninvites for people without accounts, render anything, or fetch anything itself\n(it builds messages and opens replies; the transport is the app's, as in\nidentity).\n\n## 3. Decision D-31: the hardening (formats version 2)\n\nEvery item adds binding with primitives crypto-core already has\n(`frameContext`, `sha256`, AEAD v1's additional authenticated data); no\nprimitive, mode or KDF changes. Labels come from the profile, so a v1 blob can\nnever be mistaken for v2.\n\n### 3.1 Purpose labels on signatures\n\n| Signature | Version 2 signs |\n|---|---|\n| member row | `frameContext(\"<ns>/sig/member-row/v2\", uuidBytes(objectId), uuidBytes(rowId)) ‖ UTF-8(payloadJson)` |\n| share link | `frameContext(\"<ns>/sig/share-link/v2\", hashedTokenBytes32) ‖ UTF-8(payloadJson)` |\n| direct invite / ack (mailbox, M3b) | `frameContext(\"<ns>/sig/invite/v2\", mailboxLabel32, SHA-256(recipientKey)) ‖ UTF-8(payloadJson)` — the mailbox and the recipient are in the signature, so a bundle re-sealed into another mailbox or for another recipient verifies for nobody (D-40; the collection-side check stays too). Built in `@microtoll/mailbox`, `FORMATS.md` §2 |\n\nThe frame's fixed-length parts (UUIDs as 16 bytes, a hash as 32) and the\nNUL-terminated label mean no field can shift into another and no signature\nmade for one purpose verifies for another. Version 1 signed text fields\njoined by NUL bytes (or the payload alone) with no purpose label, so nothing\nstopped a signature made for one purpose being offered for another; this\npackage does not read version 1. The signed\nenvelope keeps the outer shape `{ v, payloadJson, sig }` with `v: 3`, so a\nreader knows which message to rebuild; `v: 2` stays the quiet row.\n\n### 3.2 Additional authenticated data on the object-layer seals\n\n| Seal | Key | Context (version 2) | Prevents |\n|---|---|---|---|\n| content | `K_object` | `frameContext(\"<ns>/aad/object-content/v2\", objectId, u32be(epoch))` | content moved between objects, or an earlier epoch's content replayed after a rotation under the same… (the key changes at rotation; the epoch binding is belt and braces and lets a reader assert the epoch it was told) |\n| second tier | `K_detail` | `frameContext(\"<ns>/aad/object-detail/v2\", objectId, u32be(epoch))` | same |\n| member row | `K_object` | `frameContext(\"<ns>/aad/member-row/v2\", objectId, uuidBytes(rowId))` | a row's ciphertext presented under another row id (the signature already binds the row id for named rows; this covers quiet and legacy rows too) |\n| pointer | `K_master_symm` | `frameContext(\"<ns>/aad/pointer/v2\", routingPublicKey)` (D-37, M4: the account, not the object id — the server returns an account's pointers without an id, so a binding that needed one could never be opened on a fresh device; the object id is inside the sealed pointer) | a pointer moved to another account, or another `K_master_symm` blob presented as a pointer |\n| share-link payload | token-derived key | `frameContext(\"<ns>/aad/share-link/v2\", hashedTokenBytes32)` | a payload served under another link's hash |\n| admin box | `K_adminbox` | `frameContext(\"<ns>/object-adminbox/v1\", objectId, u32be(epoch))` | a box moved to another object, or an earlier epoch's box replayed |\n\nRotation re-encrypts each remaining row byte-for-byte under the new key with\nthe same row context, so signatures survive it.\n\n**Not bound, stated plainly:** the sealed copy of `K_object` per member\n(`encryptedSharedEventKey` on the wire) is an ECIES seal, which binds the recipient's\nkey and its version but takes no additional data; binding the row would need\nan ECIES v4 in crypto-core, which is out of scope. A server that moves a\nmember's sealed key to another of the same member's rows gains nothing the\nmember could not do.\n\n### 3.3 Not changed by version 2\n\nThe admin seats and box (frozen sizes), quiet rows, the seal-target chooser\n(a quiet row's per-object keys first, a fresh hybrid key before the classical\none, a stale advertisement treated as absent), the set-aside rule, the grant\nrule's shape, the pointer's compare-and-heal on a lagging epoch, link tokens\n(160 bits), capability secrets (256 bits, SHA-256 on the server), and every\nwire message.\n\n## 4. Decision D-32: what M3 ships and what waits\n\n**M3, `@microtoll/access`:**\n- object creation; sealing `K_object` to a member; the read capability;\n member rows (named, quiet, legacy readers); pointers with the extension\n table; admin seats and the admin box; the rotation plan and the member's\n pointer refresh; two-tier disclosure (split by the caller; grants, the\n sweep, the viewer's merge); share links (create, redeem, revoke, the\n creator's record, stats); the unverified display rule as a pure function\n over opened rows; the message builders and reply openers for the object\n wire (`create-event`, `join-event`, `fetch-event-members`,\n `rotate-event-key`, `create-participation`, `update-participation`,\n `update-event`, `delete-event`, `delete-participation`, the four link\n messages).\n- Tests: the adversarial suite the build plan names, each case against the\n in-process server stand-in extended with the object messages (the same\n stand-in identity uses, so the contract stays one).\n- Fixtures: the version-2 contexts frozen in this package's tests;\n crypto-core's frozen fixtures (`test/fixtures/frozen-v1.json`) pin the\n read capability and the detail-grant label. This package's\n `test/fixtures/frozen-v2.json` holds version-2 bytes it wrote (content,\n second tier and grants, named and quiet rows, pointers, admin seats and\n box, share links, and their hybrid variants), which every later version\n must open and reproduce.\n\n**Waits:** direct invites and acknowledgements (mailbox, M3b); contacts and\nfavourites (app or mailbox); product features that ride in rows or content\nas app fields (a first-time flag, repeat grants, proposals, signals) stay the\napp's.\n\n## 5. The adversarial suite (build plan M3), mapped\n\n| Requirement | Cases |\n|---|---|\n| A removed member's old key cannot read post-rotation writes | new content, new detail, every resealed row and sealed key; the server refuses a write at the old epoch; the removed co-owner's admin secret refused; an incomplete plan refused as stale; a substituted old row set aside and never sealed to |\n| A revoked link cannot be redeemed | revoke → not-found; expiry → expired; N uses then exhausted; a concurrent last use taken once; stats absent after revoke |\n| An already-redeemed link is unaffected | a pointer holder still reads after revoke; and IS cut off by a later rotation |\n| A forged signature is flagged unverified | a bit-flipped, truncated or wrong-key signature on a row and on a link; a row lifted to another row id or object; a v2 quiet row carrying identity claims is still quiet, not verified; the display rule strips name and keys |\n| Holding the object never yields the second tier | a `K_object`-only holder cannot open the detail; a link holder cannot; a rotation grants only to the owner, co-owners, rows `grantDue` says and rows that held a grant; a grant label is object-scoped |\n",
269
+ "sections": [
270
+ {
271
+ "heading": "@microtoll/access — the object model and the hardening design (M3)",
272
+ "level": 1,
273
+ "text": "**Status:** decided 2026-09-25 (DECISIONS.md D-30, D-31, D-32): the design below is what M3 builds."
274
+ },
275
+ {
276
+ "heading": "1. What the access layer is, in one paragraph",
277
+ "level": 2,
278
+ "text": "An **object** (an event, a document, a group) has one random symmetric key,\n`K_object`. Its **content** is sealed under that key; an optional **second\ntier** (`K_detail`, an address, say) is sealed under its own random key and\ngranted person by person. **Members** each have a row: a signed envelope\nsealed under `K_object`, and a copy of `K_object` sealed to their sealing key.\nA member's own note about the object (the key, the epoch, their capability\nsecrets) is a **pointer**, sealed under their `K_master_symm`. The server\nauthorises writes by **capability secrets** it holds only the hashes of: admin\n(a random secret carried in a sealed seat), read (derived from `K_object`),\nrow (random per row), and link management (random per link). **Share links**\ncarry `K_object` in a payload sealed under a key derived from a token that\nnever reaches the server. **Removal** rotates every key and re-seals every\nremaining row in one server transaction that checks the epoch and that every\nactive row was named. **Revocation** of a link deletes its row only."
279
+ },
280
+ {
281
+ "heading": "2. Decision D-30: the object model",
282
+ "level": 2,
283
+ "text": "The object model answers D-13 (a generic collection model) with five rules:\nan epoch on every write, a rotation the server refuses unless it names every\nactive row, an admin capability replaced on every rotation and carried in\npadded seats, rows that cannot have come from an honest client set aside, and\none rule for who gets the second tier. Version 2 adds additional\nauthenticated data to the object-layer seals (§3).\n\n**The parts:**\n\n| Part | Notes |\n|---|---|\n| object, `K_object`, `objectId` | a client-generated UUID |\n| content | JSON the app owns; the package owns `adminSeats`, `adminBox`, `detailGrants`, `ownerSigningKey` |\n| second tier, `K_detail`, `hasDetail` | the **split** is caller-supplied: `split(content) → { preview, detail }`; what goes in the second tier is the app's choice |\n| member row | a signed envelope (named) or an unsigned one (quiet), sealed under `K_object` |\n| `grantDue(row) → boolean` | caller-supplied: who is owed the second tier. The package ships an example rule: \"verified or quiet, going, not opted out\" |\n| pointer | the package owns `objectId`, `kObject`, `keyEpoch`, `rowCapabilitySecret`, `adminCapabilitySecret`, `quietRotationKey`, `quietRotationKemSeed`, `sharedLinks`, `invitedBy`; the app's fields (a status mirror, a notification flag, …) ride through a registered extension table |\n| owner, co-owner | holders of the admin capability |\n| the **coarse selector** | an opaque app value the server indexes (M4); not the package's |\n| admin seats, admin box | `ADMIN_SEAT_BUCKET` 4, seat plaintext 256 bytes, box bucket 1024 bytes |\n| quiet row (`v: 2`) | a per-object P-256 (+ KEM) rotation key in the pointer |\n| share link | token in the URL fragment; hashed token, N uses, expiry, management secret |\n| direct invite, ack | **`@microtoll/mailbox`** (M3b): the bundle formats, and the binding of the mailbox and the recipient into the signature, live there |\n\n**Some wire message names say \"event\"** (`create-event`, `rotate-event-key`,\n…) because they are the server protocol `blind-store` speaks (D-27); the\npackage's function names are generic.\n\n**What the package does not do:** decide who is granted the second tier\n(`grantDue`), split content (`split`), choose link expiry or group sizes, hold\ninvites for people without accounts, render anything, or fetch anything itself\n(it builds messages and opens replies; the transport is the app's, as in\nidentity)."
284
+ },
285
+ {
286
+ "heading": "3. Decision D-31: the hardening (formats version 2)",
287
+ "level": 2,
288
+ "text": "Every item adds binding with primitives crypto-core already has\n(`frameContext`, `sha256`, AEAD v1's additional authenticated data); no\nprimitive, mode or KDF changes. Labels come from the profile, so a v1 blob can\nnever be mistaken for v2."
289
+ },
290
+ {
291
+ "heading": "3.1 Purpose labels on signatures",
292
+ "level": 3,
293
+ "text": "| Signature | Version 2 signs |\n|---|---|\n| member row | `frameContext(\"<ns>/sig/member-row/v2\", uuidBytes(objectId), uuidBytes(rowId)) ‖ UTF-8(payloadJson)` |\n| share link | `frameContext(\"<ns>/sig/share-link/v2\", hashedTokenBytes32) ‖ UTF-8(payloadJson)` |\n| direct invite / ack (mailbox, M3b) | `frameContext(\"<ns>/sig/invite/v2\", mailboxLabel32, SHA-256(recipientKey)) ‖ UTF-8(payloadJson)` — the mailbox and the recipient are in the signature, so a bundle re-sealed into another mailbox or for another recipient verifies for nobody (D-40; the collection-side check stays too). Built in `@microtoll/mailbox`, `FORMATS.md` §2 |\n\nThe frame's fixed-length parts (UUIDs as 16 bytes, a hash as 32) and the\nNUL-terminated label mean no field can shift into another and no signature\nmade for one purpose verifies for another. Version 1 signed text fields\njoined by NUL bytes (or the payload alone) with no purpose label, so nothing\nstopped a signature made for one purpose being offered for another; this\npackage does not read version 1. The signed\nenvelope keeps the outer shape `{ v, payloadJson, sig }` with `v: 3`, so a\nreader knows which message to rebuild; `v: 2` stays the quiet row."
294
+ },
295
+ {
296
+ "heading": "3.2 Additional authenticated data on the object-layer seals",
297
+ "level": 3,
298
+ "text": "| Seal | Key | Context (version 2) | Prevents |\n|---|---|---|---|\n| content | `K_object` | `frameContext(\"<ns>/aad/object-content/v2\", objectId, u32be(epoch))` | content moved between objects, or an earlier epoch's content replayed after a rotation under the same… (the key changes at rotation; the epoch binding is belt and braces and lets a reader assert the epoch it was told) |\n| second tier | `K_detail` | `frameContext(\"<ns>/aad/object-detail/v2\", objectId, u32be(epoch))` | same |\n| member row | `K_object` | `frameContext(\"<ns>/aad/member-row/v2\", objectId, uuidBytes(rowId))` | a row's ciphertext presented under another row id (the signature already binds the row id for named rows; this covers quiet and legacy rows too) |\n| pointer | `K_master_symm` | `frameContext(\"<ns>/aad/pointer/v2\", routingPublicKey)` (D-37, M4: the account, not the object id — the server returns an account's pointers without an id, so a binding that needed one could never be opened on a fresh device; the object id is inside the sealed pointer) | a pointer moved to another account, or another `K_master_symm` blob presented as a pointer |\n| share-link payload | token-derived key | `frameContext(\"<ns>/aad/share-link/v2\", hashedTokenBytes32)` | a payload served under another link's hash |\n| admin box | `K_adminbox` | `frameContext(\"<ns>/object-adminbox/v1\", objectId, u32be(epoch))` | a box moved to another object, or an earlier epoch's box replayed |\n\nRotation re-encrypts each remaining row byte-for-byte under the new key with\nthe same row context, so signatures survive it.\n\n**Not bound, stated plainly:** the sealed copy of `K_object` per member\n(`encryptedSharedEventKey` on the wire) is an ECIES seal, which binds the recipient's\nkey and its version but takes no additional data; binding the row would need\nan ECIES v4 in crypto-core, which is out of scope. A server that moves a\nmember's sealed key to another of the same member's rows gains nothing the\nmember could not do."
299
+ },
300
+ {
301
+ "heading": "3.3 Not changed by version 2",
302
+ "level": 3,
303
+ "text": "The admin seats and box (frozen sizes), quiet rows, the seal-target chooser\n(a quiet row's per-object keys first, a fresh hybrid key before the classical\none, a stale advertisement treated as absent), the set-aside rule, the grant\nrule's shape, the pointer's compare-and-heal on a lagging epoch, link tokens\n(160 bits), capability secrets (256 bits, SHA-256 on the server), and every\nwire message."
304
+ },
305
+ {
306
+ "heading": "4. Decision D-32: what M3 ships and what waits",
307
+ "level": 2,
308
+ "text": "**M3, `@microtoll/access`:**\n- object creation; sealing `K_object` to a member; the read capability;\n member rows (named, quiet, legacy readers); pointers with the extension\n table; admin seats and the admin box; the rotation plan and the member's\n pointer refresh; two-tier disclosure (split by the caller; grants, the\n sweep, the viewer's merge); share links (create, redeem, revoke, the\n creator's record, stats); the unverified display rule as a pure function\n over opened rows; the message builders and reply openers for the object\n wire (`create-event`, `join-event`, `fetch-event-members`,\n `rotate-event-key`, `create-participation`, `update-participation`,\n `update-event`, `delete-event`, `delete-participation`, the four link\n messages).\n- Tests: the adversarial suite the build plan names, each case against the\n in-process server stand-in extended with the object messages (the same\n stand-in identity uses, so the contract stays one).\n- Fixtures: the version-2 contexts frozen in this package's tests;\n crypto-core's frozen fixtures (`test/fixtures/frozen-v1.json`) pin the\n read capability and the detail-grant label. This package's\n `test/fixtures/frozen-v2.json` holds version-2 bytes it wrote (content,\n second tier and grants, named and quiet rows, pointers, admin seats and\n box, share links, and their hybrid variants), which every later version\n must open and reproduce.\n\n**Waits:** direct invites and acknowledgements (mailbox, M3b); contacts and\nfavourites (app or mailbox); product features that ride in rows or content\nas app fields (a first-time flag, repeat grants, proposals, signals) stay the\napp's."
309
+ },
310
+ {
311
+ "heading": "5. The adversarial suite (build plan M3), mapped",
312
+ "level": 2,
313
+ "text": "| Requirement | Cases |\n|---|---|\n| A removed member's old key cannot read post-rotation writes | new content, new detail, every resealed row and sealed key; the server refuses a write at the old epoch; the removed co-owner's admin secret refused; an incomplete plan refused as stale; a substituted old row set aside and never sealed to |\n| A revoked link cannot be redeemed | revoke → not-found; expiry → expired; N uses then exhausted; a concurrent last use taken once; stats absent after revoke |\n| An already-redeemed link is unaffected | a pointer holder still reads after revoke; and IS cut off by a later rotation |\n| A forged signature is flagged unverified | a bit-flipped, truncated or wrong-key signature on a row and on a link; a row lifted to another row id or object; a v2 quiet row carrying identity claims is still quiet, not verified; the display rule strips name and keys |\n| Holding the object never yields the second tier | a `K_object`-only holder cannot open the detail; a link holder cannot; a rotation grants only to the owner, co-owners, rows `grantDue` says and rows that held a grant; a grant label is object-scoped |"
314
+ }
315
+ ]
316
+ },
317
+ {
318
+ "path": "packages/mailbox.html",
319
+ "url": "https://microtoll.dev/packages/mailbox.html",
320
+ "title": "@microtoll/mailbox",
321
+ "description": "Direct invitations under mailbox labels only two people can compute.",
322
+ "section": "Packages",
323
+ "markdown": "# @microtoll/mailbox\n\nInviting a known person directly, with nothing to forward: a sealed, signed\ninvitation dropped under a **mailbox label only the two of them can\ncompute**, collected by the recipient's own client. The server holds rows\nunder labels it cannot compute, attribute or read. Web Crypto only; depends\non `@microtoll/crypto-core` and `@microtoll/identity`.\n\n**Status:** M3b, built inside M5 (D-40); not published. Formats in\n`FORMATS.md` (the label frozen by crypto-core's fixture file; the bundle\nversion 2).\n\n## The model in one paragraph\n\nTwo people who hold each other's public keys share, for each calendar month,\na **label**: HKDF over the P-256 agreement of their sealing keys, in one\ndirection. The sender drops a **bundle** under it — the object's key and\nepoch, the sender's name and claims, signed with the mailbox and the\nrecipient bound in, then sealed to the recipient — and the recipient polls\nthe labels of everyone they know, opens what is theirs, and consumes it.\nThe sender polls the same labels to see who has collected, and can take an\nuncollected drop back. An unsigned or re-sealed drop is usable but\nattributed to nobody.\n\n## Five-minute quickstart\n\n```js\nimport { createCryptoCore } from '@microtoll/crypto-core';\nimport { createMailbox } from '@microtoll/mailbox';\n\nconst cc = createCryptoCore({ namespace: 'myapp' });\nconst mailbox = createMailbox({ cryptoCore: cc });\n\n// A contact, as the app keeps it: the person's signing key and sealing key (base64url).\nconst bob = { signingKey: '…', identityKey: '…' };\n\n// Alice invites Bob to an object she holds the key for.\nconst [sent] = await mailbox.send(ws, alice, [bob], { objectId, kObjectRaw, keyEpoch, senderName: 'Alice', claims: { greeting: 'hello' } });\nkeep({ identityKey: bob.identityKey, epoch: sent.epoch, inviteId: sent.inviteId }); // in Alice's own pointer\n\n// Bob collects from everyone he knows: verified invitations, acknowledgements, and unsigned drops held for a tray.\nconst { invites, acks, unsigned, counts } = await mailbox.collect(ws, bobIdentity, contacts);\nfor (const inv of invites) {\n await joinTheObject(inv.objectId, inv.kObjectRaw, inv.keyEpoch, inv.senderName); // the app's, with @microtoll/access\n await mailbox.consumeInvite(ws, inv.rowId); // then it is collected\n}\n\n// Alice checks, or takes an uncollected one back.\nconst [{ collected }] = await mailbox.status(ws, alice, kept);\nawait mailbox.withdraw(ws, alice, cc.fromBase64Url(bob.identityKey), sent.epoch, sent.inviteId);\n\n// Live: the labels Bob polls, watched; a drop wakes him.\nconst labels = await mailbox.receivingLabels(bobIdentity, contacts);\nawait mailbox.watchInvites(ws, [...labels.values()].map((l) => l.mailboxId));\nmailbox.onLiveInvite(ws, () => mailbox.collect(ws, bobIdentity, contacts).then(show));\n```\n\n## What the app supplies\n\n- **Contacts**: who this account knows (signing key and sealing key), kept\n in its identity blob; the invite example (`examples/invite-app`) records\n them from the rosters of notes the two share.\n- **Policy**: which senders go straight through and which are held (an app\n might hold invitations from anyone not a favourite, and let a pairwise\n label be dismissed); who is blocked; whether an acknowledgement is\n recorded; any push subscription (`pushEpochs` gives its months).\n `collect` returns the drops and consumes only acknowledgements and\n unknown kinds (a retired kind is never acted on); the app consumes an\n invitation when it has acted on it.\n- **The object side**: joining with the key in the invitation is\n `@microtoll/access` (a read-only pointer, then a first reaction).\n\n## Tests\n\n`npm test`: the label against crypto-core's frozen fixture from both ends,\nthe epochs, the bundle round trip and the re-sealing cases (a signed\ninvitation passed on to someone else verifies for nobody), the flows\nagainst the server stand-in. blind-store's suites prove the server half.\n",
324
+ "sections": [
325
+ {
326
+ "heading": "@microtoll/mailbox",
327
+ "level": 1,
328
+ "text": "Inviting a known person directly, with nothing to forward: a sealed, signed\ninvitation dropped under a **mailbox label only the two of them can\ncompute**, collected by the recipient's own client. The server holds rows\nunder labels it cannot compute, attribute or read. Web Crypto only; depends\non `@microtoll/crypto-core` and `@microtoll/identity`.\n\n**Status:** M3b, built inside M5 (D-40); not published. Formats in\n`FORMATS.md` (the label frozen by crypto-core's fixture file; the bundle\nversion 2)."
329
+ },
330
+ {
331
+ "heading": "The model in one paragraph",
332
+ "level": 2,
333
+ "text": "Two people who hold each other's public keys share, for each calendar month,\na **label**: HKDF over the P-256 agreement of their sealing keys, in one\ndirection. The sender drops a **bundle** under it — the object's key and\nepoch, the sender's name and claims, signed with the mailbox and the\nrecipient bound in, then sealed to the recipient — and the recipient polls\nthe labels of everyone they know, opens what is theirs, and consumes it.\nThe sender polls the same labels to see who has collected, and can take an\nuncollected drop back. An unsigned or re-sealed drop is usable but\nattributed to nobody."
334
+ },
335
+ {
336
+ "heading": "Five-minute quickstart",
337
+ "level": 2,
338
+ "text": "```js\nimport { createCryptoCore } from '@microtoll/crypto-core';\nimport { createMailbox } from '@microtoll/mailbox';\n\nconst cc = createCryptoCore({ namespace: 'myapp' });\nconst mailbox = createMailbox({ cryptoCore: cc });\n\n// A contact, as the app keeps it: the person's signing key and sealing key (base64url).\nconst bob = { signingKey: '…', identityKey: '…' };\n\n// Alice invites Bob to an object she holds the key for.\nconst [sent] = await mailbox.send(ws, alice, [bob], { objectId, kObjectRaw, keyEpoch, senderName: 'Alice', claims: { greeting: 'hello' } });\nkeep({ identityKey: bob.identityKey, epoch: sent.epoch, inviteId: sent.inviteId }); // in Alice's own pointer\n\n// Bob collects from everyone he knows: verified invitations, acknowledgements, and unsigned drops held for a tray.\nconst { invites, acks, unsigned, counts } = await mailbox.collect(ws, bobIdentity, contacts);\nfor (const inv of invites) {\n await joinTheObject(inv.objectId, inv.kObjectRaw, inv.keyEpoch, inv.senderName); // the app's, with @microtoll/access\n await mailbox.consumeInvite(ws, inv.rowId); // then it is collected\n}\n\n// Alice checks, or takes an uncollected one back.\nconst [{ collected }] = await mailbox.status(ws, alice, kept);\nawait mailbox.withdraw(ws, alice, cc.fromBase64Url(bob.identityKey), sent.epoch, sent.inviteId);\n\n// Live: the labels Bob polls, watched; a drop wakes him.\nconst labels = await mailbox.receivingLabels(bobIdentity, contacts);\nawait mailbox.watchInvites(ws, [...labels.values()].map((l) => l.mailboxId));\nmailbox.onLiveInvite(ws, () => mailbox.collect(ws, bobIdentity, contacts).then(show));\n```"
339
+ },
340
+ {
341
+ "heading": "What the app supplies",
342
+ "level": 2,
343
+ "text": "- **Contacts**: who this account knows (signing key and sealing key), kept\n in its identity blob; the invite example (`examples/invite-app`) records\n them from the rosters of notes the two share.\n- **Policy**: which senders go straight through and which are held (an app\n might hold invitations from anyone not a favourite, and let a pairwise\n label be dismissed); who is blocked; whether an acknowledgement is\n recorded; any push subscription (`pushEpochs` gives its months).\n `collect` returns the drops and consumes only acknowledgements and\n unknown kinds (a retired kind is never acted on); the app consumes an\n invitation when it has acted on it.\n- **The object side**: joining with the key in the invitation is\n `@microtoll/access` (a read-only pointer, then a first reaction)."
344
+ },
345
+ {
346
+ "heading": "Tests",
347
+ "level": 2,
348
+ "text": "`npm test`: the label against crypto-core's frozen fixture from both ends,\nthe epochs, the bundle round trip and the re-sealing cases (a signed\ninvitation passed on to someone else verifies for nobody), the flows\nagainst the server stand-in. blind-store's suites prove the server half."
349
+ }
350
+ ]
351
+ },
352
+ {
353
+ "path": "packages/mailbox-formats.html",
354
+ "url": "https://microtoll.dev/packages/mailbox-formats.html",
355
+ "title": "@microtoll/mailbox — formats",
356
+ "description": "The mailbox label and the version 2 bundle.",
357
+ "section": "Packages",
358
+ "markdown": "# @microtoll/mailbox — formats\n\nStatus: **decided** (D-40, 2026-09-25; the signature's binding reserved by\nD-31). The label is frozen by the crypto-core fixture file (its `mailbox`\nsection, under the test namespace), which every later version must\nreproduce; the bundle is version 2. Version-2 bundles this package wrote\nunder that label (an invitation, an acknowledgement and a hybrid invitation)\nare frozen in `test/fixtures/frozen-v2.json`.\n\n## 1. The label\n\n```\nshared = ECDH-P256(myPrivateSealingKey, theirPublicSealingKey) (256 bits)\ninfo = \"<ns>/invite-mailbox/v2|<YYYY-MM>|\" + base64url(senderPublicKey) + \"|\" + base64url(recipientPublicKey)\nlabel = HKDF-SHA-256(shared, salt ∅, info, 256 bits) (32 bytes)\n```\n\n- The keys are the two long-term P-256 **sealing** keys (65-byte raw\n public keys). Both sides derive one value from opposite ends.\n- The epoch is the calendar month in UTC. A recipient polls this month and\n the previous; a standing subscription covers this month and the next.\n- `invite-mailbox/v1` (the X25519 label) is retired in every namespace\n (D-05) and cannot be produced through the profile.\n- What the server sees: the 32-byte label, and nothing else. It cannot\n compute one, attribute one, or read what is under it.\n\n## 2. The bundle (version 2)\n\n```\npayloadJson = JSON { kind, objectId, kObject, keyEpoch, senderName, <claims…>, senderIdentityKey, senderSigningKey }\nmessage = frameContext(\"<ns>/sig/invite/v2\", label, SHA-256(recipientKey)) ‖ UTF-8(payloadJson)\nsig = Ed25519(senderSigningKey, message)\nbundle = sealToRecipient(recipientKey, UTF-8(JSON { v: 2, payloadJson, sig }))\n```\n\n| Field | Invitation (`kind: \"invite\"`) | Acknowledgement (`kind: \"invite-ack\"`) |\n|---|---|---|\n| `objectId` | the object | the object the link opened |\n| `kObject` | base64url of `K_object` | `null` |\n| `keyEpoch` | the epoch the key is for | `null` |\n| `senderName` | shown only when verified | shown only when verified |\n| `hashedToken`, `stage` | — | which link was used; what is reported (`\"seen\"`, …) |\n| claims | the app's own fields, inside the signed payload | the same |\n\n- **What the signature binds**: the purpose label, the label of the mailbox\n the drop is for, and the hash of the key it is sealed to. A bundle re-sealed\n into another mailbox, or for another recipient, or with a changed\n payload, opens (the key in it is still a key) but verifies for nobody: no\n name, no claim.\n- **Which recipient key:** `sealToRecipient` dispatches on the key's\n length — the classical sealing key (65 bytes, ECIES v3) or the hybrid KEM\n key (1216 bytes, ECIES v2, when the recipient advertises one). The\n bundle's first byte tells the recipient which of its keys to open with,\n and the signature is checked against that key.\n- **Version 1 bundles are not read.** Version 1 signed `payloadJson` alone,\n so a contact could re-seal somebody else's signed invitation into a\n mailbox shared with a third person and it arrived as the signer's. No\n version 1 data exists to migrate.\n- **Post-quantum:** the label is classical by necessity (no standard\n post-quantum non-interactive key exchange); the bundle is hybrid when the\n recipient's key is.\n\n## 3. The server's part\n\n`send-invite { mailboxId, encryptedBundle, expiresAt }` → `{ inviteId }`;\n`poll-invites { mailboxIds }` → rows with the bundle for uncollected drops\nand `consumed: true` without it for collected or withdrawn ones;\n`consume-invite { inviteId }` (collection and withdrawal alike; the row's\nbundle is emptied, so a collected or withdrawn drop keeps no key); `watch-invites { mailboxIds }` and the\n`invite-live { mailboxId }` push. All in `@microtoll/blind-store`.\n",
359
+ "sections": [
360
+ {
361
+ "heading": "@microtoll/mailbox — formats",
362
+ "level": 1,
363
+ "text": "Status: **decided** (D-40, 2026-09-25; the signature's binding reserved by\nD-31). The label is frozen by the crypto-core fixture file (its `mailbox`\nsection, under the test namespace), which every later version must\nreproduce; the bundle is version 2. Version-2 bundles this package wrote\nunder that label (an invitation, an acknowledgement and a hybrid invitation)\nare frozen in `test/fixtures/frozen-v2.json`."
364
+ },
365
+ {
366
+ "heading": "1. The label",
367
+ "level": 2,
368
+ "text": "```\nshared = ECDH-P256(myPrivateSealingKey, theirPublicSealingKey) (256 bits)\ninfo = \"<ns>/invite-mailbox/v2|<YYYY-MM>|\" + base64url(senderPublicKey) + \"|\" + base64url(recipientPublicKey)\nlabel = HKDF-SHA-256(shared, salt ∅, info, 256 bits) (32 bytes)\n```\n\n- The keys are the two long-term P-256 **sealing** keys (65-byte raw\n public keys). Both sides derive one value from opposite ends.\n- The epoch is the calendar month in UTC. A recipient polls this month and\n the previous; a standing subscription covers this month and the next.\n- `invite-mailbox/v1` (the X25519 label) is retired in every namespace\n (D-05) and cannot be produced through the profile.\n- What the server sees: the 32-byte label, and nothing else. It cannot\n compute one, attribute one, or read what is under it."
369
+ },
370
+ {
371
+ "heading": "2. The bundle (version 2)",
372
+ "level": 2,
373
+ "text": "```\npayloadJson = JSON { kind, objectId, kObject, keyEpoch, senderName, <claims…>, senderIdentityKey, senderSigningKey }\nmessage = frameContext(\"<ns>/sig/invite/v2\", label, SHA-256(recipientKey)) ‖ UTF-8(payloadJson)\nsig = Ed25519(senderSigningKey, message)\nbundle = sealToRecipient(recipientKey, UTF-8(JSON { v: 2, payloadJson, sig }))\n```\n\n| Field | Invitation (`kind: \"invite\"`) | Acknowledgement (`kind: \"invite-ack\"`) |\n|---|---|---|\n| `objectId` | the object | the object the link opened |\n| `kObject` | base64url of `K_object` | `null` |\n| `keyEpoch` | the epoch the key is for | `null` |\n| `senderName` | shown only when verified | shown only when verified |\n| `hashedToken`, `stage` | — | which link was used; what is reported (`\"seen\"`, …) |\n| claims | the app's own fields, inside the signed payload | the same |\n\n- **What the signature binds**: the purpose label, the label of the mailbox\n the drop is for, and the hash of the key it is sealed to. A bundle re-sealed\n into another mailbox, or for another recipient, or with a changed\n payload, opens (the key in it is still a key) but verifies for nobody: no\n name, no claim.\n- **Which recipient key:** `sealToRecipient` dispatches on the key's\n length — the classical sealing key (65 bytes, ECIES v3) or the hybrid KEM\n key (1216 bytes, ECIES v2, when the recipient advertises one). The\n bundle's first byte tells the recipient which of its keys to open with,\n and the signature is checked against that key.\n- **Version 1 bundles are not read.** Version 1 signed `payloadJson` alone,\n so a contact could re-seal somebody else's signed invitation into a\n mailbox shared with a third person and it arrived as the signer's. No\n version 1 data exists to migrate.\n- **Post-quantum:** the label is classical by necessity (no standard\n post-quantum non-interactive key exchange); the bundle is hybrid when the\n recipient's key is."
374
+ },
375
+ {
376
+ "heading": "3. The server's part",
377
+ "level": 2,
378
+ "text": "`send-invite { mailboxId, encryptedBundle, expiresAt }` → `{ inviteId }`;\n`poll-invites { mailboxIds }` → rows with the bundle for uncollected drops\nand `consumed: true` without it for collected or withdrawn ones;\n`consume-invite { inviteId }` (collection and withdrawal alike; the row's\nbundle is emptied, so a collected or withdrawn drop keeps no key); `watch-invites { mailboxIds }` and the\n`invite-live { mailboxId }` push. All in `@microtoll/blind-store`."
379
+ }
380
+ ]
381
+ },
382
+ {
383
+ "path": "packages/blind-store.html",
384
+ "url": "https://microtoll.dev/packages/blind-store.html",
385
+ "title": "@microtoll/blind-store",
386
+ "description": "The server that holds only what it cannot read: library, schema, reference server.",
387
+ "section": "Packages",
388
+ "markdown": "# @microtoll/blind-store\n\nThe server that holds only what it cannot read. It authenticates a\nconnection by a signed challenge, authorises writes by capability secrets\nrather than identity, indexes sealed objects by a coarse public selector the\napp chooses, pushes live changes by that same selector, sweeps what has\nexpired, and never logs a message body. A **library** with a **thin\nreference server** around it: a host app mounts the library and registers\nits own handlers beside it; the example app runs the reference server as it\nis.\n\n**Status:** M4 built; not published (the publish gate, DECISIONS.md D-01, is\nclosed). Licence AGPL-3.0-only (D-02). Design: `DESIGN.md` (decisions D-33\nto D-36). Node 24 or later; Postgres 13 or later.\n\n## What the server sees, in one paragraph\n\nEight tables (`schema/000_blind_store.sql`): `users` (a routing public key,\na sealed identity blob, a generation counter, a compare-and-swap token),\n`unlock_methods` (wrapped root keys under a credential id or a lookup\nhash), `pointers` (an account's sealed records that name no object),\n`objects` (sealed content and second tier under a collection, a selector,\nan optional date window, an epoch and two capability hashes),\n`object_members` (sealed rows with no\nidentity and no reference to `users`), `share_links` (sealed payloads under\nthe hash of a token that stays in a URL fragment), `mailbox_drops` (sealed\nbundles under labels only two parties can compute) and\n`rate_limit_counters` (routing key × action × day, the one place a key sits\nbeside an action). Random UUID keys, no timestamps but two functional\nexpiries, no identity columns — and a test (`test/schema.test.mjs`) that\nfails if any of that changes. `THREATMODEL.md` §6 lists exactly what it\nlearns anyway.\n\n## Five-minute quickstart (the library)\n\n```js\nimport pg from 'pg';\nimport { createBlindStore } from '@microtoll/blind-store';\n\nconst pool = new pg.Pool({ host: 'db', user: 'blind_store_app', password, database: 'app' });\nconst store = createBlindStore({\n namespace: 'myapp', // the same namespace the app gives createCryptoCore\n allowedOrigins: ['https://app.example'], // browsers elsewhere are refused at upgrade\n pool,\n port: 8020,\n collections: {\n notes: { selectorLength: 2 }, // a shelf; no window\n events: { selectorLength: 5, window: true, allowAll: true, imminentDays: 2 }, // with a date window\n },\n live: { connectionConfig: { host: 'db', user: 'blind_store_app', password, database: 'app' } },\n});\n\n// A host's own message, beside the engine's:\nstore.handle('my-type', async ({ pool, ws, msg, state, routingPublicKey }) => { /* answer with send(ws, {...}) */ });\n```\n\nThe reference server (`bin/blind-store.mjs`) is the same call with its\noptions read from the environment; `deploy/` holds a hardened Compose file\nand an Nginx sample; `examples/notes-app` runs it unchanged.\n\n## The protocol, briefly\n\nPlain JSON over one WebSocket. The server opens with `challenge`; the client\nsigns `\"<ns>/auth/v2\" ‖ 0x00 ‖ SHA-256(origin) ‖ nonce` with its routing key\nand sends `auth` (a version-1 signature over the bare nonce is refused: it\nbound neither purpose nor origin); `auth-ok` says whether an account exists\nand carries the sealed identity blob. Every request carries a `requestId`\nthat its answer echoes. Before sign-in only `lookup-unlock-method` (three\nper socket) and what a host registers with `auth: 'none'` or `'any'` are\nanswered; anything else closes the socket. After it, an unknown type is\nrefused by name and never reflected. Message names keep the protocol's\nevent vocabulary (D-27); the client side of every one is in\n`@microtoll/identity` (accounts) and `@microtoll/access` (objects, links,\nthe query and the watches). The mailbox's server half is here; its client\npackage is M3b.\n\nQuery and fetch replies never carry `adminCapabilityHash`: it would be a\nstable per-object token handed to every querier. `delete-pointer` lets a\nclient discard its own stale pointer. A member-row write carrying\n`quietPush: true` sets the transaction-local `blind_store.quiet_push` flag\nfor a host's own trigger to read.\n\n**The selector query.** `query-events { collection, selectors | all,\nwindowStart?, windowEnd? }` answers everything active that matches, filtered\nby nothing else. The client's obligations, which are what make the model\nwork: decrypt only what you hold keys for (from your pointers); query the\nwhole area you show, at a precision you fix; never query by a list of ids.\n`fetch-event` by one id exists for redeeming a link and healing a stale\npointer, and is the one read that tells the server which object a routing\nkey asked about. A query whose answer would exceed `maxQueryRows` (5,000) is\nrefused as `too-many`; narrow the selector.\n\n**Live watches.** `watch-events` takes the same shape as a standing watch;\nchanges are routed by the selector they fall in (now, or before a move) and\nre-read before sending; a member-row change is pushed content-free and\ndebounced. `watch-imminent` carries nothing: a collection with\n`imminentDays` pushes every change inside the server's own window.\n\n## Limits, all backstops\n\nFrame 4 MiB; sign in within 120 s; 300 messages per socket, refilled 60 a\nsecond; 2,000 sockets; per-field ciphertext caps (`src/limits.js`); links of\nat most 200 uses and 400 days; daily counters (20 objects, 20 links, 50\ndrops per routing key) that fail open. Every one is configurable; every one\nfails closed for the connection or request that crosses it and changes\nnothing for anyone else — except the counters, where refusing a real action\nbecause a counter table was down would be the wrong failure.\n\n## Tests\n\n`npm test` runs the suites that need no database (the handshake\ncross-implementation check, the transport limits over real sockets, \"the\nserver cannot decrypt\"). The database-backed suites (the schema and role\nchecks, the whole protocol, the live watches, the fixture round trip) run\nwhen a Postgres answers at `BLIND_STORE_TEST_DB` (default: the throwaway\ncontainer `node scripts/test-db.mjs up` starts on port 15433) and skip with\none line otherwise; in CI they must run.\n",
389
+ "sections": [
390
+ {
391
+ "heading": "@microtoll/blind-store",
392
+ "level": 1,
393
+ "text": "The server that holds only what it cannot read. It authenticates a\nconnection by a signed challenge, authorises writes by capability secrets\nrather than identity, indexes sealed objects by a coarse public selector the\napp chooses, pushes live changes by that same selector, sweeps what has\nexpired, and never logs a message body. A **library** with a **thin\nreference server** around it: a host app mounts the library and registers\nits own handlers beside it; the example app runs the reference server as it\nis.\n\n**Status:** M4 built; not published (the publish gate, DECISIONS.md D-01, is\nclosed). Licence AGPL-3.0-only (D-02). Design: `DESIGN.md` (decisions D-33\nto D-36). Node 24 or later; Postgres 13 or later."
394
+ },
395
+ {
396
+ "heading": "What the server sees, in one paragraph",
397
+ "level": 2,
398
+ "text": "Eight tables (`schema/000_blind_store.sql`): `users` (a routing public key,\na sealed identity blob, a generation counter, a compare-and-swap token),\n`unlock_methods` (wrapped root keys under a credential id or a lookup\nhash), `pointers` (an account's sealed records that name no object),\n`objects` (sealed content and second tier under a collection, a selector,\nan optional date window, an epoch and two capability hashes),\n`object_members` (sealed rows with no\nidentity and no reference to `users`), `share_links` (sealed payloads under\nthe hash of a token that stays in a URL fragment), `mailbox_drops` (sealed\nbundles under labels only two parties can compute) and\n`rate_limit_counters` (routing key × action × day, the one place a key sits\nbeside an action). Random UUID keys, no timestamps but two functional\nexpiries, no identity columns — and a test (`test/schema.test.mjs`) that\nfails if any of that changes. `THREATMODEL.md` §6 lists exactly what it\nlearns anyway."
399
+ },
400
+ {
401
+ "heading": "Five-minute quickstart (the library)",
402
+ "level": 2,
403
+ "text": "```js\nimport pg from 'pg';\nimport { createBlindStore } from '@microtoll/blind-store';\n\nconst pool = new pg.Pool({ host: 'db', user: 'blind_store_app', password, database: 'app' });\nconst store = createBlindStore({\n namespace: 'myapp', // the same namespace the app gives createCryptoCore\n allowedOrigins: ['https://app.example'], // browsers elsewhere are refused at upgrade\n pool,\n port: 8020,\n collections: {\n notes: { selectorLength: 2 }, // a shelf; no window\n events: { selectorLength: 5, window: true, allowAll: true, imminentDays: 2 }, // with a date window\n },\n live: { connectionConfig: { host: 'db', user: 'blind_store_app', password, database: 'app' } },\n});\n\n// A host's own message, beside the engine's:\nstore.handle('my-type', async ({ pool, ws, msg, state, routingPublicKey }) => { /* answer with send(ws, {...}) */ });\n```\n\nThe reference server (`bin/blind-store.mjs`) is the same call with its\noptions read from the environment; `deploy/` holds a hardened Compose file\nand an Nginx sample; `examples/notes-app` runs it unchanged."
404
+ },
405
+ {
406
+ "heading": "The protocol, briefly",
407
+ "level": 2,
408
+ "text": "Plain JSON over one WebSocket. The server opens with `challenge`; the client\nsigns `\"<ns>/auth/v2\" ‖ 0x00 ‖ SHA-256(origin) ‖ nonce` with its routing key\nand sends `auth` (a version-1 signature over the bare nonce is refused: it\nbound neither purpose nor origin); `auth-ok` says whether an account exists\nand carries the sealed identity blob. Every request carries a `requestId`\nthat its answer echoes. Before sign-in only `lookup-unlock-method` (three\nper socket) and what a host registers with `auth: 'none'` or `'any'` are\nanswered; anything else closes the socket. After it, an unknown type is\nrefused by name and never reflected. Message names keep the protocol's\nevent vocabulary (D-27); the client side of every one is in\n`@microtoll/identity` (accounts) and `@microtoll/access` (objects, links,\nthe query and the watches). The mailbox's server half is here; its client\npackage is M3b.\n\nQuery and fetch replies never carry `adminCapabilityHash`: it would be a\nstable per-object token handed to every querier. `delete-pointer` lets a\nclient discard its own stale pointer. A member-row write carrying\n`quietPush: true` sets the transaction-local `blind_store.quiet_push` flag\nfor a host's own trigger to read.\n\n**The selector query.** `query-events { collection, selectors | all,\nwindowStart?, windowEnd? }` answers everything active that matches, filtered\nby nothing else. The client's obligations, which are what make the model\nwork: decrypt only what you hold keys for (from your pointers); query the\nwhole area you show, at a precision you fix; never query by a list of ids.\n`fetch-event` by one id exists for redeeming a link and healing a stale\npointer, and is the one read that tells the server which object a routing\nkey asked about. A query whose answer would exceed `maxQueryRows` (5,000) is\nrefused as `too-many`; narrow the selector.\n\n**Live watches.** `watch-events` takes the same shape as a standing watch;\nchanges are routed by the selector they fall in (now, or before a move) and\nre-read before sending; a member-row change is pushed content-free and\ndebounced. `watch-imminent` carries nothing: a collection with\n`imminentDays` pushes every change inside the server's own window."
409
+ },
410
+ {
411
+ "heading": "Limits, all backstops",
412
+ "level": 2,
413
+ "text": "Frame 4 MiB; sign in within 120 s; 300 messages per socket, refilled 60 a\nsecond; 2,000 sockets; per-field ciphertext caps (`src/limits.js`); links of\nat most 200 uses and 400 days; daily counters (20 objects, 20 links, 50\ndrops per routing key) that fail open. Every one is configurable; every one\nfails closed for the connection or request that crosses it and changes\nnothing for anyone else — except the counters, where refusing a real action\nbecause a counter table was down would be the wrong failure."
414
+ },
415
+ {
416
+ "heading": "Tests",
417
+ "level": 2,
418
+ "text": "`npm test` runs the suites that need no database (the handshake\ncross-implementation check, the transport limits over real sockets, \"the\nserver cannot decrypt\"). The database-backed suites (the schema and role\nchecks, the whole protocol, the live watches, the fixture round trip) run\nwhen a Postgres answers at `BLIND_STORE_TEST_DB` (default: the throwaway\ncontainer `node scripts/test-db.mjs up` starts on port 15433) and skip with\none line otherwise; in CI they must run."
419
+ }
420
+ ]
421
+ },
422
+ {
423
+ "path": "packages/blind-store-design.html",
424
+ "url": "https://microtoll.dev/packages/blind-store-design.html",
425
+ "title": "@microtoll/blind-store — design for decision (M4)",
426
+ "description": "The collection model, the bound handshake, the schema rules, the deployment shape.",
427
+ "section": "Packages",
428
+ "markdown": "# `@microtoll/blind-store` — design for decision (M4)\n\nStatus: **decided**, 2026-09-25 (D-33 to D-36 in `DECISIONS.md`, all four as\nrecommended). This document is the design the package follows; the README\ndescribes what was built.\n\n## 1. What the package is, in one paragraph\n\nThe server the other three packages talk to. It holds only what it cannot\nread: sealed blobs, pointer rows that name no object, hashed capabilities and\nhashed link tokens. It authenticates a connection by a signed challenge,\nauthorises writes by capability secrets rather than identity, indexes objects\nby a coarse public selector the app chooses, pushes live changes by that same\nselector, sweeps what has expired, and never logs a message body. It is a\n**library** with a **thin reference server** around it (D-23): a host app\nmounts the library and registers its own handlers beside it; the example app\nruns the reference server as it is.\n\n## 2. Decision D-33: the collection model (the server half of D-13/D-30)\n\nAn object is a sealed record in a named collection, filed under a coarse\npublic selector and, where the collection has one, a date window: the\nserver half of the model D-30 set for the client side.\n\n### 2.1 What an object row holds\n\n| Column | Notes |\n|---|---|\n| `id UUID PK` | client-generated random UUID (the row id is inside what the owner signs) |\n| `collection TEXT` | which kind of object; validated by the handler against the configured collections; one accepted low-cardinality plaintext |\n| `selector TEXT` | the coarse public selector: fixed length per collection, set by the app; characters `A–Z a–z 0–9 . _ : -` |\n| `window_start DATE`, `window_end DATE` | the date window, both set or both null (a collection without a window) |\n| `sealed_content BYTEA` | sealed under `K_object` |\n| `sealed_detail BYTEA` | the second tier, nullable |\n| `key_epoch INT ≥ 1` | bumped by the server on rotation |\n| `admin_capability_hash BYTEA(32)` | replaced on every rotation, so a co-owner removed by one cannot carry on with the secret they were given |\n| `read_capability_hash BYTEA(32)` | derived from `K_object` by the client |\n| `roster_members_only BOOLEAN` | the roster needs a row capability, not the read capability |\n| `status TEXT` | `'active'`; the engine never sets anything else — a host that needs to hide an object (a moderation suspension, say) sets another value from its own code, and every engine read path then treats it as not found and every admin action refuses it |\n\n`object_members` holds one row per member (`sealed_row`,\n`sealed_object_key`, `row_capability_hash`, `key_epoch`, `status IN\n('active','removed_by_admin','left')`). **No identity column and no foreign\nkey to `users`.** A host adds its own columns in its own init file, as it\nmay for `users`.\n\n### 2.2 The query, and the cover-traffic obligation\n\n`query-events` (the protocol's message name, D-27/D-30) takes:\n\n```\n{ collection, selectors: [ ... ] | all: true, windowStart?, windowEnd? }\n```\n\n- `selectors`: 1 to `maxSelectors` (default 10,000) values of the\n collection's fixed length; **`all: true`** is the calendar-view variant\n (the window alone, every selector) and is allowed only where the collection\n is configured `allowAll: true`; the two are mutually exclusive, and an empty\n list is a hard error.\n- The window is required when the collection has one, refused when it has\n not; overlap is `window_start <= windowEnd AND window_end >= windowStart`,\n with dates as `YYYY-MM-DD` text both ways (never a JS `Date`, so the\n server's time zone cannot shift a date by a day).\n- The answer is every active row matching, and nothing is filtered by\n identity. **The client's obligation**, documented in the README and the\n threat model: decrypt only the rows it holds keys for (from its pointers),\n query the whole area it shows at a fixed precision, never query by a list\n of ids. `fetch-event` by one id stays for redeeming a link and healing a\n stale pointer, and is documented as the path that does tell the server\n which object a routing key asked for.\n- **A backstop:** a query whose answer would exceed `maxQueryRows`\n (default 5,000, configurable) is refused with reason `too-many` — closed\n for that request, nothing else affected; the client narrows its selector.\n- **Piggy-backing hook:** a collection may supply `queryExtras(pool, query)`\n whose fields ride on the same `events` reply (a host's public listings,\n say), so an app never needs a second request that would reveal intent.\n\n### 2.3 Live watches (D-14: kept)\n\n`watch-events` / `unwatch-events` take the same query shape and are validated\nby the same parser; a change to an object or a member row is routed to every\nconnection whose selector covers where it is now or where it just was,\nre-reading the row before sending (the notification payload carries the\nselector, never content). A member-row change is pushed as a content-free\n`participation` wake-up, debounced 250 ms. `watch-imminent` carries no\nparameters: a collection configured with `imminentDays: n` pushes every\nchange to an object whose window falls within the next `n` days, whatever\nits selector — \"tonight's plans changed while I was looking elsewhere\",\nwith the server owning the window so there is no parameter to grow a\nmembership graph through.\n\n### 2.4 The wire\n\n- Selector fields are generic: `collection`, `selector`, `windowStart`,\n `windowEnd`, `rosterMembersOnly`.\n- **`adminCapabilityHash` is not sent** in query and fetch replies: it\n would be a stable per-object token handed to every cover-traffic\n recipient. The access package's admin check is the admin box seat, which\n never needed it; `decodeObjectWire` keeps the field as `null`.\n- Message names, reply names and reason codes keep the protocol's event\n vocabulary (D-27). The handlers keep a fixed order of checks, one\n transaction per write, epoch guards, completeness on rotation, atomic\n redemption and the link limits.\n- The mailbox's server half (`send-invite`, `poll-invites`,\n `consume-invite`, `watch-invites`; the `mailbox_drops` table) is here:\n it has no cryptography. The client package is M3b.\n\n### 2.5 Table names\n\n`users`, `unlock_methods`, `pointers`, `objects`, `object_members`,\n`share_links`, `mailbox_drops`, `rate_limit_counters`. A host extends\n`users` by `ALTER TABLE` in its own later init file.\n\n## 3. Decision D-34: the bound handshake, server half (D-29), and the transport limits\n\n### 3.1 The verifier\n\n```\nmessage = UTF-8(\"<ns>/auth/v2\") ‖ 0x00 ‖ SHA-256(UTF-8(origin)) ‖ nonce (nonce: 32 random bytes per connection)\nverify = Ed25519(routingPublicKey, message, signature) (Node's crypto.verify; RFC 8037 JWK import)\n```\n\n- `namespace` is a required option (no default, as D-05).\n- `origin` is the upgrade request's `Origin` header when a browser sent one\n (already checked against the allowed list before the socket exists);\n when there is none (a non-browser client) each allowed origin is tried.\n- **No import of `@microtoll/crypto-core`.** The server frames the message\n with `Buffer` and verifies with `node:crypto`; a test proves the framed\n bytes equal `@microtoll/identity`'s `authMessage` and that a signature made\n with Web Crypto verifies here — the cross-implementation test D-29 asks\n for. The server package can then contain no code able to decrypt anything\n (§5.4).\n- One nonce, one chance: a wrong signature sends `auth-failed` and closes\n with 1008; a second `auth` on an authenticated connection is an unknown\n type. A version-1 signature over the bare nonce is refused: it bound\n neither purpose nor origin.\n\n### 3.2 Transport limits\n\n| Limit | Default | Fails |\n|---|---|---|\n| frame size | 4 MiB (`ws` maxPayload) | that socket, 1009 |\n| pre-authentication timeout | 120 s | that socket, 1008 |\n| messages per socket | token bucket: 300, refilled 60/s | that socket, 1008 |\n| open sockets | 2,000 | the new connection, 503 at upgrade |\n| `Origin` | must be in `allowedOrigins`; no header allowed | 403 at upgrade |\n| `lookup-unlock-method` per socket | 3 (D-20) | `rate-limited` |\n| per-field ciphertext caps | the table in `src/limits.js` (identity blob 2 MiB, content 512 KiB, detail 256 KiB, row 64 KiB, pointer 256 KiB, link and mailbox payloads 256 KiB; nested: sealed object key 4 KiB, wrapped root key 1 KiB, label 4 KiB, credential id 1,023 B, salts 256 B) | `invalid`, with the field named |\n| share links | `maxUses` ≤ 200, expiry required and ≤ 400 days | `invalid` |\n| query answer | `maxQueryRows` 5,000 (§2.2) | `too-many` |\n| daily counters | `create-event` 20, `create-url-invite` 20, `send-invite` 50, per routing key per day; **fail open** (a counter outage never refuses a real action); the one place a routing key is written beside an action | `rate-limited` |\n\nAll configurable in `createBlindStore({ transport, limits, rateLimits })`;\nthe defaults, each justified, are in `src/limits.js`. Every limit is a DoS\nbackstop, not a product rule, and the README says so.\n\n## 4. Decision D-35: schema rules as tests, the sweep, the database role\n\n### 4.1 The rules, checked against a live database\n\nA test reads `information_schema` for the engine's tables and fails on:\n\n- a primary key that is not a client-random UUID, outside the whitelist\n (`users.routing_public_key`, `share_links.hashed_token`,\n `rate_limit_counters` composite);\n- any column default using `nextval` or an identity column;\n- any `TIMESTAMP`/`TIMESTAMPTZ` column other than `expires_at` on\n `share_links` and `mailbox_drops` (functional: the sweep), and any column\n named `*_at`, `created*`, `updated*`;\n- any identity-named column (`email`, `phone`, `name`, `ip`, `address`,\n `user_agent`, `created_by`, `owner_id`, `account_id`, …) — the only\n account-bearing columns are `*routing_public_key` on `unlock_methods` and\n `pointers`, the account's own rows;\n- any column on `objects`, `object_members`, `share_links` or\n `mailbox_drops` that references `users`;\n- a capability-hash or lookup-hash column without a 32-byte `CHECK`;\n- a table in the schema file that is not in the documented list.\n\nThe same test is what a host runs against its own extended database.\n\n### 4.2 The sweep (D-19)\n\n`blind_store_sweep()` empties the payload of every share link used up or\npast its expiry, deletes a link a week after its expiry, deletes a mailbox\ndrop once past its expiry, and deletes rate counters older than two days.\nThe library runs it at start and (`sweep: { intervalMs }`, `false` to leave it to the host) and logs a\ncount only. Objects past their window are **not** swept — that is the\napp's decision; the threat model says what a database copy therefore holds.\n\n### 4.3 The database role\n\nThe server never connects as the schema's owner or a superuser, so a\ncompromised server can touch the engine's tables and nothing else. The\nschema creates `blind_store_app` (`NOLOGIN NOSUPERUSER NOCREATEROLE\nNOCREATEDB NOREPLICATION`) with exactly the table rights the handlers use and\n`EXECUTE` on the sweep; the schema is owned by the deployment's owner role.\nThe reference server and the tests connect as `blind_store_app`, so a query\nthe role may not run fails in the suite. The login and password are given\nat deployment, never in the schema file: the Compose kit sets them from a\nsecret file at first start.\n\n## 5. Decision D-36: the library, the reference server, the deployment kit, the example\n\n### 5.1 The library\n\n```js\nimport { createBlindStore } from '@microtoll/blind-store';\nconst store = createBlindStore({\n namespace: 'myapp', // required: the label prefix the handshake verifies\n pool, // a pg Pool connected as blind_store_app\n port: 8020, // listens at creation\n allowedOrigins: ['https://app.example'],\n collections: { notes: { selectorLength: 2 } }, // window: false; or events: { selectorLength: 5, window: true, allowAll: true, imminentDays: 2, queryExtras }\n registration: { columns, onRegister, authOkFields }, // optional host policy (D-17)\n live: { connectionConfig, extraChannels }, // optional: LISTEN for the live watches\n sweep: { intervalMs: 3600_000 },\n transport, limits, rateLimits, httpRoutes, onAuthenticated, onSocketClose, onDeleteAccount, log,\n});\nstore.handle('my-type', handler, { auth: 'required' | 'none' | 'any', needsPool });\nstore.setFallback(async (ctx) => false);\nstore.close(cb);\n```\n\n`createCore` is exported as an alias of `createBlindStore`. The wire\nhelpers a host's own handlers share with the engine's (`send`, the\nbase64url codecs, `blobField`, `hashField`, `hashSecret`, `expiryField`,\nthe limits, `rateLimit`, `insertPointer`, `parsePointerField`) are\nexported as they are.\n\nDependencies: **`ws` and `pg`, pinned exactly**, nothing else. `pg-listen`\nis dropped: the LISTEN connection is a plain `pg` client with a reconnect\nloop (about forty lines), and a live hub that cannot subscribe logs loudly\nat start and on every retry, because silent live-update failure is worse\nthan a crash. Ed25519, SHA-256 and random bytes come from `node:crypto`.\nNode 24 or later.\n\n### 5.2 The reference server\n\n`packages/blind-store/bin/blind-store.mjs`: reads `BLIND_STORE_NAMESPACE`,\n`BLIND_STORE_PORT`, `BLIND_STORE_ALLOWED_ORIGINS`, `BLIND_STORE_COLLECTIONS`\n(JSON), `DB_HOST/PORT/USER/NAME` and `DB_PASSWORD_FILE` (a file, never the\nenvironment), creates the pool and the live hub, starts the\nsweep, serves `/healthz` (a real `SELECT 1`; 200 or 503, nothing else) and\nstops on SIGTERM. Logs carry counts and reasons, never a message body or a\nrouting key.\n\n### 5.3 The deployment kit (`deploy/`)\n\n- `docker-compose.yml`: `postgres:17` on the internal network only (no host\n port), init from the package's `schema/`, `pg_isready` healthcheck, UTC;\n the server built from `deploy/Dockerfile` (`node:24-alpine`, the workspace\n copied in, `npm ci --omit=dev`), running as `node`, `read_only`, `tmpfs\n /tmp`, `cap_drop: [ALL]`, `no-new-privileges`, a memory limit,\n `depends_on: service_healthy`; the database passwords from files declared\n under `secrets:`, and the application role's login set by an init script\n from its secret at first start.\n- `nginx.sample.conf`: TLS 1.2/1.3 with `X25519MLKEM768:X25519:prime256v1`,\n `server_tokens off`, `access_log off`, HSTS, `nosniff`,\n `Referrer-Policy no-referrer`, a strict CSP on every location (re-added\n per location: `add_header` there cancels inheritance), the WebSocket\n upgrade block with `limit_conn`/`limit_req` per address, blanking\n `X-Forwarded-For`, `X-Real-IP`, `CF-Connecting-IP`, `True-Client-IP`\n and the Cloudflare geo headers so the server never holds an address\n beside a routing key, and `/healthz` ungated with `no-store`.\n- Deliberately absent: a superuser connection, running as root, a\n read-write source mount, a public 5432, access logs.\n\n### 5.4 Tests\n\n1. **Without Postgres** (always run): the registry and dispatcher rules, the\n pre-authentication surface, malformed and oversize frames, the token\n bucket, the pre-authentication timeout, origin refusal, every field cap,\n the handshake cross-implementation test (§3.1) — real sockets on\n localhost, `pool: null`.\n2. **\"The server cannot decrypt\"** (always run): the package's runtime\n dependencies are exactly `ws` and `pg`; no `@microtoll/*` package is\n imported at runtime; a scan of `src/` finds no decrypt, decipher, unwrap,\n derive-key, HKDF, AES or private-key identifier; and, with Postgres,\n every sealed fixture from `crypto-core/test/fixtures/frozen-v1.json`\n stored through the server reads back byte-identical.\n3. **With Postgres** (`BLIND_STORE_TEST_DB` set, or the throwaway container\n `scripts/test-db.mjs up` starts; CI runs a `postgres:17` service): the\n whole protocol driven by the real client packages — `@microtoll/identity`\n and `@microtoll/access` as devDependencies — covering every core\n message: register, lookup and unlock, the blob's compare-and-swap,\n methods, rotation of the recovery code, sign out everywhere, objects,\n members, pointers, epoch guards, rotation completeness, links made,\n counted, redeemed, exhausted, revoked, the mailbox, live watches, the\n daily caps, deletion, the sweep.\n4. **Schema conformance** (§4.1) and **role conformance** (the tests run as\n `blind_store_app`).\n5. **The example's integration test**: the notes app's own client module\n driven in Node against the running server.\n\n### 5.5 The example: `examples/notes-app`\n\nEnd-to-end-encrypted notes with sharing and revocation, small enough to read\nin ten minutes, on the reference server unchanged:\n\n- `docker compose up` in `examples/notes-app` starts Postgres, blind-store\n (collection `notes`, a 2-character selector, no window) and nginx serving\n the page and proxying `/ws`; open `http://localhost:8088`.\n- Sign up with a recovery code (works in every browser; a passkey is offered\n when the browser has PRF), write a note, share it by link, open the link\n in a private window as a second person, react to become a member, remove\n them as the owner and watch the old key fail to read the next edit.\n- **The selector is a random \"shelf\"** (one of 256), chosen when a note is\n created and kept in the note's pointer (the access package's pointer\n extension): the app asks the server for every note on the shelves it uses\n and decrypts only its own. The README states plainly what the server\n learns (which shelves this routing key reads) and that cover is only as\n deep as the crowd on a shelf — the same honesty the threat model asks of\n any coarse selector.\n- The page uses the workspace packages through an import map — the packages\n as they will be published, no build step — since the publish gate is\n closed (D-01).\n\n## 6. What stays out (D-14, confirmed)\n\nReporting and moderation, the public layer, operator disclosure keys, the\nrepeat grant, live signals (the transaction-local `blind_store.quiet_push`\nflag a host's trigger may read is kept, one line), Web Push, and terms and\nage-declaration columns (the registration hook replaces them).\n\n## 7. Threat model §6, to be completed with the package\n\nThe accepted trades, each listed: the collection, selector and window per\nobject; which selectors and windows a connection queried and watched; one\nobject id per `fetch-event` and per link redemption; routing key × action ×\nday in the rate counters; pointer counts and unlock-method counts per\naccount; the `Origin` and the request sizes and timing; what a database copy\nholds until the sweep runs. The database role model; the client\nobligations for cover traffic; availability limits.\n",
429
+ "sections": [
430
+ {
431
+ "heading": "@microtoll/blind-store — design for decision (M4)",
432
+ "level": 1,
433
+ "text": "Status: **decided**, 2026-09-25 (D-33 to D-36 in `DECISIONS.md`, all four as\nrecommended). This document is the design the package follows; the README\ndescribes what was built."
434
+ },
435
+ {
436
+ "heading": "1. What the package is, in one paragraph",
437
+ "level": 2,
438
+ "text": "The server the other three packages talk to. It holds only what it cannot\nread: sealed blobs, pointer rows that name no object, hashed capabilities and\nhashed link tokens. It authenticates a connection by a signed challenge,\nauthorises writes by capability secrets rather than identity, indexes objects\nby a coarse public selector the app chooses, pushes live changes by that same\nselector, sweeps what has expired, and never logs a message body. It is a\n**library** with a **thin reference server** around it (D-23): a host app\nmounts the library and registers its own handlers beside it; the example app\nruns the reference server as it is."
439
+ },
440
+ {
441
+ "heading": "2. Decision D-33: the collection model (the server half of D-13/D-30)",
442
+ "level": 2,
443
+ "text": "An object is a sealed record in a named collection, filed under a coarse\npublic selector and, where the collection has one, a date window: the\nserver half of the model D-30 set for the client side."
444
+ },
445
+ {
446
+ "heading": "2.1 What an object row holds",
447
+ "level": 3,
448
+ "text": "| Column | Notes |\n|---|---|\n| `id UUID PK` | client-generated random UUID (the row id is inside what the owner signs) |\n| `collection TEXT` | which kind of object; validated by the handler against the configured collections; one accepted low-cardinality plaintext |\n| `selector TEXT` | the coarse public selector: fixed length per collection, set by the app; characters `A–Z a–z 0–9 . _ : -` |\n| `window_start DATE`, `window_end DATE` | the date window, both set or both null (a collection without a window) |\n| `sealed_content BYTEA` | sealed under `K_object` |\n| `sealed_detail BYTEA` | the second tier, nullable |\n| `key_epoch INT ≥ 1` | bumped by the server on rotation |\n| `admin_capability_hash BYTEA(32)` | replaced on every rotation, so a co-owner removed by one cannot carry on with the secret they were given |\n| `read_capability_hash BYTEA(32)` | derived from `K_object` by the client |\n| `roster_members_only BOOLEAN` | the roster needs a row capability, not the read capability |\n| `status TEXT` | `'active'`; the engine never sets anything else — a host that needs to hide an object (a moderation suspension, say) sets another value from its own code, and every engine read path then treats it as not found and every admin action refuses it |\n\n`object_members` holds one row per member (`sealed_row`,\n`sealed_object_key`, `row_capability_hash`, `key_epoch`, `status IN\n('active','removed_by_admin','left')`). **No identity column and no foreign\nkey to `users`.** A host adds its own columns in its own init file, as it\nmay for `users`."
449
+ },
450
+ {
451
+ "heading": "2.2 The query, and the cover-traffic obligation",
452
+ "level": 3,
453
+ "text": "`query-events` (the protocol's message name, D-27/D-30) takes:\n\n```\n{ collection, selectors: [ ... ] | all: true, windowStart?, windowEnd? }\n```\n\n- `selectors`: 1 to `maxSelectors` (default 10,000) values of the\n collection's fixed length; **`all: true`** is the calendar-view variant\n (the window alone, every selector) and is allowed only where the collection\n is configured `allowAll: true`; the two are mutually exclusive, and an empty\n list is a hard error.\n- The window is required when the collection has one, refused when it has\n not; overlap is `window_start <= windowEnd AND window_end >= windowStart`,\n with dates as `YYYY-MM-DD` text both ways (never a JS `Date`, so the\n server's time zone cannot shift a date by a day).\n- The answer is every active row matching, and nothing is filtered by\n identity. **The client's obligation**, documented in the README and the\n threat model: decrypt only the rows it holds keys for (from its pointers),\n query the whole area it shows at a fixed precision, never query by a list\n of ids. `fetch-event` by one id stays for redeeming a link and healing a\n stale pointer, and is documented as the path that does tell the server\n which object a routing key asked for.\n- **A backstop:** a query whose answer would exceed `maxQueryRows`\n (default 5,000, configurable) is refused with reason `too-many` — closed\n for that request, nothing else affected; the client narrows its selector.\n- **Piggy-backing hook:** a collection may supply `queryExtras(pool, query)`\n whose fields ride on the same `events` reply (a host's public listings,\n say), so an app never needs a second request that would reveal intent."
454
+ },
455
+ {
456
+ "heading": "2.3 Live watches (D-14: kept)",
457
+ "level": 3,
458
+ "text": "`watch-events` / `unwatch-events` take the same query shape and are validated\nby the same parser; a change to an object or a member row is routed to every\nconnection whose selector covers where it is now or where it just was,\nre-reading the row before sending (the notification payload carries the\nselector, never content). A member-row change is pushed as a content-free\n`participation` wake-up, debounced 250 ms. `watch-imminent` carries no\nparameters: a collection configured with `imminentDays: n` pushes every\nchange to an object whose window falls within the next `n` days, whatever\nits selector — \"tonight's plans changed while I was looking elsewhere\",\nwith the server owning the window so there is no parameter to grow a\nmembership graph through."
459
+ },
460
+ {
461
+ "heading": "2.4 The wire",
462
+ "level": 3,
463
+ "text": "- Selector fields are generic: `collection`, `selector`, `windowStart`,\n `windowEnd`, `rosterMembersOnly`.\n- **`adminCapabilityHash` is not sent** in query and fetch replies: it\n would be a stable per-object token handed to every cover-traffic\n recipient. The access package's admin check is the admin box seat, which\n never needed it; `decodeObjectWire` keeps the field as `null`.\n- Message names, reply names and reason codes keep the protocol's event\n vocabulary (D-27). The handlers keep a fixed order of checks, one\n transaction per write, epoch guards, completeness on rotation, atomic\n redemption and the link limits.\n- The mailbox's server half (`send-invite`, `poll-invites`,\n `consume-invite`, `watch-invites`; the `mailbox_drops` table) is here:\n it has no cryptography. The client package is M3b."
464
+ },
465
+ {
466
+ "heading": "2.5 Table names",
467
+ "level": 3,
468
+ "text": "`users`, `unlock_methods`, `pointers`, `objects`, `object_members`,\n`share_links`, `mailbox_drops`, `rate_limit_counters`. A host extends\n`users` by `ALTER TABLE` in its own later init file."
469
+ },
470
+ {
471
+ "heading": "3. Decision D-34: the bound handshake, server half (D-29), and the transport limits",
472
+ "level": 2,
473
+ "text": ""
474
+ },
475
+ {
476
+ "heading": "3.1 The verifier",
477
+ "level": 3,
478
+ "text": "```\nmessage = UTF-8(\"<ns>/auth/v2\") ‖ 0x00 ‖ SHA-256(UTF-8(origin)) ‖ nonce (nonce: 32 random bytes per connection)\nverify = Ed25519(routingPublicKey, message, signature) (Node's crypto.verify; RFC 8037 JWK import)\n```\n\n- `namespace` is a required option (no default, as D-05).\n- `origin` is the upgrade request's `Origin` header when a browser sent one\n (already checked against the allowed list before the socket exists);\n when there is none (a non-browser client) each allowed origin is tried.\n- **No import of `@microtoll/crypto-core`.** The server frames the message\n with `Buffer` and verifies with `node:crypto`; a test proves the framed\n bytes equal `@microtoll/identity`'s `authMessage` and that a signature made\n with Web Crypto verifies here — the cross-implementation test D-29 asks\n for. The server package can then contain no code able to decrypt anything\n (§5.4).\n- One nonce, one chance: a wrong signature sends `auth-failed` and closes\n with 1008; a second `auth` on an authenticated connection is an unknown\n type. A version-1 signature over the bare nonce is refused: it bound\n neither purpose nor origin."
479
+ },
480
+ {
481
+ "heading": "3.2 Transport limits",
482
+ "level": 3,
483
+ "text": "| Limit | Default | Fails |\n|---|---|---|\n| frame size | 4 MiB (`ws` maxPayload) | that socket, 1009 |\n| pre-authentication timeout | 120 s | that socket, 1008 |\n| messages per socket | token bucket: 300, refilled 60/s | that socket, 1008 |\n| open sockets | 2,000 | the new connection, 503 at upgrade |\n| `Origin` | must be in `allowedOrigins`; no header allowed | 403 at upgrade |\n| `lookup-unlock-method` per socket | 3 (D-20) | `rate-limited` |\n| per-field ciphertext caps | the table in `src/limits.js` (identity blob 2 MiB, content 512 KiB, detail 256 KiB, row 64 KiB, pointer 256 KiB, link and mailbox payloads 256 KiB; nested: sealed object key 4 KiB, wrapped root key 1 KiB, label 4 KiB, credential id 1,023 B, salts 256 B) | `invalid`, with the field named |\n| share links | `maxUses` ≤ 200, expiry required and ≤ 400 days | `invalid` |\n| query answer | `maxQueryRows` 5,000 (§2.2) | `too-many` |\n| daily counters | `create-event` 20, `create-url-invite` 20, `send-invite` 50, per routing key per day; **fail open** (a counter outage never refuses a real action); the one place a routing key is written beside an action | `rate-limited` |\n\nAll configurable in `createBlindStore({ transport, limits, rateLimits })`;\nthe defaults, each justified, are in `src/limits.js`. Every limit is a DoS\nbackstop, not a product rule, and the README says so."
484
+ },
485
+ {
486
+ "heading": "4. Decision D-35: schema rules as tests, the sweep, the database role",
487
+ "level": 2,
488
+ "text": ""
489
+ },
490
+ {
491
+ "heading": "4.1 The rules, checked against a live database",
492
+ "level": 3,
493
+ "text": "A test reads `information_schema` for the engine's tables and fails on:\n\n- a primary key that is not a client-random UUID, outside the whitelist\n (`users.routing_public_key`, `share_links.hashed_token`,\n `rate_limit_counters` composite);\n- any column default using `nextval` or an identity column;\n- any `TIMESTAMP`/`TIMESTAMPTZ` column other than `expires_at` on\n `share_links` and `mailbox_drops` (functional: the sweep), and any column\n named `*_at`, `created*`, `updated*`;\n- any identity-named column (`email`, `phone`, `name`, `ip`, `address`,\n `user_agent`, `created_by`, `owner_id`, `account_id`, …) — the only\n account-bearing columns are `*routing_public_key` on `unlock_methods` and\n `pointers`, the account's own rows;\n- any column on `objects`, `object_members`, `share_links` or\n `mailbox_drops` that references `users`;\n- a capability-hash or lookup-hash column without a 32-byte `CHECK`;\n- a table in the schema file that is not in the documented list.\n\nThe same test is what a host runs against its own extended database."
494
+ },
495
+ {
496
+ "heading": "4.2 The sweep (D-19)",
497
+ "level": 3,
498
+ "text": "`blind_store_sweep()` empties the payload of every share link used up or\npast its expiry, deletes a link a week after its expiry, deletes a mailbox\ndrop once past its expiry, and deletes rate counters older than two days.\nThe library runs it at start and (`sweep: { intervalMs }`, `false` to leave it to the host) and logs a\ncount only. Objects past their window are **not** swept — that is the\napp's decision; the threat model says what a database copy therefore holds."
499
+ },
500
+ {
501
+ "heading": "4.3 The database role",
502
+ "level": 3,
503
+ "text": "The server never connects as the schema's owner or a superuser, so a\ncompromised server can touch the engine's tables and nothing else. The\nschema creates `blind_store_app` (`NOLOGIN NOSUPERUSER NOCREATEROLE\nNOCREATEDB NOREPLICATION`) with exactly the table rights the handlers use and\n`EXECUTE` on the sweep; the schema is owned by the deployment's owner role.\nThe reference server and the tests connect as `blind_store_app`, so a query\nthe role may not run fails in the suite. The login and password are given\nat deployment, never in the schema file: the Compose kit sets them from a\nsecret file at first start."
504
+ },
505
+ {
506
+ "heading": "5. Decision D-36: the library, the reference server, the deployment kit, the example",
507
+ "level": 2,
508
+ "text": ""
509
+ },
510
+ {
511
+ "heading": "5.1 The library",
512
+ "level": 3,
513
+ "text": "```js\nimport { createBlindStore } from '@microtoll/blind-store';\nconst store = createBlindStore({\n namespace: 'myapp', // required: the label prefix the handshake verifies\n pool, // a pg Pool connected as blind_store_app\n port: 8020, // listens at creation\n allowedOrigins: ['https://app.example'],\n collections: { notes: { selectorLength: 2 } }, // window: false; or events: { selectorLength: 5, window: true, allowAll: true, imminentDays: 2, queryExtras }\n registration: { columns, onRegister, authOkFields }, // optional host policy (D-17)\n live: { connectionConfig, extraChannels }, // optional: LISTEN for the live watches\n sweep: { intervalMs: 3600_000 },\n transport, limits, rateLimits, httpRoutes, onAuthenticated, onSocketClose, onDeleteAccount, log,\n});\nstore.handle('my-type', handler, { auth: 'required' | 'none' | 'any', needsPool });\nstore.setFallback(async (ctx) => false);\nstore.close(cb);\n```\n\n`createCore` is exported as an alias of `createBlindStore`. The wire\nhelpers a host's own handlers share with the engine's (`send`, the\nbase64url codecs, `blobField`, `hashField`, `hashSecret`, `expiryField`,\nthe limits, `rateLimit`, `insertPointer`, `parsePointerField`) are\nexported as they are.\n\nDependencies: **`ws` and `pg`, pinned exactly**, nothing else. `pg-listen`\nis dropped: the LISTEN connection is a plain `pg` client with a reconnect\nloop (about forty lines), and a live hub that cannot subscribe logs loudly\nat start and on every retry, because silent live-update failure is worse\nthan a crash. Ed25519, SHA-256 and random bytes come from `node:crypto`.\nNode 24 or later."
514
+ },
515
+ {
516
+ "heading": "5.2 The reference server",
517
+ "level": 3,
518
+ "text": "`packages/blind-store/bin/blind-store.mjs`: reads `BLIND_STORE_NAMESPACE`,\n`BLIND_STORE_PORT`, `BLIND_STORE_ALLOWED_ORIGINS`, `BLIND_STORE_COLLECTIONS`\n(JSON), `DB_HOST/PORT/USER/NAME` and `DB_PASSWORD_FILE` (a file, never the\nenvironment), creates the pool and the live hub, starts the\nsweep, serves `/healthz` (a real `SELECT 1`; 200 or 503, nothing else) and\nstops on SIGTERM. Logs carry counts and reasons, never a message body or a\nrouting key."
519
+ },
520
+ {
521
+ "heading": "5.3 The deployment kit (deploy/)",
522
+ "level": 3,
523
+ "text": "- `docker-compose.yml`: `postgres:17` on the internal network only (no host\n port), init from the package's `schema/`, `pg_isready` healthcheck, UTC;\n the server built from `deploy/Dockerfile` (`node:24-alpine`, the workspace\n copied in, `npm ci --omit=dev`), running as `node`, `read_only`, `tmpfs\n /tmp`, `cap_drop: [ALL]`, `no-new-privileges`, a memory limit,\n `depends_on: service_healthy`; the database passwords from files declared\n under `secrets:`, and the application role's login set by an init script\n from its secret at first start.\n- `nginx.sample.conf`: TLS 1.2/1.3 with `X25519MLKEM768:X25519:prime256v1`,\n `server_tokens off`, `access_log off`, HSTS, `nosniff`,\n `Referrer-Policy no-referrer`, a strict CSP on every location (re-added\n per location: `add_header` there cancels inheritance), the WebSocket\n upgrade block with `limit_conn`/`limit_req` per address, blanking\n `X-Forwarded-For`, `X-Real-IP`, `CF-Connecting-IP`, `True-Client-IP`\n and the Cloudflare geo headers so the server never holds an address\n beside a routing key, and `/healthz` ungated with `no-store`.\n- Deliberately absent: a superuser connection, running as root, a\n read-write source mount, a public 5432, access logs."
524
+ },
525
+ {
526
+ "heading": "5.4 Tests",
527
+ "level": 3,
528
+ "text": "1. **Without Postgres** (always run): the registry and dispatcher rules, the\n pre-authentication surface, malformed and oversize frames, the token\n bucket, the pre-authentication timeout, origin refusal, every field cap,\n the handshake cross-implementation test (§3.1) — real sockets on\n localhost, `pool: null`.\n2. **\"The server cannot decrypt\"** (always run): the package's runtime\n dependencies are exactly `ws` and `pg`; no `@microtoll/*` package is\n imported at runtime; a scan of `src/` finds no decrypt, decipher, unwrap,\n derive-key, HKDF, AES or private-key identifier; and, with Postgres,\n every sealed fixture from `crypto-core/test/fixtures/frozen-v1.json`\n stored through the server reads back byte-identical.\n3. **With Postgres** (`BLIND_STORE_TEST_DB` set, or the throwaway container\n `scripts/test-db.mjs up` starts; CI runs a `postgres:17` service): the\n whole protocol driven by the real client packages — `@microtoll/identity`\n and `@microtoll/access` as devDependencies — covering every core\n message: register, lookup and unlock, the blob's compare-and-swap,\n methods, rotation of the recovery code, sign out everywhere, objects,\n members, pointers, epoch guards, rotation completeness, links made,\n counted, redeemed, exhausted, revoked, the mailbox, live watches, the\n daily caps, deletion, the sweep.\n4. **Schema conformance** (§4.1) and **role conformance** (the tests run as\n `blind_store_app`).\n5. **The example's integration test**: the notes app's own client module\n driven in Node against the running server."
529
+ },
530
+ {
531
+ "heading": "5.5 The example: examples/notes-app",
532
+ "level": 3,
533
+ "text": "End-to-end-encrypted notes with sharing and revocation, small enough to read\nin ten minutes, on the reference server unchanged:\n\n- `docker compose up` in `examples/notes-app` starts Postgres, blind-store\n (collection `notes`, a 2-character selector, no window) and nginx serving\n the page and proxying `/ws`; open `http://localhost:8088`.\n- Sign up with a recovery code (works in every browser; a passkey is offered\n when the browser has PRF), write a note, share it by link, open the link\n in a private window as a second person, react to become a member, remove\n them as the owner and watch the old key fail to read the next edit.\n- **The selector is a random \"shelf\"** (one of 256), chosen when a note is\n created and kept in the note's pointer (the access package's pointer\n extension): the app asks the server for every note on the shelves it uses\n and decrypts only its own. The README states plainly what the server\n learns (which shelves this routing key reads) and that cover is only as\n deep as the crowd on a shelf — the same honesty the threat model asks of\n any coarse selector.\n- The page uses the workspace packages through an import map — the packages\n as they will be published, no build step — since the publish gate is\n closed (D-01)."
534
+ },
535
+ {
536
+ "heading": "6. What stays out (D-14, confirmed)",
537
+ "level": 2,
538
+ "text": "Reporting and moderation, the public layer, operator disclosure keys, the\nrepeat grant, live signals (the transaction-local `blind_store.quiet_push`\nflag a host's trigger may read is kept, one line), Web Push, and terms and\nage-declaration columns (the registration hook replaces them)."
539
+ },
540
+ {
541
+ "heading": "7. Threat model §6, to be completed with the package",
542
+ "level": 2,
543
+ "text": "The accepted trades, each listed: the collection, selector and window per\nobject; which selectors and windows a connection queried and watched; one\nobject id per `fetch-event` and per link redemption; routing key × action ×\nday in the rate counters; pointer counts and unlock-method counts per\naccount; the `Origin` and the request sizes and timing; what a database copy\nholds until the sweep runs. The database role model; the client\nobligations for cover traffic; availability limits."
544
+ }
545
+ ]
546
+ },
547
+ {
548
+ "path": "packages/mcp.html",
549
+ "url": "https://microtoll.dev/packages/mcp.html",
550
+ "title": "@microtoll/mcp",
551
+ "description": "Docs search and project scaffolding for AI coding agents, over the Model Context Protocol.",
552
+ "section": "Packages",
553
+ "markdown": "# @microtoll/mcp\n\nMicrotoll Engine inside your coding tool. A Model Context Protocol server\n(over standard input and output) that Claude Code, Cursor and any MCP host\ncan call for the engine's documentation and for a project scaffold, so an\nagent can wire the engine in without leaving the editor — and without\nleaving the machine: the pages are inside the package, nothing is fetched,\nnothing runs but what is in `src/`.\n\n**Status:** M5; not published (the publish gate, DECISIONS.md D-01).\nApache-2.0. Zero dependencies (D-39). Node 24 or later.\n\n## Add it to a host\n\n```sh\nclaude mcp add microtoll -- npx -y @microtoll/mcp\n```\n\nAny other host takes the same command (`npx -y @microtoll/mcp`) as a stdio\nserver.\n\n## The three tools\n\n| Tool | What it does |\n|---|---|\n| `microtoll_search_docs({ query, limit? })` | Full-text search over the documentation — the same pages as microtoll.dev, shipped in `generated/docs.json` by the repository's docs build so they never drift. Returns page, section and excerpt. |\n| `microtoll_read_doc({ path })` | One page as Markdown, by the `path` a search result gives (`packages/access.html`) or from `llms.txt`. |\n| `microtoll_scaffold({ directory, namespace?, origin?, name? })` | Writes the notes starter into an **empty** directory: `notes.js`, `page.js`, `index.html`, `docker-compose.yml`, `nginx.conf`, `package.json`, `README.md`, with the namespace and origin filled in. Writes files and nothing else; refuses a directory that is not empty; never overwrites. The next steps (`npm install`, `docker compose up`) come back as text for the person to run. |\n\n## What it speaks\n\nJSON-RPC 2.0, one message per line: `initialize` (protocol version\n`2025-06-18`, tools capability), `ping`, `tools/list`, `tools/call`;\nnotifications are acknowledged by silence; a tool's failure is a tool\nresult with `isError`, a protocol failure a JSON-RPC error. About 120 lines\nin `src/protocol.js`, tested against a transcript of what a host sends.\n\n## Tests\n\n`npm test`: the protocol over a real child process, the search and the\npage reader over the shipped snapshot, the scaffold into a temporary\ndirectory (placeholders filled, a non-empty directory refused).\n",
554
+ "sections": [
555
+ {
556
+ "heading": "@microtoll/mcp",
557
+ "level": 1,
558
+ "text": "Microtoll Engine inside your coding tool. A Model Context Protocol server\n(over standard input and output) that Claude Code, Cursor and any MCP host\ncan call for the engine's documentation and for a project scaffold, so an\nagent can wire the engine in without leaving the editor — and without\nleaving the machine: the pages are inside the package, nothing is fetched,\nnothing runs but what is in `src/`.\n\n**Status:** M5; not published (the publish gate, DECISIONS.md D-01).\nApache-2.0. Zero dependencies (D-39). Node 24 or later."
559
+ },
560
+ {
561
+ "heading": "Add it to a host",
562
+ "level": 2,
563
+ "text": "```sh\nclaude mcp add microtoll -- npx -y @microtoll/mcp\n```\n\nAny other host takes the same command (`npx -y @microtoll/mcp`) as a stdio\nserver."
564
+ },
565
+ {
566
+ "heading": "The three tools",
567
+ "level": 2,
568
+ "text": "| Tool | What it does |\n|---|---|\n| `microtoll_search_docs({ query, limit? })` | Full-text search over the documentation — the same pages as microtoll.dev, shipped in `generated/docs.json` by the repository's docs build so they never drift. Returns page, section and excerpt. |\n| `microtoll_read_doc({ path })` | One page as Markdown, by the `path` a search result gives (`packages/access.html`) or from `llms.txt`. |\n| `microtoll_scaffold({ directory, namespace?, origin?, name? })` | Writes the notes starter into an **empty** directory: `notes.js`, `page.js`, `index.html`, `docker-compose.yml`, `nginx.conf`, `package.json`, `README.md`, with the namespace and origin filled in. Writes files and nothing else; refuses a directory that is not empty; never overwrites. The next steps (`npm install`, `docker compose up`) come back as text for the person to run. |"
569
+ },
570
+ {
571
+ "heading": "What it speaks",
572
+ "level": 2,
573
+ "text": "JSON-RPC 2.0, one message per line: `initialize` (protocol version\n`2025-06-18`, tools capability), `ping`, `tools/list`, `tools/call`;\nnotifications are acknowledged by silence; a tool's failure is a tool\nresult with `isError`, a protocol failure a JSON-RPC error. About 120 lines\nin `src/protocol.js`, tested against a transcript of what a host sends."
574
+ },
575
+ {
576
+ "heading": "Tests",
577
+ "level": 2,
578
+ "text": "`npm test`: the protocol over a real child process, the search and the\npage reader over the shipped snapshot, the scaffold into a temporary\ndirectory (placeholders filled, a non-empty directory refused)."
579
+ }
580
+ ]
581
+ },
582
+ {
583
+ "path": "examples/notes-app.html",
584
+ "url": "https://microtoll.dev/examples/notes-app.html",
585
+ "title": "Notes — the first example",
586
+ "description": "End-to-end-encrypted notes with sharing and revocation, in ten minutes.",
587
+ "section": "Examples and deployment",
588
+ "markdown": "# Notes — the first example\n\nEnd-to-end-encrypted notes with sharing and revocation, on the unchanged\nblind-store reference server. Small enough to read in ten minutes:\n`notes.js` is the whole model (about 200 lines), `page.js` wires it to\nbuttons, and nothing else is the app's.\n\n## Run it\n\n```sh\ncd examples/notes-app\ndocker compose up\n```\n\nThen open <http://localhost:8088>. The first start builds the server image\nand pulls Postgres and nginx (a few minutes on a clean machine); the notes\nappear the moment nginx answers.\n\n1. **Boot**, then **Sign up with a recovery code** (works in every browser;\n the passkey button needs a browser with PRF support). Keep the code: it\n is the only way back in.\n2. **New note**, open it, write, **Save**.\n3. **Share**: copy the link. Open a private window as a second person, paste\n the link's address into the browser, boot, sign up, press **Open link**.\n The second person reads the note.\n4. As the second person, **join as member**. As the owner, **members**\n shows them (verified by their signature); **remove** rotates every key.\n5. As the owner, edit and save. The second person's copy is now stale:\n **heal** finds no new key for them — they were removed, and the old key\n opens nothing written since.\n\n`docker compose down -v` throws the database away.\n\n## What the server learns, honestly\n\n- **The shelf of every note** (one of 256, chosen at random when the note is\n made), and **which shelves each routing key asks for** — that is the\n cover-traffic trade: the server answers with every note on those shelves\n and cannot tell which are yours. With few users the crowd on a shelf is\n thin, and the server can guess well; with many it cannot. A real app picks\n a selector that is meaningful and coarse (a region and a day, say).\n- **One note id per link opened** (`fetch-event`), because redeeming a link\n needs the note's current key epoch.\n- **Sizes and timing**, the number of pointers an account holds, and that a\n routing key made a note today (the daily counter).\n\nIt does not learn a title, a body, a name, who owns what, who is a member of\nwhat, or a link's secret. `THREATMODEL.md` §6 in the repository root is the\nfull list.\n\n## Where things are\n\n- `notes.js` — the model: list (the shelf query), create, update, share,\n open, join, members, remove (rotation), heal, delete, watch.\n- `page.js` — the page, the identity session (`@microtoll/identity`) with\n prompts for the code and confirmations.\n- `docker-compose.yml`, `nginx.conf` — the three containers: Postgres with\n the engine schema, the reference server with the `notes` collection\n (`{\"notes\":{\"selectorLength\":2}}`), nginx serving this page and the\n packages by import map and forwarding `/ws`.\n- `test/notes.test.mjs` — the same model driven from Node against a real\n server and database (`node scripts/test-db.mjs up`, then `npm test`).\n\nThe packages load from `/packages/*/src` through an import map: no build\nstep, and exactly the files that will be published.\n",
589
+ "sections": [
590
+ {
591
+ "heading": "Notes — the first example",
592
+ "level": 1,
593
+ "text": "End-to-end-encrypted notes with sharing and revocation, on the unchanged\nblind-store reference server. Small enough to read in ten minutes:\n`notes.js` is the whole model (about 200 lines), `page.js` wires it to\nbuttons, and nothing else is the app's."
594
+ },
595
+ {
596
+ "heading": "Run it",
597
+ "level": 2,
598
+ "text": "```sh\ncd examples/notes-app\ndocker compose up\n```\n\nThen open <http://localhost:8088>. The first start builds the server image\nand pulls Postgres and nginx (a few minutes on a clean machine); the notes\nappear the moment nginx answers.\n\n1. **Boot**, then **Sign up with a recovery code** (works in every browser;\n the passkey button needs a browser with PRF support). Keep the code: it\n is the only way back in.\n2. **New note**, open it, write, **Save**.\n3. **Share**: copy the link. Open a private window as a second person, paste\n the link's address into the browser, boot, sign up, press **Open link**.\n The second person reads the note.\n4. As the second person, **join as member**. As the owner, **members**\n shows them (verified by their signature); **remove** rotates every key.\n5. As the owner, edit and save. The second person's copy is now stale:\n **heal** finds no new key for them — they were removed, and the old key\n opens nothing written since.\n\n`docker compose down -v` throws the database away."
599
+ },
600
+ {
601
+ "heading": "What the server learns, honestly",
602
+ "level": 2,
603
+ "text": "- **The shelf of every note** (one of 256, chosen at random when the note is\n made), and **which shelves each routing key asks for** — that is the\n cover-traffic trade: the server answers with every note on those shelves\n and cannot tell which are yours. With few users the crowd on a shelf is\n thin, and the server can guess well; with many it cannot. A real app picks\n a selector that is meaningful and coarse (a region and a day, say).\n- **One note id per link opened** (`fetch-event`), because redeeming a link\n needs the note's current key epoch.\n- **Sizes and timing**, the number of pointers an account holds, and that a\n routing key made a note today (the daily counter).\n\nIt does not learn a title, a body, a name, who owns what, who is a member of\nwhat, or a link's secret. `THREATMODEL.md` §6 in the repository root is the\nfull list."
604
+ },
605
+ {
606
+ "heading": "Where things are",
607
+ "level": 2,
608
+ "text": "- `notes.js` — the model: list (the shelf query), create, update, share,\n open, join, members, remove (rotation), heal, delete, watch.\n- `page.js` — the page, the identity session (`@microtoll/identity`) with\n prompts for the code and confirmations.\n- `docker-compose.yml`, `nginx.conf` — the three containers: Postgres with\n the engine schema, the reference server with the `notes` collection\n (`{\"notes\":{\"selectorLength\":2}}`), nginx serving this page and the\n packages by import map and forwarding `/ws`.\n- `test/notes.test.mjs` — the same model driven from Node against a real\n server and database (`node scripts/test-db.mjs up`, then `npm test`).\n\nThe packages load from `/packages/*/src` through an import map: no build\nstep, and exactly the files that will be published."
609
+ }
610
+ ]
611
+ },
612
+ {
613
+ "path": "examples/invite-app.html",
614
+ "url": "https://microtoll.dev/examples/invite-app.html",
615
+ "title": "Invites — the second example",
616
+ "description": "Inviting a known person directly, with nothing to forward.",
617
+ "section": "Examples and deployment",
618
+ "markdown": "# Invites — the second example\n\nWhat a share link cannot do: inviting a known person **directly**, with no\nlink and nothing to forward, through a mailbox only the two of you can\ncompute. Built on the notes example (`../notes-app/notes.js`) plus\n`@microtoll/mailbox`; `invites.js` is everything this example adds (about\n150 lines).\n\n## Run it\n\n```sh\ncd examples/invite-app\ndocker compose up\n```\n\nThen open <http://localhost:8089>, in two browsers or a normal and a private\nwindow, as Ada and Bea:\n\n1. Both: **Boot**, **Sign up**, type a name.\n2. Ada: **New note**, **share by link**; Bea pastes the link, **Open link**,\n then **join as member**.\n3. Both: **remember people** on that note. Each now holds the other's keys\n from the note's own roster — nothing was exchanged with the server.\n4. Ada: **New note**, then **invite Bea directly**. Bea's page is woken by\n the live watch and collects the invitation; the note appears with read\n access. Nobody made a link.\n5. Bea: **open** it — her app tells Ada it was seen. Ada: **invitations**\n shows \"collected, seen\".\n6. Ada: another note, **invite Bea directly**, then **take back** before\n Bea's page collects it. Bea's next collection finds nothing.\n\n`docker compose down -v` throws the database away.\n\n## What the server learns, honestly\n\n- **Mailbox labels**: a 32-byte value per pair per month, written under by\n one connection and polled by another. It cannot compute a label (that\n needs one of the two private keys), attribute one, or pair the writer and\n the reader without both keys.\n- **Which labels a routing key polls and watches** — one per contact per\n month, two months deep — so it learns how many people an account knows,\n not who.\n- **That a routing key sent a drop today** (the daily counter), and each\n drop's expiry.\n- Everything the notes example lists, since the notes are the same.\n\nIt does not learn a name, a note's key, who invited whom, or that an\ninvitation was seen (the acknowledgement is a drop like any other).\n\n## Where things are\n\n- `invites.js` — contacts from a roster, a direct invitation, collection,\n the acknowledgement on opening, withdrawal, the live watch.\n- `page.js`, `index.html` — the page.\n- `test/invites.test.mjs` — the story above, driven from Node against a real\n server and database (`node scripts/test-db.mjs up`, then `npm test`).\n",
619
+ "sections": [
620
+ {
621
+ "heading": "Invites — the second example",
622
+ "level": 1,
623
+ "text": "What a share link cannot do: inviting a known person **directly**, with no\nlink and nothing to forward, through a mailbox only the two of you can\ncompute. Built on the notes example (`../notes-app/notes.js`) plus\n`@microtoll/mailbox`; `invites.js` is everything this example adds (about\n150 lines)."
624
+ },
625
+ {
626
+ "heading": "Run it",
627
+ "level": 2,
628
+ "text": "```sh\ncd examples/invite-app\ndocker compose up\n```\n\nThen open <http://localhost:8089>, in two browsers or a normal and a private\nwindow, as Ada and Bea:\n\n1. Both: **Boot**, **Sign up**, type a name.\n2. Ada: **New note**, **share by link**; Bea pastes the link, **Open link**,\n then **join as member**.\n3. Both: **remember people** on that note. Each now holds the other's keys\n from the note's own roster — nothing was exchanged with the server.\n4. Ada: **New note**, then **invite Bea directly**. Bea's page is woken by\n the live watch and collects the invitation; the note appears with read\n access. Nobody made a link.\n5. Bea: **open** it — her app tells Ada it was seen. Ada: **invitations**\n shows \"collected, seen\".\n6. Ada: another note, **invite Bea directly**, then **take back** before\n Bea's page collects it. Bea's next collection finds nothing.\n\n`docker compose down -v` throws the database away."
629
+ },
630
+ {
631
+ "heading": "What the server learns, honestly",
632
+ "level": 2,
633
+ "text": "- **Mailbox labels**: a 32-byte value per pair per month, written under by\n one connection and polled by another. It cannot compute a label (that\n needs one of the two private keys), attribute one, or pair the writer and\n the reader without both keys.\n- **Which labels a routing key polls and watches** — one per contact per\n month, two months deep — so it learns how many people an account knows,\n not who.\n- **That a routing key sent a drop today** (the daily counter), and each\n drop's expiry.\n- Everything the notes example lists, since the notes are the same.\n\nIt does not learn a name, a note's key, who invited whom, or that an\ninvitation was seen (the acknowledgement is a drop like any other)."
634
+ },
635
+ {
636
+ "heading": "Where things are",
637
+ "level": 2,
638
+ "text": "- `invites.js` — contacts from a roster, a direct invitation, collection,\n the acknowledgement on opening, withdrawal, the live watch.\n- `page.js`, `index.html` — the page.\n- `test/invites.test.mjs` — the story above, driven from Node against a real\n server and database (`node scripts/test-db.mjs up`, then `npm test`)."
639
+ }
640
+ ]
641
+ },
642
+ {
643
+ "path": "deploy.html",
644
+ "url": "https://microtoll.dev/deploy.html",
645
+ "title": "Deploying blind-store",
646
+ "description": "The hardened Compose file, the Dockerfile and the Nginx sample.",
647
+ "section": "Examples and deployment",
648
+ "markdown": "# Deploying blind-store\n\nThe reference deployment: one Compose file, one Dockerfile, one Nginx\nsample. The server never connects as the database superuser, never runs as\nroot, and never keeps an access log.\n\n## Pieces\n\n| File | What it is |\n|---|---|\n| `docker-compose.yml` | Postgres 17 on the internal network only, with the engine schema and the service role's login applied at first start; the server built from this repository, running as `node`, read-only, no capabilities, with passwords from secret files. |\n| `Dockerfile` | `node:24-alpine`, the workspace's pinned `ws` and `pg`, the package, nothing else. |\n| `../packages/blind-store/schema/900_app_login.sh` | Gives `blind_store_app` its login from the secret at first start. |\n| `nginx.sample.conf` | TLS with the hybrid post-quantum group, the security headers, the WebSocket location with per-address limits and the client's address blanked, `/healthz`. |\n| `.env.example` | The namespace, the allowed origins, the collections. |\n\n## First start\n\n```sh\ncd deploy\ncp .env.example .env # edit: namespace, origins, collections\nmkdir -p secrets\nopenssl rand -base64 32 > secrets/postgres.password\nopenssl rand -base64 32 > secrets/blind_store_app.password\ndocker compose up -d\ncurl -s http://127.0.0.1:8020/healthz # ok\n```\n\nPut the reverse proxy in front (`nginx.sample.conf`, with your certificate),\nserving the app's static files and forwarding `/ws` to `127.0.0.1:8020`.\n\n## What is deliberately not here\n\n- No hosted service, no telemetry, no metrics endpoint: `/healthz` says `ok`\n or `unhealthy` and nothing else.\n- No access log at the proxy, no request log at the server. The server logs\n counts and reasons (a sweep total, a lost LISTEN connection) and never a\n message body or a routing key.\n- No client address reaches the server: the proxy blanks it, and the\n per-address limits live at the proxy.\n- No schema migration tooling. The schema runs once, on an empty data\n directory; a change to a table is a new init file for a new deployment or\n a migration you write, and the schema-conformance test\n (`packages/blind-store/test/schema.test.mjs`) is there to run against it.\n\n## Backups\n\nA database copy holds what THREATMODEL.md §6 lists: sealed blobs, hashed\ncapabilities, the selectors and windows, the counters for the last two days,\nand links and drops until the sweep removes them. It holds no key. Copy it\nwith the same care as the running database; there is nothing in it to\nredact.\n",
649
+ "sections": [
650
+ {
651
+ "heading": "Deploying blind-store",
652
+ "level": 1,
653
+ "text": "The reference deployment: one Compose file, one Dockerfile, one Nginx\nsample. The server never connects as the database superuser, never runs as\nroot, and never keeps an access log."
654
+ },
655
+ {
656
+ "heading": "Pieces",
657
+ "level": 2,
658
+ "text": "| File | What it is |\n|---|---|\n| `docker-compose.yml` | Postgres 17 on the internal network only, with the engine schema and the service role's login applied at first start; the server built from this repository, running as `node`, read-only, no capabilities, with passwords from secret files. |\n| `Dockerfile` | `node:24-alpine`, the workspace's pinned `ws` and `pg`, the package, nothing else. |\n| `../packages/blind-store/schema/900_app_login.sh` | Gives `blind_store_app` its login from the secret at first start. |\n| `nginx.sample.conf` | TLS with the hybrid post-quantum group, the security headers, the WebSocket location with per-address limits and the client's address blanked, `/healthz`. |\n| `.env.example` | The namespace, the allowed origins, the collections. |"
659
+ },
660
+ {
661
+ "heading": "First start",
662
+ "level": 2,
663
+ "text": "```sh\ncd deploy\ncp .env.example .env # edit: namespace, origins, collections\nmkdir -p secrets\nopenssl rand -base64 32 > secrets/postgres.password\nopenssl rand -base64 32 > secrets/blind_store_app.password\ndocker compose up -d\ncurl -s http://127.0.0.1:8020/healthz # ok\n```\n\nPut the reverse proxy in front (`nginx.sample.conf`, with your certificate),\nserving the app's static files and forwarding `/ws` to `127.0.0.1:8020`."
664
+ },
665
+ {
666
+ "heading": "What is deliberately not here",
667
+ "level": 2,
668
+ "text": "- No hosted service, no telemetry, no metrics endpoint: `/healthz` says `ok`\n or `unhealthy` and nothing else.\n- No access log at the proxy, no request log at the server. The server logs\n counts and reasons (a sweep total, a lost LISTEN connection) and never a\n message body or a routing key.\n- No client address reaches the server: the proxy blanks it, and the\n per-address limits live at the proxy.\n- No schema migration tooling. The schema runs once, on an empty data\n directory; a change to a table is a new init file for a new deployment or\n a migration you write, and the schema-conformance test\n (`packages/blind-store/test/schema.test.mjs`) is there to run against it."
669
+ },
670
+ {
671
+ "heading": "Backups",
672
+ "level": 2,
673
+ "text": "A database copy holds what THREATMODEL.md §6 lists: sealed blobs, hashed\ncapabilities, the selectors and windows, the counters for the last two days,\nand links and drops until the sweep removes them. It holds no key. Copy it\nwith the same care as the running database; there is nothing in it to\nredact."
674
+ }
675
+ ]
676
+ },
677
+ {
678
+ "path": "threat-model.html",
679
+ "url": "https://microtoll.dev/threat-model.html",
680
+ "title": "THREATMODEL.md — Microtoll Engine",
681
+ "description": "Adversaries, global limits, and what each package does and does not protect.",
682
+ "section": "The honest part",
683
+ "markdown": "# THREATMODEL.md — Microtoll Engine\n\n**Status:** each package's section was completed in its milestone, before\nthat package's API was reviewed; the formats each claim rests on are in the\npackages' `FORMATS.md` and `DESIGN.md`. A limit is stated as plainly as a\nprotection.\n\n## 1. Adversaries\n\n| ID | Adversary | Can do |\n|---|---|---|\n| A1 | **Server operator or database copy** | Reads every row, log and ciphertext; sees traffic timing and sizes; can drop, replay, reorder or roll back what it serves. It does not run the client. |\n| A2 | **Network observer** | Sees TLS metadata and timing (TLS itself is assumed). |\n| A3 | **Co-member** | Legitimately holds an object's key; sees other members' public keys and rows. |\n| A4 | **Removed member** | Holds the *old* keys and everything they saw before removal. |\n| A5 | **Link holder** | Holds a share link (and therefore the object key) without being a member. |\n| A6 | **Stranger with an account** | Can authenticate and send any well-formed message. |\n| A7 | **Harvest-now, decrypt-later** | A1's copy plus a future large quantum computer. |\n| A8 | **Compromised device or page** | Malicious script in the origin, malware, or someone holding the unlocked device or a copy of the browser profile. |\n\n## 2. Global limits (true of every package)\n\n- **A8 wins.** Script injection into the page, or a compromised device, is\n total compromise. A trusted-device session is exactly as safe as the unlocked\n device it sits on.\n- **Traffic shape is visible to A1 and A2.** This includes who connects and\n when, how many rows an account owns, ciphertext sizes (unpadded, in bands),\n and the plaintext coarse selector (area and date range) of every item. While\n few people use a deployment, one active account is easy to pick out.\n Hiding it would need mix-network routing, which is out of scope.\n- **A link is as private as the channel it is sent through.** The secret is\n in the URL fragment, which servers and link previewers do not receive. The\n messaging app it travels through can read it unless that chat is\n end-to-end encrypted.\n- **Nothing can be moderated in advance.** The server cannot read content, so\n abuse handling starts with a report from someone who can.\n- **No server-side recovery.** Lose every unlock method and the data is lost\n to everyone, including the operator.\n- **Quantum (A7).** Every public-key seal is classical (P-256) unless hybrid\n mode is on for every recipient. A copy of a database taken today could be\n opened by a large enough quantum computer, which does not yet exist. Hybrid\n mode protects only what is sealed after it is switched on, and an object is\n only as protected as its weakest member's copy of the key. Symmetric\n encryption (AES-256-GCM, HKDF, SHA-256) is not materially weakened.\n- **Cooperative, not cryptographic:** session-generation \"sign out\n everywhere\" and link withdrawal are honoured by honest clients. Neither can\n take back a key that has already left.\n\n## 3. `@microtoll/crypto-core`\n\n- **Protects:**\n - Confidentiality and integrity of sealed bytes against A1, A2 and A6: AES-256-GCM with a leading version byte; recipient-bound, version-authenticated ECIES v3 (P-256) and v2 (X-Wing hybrid).\n - Domain separation of every derived key by label.\n- **From whom:** A1, A2, A6. Against A7, only the v2 hybrid seal protects.\n- **Does not protect:**\n - Anything once the caller mishandles keys.\n - Metadata such as sizes.\n - Anything against A8.\n - Recovery-code secrecy beyond its 128 bits of entropy. PBKDF2 at 310,000 iterations is not memory-hard; the entropy carries the security.\n - Hybrid mode is not \"quantum-safe\" until every recipient uses it (§2).\n- **What each format refuses** (every row is a test in `packages/crypto-core/test/`):\n\n | Format | Refused, and how |\n |---|---|\n | AEAD v1 `[0x01][IV][ct‖tag]` | wrong key; any flipped bit in IV, ciphertext or tag; truncation; unknown version byte; additional data that differs or is missing — all fail at the GCM tag, before any plaintext is returned |\n | ECIES v3 `[0x03][ephemeral][AEAD]` | wrong recipient pair; the right private key with a swapped public half (the recipient is bound into the key); a relabelled version byte (authenticated as AAD); an ephemeral point off the curve; a blob from another namespace (the label is in the key); the retired v1 format, by name |\n | ECIES v2 `[0x02][KEM ct][AEAD]` | as v3, plus any flipped bit in the 1120-byte KEM ciphertext (the shared secret changes, then the tag fails); truncation; a v3 blob given to the v2 opener and the reverse |\n | Recovery code v3 (D-46) | wrong length, an invalid character, a failed check character, non-zero padding bits — each with an error `code`; **every** single wrong character and **every** swap of two different characters fails the check; a random typo passes it with probability 1/32 and then fails lookup, never opens another account. A version-2 code is read only when asked for by name |\n\n- **Misuse the API stops:** a bare private key where the pair is needed (`openWithPrivateKey`), a 32-byte \"recipient key\" (the retired X25519 length), a seed or key of the wrong length, a namespace missing or malformed, a retired label in any namespace, `hybridSealing` left off on a capable runtime (it stays classical; a capable browser never switches itself on).\n\n- **Key material in memory:**\n - AES-GCM keys derived by HKDF or PBKDF2, the imported sealing private key and the session key are **non-extractable** `CryptoKey`s.\n - Ed25519: crypto-core's `importEd25519PrivateKeyFromSeed` returns an **extractable** key, because Web Crypto offers no other way to read the public half. The identity package, which owns the root key's lifetime, reads the public half once and holds its working routing and signing keys **non-extractable** (identity `FORMATS.md` §2.6). The raw root secret and the HKDF seeds still exist as bytes in JavaScript.\n - The hybrid KEM private key object is extractable as `raw-seed` (its seed is the HKDF output the identity package already holds).\n\n- **Stability as a security property:** frozen fixtures (`test/fixtures/frozen-v1.json`, written once and never regenerated) must open and reproduce with every later version. A change that broke them would surface as a red test, not as silently unreadable data.\n\n## 4. `@microtoll/identity`\n\n- **Protects:**\n - The root key at rest against A1: wrapped only under a passkey PRF or a 128-bit recovery code; never stored in plaintext.\n - Unlinkability between the plaintext routing key and the person's identity keys against A1.\n - Independent unlock methods: removing one never locks out another, and the server refuses to remove the last.\n - Session key non-extractable by page script where the platform allows.\n - Step-up before sensitive changes.\n- **From whom:** A1, A2, A6. A3 learns only public identity keys.\n- **Does not protect:**\n - Against A8: the unlocked device, the browser profile, the root key and the HKDF seeds in page memory (the working Ed25519 keys are non-extractable, but the seed bytes they came from were in JavaScript).\n - The unauthenticated lookup, which returns the wrapped root key to anyone holding a credential id. That is safe only because the PRF output is secret.\n - The link between routing key and wrapped root key, which A1 can see (a locker number).\n - The existence and approximate age of accounts, and how often unlock methods changed (`session_generation`).\n - Step-up is client-side only.\n- **What each stored item is bound to (formats version 2, D-28, and the label in version 3, D-47; every row is a test in `packages/identity/test/`):**\n\n | Item | Bound to | So that |\n |---|---|---|\n | Wrapped root key | method type + SHA-256(credential id) or the recovery lookup hash | a blob cannot be presented under another row or on the other unlock path |\n | Identity blob | the routing public key; carries a `revision` | it cannot be moved between accounts; a rollback is refused on a device that saw a later revision (cooperative) |\n | Unlock-method label | the method: its type, and SHA-256(credential id) for a passkey (version 3, D-47) | it cannot be shown against another method, even another passkey of the same account, so a person removing a method is not misled about which one; nor moved between accounts (another `K_master_symm`) |\n | Trusted-device session | routing key, expiry, session generation | an edited expiry or generation in a copied profile fails to open; a stored routing key that disagrees with the derived one is refused |\n | Handshake signature | `\"<ns>/auth/v2\"`, SHA-256(origin), the nonce (D-29) | the signature is useless for another purpose, another deployment or another connection |\n\n- **The recovery code: loss versus theft.** Loss of every unlock method loses the account for everyone; there is no server-side recovery, by design. Theft of the code opens the account from anywhere: it is 128 bits of entropy behind PBKDF2 (310,000 iterations, not memory-hard), so the entropy carries the security and the code must be kept like a key. Rotating it (a fresh proof first) cancels every old code in one server transaction and stales every other device's session.\n- **Passkeys.** Used only as a PRF oracle; the server never sees or verifies an assertion, so a passkey's signature algorithm is irrelevant to the account's security. The \"synced\" flags an authenticator reports describe the kind of credential, not the live sync setting (measured on iOS), so the package reports `passkeyBackedUp` and the app must not claim more than \"the phone reports a synced kind\". A credential whose PRF is refused is disowned and never registered.\n- **Step-up** proves the person, not the device, before adding or removing a method, rotating the code or deleting the account; a five-minute grace after a proof is bound to the account it proved, so switching to another account in the same tab inherits nothing. It is client-side: the server cannot ask for more than the connection already proves.\n- **Deletion order and partial failure.** Confirm → prove → forget the session → the app's sweep of its own rows (while the capability secrets in its pointers still exist) → `delete-account` → the device's records. The sweep is the app's, best-effort step by step: a row it cannot reach is left, and the Privacy Notice must say what deletion does not reach (rows the account never held a secret for, sealed drops already in others' mailboxes, other members' decrypted copies). A failure before `delete-account` leaves the account intact and unlockable to finish the job.\n- **Tested behaviours that close known failure modes:** an unreadable identity blob stops the unlock and nothing is written over it (a corrupt blob can never be silently replaced by a fresh one); the step-up grace is bound to the account it proved.\n- **Still cooperative, stated plainly:** \"sign out everywhere\" (a generation counter honoured by honest clients), the blob revision (a device with no memory of a later revision accepts an older one), and the trusted session's expiry (enforced by the client that reads it, now with the expiry authenticated).\n\n## 5. `@microtoll/access`\n\n- **Protects:**\n - Object content against A1 and A6.\n - Second-tier payloads (two-tier disclosure) against anyone without a grant, including A3 and A5.\n - Authorship: a row whose signature fails is shown as unverified and never carries a name or identity.\n - A removed member cannot read writes made after rotation under the new key.\n - A revoked link cannot be redeemed again.\n- **From whom:** A1, A5, A6, and A4 after rotation.\n- **Does not protect:**\n - What A4 already saw, and anything written under the old key. Rotation protects the future only.\n - A5 holding the object key can read content and, by default, list named members through the derived read capability. **Where a link is posted is the real access control.**\n - Revoking a link does not remove access from anyone who already redeemed it; only removal (rotation) does.\n - Grant labels do not hide room membership from a link holder.\n - A1 can serve an older version of the same thing under the same key and epoch (an earlier edit): the additional authenticated data binds where a blob belongs, not which version it is.\n- **Tested behaviours that close known failure modes:** the admin capability is replaced on every rotation and carried in padded seats, so a removed co-owner keeps no admin power; one grant rule serves both the sweep and rotation, so a rotation cannot hand the second tier to members the sweep would not; the server refuses a rotation at the wrong epoch or one that does not name every active row, and refuses content and row writes at the wrong epoch; a row that cannot have come from an honest client is set aside and never sealed to. Re-sealed invitations and withdrawn drops belong to the mailbox package (M3b).\n\n- **What each format binds (version 2, D-31; every row is a test in `packages/access/test/`):**\n\n | Item | Bound to | So that |\n |---|---|---|\n | Member-row signature | `\"<ns>/sig/member-row/v2\"`, object id, row id | a row cannot be lifted into another row or object and still verify |\n | Member-row seal | object id, row id (AAD) | a row's ciphertext cannot be presented under another row id, quiet and unsigned rows included |\n | Content, second tier | object id, epoch (AAD) | content cannot be moved between objects; a reader can assert the epoch it was told |\n | Pointer | the account's routing key (AAD; D-37) | a pointer cannot be moved to another account or confused with another blob under `K_master_symm`; the object it names is inside it, since the server returns pointers without an id |\n | Share-link payload | the token hash (AAD) and, when signed, `\"<ns>/sig/share-link/v2\"` + the token hash | a payload cannot be served under another link's hash; a signed payload cannot be re-wrapped in a fresh link as its creator's |\n | Admin box | object id, epoch (AAD) | a box from another object or epoch does not open |\n | Sealed copy of `K_object` per member | the recipient key and version (ECIES v3/v2) — **no additional data** | a server that moves it to another of the same member's rows gains nothing the member could not do; binding it would need an ECIES v4 |\n\n- **The adversarial suite, mapped to the build plan's four requirements:**\n - *A removed member's old key cannot read post-rotation writes:* the new content, second tier and every re-sealed row and key are opened by remaining members and refused to the removed one; the server refuses the removed member's row write (`unauthorized`), a remaining member's write at the old epoch (`stale`), the old admin secret (`unauthorized`), a plan on the old epoch or missing a row (`stale`); a removed member's old signed row substituted under a remaining member's id is set aside, never sealed to.\n - *A revoked link cannot be redeemed:* `not-found` after revoke; `expired`; `exhausted` after N uses; stats only with the management secret; a recipient cannot revoke.\n - *An already-redeemed link is unaffected by revoke:* the holder still reads and lists the roster; and is cut off by a later rotation.\n - *A forged signature is flagged unverified:* a bit-flipped signature, a row lifted to another row id, a member signing with their own key while claiming another's; the display rule shows no name, status or keys for such a row; a quiet row carrying identity claims stays quiet, never verified.\n - *Holding the object never yields the second tier:* a `K_object`-only holder and a link holder get the preview only; the sweep grants \"going\" and admins, not \"interested\"; after a rotation only the owner, admins, `grantDue` rows and earlier grant holders open the new second tier; grant labels are object-scoped; a device holding only the preview cannot rotate a two-tier object.\n\n- **Stated limits:** a link holder can list named members through the derived read capability unless the app's server enforces a responders-only roster; where a link is posted is the real access control. Rotation protects the future only. A quiet member's per-object key lives in their pointer; losing the pointer loses the object.\n\n## 6. `@microtoll/blind-store`\n\n- **Protects:**\n - The server holds only what it cannot read: opaque blobs, pointer rows with no object id, hashed capabilities, and hashed link tokens.\n - No identity columns, no creator columns, and no timestamps unless needed for expiry.\n - Random UUID keys.\n - Authorisation by capability, so writes do not reveal who made them.\n- **From whom:** A1 (content), A6 (unauthorised writes).\n- **Does not protect:**\n - The accepted trades, each to be listed exactly:\n - the coarse selector and window per item;\n - which selectors a connection queried;\n - the routing key × action × day in rate limits;\n - pointer counts per account;\n - timing and sizes;\n - push endpoints joining the subscriptions of one browser (if push is enabled).\n - Availability against A1.\n - Denial of service beyond the transport limits below.\n- **Completed in M4** (`packages/blind-store/DESIGN.md`, D-33 to D-36):\n - **What the server learns, exactly** (the trades above, spelled out). Per object: its collection, its selector and its window, its epoch, whether its roster is members-only, and the sizes of its sealed parts. Per connection: the routing key; the `Origin`; which selectors and windows it queried and watched, and when; one object id per `fetch-event` (a link redeemed, a pointer healed); which link hashes it redeemed, revoked or asked stats for; which mailbox labels it polled or watched. Per account: how many pointers and unlock methods it holds; its session generation and blob token (what changed, never when); and, in `rate_limit_counters`, that it made an object, a link or a drop today — the one table with a routing key beside an action, deleted after two days. Per link: uses, expiry, and its management hash. Everything else is ciphertext or a hash.\n - **What a database copy holds:** the above, plus sealed link payloads and drops until the sweep empties or deletes them (a used-up or expired link's payload at once; the link a week after expiry; a drop at expiry). Objects are never swept: what to keep is the app's decision. No key of any kind is in the database.\n - **The client obligations for cover traffic:** decrypt only what its pointers hold keys for; query the whole area it shows at a precision it fixes; never query by a list of ids; keep `fetch-event` for redeeming and healing. The engine cannot check these; the access package's `queryObjects` and the example app follow them.\n - **The schema rules as a test:** `test/schema.test.mjs` fails on a non-random primary key outside the whitelist, a sequence, a timestamp other than the two expiries, an identity-named column, a reference from objects, members, links or drops to `users`, or a hash column without a 32-byte check. A host runs it against its own tables.\n - **The database role:** the server connects as `blind_store_app`, which can read and write the eight tables and run the sweep, and can do no DDL, make no role and reach no other schema — so a compromise of the server process is a compromise of what the server can already read, and nothing more.\n - **The bound handshake (server half):** a signature made for another origin, another namespace or another nonce is refused; a non-browser client is verified against each allowed origin in turn; a browser's `Origin` is checked at upgrade and used for the verification.\n - **Transport limits:** frame 4 MiB, sign-in within 120 s, 300 messages per socket refilled 60 a second, 2,000 sockets, three unlock lookups per socket, per-field caps, a 5,000-row query answer. Each fails closed for one connection or request only. The per-address limits and the blanking of the client's address are the reverse proxy's (`deploy/nginx.sample.conf`): the server never holds an address beside a routing key.\n - **Live watches leak nothing new:** routing is by the selector a connection already sent; a member-row change is pushed content-free and debounced; the imminent watch carries no parameter at all.\n - **Availability** is not protected against A1 or against a determined flood: the limits are backstops, the daily counters fail open, and a lost LISTEN connection costs live updates (loudly logged, retried), not the service.\n\n## 7. `@microtoll/mailbox` (M3b, built inside M5)\n\n- **Protects:**\n - A1 cannot compute a label (that needs one of the two private keys), attribute a row to an account, or read a bundle (sealed to the recipient, hybrid when the recipient's key is). Labels change monthly, so a stable polling fingerprint lasts at most two months.\n - A6 cannot forge an invitation from a named person: the bundle is signed, and (version 2, `packages/mailbox/FORMATS.md` §2) the signature binds the mailbox it is for and the recipient it is sealed to, so a bundle re-sealed into another mailbox or for another recipient verifies for nobody. The collection-side check — the signer must be the contact whose mailbox it arrived in — stays as a second lock.\n - Withdrawal: a consumed or withdrawn drop is served without its bundle and emptied in the row, so a client that ignores the flag still finds nothing to open.\n- **From whom:** A1 (content and attribution), A6 (forgery), A3 (the mailbox needs the address, and the address needs a key only a co-member holds).\n- **Does not protect:**\n - The routing graph: A1 sees that one anonymous label was written under and then read; with the connection's routing key, that this account polls these labels. It cannot pair the two ends without both private keys.\n - Linkage is classical (P-256 ECDH). Under A7, a former co-member could recover who invites whom. There is no standard post-quantum non-interactive key exchange; the bundle itself is hybrid where the recipient's key is.\n - Withdrawal reaches only a drop not yet collected; a collected key has left.\n - An unsigned drop is usable: the key in it either works or it does not. The engine attributes it to nobody; what the app does with it (a tray, a hold) is the app's.\n - Who is a contact, who is a favourite, who is blocked: the app's, in its identity blob.\n- **Trades:** the labels a connection polls and watches (one per contact per month, two months deep); the daily count of drops per routing key; the row's expiry.\n\n## 8. Review triggers\n\nUpdate this document when any of these happen:\n- a new format or label;\n- a new plaintext column;\n- a new pre-authentication message;\n- a change to a default (selector precision, session length, iteration count);\n- hybrid mode switched on;\n- a browser shipping or withdrawing a primitive the engine relies on.\n",
684
+ "sections": [
685
+ {
686
+ "heading": "THREATMODEL.md — Microtoll Engine",
687
+ "level": 1,
688
+ "text": "**Status:** each package's section was completed in its milestone, before\nthat package's API was reviewed; the formats each claim rests on are in the\npackages' `FORMATS.md` and `DESIGN.md`. A limit is stated as plainly as a\nprotection."
689
+ },
690
+ {
691
+ "heading": "1. Adversaries",
692
+ "level": 2,
693
+ "text": "| ID | Adversary | Can do |\n|---|---|---|\n| A1 | **Server operator or database copy** | Reads every row, log and ciphertext; sees traffic timing and sizes; can drop, replay, reorder or roll back what it serves. It does not run the client. |\n| A2 | **Network observer** | Sees TLS metadata and timing (TLS itself is assumed). |\n| A3 | **Co-member** | Legitimately holds an object's key; sees other members' public keys and rows. |\n| A4 | **Removed member** | Holds the *old* keys and everything they saw before removal. |\n| A5 | **Link holder** | Holds a share link (and therefore the object key) without being a member. |\n| A6 | **Stranger with an account** | Can authenticate and send any well-formed message. |\n| A7 | **Harvest-now, decrypt-later** | A1's copy plus a future large quantum computer. |\n| A8 | **Compromised device or page** | Malicious script in the origin, malware, or someone holding the unlocked device or a copy of the browser profile. |"
694
+ },
695
+ {
696
+ "heading": "2. Global limits (true of every package)",
697
+ "level": 2,
698
+ "text": "- **A8 wins.** Script injection into the page, or a compromised device, is\n total compromise. A trusted-device session is exactly as safe as the unlocked\n device it sits on.\n- **Traffic shape is visible to A1 and A2.** This includes who connects and\n when, how many rows an account owns, ciphertext sizes (unpadded, in bands),\n and the plaintext coarse selector (area and date range) of every item. While\n few people use a deployment, one active account is easy to pick out.\n Hiding it would need mix-network routing, which is out of scope.\n- **A link is as private as the channel it is sent through.** The secret is\n in the URL fragment, which servers and link previewers do not receive. The\n messaging app it travels through can read it unless that chat is\n end-to-end encrypted.\n- **Nothing can be moderated in advance.** The server cannot read content, so\n abuse handling starts with a report from someone who can.\n- **No server-side recovery.** Lose every unlock method and the data is lost\n to everyone, including the operator.\n- **Quantum (A7).** Every public-key seal is classical (P-256) unless hybrid\n mode is on for every recipient. A copy of a database taken today could be\n opened by a large enough quantum computer, which does not yet exist. Hybrid\n mode protects only what is sealed after it is switched on, and an object is\n only as protected as its weakest member's copy of the key. Symmetric\n encryption (AES-256-GCM, HKDF, SHA-256) is not materially weakened.\n- **Cooperative, not cryptographic:** session-generation \"sign out\n everywhere\" and link withdrawal are honoured by honest clients. Neither can\n take back a key that has already left."
699
+ },
700
+ {
701
+ "heading": "3. @microtoll/crypto-core",
702
+ "level": 2,
703
+ "text": "- **Protects:**\n - Confidentiality and integrity of sealed bytes against A1, A2 and A6: AES-256-GCM with a leading version byte; recipient-bound, version-authenticated ECIES v3 (P-256) and v2 (X-Wing hybrid).\n - Domain separation of every derived key by label.\n- **From whom:** A1, A2, A6. Against A7, only the v2 hybrid seal protects.\n- **Does not protect:**\n - Anything once the caller mishandles keys.\n - Metadata such as sizes.\n - Anything against A8.\n - Recovery-code secrecy beyond its 128 bits of entropy. PBKDF2 at 310,000 iterations is not memory-hard; the entropy carries the security.\n - Hybrid mode is not \"quantum-safe\" until every recipient uses it (§2).\n- **What each format refuses** (every row is a test in `packages/crypto-core/test/`):\n\n | Format | Refused, and how |\n |---|---|\n | AEAD v1 `[0x01][IV][ct‖tag]` | wrong key; any flipped bit in IV, ciphertext or tag; truncation; unknown version byte; additional data that differs or is missing — all fail at the GCM tag, before any plaintext is returned |\n | ECIES v3 `[0x03][ephemeral][AEAD]` | wrong recipient pair; the right private key with a swapped public half (the recipient is bound into the key); a relabelled version byte (authenticated as AAD); an ephemeral point off the curve; a blob from another namespace (the label is in the key); the retired v1 format, by name |\n | ECIES v2 `[0x02][KEM ct][AEAD]` | as v3, plus any flipped bit in the 1120-byte KEM ciphertext (the shared secret changes, then the tag fails); truncation; a v3 blob given to the v2 opener and the reverse |\n | Recovery code v3 (D-46) | wrong length, an invalid character, a failed check character, non-zero padding bits — each with an error `code`; **every** single wrong character and **every** swap of two different characters fails the check; a random typo passes it with probability 1/32 and then fails lookup, never opens another account. A version-2 code is read only when asked for by name |\n\n- **Misuse the API stops:** a bare private key where the pair is needed (`openWithPrivateKey`), a 32-byte \"recipient key\" (the retired X25519 length), a seed or key of the wrong length, a namespace missing or malformed, a retired label in any namespace, `hybridSealing` left off on a capable runtime (it stays classical; a capable browser never switches itself on).\n\n- **Key material in memory:**\n - AES-GCM keys derived by HKDF or PBKDF2, the imported sealing private key and the session key are **non-extractable** `CryptoKey`s.\n - Ed25519: crypto-core's `importEd25519PrivateKeyFromSeed` returns an **extractable** key, because Web Crypto offers no other way to read the public half. The identity package, which owns the root key's lifetime, reads the public half once and holds its working routing and signing keys **non-extractable** (identity `FORMATS.md` §2.6). The raw root secret and the HKDF seeds still exist as bytes in JavaScript.\n - The hybrid KEM private key object is extractable as `raw-seed` (its seed is the HKDF output the identity package already holds).\n\n- **Stability as a security property:** frozen fixtures (`test/fixtures/frozen-v1.json`, written once and never regenerated) must open and reproduce with every later version. A change that broke them would surface as a red test, not as silently unreadable data."
704
+ },
705
+ {
706
+ "heading": "4. @microtoll/identity",
707
+ "level": 2,
708
+ "text": "- **Protects:**\n - The root key at rest against A1: wrapped only under a passkey PRF or a 128-bit recovery code; never stored in plaintext.\n - Unlinkability between the plaintext routing key and the person's identity keys against A1.\n - Independent unlock methods: removing one never locks out another, and the server refuses to remove the last.\n - Session key non-extractable by page script where the platform allows.\n - Step-up before sensitive changes.\n- **From whom:** A1, A2, A6. A3 learns only public identity keys.\n- **Does not protect:**\n - Against A8: the unlocked device, the browser profile, the root key and the HKDF seeds in page memory (the working Ed25519 keys are non-extractable, but the seed bytes they came from were in JavaScript).\n - The unauthenticated lookup, which returns the wrapped root key to anyone holding a credential id. That is safe only because the PRF output is secret.\n - The link between routing key and wrapped root key, which A1 can see (a locker number).\n - The existence and approximate age of accounts, and how often unlock methods changed (`session_generation`).\n - Step-up is client-side only.\n- **What each stored item is bound to (formats version 2, D-28, and the label in version 3, D-47; every row is a test in `packages/identity/test/`):**\n\n | Item | Bound to | So that |\n |---|---|---|\n | Wrapped root key | method type + SHA-256(credential id) or the recovery lookup hash | a blob cannot be presented under another row or on the other unlock path |\n | Identity blob | the routing public key; carries a `revision` | it cannot be moved between accounts; a rollback is refused on a device that saw a later revision (cooperative) |\n | Unlock-method label | the method: its type, and SHA-256(credential id) for a passkey (version 3, D-47) | it cannot be shown against another method, even another passkey of the same account, so a person removing a method is not misled about which one; nor moved between accounts (another `K_master_symm`) |\n | Trusted-device session | routing key, expiry, session generation | an edited expiry or generation in a copied profile fails to open; a stored routing key that disagrees with the derived one is refused |\n | Handshake signature | `\"<ns>/auth/v2\"`, SHA-256(origin), the nonce (D-29) | the signature is useless for another purpose, another deployment or another connection |\n\n- **The recovery code: loss versus theft.** Loss of every unlock method loses the account for everyone; there is no server-side recovery, by design. Theft of the code opens the account from anywhere: it is 128 bits of entropy behind PBKDF2 (310,000 iterations, not memory-hard), so the entropy carries the security and the code must be kept like a key. Rotating it (a fresh proof first) cancels every old code in one server transaction and stales every other device's session.\n- **Passkeys.** Used only as a PRF oracle; the server never sees or verifies an assertion, so a passkey's signature algorithm is irrelevant to the account's security. The \"synced\" flags an authenticator reports describe the kind of credential, not the live sync setting (measured on iOS), so the package reports `passkeyBackedUp` and the app must not claim more than \"the phone reports a synced kind\". A credential whose PRF is refused is disowned and never registered.\n- **Step-up** proves the person, not the device, before adding or removing a method, rotating the code or deleting the account; a five-minute grace after a proof is bound to the account it proved, so switching to another account in the same tab inherits nothing. It is client-side: the server cannot ask for more than the connection already proves.\n- **Deletion order and partial failure.** Confirm → prove → forget the session → the app's sweep of its own rows (while the capability secrets in its pointers still exist) → `delete-account` → the device's records. The sweep is the app's, best-effort step by step: a row it cannot reach is left, and the Privacy Notice must say what deletion does not reach (rows the account never held a secret for, sealed drops already in others' mailboxes, other members' decrypted copies). A failure before `delete-account` leaves the account intact and unlockable to finish the job.\n- **Tested behaviours that close known failure modes:** an unreadable identity blob stops the unlock and nothing is written over it (a corrupt blob can never be silently replaced by a fresh one); the step-up grace is bound to the account it proved.\n- **Still cooperative, stated plainly:** \"sign out everywhere\" (a generation counter honoured by honest clients), the blob revision (a device with no memory of a later revision accepts an older one), and the trusted session's expiry (enforced by the client that reads it, now with the expiry authenticated)."
709
+ },
710
+ {
711
+ "heading": "5. @microtoll/access",
712
+ "level": 2,
713
+ "text": "- **Protects:**\n - Object content against A1 and A6.\n - Second-tier payloads (two-tier disclosure) against anyone without a grant, including A3 and A5.\n - Authorship: a row whose signature fails is shown as unverified and never carries a name or identity.\n - A removed member cannot read writes made after rotation under the new key.\n - A revoked link cannot be redeemed again.\n- **From whom:** A1, A5, A6, and A4 after rotation.\n- **Does not protect:**\n - What A4 already saw, and anything written under the old key. Rotation protects the future only.\n - A5 holding the object key can read content and, by default, list named members through the derived read capability. **Where a link is posted is the real access control.**\n - Revoking a link does not remove access from anyone who already redeemed it; only removal (rotation) does.\n - Grant labels do not hide room membership from a link holder.\n - A1 can serve an older version of the same thing under the same key and epoch (an earlier edit): the additional authenticated data binds where a blob belongs, not which version it is.\n- **Tested behaviours that close known failure modes:** the admin capability is replaced on every rotation and carried in padded seats, so a removed co-owner keeps no admin power; one grant rule serves both the sweep and rotation, so a rotation cannot hand the second tier to members the sweep would not; the server refuses a rotation at the wrong epoch or one that does not name every active row, and refuses content and row writes at the wrong epoch; a row that cannot have come from an honest client is set aside and never sealed to. Re-sealed invitations and withdrawn drops belong to the mailbox package (M3b).\n\n- **What each format binds (version 2, D-31; every row is a test in `packages/access/test/`):**\n\n | Item | Bound to | So that |\n |---|---|---|\n | Member-row signature | `\"<ns>/sig/member-row/v2\"`, object id, row id | a row cannot be lifted into another row or object and still verify |\n | Member-row seal | object id, row id (AAD) | a row's ciphertext cannot be presented under another row id, quiet and unsigned rows included |\n | Content, second tier | object id, epoch (AAD) | content cannot be moved between objects; a reader can assert the epoch it was told |\n | Pointer | the account's routing key (AAD; D-37) | a pointer cannot be moved to another account or confused with another blob under `K_master_symm`; the object it names is inside it, since the server returns pointers without an id |\n | Share-link payload | the token hash (AAD) and, when signed, `\"<ns>/sig/share-link/v2\"` + the token hash | a payload cannot be served under another link's hash; a signed payload cannot be re-wrapped in a fresh link as its creator's |\n | Admin box | object id, epoch (AAD) | a box from another object or epoch does not open |\n | Sealed copy of `K_object` per member | the recipient key and version (ECIES v3/v2) — **no additional data** | a server that moves it to another of the same member's rows gains nothing the member could not do; binding it would need an ECIES v4 |\n\n- **The adversarial suite, mapped to the build plan's four requirements:**\n - *A removed member's old key cannot read post-rotation writes:* the new content, second tier and every re-sealed row and key are opened by remaining members and refused to the removed one; the server refuses the removed member's row write (`unauthorized`), a remaining member's write at the old epoch (`stale`), the old admin secret (`unauthorized`), a plan on the old epoch or missing a row (`stale`); a removed member's old signed row substituted under a remaining member's id is set aside, never sealed to.\n - *A revoked link cannot be redeemed:* `not-found` after revoke; `expired`; `exhausted` after N uses; stats only with the management secret; a recipient cannot revoke.\n - *An already-redeemed link is unaffected by revoke:* the holder still reads and lists the roster; and is cut off by a later rotation.\n - *A forged signature is flagged unverified:* a bit-flipped signature, a row lifted to another row id, a member signing with their own key while claiming another's; the display rule shows no name, status or keys for such a row; a quiet row carrying identity claims stays quiet, never verified.\n - *Holding the object never yields the second tier:* a `K_object`-only holder and a link holder get the preview only; the sweep grants \"going\" and admins, not \"interested\"; after a rotation only the owner, admins, `grantDue` rows and earlier grant holders open the new second tier; grant labels are object-scoped; a device holding only the preview cannot rotate a two-tier object.\n\n- **Stated limits:** a link holder can list named members through the derived read capability unless the app's server enforces a responders-only roster; where a link is posted is the real access control. Rotation protects the future only. A quiet member's per-object key lives in their pointer; losing the pointer loses the object."
714
+ },
715
+ {
716
+ "heading": "6. @microtoll/blind-store",
717
+ "level": 2,
718
+ "text": "- **Protects:**\n - The server holds only what it cannot read: opaque blobs, pointer rows with no object id, hashed capabilities, and hashed link tokens.\n - No identity columns, no creator columns, and no timestamps unless needed for expiry.\n - Random UUID keys.\n - Authorisation by capability, so writes do not reveal who made them.\n- **From whom:** A1 (content), A6 (unauthorised writes).\n- **Does not protect:**\n - The accepted trades, each to be listed exactly:\n - the coarse selector and window per item;\n - which selectors a connection queried;\n - the routing key × action × day in rate limits;\n - pointer counts per account;\n - timing and sizes;\n - push endpoints joining the subscriptions of one browser (if push is enabled).\n - Availability against A1.\n - Denial of service beyond the transport limits below.\n- **Completed in M4** (`packages/blind-store/DESIGN.md`, D-33 to D-36):\n - **What the server learns, exactly** (the trades above, spelled out). Per object: its collection, its selector and its window, its epoch, whether its roster is members-only, and the sizes of its sealed parts. Per connection: the routing key; the `Origin`; which selectors and windows it queried and watched, and when; one object id per `fetch-event` (a link redeemed, a pointer healed); which link hashes it redeemed, revoked or asked stats for; which mailbox labels it polled or watched. Per account: how many pointers and unlock methods it holds; its session generation and blob token (what changed, never when); and, in `rate_limit_counters`, that it made an object, a link or a drop today — the one table with a routing key beside an action, deleted after two days. Per link: uses, expiry, and its management hash. Everything else is ciphertext or a hash.\n - **What a database copy holds:** the above, plus sealed link payloads and drops until the sweep empties or deletes them (a used-up or expired link's payload at once; the link a week after expiry; a drop at expiry). Objects are never swept: what to keep is the app's decision. No key of any kind is in the database.\n - **The client obligations for cover traffic:** decrypt only what its pointers hold keys for; query the whole area it shows at a precision it fixes; never query by a list of ids; keep `fetch-event` for redeeming and healing. The engine cannot check these; the access package's `queryObjects` and the example app follow them.\n - **The schema rules as a test:** `test/schema.test.mjs` fails on a non-random primary key outside the whitelist, a sequence, a timestamp other than the two expiries, an identity-named column, a reference from objects, members, links or drops to `users`, or a hash column without a 32-byte check. A host runs it against its own tables.\n - **The database role:** the server connects as `blind_store_app`, which can read and write the eight tables and run the sweep, and can do no DDL, make no role and reach no other schema — so a compromise of the server process is a compromise of what the server can already read, and nothing more.\n - **The bound handshake (server half):** a signature made for another origin, another namespace or another nonce is refused; a non-browser client is verified against each allowed origin in turn; a browser's `Origin` is checked at upgrade and used for the verification.\n - **Transport limits:** frame 4 MiB, sign-in within 120 s, 300 messages per socket refilled 60 a second, 2,000 sockets, three unlock lookups per socket, per-field caps, a 5,000-row query answer. Each fails closed for one connection or request only. The per-address limits and the blanking of the client's address are the reverse proxy's (`deploy/nginx.sample.conf`): the server never holds an address beside a routing key.\n - **Live watches leak nothing new:** routing is by the selector a connection already sent; a member-row change is pushed content-free and debounced; the imminent watch carries no parameter at all.\n - **Availability** is not protected against A1 or against a determined flood: the limits are backstops, the daily counters fail open, and a lost LISTEN connection costs live updates (loudly logged, retried), not the service."
719
+ },
720
+ {
721
+ "heading": "7. @microtoll/mailbox (M3b, built inside M5)",
722
+ "level": 2,
723
+ "text": "- **Protects:**\n - A1 cannot compute a label (that needs one of the two private keys), attribute a row to an account, or read a bundle (sealed to the recipient, hybrid when the recipient's key is). Labels change monthly, so a stable polling fingerprint lasts at most two months.\n - A6 cannot forge an invitation from a named person: the bundle is signed, and (version 2, `packages/mailbox/FORMATS.md` §2) the signature binds the mailbox it is for and the recipient it is sealed to, so a bundle re-sealed into another mailbox or for another recipient verifies for nobody. The collection-side check — the signer must be the contact whose mailbox it arrived in — stays as a second lock.\n - Withdrawal: a consumed or withdrawn drop is served without its bundle and emptied in the row, so a client that ignores the flag still finds nothing to open.\n- **From whom:** A1 (content and attribution), A6 (forgery), A3 (the mailbox needs the address, and the address needs a key only a co-member holds).\n- **Does not protect:**\n - The routing graph: A1 sees that one anonymous label was written under and then read; with the connection's routing key, that this account polls these labels. It cannot pair the two ends without both private keys.\n - Linkage is classical (P-256 ECDH). Under A7, a former co-member could recover who invites whom. There is no standard post-quantum non-interactive key exchange; the bundle itself is hybrid where the recipient's key is.\n - Withdrawal reaches only a drop not yet collected; a collected key has left.\n - An unsigned drop is usable: the key in it either works or it does not. The engine attributes it to nobody; what the app does with it (a tray, a hold) is the app's.\n - Who is a contact, who is a favourite, who is blocked: the app's, in its identity blob.\n- **Trades:** the labels a connection polls and watches (one per contact per month, two months deep); the daily count of drops per routing key; the row's expiry."
724
+ },
725
+ {
726
+ "heading": "8. Review triggers",
727
+ "level": 2,
728
+ "text": "Update this document when any of these happen:\n- a new format or label;\n- a new plaintext column;\n- a new pre-authentication message;\n- a change to a default (selector precision, session length, iteration count);\n- hybrid mode switched on;\n- a browser shipping or withdrawing a primitive the engine relies on."
729
+ }
730
+ ]
731
+ },
732
+ {
733
+ "path": "honest-limits.html",
734
+ "url": "https://microtoll.dev/honest-limits.html",
735
+ "title": "The honest limits",
736
+ "description": "What nothing here protects against, on one page.",
737
+ "section": "The honest part",
738
+ "markdown": "# The honest limits\n\nWhat nothing here protects against, on one page, in plain words. Every\npackage's page repeats its own part; the\n[threat model](threat-model.html) has the adversaries and the reasoning.\n\n## A compromised device or page wins\n\nIf a script runs inside your page — an injected one, a bad dependency, a\nbrowser extension with the wrong permissions — or the device itself is\ncompromised, everything the page holds is gone: the root key, the keys to\nevery object, the session. A trusted-device session is exactly as safe as\nthe unlocked device it sits on. This is why the examples ship with a strict\ncontent security policy and no third-party script, and why the engine has\nno dependencies to carry one in.\n\n## Traffic shape is visible\n\nThe server, and anyone who can watch the wire, sees who connects and when,\nhow many rows an account holds, the sizes of ciphertexts, and the coarse\nselector of every object (a map cell and a day in an events app; a shelf\nin the notes example). While few people use a deployment, one active account is easy to\npick out. Hiding this would need mix-network routing, which is out of scope.\n\n## A link is as private as the channel it travels through\n\nThe secret is in the URL fragment, which servers and link previewers do not\nreceive. The messaging app the link is sent through can read it unless that\nchat is end-to-end encrypted. Where a link is posted is the real access\ncontrol.\n\n## Nothing can be moderated in advance\n\nThe server cannot read content, so abuse handling starts with a report from\nsomeone who can. The engine ships no reporting; an app that needs it builds its own.\n\n## No server-side recovery\n\nLose every unlock method — the passkey and the recovery code — and the data\nis lost to everyone, including the operator. There is no reset link because\nthere is nothing for one to reset.\n\n## Quantum computers, honestly\n\nEvery public-key seal is classical (P-256) unless hybrid mode is on for\nevery recipient. A copy of a database taken today could be opened by a large\nenough quantum computer, which does not yet exist. Hybrid mode protects only\nwhat is sealed after it is switched on, and an object is only as protected\nas its weakest member's copy of the key. Symmetric encryption (AES-256-GCM,\nHKDF, SHA-256) is not materially weakened. The mailbox label is classical\nby necessity: there is no standard post-quantum non-interactive key\nexchange.\n\n## Cooperative, not cryptographic\n\n\"Sign out everywhere\" and withdrawing a link are honoured by honest clients.\nNeither can take back a key that has already left. Removal re-keys the\nfuture; it cannot un-read the past.\n\n## What the server does learn\n\nPer object: its collection, selector and window, its epoch, whether its\nroster is members-only, the sizes of its parts. Per connection: the routing\nkey, the origin, which selectors it queried and watched, one object id per\nlink redeemed, which link hashes and mailbox labels it touched. Per account:\nhow many pointers and unlock methods it holds, and that it made an object, a\nlink or a drop today (deleted after two days). Everything else is ciphertext\nor a hash. The full list, and what a database copy holds, is in the\n[threat model](threat-model.html#6-microtollblind-store).\n",
739
+ "sections": [
740
+ {
741
+ "heading": "The honest limits",
742
+ "level": 1,
743
+ "text": "What nothing here protects against, on one page, in plain words. Every\npackage's page repeats its own part; the\n[threat model](threat-model.html) has the adversaries and the reasoning."
744
+ },
745
+ {
746
+ "heading": "A compromised device or page wins",
747
+ "level": 2,
748
+ "text": "If a script runs inside your page — an injected one, a bad dependency, a\nbrowser extension with the wrong permissions — or the device itself is\ncompromised, everything the page holds is gone: the root key, the keys to\nevery object, the session. A trusted-device session is exactly as safe as\nthe unlocked device it sits on. This is why the examples ship with a strict\ncontent security policy and no third-party script, and why the engine has\nno dependencies to carry one in."
749
+ },
750
+ {
751
+ "heading": "Traffic shape is visible",
752
+ "level": 2,
753
+ "text": "The server, and anyone who can watch the wire, sees who connects and when,\nhow many rows an account holds, the sizes of ciphertexts, and the coarse\nselector of every object (a map cell and a day in an events app; a shelf\nin the notes example). While few people use a deployment, one active account is easy to\npick out. Hiding this would need mix-network routing, which is out of scope."
754
+ },
755
+ {
756
+ "heading": "A link is as private as the channel it travels through",
757
+ "level": 2,
758
+ "text": "The secret is in the URL fragment, which servers and link previewers do not\nreceive. The messaging app the link is sent through can read it unless that\nchat is end-to-end encrypted. Where a link is posted is the real access\ncontrol."
759
+ },
760
+ {
761
+ "heading": "Nothing can be moderated in advance",
762
+ "level": 2,
763
+ "text": "The server cannot read content, so abuse handling starts with a report from\nsomeone who can. The engine ships no reporting; an app that needs it builds its own."
764
+ },
765
+ {
766
+ "heading": "No server-side recovery",
767
+ "level": 2,
768
+ "text": "Lose every unlock method — the passkey and the recovery code — and the data\nis lost to everyone, including the operator. There is no reset link because\nthere is nothing for one to reset."
769
+ },
770
+ {
771
+ "heading": "Quantum computers, honestly",
772
+ "level": 2,
773
+ "text": "Every public-key seal is classical (P-256) unless hybrid mode is on for\nevery recipient. A copy of a database taken today could be opened by a large\nenough quantum computer, which does not yet exist. Hybrid mode protects only\nwhat is sealed after it is switched on, and an object is only as protected\nas its weakest member's copy of the key. Symmetric encryption (AES-256-GCM,\nHKDF, SHA-256) is not materially weakened. The mailbox label is classical\nby necessity: there is no standard post-quantum non-interactive key\nexchange."
774
+ },
775
+ {
776
+ "heading": "Cooperative, not cryptographic",
777
+ "level": 2,
778
+ "text": "\"Sign out everywhere\" and withdrawing a link are honoured by honest clients.\nNeither can take back a key that has already left. Removal re-keys the\nfuture; it cannot un-read the past."
779
+ },
780
+ {
781
+ "heading": "What the server does learn",
782
+ "level": 2,
783
+ "text": "Per object: its collection, selector and window, its epoch, whether its\nroster is members-only, the sizes of its parts. Per connection: the routing\nkey, the origin, which selectors it queried and watched, one object id per\nlink redeemed, which link hashes and mailbox labels it touched. Per account:\nhow many pointers and unlock methods it holds, and that it made an object, a\nlink or a drop today (deleted after two days). Everything else is ciphertext\nor a hash. The full list, and what a database copy holds, is in the\n[threat model](threat-model.html#6-microtollblind-store)."
784
+ }
785
+ ]
786
+ },
787
+ {
788
+ "path": "formats-and-stability.html",
789
+ "url": "https://microtoll.dev/formats-and-stability.html",
790
+ "title": "Formats and stability",
791
+ "description": "The promise about bytes, the version numbers, and how a format is allowed to change.",
792
+ "section": "The honest part",
793
+ "markdown": "# Formats and stability\n\nTwo promises, stated separately, because they are different things.\n\n## The bytes never change meaning\n\nFrom the first published version: no version byte, label, key-derivation\nparameter or signed-byte layout ever changes meaning, and readers for every\npublished format stay supported. **Anything you encrypt with any published\nversion will decrypt with every later one.**\n\nA format is a versioned thing. The AEAD blob starts with `0x01`; the\nclassical seal with `0x03`, the hybrid seal with `0x02`; a signed member row\nis `{ v: 3 }`; a mailbox bundle `{ v: 2 }`. A new format is a new version\nbyte beside the old one, never a change to the old one. Every label the\nengine derives a key or a context from is `<namespace>/<purpose>/v<n>`, and\nthree labels of retired formats are refused in every namespace so they can\nnever be reused by accident.\n\nHow a format is allowed to change: as a **pending decision** in the\nrepository's `DECISIONS.md`, with the reasoning, decided by the maintainer\nbefore any code, and recorded with the date. The record of every format\ndecision so far is on the [decisions](decisions.html) page; the formats\nthemselves are in each package's formats page ([identity](packages/identity-formats.html),\n[access](packages/access-formats.html), [mailbox](packages/mailbox-formats.html)).\n\n## The API may change until 1.0\n\nFunction names and options may change in any 0.x minor release. Every such\nchange is listed in the package's `CHANGELOG.md` with a migration note; patch\nreleases never break. The packages move in lockstep: one engine version to\npin, one to name in a bug report.\n\n## What a fixture is\n\nA fixture is a set of bytes an earlier version wrote — a sealed blob, a\nsignature, a derived key — frozen in the repository and never regenerated.\nEvery later version must open and reproduce it, so a changed label, version\nbyte, parameter or layout turns a test red instead of making stored data\nsilently unreadable. A new format gets new fixtures beside the old ones.\nPublished test vectors (RFC 5869, RFC 8032, RFC 5903, RFC 7914, NIST's\nAES-GCM, the X-Wing drafts) run through the public API, never through a\nre-implementation in a test file.\n",
794
+ "sections": [
795
+ {
796
+ "heading": "Formats and stability",
797
+ "level": 1,
798
+ "text": "Two promises, stated separately, because they are different things."
799
+ },
800
+ {
801
+ "heading": "The bytes never change meaning",
802
+ "level": 2,
803
+ "text": "From the first published version: no version byte, label, key-derivation\nparameter or signed-byte layout ever changes meaning, and readers for every\npublished format stay supported. **Anything you encrypt with any published\nversion will decrypt with every later one.**\n\nA format is a versioned thing. The AEAD blob starts with `0x01`; the\nclassical seal with `0x03`, the hybrid seal with `0x02`; a signed member row\nis `{ v: 3 }`; a mailbox bundle `{ v: 2 }`. A new format is a new version\nbyte beside the old one, never a change to the old one. Every label the\nengine derives a key or a context from is `<namespace>/<purpose>/v<n>`, and\nthree labels of retired formats are refused in every namespace so they can\nnever be reused by accident.\n\nHow a format is allowed to change: as a **pending decision** in the\nrepository's `DECISIONS.md`, with the reasoning, decided by the maintainer\nbefore any code, and recorded with the date. The record of every format\ndecision so far is on the [decisions](decisions.html) page; the formats\nthemselves are in each package's formats page ([identity](packages/identity-formats.html),\n[access](packages/access-formats.html), [mailbox](packages/mailbox-formats.html))."
804
+ },
805
+ {
806
+ "heading": "The API may change until 1.0",
807
+ "level": 2,
808
+ "text": "Function names and options may change in any 0.x minor release. Every such\nchange is listed in the package's `CHANGELOG.md` with a migration note; patch\nreleases never break. The packages move in lockstep: one engine version to\npin, one to name in a bug report."
809
+ },
810
+ {
811
+ "heading": "What a fixture is",
812
+ "level": 2,
813
+ "text": "A fixture is a set of bytes an earlier version wrote — a sealed blob, a\nsignature, a derived key — frozen in the repository and never regenerated.\nEvery later version must open and reproduce it, so a changed label, version\nbyte, parameter or layout turns a test red instead of making stored data\nsilently unreadable. A new format gets new fixtures beside the old ones.\nPublished test vectors (RFC 5869, RFC 8032, RFC 5903, RFC 7914, NIST's\nAES-GCM, the X-Wing drafts) run through the public API, never through a\nre-implementation in a test file."
814
+ }
815
+ ]
816
+ },
817
+ {
818
+ "path": "for-agents.html",
819
+ "url": "https://microtoll.dev/for-agents.html",
820
+ "title": "For AI coding agents (and the people using them)",
821
+ "description": "llms.txt, the MCP server and the scaffold: wiring the engine in from inside a coding tool.",
822
+ "section": "For agents",
823
+ "markdown": "# For AI coding agents (and the people using them)\n\nThe engine exists for two audiences, and the second one writes most new\ncode now. Three things make it usable from inside a coding tool without a\nbrowser tab open.\n\n## llms.txt\n\n<https://microtoll.dev/llms.txt> is the index: one line per page with what\nit covers, following the llms.txt convention. <https://microtoll.dev/llms-full.txt>\nis every page concatenated, as Markdown, for a tool that wants the whole\nthing in context (about the size of a long article). Both are generated\nfrom the same sources as this site, so they never drift from it.\n\n## The MCP server\n\n`@microtoll/mcp` speaks the Model Context Protocol over standard input and\noutput, so Claude Code, Cursor and any MCP host can call it. Zero\ndependencies; no network; nothing runs but what you see in `src/`.\n\nAdd it to a host (Claude Code shown; the others take the same command):\n\n```sh\nclaude mcp add microtoll -- npx -y @microtoll/mcp\n```\n\nThree tools:\n\n- `microtoll_search_docs({ query })` — full-text search over these pages,\n returning the page, the section and an excerpt.\n- `microtoll_read_doc({ path })` — one page as Markdown (the `path` from a\n search result, or from llms.txt).\n- `microtoll_scaffold({ directory, namespace, origin })` — writes the notes\n starter into an **empty** directory: the client (`notes.js`, `page.js`,\n `index.html`), a Compose file that runs Postgres, the server and nginx\n from the published packages, and a README of next steps. It writes files\n and nothing else: no commands run, no network, and it refuses a directory\n that is not empty.\n\n## What an agent should know before writing security code with this\n\n- **Do not add cryptography.** Every construction needed is here, with its\n threat model. If a task seems to need a new primitive, mode or key\n derivation, the answer is a question to a person, not code.\n- **The server stores only what it cannot read.** A column that could name\n a person, a plaintext timestamp, a sequential id: the schema test will\n fail, and it should.\n- **The namespace is one string, used twice**: in `createCryptoCore` on the\n client and in `BLIND_STORE_NAMESPACE` on the server. Sign-in is bound to\n it and to the page's origin.\n- **Say what is not protected.** Copy the [honest limits](honest-limits.html)\n into the app's own about page. That is not optional.\n",
824
+ "sections": [
825
+ {
826
+ "heading": "For AI coding agents (and the people using them)",
827
+ "level": 1,
828
+ "text": "The engine exists for two audiences, and the second one writes most new\ncode now. Three things make it usable from inside a coding tool without a\nbrowser tab open."
829
+ },
830
+ {
831
+ "heading": "llms.txt",
832
+ "level": 2,
833
+ "text": "<https://microtoll.dev/llms.txt> is the index: one line per page with what\nit covers, following the llms.txt convention. <https://microtoll.dev/llms-full.txt>\nis every page concatenated, as Markdown, for a tool that wants the whole\nthing in context (about the size of a long article). Both are generated\nfrom the same sources as this site, so they never drift from it."
834
+ },
835
+ {
836
+ "heading": "The MCP server",
837
+ "level": 2,
838
+ "text": "`@microtoll/mcp` speaks the Model Context Protocol over standard input and\noutput, so Claude Code, Cursor and any MCP host can call it. Zero\ndependencies; no network; nothing runs but what you see in `src/`.\n\nAdd it to a host (Claude Code shown; the others take the same command):\n\n```sh\nclaude mcp add microtoll -- npx -y @microtoll/mcp\n```\n\nThree tools:\n\n- `microtoll_search_docs({ query })` — full-text search over these pages,\n returning the page, the section and an excerpt.\n- `microtoll_read_doc({ path })` — one page as Markdown (the `path` from a\n search result, or from llms.txt).\n- `microtoll_scaffold({ directory, namespace, origin })` — writes the notes\n starter into an **empty** directory: the client (`notes.js`, `page.js`,\n `index.html`), a Compose file that runs Postgres, the server and nginx\n from the published packages, and a README of next steps. It writes files\n and nothing else: no commands run, no network, and it refuses a directory\n that is not empty."
839
+ },
840
+ {
841
+ "heading": "What an agent should know before writing security code with this",
842
+ "level": 2,
843
+ "text": "- **Do not add cryptography.** Every construction needed is here, with its\n threat model. If a task seems to need a new primitive, mode or key\n derivation, the answer is a question to a person, not code.\n- **The server stores only what it cannot read.** A column that could name\n a person, a plaintext timestamp, a sequential id: the schema test will\n fail, and it should.\n- **The namespace is one string, used twice**: in `createCryptoCore` on the\n client and in `BLIND_STORE_NAMESPACE` on the server. Sign-in is bound to\n it and to the page's origin.\n- **Say what is not protected.** Copy the [honest limits](honest-limits.html)\n into the app's own about page. That is not optional."
844
+ }
845
+ ]
846
+ },
847
+ {
848
+ "path": "contributing.html",
849
+ "url": "https://microtoll.dev/contributing.html",
850
+ "title": "Contributing",
851
+ "description": "The rules, the sign-off, and what a pull request needs.",
852
+ "section": "Project",
853
+ "markdown": "# Contributing\n\nMicrotoll Engine will face security review and a funded audit, so the rules\nbelow exist to keep it readable and its cryptography unchanged. They apply\nto every contribution, the maintainer's included.\n\n## The rules\n\n1. **No new cryptography.** The constructions are fixed, verified by\n published test vectors and pinned by frozen fixtures. A change to a\n primitive, a mode, a label, a version byte, a key-derivation parameter\n or a signed-byte layout is not a pull request: it is a **pending decision**\n in `DECISIONS.md`, with the reasoning and a recommendation, for the\n maintainer to decide first. Adding a test vector or a fixture is fine.\n2. **Zero runtime dependencies** in the browser packages. The server package\n has exactly `ws` and `pg`, pinned; a new dependency anywhere needs a\n justification in the pull request and the maintainer's decision.\n3. **The server stores only what it cannot read.** No identity column, no\n plaintext content, random UUID keys, no timestamp unless something\n functionally needs it. `packages/blind-store/test/schema.test.mjs`\n enforces the rules against a live database; a change that fails it is a\n design question, not a test to loosen.\n4. **Nothing financial, no telemetry, no hosted-service code**, anywhere.\n5. **Honest claims.** Post-quantum protection is \"hybrid mode\"; every\n package's README and `THREATMODEL.md` say what is *not* protected. A\n change to the attack surface updates the threat model in the same pull\n request.\n6. **Written to be read.** Every non-obvious choice gets a comment naming\n the test vector, RFC or threat it addresses. Plain language, UK English.\n\n## Sign-off (DCO)\n\nEvery commit carries a `Signed-off-by:` line with your real name and email\n(`git commit -s`), certifying the [Developer Certificate of Origin](https://developercertificate.org/):\nthat you wrote the change or have the right to submit it under the file's\nlicence (Apache-2.0 for the browser packages and examples, AGPL-3.0-only for\n`blind-store`, CC-BY-4.0 for documentation).\n\n## Working on it\n\n```\nnpm install # TypeScript for the declaration check; ws and pg for blind-store\nnpm run check # typecheck + every suite\nnpm run db:up # a throwaway Postgres 17 in Docker for the database-backed suites\nnpm run docs # builds the docs site and llms.txt into docs/site/\n```\n\n- Tests use Node's built-in `node:test`, no framework, and drive each\n package through its **public API** — never a re-implementation in the\n test file. A frozen fixture pins every format.\n- Each package keeps a `CHANGELOG.md`.\n- The repository is self-contained: it names no other product.\n\n## What a pull request needs\n\nTests; docs updated (README, `CHANGELOG.md`, `FORMATS.md` where a format is\ntouched); the threat model updated if the attack surface changed; the\ndeviations section updated; `npm run check` green with the database-backed\nsuites running. A pull request that changes a format without a recorded\ndecision will be closed with a pointer to `DECISIONS.md`, kindly.\n\n## Conduct\n\nBe direct and be kind. Security findings are welcome and are handled\nthrough `SECURITY.md`, never through a public issue.\n",
854
+ "sections": [
855
+ {
856
+ "heading": "Contributing",
857
+ "level": 1,
858
+ "text": "Microtoll Engine will face security review and a funded audit, so the rules\nbelow exist to keep it readable and its cryptography unchanged. They apply\nto every contribution, the maintainer's included."
859
+ },
860
+ {
861
+ "heading": "The rules",
862
+ "level": 2,
863
+ "text": "1. **No new cryptography.** The constructions are fixed, verified by\n published test vectors and pinned by frozen fixtures. A change to a\n primitive, a mode, a label, a version byte, a key-derivation parameter\n or a signed-byte layout is not a pull request: it is a **pending decision**\n in `DECISIONS.md`, with the reasoning and a recommendation, for the\n maintainer to decide first. Adding a test vector or a fixture is fine.\n2. **Zero runtime dependencies** in the browser packages. The server package\n has exactly `ws` and `pg`, pinned; a new dependency anywhere needs a\n justification in the pull request and the maintainer's decision.\n3. **The server stores only what it cannot read.** No identity column, no\n plaintext content, random UUID keys, no timestamp unless something\n functionally needs it. `packages/blind-store/test/schema.test.mjs`\n enforces the rules against a live database; a change that fails it is a\n design question, not a test to loosen.\n4. **Nothing financial, no telemetry, no hosted-service code**, anywhere.\n5. **Honest claims.** Post-quantum protection is \"hybrid mode\"; every\n package's README and `THREATMODEL.md` say what is *not* protected. A\n change to the attack surface updates the threat model in the same pull\n request.\n6. **Written to be read.** Every non-obvious choice gets a comment naming\n the test vector, RFC or threat it addresses. Plain language, UK English."
864
+ },
865
+ {
866
+ "heading": "Sign-off (DCO)",
867
+ "level": 2,
868
+ "text": "Every commit carries a `Signed-off-by:` line with your real name and email\n(`git commit -s`), certifying the [Developer Certificate of Origin](https://developercertificate.org/):\nthat you wrote the change or have the right to submit it under the file's\nlicence (Apache-2.0 for the browser packages and examples, AGPL-3.0-only for\n`blind-store`, CC-BY-4.0 for documentation)."
869
+ },
870
+ {
871
+ "heading": "Working on it",
872
+ "level": 2,
873
+ "text": "```\nnpm install # TypeScript for the declaration check; ws and pg for blind-store\nnpm run check # typecheck + every suite\nnpm run db:up # a throwaway Postgres 17 in Docker for the database-backed suites\nnpm run docs # builds the docs site and llms.txt into docs/site/\n```\n\n- Tests use Node's built-in `node:test`, no framework, and drive each\n package through its **public API** — never a re-implementation in the\n test file. A frozen fixture pins every format.\n- Each package keeps a `CHANGELOG.md`.\n- The repository is self-contained: it names no other product."
874
+ },
875
+ {
876
+ "heading": "What a pull request needs",
877
+ "level": 2,
878
+ "text": "Tests; docs updated (README, `CHANGELOG.md`, `FORMATS.md` where a format is\ntouched); the threat model updated if the attack surface changed; the\ndeviations section updated; `npm run check` green with the database-backed\nsuites running. A pull request that changes a format without a recorded\ndecision will be closed with a pointer to `DECISIONS.md`, kindly."
879
+ },
880
+ {
881
+ "heading": "Conduct",
882
+ "level": 2,
883
+ "text": "Be direct and be kind. Security findings are welcome and are handled\nthrough `SECURITY.md`, never through a public issue."
884
+ }
885
+ ]
886
+ },
887
+ {
888
+ "path": "security.html",
889
+ "url": "https://microtoll.dev/security.html",
890
+ "title": "Security policy",
891
+ "description": "How to report a weakness and what to expect.",
892
+ "section": "Project",
893
+ "markdown": "# Security policy\n\nMicrotoll Engine is security software: sign-in, key handling, access control\nand revocation for end-to-end-encrypted apps. If you have found a weakness\nin it, thank you for reading this first.\n\n## Reporting\n\n- Write to **security@microtoll.dev**. Never open a public issue for a\n security problem.\n- Say which package and version, what you did, what happened, and what you\n expected. A proof of concept is welcome; user data is not — please do not\n include anyone's real content or keys.\n- If you want your report encrypted, ask for the current key in a first,\n content-free message; the key's fingerprint is published in this file once\n the project is public.\n\n## What to expect\n\n- An acknowledgement within **seven days**.\n- A fix, or a written statement of why there will not be one, within\n **ninety days** of the report, sooner where the fix is simple. Where a fix\n changes a stored format, the format rules (DECISIONS.md D-04) still apply:\n readers for every published format stay supported.\n- Credit in the release notes, if you want it. There is no bounty: nothing\n in this project is financial, by rule.\n- Coordinated disclosure: we ask that you give us the ninety days before\n publishing, and we will tell you the release date in advance.\n\n## Scope\n\n**In scope:** the packages under `packages/` (`crypto-core`, `identity`,\n`access`, `mailbox`, `blind-store`, `mcp`), the reference deployment under\n`deploy/`, the examples, and the documents that describe what they protect\n(`THREATMODEL.md` and each package's `FORMATS.md`). A gap between what the\nthreat model claims and what the code does is in scope even if nothing is\n\"exploited\".\n\n**Out of scope:** applications built on the engine (report to their\nowners), the docs site's hosting, and the limits the threat model\nalready states (`THREATMODEL.md` §2: traffic shape, a compromised device,\nscript injection into a page that holds keys).\n\n## Verifying a release\n\nRelease tags are signed with the maintainer's key; the public key is\npublished here when the first release is made, and every published package\ncarries npm provenance linking it to the tagged commit and the workflow that\nbuilt it.\n",
894
+ "sections": [
895
+ {
896
+ "heading": "Security policy",
897
+ "level": 1,
898
+ "text": "Microtoll Engine is security software: sign-in, key handling, access control\nand revocation for end-to-end-encrypted apps. If you have found a weakness\nin it, thank you for reading this first."
899
+ },
900
+ {
901
+ "heading": "Reporting",
902
+ "level": 2,
903
+ "text": "- Write to **security@microtoll.dev**. Never open a public issue for a\n security problem.\n- Say which package and version, what you did, what happened, and what you\n expected. A proof of concept is welcome; user data is not — please do not\n include anyone's real content or keys.\n- If you want your report encrypted, ask for the current key in a first,\n content-free message; the key's fingerprint is published in this file once\n the project is public."
904
+ },
905
+ {
906
+ "heading": "What to expect",
907
+ "level": 2,
908
+ "text": "- An acknowledgement within **seven days**.\n- A fix, or a written statement of why there will not be one, within\n **ninety days** of the report, sooner where the fix is simple. Where a fix\n changes a stored format, the format rules (DECISIONS.md D-04) still apply:\n readers for every published format stay supported.\n- Credit in the release notes, if you want it. There is no bounty: nothing\n in this project is financial, by rule.\n- Coordinated disclosure: we ask that you give us the ninety days before\n publishing, and we will tell you the release date in advance."
909
+ },
910
+ {
911
+ "heading": "Scope",
912
+ "level": 2,
913
+ "text": "**In scope:** the packages under `packages/` (`crypto-core`, `identity`,\n`access`, `mailbox`, `blind-store`, `mcp`), the reference deployment under\n`deploy/`, the examples, and the documents that describe what they protect\n(`THREATMODEL.md` and each package's `FORMATS.md`). A gap between what the\nthreat model claims and what the code does is in scope even if nothing is\n\"exploited\".\n\n**Out of scope:** applications built on the engine (report to their\nowners), the docs site's hosting, and the limits the threat model\nalready states (`THREATMODEL.md` §2: traffic shape, a compromised device,\nscript injection into a page that holds keys)."
914
+ },
915
+ {
916
+ "heading": "Verifying a release",
917
+ "level": 2,
918
+ "text": "Release tags are signed with the maintainer's key; the public key is\npublished here when the first release is made, and every published package\ncarries npm provenance linking it to the tagged commit and the workflow that\nbuilt it."
919
+ }
920
+ ]
921
+ },
922
+ {
923
+ "path": "decisions.html",
924
+ "url": "https://microtoll.dev/decisions.html",
925
+ "title": "DECISIONS.md — founder decisions log",
926
+ "description": "The founder's decisions: every crypto, licensing and scope choice with its reasoning.",
927
+ "section": "Project",
928
+ "markdown": "# DECISIONS.md — founder decisions log\n\n**Append-only.** A decision is recorded by adding a dated entry under\n\"Recorded\"; it is never edited afterwards, only superseded by a later entry\nthat names it. Open questions sit under \"Pending\" with a recommendation until\nthe founder decides. Claude Code never resolves a crypto, licensing or IP\nquestion silently (build plan §4).\n\nStanding rules are in `microtoll-build-plan.md` §0 (the non-negotiables) and\nare not repeated here.\n\n**2026-09-27: this log was restated so that it names no other product** (the\nfounder's direction, recorded below). Each decision keeps its number, its\ndate and its substance; the earlier wording is in the git history. Two\nentries, D-10 and D-22, were not engine decisions and were withdrawn from\nthis log; their numbers are not reused.\n\n---\n\n## Recorded\n\n**2026-09-27 — `pqc-scan` v0.1.0 tagged and signed; trusted publishing set.**\nThe founder made the release signing key (`~/.ssh/microtoll-release`,\nEd25519, passphrase-protected; launch item 3), registered its public half\non GitHub as a signing key, and pushed the signed tag `v0.1.0`\n(`3e7c05c`), which GitHub verifies as valid. On npm, the package trusts\n`release.yml` for **staged** publishing only, npm's recommended setting: a\ntag stages a version with provenance and the founder approves it by hand,\nso a compromised build run can never publish alone (`pqc-scan` `5414d9d`).\nPublishing access requires two-factor authentication with no bypass\ntokens. Provenance starts with the next version; 0.1.0 was published by\nhand.\n\n**2026-09-27 — `@microtoll/pqc-scan` 0.1.0 is on the npm registry.** The\nfirst public release of anything from this project. Published by the\nfounder from their own machine (`npm publish --access public`, two-factor\nauthentication by passkey), from `pqc-scan` `5955777`; shasum\n`41000bdca199ed0b115b40e5c483201538741b68`; 22 files, 73.5 kB. Verified from\nthe registry: `npx @microtoll/pqc-scan --version` prints 0.1.0 and a scan\nwrites its two reports. The first publish attempt showed that npm removes a\n`bin` path written with a leading `./`, which would have left the package\nwith no command; fixed before publishing. The npm organisation `microtoll`\nwas already owned by the founder's account (`npm org ls`). Still to do for\nthis release: trusted publishing on npmjs.com for `release.yml`, the signing\nkey, and the signed tag `v0.1.0` (the workflow skips a version already\npublished). Provenance therefore starts with the next version.\n\n**2026-09-27 — `pqc-scan` is public.** `github.com/microtoll/pqc-scan` was\nmade public at `19eec51` (version 0.1.0, no longer private, a release\nworkflow that publishes `@microtoll/pqc-scan` with provenance on a `v*`\ntag). Its history names no other product. Waiting on the founder: the\nsigning key (launch item 3), the `@microtoll` npm organisation with trusted\npublishing for this workflow (item 4), and then the signed tag `v0.1.0`,\nwhich publishes. The engine stays private until its own launch items.\n\n**2026-09-27 — D-01 passed: the publish gate is OPEN. M6 accepted.** The\nfounder confirmed, through the question tool, that both checks of D-01 have\npassed: who owns the code the engine is built from, and what the founder's\nemployment terms require. Recorded on the founder's word; the evidence and\nany approval that was needed stay in the founder's private notes, outside\nthis repository. From this entry, a public repository, a registry and a\npublic listing are allowed for both the engine and `pqc-scan`; the launch\nchecklist (`docs/LAUNCH.md`) governs the order. The history is still never\nrewritten. **M6 is accepted** on the same day: the founder ran the scanner\non their own application from a fresh clone on a Windows PC and read the\nreport, which completes the last acceptance item (the founder's reading);\nthat run also led to the terminal default, `--test-files` and the Windows\nlauncher (`pqc-scan` `6fac933`). The founder's choice for the first public\nstep: `pqc-scan` goes public and to npm together, as `@microtoll/pqc-scan`\n0.1.0.\n\n**2026-09-27 — M6: the three public repositories reviewed by hand; the\nscanner corrected.** The founder asked for three to be proposed and run.\nChosen for three shapes, each a shallow clone read once: a JSON Web Token\nlibrary where every finding should be genuine (`panva/jose` at `55c959f`), a\nlarge browser application with almost no cryptography (`excalidraw/excalidraw`\nat `84e3f5a`), and a server application with a typical sign-in stack\n(`requarks/wiki` at `712a3a5`). Every reported finding was a genuine\ncryptographic use, and a hand search of jose's source and of Excalidraw\nfound nothing missed. Seven faults around the findings were found, each\nfixed in `pqc-scan` with a fixture and a test (its `DESIGN.md` §8.12):\ndependency versions printed as `\\1.0.6` (an invalid Markdown escape, in\nevery report with a lockfile); a package listed as a transitive dependency of\nitself (jose; the engine's own report had likewise listed\n`@microtoll/crypto-core`); `test-d` type tests not marked as test code; two\n\"could not be read\" pointers that led only to token decoding and header\nextraction; the RSA key pair that signs Wiki.js's tokens reported High under\nthe comment \"Generate certificates\" (a certificate is a signing artefact:\nMedium); Wiki.js's SAML sign-in, encrypted assertions and two-factor codes\nunreported because their libraries were not catalogued (eight packages\nadded, 57 in all); and the SHA-1 note, which told both applications to\nreplace a plain content identifier as if it protected something. 50 tests\npass. The engine's own report is unchanged apart from its dependency line\n(its own package is not a dependency). The reports, before and after, are in\nthe founder's private acceptance notes. Still open for M6: the founder's\nreading of the reports (the engine's, the second application's and these\nthree), which is the acceptance.\n\n**2026-09-27 — M6: the second application scanned; the public repository\nwill start from a snapshot.** Two founder's choices.\n- **The second real application** (DESIGN §6, second item) was chosen by the\n founder and scanned read-only; the report and the hand review are in the\n founder's private notes, because they describe that application. Every\n expectation of the item was met. The review found two scanner faults,\n fixed in `pqc-scan` `ab5332f` with a fixture and a test each: a WebAuthn\n key reported High (it only verifies signatures: Medium), and a source file\n skipped as binary because of a raw control character far down (binary now\n means a NUL in the first 8,000 bytes). The first item still passes on this\n repository. Still open for M6: three public repositories for the\n false-positive review, and the founder's reading of both reports.\n- **At the publish gate**, this repository stays private as the dated\n record, and the public repository starts from a snapshot of the tree, with\n a first commit that says where the private history is kept. Nothing is\n rewritten (D-01's evidence rule). `docs/LAUNCH.md` item 7 says so.\n\n**2026-09-27 — D-46 and D-47 decided: option (a) of each, version 3.** The\nfounder's choice, and built the same day.\n- **D-46:** new recovery codes are version 3. The check character is\n Σ aⁱ⁺¹·sᵢ over the 26 data characters in GF(32) = GF(2)[x]/(x⁵ + x² + 1),\n a = x, and the parser refuses a code whose two unused bits are not zero or\n that has more than 26 data characters. Tests prove every single wrong\n character and every swap of two different characters is refused.\n- **D-47:** an unlock method's sealed label is bound to that method:\n `frameContext(\"<ns>/aad/unlock-label/v3\", methodType, SHA-256(credentialId))`\n for a passkey, the method type alone for the recovery code. A test against\n the real server swaps two passkeys' labels in the database and both read\n as null.\n- **How version 2 stays readable** (the reading of both decisions, written\n down here so that it is not silent): a version-2 code has the same shape\n as a version-3 one, and a label carries no version byte, so neither can be\n recognised by looking at it. Trying version 2 whenever version 3 fails\n would give back exactly what version 3 closes (a mistyped code accepted as\n version 2 one time in 32; a label moved between methods opening as\n version 2). Version 2 is therefore read only when a caller names it:\n `{ version: 2 }` in crypto-core, `{ recoveryCodeVersion: 2 }` and\n `openMethodLabelV2` in identity. Nothing writes version 2. No version-2\n code or label existed outside tests.\n- Every earlier frozen fixture still opens; the version-3 bytes are frozen\n beside them (`crypto-core/test/fixtures/frozen-recovery-v3.json`,\n `identity/test/fixtures/frozen-v3.json`).\n\n**2026-09-27 — M6 built; its acceptance waits on the founder.** `pqc-scan`\nis built in its own repository (`D:\\PROJECTS\\pqc-scan`, commit `3680f12`,\nno remote): the detectors, the JSON report (schema version 1) and the\nMarkdown report, the command line and the composite GitHub Action; 47 tests\npass on Node 20 and 24. The details settled while building are that\nrepository's `DESIGN.md` §8.9–§8.11. The first acceptance item passes: on\nthis repository it finds AES-256-GCM, HKDF-SHA-256, PBKDF2 at 310,000\niterations, SHA-256, Ed25519 (Medium), the P-256 ECDH seal (High), the\nhybrid `MLKEM768-X25519` (post-quantum), the server's `node:crypto` verify\nand hash, and `deploy/nginx.sample.conf` as hybrid-enabled. The other items\nneed the founder's choices: a second real application, and three public\nrepositories for the false-positive review. Also since the entries below:\n`identity`, `access` and `mailbox` carry frozen version-2 fixtures (commit\n`e7a4d59`, 265 tests); no format changed.\n\n**2026-09-27 — D-42, D-43, D-44, D-45 decided: `pqc-scan` as designed\n(`docs/DESIGN-M6-pqc-scan.md`).** The founder directed that the build be\ncompleted as far as possible without further questions, which takes each\nrecommended option: D-42 (a) its own repository `D:\\PROJECTS\\pqc-scan`, npm\n`@microtoll/pqc-scan`, binary `pqc-scan`, Apache-2.0, Node 20 or later,\nzero runtime dependencies; D-43 (a) a tokenizer with call-site patterns;\nD-44 (a) JSON schema v1 and NCSC-shaped Markdown; D-45 (a) a composite\nGitHub Action, the JSON schema as the only seam for a hosted report, nothing\npaid built. Crypto decisions are not covered by that direction and stay\npending (D-46, D-47).\n\n**2026-09-27 — The repository is self-contained; a private GitHub remote is\nallowed.** The founder's direction: nothing in the engine's code, tests or\ndocumentation names another product, its files, documents or versions.\nNotes that need them are kept outside the repository. The git history is\nnot rewritten (it is part of the ownership record, D-01). The repository is\npushed to a **private** GitHub repository; the publish gate (D-01) still\ncloses every public repository, registry and listing. The frozen fixtures\nare now written by the engine itself under the test namespace `example`\n(`packages/crypto-core/test/fixtures/frozen-v1.json`); no format changed.\n\n**2026-09-25 — M5 approved; M6 starts.** The distribution kit accepted at\ncommit `216574f`: `@microtoll/mailbox` (M3b) and `examples/invite-app`, the\ndocs site source with `llms.txt`, `@microtoll/mcp`, `SECURITY.md`,\n`CONTRIBUTING.md`, the inert release workflow and `docs/LAUNCH.md`; 242\ntests green. Nothing pushed, published or listed (D-01). M6 (`pqc-scan`, a\nseparate repository) begins with a written design for decision.\n\n**2026-09-25 — D-38, D-39, D-40, D-41 decided: the distribution kit as\ndesigned (`docs/DESIGN-M5.md`).** D-38: the docs site from Markdown in\n`docs/` with a zero-dependency renderer, `llms.txt` from the same build,\nGitHub Pages for microtoll.dev once public, no analytics. D-39:\n`@microtoll/mcp` with the stdio JSON-RPC subset written in (zero\ndependencies), docs search, read-doc and an empty-directory scaffold that\nruns nothing and fetches nothing. D-40: M3b (`@microtoll/mailbox`: the\npairwise labels, bundles version 2 with the recipient and mailbox bound into\nthe signature, withdrawal as the server does it) built inside M5, and\n`examples/invite-app` as the direct-invite demo. D-41: lockstep versions\nfrom `0.1.0`; a release workflow on a signed tag with npm provenance, inert\nuntil the founder sets `PUBLISH_GATE_OPEN` after D-01; signed tags;\n`SECURITY.md` with coordinated disclosure to `security@microtoll.dev` and no\nbounty; `CONTRIBUTING.md` with DCO. D-06, D-07, D-08, D-09, D-11, D-12 and\nD-18 are closed as adopted in M0–M2.\n\n**2026-09-25 — M4 approved; M5 starts.** `@microtoll/blind-store` accepted\nat commit `1efc156`: the library, schema, reference server, deployment kit\nand `examples/notes-app`; 221 tests green including the database-backed\nsuites; the example built and ran end to end through Docker Compose on the\nfounder's machine. Carried forward: M3b (the mailbox client, on the server\nhalf M4 ships); the founder's own browser run of the example; D-01 still\ngates publishing. M5 (the distribution kit) begins.\n\n**2026-09-25 — D-37 decided: the pointer's binding is the account, not the\nobject id (amends D-31).** The server returns an account's pointers without\nan id, by design, so the M3 binding could never be opened on a fresh device\n(found by the notes example). The pointer's additional authenticated data is\nnow `frameContext(\"<ns>/aad/pointer/v2\", routingPublicKey)`; the object id is\nread from inside the sealed pointer. Still AES-GCM with additional data; no\nprimitive, label or KDF change; no pointer had been written outside tests.\n`packages/access/FORMATS.md` §3.2 and THREATMODEL §5 updated.\n\n**2026-09-25 — D-33, D-34, D-35, D-36 decided: the server as designed\n(`packages/blind-store/DESIGN.md`).**\nD-33: an events-and-members model becomes `objects` and `object_members`\nwith a `collection` column, a fixed-length selector and an optional date\nwindow; the query answers everything matching with no identity filter,\nrefuses past `maxQueryRows` (5,000), and carries a host's extra fields; live\nwatches route by the same selector, the imminent watch by a per-collection\nserver-owned window; the message names are fixed (D-27), the selector fields\ngeneric, and `adminCapabilityHash` leaves the query and fetch replies (it\nwould give everyone who receives cover traffic a stable per-object token);\nthe mailbox's server half is included. This settles the server half of D-13\nand confirms D-14 (what stays out).\nD-34: the handshake's server half verifies `\"<ns>/auth/v2\" ‖ 0x00 ‖\nSHA-256(origin) ‖ nonce` with `node:crypto` against the connection's\n`Origin`, or each allowed origin when none was sent; the server imports no\n`@microtoll` package and a cross-implementation test proves the framing\nagainst identity's `authMessage`; the transport limits plus the query-rows\nbackstop; the three daily counters fail open. D-20's lookup limit (3 per\nsocket) is in it.\nD-35: the M0 schema rules run as a test against a live database;\n`blind_store_sweep()` runs hourly from the library and never sweeps objects\n(resolves D-19); the schema creates the least-privilege role\n`blind_store_app` that the server and the tests connect as, so a server\ncompromise reaches no more than the server can already read. D-17 is\nresolved by the registration hook.\nD-36: `createBlindStore` (with `createCore` as an alias); the thin\n`bin/blind-store.mjs`; dependencies exactly `ws` and `pg`, pinned, with the\nLISTEN client written in and `pg-listen` dropped; `deploy/` with the\nhardened Compose file and the Nginx sample; `examples/notes-app` on the\nunchanged reference server with a random-shelf selector and import-map\nloading; Postgres for tests from a throwaway container locally and a\nservice container in CI.\n\n**2026-09-25 — M3 approved; M4 starts.** `@microtoll/access` accepted at\ncommit `0ba99bf`: formats v2 (D-31), the adversarial suite green with every\ncase mapped in THREATMODEL §5, 160 tests across the three packages. M4 begins\nwith a written design of the collection and coarse-selector abstraction and\nthe server-side hardening for decision, then the server core as a library\nwith a thin reference server (D-23), and `examples/notes-app`.\n\n**2026-09-25 — D-30, D-31, D-32 decided: the access layer as designed.**\nD-30: an events-and-members model is generalised by renaming and widening\n(event → object, `K_event` → `K_object`, participation row → member row,\norganiser → owner); the content split and the grant rule are supplied by the\napp; the pointer has an extension table; wire message names are fixed\n(D-27). D-31: formats version 2 as in `packages/access/FORMATS.md` §3 —\npurpose labels on the member-row and share-link signatures (and, in M3b, the\nrecipient inside the invite signature), additional authenticated data on\ncontent, second tier, member rows, pointers and link payloads; the\nper-member sealed `K_object` stays an ECIES seal without extra data, stated\nin the threat model. D-32: M3 ships objects, members, pointers, admin seats,\nrotation, two-tier disclosure, share links, the display rule, the wire\nbuilders and the adversarial suite; direct invites wait for M3b.\n\n**2026-09-25 — M2 approved; M3 starts.** `@microtoll/identity` accepted at\ncommit `cf78168`: formats v2 (D-28, D-29), 53 tests including every gap the\naudit listed, the acceptance demo page, THREATMODEL §4 complete. Carried\nforward: the server half of the bound handshake (M4); the founder's own run\nof the demo on a real authenticator. M3 begins with the written design of the\naccess-layer hardening and the object model (D-13) for decision.\n\n**2026-09-25 — D-28 decided: additional authenticated data on all four identity-layer seals.**\nAs designed in `packages/identity/FORMATS.md` §2.1–2.4: the wrapped root key\nis bound to its method type and identifier (SHA-256 of the credential id, or\nthe recovery lookup hash); the identity blob and the unlock-method label are\nbound to the routing public key; the trusted-device session record (version 2)\nis bound to the routing public key, its expiry and its session generation, and\na restored record whose stored routing key differs from the derived one is\nrefused. The blob gains a cooperative `revision` counter so a rolled-back blob\nis refused on a device that saw a later one. Contexts are\n`frameContext(\"<ns>/aad/<purpose>/v2\", …)`; no primitive, mode or KDF changes.\n\n**2026-09-25 — D-29 decided: the handshake signature covers purpose, origin and nonce.**\nThe routing key signs `frameContext(\"<ns>/auth/v2\", SHA-256(UTF-8(origin)), nonce)`.\nThe client half ships in M2; the server half in M4 (`blind-store`), verifying\nagainst its allowed origins, with a cross-implementation test.\n\n**2026-09-25 — M1 approved; M2 starts.** `@microtoll/crypto-core` accepted at\ncommit `095824c`: vectors green through the public API, fixtures proved in\nboth directions, zero runtime dependencies, README quickstart and threat\nmodel complete. Carried forward: the browser cross-check of the hybrid seal\n(release gate, D-07) and the non-extractable signing key (identity, D-24). M2\nbegins with a written design of the identity-layer format hardening for the\nfounder's decision (D-25).\n\n**2026-09-25 — D-26 decided: recovery-code check character, version 2.**\nThe check character is the Crockford digit of the low five bits of the first\nbyte of SHA-256(secret bytes). Entropy (128 bits), length (27 characters) and\ngrouping are unchanged from version 1; only the check rule changes, so a\nrandom transcription error of any kind is caught with probability 31/32.\nThe engine writes version 2 only; no version-1 data exists to read.\n`formatRecoveryCode` and `parseRecoveryCode` become asynchronous (SHA-256 is\nasynchronous in Web Crypto). Implemented in crypto-core `src/recovery.js`.\n(Revisited by pending D-46.)\n\n**2026-09-25 — D-27 decided: crypto-core keeps its v0 function names.**\n`sealToRecipient`, `openWithPrivateKey`, `pqSealAvailable` and the rest keep\ntheir names for v0, as do the wire message names. Any renames for outside\nusers come with aliases.\n\n**2026-09-25 — D-24 decided (amends D-21): format hardening happens in the\nengine, package by package.** The signature purpose labels, recipient\nbinding, additional authenticated data, non-extractable keys, recovery\nchecksum and handshake binding are designed and built as each package is\nbuilt (M1 to M4). Each change is written up before code and recorded here;\nthe engine freezes its own fixtures. The crypto-core formats (AEAD v1, ECIES\nv3 and v2) are unchanged by the pass; the recovery-code checksum is the one\nM1 format decision.\n\n**2026-09-25 — D-25 decided: identity and access take their screens as\ncallbacks.** `@microtoll/identity` and `@microtoll/access` hold no DOM and no\npage state; the app supplies its UI through callbacks and hooks, as D-11\nrecommends.\n\n**2026-09-25 — M0 signed off.** The founder accepted the audit,\n`THREATMODEL.md` and the decisions list. M1 groundwork starts: the\nrepository, the monorepo layout, licences, CI, the standard RFC and NIST\nvectors, and the crypto-core API design. D-01 still gates publishing.\n\n**2026-09-25 — D-04 decided: the v0.x stability promise, as drafted.**\nTwo promises, stated separately. Formats are frozen from the first publish: no\nversion byte, label, KDF parameter or signed-byte layout ever changes meaning,\nand readers for every published format stay supported. The API may change in\nany 0.x minor release, always listed in the package `CHANGELOG.md` with a\nmigration note; patch releases never break. Public wording: *\"Microtoll Engine\nis pre-1.0. Function names and options may change between minor versions; the\nbytes it writes never will. Anything you encrypt with any published version\nwill decrypt with every later one.\"*\n\n**2026-09-25 — D-23 decided: `blind-store` is a library first, with a thin reference server.**\n`blind-store` exports its core handlers, dispatcher and schema. The Docker\nreference server is a thin wrapper around them. A host application mounts\nthe library and registers its own handlers beside it, so there is one server\ncore for every consumer. Item and membership handlers stay the host's until\nthe generic collection model (D-13) is settled (it was, by D-30 and D-33).\n**Licence consequence:** a host server that embeds AGPL-3.0 `blind-store`\nmust be distributed under AGPL-compatible terms.\n\n**2026-09-24 — D-21 decided: fix format-level weaknesses before any format is frozen.**\nNothing is published, so no format is frozen yet. The format-level\nweaknesses are fixed in one deliberate pass before the first publish:\n- purpose (domain-separation) labels on signatures;\n- recipient and mailbox binding in invitation and acknowledgement\n signatures;\n- additional authenticated data (AAD) on object-layer and identity-layer\n seals;\n- non-extractable Ed25519 private keys (this absorbs D-15);\n- a stronger recovery-code checksum;\n- binding for the handshake signature.\n\nThis adds binding to existing constructions; no primitive, mode or KDF is\nadded or substituted. Each change and its reason is recorded here. (Where the\npass happens: D-24.)\n\n**2026-09-24 — D-05 decided: label namespace profile.**\nThe constructions are fixed and only the label prefix varies:\n`createProfile({ namespace })`. A namespace is required, with no silent\ndefault. Retired labels stay reserved in every namespace.\n\n**2026-09-24 — D-02 decided: licensing as recommended.**\n- Apache-2.0: `crypto-core`, `identity`, `access`, `mailbox` and the examples.\n- AGPL-3.0-only: `blind-store`.\n- CC-BY-4.0: the docs.\n- Outside contributions: DCO sign-off.\n\nThis decision does not open the publish gate; D-01 still governs that.\n\n**2026-09-24 — D-03 decided: the product name is \"Microtoll Engine\".**\nPackages are named by function under the `@microtoll` scope.\n\n**State of the publish gate (non-negotiable 8): OPEN since 2026-09-27.** The\nentry of that date records that both checks of D-01 passed. Publishing\nfollows `docs/LAUNCH.md`, in order.\n\n---\n\n## Pending\n\nEach entry gives the question, the options, a recommendation, and the\nmilestone it blocks.\n\n### Blocks the first publish\n\n(Nothing: D-01 passed on 2026-09-27, see Recorded. The entry is kept below\nfor the reasoning.)\n\n**D-01 — IP and employment clearance (the publish gate).** *(Passed 2026-09-27 — see Recorded.)*\nTwo checks must both pass before anything is published: who owns the code\nthe engine is built from, and what the founder's employment terms require.\nThe details, the evidence being kept and the options are in the founder's\nprivate notes, outside this repository.\n\nRules that follow from it here:\n- Never rewrite git history (rebase, amend, force-push or date changes) on\n this repository: the dated history is part of the evidence.\n- Local and private work may continue meanwhile.\n\n**Record here when done:** both checks passed, the date, who confirmed, and\nany approval that was needed (with its date).\n\n### Closed questions (kept for the reasoning)\n\n**D-46 — Recovery-code check character, version 3: a weighted check over GF(32).** *(Decided 2026-09-27, option (a) — see Recorded.)*\nVersion 2 (D-26) takes the check character from SHA-256 of the secret. A\nrandom error is caught with probability 31/32, but no class of error is\ncaught for certain: one mistyped character, or two swapped characters, slips\nthrough one time in 32 and is then refused by lookup as \"no such account\", a\nconfusing failure (never a wrong account).\nOptions, all keeping 128 bits of entropy, 27 characters and the grouping:\n- (a) **Version 3:** the check is Σ aⁱ⁺¹·sᵢ over the 26 data characters in\n GF(32), with a = x under x⁵ + x² + 1 (the arithmetic bech32 uses). The 26\n weights are distinct and non-zero, so **every** single wrong character and\n **every** swap of two characters, adjacent or not, is caught; random\n errors are still caught 31/32. Synchronous again (no hash). The parser\n also refuses a code whose two unused final bits are not zero (26\n characters carry 130 bits for 128), so one string names one secret; today\n four strings parse to the same bytes. **Recommended.**\n- (b) Keep version 2.\n- (c) Crockford's mod-37 check symbol (catches single errors and adjacent\n swaps; the check position may show `*~$=U`).\nError detection, not cryptography: the code's 128 random bits protect the\naccount either way. No version-2 code exists outside tests.\n**Needed before:** the first publish.\n\n**D-47 — Bind an unlock method's name to the method, not only the account.** *(Decided 2026-09-27, option (a) — see Recorded.)*\nVersion 2 (D-28) binds the sealed name of an unlock method (\"Alice's phone\")\nto the account's routing key. That stops nothing the account's own key does\nnot already stop (another account's name would not open), and it does **not**\nstop the server showing one passkey's name against another of the same\naccount's passkeys — the case that misleads a person removing a method.\nOptions:\n- (a) **Version 3 of the label context:** `frameContext(\"<ns>/aad/unlock-label/v3\",\n methodType, SHA-256(credentialId))` for a passkey, the method type alone\n for the recovery code, matching the wrapped root key's binding (D-28).\n **Recommended.**\n- (b) Keep version 2.\n- (c) Bind both (routing key and method): no gain over (a), since the key is\n per account.\n**Needed before:** the first publish.\n\n**D-02 — Licensing.** *(Decided 2026-09-24 — see Recorded.)*\nApache-2.0 client packages can be used inside an AGPL application; AGPL on\n`blind-store` means anyone running a modified server as a service must\npublish their changes.\n**Recommendation:** Apache-2.0 for `crypto-core`, `identity`, `access` and\n`mailbox`; AGPL-3.0-only for `blind-store`; Apache-2.0 for the examples and\nCC-BY-4.0 for the docs; a DCO sign-off (not a CLA) for outside contributions.\n\n**D-03 — Engine product name under the Microtoll brand.** *(Decided 2026-09-24 — see Recorded.)*\n- (a) \"Microtoll Engine\", descriptive, with packages named by function\n (`@microtoll/identity`, …).\n- (b) A distinct product name, which needs a trade-mark search.\n\n**Recommendation:** (a) for v0. It can be revisited at the eight-week review.\n\n**D-04 — API stability promise for v0.x.** *(Decided 2026-09-25 — see Recorded.)*\n\n**D-05 — KDF label namespace.** *(Decided 2026-09-24 — see Recorded.)*\nA public library hard-wired to one product's label prefix is confusing, and\nchanging a label counts as substituting one (non-negotiable 1).\n- (a) One fixed label set for everyone.\n- (b) Labels become a *profile*: the construction is fixed, and only the\n namespace prefix varies; apps pass their own namespace, for example\n `\"myapp\"` → `\"myapp/routing/v1\"`.\n- (c) A new fixed `microtoll/...` label set.\n\n**Recommendation:** (b). Nothing in any construction changes. A namespace is\nrequired (no silent default), so two apps never share a derivation by\naccident. Retired labels stay reserved in every namespace.\n\n**D-06 — Correct the build plan's primitive list.** *(Adopted in M1 (2026-09-25): P-256, RFC 5903 §8.1, RFC 7914 §11, X-Wing vectors — closed by the M5 design.)*\nThe plan listed \"Ed25519/X25519 seed derivation\" and RFC 7748 and RFC 6070\nvectors. In fact the sealing curve is **P-256** (X25519 was retired because\nSafari lacks it) and PBKDF2 is **SHA-256** (RFC 6070 is SHA-1 only).\n**Recommendation:** P-256; RFC 5903 §8.1 plus a known-answer test of the v3\nseal key derivation instead of RFC 7748; RFC 7914 §11 (PBKDF2-HMAC-SHA-256)\ninstead of RFC 6070; X25519 only inside the X-Wing hybrid, tested through the\nX-Wing vectors. Adding test vectors is not new cryptography.\n\n**D-07 — Post-quantum hybrid in crypto-core.** *(Adopted in M1: hybrid off by default; the Chrome cross-check stays a release gate — closed by the M5 design.)*\nThe X-Wing seal (ECIES v2, `MLKEM768-X25519`) needs native browser support\n(Chrome 154 has it), Node has no native X-Wing, and no cross-check on a real\nbrowser has been done.\n**Recommendation:** off by default, behind an explicit opt-in; documented as\n\"hybrid mode (experimental; requires a browser with native\nMLKEM768-X25519)\"; tested through a test-only shim, never in a published\nruntime path; the Chrome seal/open cross-check a release gate before the\nopt-in is documented as usable.\n\n**D-08 — Source language.** *(Adopted in M1: JavaScript with hand-written declarations, no bundler — closed by the M5 design.)*\nJavaScript source with JSDoc; hand-written `.d.ts` declarations checked by\n`tsc --noEmit` in CI (TypeScript as a development dependency only); no\nbundler; zero runtime dependencies in the client packages.\n\n**D-09 — Supported runtimes.** *(Adopted in M1: Node ≥ 24; current Chrome, Firefox, Safari, Edge; the post-quantum path as stated — closed by the M5 design.)*\nNode ≥ 24; current Chrome, Firefox, Safari and Edge for the classical path;\nthe post-quantum path needs Node ≥ 24.7 on OpenSSL ≥ 3.5, or Chrome ≥ 154.\n\n**D-10 — Withdrawn from this log** (2026-09-27): not an engine decision.\n\n**D-11 — Identity package boundary.** *(Adopted in M2 as built (createIdentitySession with the UI as callbacks) — closed by the M5 design.)*\nThe package ends at \"authenticated connection, `auth-ok` fields, identity\nblob opened, sealing key adopted\". All UI (asking for a code, showing a code,\nconfirming a deletion) comes in through injected callbacks. A deterministic\navatar and handle stay out of v0; the passkey's user name is a\ncaller-supplied string.\n\n**D-12 — Where the sealing key lives.** *(Adopted in M2 as built (operations in crypto-core, storage and adoption in identity) — closed by the M5 design.)*\n\n**D-13 — Generic collection model for access and blind-store.** *(Decided 2026-09-25 by D-30 and D-33 — see Recorded.)*\nGeneralise an events-and-members model, taking the stronger mechanics of a\nseat-based model: an `expectedEpoch` refusal and a rotation completeness\ncheck; role-scoped capability replacement on rotation; hash-length and\nbyte-cap `CHECK`s; `timingSafeEqual` comparisons; then the coarse selector\nand the cover-traffic query.\n\n**D-14 — What stays out of v0.** *(Confirmed 2026-09-25 by D-33 — see Recorded.)*\nReporting and moderation; a public layer; operator disclosure keys; repeat\ngrants; live signals; Web Push. Guest (ephemeral) identities and live\nwatches over the selector stay in. Push can follow as an optional package\nonce its documented join is written into THREATMODEL.md.\n\n**D-15 — Non-extractable Ed25519 private keys.** *(Absorbed into D-21, 2026-09-24; built in identity, M2.)*\nDerive the public key once with an extractable import, then re-import the\nprivate key non-extractable: byte-identical outputs.\n\n**D-16 — Milestone numbering.** *(Adopted: the mailbox is M3b.)*\n`pqc-scan` stays M6; `mailbox` is M3b, built inside M5 (D-40).\n\n**D-17 — Terms and 18+ columns.** *(Decided 2026-09-25 by D-35 — see Recorded.)*\nThe core schema carries no policy columns; a registration hook lets an app\nenforce its own policy and store its own flag.\n\n**D-18 — Repository location and version control.** *(Adopted at M0: `D:\\PROJECTS\\microtoll`; a private GitHub remote from 2026-09-27; public only when the gate opens.)*\n`pqc-scan` has its own repository (D-42).\n\n**D-19 — Retention sweeps.** *(Decided 2026-09-25 by D-35 — see Recorded.)*\n`blind-store` sweeps expired and fully used link tokens, consumed and\nexpired mailbox rows, and rate counters older than two days — all of which\nwould otherwise keep ciphertext that carries keys, or activity records,\nindefinitely. Its effect on what a database copy reveals is in\nTHREATMODEL.md.\n\n**D-20 — Keep the passkey-as-PRF-only model and the open unlock lookup.** *(Decided 2026-09-25 by D-34 — see Recorded.)*\nThe server never verifies a WebAuthn assertion; the PRF output is the\nsecret; the unauthenticated lookup returns only wrapped material; the lookup\nis rate-limited in `blind-store`.\n\n**D-22 — Withdrawn from this log** (2026-09-27): not an engine decision.\n\n**D-26 — The recovery-code check character.** *(Decided 2026-09-25 — see Recorded; revisited by D-46.)*\nVersion 1 was the sum of the 16 bytes mod 32, which misses a mistyped\ncharacter whose error falls only in a byte's top three bits, and misses\nswapped neighbours.\n- (a) Keep version 1.\n- (b) Crockford's mod-37 check symbol.\n- (c) One character from SHA-256 of the 16 bytes (chosen).\n\n**D-30 — The object model (resolves D-13).** *(Decided 2026-09-25 — see Recorded.)*\n- (a) As designed. **Recommended.**\n- (b) Keep an event vocabulary in the package API (no renames).\n- (c) A wider redesign around a seat model for members.\n\n**D-31 — Access-layer hardening, version 2 (FORMATS.md §3).** *(Decided 2026-09-25 — see Recorded.)*\n- (a) All of it. **Recommended.** Every context is known before the open, no\n schema change, no new primitive.\n- (b) Signature labels only.\n- (c) Keep version 1.\n\n**D-32 — What M3 ships (FORMATS.md §4).** *(Decided 2026-09-25 — see Recorded.)*\n\n**D-33 — The collection model on the server (DESIGN.md §2).** *(Decided 2026-09-25 — see Recorded.)*\n- (a) As in DESIGN.md §2. **Recommended.**\n- (b) Keep domain-specific field names (`geoBucket`, `dateStart`,\n `dateEnd`) and the admin hash on the wire.\n- (c) One table per configured collection instead of a `collection` column.\n\n**D-34 — The bound handshake, server half, and the transport limits (DESIGN.md §3).** *(Decided 2026-09-25 — see Recorded.)*\n- (a) As in DESIGN.md §3. **Recommended.**\n- (b) Verify against the `Origin` header only, refusing a connection that\n sends none (breaks non-browser clients and every test client).\n- (c) Import `@microtoll/crypto-core` on the server for the framing (one\n implementation, but the server package then contains code that can\n decrypt).\n\n**D-35 — Schema rules as tests, the sweep, the database role (DESIGN.md §4).** *(Decided 2026-09-25 — see Recorded.)*\n- (a) As in DESIGN.md §4. **Recommended.**\n- (b) Also sweep objects a configurable time after their window ends.\n\n**D-36 — Library, reference server, deployment kit, example (DESIGN.md §5).** *(Decided 2026-09-25 — see Recorded.)*\n- (a) As in DESIGN.md §5. **Recommended.**\n- (b) Keep `pg-listen` as a third dependency.\n- (c) Make the example a calendar rather than notes.\n\n**D-37 — Correct the pointer's binding (amends D-31).** *(Decided 2026-09-25 — see Recorded.)*\n- (a) Bind the routing public key; the id inside. **Recommended; done.**\n- (b) Keep the object-id binding and add a plaintext object-id column to the\n pointer table (breaks the M0 rule: the server would hold every account's\n object list).\n- (c) No binding beyond the label.\n\n**D-38, D-39, D-40, D-41** *(Decided 2026-09-25 — see Recorded; the options\nare in `docs/DESIGN-M5.md`.)*\n\n**D-42, D-43, D-44, D-45** *(Decided 2026-09-27 — see Recorded; the options\nare in `docs/DESIGN-M6-pqc-scan.md`.)*\n\n**D-28 — Additional authenticated data on the four identity-layer seals (FORMATS.md §2.1–2.4).** *(Decided 2026-09-25 — see Recorded; the label binding revisited by D-47.)*\n- (a) All four, as designed. **Recommended.**\n- (b) Only the session record and the wrapped root key.\n- (c) None.\n\n**D-29 — The handshake signature is bound to its purpose and origin (FORMATS.md §2.5).** *(Decided 2026-09-25 — see Recorded.)*\n- (a) Label and origin, as designed. **Recommended.**\n- (b) Label only.\n- (c) Keep the bare nonce.\n",
929
+ "sections": [
930
+ {
931
+ "heading": "DECISIONS.md — founder decisions log",
932
+ "level": 1,
933
+ "text": "**Append-only.** A decision is recorded by adding a dated entry under\n\"Recorded\"; it is never edited afterwards, only superseded by a later entry\nthat names it. Open questions sit under \"Pending\" with a recommendation until\nthe founder decides. Claude Code never resolves a crypto, licensing or IP\nquestion silently (build plan §4).\n\nStanding rules are in `microtoll-build-plan.md` §0 (the non-negotiables) and\nare not repeated here.\n\n**2026-09-27: this log was restated so that it names no other product** (the\nfounder's direction, recorded below). Each decision keeps its number, its\ndate and its substance; the earlier wording is in the git history. Two\nentries, D-10 and D-22, were not engine decisions and were withdrawn from\nthis log; their numbers are not reused.\n\n---"
934
+ },
935
+ {
936
+ "heading": "Recorded",
937
+ "level": 2,
938
+ "text": "**2026-09-27 — `pqc-scan` v0.1.0 tagged and signed; trusted publishing set.**\nThe founder made the release signing key (`~/.ssh/microtoll-release`,\nEd25519, passphrase-protected; launch item 3), registered its public half\non GitHub as a signing key, and pushed the signed tag `v0.1.0`\n(`3e7c05c`), which GitHub verifies as valid. On npm, the package trusts\n`release.yml` for **staged** publishing only, npm's recommended setting: a\ntag stages a version with provenance and the founder approves it by hand,\nso a compromised build run can never publish alone (`pqc-scan` `5414d9d`).\nPublishing access requires two-factor authentication with no bypass\ntokens. Provenance starts with the next version; 0.1.0 was published by\nhand.\n\n**2026-09-27 — `@microtoll/pqc-scan` 0.1.0 is on the npm registry.** The\nfirst public release of anything from this project. Published by the\nfounder from their own machine (`npm publish --access public`, two-factor\nauthentication by passkey), from `pqc-scan` `5955777`; shasum\n`41000bdca199ed0b115b40e5c483201538741b68`; 22 files, 73.5 kB. Verified from\nthe registry: `npx @microtoll/pqc-scan --version` prints 0.1.0 and a scan\nwrites its two reports. The first publish attempt showed that npm removes a\n`bin` path written with a leading `./`, which would have left the package\nwith no command; fixed before publishing. The npm organisation `microtoll`\nwas already owned by the founder's account (`npm org ls`). Still to do for\nthis release: trusted publishing on npmjs.com for `release.yml`, the signing\nkey, and the signed tag `v0.1.0` (the workflow skips a version already\npublished). Provenance therefore starts with the next version.\n\n**2026-09-27 — `pqc-scan` is public.** `github.com/microtoll/pqc-scan` was\nmade public at `19eec51` (version 0.1.0, no longer private, a release\nworkflow that publishes `@microtoll/pqc-scan` with provenance on a `v*`\ntag). Its history names no other product. Waiting on the founder: the\nsigning key (launch item 3), the `@microtoll` npm organisation with trusted\npublishing for this workflow (item 4), and then the signed tag `v0.1.0`,\nwhich publishes. The engine stays private until its own launch items.\n\n**2026-09-27 — D-01 passed: the publish gate is OPEN. M6 accepted.** The\nfounder confirmed, through the question tool, that both checks of D-01 have\npassed: who owns the code the engine is built from, and what the founder's\nemployment terms require. Recorded on the founder's word; the evidence and\nany approval that was needed stay in the founder's private notes, outside\nthis repository. From this entry, a public repository, a registry and a\npublic listing are allowed for both the engine and `pqc-scan`; the launch\nchecklist (`docs/LAUNCH.md`) governs the order. The history is still never\nrewritten. **M6 is accepted** on the same day: the founder ran the scanner\non their own application from a fresh clone on a Windows PC and read the\nreport, which completes the last acceptance item (the founder's reading);\nthat run also led to the terminal default, `--test-files` and the Windows\nlauncher (`pqc-scan` `6fac933`). The founder's choice for the first public\nstep: `pqc-scan` goes public and to npm together, as `@microtoll/pqc-scan`\n0.1.0.\n\n**2026-09-27 — M6: the three public repositories reviewed by hand; the\nscanner corrected.** The founder asked for three to be proposed and run.\nChosen for three shapes, each a shallow clone read once: a JSON Web Token\nlibrary where every finding should be genuine (`panva/jose` at `55c959f`), a\nlarge browser application with almost no cryptography (`excalidraw/excalidraw`\nat `84e3f5a`), and a server application with a typical sign-in stack\n(`requarks/wiki` at `712a3a5`). Every reported finding was a genuine\ncryptographic use, and a hand search of jose's source and of Excalidraw\nfound nothing missed. Seven faults around the findings were found, each\nfixed in `pqc-scan` with a fixture and a test (its `DESIGN.md` §8.12):\ndependency versions printed as `\\1.0.6` (an invalid Markdown escape, in\nevery report with a lockfile); a package listed as a transitive dependency of\nitself (jose; the engine's own report had likewise listed\n`@microtoll/crypto-core`); `test-d` type tests not marked as test code; two\n\"could not be read\" pointers that led only to token decoding and header\nextraction; the RSA key pair that signs Wiki.js's tokens reported High under\nthe comment \"Generate certificates\" (a certificate is a signing artefact:\nMedium); Wiki.js's SAML sign-in, encrypted assertions and two-factor codes\nunreported because their libraries were not catalogued (eight packages\nadded, 57 in all); and the SHA-1 note, which told both applications to\nreplace a plain content identifier as if it protected something. 50 tests\npass. The engine's own report is unchanged apart from its dependency line\n(its own package is not a dependency). The reports, before and after, are in\nthe founder's private acceptance notes. Still open for M6: the founder's\nreading of the reports (the engine's, the second application's and these\nthree), which is the acceptance.\n\n**2026-09-27 — M6: the second application scanned; the public repository\nwill start from a snapshot.** Two founder's choices.\n- **The second real application** (DESIGN §6, second item) was chosen by the\n founder and scanned read-only; the report and the hand review are in the\n founder's private notes, because they describe that application. Every\n expectation of the item was met. The review found two scanner faults,\n fixed in `pqc-scan` `ab5332f` with a fixture and a test each: a WebAuthn\n key reported High (it only verifies signatures: Medium), and a source file\n skipped as binary because of a raw control character far down (binary now\n means a NUL in the first 8,000 bytes). The first item still passes on this\n repository. Still open for M6: three public repositories for the\n false-positive review, and the founder's reading of both reports.\n- **At the publish gate**, this repository stays private as the dated\n record, and the public repository starts from a snapshot of the tree, with\n a first commit that says where the private history is kept. Nothing is\n rewritten (D-01's evidence rule). `docs/LAUNCH.md` item 7 says so.\n\n**2026-09-27 — D-46 and D-47 decided: option (a) of each, version 3.** The\nfounder's choice, and built the same day.\n- **D-46:** new recovery codes are version 3. The check character is\n Σ aⁱ⁺¹·sᵢ over the 26 data characters in GF(32) = GF(2)[x]/(x⁵ + x² + 1),\n a = x, and the parser refuses a code whose two unused bits are not zero or\n that has more than 26 data characters. Tests prove every single wrong\n character and every swap of two different characters is refused.\n- **D-47:** an unlock method's sealed label is bound to that method:\n `frameContext(\"<ns>/aad/unlock-label/v3\", methodType, SHA-256(credentialId))`\n for a passkey, the method type alone for the recovery code. A test against\n the real server swaps two passkeys' labels in the database and both read\n as null.\n- **How version 2 stays readable** (the reading of both decisions, written\n down here so that it is not silent): a version-2 code has the same shape\n as a version-3 one, and a label carries no version byte, so neither can be\n recognised by looking at it. Trying version 2 whenever version 3 fails\n would give back exactly what version 3 closes (a mistyped code accepted as\n version 2 one time in 32; a label moved between methods opening as\n version 2). Version 2 is therefore read only when a caller names it:\n `{ version: 2 }` in crypto-core, `{ recoveryCodeVersion: 2 }` and\n `openMethodLabelV2` in identity. Nothing writes version 2. No version-2\n code or label existed outside tests.\n- Every earlier frozen fixture still opens; the version-3 bytes are frozen\n beside them (`crypto-core/test/fixtures/frozen-recovery-v3.json`,\n `identity/test/fixtures/frozen-v3.json`).\n\n**2026-09-27 — M6 built; its acceptance waits on the founder.** `pqc-scan`\nis built in its own repository (`D:\\PROJECTS\\pqc-scan`, commit `3680f12`,\nno remote): the detectors, the JSON report (schema version 1) and the\nMarkdown report, the command line and the composite GitHub Action; 47 tests\npass on Node 20 and 24. The details settled while building are that\nrepository's `DESIGN.md` §8.9–§8.11. The first acceptance item passes: on\nthis repository it finds AES-256-GCM, HKDF-SHA-256, PBKDF2 at 310,000\niterations, SHA-256, Ed25519 (Medium), the P-256 ECDH seal (High), the\nhybrid `MLKEM768-X25519` (post-quantum), the server's `node:crypto` verify\nand hash, and `deploy/nginx.sample.conf` as hybrid-enabled. The other items\nneed the founder's choices: a second real application, and three public\nrepositories for the false-positive review. Also since the entries below:\n`identity`, `access` and `mailbox` carry frozen version-2 fixtures (commit\n`e7a4d59`, 265 tests); no format changed.\n\n**2026-09-27 — D-42, D-43, D-44, D-45 decided: `pqc-scan` as designed\n(`docs/DESIGN-M6-pqc-scan.md`).** The founder directed that the build be\ncompleted as far as possible without further questions, which takes each\nrecommended option: D-42 (a) its own repository `D:\\PROJECTS\\pqc-scan`, npm\n`@microtoll/pqc-scan`, binary `pqc-scan`, Apache-2.0, Node 20 or later,\nzero runtime dependencies; D-43 (a) a tokenizer with call-site patterns;\nD-44 (a) JSON schema v1 and NCSC-shaped Markdown; D-45 (a) a composite\nGitHub Action, the JSON schema as the only seam for a hosted report, nothing\npaid built. Crypto decisions are not covered by that direction and stay\npending (D-46, D-47).\n\n**2026-09-27 — The repository is self-contained; a private GitHub remote is\nallowed.** The founder's direction: nothing in the engine's code, tests or\ndocumentation names another product, its files, documents or versions.\nNotes that need them are kept outside the repository. The git history is\nnot rewritten (it is part of the ownership record, D-01). The repository is\npushed to a **private** GitHub repository; the publish gate (D-01) still\ncloses every public repository, registry and listing. The frozen fixtures\nare now written by the engine itself under the test namespace `example`\n(`packages/crypto-core/test/fixtures/frozen-v1.json`); no format changed.\n\n**2026-09-25 — M5 approved; M6 starts.** The distribution kit accepted at\ncommit `216574f`: `@microtoll/mailbox` (M3b) and `examples/invite-app`, the\ndocs site source with `llms.txt`, `@microtoll/mcp`, `SECURITY.md`,\n`CONTRIBUTING.md`, the inert release workflow and `docs/LAUNCH.md`; 242\ntests green. Nothing pushed, published or listed (D-01). M6 (`pqc-scan`, a\nseparate repository) begins with a written design for decision.\n\n**2026-09-25 — D-38, D-39, D-40, D-41 decided: the distribution kit as\ndesigned (`docs/DESIGN-M5.md`).** D-38: the docs site from Markdown in\n`docs/` with a zero-dependency renderer, `llms.txt` from the same build,\nGitHub Pages for microtoll.dev once public, no analytics. D-39:\n`@microtoll/mcp` with the stdio JSON-RPC subset written in (zero\ndependencies), docs search, read-doc and an empty-directory scaffold that\nruns nothing and fetches nothing. D-40: M3b (`@microtoll/mailbox`: the\npairwise labels, bundles version 2 with the recipient and mailbox bound into\nthe signature, withdrawal as the server does it) built inside M5, and\n`examples/invite-app` as the direct-invite demo. D-41: lockstep versions\nfrom `0.1.0`; a release workflow on a signed tag with npm provenance, inert\nuntil the founder sets `PUBLISH_GATE_OPEN` after D-01; signed tags;\n`SECURITY.md` with coordinated disclosure to `security@microtoll.dev` and no\nbounty; `CONTRIBUTING.md` with DCO. D-06, D-07, D-08, D-09, D-11, D-12 and\nD-18 are closed as adopted in M0–M2.\n\n**2026-09-25 — M4 approved; M5 starts.** `@microtoll/blind-store` accepted\nat commit `1efc156`: the library, schema, reference server, deployment kit\nand `examples/notes-app`; 221 tests green including the database-backed\nsuites; the example built and ran end to end through Docker Compose on the\nfounder's machine. Carried forward: M3b (the mailbox client, on the server\nhalf M4 ships); the founder's own browser run of the example; D-01 still\ngates publishing. M5 (the distribution kit) begins.\n\n**2026-09-25 — D-37 decided: the pointer's binding is the account, not the\nobject id (amends D-31).** The server returns an account's pointers without\nan id, by design, so the M3 binding could never be opened on a fresh device\n(found by the notes example). The pointer's additional authenticated data is\nnow `frameContext(\"<ns>/aad/pointer/v2\", routingPublicKey)`; the object id is\nread from inside the sealed pointer. Still AES-GCM with additional data; no\nprimitive, label or KDF change; no pointer had been written outside tests.\n`packages/access/FORMATS.md` §3.2 and THREATMODEL §5 updated.\n\n**2026-09-25 — D-33, D-34, D-35, D-36 decided: the server as designed\n(`packages/blind-store/DESIGN.md`).**\nD-33: an events-and-members model becomes `objects` and `object_members`\nwith a `collection` column, a fixed-length selector and an optional date\nwindow; the query answers everything matching with no identity filter,\nrefuses past `maxQueryRows` (5,000), and carries a host's extra fields; live\nwatches route by the same selector, the imminent watch by a per-collection\nserver-owned window; the message names are fixed (D-27), the selector fields\ngeneric, and `adminCapabilityHash` leaves the query and fetch replies (it\nwould give everyone who receives cover traffic a stable per-object token);\nthe mailbox's server half is included. This settles the server half of D-13\nand confirms D-14 (what stays out).\nD-34: the handshake's server half verifies `\"<ns>/auth/v2\" ‖ 0x00 ‖\nSHA-256(origin) ‖ nonce` with `node:crypto` against the connection's\n`Origin`, or each allowed origin when none was sent; the server imports no\n`@microtoll` package and a cross-implementation test proves the framing\nagainst identity's `authMessage`; the transport limits plus the query-rows\nbackstop; the three daily counters fail open. D-20's lookup limit (3 per\nsocket) is in it.\nD-35: the M0 schema rules run as a test against a live database;\n`blind_store_sweep()` runs hourly from the library and never sweeps objects\n(resolves D-19); the schema creates the least-privilege role\n`blind_store_app` that the server and the tests connect as, so a server\ncompromise reaches no more than the server can already read. D-17 is\nresolved by the registration hook.\nD-36: `createBlindStore` (with `createCore` as an alias); the thin\n`bin/blind-store.mjs`; dependencies exactly `ws` and `pg`, pinned, with the\nLISTEN client written in and `pg-listen` dropped; `deploy/` with the\nhardened Compose file and the Nginx sample; `examples/notes-app` on the\nunchanged reference server with a random-shelf selector and import-map\nloading; Postgres for tests from a throwaway container locally and a\nservice container in CI.\n\n**2026-09-25 — M3 approved; M4 starts.** `@microtoll/access` accepted at\ncommit `0ba99bf`: formats v2 (D-31), the adversarial suite green with every\ncase mapped in THREATMODEL §5, 160 tests across the three packages. M4 begins\nwith a written design of the collection and coarse-selector abstraction and\nthe server-side hardening for decision, then the server core as a library\nwith a thin reference server (D-23), and `examples/notes-app`.\n\n**2026-09-25 — D-30, D-31, D-32 decided: the access layer as designed.**\nD-30: an events-and-members model is generalised by renaming and widening\n(event → object, `K_event` → `K_object`, participation row → member row,\norganiser → owner); the content split and the grant rule are supplied by the\napp; the pointer has an extension table; wire message names are fixed\n(D-27). D-31: formats version 2 as in `packages/access/FORMATS.md` §3 —\npurpose labels on the member-row and share-link signatures (and, in M3b, the\nrecipient inside the invite signature), additional authenticated data on\ncontent, second tier, member rows, pointers and link payloads; the\nper-member sealed `K_object` stays an ECIES seal without extra data, stated\nin the threat model. D-32: M3 ships objects, members, pointers, admin seats,\nrotation, two-tier disclosure, share links, the display rule, the wire\nbuilders and the adversarial suite; direct invites wait for M3b.\n\n**2026-09-25 — M2 approved; M3 starts.** `@microtoll/identity` accepted at\ncommit `cf78168`: formats v2 (D-28, D-29), 53 tests including every gap the\naudit listed, the acceptance demo page, THREATMODEL §4 complete. Carried\nforward: the server half of the bound handshake (M4); the founder's own run\nof the demo on a real authenticator. M3 begins with the written design of the\naccess-layer hardening and the object model (D-13) for decision.\n\n**2026-09-25 — D-28 decided: additional authenticated data on all four identity-layer seals.**\nAs designed in `packages/identity/FORMATS.md` §2.1–2.4: the wrapped root key\nis bound to its method type and identifier (SHA-256 of the credential id, or\nthe recovery lookup hash); the identity blob and the unlock-method label are\nbound to the routing public key; the trusted-device session record (version 2)\nis bound to the routing public key, its expiry and its session generation, and\na restored record whose stored routing key differs from the derived one is\nrefused. The blob gains a cooperative `revision` counter so a rolled-back blob\nis refused on a device that saw a later one. Contexts are\n`frameContext(\"<ns>/aad/<purpose>/v2\", …)`; no primitive, mode or KDF changes.\n\n**2026-09-25 — D-29 decided: the handshake signature covers purpose, origin and nonce.**\nThe routing key signs `frameContext(\"<ns>/auth/v2\", SHA-256(UTF-8(origin)), nonce)`.\nThe client half ships in M2; the server half in M4 (`blind-store`), verifying\nagainst its allowed origins, with a cross-implementation test.\n\n**2026-09-25 — M1 approved; M2 starts.** `@microtoll/crypto-core` accepted at\ncommit `095824c`: vectors green through the public API, fixtures proved in\nboth directions, zero runtime dependencies, README quickstart and threat\nmodel complete. Carried forward: the browser cross-check of the hybrid seal\n(release gate, D-07) and the non-extractable signing key (identity, D-24). M2\nbegins with a written design of the identity-layer format hardening for the\nfounder's decision (D-25).\n\n**2026-09-25 — D-26 decided: recovery-code check character, version 2.**\nThe check character is the Crockford digit of the low five bits of the first\nbyte of SHA-256(secret bytes). Entropy (128 bits), length (27 characters) and\ngrouping are unchanged from version 1; only the check rule changes, so a\nrandom transcription error of any kind is caught with probability 31/32.\nThe engine writes version 2 only; no version-1 data exists to read.\n`formatRecoveryCode` and `parseRecoveryCode` become asynchronous (SHA-256 is\nasynchronous in Web Crypto). Implemented in crypto-core `src/recovery.js`.\n(Revisited by pending D-46.)\n\n**2026-09-25 — D-27 decided: crypto-core keeps its v0 function names.**\n`sealToRecipient`, `openWithPrivateKey`, `pqSealAvailable` and the rest keep\ntheir names for v0, as do the wire message names. Any renames for outside\nusers come with aliases.\n\n**2026-09-25 — D-24 decided (amends D-21): format hardening happens in the\nengine, package by package.** The signature purpose labels, recipient\nbinding, additional authenticated data, non-extractable keys, recovery\nchecksum and handshake binding are designed and built as each package is\nbuilt (M1 to M4). Each change is written up before code and recorded here;\nthe engine freezes its own fixtures. The crypto-core formats (AEAD v1, ECIES\nv3 and v2) are unchanged by the pass; the recovery-code checksum is the one\nM1 format decision.\n\n**2026-09-25 — D-25 decided: identity and access take their screens as\ncallbacks.** `@microtoll/identity` and `@microtoll/access` hold no DOM and no\npage state; the app supplies its UI through callbacks and hooks, as D-11\nrecommends.\n\n**2026-09-25 — M0 signed off.** The founder accepted the audit,\n`THREATMODEL.md` and the decisions list. M1 groundwork starts: the\nrepository, the monorepo layout, licences, CI, the standard RFC and NIST\nvectors, and the crypto-core API design. D-01 still gates publishing.\n\n**2026-09-25 — D-04 decided: the v0.x stability promise, as drafted.**\nTwo promises, stated separately. Formats are frozen from the first publish: no\nversion byte, label, KDF parameter or signed-byte layout ever changes meaning,\nand readers for every published format stay supported. The API may change in\nany 0.x minor release, always listed in the package `CHANGELOG.md` with a\nmigration note; patch releases never break. Public wording: *\"Microtoll Engine\nis pre-1.0. Function names and options may change between minor versions; the\nbytes it writes never will. Anything you encrypt with any published version\nwill decrypt with every later one.\"*\n\n**2026-09-25 — D-23 decided: `blind-store` is a library first, with a thin reference server.**\n`blind-store` exports its core handlers, dispatcher and schema. The Docker\nreference server is a thin wrapper around them. A host application mounts\nthe library and registers its own handlers beside it, so there is one server\ncore for every consumer. Item and membership handlers stay the host's until\nthe generic collection model (D-13) is settled (it was, by D-30 and D-33).\n**Licence consequence:** a host server that embeds AGPL-3.0 `blind-store`\nmust be distributed under AGPL-compatible terms.\n\n**2026-09-24 — D-21 decided: fix format-level weaknesses before any format is frozen.**\nNothing is published, so no format is frozen yet. The format-level\nweaknesses are fixed in one deliberate pass before the first publish:\n- purpose (domain-separation) labels on signatures;\n- recipient and mailbox binding in invitation and acknowledgement\n signatures;\n- additional authenticated data (AAD) on object-layer and identity-layer\n seals;\n- non-extractable Ed25519 private keys (this absorbs D-15);\n- a stronger recovery-code checksum;\n- binding for the handshake signature.\n\nThis adds binding to existing constructions; no primitive, mode or KDF is\nadded or substituted. Each change and its reason is recorded here. (Where the\npass happens: D-24.)\n\n**2026-09-24 — D-05 decided: label namespace profile.**\nThe constructions are fixed and only the label prefix varies:\n`createProfile({ namespace })`. A namespace is required, with no silent\ndefault. Retired labels stay reserved in every namespace.\n\n**2026-09-24 — D-02 decided: licensing as recommended.**\n- Apache-2.0: `crypto-core`, `identity`, `access`, `mailbox` and the examples.\n- AGPL-3.0-only: `blind-store`.\n- CC-BY-4.0: the docs.\n- Outside contributions: DCO sign-off.\n\nThis decision does not open the publish gate; D-01 still governs that.\n\n**2026-09-24 — D-03 decided: the product name is \"Microtoll Engine\".**\nPackages are named by function under the `@microtoll` scope.\n\n**State of the publish gate (non-negotiable 8): OPEN since 2026-09-27.** The\nentry of that date records that both checks of D-01 passed. Publishing\nfollows `docs/LAUNCH.md`, in order.\n\n---"
939
+ },
940
+ {
941
+ "heading": "Pending",
942
+ "level": 2,
943
+ "text": "Each entry gives the question, the options, a recommendation, and the\nmilestone it blocks."
944
+ },
945
+ {
946
+ "heading": "Blocks the first publish",
947
+ "level": 3,
948
+ "text": "(Nothing: D-01 passed on 2026-09-27, see Recorded. The entry is kept below\nfor the reasoning.)\n\n**D-01 — IP and employment clearance (the publish gate).** *(Passed 2026-09-27 — see Recorded.)*\nTwo checks must both pass before anything is published: who owns the code\nthe engine is built from, and what the founder's employment terms require.\nThe details, the evidence being kept and the options are in the founder's\nprivate notes, outside this repository.\n\nRules that follow from it here:\n- Never rewrite git history (rebase, amend, force-push or date changes) on\n this repository: the dated history is part of the evidence.\n- Local and private work may continue meanwhile.\n\n**Record here when done:** both checks passed, the date, who confirmed, and\nany approval that was needed (with its date)."
949
+ },
950
+ {
951
+ "heading": "Closed questions (kept for the reasoning)",
952
+ "level": 3,
953
+ "text": "**D-46 — Recovery-code check character, version 3: a weighted check over GF(32).** *(Decided 2026-09-27, option (a) — see Recorded.)*\nVersion 2 (D-26) takes the check character from SHA-256 of the secret. A\nrandom error is caught with probability 31/32, but no class of error is\ncaught for certain: one mistyped character, or two swapped characters, slips\nthrough one time in 32 and is then refused by lookup as \"no such account\", a\nconfusing failure (never a wrong account).\nOptions, all keeping 128 bits of entropy, 27 characters and the grouping:\n- (a) **Version 3:** the check is Σ aⁱ⁺¹·sᵢ over the 26 data characters in\n GF(32), with a = x under x⁵ + x² + 1 (the arithmetic bech32 uses). The 26\n weights are distinct and non-zero, so **every** single wrong character and\n **every** swap of two characters, adjacent or not, is caught; random\n errors are still caught 31/32. Synchronous again (no hash). The parser\n also refuses a code whose two unused final bits are not zero (26\n characters carry 130 bits for 128), so one string names one secret; today\n four strings parse to the same bytes. **Recommended.**\n- (b) Keep version 2.\n- (c) Crockford's mod-37 check symbol (catches single errors and adjacent\n swaps; the check position may show `*~$=U`).\nError detection, not cryptography: the code's 128 random bits protect the\naccount either way. No version-2 code exists outside tests.\n**Needed before:** the first publish.\n\n**D-47 — Bind an unlock method's name to the method, not only the account.** *(Decided 2026-09-27, option (a) — see Recorded.)*\nVersion 2 (D-28) binds the sealed name of an unlock method (\"Alice's phone\")\nto the account's routing key. That stops nothing the account's own key does\nnot already stop (another account's name would not open), and it does **not**\nstop the server showing one passkey's name against another of the same\naccount's passkeys — the case that misleads a person removing a method.\nOptions:\n- (a) **Version 3 of the label context:** `frameContext(\"<ns>/aad/unlock-label/v3\",\n methodType, SHA-256(credentialId))` for a passkey, the method type alone\n for the recovery code, matching the wrapped root key's binding (D-28).\n **Recommended.**\n- (b) Keep version 2.\n- (c) Bind both (routing key and method): no gain over (a), since the key is\n per account.\n**Needed before:** the first publish.\n\n**D-02 — Licensing.** *(Decided 2026-09-24 — see Recorded.)*\nApache-2.0 client packages can be used inside an AGPL application; AGPL on\n`blind-store` means anyone running a modified server as a service must\npublish their changes.\n**Recommendation:** Apache-2.0 for `crypto-core`, `identity`, `access` and\n`mailbox`; AGPL-3.0-only for `blind-store`; Apache-2.0 for the examples and\nCC-BY-4.0 for the docs; a DCO sign-off (not a CLA) for outside contributions.\n\n**D-03 — Engine product name under the Microtoll brand.** *(Decided 2026-09-24 — see Recorded.)*\n- (a) \"Microtoll Engine\", descriptive, with packages named by function\n (`@microtoll/identity`, …).\n- (b) A distinct product name, which needs a trade-mark search.\n\n**Recommendation:** (a) for v0. It can be revisited at the eight-week review.\n\n**D-04 — API stability promise for v0.x.** *(Decided 2026-09-25 — see Recorded.)*\n\n**D-05 — KDF label namespace.** *(Decided 2026-09-24 — see Recorded.)*\nA public library hard-wired to one product's label prefix is confusing, and\nchanging a label counts as substituting one (non-negotiable 1).\n- (a) One fixed label set for everyone.\n- (b) Labels become a *profile*: the construction is fixed, and only the\n namespace prefix varies; apps pass their own namespace, for example\n `\"myapp\"` → `\"myapp/routing/v1\"`.\n- (c) A new fixed `microtoll/...` label set.\n\n**Recommendation:** (b). Nothing in any construction changes. A namespace is\nrequired (no silent default), so two apps never share a derivation by\naccident. Retired labels stay reserved in every namespace.\n\n**D-06 — Correct the build plan's primitive list.** *(Adopted in M1 (2026-09-25): P-256, RFC 5903 §8.1, RFC 7914 §11, X-Wing vectors — closed by the M5 design.)*\nThe plan listed \"Ed25519/X25519 seed derivation\" and RFC 7748 and RFC 6070\nvectors. In fact the sealing curve is **P-256** (X25519 was retired because\nSafari lacks it) and PBKDF2 is **SHA-256** (RFC 6070 is SHA-1 only).\n**Recommendation:** P-256; RFC 5903 §8.1 plus a known-answer test of the v3\nseal key derivation instead of RFC 7748; RFC 7914 §11 (PBKDF2-HMAC-SHA-256)\ninstead of RFC 6070; X25519 only inside the X-Wing hybrid, tested through the\nX-Wing vectors. Adding test vectors is not new cryptography.\n\n**D-07 — Post-quantum hybrid in crypto-core.** *(Adopted in M1: hybrid off by default; the Chrome cross-check stays a release gate — closed by the M5 design.)*\nThe X-Wing seal (ECIES v2, `MLKEM768-X25519`) needs native browser support\n(Chrome 154 has it), Node has no native X-Wing, and no cross-check on a real\nbrowser has been done.\n**Recommendation:** off by default, behind an explicit opt-in; documented as\n\"hybrid mode (experimental; requires a browser with native\nMLKEM768-X25519)\"; tested through a test-only shim, never in a published\nruntime path; the Chrome seal/open cross-check a release gate before the\nopt-in is documented as usable.\n\n**D-08 — Source language.** *(Adopted in M1: JavaScript with hand-written declarations, no bundler — closed by the M5 design.)*\nJavaScript source with JSDoc; hand-written `.d.ts` declarations checked by\n`tsc --noEmit` in CI (TypeScript as a development dependency only); no\nbundler; zero runtime dependencies in the client packages.\n\n**D-09 — Supported runtimes.** *(Adopted in M1: Node ≥ 24; current Chrome, Firefox, Safari, Edge; the post-quantum path as stated — closed by the M5 design.)*\nNode ≥ 24; current Chrome, Firefox, Safari and Edge for the classical path;\nthe post-quantum path needs Node ≥ 24.7 on OpenSSL ≥ 3.5, or Chrome ≥ 154.\n\n**D-10 — Withdrawn from this log** (2026-09-27): not an engine decision.\n\n**D-11 — Identity package boundary.** *(Adopted in M2 as built (createIdentitySession with the UI as callbacks) — closed by the M5 design.)*\nThe package ends at \"authenticated connection, `auth-ok` fields, identity\nblob opened, sealing key adopted\". All UI (asking for a code, showing a code,\nconfirming a deletion) comes in through injected callbacks. A deterministic\navatar and handle stay out of v0; the passkey's user name is a\ncaller-supplied string.\n\n**D-12 — Where the sealing key lives.** *(Adopted in M2 as built (operations in crypto-core, storage and adoption in identity) — closed by the M5 design.)*\n\n**D-13 — Generic collection model for access and blind-store.** *(Decided 2026-09-25 by D-30 and D-33 — see Recorded.)*\nGeneralise an events-and-members model, taking the stronger mechanics of a\nseat-based model: an `expectedEpoch` refusal and a rotation completeness\ncheck; role-scoped capability replacement on rotation; hash-length and\nbyte-cap `CHECK`s; `timingSafeEqual` comparisons; then the coarse selector\nand the cover-traffic query.\n\n**D-14 — What stays out of v0.** *(Confirmed 2026-09-25 by D-33 — see Recorded.)*\nReporting and moderation; a public layer; operator disclosure keys; repeat\ngrants; live signals; Web Push. Guest (ephemeral) identities and live\nwatches over the selector stay in. Push can follow as an optional package\nonce its documented join is written into THREATMODEL.md.\n\n**D-15 — Non-extractable Ed25519 private keys.** *(Absorbed into D-21, 2026-09-24; built in identity, M2.)*\nDerive the public key once with an extractable import, then re-import the\nprivate key non-extractable: byte-identical outputs.\n\n**D-16 — Milestone numbering.** *(Adopted: the mailbox is M3b.)*\n`pqc-scan` stays M6; `mailbox` is M3b, built inside M5 (D-40).\n\n**D-17 — Terms and 18+ columns.** *(Decided 2026-09-25 by D-35 — see Recorded.)*\nThe core schema carries no policy columns; a registration hook lets an app\nenforce its own policy and store its own flag.\n\n**D-18 — Repository location and version control.** *(Adopted at M0: `D:\\PROJECTS\\microtoll`; a private GitHub remote from 2026-09-27; public only when the gate opens.)*\n`pqc-scan` has its own repository (D-42).\n\n**D-19 — Retention sweeps.** *(Decided 2026-09-25 by D-35 — see Recorded.)*\n`blind-store` sweeps expired and fully used link tokens, consumed and\nexpired mailbox rows, and rate counters older than two days — all of which\nwould otherwise keep ciphertext that carries keys, or activity records,\nindefinitely. Its effect on what a database copy reveals is in\nTHREATMODEL.md.\n\n**D-20 — Keep the passkey-as-PRF-only model and the open unlock lookup.** *(Decided 2026-09-25 by D-34 — see Recorded.)*\nThe server never verifies a WebAuthn assertion; the PRF output is the\nsecret; the unauthenticated lookup returns only wrapped material; the lookup\nis rate-limited in `blind-store`.\n\n**D-22 — Withdrawn from this log** (2026-09-27): not an engine decision.\n\n**D-26 — The recovery-code check character.** *(Decided 2026-09-25 — see Recorded; revisited by D-46.)*\nVersion 1 was the sum of the 16 bytes mod 32, which misses a mistyped\ncharacter whose error falls only in a byte's top three bits, and misses\nswapped neighbours.\n- (a) Keep version 1.\n- (b) Crockford's mod-37 check symbol.\n- (c) One character from SHA-256 of the 16 bytes (chosen).\n\n**D-30 — The object model (resolves D-13).** *(Decided 2026-09-25 — see Recorded.)*\n- (a) As designed. **Recommended.**\n- (b) Keep an event vocabulary in the package API (no renames).\n- (c) A wider redesign around a seat model for members.\n\n**D-31 — Access-layer hardening, version 2 (FORMATS.md §3).** *(Decided 2026-09-25 — see Recorded.)*\n- (a) All of it. **Recommended.** Every context is known before the open, no\n schema change, no new primitive.\n- (b) Signature labels only.\n- (c) Keep version 1.\n\n**D-32 — What M3 ships (FORMATS.md §4).** *(Decided 2026-09-25 — see Recorded.)*\n\n**D-33 — The collection model on the server (DESIGN.md §2).** *(Decided 2026-09-25 — see Recorded.)*\n- (a) As in DESIGN.md §2. **Recommended.**\n- (b) Keep domain-specific field names (`geoBucket`, `dateStart`,\n `dateEnd`) and the admin hash on the wire.\n- (c) One table per configured collection instead of a `collection` column.\n\n**D-34 — The bound handshake, server half, and the transport limits (DESIGN.md §3).** *(Decided 2026-09-25 — see Recorded.)*\n- (a) As in DESIGN.md §3. **Recommended.**\n- (b) Verify against the `Origin` header only, refusing a connection that\n sends none (breaks non-browser clients and every test client).\n- (c) Import `@microtoll/crypto-core` on the server for the framing (one\n implementation, but the server package then contains code that can\n decrypt).\n\n**D-35 — Schema rules as tests, the sweep, the database role (DESIGN.md §4).** *(Decided 2026-09-25 — see Recorded.)*\n- (a) As in DESIGN.md §4. **Recommended.**\n- (b) Also sweep objects a configurable time after their window ends.\n\n**D-36 — Library, reference server, deployment kit, example (DESIGN.md §5).** *(Decided 2026-09-25 — see Recorded.)*\n- (a) As in DESIGN.md §5. **Recommended.**\n- (b) Keep `pg-listen` as a third dependency.\n- (c) Make the example a calendar rather than notes.\n\n**D-37 — Correct the pointer's binding (amends D-31).** *(Decided 2026-09-25 — see Recorded.)*\n- (a) Bind the routing public key; the id inside. **Recommended; done.**\n- (b) Keep the object-id binding and add a plaintext object-id column to the\n pointer table (breaks the M0 rule: the server would hold every account's\n object list).\n- (c) No binding beyond the label.\n\n**D-38, D-39, D-40, D-41** *(Decided 2026-09-25 — see Recorded; the options\nare in `docs/DESIGN-M5.md`.)*\n\n**D-42, D-43, D-44, D-45** *(Decided 2026-09-27 — see Recorded; the options\nare in `docs/DESIGN-M6-pqc-scan.md`.)*\n\n**D-28 — Additional authenticated data on the four identity-layer seals (FORMATS.md §2.1–2.4).** *(Decided 2026-09-25 — see Recorded; the label binding revisited by D-47.)*\n- (a) All four, as designed. **Recommended.**\n- (b) Only the session record and the wrapped root key.\n- (c) None.\n\n**D-29 — The handshake signature is bound to its purpose and origin (FORMATS.md §2.5).** *(Decided 2026-09-25 — see Recorded.)*\n- (a) Label and origin, as designed. **Recommended.**\n- (b) Label only.\n- (c) Keep the bare nonce."
954
+ }
955
+ ]
956
+ }
957
+ ]