From 44263c65f2d2d302f683577c9ac8b4531a24872e Mon Sep 17 00:00:00 2001 From: Tom Wilson Date: Wed, 10 Jun 2026 16:49:43 -0400 Subject: [PATCH 1/4] fix(proxy): use undici's own fetch so the dispatcher version matches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real root cause of the proxy 502s (UND_ERR_INVALID_ARG): the code passed an npm-undici `Agent` as the `dispatcher` to Node's **built-in** global `fetch`. Built-in fetch validates the dispatcher against its own bundled undici's Dispatcher class; an Agent from the npm `undici` package is a different class when the two undici versions differ, so it is rejected with UND_ERR_INVALID_ARG — before any lookup/connector code runs. That is why #42 (lookup forms) and #43 (connector override) both failed with the exact same error, and why it worked locally (local Node's bundled undici matches npm undici 6.26). Fix: call undici's own `fetch` (imported from the same package as Agent), so dispatcher and fetch are guaranteed the same version. SSRF validation and connector IP-pinning are unchanged. Verified locally end-to-end (undici fetch + pinned dispatcher + full response body read): https/http public hosts return 200; SSRF targets remain blocked. tsc + build clean; proxy.test.ts 52/52. Co-Authored-By: Claude Fable 5 --- src/routes/proxy.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/routes/proxy.ts b/src/routes/proxy.ts index 631960a..d947577 100644 --- a/src/routes/proxy.ts +++ b/src/routes/proxy.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import { Agent, buildConnector } from 'undici'; +import { Agent, buildConnector, fetch as undiciFetch } from 'undici'; import { config } from '../config.js'; import { validateProxyRequest, ProxyRequest } from '../utils/validation.js'; import { resolveAndValidate } from '../utils/ssrf.js'; @@ -141,14 +141,14 @@ proxy.post('/', async (c) => { let currentUrl = proxyReq.url; let redirectCount = 0; - let response: Response | null = null; + let response: Awaited> | null = null; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { while (redirectCount <= maxRedirects) { - const fetchOptions: RequestInit & { dispatcher?: Agent } = { + const fetchOptions: Parameters[1] = { method: redirectCount === 0 ? method : 'GET', // Follow redirects with GET headers: outgoingHeaders, signal: controller.signal, @@ -159,10 +159,13 @@ proxy.post('/', async (c) => { // Only include body on first request and if method supports it if (redirectCount === 0 && proxyReq.body !== undefined && !['GET', 'HEAD'].includes(method)) { - fetchOptions.body = JSON.stringify(proxyReq.body); + fetchOptions!.body = JSON.stringify(proxyReq.body); } - response = await fetch(currentUrl, fetchOptions); + // Use undici's own fetch so the dispatcher (also undici) is the SAME + // version. Passing an npm-undici Agent to Node's built-in global fetch + // fails with UND_ERR_INVALID_ARG when the two undici versions differ. + response = await undiciFetch(currentUrl, fetchOptions); // Check for redirect if ([301, 302, 303, 307, 308].includes(response.status)) { From a39d5f7e31f564508177f608c9f597b85f5df77e Mon Sep 17 00:00:00 2001 From: Tom Wilson Date: Thu, 11 Jun 2026 17:04:55 -0400 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20CAP-Tree=20v0.3=20HTTP=20binding=20?= =?UTF-8?q?=E2=80=94=20content-addressed=20object=20host?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes ZenBin a conformant CAP-Tree v0.3 host alongside the existing v0.2 pages API (nothing migrated). Implements the PRD in plan-prd.md. - Vendor cap-tree-core (twilson63/cap-tree core/, MIT) into src/vendor/cap-tree-core; add DOM to tsconfig lib for WebCrypto types. All canonicalization/hashing/verification comes from the library. - Storage (src/storage/capTreeDb.ts): objects, tree-index, refs LMDB environments; immutable content-addressed objects; atomic refs-chain head advance via transactionSync (fork-safe). config.capTree.maxObjectBytes. - ObjectService (src/services/objectService.ts): host-side validation (HB § 5) — envelope + structural checks, genesis owner match, parent resolution, verifyRootChain for non-genesis/merge roots, refs target + owner checks + chain extension. Merge roots are NOT policy-policed. - Routes: POST/GET /v1/objects (signed publish / open retrieve, immutable cache), /v1/trees reads (refs, refs/history, roots, roots/history, resolve), /v1/reviews query, /.well-known/cap-tree.json discovery. Objects consume page quota 1:1 via reserve/release. - Errors: 13 CAP-Tree codes; 422 bodies carry errors[]; 409 carries currentSeq/currentHash recovery info. - Tests: cap-tree-objects (vectors round-trip byte-exact, idempotency, tamper/parent/owner rejections, oversize, policy-violating merge accepted), cap-tree-refs (chain enforcement, conflict/gap → 409, non-owner → 403, history), cap-tree-reads (roots/history/resolve/ reviews/discovery), cap-tree-lifecycle (build → publish → clone → verify via API-only resolver). setup.ts cleans the new content-addressed envs so fixed-hash objects don't leak across files. All § 8 acceptance criteria pass: 429/429 tests green, tsc --noEmit and production build clean, v0.2 suite unchanged. No new runtime dependency beyond the vendored cap-tree-core. Co-Authored-By: Claude Fable 5 --- plan-prd.md | 382 +++++++++++ src/config.ts | 7 + src/docs/agentInstructions.ts | 16 + src/errors.ts | 15 + src/index.ts | 5 + src/routes/objects.ts | 114 ++++ src/routes/trees.ts | 112 ++++ src/routes/wellKnown.ts | 12 + src/services/container.ts | 4 + src/services/interfaces.ts | 22 + src/services/objectService.ts | 432 +++++++++++++ src/storage/capTreeDb.ts | 237 +++++++ src/storage/db.ts | 5 + src/test/cap-tree-lifecycle.test.ts | 151 +++++ src/test/cap-tree-objects.test.ts | 179 +++++ src/test/cap-tree-reads.test.ts | 117 ++++ src/test/cap-tree-refs.test.ts | 119 ++++ src/test/fixtures/cap-tree-keypair-owner.json | 13 + .../fixtures/cap-tree-keypair-reviewer.json | 13 + src/test/fixtures/cap-tree-vectors.json | 609 ++++++++++++++++++ src/test/setup.ts | 8 +- src/vendor/cap-tree-core/LICENSE | 25 + src/vendor/cap-tree-core/crypto.ts | 122 ++++ src/vendor/cap-tree-core/encoding.ts | 79 +++ src/vendor/cap-tree-core/index.ts | 25 + src/vendor/cap-tree-core/objects.ts | 228 +++++++ src/vendor/cap-tree-core/verify.ts | 278 ++++++++ tsconfig.json | 2 +- 28 files changed, 3329 insertions(+), 2 deletions(-) create mode 100644 plan-prd.md create mode 100644 src/routes/objects.ts create mode 100644 src/routes/trees.ts create mode 100644 src/services/objectService.ts create mode 100644 src/storage/capTreeDb.ts create mode 100644 src/test/cap-tree-lifecycle.test.ts create mode 100644 src/test/cap-tree-objects.test.ts create mode 100644 src/test/cap-tree-reads.test.ts create mode 100644 src/test/cap-tree-refs.test.ts create mode 100644 src/test/fixtures/cap-tree-keypair-owner.json create mode 100644 src/test/fixtures/cap-tree-keypair-reviewer.json create mode 100644 src/test/fixtures/cap-tree-vectors.json create mode 100644 src/vendor/cap-tree-core/LICENSE create mode 100644 src/vendor/cap-tree-core/crypto.ts create mode 100644 src/vendor/cap-tree-core/encoding.ts create mode 100644 src/vendor/cap-tree-core/index.ts create mode 100644 src/vendor/cap-tree-core/objects.ts create mode 100644 src/vendor/cap-tree-core/verify.ts diff --git a/plan-prd.md b/plan-prd.md new file mode 100644 index 0000000..c1993f3 --- /dev/null +++ b/plan-prd.md @@ -0,0 +1,382 @@ +# PRD: CAP-Tree v0.3 HTTP Binding for ZenBin + +**Status:** Ready for implementation +**Date:** 2026-06-11 +**Spec:** https://github.com/twilson63/cap-tree — normative references below are +`data-model.md` (DM §) and `http-binding.md` (HB §) in that repo. +**Showcase / context:** https://zenbin.org/p/cap-tree-v0-3-showcase + +## 1. Goal + +Make ZenBin the first conformant CAP-Tree v0.3 **host**: content-addressed +object storage, tree read endpoints, refs-chain enforcement, and a discovery +document — alongside (not replacing) the existing v0.2 pages API. When this +ships, the rebuilt zen-vcs client has a live host to push/pull/clone against, +and the HB § 8 conformance criteria can be evaluated against zenbin.org. + +**Definition of done:** all acceptance criteria in § 8 pass; the committed +spec test vectors round-trip byte-exactly through the new endpoints; existing +v0.2 tests stay green. + +## 2. Non-goals (explicitly out of scope) + +- No changes to existing pages/keys/billing/subdomain behavior. v0.2 pages + and v0.3 objects coexist; nothing is migrated. +- No policy gating of merges. Per HB § 5, the host MUST NOT reject a merge + root for failing its declared policy — policy is a client-side verdict. + (Optional advisory annotation is a stretch goal, § 9.) +- No human-facing HTML rendering of tree objects (`/p/...`-style). Later. +- No encrypted blobs, no chunking *enforcement* (chunk manifests are just + objects; the host stores them like any other). +- No sharding integration (the `src/sharding/` module stays unused for now). +- No npm publish of zen-vcs or client work — this PRD is server-only. + +## 3. Dependency: `cap-tree-core` + +All canonicalization, hashing, envelope verification, structural validation, +and chain verification MUST come from `cap-tree-core` (the reference library +in the spec repo, `core/`) — do not reimplement any of it in ZenBin. + +The package is not on npm yet. Resolution order: + +1. **Preferred:** Tom publishes `cap-tree-core@0.3.0` (`cd core && npm publish` + in the spec repo — it's ready; `prepublishOnly` runs the suite). Then + `npm install cap-tree-core` here. +2. **Fallback if not published when you start:** vendor it — copy the spec + repo's `core/src/` into `vendor/cap-tree-core/` with its LICENSE, build it + with the existing tsconfig, and leave a `// VENDORED from` header noting + the commit. Swap to the npm package when available. + +Key imports you will use: `verifyEnvelope`, `validateObject`, `objectHash`, +`blobHash`, `verifyRootChain`, `verifyRefs`, `canonicalize`, `HASH_RE`, and +the types (`SignatureEnvelope`, `TreeRoot`, `Refs`, `ObjectRef`). + +Also copy `test-vectors/vectors.json` from the spec repo into +`src/test/fixtures/cap-tree-vectors.json` (with a header comment naming the +source commit). The integration tests in § 7 are driven by it. + +## 4. Storage design + +Follow the existing pattern in `src/storage/db.ts` (`initDatabase()` opens +named LMDB instances; key-naming conventions documented at the top). + +### 4.1 New LMDB databases + +| Instance | Path suffix | Key → Value | +|---|---|---| +| `objectDb` | `{lmdbPath}-objects` | `{hash}` → `StoredObject` | +| `treeIndexDb` | `{lmdbPath}-tree-index` | see § 4.3 | +| `refsDb` | `{lmdbPath}-refs` | see § 4.4 | + +### 4.2 StoredObject + +```typescript +interface StoredObject { + hash: string; // objectHash or blobHash — also the key + kind: 'envelope' | 'blob'; + envelope?: SignatureEnvelope; // kind === 'envelope' + blobBase64?: string; // kind === 'blob' (raw bytes, base64) + objectType?: string; // payload.type for envelopes + uploaderKeyId: string; // ZenBin agent key that published it + size: number; // bytes of canonical/raw content + created_at: string; // ISO 8601 (server clock) +} +``` + +Objects are **immutable**: a second publish at an existing hash is a no-op +returning the stored record (idempotent, HB § 3.1 step 3). There is no +update or delete path in v0.3. + +### 4.3 treeIndexDb keys + +Written when a `tree-root` envelope is stored: + +- `root:{treeId}:{rootHash}` → `{ parents: string[], timestamp, message }` — + membership + cheap history metadata. `treeId` is the genesis hash (DM § 3.1); + for a genesis root, `treeId === rootHash`. +- `rootmeta:{rootHash}` → `{ treeId, ownerFingerprint }` — reverse lookup so + reviews and refs can resolve which tree a root belongs to. +- `reviewref:{referencedRootHash}:{objectHash}` → `{ objectType, recipient }` + — written for `review-request` (keyed by `payload.root.hash` and + `payload.target.hash`) and `review-response` (keyed by `payload.root.hash`), + powering the `/v1/reviews` query (§ 5.6). + +Determining `treeId` on publish: run `verifyRootChain` from cap-tree-core +with a resolver backed by `objectDb` (§ 5.2). The walk both validates the +root and yields `genesisHash` = treeId. Ancestors must already be stored — +publish order is parents-first, which the binding's design already implies +(you cannot reference what doesn't hash-resolve). + +### 4.4 refsDb keys + +- `head:{treeId}` → `{ seq, hash }` — current chain head. +- `chain:{treeId}:{seq}` (zero-padded to 10 digits for lexicographic order) + → the stored refs **envelope hash** (the envelope itself lives in + `objectDb` like every other object). + +The full refs history is retained (it's the equivocation-evidence trail). + +## 5. Endpoints + +New route module: `src/routes/objects.ts` and `src/routes/trees.ts`, +registered in `src/index.ts` alongside the existing `app.route()` calls: + +```typescript +app.route('/v1/objects', objects); +app.route('/v1/trees', trees); +app.route('/v1/reviews', reviews); // may live in trees.ts +``` + +Add a service following the DI pattern: `IObjectService` in +`src/services/interfaces.ts`, implemented in `src/services/objectService.ts`, +wired in `src/services/container.ts`. Routes stay thin; validation + +storage logic lives in the service. + +### 5.1 `POST /v1/objects` — publish + +Auth: existing `signedAgent` middleware (`requireSignedAgent`), which already +handles CAP-*/X-Zenbin-* header aliases, the canonical string, timestamp skew +(300 s default), and nonce replay via `nonceDb`. **No new signing code.** +Note this satisfies HB § 2.1 — the request signature (transport auth, any +registered ZenBin key) is distinct from the envelope signature (protocol +authorship); both are checked. + +Two content types: + +- `application/vnd.cap-tree+json` — body is a signature envelope. + 1. `verifyEnvelope(env)` → 422 `CAP_ENVELOPE_INVALID` on failure. + 2. `validateObject(env.payload)` → 422 `CAP_OBJECT_INVALID` with the + violation list in the body. + 3. Compute `hash = objectHash(env.payload)`. If stored: return **200** + with the existing record (idempotent). + 4. Type-specific host validation (§ 5.2). Failures → 422. + 5. Store; write indexes; return **201** + `{ hash, url: "{baseUrl}/v1/objects/{hash}", received }`. +- `application/octet-stream` — raw blob bytes. + 1. `hash = blobHash(bytes)`; idempotent-200 if present. + 2. Enforce `config.capTree.maxObjectBytes` → 413 `OBJECT_TOO_LARGE`. + 3. Store as `kind: 'blob'`; return 201. + +Size limit: add `capTree.maxObjectBytes` to `src/config.ts` (env +`CAP_TREE_MAX_OBJECT_BYTES`, default `10485760`). Applies to both kinds +(canonical bytes length for envelopes). + +Billing: count each **201** (not idempotent 200s) against the key's monthly +quota via the existing `reservePageQuota`/`releasePageQuota` mechanism — +objects consume page quota 1:1 for now. Per HB § 7, quota exhaustion is a +plain 402/429; it MUST NOT vary by object type or content. + +### 5.2 Host-side validation (HB § 5) — the heart of this PRD + +For `tree-root` envelopes: + +1. Build a resolver over `objectDb`: + `const resolve = async (ref) => objectDb lookup → stored envelope ?? null`. +2. **Genesis** (`parents: []`): require `env.signerFingerprint === + payload.ownerFingerprint` (422 `CAP_OWNER_MISMATCH`). treeId = its hash. +3. **Non-genesis:** run `verifyRootChain(env, candidateTreeId, resolve)` + where `candidateTreeId` comes from `rootmeta:{parents[0].hash}` (422 + `CAP_PARENT_UNKNOWN` if the first parent isn't a stored, indexed root). + A failed chain verdict → 422 `CAP_CHAIN_INVALID` with `verdict.errors`. + This single call covers: parents resolvable **and** hash-verified, owner + signing (including § 7.2 key rotation), and structural integrity of every + ancestor — do not re-derive any of it by hand. +4. **Entry refs are NOT required to resolve** at publish time (DM allows + trees referencing blobs the host hasn't seen; clients verify on fetch). + Do not validate entry existence — only entry *format* (already covered + by `validateObject`). +5. **Merge roots:** validated exactly like any root. Do NOT evaluate policy + (HB § 5: "MUST NOT reject a merge root solely for failing the declared + policy"). + +For `refs` envelopes: + +1. Resolve `treeId` — must have a stored genesis (`root:{treeId}:{treeId}` + exists) → 422 `CAP_TREE_UNKNOWN`. +2. Owner check: every branch/tag target must be a stored root of this tree + (`rootmeta` lookup) → 422 `CAP_REF_TARGET_UNKNOWN`; the refs envelope + signer must equal the tree's current owner. Determine current owner via + `verifyRootChain` on the `branches.main` target (or any target) — its + verified tip owner is authoritative. Mismatch → 403 `CAP_NOT_OWNER`. +3. Chain extension: read `head:{treeId}`. + - No head: require `seq === 1 && prev === null`, else 409 `CAP_REFS_CONFLICT`. + - Head `{seq: n, hash: h}`: require `seq === n + 1 && prev === h`, else + **409** `CAP_REFS_CONFLICT` with `{ currentSeq: n, currentHash: h }` in + the body (the client needs this to recover — it's the + `--force-with-lease` handshake). +4. Store envelope in `objectDb`, write `chain:` entry, advance `head:`. + These writes MUST be atomic (single LMDB transaction — + `db.transaction(() => …)` per the lmdb package API) so a concurrent + publish can't fork the chain. + +For `review-request` / `review-response` / `tree` / `chunks`: structural +validation only (already done), plus `reviewref:` index writes for review +types. No authorization beyond a valid envelope — HB § 6 allows anyone to +publish review messages; whether they *count* is policy, i.e. not ours. + +### 5.3 `GET /v1/objects/{hash}` — retrieve + +Unauthenticated (HB § 2.1: reads are open). 404 `OBJECT_NOT_FOUND` on miss. + +- Envelope: `Content-Type: application/vnd.cap-tree+json`, body is the + stored envelope **exactly as stored** (clients re-hash what they receive — + do not re-serialize through any transform that could reorder keys; store + and return the raw canonical-envelope JSON string). +- Blob: `Content-Type: application/octet-stream`, raw bytes. +- Send `ETag: "{hash}"` and `Cache-Control: public, max-age=31536000, immutable` + — content addressing makes this safe and is a free CDN win. + +### 5.4 Tree read endpoints (HB § 4) + +All unauthenticated, all returning full ObjectRefs (`{id?, hash}`) so every +hop is client-verifiable. All are derivable from `objectDb` + indexes. + +- `GET /v1/trees/{treeId}` and `GET /v1/trees/{treeId}/refs` — current refs + envelope (via `head:` → `chain:` → `objectDb`). 404 `CAP_TREE_UNKNOWN` / + 404 `REFS_NOT_FOUND` if no refs published yet. +- `GET /v1/trees/{treeId}/refs/history?since={seq}&limit={n}` — refs + envelopes newest-first from the `chain:` index. Cursor = seq. Default + limit 20, max 100 (mirror `listPagesByOwner` conventions). +- `GET /v1/trees/{treeId}/roots/{rootHash}` — the root envelope, 404 if not + a member of this tree (check `root:{treeId}:{rootHash}`). +- `GET /v1/trees/{treeId}/roots/{rootHash}/history?limit={n}` — ancestor + walk: BFS over `parents` via stored envelopes, newest-first, returning + `{ roots: [{ hash, parents, message, timestamp, entryCount }], next_cursor }`. +- `GET /v1/trees/{treeId}/resolve?root={rootHash}&path=a/b/c` — split path + on `/`, walk subtree entries (fetch each `tree` object by hash), return + the terminal entry's `{ path, kind, ref }`. 404 `PATH_NOT_FOUND` on a miss + or on traversal through a `blob`. + +### 5.5 Discovery (HB § 1) + +Add to `src/routes/wellKnown.ts`: + +``` +GET /.well-known/cap-tree.json +``` + +```json +{ + "protocol": "cap-tree", + "specVersion": 3, + "endpoints": { "objects": "/v1/objects", "trees": "/v1/trees", "keys": "/v1/keys" }, + "maxObjectBytes": , + "operator": "ZenBin (zenbin.org)" +} +``` + +Use `config.baseUrl` only for docs; the endpoint paths are relative per the +binding. Also append a short CAP-Tree v0.3 section to the agent docs +templates in `src/docs/agentInstructions.ts` (skill.md) pointing at the +discovery doc and the spec repo. + +### 5.6 `GET /v1/reviews` (HB § 4 query) + +Query params: `tree` (required), `type` (`review-request` | `review-response`), +`recipient` (fingerprint), `outcome`, `root` (rootHash), `limit`, `cursor`. +Implementation: iterate `reviewref:{rootHash}:` for each root of the tree — +or, when `root=` is given, just that prefix — then filter by the remaining +params against stored envelopes. Returns `{ reviews: [envelope...], +next_cursor }`. Keep it simple; this is a convenience index, not a search +engine (HB § 4: "hosts provide them as indexes"). + +## 6. Errors + +Extend `src/errors.ts` ErrorCodes (keep the established +`{ error, error_code }` shape and `errorResponse()` builder): + +`CAP_ENVELOPE_INVALID` (422), `CAP_OBJECT_INVALID` (422), +`CAP_OWNER_MISMATCH` (422), `CAP_PARENT_UNKNOWN` (422), +`CAP_CHAIN_INVALID` (422), `CAP_TREE_UNKNOWN` (404), +`CAP_REF_TARGET_UNKNOWN` (422), `CAP_NOT_OWNER` (403), +`CAP_REFS_CONFLICT` (409), `OBJECT_NOT_FOUND` (404), +`OBJECT_TOO_LARGE` (413), `REFS_NOT_FOUND` (404), `PATH_NOT_FOUND` (404). + +422 bodies for validation failures MUST include an `errors: string[]` array +carrying the verdict/violation messages from cap-tree-core — clients debug +against these. + +## 7. Tests + +Follow the conventions in `src/test/cap-attestation.test.ts` (vitest, +`initDatabase()` in beforeAll, `createTestSigner` + `jsonCapSignedRequest` +helpers from `src/test/helpers/signing.ts`, enterprise plan to bypass +billing, unique IDs per test). + +New files: + +1. **`src/test/cap-tree-objects.test.ts`** — publish/retrieve: + - Every envelope in `fixtures/cap-tree-vectors.json` publishes (201) in + dependency order: genesisRoot → secondRoot → featureRoot → + reviewRequest → reviewResponse → mergeRoot → refsSeq1 → refsSeq2. + - Re-publishing any → 200 with identical record (idempotency). + - `GET /v1/objects/{hash}` returns bytes that re-hash to the address + (assert via cap-tree-core `objectHash`/`blobHash`, not string compare). + - Blob publish + retrieve round-trips the vector blob. + - Tampered envelope (mutate `message`) → 422 `CAP_ENVELOPE_INVALID`. + - Root with unknown parent → 422 `CAP_PARENT_UNKNOWN`. + - Root signed by a non-owner key (sign with a second test signer's + protocol key) → 422 `CAP_CHAIN_INVALID`. + - Oversize blob → 413. + - Merge root **without** approvals (policy-violating but valid) → + **201**. This is the "host MUST NOT police policy" regression test. +2. **`src/test/cap-tree-refs.test.ts`** — chain enforcement: + - seq 1 → 201; seq 2 with correct prev → 201; head advances. + - seq 2 replayed → 200 (idempotent, same hash). + - Conflicting seq 2 (different branches, valid signature) → 409 with + `currentSeq`/`currentHash` in body. + - seq gap (1 → 3) → 409. + - Refs signed by non-owner → 403 `CAP_NOT_OWNER`. + - `refs/history` returns the chain newest-first. +3. **`src/test/cap-tree-reads.test.ts`** — tree endpoints + discovery: + - roots/{hash}, roots history walk, resolve?path= through the vector + subtree (`docs/README.md`), reviews query by tree/type/outcome, + - `/.well-known/cap-tree.json` shape matches HB § 1. +4. **Lifecycle test (`src/test/cap-tree-lifecycle.test.ts`)** — the HB § 8 + criterion, in-process: using cap-tree-core's `generateKeyPair` + + `signEnvelope`, create a fresh tree (genesis → 3 commits → branch → + review request → approval from a second keypair → merge → refs updates), + publish everything through the API, then "clone": fetch refs → walk → + verify with `verifyRootChain`/`verifyMerge`/`verifyRefs` against only + what the API returned. This test doubles as the conformance-suite seed. + +Existing test suites MUST stay green (`npx vitest run`). + +## 8. Acceptance criteria + +1. All four new test files pass; full suite green; `npm run typecheck` clean. +2. All 8 vector envelopes + the vector blob round-trip byte-exactly and are + served at their vector-stated hashes (HB § 8 criterion 2). +3. Publish is idempotent; objects are immutable (no path mutates a stored + object). +4. Refs chain: out-of-order/conflicting seq rejected with 409 + recovery + info; chain writes atomic; full history retrievable. +5. A policy-violating merge is accepted (201) — with a test proving it. +6. GET endpoints work unauthenticated; POST requires a valid signed request + from a registered key with replay protection (existing middleware). +7. `/.well-known/cap-tree.json` served and accurate. +8. No regression in v0.2 endpoints (full existing suite green). +9. No new runtime dependency other than `cap-tree-core` (or its vendored + copy). + +## 9. Stretch goals (do not block on these) + +- Advisory policy annotation on root reads: `"policyEvaluation"` field + computed via cap-tree-core `verifyMerge`, clearly marked advisory (HB § 5 + allows it; clients must not rely on it). +- `HEAD /v1/objects/{hash}` for cheap existence checks. +- Per-uploader object listing (`GET /v1/objects?uploader=me`) mirroring the + pages owner index. + +## 10. Sequencing for the implementing agent + +1. Dependency in place (§ 3) + fixtures copied. +2. Storage (§ 4): db.ts additions, types, objectService + interfaces + + container wiring. Unit-testable without routes. +3. `POST/GET /v1/objects` with envelope/blob handling + § 5.2 validation. + Land cap-tree-objects.test.ts here. +4. Refs enforcement + cap-tree-refs.test.ts. +5. Tree reads + reviews + discovery + cap-tree-reads.test.ts. +6. Lifecycle test, docs template updates, typecheck/lint pass. + +Each step leaves the repo green; commit per step. diff --git a/src/config.ts b/src/config.ts index e2b20c2..db6f2d8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -89,6 +89,13 @@ export const config = { }; }, + // CAP-Tree v0.3 host (content-addressed object storage) + get capTree() { + return { + maxObjectBytes: parseInt(process.env.CAP_TREE_MAX_OBJECT_BYTES || '10485760', 10), + }; + }, + // Subdomains get subdomains() { return { diff --git a/src/docs/agentInstructions.ts b/src/docs/agentInstructions.ts index 4b5087e..76848de 100644 --- a/src/docs/agentInstructions.ts +++ b/src/docs/agentInstructions.ts @@ -1055,6 +1055,22 @@ Agents should switch on \`error_code\` for programmatic handling. The \`error\` 5. Use standalone pages for one-off artifacts such as reports and demos. 6. Use the \`video\` field for uploaded video assets, or embed remote video inside HTML when that better fits your workflow. +## CAP-Tree v0.3 (content-addressed object host) + +ZenBin is also a conformant CAP-Tree v0.3 host: content-addressed object +storage, tree/refs read endpoints, and refs-chain enforcement — a live host for +push/pull/clone of signed object trees. This is independent of the v0.2 pages +API above; nothing is migrated. + +- Discovery: \`GET ${baseUrl}/.well-known/cap-tree.json\` +- Publish (signed): \`POST ${baseUrl}/v1/objects\` — \`application/vnd.cap-tree+json\` (envelope) or \`application/octet-stream\` (blob) +- Retrieve (open): \`GET ${baseUrl}/v1/objects/{hash}\` +- Tree reads (open): \`GET ${baseUrl}/v1/trees/{treeId}\` and \`/refs\`, \`/refs/history\`, \`/roots/{rootHash}\`, \`/roots/{rootHash}/history\`, \`/resolve?root=&path=\` +- Reviews (open): \`GET ${baseUrl}/v1/reviews?tree={treeId}\` + +All canonicalization, hashing, and verification follow the CAP-Tree spec: +https://github.com/twilson63/cap-tree + ## Support - Website: ${baseUrl} diff --git a/src/errors.ts b/src/errors.ts index 920b615..3282089 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -64,6 +64,21 @@ export const ErrorCodes = { VIDEO_NOT_FOUND: 'VIDEO_NOT_FOUND', MARKDOWN_NOT_FOUND: 'MARKDOWN_NOT_FOUND', IMAGE_NOT_FOUND: 'IMAGE_NOT_FOUND', + + // CAP-Tree v0.3 errors (PRD § 6). 422 bodies carry an `errors: string[]`. + CAP_ENVELOPE_INVALID: 'CAP_ENVELOPE_INVALID', // 422 + CAP_OBJECT_INVALID: 'CAP_OBJECT_INVALID', // 422 + CAP_OWNER_MISMATCH: 'CAP_OWNER_MISMATCH', // 422 + CAP_PARENT_UNKNOWN: 'CAP_PARENT_UNKNOWN', // 422 + CAP_CHAIN_INVALID: 'CAP_CHAIN_INVALID', // 422 + CAP_TREE_UNKNOWN: 'CAP_TREE_UNKNOWN', // 404 + CAP_REF_TARGET_UNKNOWN: 'CAP_REF_TARGET_UNKNOWN', // 422 + CAP_NOT_OWNER: 'CAP_NOT_OWNER', // 403 + CAP_REFS_CONFLICT: 'CAP_REFS_CONFLICT', // 409 + OBJECT_NOT_FOUND: 'OBJECT_NOT_FOUND', // 404 + OBJECT_TOO_LARGE: 'OBJECT_TOO_LARGE', // 413 + REFS_NOT_FOUND: 'REFS_NOT_FOUND', // 404 + PATH_NOT_FOUND: 'PATH_NOT_FOUND', // 404 } as const; export type ErrorCode = typeof ErrorCodes[keyof typeof ErrorCodes]; diff --git a/src/index.ts b/src/index.ts index 0b2ef5e..3aa56cc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,8 @@ import { serveSubdomainPage } from './routes/subdomainRender.js'; import { adminKeys } from './routes/adminKeys.js'; import { keys } from './routes/keys.js'; import { verify } from './routes/verify.js'; +import { objects } from './routes/objects.js'; +import { trees, reviews } from './routes/trees.js'; // Type for context variables type Variables = { @@ -147,6 +149,9 @@ app.route('/v1/keys', keys); app.route('/v1/verify', verify); app.route('/v1/admin/keys', adminKeys); app.route('/v1/billing', billing); +app.route('/v1/objects', objects); +app.route('/v1/trees', trees); +app.route('/v1/reviews', reviews); // Agent instructions app.route('/api/agent', agent); diff --git a/src/routes/objects.ts b/src/routes/objects.ts new file mode 100644 index 0000000..bf55013 --- /dev/null +++ b/src/routes/objects.ts @@ -0,0 +1,114 @@ +/** + * CAP-Tree v0.3 object endpoints (PRD § 5.1 / § 5.3). + * + * POST /v1/objects — publish an envelope or a raw blob (signed request). + * GET /v1/objects/{hash} — retrieve, unauthenticated, immutable + cacheable. + * + * Routes are thin: all validation/storage/indexing lives in ObjectService. + */ +import { Context, Hono } from 'hono'; +import { config } from '../config.js'; +import { requireSignedAgent } from '../middleware/signedAgent.js'; +import { ErrorCodes, errorResponse } from '../errors.js'; +import { HASH_RE, type SignatureEnvelope } from '../vendor/cap-tree-core/index.js'; +import type { Services } from '../services/container.js'; + +const objects = new Hono(); + +const CAP_CONTENT_TYPE = 'application/vnd.cap-tree+json'; + +function getServices(c: Context): Services { + return c.get('services')!; +} +function getSignedKey(c: Context): string { + const signedAgent = c.get('signedAgent'); + if (!signedAgent) throw new Error('Signed agent context missing'); + return signedAgent.key.keyId; +} + +// POST /v1/objects — publish (signed). Envelope or blob by Content-Type. +objects.post('/', requireSignedAgent, async (c) => { + const keyId = getSignedKey(c); + const services = getServices(c); + const contentType = c.req.header('content-type') || ''; + + // Objects consume monthly page quota 1:1 (HB § 7 — content-blind). Reserve + // optimistically, release on anything that is not a fresh 201. + const reservation = services.pages.reservePageQuota(keyId); + if (!reservation.allowed) { + return c.json({ + error: reservation.reason, + plan: reservation.plan, + upgradeUrl: `${config.baseUrl}/v1/billing/checkout?plan=pro`, + }, 402); + } + + let result; + if (contentType.includes(CAP_CONTENT_TYPE)) { + let env: SignatureEnvelope; + try { + env = JSON.parse(c.get('rawBody') || (await c.req.text())) as SignatureEnvelope; + } catch { + services.pages.releasePageQuota(keyId); + return errorResponse(ErrorCodes.INVALID_JSON, 'Invalid JSON envelope', 400); + } + result = await services.objects.publishEnvelope(env, keyId); + } else if (contentType.includes('application/octet-stream')) { + const bytes = new Uint8Array(await c.req.arrayBuffer()); + result = await services.objects.publishBlob(bytes, keyId); + } else { + services.pages.releasePageQuota(keyId); + return errorResponse( + ErrorCodes.INVALID_REQUEST, + `Content-Type must be ${CAP_CONTENT_TYPE} or application/octet-stream`, + 415, + ); + } + + if (result.status !== 'created') { + services.pages.releasePageQuota(keyId); + } + + if (result.status === 'error') { + return errorResponse(result.code, result.message, result.httpStatus, { + ...(result.errors ? { errors: result.errors } : {}), + ...(result.extra ?? {}), + }); + } + + const stored = result.stored; + return c.json( + { + hash: stored.hash, + url: `${config.baseUrl}/v1/objects/${stored.hash}`, + received: stored.created_at, + }, + result.status === 'created' ? 201 : 200, + ); +}); + +// GET /v1/objects/{hash} — retrieve (open). Content-addressed → immutable cache. +objects.get('/:hash', (c) => { + const hash = c.req.param('hash'); + if (!HASH_RE.test(hash)) { + return errorResponse(ErrorCodes.OBJECT_NOT_FOUND, 'Object not found', 404); + } + const services = getServices(c); + const stored = services.objects.get(hash); + if (!stored) { + return errorResponse(ErrorCodes.OBJECT_NOT_FOUND, 'Object not found', 404); + } + + c.header('ETag', `"${hash}"`); + c.header('Cache-Control', 'public, max-age=31536000, immutable'); + + if (stored.kind === 'blob') { + c.header('Content-Type', 'application/octet-stream'); + return c.body(Buffer.from(stored.blobBase64 ?? '', 'base64')); + } + + c.header('Content-Type', CAP_CONTENT_TYPE); + return c.body(services.objects.envelopeBytes(stored)); +}); + +export { objects }; diff --git a/src/routes/trees.ts b/src/routes/trees.ts new file mode 100644 index 0000000..1612721 --- /dev/null +++ b/src/routes/trees.ts @@ -0,0 +1,112 @@ +/** + * CAP-Tree v0.3 tree read endpoints + reviews query (PRD § 5.4 / § 5.6). + * + * All endpoints are unauthenticated (HB § 2.1 — reads are open) and return full + * envelopes / ObjectRefs so every hop is client-verifiable. + */ +import { Context, Hono } from 'hono'; +import { ErrorCodes, errorResponse } from '../errors.js'; +import { HASH_RE } from '../vendor/cap-tree-core/index.js'; +import type { Services } from '../services/container.js'; + +const trees = new Hono(); +const reviews = new Hono(); + +function getServices(c: Context): Services { + return c.get('services')!; +} +function parseLimit(c: Context, def: number, max: number): number { + return Math.min(Math.max(parseInt(c.req.query('limit') || String(def), 10) || def, 1), max); +} + +// GET /v1/trees/:treeId and /:treeId/refs — current refs envelope. +function currentRefsHandler(c: Context) { + const treeId = c.req.param('treeId') ?? ''; + if (!HASH_RE.test(treeId)) return errorResponse(ErrorCodes.CAP_TREE_UNKNOWN, 'Tree not found', 404); + const services = getServices(c); + if (!services.objects.treeExists(treeId)) return errorResponse(ErrorCodes.CAP_TREE_UNKNOWN, 'Tree not found', 404); + const refs = services.objects.currentRefs(treeId); + if (!refs) return errorResponse(ErrorCodes.REFS_NOT_FOUND, 'No refs published for this tree', 404); + return c.json(refs); +} + +trees.get('/:treeId/refs/history', (c) => { + const treeId = c.req.param('treeId') ?? ''; + if (!HASH_RE.test(treeId)) return errorResponse(ErrorCodes.CAP_TREE_UNKNOWN, 'Tree not found', 404); + const services = getServices(c); + if (!services.objects.treeExists(treeId)) return errorResponse(ErrorCodes.CAP_TREE_UNKNOWN, 'Tree not found', 404); + const sinceParam = c.req.query('since'); + const before = sinceParam !== undefined ? parseInt(sinceParam, 10) : undefined; + const limit = parseLimit(c, 20, 100); + const { entries, next_cursor } = services.objects.refsHistory(treeId, before, limit); + return c.json({ refs: entries, next_cursor }); +}); + +trees.get('/:treeId/refs', currentRefsHandler); + +trees.get('/:treeId/roots/:rootHash/history', (c) => { + const treeId = c.req.param('treeId') ?? ''; + const rootHash = c.req.param('rootHash') ?? ''; + if (!HASH_RE.test(treeId) || !HASH_RE.test(rootHash)) { + return errorResponse(ErrorCodes.CAP_TREE_UNKNOWN, 'Tree not found', 404); + } + const services = getServices(c); + if (!services.objects.rootOfTree(treeId, rootHash)) { + return errorResponse(ErrorCodes.OBJECT_NOT_FOUND, 'Root not found in this tree', 404); + } + const limit = parseLimit(c, 20, 100); + return c.json(services.objects.rootHistory(treeId, rootHash, limit)); +}); + +trees.get('/:treeId/roots/:rootHash', (c) => { + const treeId = c.req.param('treeId') ?? ''; + const rootHash = c.req.param('rootHash') ?? ''; + if (!HASH_RE.test(treeId) || !HASH_RE.test(rootHash)) { + return errorResponse(ErrorCodes.OBJECT_NOT_FOUND, 'Root not found in this tree', 404); + } + const services = getServices(c); + const root = services.objects.rootOfTree(treeId, rootHash); + if (!root) return errorResponse(ErrorCodes.OBJECT_NOT_FOUND, 'Root not found in this tree', 404); + return c.json(root); +}); + +trees.get('/:treeId/resolve', async (c) => { + const treeId = c.req.param('treeId') ?? ''; + const rootHash = c.req.query('root'); + const path = c.req.query('path'); + if (!HASH_RE.test(treeId)) return errorResponse(ErrorCodes.CAP_TREE_UNKNOWN, 'Tree not found', 404); + if (!rootHash || !HASH_RE.test(rootHash) || !path) { + return errorResponse(ErrorCodes.INVALID_REQUEST, 'root (rootHash) and path query params are required', 400); + } + const services = getServices(c); + const resolved = await services.objects.resolvePath(treeId, rootHash, path); + if (!resolved) return errorResponse(ErrorCodes.PATH_NOT_FOUND, 'Path not found', 404); + return c.json(resolved); +}); + +trees.get('/:treeId', currentRefsHandler); + +// GET /v1/reviews — query review messages (HB § 4 convenience index). +reviews.get('/', (c) => { + const treeId = c.req.query('tree'); + if (!treeId || !HASH_RE.test(treeId)) { + return errorResponse(ErrorCodes.INVALID_REQUEST, 'tree (treeId) query param is required', 400); + } + const services = getServices(c); + if (!services.objects.treeExists(treeId)) { + return errorResponse(ErrorCodes.CAP_TREE_UNKNOWN, 'Tree not found', 404); + } + const limit = parseLimit(c, 20, 100); + const result = services.objects.queryReviews({ + treeId, + type: c.req.query('type'), + recipient: c.req.query('recipient'), + outcome: c.req.query('outcome'), + root: c.req.query('root'), + limit, + cursor: c.req.query('cursor'), + }); + return c.json(result); +}); + +export { trees, reviews }; diff --git a/src/routes/wellKnown.ts b/src/routes/wellKnown.ts index 50418fb..b6f629e 100644 --- a/src/routes/wellKnown.ts +++ b/src/routes/wellKnown.ts @@ -1,10 +1,22 @@ import { Hono } from 'hono'; +import { config } from '../config.js'; import { getAgentInstructions } from '../docs/agentInstructions.js'; import { getRegisterInstructions } from '../docs/registerInstructions.js'; import { getAgentSetupInstructions } from '../docs/agentSetupInstructions.js'; const wellKnown = new Hono(); +// GET /.well-known/cap-tree.json - CAP-Tree v0.3 host discovery document (HB § 1) +wellKnown.get('/cap-tree.json', (c) => { + return c.json({ + protocol: 'cap-tree', + specVersion: 3, + endpoints: { objects: '/v1/objects', trees: '/v1/trees', keys: '/v1/keys' }, + maxObjectBytes: config.capTree.maxObjectBytes, + operator: 'ZenBin (zenbin.org)', + }); +}); + // GET /.well-known/skill.md - canonical agent-facing ZenBin instructions wellKnown.get('/skill.md', (c) => { c.header('Content-Type', 'text/markdown; charset=utf-8'); diff --git a/src/services/container.ts b/src/services/container.ts index 68c919d..caaa9c8 100644 --- a/src/services/container.ts +++ b/src/services/container.ts @@ -12,6 +12,7 @@ import { NonceService } from './nonceService.js'; import { AuditService } from './auditService.js'; import { VideoService } from './videoService.js'; import { billingService } from './billingService.js'; +import { ObjectService } from './objectService.js'; import type { IPageService, ISubdomainService, @@ -20,6 +21,7 @@ import type { IAuditService, IVideoService, IBillingService, + IObjectService, } from './interfaces.js'; export interface Services { @@ -30,6 +32,7 @@ export interface Services { audit: IAuditService; videos: IVideoService; billing: IBillingService; + objects: IObjectService; } export function createServices(): Services { @@ -41,5 +44,6 @@ export function createServices(): Services { audit: new AuditService(), videos: new VideoService(), billing: billingService, + objects: new ObjectService(), }; } \ No newline at end of file diff --git a/src/services/interfaces.ts b/src/services/interfaces.ts index f23fddb..cf43abc 100644 --- a/src/services/interfaces.ts +++ b/src/services/interfaces.ts @@ -145,6 +145,28 @@ export interface IVideoService { getMimeType(path: string): string | undefined; } +// ─── Object Service (CAP-Tree v0.3) ───────────────────────── + +import type { + SignatureEnvelope, ObjectRef, TreeRoot, Refs, +} from '../vendor/cap-tree-core/index.js'; +import type { StoredObject } from '../storage/capTreeDb.js'; +import type { PublishResult } from './objectService.js'; + +export interface IObjectService { + publishEnvelope(env: SignatureEnvelope, uploaderKeyId: string): Promise; + publishBlob(bytes: Uint8Array, uploaderKeyId: string): Promise; + get(hash: string): StoredObject | undefined; + envelopeBytes(stored: StoredObject): string; + treeExists(treeId: string): boolean; + currentRefs(treeId: string): SignatureEnvelope | null; + refsHistory(treeId: string, before: number | undefined, limit: number): { entries: SignatureEnvelope[]; next_cursor: number | null }; + rootOfTree(treeId: string, rootHash: string): SignatureEnvelope | null; + rootHistory(treeId: string, rootHash: string, limit: number): { roots: Array<{ hash: string; parents: string[]; message: string; timestamp: string; entryCount: number }>; next_cursor: string | null }; + resolvePath(treeId: string, rootHash: string, path: string): Promise<{ path: string; kind: 'blob' | 'tree'; ref: ObjectRef } | null>; + queryReviews(params: { treeId: string; type?: string; recipient?: string; outcome?: string; root?: string; limit: number; cursor?: string }): { reviews: SignatureEnvelope[]; next_cursor: string | null }; +} + // ─── Billing Service ──────────────────────────────────────── export interface IBillingService { diff --git a/src/services/objectService.ts b/src/services/objectService.ts new file mode 100644 index 0000000..52fa5a8 --- /dev/null +++ b/src/services/objectService.ts @@ -0,0 +1,432 @@ +/** + * ObjectService — CAP-Tree v0.3 object publish/retrieve + host-side validation + * (PRD § 5). All canonicalization/hashing/verification comes from + * cap-tree-core; this service orchestrates it, enforces the host rules + * (HB § 5), and maintains the storage indexes. Routes stay thin. + */ +import { config } from '../config.js'; +import { + verifyEnvelope, + validateObject, + objectHash, + blobHash, + canonicalize, + canonicalBytes, + verifyRootChain, + type SignatureEnvelope, + type ObjectRef, + type TreeRoot, + type Refs, + type ReviewRequest, + type ReviewResponse, + type Tree, + type Resolver, +} from '../vendor/cap-tree-core/index.js'; +import { + getObject, + hasObject, + putObject, + putRootIndex, + putRootMeta, + getRootMeta, + isRootMember, + treeExists, + listRootHashesOfTree, + putReviewRef, + listReviewRefObjectHashes, + getRefsHead, + getChainEntry, + listRefsChain, + extendRefsChain, + type StoredObject, +} from '../storage/capTreeDb.js'; +import { ErrorCodes, type ErrorCode } from '../errors.js'; +import type { IObjectService } from './interfaces.js'; + +function nowIso(): string { + return new Date().toISOString(); +} + +/** A failure the route renders via errorResponse(code, message, httpStatus, extra). */ +export interface PublishError { + status: 'error'; + code: ErrorCode; + httpStatus: number; + message: string; + errors?: string[]; + extra?: Record; +} +export type PublishResult = + | { status: 'created'; stored: StoredObject } + | { status: 'exists'; stored: StoredObject } + | PublishError; + +function err(code: ErrorCode, httpStatus: number, message: string, errors?: string[], extra?: Record): PublishError { + return { status: 'error', code, httpStatus, message, errors, extra }; +} + +/** Resolver over objectDb — fetches stored envelopes by reference (HB § 5). */ +const resolve: Resolver = async (ref: ObjectRef) => { + const o = getObject(ref.hash); + return o && o.kind === 'envelope' && o.envelope ? o.envelope : null; +}; + +export class ObjectService implements IObjectService { + // ─── Publish ────────────────────────────────────────────── + + /** Publish a signature envelope (PRD § 5.1 / § 5.2). */ + async publishEnvelope(env: SignatureEnvelope, uploaderKeyId: string): Promise { + // 1. Envelope signature. + const verdict = await verifyEnvelope(env); + if (!verdict.ok) { + return err(ErrorCodes.CAP_ENVELOPE_INVALID, 422, 'Envelope failed verification', verdict.errors); + } + const hash = verdict.hash; + + // 2. Structural validation. + const violations = validateObject(env.payload); + if (violations.length > 0) { + return err(ErrorCodes.CAP_OBJECT_INVALID, 422, 'Object failed structural validation', violations); + } + + // Canonical size guard (both kinds; HB § 7 is content-blind). + const size = canonicalBytes(env.payload).length; + if (size > config.capTree.maxObjectBytes) { + return err(ErrorCodes.OBJECT_TOO_LARGE, 413, `Object exceeds ${config.capTree.maxObjectBytes} bytes`); + } + + // 3. Idempotency — immutable objects (HB § 3.1 step 3). + const existing = getObject(hash); + if (existing) { + return { status: 'exists', stored: existing }; + } + + const payload = env.payload as { type?: string }; + const clean: SignatureEnvelope = { + payload: env.payload, + signerFingerprint: env.signerFingerprint, + publicKey: env.publicKey, + signature: env.signature, + }; + + // 4. Type-specific host validation (PRD § 5.2). + switch (payload.type) { + case 'tree-root': + return this.publishTreeRoot(clean, env as SignatureEnvelope, hash, size, uploaderKeyId); + case 'refs': + return this.publishRefs(clean, env as SignatureEnvelope, hash, size, uploaderKeyId); + case 'review-request': + case 'review-response': + return this.publishReview(clean, env, hash, size, uploaderKeyId); + case 'tree': + case 'chunks': + // Structural validation only — store as-is. + return this.store(clean, hash, payload.type, size, uploaderKeyId); + default: + return err(ErrorCodes.CAP_OBJECT_INVALID, 422, 'Unknown object type', [`unknown object type: ${String(payload.type)}`]); + } + } + + private store(env: SignatureEnvelope, hash: string, objectType: string, size: number, uploaderKeyId: string): PublishResult { + const stored: StoredObject = { + hash, + kind: 'envelope', + envelope: env, + objectType, + uploaderKeyId, + size, + created_at: nowIso(), + }; + putObject(stored); + return { status: 'created', stored }; + } + + private async publishTreeRoot( + clean: SignatureEnvelope, + env: SignatureEnvelope, + hash: string, + size: number, + uploaderKeyId: string, + ): Promise { + const root = env.payload; + let treeId: string; + + if (root.parents.length === 0) { + // Genesis — signer must be the declared owner; treeId is this hash. + if (env.signerFingerprint !== root.ownerFingerprint) { + return err(ErrorCodes.CAP_OWNER_MISMATCH, 422, 'Genesis root signer is not the declared owner'); + } + treeId = hash; + } else { + // Non-genesis — first parent must be a stored, indexed root; the chain + // walk validates parents, owner signing (incl. rotation), and structure. + const parentMeta = getRootMeta(root.parents[0]!.hash); + if (!parentMeta) { + return err(ErrorCodes.CAP_PARENT_UNKNOWN, 422, 'First parent is not a known root', [ + `parent ${root.parents[0]!.hash} is not a stored, indexed root`, + ]); + } + treeId = parentMeta.treeId; + const chain = await verifyRootChain(env, treeId, resolve); + if (!chain.ok) { + return err(ErrorCodes.CAP_CHAIN_INVALID, 422, 'Root chain verification failed', chain.errors); + } + } + + const result = this.store(clean, hash, 'tree-root', size, uploaderKeyId); + putRootIndex(treeId, hash, { + parents: root.parents.map((p) => p.hash), + timestamp: root.timestamp, + message: root.message, + }); + putRootMeta(hash, { treeId, ownerFingerprint: root.ownerFingerprint }); + return result; + } + + private async publishRefs( + clean: SignatureEnvelope, + env: SignatureEnvelope, + hash: string, + size: number, + uploaderKeyId: string, + ): Promise { + const refs = env.payload; + + // 1. Tree must have a stored genesis. (§ 5.2 refs step 1 — 422 here, vs the + // 404 used by GET tree reads for the same code.) + if (!treeExists(refs.treeId)) { + return err(ErrorCodes.CAP_TREE_UNKNOWN, 422, 'Refs target an unknown tree'); + } + + // 2. Every branch/tag target must be a stored root of this tree, and the + // signer must be the tree's current owner (authoritative via chain walk). + const targets = [...Object.values(refs.branches), ...Object.values(refs.tags)]; + if (targets.length === 0) { + return err(ErrorCodes.CAP_REF_TARGET_UNKNOWN, 422, 'Refs must name at least one branch or tag target'); + } + for (const ref of targets) { + if (!isRootMember(refs.treeId, ref.hash)) { + return err(ErrorCodes.CAP_REF_TARGET_UNKNOWN, 422, 'A refs target is not a stored root of this tree', [ + `target ${ref.hash} is not a member of tree ${refs.treeId}`, + ]); + } + } + const ownerRef = refs.branches['main'] ?? targets[0]!; + const ownerEnv = (await resolve(ownerRef)) as SignatureEnvelope | null; + if (!ownerEnv) { + return err(ErrorCodes.CAP_REF_TARGET_UNKNOWN, 422, 'Refs target object is unavailable'); + } + const ownerChain = await verifyRootChain(ownerEnv, refs.treeId, resolve); + if (!ownerChain.ok) { + return err(ErrorCodes.CAP_CHAIN_INVALID, 422, 'Refs target failed chain verification', ownerChain.errors); + } + const currentOwner = ownerEnv.payload.rotateTo ?? ownerEnv.payload.ownerFingerprint; + if (env.signerFingerprint !== currentOwner && env.signerFingerprint !== ownerEnv.payload.ownerFingerprint) { + return err(ErrorCodes.CAP_NOT_OWNER, 403, 'Refs not signed by the tree owner'); + } + + // 3. Atomic chain extension. Object is stored only on success, so a stored + // refs is always a chain member → generic idempotency stays correct. + const ext = extendRefsChain(refs.treeId, refs.seq, refs.prev, hash); + if (!ext.ok) { + return err(ErrorCodes.CAP_REFS_CONFLICT, 409, 'Refs chain conflict', undefined, { + currentSeq: ext.currentSeq, + currentHash: ext.currentHash, + }); + } + return this.store(clean, hash, refs.type, size, uploaderKeyId); + } + + private publishReview( + clean: SignatureEnvelope, + env: SignatureEnvelope, + hash: string, + size: number, + uploaderKeyId: string, + ): PublishResult { + const result = this.store(clean, hash, (env.payload as { type: string }).type, size, uploaderKeyId); + const p = env.payload as ReviewRequest | ReviewResponse; + if (p.type === 'review-request') { + const entry = { objectType: 'review-request', recipient: p.reviewerFingerprint }; + putReviewRef(p.root.hash, hash, entry); + putReviewRef(p.target.hash, hash, entry); + } else { + putReviewRef(p.root.hash, hash, { objectType: 'review-response', recipient: p.reviewerFingerprint }); + } + return result; + } + + /** Publish a raw blob (PRD § 5.1). */ + async publishBlob(bytes: Uint8Array, uploaderKeyId: string): Promise { + const hash = await blobHash(bytes); + const existing = getObject(hash); + if (existing) { + return { status: 'exists', stored: existing }; + } + if (bytes.length > config.capTree.maxObjectBytes) { + return err(ErrorCodes.OBJECT_TOO_LARGE, 413, `Object exceeds ${config.capTree.maxObjectBytes} bytes`); + } + const stored: StoredObject = { + hash, + kind: 'blob', + blobBase64: Buffer.from(bytes).toString('base64'), + uploaderKeyId, + size: bytes.length, + created_at: nowIso(), + }; + putObject(stored); + return { status: 'created', stored }; + } + + // ─── Retrieve ───────────────────────────────────────────── + + get(hash: string): StoredObject | undefined { + return getObject(hash); + } + + /** Deterministic canonical bytes for a stored envelope (PRD § 5.3). */ + envelopeBytes(stored: StoredObject): string { + return canonicalize(stored.envelope as unknown); + } + + // ─── Tree reads (PRD § 5.4) ─────────────────────────────── + + treeExists(treeId: string): boolean { + return treeExists(treeId); + } + + /** Current refs envelope for a tree, or null if none published. */ + currentRefs(treeId: string): SignatureEnvelope | null { + const head = getRefsHead(treeId); + if (!head) return null; + const o = getObject(head.hash); + return o?.envelope ? (o.envelope as SignatureEnvelope) : null; + } + + refsHistory(treeId: string, before: number | undefined, limit: number): { entries: SignatureEnvelope[]; next_cursor: number | null } { + const chain = listRefsChain(treeId, { before, limit: limit + 1 }); + const hasMore = chain.length > limit; + const page = chain.slice(0, limit); + const entries = page + .map((e) => getObject(e.hash)?.envelope as SignatureEnvelope | undefined) + .filter((e): e is SignatureEnvelope => !!e); + const next_cursor = hasMore ? page[page.length - 1]!.seq : null; + return { entries, next_cursor }; + } + + /** A root envelope, only if it is a member of the given tree. */ + rootOfTree(treeId: string, rootHash: string): SignatureEnvelope | null { + if (!isRootMember(treeId, rootHash)) return null; + const o = getObject(rootHash); + return o?.envelope ? (o.envelope as SignatureEnvelope) : null; + } + + /** Ancestor walk (BFS over parents), newest-first. */ + rootHistory(treeId: string, rootHash: string, limit: number): { roots: Array<{ hash: string; parents: string[]; message: string; timestamp: string; entryCount: number }>; next_cursor: string | null } { + const out: Array<{ hash: string; parents: string[]; message: string; timestamp: string; entryCount: number }> = []; + const seen = new Set(); + const queue: string[] = [rootHash]; + while (queue.length > 0 && out.length < limit + 1) { + const h = queue.shift()!; + if (seen.has(h)) continue; + seen.add(h); + if (!isRootMember(treeId, h)) continue; + const o = getObject(h); + const root = o?.envelope?.payload as TreeRoot | undefined; + if (!root || root.type !== 'tree-root') continue; + out.push({ + hash: h, + parents: root.parents.map((p) => p.hash), + message: root.message, + timestamp: root.timestamp, + entryCount: root.entries.length, + }); + for (const p of root.parents) queue.push(p.hash); + } + const hasMore = out.length > limit; + const page = out.slice(0, limit); + const next_cursor = hasMore ? page[page.length - 1]!.hash : null; + return { roots: page, next_cursor }; + } + + /** + * Resolve a path within a root's subtree (PRD § 5.4). Walks `tree` objects by + * hash. Returns the terminal entry, or null on a miss / traversal through a + * blob. + */ + async resolvePath(treeId: string, rootHash: string, path: string): Promise<{ path: string; kind: 'blob' | 'tree'; ref: ObjectRef } | null> { + const root = this.rootOfTree(treeId, rootHash); + if (!root) return null; + const segments = path.split('/').filter((s) => s.length > 0); + if (segments.length === 0) return null; + + let entries = root.payload.entries; + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]!; + const entry = entries.find((e) => e.path === seg); + if (!entry) return null; + const isLast = i === segments.length - 1; + if (isLast) { + return { path: segments.join('/'), kind: entry.kind, ref: entry.ref }; + } + // Need to descend — entry must be a tree. + if (entry.kind !== 'tree') return null; + const o = getObject(entry.ref.hash); + const sub = o?.envelope?.payload as Tree | undefined; + if (!sub || sub.type !== 'tree') return null; + entries = sub.entries; + } + return null; + } + + // ─── Reviews (PRD § 5.6) ────────────────────────────────── + + queryReviews(params: { + treeId: string; + type?: string; + recipient?: string; + outcome?: string; + root?: string; + limit: number; + cursor?: string; + }): { reviews: SignatureEnvelope[]; next_cursor: string | null } { + // Collect candidate review object hashes from the reviewref index. + const rootHashes = params.root ? [params.root] : listRootHashesOfTree(params.treeId); + const seen = new Set(); + const candidates: string[] = []; + for (const rh of rootHashes) { + for (const { objectHash } of listReviewRefObjectHashes(rh)) { + if (!seen.has(objectHash)) { + seen.add(objectHash); + candidates.push(objectHash); + } + } + } + candidates.sort(); + + const reviews: SignatureEnvelope[] = []; + let started = !params.cursor; + let next_cursor: string | null = null; + for (const h of candidates) { + if (!started) { + if (h === params.cursor) started = true; + continue; + } + const o = getObject(h); + const env = o?.envelope; + if (!env) continue; + const p = env.payload as ReviewRequest | ReviewResponse; + if (params.type && p.type !== params.type) continue; + if (params.recipient && p.reviewerFingerprint !== params.recipient) continue; + if (params.outcome && (p.type !== 'review-response' || p.outcome !== params.outcome)) continue; + if (reviews.length >= params.limit) { + next_cursor = h; + break; + } + reviews.push(env); + } + return { reviews, next_cursor }; + } +} + +export const objectService = new ObjectService(); diff --git a/src/storage/capTreeDb.ts b/src/storage/capTreeDb.ts new file mode 100644 index 0000000..0a3faed --- /dev/null +++ b/src/storage/capTreeDb.ts @@ -0,0 +1,237 @@ +/** + * CAP-Tree v0.3 storage (PRD § 4). + * + * Three LMDB environments — content-addressed objects, tree membership/history + * indexes, and the per-tree refs chain — following the same open()/accessor + * pattern as db.ts. Objects are immutable and content-addressed: a write at an + * existing hash is a no-op. The refs chain head is the only mutable state, and + * it is advanced inside a single transaction so concurrent publishes cannot + * fork the chain. + * + * Note: separate open() calls are separate LMDB environments, so a transaction + * cannot span them. That is fine — the only atomicity requirement (HB § 5 refs) + * is the head/chain advance, which lives entirely in refsDb. Object bytes are + * stored first (idempotent); an orphaned object from a failed chain write is + * harmless. + */ +import { open, Database } from 'lmdb'; +import { config } from '../config.js'; +import type { SignatureEnvelope } from '../vendor/cap-tree-core/index.js'; + +// ─── Stored value shapes ──────────────────────────────────── + +/** A stored CAP-Tree object — keyed by its content address (PRD § 4.2). */ +export interface StoredObject { + hash: string; // objectHash or blobHash — also the key + kind: 'envelope' | 'blob'; + envelope?: SignatureEnvelope; // kind === 'envelope' + blobBase64?: string; // kind === 'blob' (raw bytes, base64) + objectType?: string; // payload.type for envelopes + uploaderKeyId: string; // ZenBin agent key that published it + size: number; // bytes of canonical/raw content + created_at: string; // ISO 8601 (server clock) +} + +/** treeIndexDb `root:{treeId}:{rootHash}` value. */ +export interface RootIndexEntry { + parents: string[]; + timestamp: string; + message: string; +} + +/** treeIndexDb `rootmeta:{rootHash}` value. */ +export interface RootMeta { + treeId: string; + ownerFingerprint: string; +} + +/** treeIndexDb `reviewref:{referencedRootHash}:{objectHash}` value. */ +export interface ReviewRefEntry { + objectType: string; + recipient?: string; +} + +/** refsDb `head:{treeId}` value. */ +export interface RefsHead { + seq: number; + hash: string; +} + +type TreeIndexValue = RootIndexEntry | RootMeta | ReviewRefEntry; +type RefsValue = RefsHead | string; + +// ─── Environments ─────────────────────────────────────────── + +let objectDb: Database; +let treeIndexDb: Database; +let refsDb: Database; + +/** Open the CAP-Tree environments (idempotent). Called from initDatabase(). */ +export function initCapTreeDb(): void { + if (!objectDb) { + objectDb = open({ path: `${config.lmdbPath}-objects`, compression: true }); + } + if (!treeIndexDb) { + treeIndexDb = open({ path: `${config.lmdbPath}-tree-index`, compression: true }); + } + if (!refsDb) { + refsDb = open({ path: `${config.lmdbPath}-refs`, compression: true }); + } +} + +export async function closeCapTreeDb(): Promise { + if (objectDb) { await objectDb.close(); objectDb = undefined as unknown as Database; } + if (treeIndexDb) { await treeIndexDb.close(); treeIndexDb = undefined as unknown as Database; } + if (refsDb) { await refsDb.close(); refsDb = undefined as unknown as Database; } +} + +function getObjectDb(): Database { + if (!objectDb) throw new Error('CAP-Tree DB not initialized. Call initDatabase() first.'); + return objectDb; +} +function getTreeIndexDb(): Database { + if (!treeIndexDb) throw new Error('CAP-Tree DB not initialized. Call initDatabase() first.'); + return treeIndexDb; +} +function getRefsDb(): Database { + if (!refsDb) throw new Error('CAP-Tree DB not initialized. Call initDatabase() first.'); + return refsDb; +} + +// ─── Key helpers ──────────────────────────────────────────── + +const rootKey = (treeId: string, rootHash: string) => `root:${treeId}:${rootHash}`; +const rootMetaKey = (rootHash: string) => `rootmeta:${rootHash}`; +const reviewRefKey = (referencedRootHash: string, objectHash: string) => `reviewref:${referencedRootHash}:${objectHash}`; +const headKey = (treeId: string) => `head:${treeId}`; +const chainKey = (treeId: string, seq: number) => `chain:${treeId}:${String(seq).padStart(10, '0')}`; + +// ─── Object storage ───────────────────────────────────────── + +export function getObject(hash: string): StoredObject | undefined { + return getObjectDb().get(hash); +} + +export function hasObject(hash: string): boolean { + return getObjectDb().get(hash) !== undefined; +} + +/** Store an immutable object. Caller must check existence for idempotency. */ +export function putObject(obj: StoredObject): void { + getObjectDb().putSync(obj.hash, obj); +} + +// ─── Tree index ───────────────────────────────────────────── + +export function putRootIndex(treeId: string, rootHash: string, entry: RootIndexEntry): void { + getTreeIndexDb().putSync(rootKey(treeId, rootHash), entry); +} + +export function getRootIndex(treeId: string, rootHash: string): RootIndexEntry | undefined { + return getTreeIndexDb().get(rootKey(treeId, rootHash)) as RootIndexEntry | undefined; +} + +export function isRootMember(treeId: string, rootHash: string): boolean { + return getTreeIndexDb().get(rootKey(treeId, rootHash)) !== undefined; +} + +export function putRootMeta(rootHash: string, meta: RootMeta): void { + getTreeIndexDb().putSync(rootMetaKey(rootHash), meta); +} + +export function getRootMeta(rootHash: string): RootMeta | undefined { + return getTreeIndexDb().get(rootMetaKey(rootHash)) as RootMeta | undefined; +} + +/** Is there a stored genesis root for this tree (`root:{treeId}:{treeId}`)? */ +export function treeExists(treeId: string): boolean { + return isRootMember(treeId, treeId); +} + +/** All root hashes that belong to a tree (membership index scan). */ +export function listRootHashesOfTree(treeId: string): string[] { + const prefix = `${rootKey(treeId, '')}`; // `root:{treeId}:` + const out: string[] = []; + for (const key of getTreeIndexDb().getKeys({ start: prefix })) { + if (typeof key !== 'string' || !key.startsWith(prefix)) break; + out.push(key.slice(prefix.length)); + } + return out; +} + +export function putReviewRef(referencedRootHash: string, objectHash: string, entry: ReviewRefEntry): void { + getTreeIndexDb().putSync(reviewRefKey(referencedRootHash, objectHash), entry); +} + +/** Object hashes of review messages referencing a given root. */ +export function listReviewRefObjectHashes(referencedRootHash: string): Array<{ objectHash: string; entry: ReviewRefEntry }> { + const prefix = `reviewref:${referencedRootHash}:`; + const out: Array<{ objectHash: string; entry: ReviewRefEntry }> = []; + for (const { key, value } of getTreeIndexDb().getRange({ start: prefix })) { + if (typeof key !== 'string' || !key.startsWith(prefix)) break; + out.push({ objectHash: key.slice(prefix.length), entry: value as ReviewRefEntry }); + } + return out; +} + +// ─── Refs chain ───────────────────────────────────────────── + +export function getRefsHead(treeId: string): RefsHead | undefined { + return getRefsDb().get(headKey(treeId)) as RefsHead | undefined; +} + +export function getChainEntry(treeId: string, seq: number): string | undefined { + return getRefsDb().get(chainKey(treeId, seq)) as string | undefined; +} + +/** + * The refs chain newest-first. `before` (exclusive seq) drives the cursor. + * Returns `{ seq, hash }` entries. + */ +export function listRefsChain( + treeId: string, + opts: { before?: number; limit: number }, +): Array<{ seq: number; hash: string }> { + const prefix = `chain:${treeId}:`; + const all: Array<{ seq: number; hash: string }> = []; + for (const { key, value } of getRefsDb().getRange({ start: prefix })) { + if (typeof key !== 'string' || !key.startsWith(prefix)) break; + const seq = parseInt(key.slice(prefix.length), 10); + all.push({ seq, hash: value as string }); + } + all.sort((a, b) => b.seq - a.seq); // newest-first + const filtered = opts.before === undefined ? all : all.filter((e) => e.seq < opts.before!); + return filtered.slice(0, opts.limit); +} + +export type ChainExtendResult = + | { ok: true } + | { ok: false; currentSeq: number; currentHash: string | null }; + +/** + * Atomically extend the refs chain (PRD § 5.2 refs step 4). The head read, + * seq/prev check, chain write, and head advance happen in one transaction so a + * concurrent publish cannot fork the chain. The refs envelope must already be + * stored in objectDb (idempotent). + */ +export function extendRefsChain( + treeId: string, + seq: number, + prev: string | null, + refsObjectHash: string, +): ChainExtendResult { + const db = getRefsDb(); + return db.transactionSync(() => { + const head = db.get(headKey(treeId)) as RefsHead | undefined; + if (!head) { + if (!(seq === 1 && prev === null)) { + return { ok: false, currentSeq: 0, currentHash: null }; + } + } else if (!(seq === head.seq + 1 && prev === head.hash)) { + return { ok: false, currentSeq: head.seq, currentHash: head.hash }; + } + db.putSync(chainKey(treeId, seq), refsObjectHash); + db.putSync(headKey(treeId), { seq, hash: refsObjectHash }); + return { ok: true }; + }); +} diff --git a/src/storage/db.ts b/src/storage/db.ts index 5723880..3e697bc 100644 --- a/src/storage/db.ts +++ b/src/storage/db.ts @@ -2,6 +2,7 @@ import crypto from 'node:crypto'; import { open, Database } from 'lmdb'; import { config } from '../config.js'; import { deleteVideo } from './video.js'; +import { initCapTreeDb, closeCapTreeDb } from './capTreeDb.js'; import { PLAN_LIMITS, isBillingCycleExpired } from '../rules.js'; import type { Page, @@ -125,6 +126,9 @@ export function initDatabase(): { }); } + // CAP-Tree v0.3 object/tree/refs environments (PRD § 4). + initCapTreeDb(); + return { pages: db, subdomains: subdomainDb, @@ -1295,4 +1299,5 @@ export async function closeDatabase(): Promise { await attestationTypeSubjectIndexDb.close(); attestationTypeSubjectIndexDb = undefined as unknown as Database; } + await closeCapTreeDb(); } diff --git a/src/test/cap-tree-lifecycle.test.ts b/src/test/cap-tree-lifecycle.test.ts new file mode 100644 index 0000000..3c4147d --- /dev/null +++ b/src/test/cap-tree-lifecycle.test.ts @@ -0,0 +1,151 @@ +/** + * HB § 8 conformance seed: build a fresh tree with cap-tree-core, publish every + * object through the API, then "clone" — fetch refs, walk, and verify using + * ONLY what the API returns (the resolver fetches via GET /v1/objects). + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { Hono } from 'hono'; +import { objects } from '../routes/objects.js'; +import { trees } from '../routes/trees.js'; +import { initDatabase, closeDatabase, updateAgentKeyPlan } from '../storage/db.js'; +import { createServices, type Services } from '../services/container.js'; +import { rmSync } from 'fs'; +import { createTestSigner, createCapSignedHeaders, type TestSigner } from './helpers/signing.js'; +import { + generateKeyPair, signEnvelope, objectHash, + verifyRootChain, verifyMerge, verifyRefs, + type SignatureEnvelope, type TreeRoot, type Refs, type Policy, type ObjectRef, type Resolver, +} from '../vendor/cap-tree-core/index.js'; + +const CAP_CT = 'application/vnd.cap-tree+json'; +const TEST_DB_PATH = './data/test-cap-tree-lifecycle.lmdb'; +const SUFFIXES = ['', '-subdomains', '-agent-keys', '-nonces', '-audit', '-owner-index', '-recipient-index', '-attestation-subject-index', '-attestation-type-subject-index', '-objects', '-tree-index', '-refs']; + +const services = createServices(); +const app = new Hono<{ Variables: { services: Services } }>(); +app.use('*', async (c, next) => { c.set('services', services); await next(); }); +app.route('/v1/objects', objects); +app.route('/v1/trees', trees); + +let signer: TestSigner; +let owner: Awaited>; +let reviewer: Awaited>; + +function publish(env: SignatureEnvelope): Request { + const body = JSON.stringify(env); + const headers = { 'Content-Type': CAP_CT, ...createCapSignedHeaders({ signer, method: 'POST', path: '/v1/objects', body }) }; + return new Request('http://localhost/v1/objects', { method: 'POST', headers, body }); +} + +// Resolver used by the "clone" — fetches envelopes ONLY through the public API. +const apiResolver: Resolver = async (ref: ObjectRef) => { + const res = await app.request(`http://localhost/v1/objects/${ref.hash}`); + if (res.status !== 200) return null; + return (await res.json()) as SignatureEnvelope; +}; + +const T = (n: number) => `2026-01-0${n}T00:00:00Z`; + +beforeAll(async () => { + for (const s of SUFFIXES) { try { rmSync(`${TEST_DB_PATH}${s}`, { recursive: true, force: true }); } catch {} } + process.env.LMDB_PATH = TEST_DB_PATH; + initDatabase(); + signer = await createTestSigner(`cap-tree-life-${Date.now()}`); + await updateAgentKeyPlan(signer.keyId, 'enterprise'); + owner = await generateKeyPair(); + reviewer = await generateKeyPair(); +}); + +afterAll(async () => { + await closeDatabase(); + for (const s of SUFFIXES) { try { rmSync(`${TEST_DB_PATH}${s}`, { recursive: true, force: true }); } catch {} } +}); + +describe('CAP-Tree lifecycle — build, publish, clone, verify', () => { + it('round-trips a full tree through the API and verifies the clone', async () => { + const policy: Policy = { requiredApprovals: 1, reviewers: [reviewer.fingerprint], selfReview: false }; + const baseRoot = (over: Partial): TreeRoot => ({ + type: 'tree-root', specVersion: 3, ownerFingerprint: owner.fingerprint, + adminFingerprints: [], entries: [], parents: [], policy, approvals: [], + message: '', timestamp: T(1), ...over, + }); + const sign = (p: unknown, k: typeof owner | typeof reviewer) => signEnvelope(p, k.privateJwk, k.publicJwk); + const hash = (p: unknown) => objectHash(p); + + // Genesis → c1 → c2 (main), branch b1 off c1. + const genesis = baseRoot({ message: 'genesis', timestamp: T(1) }); + const genesisHash = await hash(genesis); + const treeId = genesisHash; + + const c1 = baseRoot({ parents: [{ hash: genesisHash }], message: 'c1', timestamp: T(2) }); + const c1Hash = await hash(c1); + const c2 = baseRoot({ parents: [{ hash: c1Hash }], message: 'c2', timestamp: T(3) }); + const c2Hash = await hash(c2); + const b1 = baseRoot({ parents: [{ hash: c1Hash }], message: 'feature b1', timestamp: T(4) }); + const b1Hash = await hash(b1); + + const reviewRequest = { + type: 'review-request', specVersion: 3, root: { hash: b1Hash }, target: { hash: c2Hash }, + authorFingerprint: owner.fingerprint, reviewerFingerprint: reviewer.fingerprint, + message: 'please review', timestamp: T(5), + }; + const reqHash = await hash(reviewRequest); + const reviewResponse = { + type: 'review-response', specVersion: 3, request: { hash: reqHash }, root: { hash: b1Hash }, + outcome: 'approved', reviewerFingerprint: reviewer.fingerprint, message: 'lgtm', timestamp: T(6), + }; + const respHash = await hash(reviewResponse); + + const merge = baseRoot({ + parents: [{ hash: c2Hash }, { hash: b1Hash }], approvals: [{ hash: respHash }], + message: 'merge b1 into main', timestamp: T(7), + }); + const mergeHash = await hash(merge); + + const refs1: Refs = { type: 'refs', specVersion: 3, treeId, seq: 1, prev: null, branches: { main: { hash: c2Hash } }, tags: {}, timestamp: T(8) }; + const refs1Hash = await hash(refs1); + const refs2: Refs = { type: 'refs', specVersion: 3, treeId, seq: 2, prev: refs1Hash, branches: { main: { hash: mergeHash } }, tags: {}, timestamp: T(9) }; + + // Publish everything, parents-first. + const steps: Array<[string, SignatureEnvelope]> = [ + ['genesis', await sign(genesis, owner)], + ['c1', await sign(c1, owner)], + ['c2', await sign(c2, owner)], + ['b1', await sign(b1, owner)], + ['reviewRequest', await sign(reviewRequest, owner)], + ['reviewResponse', await sign(reviewResponse, reviewer)], + ['merge', await sign(merge, owner)], + ['refs1', await sign(refs1, owner)], + ['refs2', await sign(refs2, owner)], + ]; + for (const [name, env] of steps) { + const res = await app.request(publish(env)); + expect(res.status, `publish ${name}`).toBe(201); + } + + // ─── Clone: fetch current refs, then verify using only the API. ─── + const refsRes = await app.request(`http://localhost/v1/trees/${treeId}/refs`); + expect(refsRes.status).toBe(200); + const currentRefs = (await refsRes.json()) as SignatureEnvelope; + expect(currentRefs.payload.seq).toBe(2); + + const mainTip = currentRefs.payload.branches.main!; + expect(mainTip.hash).toBe(mergeHash); + + const mergeEnv = (await apiResolver(mainTip)) as SignatureEnvelope; + expect(mergeEnv).not.toBeNull(); + + const chain = await verifyRootChain(mergeEnv, treeId, apiResolver); + expect(chain.ok, `chain errors: ${chain.errors.join('; ')}`).toBe(true); + expect(chain.genesisHash).toBe(treeId); + + const mergeVerdict = await verifyMerge(mergeEnv, treeId, apiResolver); + expect(mergeVerdict.ok, `merge errors: ${mergeVerdict.errors.join('; ')}`).toBe(true); + expect(mergeVerdict.policySatisfied).toBe(true); + expect(mergeVerdict.countedApprovals).toBe(1); + + const refsVerdict = await verifyRefs(currentRefs, { treeId, resolve: apiResolver }); + expect(refsVerdict.ok, `refs errors: ${refsVerdict.errors.join('; ')}`).toBe(true); + expect(refsVerdict.equivocation).toBe(false); + }); +}); diff --git a/src/test/cap-tree-objects.test.ts b/src/test/cap-tree-objects.test.ts new file mode 100644 index 0000000..27da57b --- /dev/null +++ b/src/test/cap-tree-objects.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { Hono } from 'hono'; +import { objects } from '../routes/objects.js'; +import { initDatabase, closeDatabase, updateAgentKeyPlan } from '../storage/db.js'; +import { createServices, type Services } from '../services/container.js'; +import { rmSync } from 'fs'; +import { createTestSigner, createCapSignedHeaders, type TestSigner } from './helpers/signing.js'; +import { + objectHash, blobHash, signEnvelope, + type SignatureEnvelope, type TreeRoot, type Ed25519Jwk, +} from '../vendor/cap-tree-core/index.js'; +import vectors from './fixtures/cap-tree-vectors.json'; +import ownerKeys from './fixtures/cap-tree-keypair-owner.json'; +import reviewerKeys from './fixtures/cap-tree-keypair-reviewer.json'; + +const CAP_CT = 'application/vnd.cap-tree+json'; +const TEST_DB_PATH = './data/test-cap-tree-objects.lmdb'; +const TEST_DB_SUFFIXES = [ + '', '-subdomains', '-agent-keys', '-nonces', '-audit', + '-owner-index', '-recipient-index', + '-attestation-subject-index', '-attestation-type-subject-index', + '-objects', '-tree-index', '-refs', +]; + +const services = createServices(); +const app = new Hono<{ Variables: { services: Services } }>(); +app.use('*', async (c, next) => { c.set('services', services); await next(); }); +app.route('/v1/objects', objects); + +let signer: TestSigner; +const owner = ownerKeys as { publicJwk: Ed25519Jwk; privateJwk: Ed25519Jwk }; +const reviewer = reviewerKeys as { publicJwk: Ed25519Jwk; privateJwk: Ed25519Jwk }; +const vecObjects = vectors.objects as Record; + +/** The signature envelope for a named vector (vectors nest it under `.envelope`). */ +function envOf(name: string): SignatureEnvelope { + return vecObjects[name].envelope as SignatureEnvelope; +} +function payloadOf(name: string): TreeRoot { + return vecObjects[name].envelope.payload as TreeRoot; +} + +function publishEnvelope(env: SignatureEnvelope): Request { + const body = JSON.stringify(env); + const headers = { 'Content-Type': CAP_CT, ...createCapSignedHeaders({ signer, method: 'POST', path: '/v1/objects', body }) }; + return new Request('http://localhost/v1/objects', { method: 'POST', headers, body }); +} +function publishBlob(content: string): Request { + const headers = { 'Content-Type': 'application/octet-stream', ...createCapSignedHeaders({ signer, method: 'POST', path: '/v1/objects', body: content }) }; + return new Request('http://localhost/v1/objects', { method: 'POST', headers, body: content }); +} + +beforeAll(async () => { + for (const suffix of TEST_DB_SUFFIXES) { try { rmSync(`${TEST_DB_PATH}${suffix}`, { recursive: true, force: true }); } catch {} } + process.env.LMDB_PATH = TEST_DB_PATH; + initDatabase(); + signer = await createTestSigner(`cap-tree-obj-${Date.now()}`); + await updateAgentKeyPlan(signer.keyId, 'enterprise'); +}); + +afterAll(async () => { + await closeDatabase(); + for (const suffix of TEST_DB_SUFFIXES) { try { rmSync(`${TEST_DB_PATH}${suffix}`, { recursive: true, force: true }); } catch {} } +}); + +describe('CAP-Tree objects — publish & retrieve', () => { + // Dependency order: parents before children, targets before refs. + const order = ['genesisRoot', 'secondRoot', 'featureRoot', 'reviewRequest', 'reviewResponse', 'mergeRoot', 'refsSeq1', 'refsSeq2']; + + it('publishes every vector envelope (201) at its stated hash', async () => { + for (const name of order) { + const env = envOf(name); + const res = await app.request(publishEnvelope(env)); + expect(res.status, `${name} should publish`).toBe(201); + const body = await res.json(); + expect(body.hash, `${name} hash`).toBe(vecObjects[name].hash); + } + }); + + it('is idempotent — re-publishing returns 200 with the same hash', async () => { + for (const name of order) { + const res = await app.request(publishEnvelope(envOf(name))); + expect(res.status, `${name} re-publish`).toBe(200); + const body = await res.json(); + expect(body.hash).toBe(vecObjects[name].hash); + } + }); + + it('GET returns envelope bytes that re-hash to the address', async () => { + for (const name of order) { + const hash = vecObjects[name].hash; + const res = await app.request(`http://localhost/v1/objects/${hash}`); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain(CAP_CT); + expect(res.headers.get('etag')).toBe(`"${hash}"`); + const env = JSON.parse(await res.text()); + expect(await objectHash(env.payload)).toBe(hash); + } + }); + + it('publishes and retrieves a blob, round-tripping its bytes', async () => { + const content = vecObjects.blob.contentUtf8 as string; + const res = await app.request(publishBlob(content)); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.hash).toBe(vecObjects.blob.hash); + + const get = await app.request(`http://localhost/v1/objects/${vecObjects.blob.hash}`); + expect(get.status).toBe(200); + expect(get.headers.get('content-type')).toBe('application/octet-stream'); + const bytes = new Uint8Array(await get.arrayBuffer()); + expect(await blobHash(bytes)).toBe(vecObjects.blob.hash); + expect(new TextDecoder().decode(bytes)).toBe(content); + }); + + it('GET unknown hash → 404 OBJECT_NOT_FOUND', async () => { + const res = await app.request('http://localhost/v1/objects/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + expect(res.status).toBe(404); + expect((await res.json()).error_code).toBe('OBJECT_NOT_FOUND'); + }); + + it('tampered envelope → 422 CAP_ENVELOPE_INVALID', async () => { + const env = envOf('secondRoot'); + const tampered = { ...env, payload: { ...env.payload, message: 'tampered' } }; + const res = await app.request(publishEnvelope(tampered)); + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.error_code).toBe('CAP_ENVELOPE_INVALID'); + expect(Array.isArray(body.errors)).toBe(true); + }); + + it('root with unknown parent → 422 CAP_PARENT_UNKNOWN', async () => { + const base = payloadOf('secondRoot'); + const payload: TreeRoot = { + ...base, + parents: [{ hash: 'A'.repeat(43) }], // valid 43-char base64url, but unknown + message: 'orphan', + }; + const env = await signEnvelope(payload, owner.privateJwk, owner.publicJwk); + const res = await app.request(publishEnvelope(env)); + expect(res.status).toBe(422); + expect((await res.json()).error_code).toBe('CAP_PARENT_UNKNOWN'); + }); + + it('root signed by a non-owner key → 422 CAP_CHAIN_INVALID', async () => { + const base = payloadOf('secondRoot'); + // Valid parent (genesis is published), but signed by the reviewer key. + const payload: TreeRoot = { ...base, message: 'usurped' }; + const env = await signEnvelope(payload, reviewer.privateJwk, reviewer.publicJwk); + const res = await app.request(publishEnvelope(env)); + expect(res.status).toBe(422); + expect((await res.json()).error_code).toBe('CAP_CHAIN_INVALID'); + }); + + it('oversize blob → 413 OBJECT_TOO_LARGE', async () => { + const prev = process.env.CAP_TREE_MAX_OBJECT_BYTES; + process.env.CAP_TREE_MAX_OBJECT_BYTES = '8'; + try { + const res = await app.request(publishBlob('this is definitely more than eight bytes')); + expect(res.status).toBe(413); + expect((await res.json()).error_code).toBe('OBJECT_TOO_LARGE'); + } finally { + if (prev === undefined) delete process.env.CAP_TREE_MAX_OBJECT_BYTES; + else process.env.CAP_TREE_MAX_OBJECT_BYTES = prev; + } + }); + + it('policy-violating merge (zero approvals) is accepted → 201', async () => { + // The host MUST NOT police policy (HB § 5). Build a merge over the two + // published roots with NO approvals and publish it. + const merge = payloadOf('mergeRoot'); + const payload: TreeRoot = { ...merge, approvals: [], message: 'merge without approvals' }; + const env = await signEnvelope(payload, owner.privateJwk, owner.publicJwk); + const res = await app.request(publishEnvelope(env)); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.hash).toBe(await objectHash(payload)); + }); +}); diff --git a/src/test/cap-tree-reads.test.ts b/src/test/cap-tree-reads.test.ts new file mode 100644 index 0000000..145c254 --- /dev/null +++ b/src/test/cap-tree-reads.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { Hono } from 'hono'; +import { objects } from '../routes/objects.js'; +import { trees, reviews } from '../routes/trees.js'; +import { wellKnown } from '../routes/wellKnown.js'; +import { initDatabase, closeDatabase, updateAgentKeyPlan } from '../storage/db.js'; +import { createServices, type Services } from '../services/container.js'; +import { rmSync } from 'fs'; +import { createTestSigner, createCapSignedHeaders, type TestSigner } from './helpers/signing.js'; +import { type SignatureEnvelope } from '../vendor/cap-tree-core/index.js'; +import vectors from './fixtures/cap-tree-vectors.json'; + +const CAP_CT = 'application/vnd.cap-tree+json'; +const TEST_DB_PATH = './data/test-cap-tree-reads.lmdb'; +const SUFFIXES = ['', '-subdomains', '-agent-keys', '-nonces', '-audit', '-owner-index', '-recipient-index', '-attestation-subject-index', '-attestation-type-subject-index', '-objects', '-tree-index', '-refs']; + +const services = createServices(); +const app = new Hono<{ Variables: { services: Services } }>(); +app.use('*', async (c, next) => { c.set('services', services); await next(); }); +app.route('/v1/objects', objects); +app.route('/v1/trees', trees); +app.route('/v1/reviews', reviews); +app.route('/.well-known', wellKnown); + +let signer: TestSigner; +const vo = vectors.objects as Record; +const treeId = vectors.treeId as string; +const genesisHash = vo.genesisRoot.hash as string; +const secondHash = vo.secondRoot.hash as string; +const featureHash = vo.featureRoot.hash as string; +const blobHashStr = vo.blob.hash as string; +const reviewerFp = vectors.keys.reviewer.fingerprint as string; + +function publishEnvelope(env: SignatureEnvelope): Request { + const body = JSON.stringify(env); + const headers = { 'Content-Type': CAP_CT, ...createCapSignedHeaders({ signer, method: 'POST', path: '/v1/objects', body }) }; + return new Request('http://localhost/v1/objects', { method: 'POST', headers, body }); +} +const envOf = (name: string): SignatureEnvelope => vo[name].envelope as SignatureEnvelope; + +beforeAll(async () => { + for (const s of SUFFIXES) { try { rmSync(`${TEST_DB_PATH}${s}`, { recursive: true, force: true }); } catch {} } + process.env.LMDB_PATH = TEST_DB_PATH; + initDatabase(); + signer = await createTestSigner(`cap-tree-reads-${Date.now()}`); + await updateAgentKeyPlan(signer.keyId, 'enterprise'); + for (const n of ['genesisRoot', 'secondRoot', 'featureRoot', 'subtree', 'reviewRequest', 'reviewResponse']) { + const res = await app.request(publishEnvelope(envOf(n))); + expect(res.status, `prereq ${n}`).toBe(201); + } +}); + +afterAll(async () => { + await closeDatabase(); + for (const s of SUFFIXES) { try { rmSync(`${TEST_DB_PATH}${s}`, { recursive: true, force: true }); } catch {} } +}); + +describe('CAP-Tree reads — tree endpoints, reviews, discovery', () => { + it('GET roots/{hash} returns the root envelope (404 for non-members)', async () => { + const ok = await app.request(`http://localhost/v1/trees/${treeId}/roots/${secondHash}`); + expect(ok.status).toBe(200); + expect((await ok.json()).payload.type).toBe('tree-root'); + + const miss = await app.request(`http://localhost/v1/trees/${treeId}/roots/${'B'.repeat(43)}`); + expect(miss.status).toBe(404); + }); + + it('roots history walks ancestry newest-first', async () => { + const res = await app.request(`http://localhost/v1/trees/${treeId}/roots/${featureHash}/history`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.roots.map((r: any) => r.hash)).toEqual([featureHash, secondHash, genesisHash]); + expect(body.roots[0].entryCount).toBeGreaterThanOrEqual(0); + }); + + it('resolve walks a subtree path to its terminal entry', async () => { + const res = await app.request(`http://localhost/v1/trees/${treeId}/resolve?root=${genesisHash}&path=docs/README.md`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.path).toBe('docs/README.md'); + expect(body.kind).toBe('blob'); + expect(body.ref.hash).toBe(blobHashStr); + }); + + it('resolve returns 404 PATH_NOT_FOUND for a missing path', async () => { + const res = await app.request(`http://localhost/v1/trees/${treeId}/resolve?root=${genesisHash}&path=docs/nope.md`); + expect(res.status).toBe(404); + expect((await res.json()).error_code).toBe('PATH_NOT_FOUND'); + }); + + it('reviews query filters by tree/type/outcome', async () => { + const all = await app.request(`http://localhost/v1/trees/${treeId}/refs`); // tree exists sanity (404 refs ok) + expect([200, 404]).toContain(all.status); + + const res = await app.request(`http://localhost/v1/reviews?tree=${treeId}&type=review-response&outcome=approved`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.reviews.length).toBe(1); + expect(body.reviews[0].payload.type).toBe('review-response'); + expect(body.reviews[0].payload.outcome).toBe('approved'); + expect(body.reviews[0].payload.reviewerFingerprint).toBe(reviewerFp); + + const requests = await app.request(`http://localhost/v1/reviews?tree=${treeId}&type=review-request`); + expect((await requests.json()).reviews.length).toBe(1); + }); + + it('serves /.well-known/cap-tree.json with the discovery shape', async () => { + const res = await app.request('http://localhost/.well-known/cap-tree.json'); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.protocol).toBe('cap-tree'); + expect(body.specVersion).toBe(3); + expect(body.endpoints).toEqual({ objects: '/v1/objects', trees: '/v1/trees', keys: '/v1/keys' }); + expect(typeof body.maxObjectBytes).toBe('number'); + expect(body.operator).toContain('ZenBin'); + }); +}); diff --git a/src/test/cap-tree-refs.test.ts b/src/test/cap-tree-refs.test.ts new file mode 100644 index 0000000..7c189db --- /dev/null +++ b/src/test/cap-tree-refs.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { Hono } from 'hono'; +import { objects } from '../routes/objects.js'; +import { trees } from '../routes/trees.js'; +import { initDatabase, closeDatabase, updateAgentKeyPlan } from '../storage/db.js'; +import { createServices, type Services } from '../services/container.js'; +import { rmSync } from 'fs'; +import { createTestSigner, createCapSignedHeaders, type TestSigner } from './helpers/signing.js'; +import { signEnvelope, type SignatureEnvelope, type Refs, type Ed25519Jwk } from '../vendor/cap-tree-core/index.js'; +import vectors from './fixtures/cap-tree-vectors.json'; +import ownerKeys from './fixtures/cap-tree-keypair-owner.json'; +import reviewerKeys from './fixtures/cap-tree-keypair-reviewer.json'; + +const CAP_CT = 'application/vnd.cap-tree+json'; +const TEST_DB_PATH = './data/test-cap-tree-refs.lmdb'; +const SUFFIXES = ['', '-subdomains', '-agent-keys', '-nonces', '-audit', '-owner-index', '-recipient-index', '-attestation-subject-index', '-attestation-type-subject-index', '-objects', '-tree-index', '-refs']; + +const services = createServices(); +const app = new Hono<{ Variables: { services: Services } }>(); +app.use('*', async (c, next) => { c.set('services', services); await next(); }); +app.route('/v1/objects', objects); +app.route('/v1/trees', trees); + +let signer: TestSigner; +const owner = ownerKeys as { publicJwk: Ed25519Jwk; privateJwk: Ed25519Jwk }; +const reviewer = reviewerKeys as { publicJwk: Ed25519Jwk; privateJwk: Ed25519Jwk }; +const vo = vectors.objects as Record; +const treeId = vectors.treeId as string; +const seq1Hash = vo.refsSeq1.hash as string; +const seq2Hash = vo.refsSeq2.hash as string; + +function publishEnvelope(env: SignatureEnvelope): Request { + const body = JSON.stringify(env); + const headers = { 'Content-Type': CAP_CT, ...createCapSignedHeaders({ signer, method: 'POST', path: '/v1/objects', body }) }; + return new Request('http://localhost/v1/objects', { method: 'POST', headers, body }); +} +const envOf = (name: string): SignatureEnvelope => vo[name].envelope as SignatureEnvelope; +const refsPayload = (name: string): Refs => vo[name].envelope.payload as Refs; + +async function publishRefs(payload: Refs, keys: { publicJwk: Ed25519Jwk; privateJwk: Ed25519Jwk }) { + const env = await signEnvelope(payload, keys.privateJwk, keys.publicJwk); + return app.request(publishEnvelope(env)); +} + +beforeAll(async () => { + for (const s of SUFFIXES) { try { rmSync(`${TEST_DB_PATH}${s}`, { recursive: true, force: true }); } catch {} } + process.env.LMDB_PATH = TEST_DB_PATH; + initDatabase(); + signer = await createTestSigner(`cap-tree-refs-${Date.now()}`); + await updateAgentKeyPlan(signer.keyId, 'enterprise'); + // Prereqs: the roots that the refs target must be stored first. + for (const n of ['genesisRoot', 'secondRoot', 'featureRoot']) { + const res = await app.request(publishEnvelope(envOf(n))); + expect(res.status, `prereq ${n}`).toBe(201); + } +}); + +afterAll(async () => { + await closeDatabase(); + for (const s of SUFFIXES) { try { rmSync(`${TEST_DB_PATH}${s}`, { recursive: true, force: true }); } catch {} } +}); + +describe('CAP-Tree refs — chain enforcement', () => { + it('publishes seq 1 → 201 and exposes it as the current refs', async () => { + const res = await app.request(publishEnvelope(envOf('refsSeq1'))); + expect(res.status).toBe(201); + const cur = await app.request(`http://localhost/v1/trees/${treeId}/refs`); + expect(cur.status).toBe(200); + expect((await cur.json()).payload.seq).toBe(1); + }); + + it('rejects a seq gap (1 → 3) with 409 + recovery info', async () => { + const payload: Refs = { ...refsPayload('refsSeq2'), seq: 3, prev: seq1Hash, timestamp: '2031-01-01T00:00:00Z' }; + const res = await publishRefs(payload, owner); + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.error_code).toBe('CAP_REFS_CONFLICT'); + expect(body.currentSeq).toBe(1); + expect(body.currentHash).toBe(seq1Hash); + }); + + it('publishes seq 2 with correct prev → 201 and advances the head', async () => { + const res = await app.request(publishEnvelope(envOf('refsSeq2'))); + expect(res.status).toBe(201); + const cur = await app.request(`http://localhost/v1/trees/${treeId}/refs`); + expect((await cur.json()).payload.seq).toBe(2); + }); + + it('replaying seq 2 is idempotent → 200 with same hash', async () => { + const res = await app.request(publishEnvelope(envOf('refsSeq2'))); + expect(res.status).toBe(200); + expect((await res.json()).hash).toBe(seq2Hash); + }); + + it('rejects a conflicting seq 2 (different object) with 409 + recovery info', async () => { + const payload: Refs = { ...refsPayload('refsSeq2'), timestamp: '2032-02-02T00:00:00Z' }; + const res = await publishRefs(payload, owner); + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.error_code).toBe('CAP_REFS_CONFLICT'); + expect(body.currentSeq).toBe(2); + expect(body.currentHash).toBe(seq2Hash); + }); + + it('rejects refs signed by a non-owner → 403 CAP_NOT_OWNER', async () => { + const payload: Refs = { ...refsPayload('refsSeq2'), seq: 3, prev: seq2Hash, timestamp: '2033-03-03T00:00:00Z' }; + const res = await publishRefs(payload, reviewer); + expect(res.status).toBe(403); + expect((await res.json()).error_code).toBe('CAP_NOT_OWNER'); + }); + + it('refs/history returns the chain newest-first', async () => { + const res = await app.request(`http://localhost/v1/trees/${treeId}/refs/history`); + expect(res.status).toBe(200); + const body = await res.json(); + const seqs = body.refs.map((e: any) => e.payload.seq); + expect(seqs).toEqual([2, 1]); + }); +}); diff --git a/src/test/fixtures/cap-tree-keypair-owner.json b/src/test/fixtures/cap-tree-keypair-owner.json new file mode 100644 index 0000000..65c0312 --- /dev/null +++ b/src/test/fixtures/cap-tree-keypair-owner.json @@ -0,0 +1,13 @@ +{ + "publicJwk": { + "crv": "Ed25519", + "x": "XWPFEtgrDNzScCtEGr_5UhzGXXhU_QwBvUpiWvIW2yM", + "kty": "OKP" + }, + "privateJwk": { + "crv": "Ed25519", + "d": "zIdKBDy8X-A6b5J9K7PqGbrQtLZRFQPetWQhtcTbrv4", + "x": "XWPFEtgrDNzScCtEGr_5UhzGXXhU_QwBvUpiWvIW2yM", + "kty": "OKP" + } +} diff --git a/src/test/fixtures/cap-tree-keypair-reviewer.json b/src/test/fixtures/cap-tree-keypair-reviewer.json new file mode 100644 index 0000000..0920522 --- /dev/null +++ b/src/test/fixtures/cap-tree-keypair-reviewer.json @@ -0,0 +1,13 @@ +{ + "publicJwk": { + "crv": "Ed25519", + "x": "2T1zRwPP6b14vU52IhTKeS8rUB6etwdtrvF_658VBzE", + "kty": "OKP" + }, + "privateJwk": { + "crv": "Ed25519", + "d": "Z5pUjzzWtYr93Dab5wpPWnbUzap7Foy8mZUDeIASh3g", + "x": "2T1zRwPP6b14vU52IhTKeS8rUB6etwdtrvF_658VBzE", + "kty": "OKP" + } +} diff --git a/src/test/fixtures/cap-tree-vectors.json b/src/test/fixtures/cap-tree-vectors.json new file mode 100644 index 0000000..1c00200 --- /dev/null +++ b/src/test/fixtures/cap-tree-vectors.json @@ -0,0 +1,609 @@ +{ + "spec": "cap-tree", + "specVersion": 3, + "objects": { + "blob": { + "contentUtf8": "# hello cap-tree\n", + "hash": "liLMrbuOfj2xQE9F_O7kc07Gfuq1kP64WNgoeIIsBUQ" + }, + "subtree": { + "payload": { + "type": "tree", + "specVersion": 3, + "entries": [ + { + "path": "README.md", + "kind": "blob", + "ref": { + "hash": "liLMrbuOfj2xQE9F_O7kc07Gfuq1kP64WNgoeIIsBUQ" + } + } + ] + }, + "canonical": "{\"entries\":[{\"kind\":\"blob\",\"path\":\"README.md\",\"ref\":{\"hash\":\"liLMrbuOfj2xQE9F_O7kc07Gfuq1kP64WNgoeIIsBUQ\"}}],\"specVersion\":3,\"type\":\"tree\"}", + "hash": "KQkTXDCKOgAb4HpwreaL6wnni5K4ZSwykcFgtAazQkM", + "envelope": { + "payload": { + "type": "tree", + "specVersion": 3, + "entries": [ + { + "path": "README.md", + "kind": "blob", + "ref": { + "hash": "liLMrbuOfj2xQE9F_O7kc07Gfuq1kP64WNgoeIIsBUQ" + } + } + ] + }, + "signerFingerprint": "AAYbPyfiQALTHkyDMLLJ4IaszXMyg-nvuTbiGQjStcQ", + "publicKey": { + "crv": "Ed25519", + "x": "XWPFEtgrDNzScCtEGr_5UhzGXXhU_QwBvUpiWvIW2yM", + "kty": "OKP" + }, + "signature": "-2I7dnHf5aCCCKzwJTiP6m519fLXrJjNnitg0oxsglbQ8k94U385i1AyrvLuZPMf7kD_5sbItOtroFjyFSyIAA" + } + }, + "genesisRoot": { + "payload": { + "type": "tree-root", + "specVersion": 3, + "ownerFingerprint": "AAYbPyfiQALTHkyDMLLJ4IaszXMyg-nvuTbiGQjStcQ", + "adminFingerprints": [], + "entries": [ + { + "path": "docs", + "kind": "tree", + "ref": { + "hash": "KQkTXDCKOgAb4HpwreaL6wnni5K4ZSwykcFgtAazQkM" + } + } + ], + "parents": [], + "policy": null, + "approvals": [], + "message": "genesis", + "timestamp": "2026-06-11T00:00:00Z" + }, + "canonical": "{\"adminFingerprints\":[],\"approvals\":[],\"entries\":[{\"kind\":\"tree\",\"path\":\"docs\",\"ref\":{\"hash\":\"KQkTXDCKOgAb4HpwreaL6wnni5K4ZSwykcFgtAazQkM\"}}],\"message\":\"genesis\",\"ownerFingerprint\":\"AAYbPyfiQALTHkyDMLLJ4IaszXMyg-nvuTbiGQjStcQ\",\"parents\":[],\"policy\":null,\"specVersion\":3,\"timestamp\":\"2026-06-11T00:00:00Z\",\"type\":\"tree-root\"}", + "hash": "AuD_CMBSVyeBGtg622UN20H8FqhfTpJrl7vxsopDW4Y", + "envelope": { + "payload": { + "type": "tree-root", + "specVersion": 3, + "ownerFingerprint": "AAYbPyfiQALTHkyDMLLJ4IaszXMyg-nvuTbiGQjStcQ", + "adminFingerprints": [], + "entries": [ + { + "path": "docs", + "kind": "tree", + "ref": { + "hash": "KQkTXDCKOgAb4HpwreaL6wnni5K4ZSwykcFgtAazQkM" + } + } + ], + "parents": [], + "policy": null, + "approvals": [], + "message": "genesis", + "timestamp": "2026-06-11T00:00:00Z" + }, + "signerFingerprint": "AAYbPyfiQALTHkyDMLLJ4IaszXMyg-nvuTbiGQjStcQ", + "publicKey": { + "crv": "Ed25519", + "x": "XWPFEtgrDNzScCtEGr_5UhzGXXhU_QwBvUpiWvIW2yM", + "kty": "OKP" + }, + "signature": "ioh2DLgtjjeA-k9LfUx1xKXl1qsFKaaQJLXtIb4CUsE4FeAlJlGacffOHsUeqj8ELI8G5ILk-L_9a4CM_RfDDg" + } + }, + "secondRoot": { + "payload": { + "type": "tree-root", + "specVersion": 3, + "ownerFingerprint": "AAYbPyfiQALTHkyDMLLJ4IaszXMyg-nvuTbiGQjStcQ", + "adminFingerprints": [], + "entries": [ + { + "path": "README.md", + "kind": "blob", + "ref": { + "hash": "liLMrbuOfj2xQE9F_O7kc07Gfuq1kP64WNgoeIIsBUQ" + } + }, + { + "path": "docs", + "kind": "tree", + "ref": { + "hash": "KQkTXDCKOgAb4HpwreaL6wnni5K4ZSwykcFgtAazQkM" + } + } + ], + "parents": [ + { + "hash": "AuD_CMBSVyeBGtg622UN20H8FqhfTpJrl7vxsopDW4Y" + } + ], + "policy": { + "requiredApprovals": 1, + "reviewers": [ + "ahifIojLfFyU7K8JFjexzAn-m49xBwv3X8oSKwQXf60" + ], + "selfReview": false + }, + "approvals": [], + "message": "add README, declare review policy", + "timestamp": "2026-06-11T00:01:00Z" + }, + "canonical": "{\"adminFingerprints\":[],\"approvals\":[],\"entries\":[{\"kind\":\"blob\",\"path\":\"README.md\",\"ref\":{\"hash\":\"liLMrbuOfj2xQE9F_O7kc07Gfuq1kP64WNgoeIIsBUQ\"}},{\"kind\":\"tree\",\"path\":\"docs\",\"ref\":{\"hash\":\"KQkTXDCKOgAb4HpwreaL6wnni5K4ZSwykcFgtAazQkM\"}}],\"message\":\"add README, declare review policy\",\"ownerFingerprint\":\"AAYbPyfiQALTHkyDMLLJ4IaszXMyg-nvuTbiGQjStcQ\",\"parents\":[{\"hash\":\"AuD_CMBSVyeBGtg622UN20H8FqhfTpJrl7vxsopDW4Y\"}],\"policy\":{\"requiredApprovals\":1,\"reviewers\":[\"ahifIojLfFyU7K8JFjexzAn-m49xBwv3X8oSKwQXf60\"],\"selfReview\":false},\"specVersion\":3,\"timestamp\":\"2026-06-11T00:01:00Z\",\"type\":\"tree-root\"}", + "hash": "ZE1sLIfSszf9Zd-yF-Y0_RA6q-wfxfNsotHYiu_eSY4", + "envelope": { + "payload": { + "type": "tree-root", + "specVersion": 3, + "ownerFingerprint": "AAYbPyfiQALTHkyDMLLJ4IaszXMyg-nvuTbiGQjStcQ", + "adminFingerprints": [], + "entries": [ + { + "path": "README.md", + "kind": "blob", + "ref": { + "hash": "liLMrbuOfj2xQE9F_O7kc07Gfuq1kP64WNgoeIIsBUQ" + } + }, + { + "path": "docs", + "kind": "tree", + "ref": { + "hash": "KQkTXDCKOgAb4HpwreaL6wnni5K4ZSwykcFgtAazQkM" + } + } + ], + "parents": [ + { + "hash": "AuD_CMBSVyeBGtg622UN20H8FqhfTpJrl7vxsopDW4Y" + } + ], + "policy": { + "requiredApprovals": 1, + "reviewers": [ + "ahifIojLfFyU7K8JFjexzAn-m49xBwv3X8oSKwQXf60" + ], + "selfReview": false + }, + "approvals": [], + "message": "add README, declare review policy", + "timestamp": "2026-06-11T00:01:00Z" + }, + "signerFingerprint": "AAYbPyfiQALTHkyDMLLJ4IaszXMyg-nvuTbiGQjStcQ", + "publicKey": { + "crv": "Ed25519", + "x": "XWPFEtgrDNzScCtEGr_5UhzGXXhU_QwBvUpiWvIW2yM", + "kty": "OKP" + }, + "signature": "aGS4285BzJooNHOweMk5VSqfpWJnoUkcxP6ys7TMoPgqK6Vu6Mk4IsOgq7SNWcgVJzUL_9fliXHb-l8t3F28Dw" + } + }, + "featureRoot": { + "payload": { + "type": "tree-root", + "specVersion": 3, + "ownerFingerprint": "AAYbPyfiQALTHkyDMLLJ4IaszXMyg-nvuTbiGQjStcQ", + "adminFingerprints": [], + "entries": [ + { + "path": "README.md", + "kind": "blob", + "ref": { + "hash": "liLMrbuOfj2xQE9F_O7kc07Gfuq1kP64WNgoeIIsBUQ" + } + }, + { + "path": "docs", + "kind": "tree", + "ref": { + "hash": "KQkTXDCKOgAb4HpwreaL6wnni5K4ZSwykcFgtAazQkM" + } + } + ], + "parents": [ + { + "hash": "ZE1sLIfSszf9Zd-yF-Y0_RA6q-wfxfNsotHYiu_eSY4" + } + ], + "policy": { + "requiredApprovals": 1, + "reviewers": [ + "ahifIojLfFyU7K8JFjexzAn-m49xBwv3X8oSKwQXf60" + ], + "selfReview": false + }, + "approvals": [], + "message": "feature work", + "timestamp": "2026-06-11T00:02:00Z" + }, + "canonical": "{\"adminFingerprints\":[],\"approvals\":[],\"entries\":[{\"kind\":\"blob\",\"path\":\"README.md\",\"ref\":{\"hash\":\"liLMrbuOfj2xQE9F_O7kc07Gfuq1kP64WNgoeIIsBUQ\"}},{\"kind\":\"tree\",\"path\":\"docs\",\"ref\":{\"hash\":\"KQkTXDCKOgAb4HpwreaL6wnni5K4ZSwykcFgtAazQkM\"}}],\"message\":\"feature work\",\"ownerFingerprint\":\"AAYbPyfiQALTHkyDMLLJ4IaszXMyg-nvuTbiGQjStcQ\",\"parents\":[{\"hash\":\"ZE1sLIfSszf9Zd-yF-Y0_RA6q-wfxfNsotHYiu_eSY4\"}],\"policy\":{\"requiredApprovals\":1,\"reviewers\":[\"ahifIojLfFyU7K8JFjexzAn-m49xBwv3X8oSKwQXf60\"],\"selfReview\":false},\"specVersion\":3,\"timestamp\":\"2026-06-11T00:02:00Z\",\"type\":\"tree-root\"}", + "hash": "siysxHAhM8Iq9EyhSH7PD6YHM8DEWG_xllj7fE0G214", + "envelope": { + "payload": { + "type": "tree-root", + "specVersion": 3, + "ownerFingerprint": "AAYbPyfiQALTHkyDMLLJ4IaszXMyg-nvuTbiGQjStcQ", + "adminFingerprints": [], + "entries": [ + { + "path": "README.md", + "kind": "blob", + "ref": { + "hash": "liLMrbuOfj2xQE9F_O7kc07Gfuq1kP64WNgoeIIsBUQ" + } + }, + { + "path": "docs", + "kind": "tree", + "ref": { + "hash": "KQkTXDCKOgAb4HpwreaL6wnni5K4ZSwykcFgtAazQkM" + } + } + ], + "parents": [ + { + "hash": "ZE1sLIfSszf9Zd-yF-Y0_RA6q-wfxfNsotHYiu_eSY4" + } + ], + "policy": { + "requiredApprovals": 1, + "reviewers": [ + "ahifIojLfFyU7K8JFjexzAn-m49xBwv3X8oSKwQXf60" + ], + "selfReview": false + }, + "approvals": [], + "message": "feature work", + "timestamp": "2026-06-11T00:02:00Z" + }, + "signerFingerprint": "AAYbPyfiQALTHkyDMLLJ4IaszXMyg-nvuTbiGQjStcQ", + "publicKey": { + "crv": "Ed25519", + "x": "XWPFEtgrDNzScCtEGr_5UhzGXXhU_QwBvUpiWvIW2yM", + "kty": "OKP" + }, + "signature": "Fj2xLrr_LOEvffRGhLnKaKGoCn3-_lK3omPw37PfdTk1CYur4MonyxNLYG1VCkohDWW_4qSltHXsYN-TT4yHBA" + } + }, + "reviewRequest": { + "payload": { + "type": "review-request", + "specVersion": 3, + "root": { + "hash": "siysxHAhM8Iq9EyhSH7PD6YHM8DEWG_xllj7fE0G214" + }, + "target": { + "hash": "ZE1sLIfSszf9Zd-yF-Y0_RA6q-wfxfNsotHYiu_eSY4" + }, + "authorFingerprint": "AAYbPyfiQALTHkyDMLLJ4IaszXMyg-nvuTbiGQjStcQ", + "reviewerFingerprint": "ahifIojLfFyU7K8JFjexzAn-m49xBwv3X8oSKwQXf60", + "message": "please review", + "timestamp": "2026-06-11T00:03:00Z" + }, + "canonical": "{\"authorFingerprint\":\"AAYbPyfiQALTHkyDMLLJ4IaszXMyg-nvuTbiGQjStcQ\",\"message\":\"please review\",\"reviewerFingerprint\":\"ahifIojLfFyU7K8JFjexzAn-m49xBwv3X8oSKwQXf60\",\"root\":{\"hash\":\"siysxHAhM8Iq9EyhSH7PD6YHM8DEWG_xllj7fE0G214\"},\"specVersion\":3,\"target\":{\"hash\":\"ZE1sLIfSszf9Zd-yF-Y0_RA6q-wfxfNsotHYiu_eSY4\"},\"timestamp\":\"2026-06-11T00:03:00Z\",\"type\":\"review-request\"}", + "hash": "ZMexHANfWBSvCB9zbvYbGIpRx1Rev7BV2M_I-FsDRzY", + "envelope": { + "payload": { + "type": "review-request", + "specVersion": 3, + "root": { + "hash": "siysxHAhM8Iq9EyhSH7PD6YHM8DEWG_xllj7fE0G214" + }, + "target": { + "hash": "ZE1sLIfSszf9Zd-yF-Y0_RA6q-wfxfNsotHYiu_eSY4" + }, + "authorFingerprint": "AAYbPyfiQALTHkyDMLLJ4IaszXMyg-nvuTbiGQjStcQ", + "reviewerFingerprint": "ahifIojLfFyU7K8JFjexzAn-m49xBwv3X8oSKwQXf60", + "message": "please review", + "timestamp": "2026-06-11T00:03:00Z" + }, + "signerFingerprint": "AAYbPyfiQALTHkyDMLLJ4IaszXMyg-nvuTbiGQjStcQ", + "publicKey": { + "crv": "Ed25519", + "x": "XWPFEtgrDNzScCtEGr_5UhzGXXhU_QwBvUpiWvIW2yM", + "kty": "OKP" + }, + "signature": "x-0TkfPLzTVVx_98eUrfoslFRWXYaOQj2Gt5sSfoRT6-SdCQPWNxMAZXQgqB6Ye76svDCQttFyLWNt5Wol2QCg" + } + }, + "reviewResponse": { + "payload": { + "type": "review-response", + "specVersion": 3, + "request": { + "hash": "ZMexHANfWBSvCB9zbvYbGIpRx1Rev7BV2M_I-FsDRzY" + }, + "root": { + "hash": "siysxHAhM8Iq9EyhSH7PD6YHM8DEWG_xllj7fE0G214" + }, + "outcome": "approved", + "reviewerFingerprint": "ahifIojLfFyU7K8JFjexzAn-m49xBwv3X8oSKwQXf60", + "message": "LGTM", + "timestamp": "2026-06-11T00:04:00Z" + }, + "canonical": "{\"message\":\"LGTM\",\"outcome\":\"approved\",\"request\":{\"hash\":\"ZMexHANfWBSvCB9zbvYbGIpRx1Rev7BV2M_I-FsDRzY\"},\"reviewerFingerprint\":\"ahifIojLfFyU7K8JFjexzAn-m49xBwv3X8oSKwQXf60\",\"root\":{\"hash\":\"siysxHAhM8Iq9EyhSH7PD6YHM8DEWG_xllj7fE0G214\"},\"specVersion\":3,\"timestamp\":\"2026-06-11T00:04:00Z\",\"type\":\"review-response\"}", + "hash": "rsJQgIFvd1cj2rcArJbGZ4_5pMuBH_48FuokXqm_jlk", + "envelope": { + "payload": { + "type": "review-response", + "specVersion": 3, + "request": { + "hash": "ZMexHANfWBSvCB9zbvYbGIpRx1Rev7BV2M_I-FsDRzY" + }, + "root": { + "hash": "siysxHAhM8Iq9EyhSH7PD6YHM8DEWG_xllj7fE0G214" + }, + "outcome": "approved", + "reviewerFingerprint": "ahifIojLfFyU7K8JFjexzAn-m49xBwv3X8oSKwQXf60", + "message": "LGTM", + "timestamp": "2026-06-11T00:04:00Z" + }, + "signerFingerprint": "ahifIojLfFyU7K8JFjexzAn-m49xBwv3X8oSKwQXf60", + "publicKey": { + "crv": "Ed25519", + "x": "2T1zRwPP6b14vU52IhTKeS8rUB6etwdtrvF_658VBzE", + "kty": "OKP" + }, + "signature": "XcWJ0In92Dx40lINt5ZCO7tthDlN2hl18WqIHI6QSSqL5eIAimWH-nrTOsbZ9j-C9lzhg7E0XjtmvI7N6ynrBQ" + } + }, + "mergeRoot": { + "payload": { + "type": "tree-root", + "specVersion": 3, + "ownerFingerprint": "AAYbPyfiQALTHkyDMLLJ4IaszXMyg-nvuTbiGQjStcQ", + "adminFingerprints": [], + "entries": [ + { + "path": "README.md", + "kind": "blob", + "ref": { + "hash": "liLMrbuOfj2xQE9F_O7kc07Gfuq1kP64WNgoeIIsBUQ" + } + }, + { + "path": "docs", + "kind": "tree", + "ref": { + "hash": "KQkTXDCKOgAb4HpwreaL6wnni5K4ZSwykcFgtAazQkM" + } + } + ], + "parents": [ + { + "hash": "ZE1sLIfSszf9Zd-yF-Y0_RA6q-wfxfNsotHYiu_eSY4" + }, + { + "hash": "siysxHAhM8Iq9EyhSH7PD6YHM8DEWG_xllj7fE0G214" + } + ], + "policy": { + "requiredApprovals": 1, + "reviewers": [ + "ahifIojLfFyU7K8JFjexzAn-m49xBwv3X8oSKwQXf60" + ], + "selfReview": false + }, + "approvals": [ + { + "hash": "rsJQgIFvd1cj2rcArJbGZ4_5pMuBH_48FuokXqm_jlk" + } + ], + "message": "merge feature", + "timestamp": "2026-06-11T00:05:00Z" + }, + "canonical": "{\"adminFingerprints\":[],\"approvals\":[{\"hash\":\"rsJQgIFvd1cj2rcArJbGZ4_5pMuBH_48FuokXqm_jlk\"}],\"entries\":[{\"kind\":\"blob\",\"path\":\"README.md\",\"ref\":{\"hash\":\"liLMrbuOfj2xQE9F_O7kc07Gfuq1kP64WNgoeIIsBUQ\"}},{\"kind\":\"tree\",\"path\":\"docs\",\"ref\":{\"hash\":\"KQkTXDCKOgAb4HpwreaL6wnni5K4ZSwykcFgtAazQkM\"}}],\"message\":\"merge feature\",\"ownerFingerprint\":\"AAYbPyfiQALTHkyDMLLJ4IaszXMyg-nvuTbiGQjStcQ\",\"parents\":[{\"hash\":\"ZE1sLIfSszf9Zd-yF-Y0_RA6q-wfxfNsotHYiu_eSY4\"},{\"hash\":\"siysxHAhM8Iq9EyhSH7PD6YHM8DEWG_xllj7fE0G214\"}],\"policy\":{\"requiredApprovals\":1,\"reviewers\":[\"ahifIojLfFyU7K8JFjexzAn-m49xBwv3X8oSKwQXf60\"],\"selfReview\":false},\"specVersion\":3,\"timestamp\":\"2026-06-11T00:05:00Z\",\"type\":\"tree-root\"}", + "hash": "bUQAQp_a3gH0m6vjrhwrONQSgdBy1BTJevOCOzXHTjc", + "envelope": { + "payload": { + "type": "tree-root", + "specVersion": 3, + "ownerFingerprint": "AAYbPyfiQALTHkyDMLLJ4IaszXMyg-nvuTbiGQjStcQ", + "adminFingerprints": [], + "entries": [ + { + "path": "README.md", + "kind": "blob", + "ref": { + "hash": "liLMrbuOfj2xQE9F_O7kc07Gfuq1kP64WNgoeIIsBUQ" + } + }, + { + "path": "docs", + "kind": "tree", + "ref": { + "hash": "KQkTXDCKOgAb4HpwreaL6wnni5K4ZSwykcFgtAazQkM" + } + } + ], + "parents": [ + { + "hash": "ZE1sLIfSszf9Zd-yF-Y0_RA6q-wfxfNsotHYiu_eSY4" + }, + { + "hash": "siysxHAhM8Iq9EyhSH7PD6YHM8DEWG_xllj7fE0G214" + } + ], + "policy": { + "requiredApprovals": 1, + "reviewers": [ + "ahifIojLfFyU7K8JFjexzAn-m49xBwv3X8oSKwQXf60" + ], + "selfReview": false + }, + "approvals": [ + { + "hash": "rsJQgIFvd1cj2rcArJbGZ4_5pMuBH_48FuokXqm_jlk" + } + ], + "message": "merge feature", + "timestamp": "2026-06-11T00:05:00Z" + }, + "signerFingerprint": "AAYbPyfiQALTHkyDMLLJ4IaszXMyg-nvuTbiGQjStcQ", + "publicKey": { + "crv": "Ed25519", + "x": "XWPFEtgrDNzScCtEGr_5UhzGXXhU_QwBvUpiWvIW2yM", + "kty": "OKP" + }, + "signature": "r0b9Zp_TvafpqDtFZBAHerAPnDkgOJVYOvkOxBfz1IA4ZGhW3SYxe3Yj-pl1VJzxnQt2cyB65U30vVOyWPTYAg" + } + }, + "refsSeq1": { + "payload": { + "type": "refs", + "specVersion": 3, + "treeId": "AuD_CMBSVyeBGtg622UN20H8FqhfTpJrl7vxsopDW4Y", + "seq": 1, + "prev": null, + "branches": { + "main": { + "hash": "AuD_CMBSVyeBGtg622UN20H8FqhfTpJrl7vxsopDW4Y" + } + }, + "tags": {}, + "timestamp": "2026-06-11T00:00:30Z" + }, + "canonical": "{\"branches\":{\"main\":{\"hash\":\"AuD_CMBSVyeBGtg622UN20H8FqhfTpJrl7vxsopDW4Y\"}},\"prev\":null,\"seq\":1,\"specVersion\":3,\"tags\":{},\"timestamp\":\"2026-06-11T00:00:30Z\",\"treeId\":\"AuD_CMBSVyeBGtg622UN20H8FqhfTpJrl7vxsopDW4Y\",\"type\":\"refs\"}", + "hash": "QYtINGh_TBz_W7Qud6OrkIuECRkXMrZ7ryo_TI0AEXY", + "envelope": { + "payload": { + "type": "refs", + "specVersion": 3, + "treeId": "AuD_CMBSVyeBGtg622UN20H8FqhfTpJrl7vxsopDW4Y", + "seq": 1, + "prev": null, + "branches": { + "main": { + "hash": "AuD_CMBSVyeBGtg622UN20H8FqhfTpJrl7vxsopDW4Y" + } + }, + "tags": {}, + "timestamp": "2026-06-11T00:00:30Z" + }, + "signerFingerprint": "AAYbPyfiQALTHkyDMLLJ4IaszXMyg-nvuTbiGQjStcQ", + "publicKey": { + "crv": "Ed25519", + "x": "XWPFEtgrDNzScCtEGr_5UhzGXXhU_QwBvUpiWvIW2yM", + "kty": "OKP" + }, + "signature": "i7xVf8magS6EGw72GebVh3oJF3iypfyufjfaxy2omH6K-7_tys69NCtINzISatJ62tRNK8gfjW_etTll6S16Bw" + } + }, + "refsSeq2": { + "payload": { + "type": "refs", + "specVersion": 3, + "treeId": "AuD_CMBSVyeBGtg622UN20H8FqhfTpJrl7vxsopDW4Y", + "seq": 2, + "prev": "QYtINGh_TBz_W7Qud6OrkIuECRkXMrZ7ryo_TI0AEXY", + "branches": { + "main": { + "hash": "ZE1sLIfSszf9Zd-yF-Y0_RA6q-wfxfNsotHYiu_eSY4" + }, + "feature": { + "hash": "siysxHAhM8Iq9EyhSH7PD6YHM8DEWG_xllj7fE0G214" + } + }, + "tags": {}, + "timestamp": "2026-06-11T00:02:30Z" + }, + "canonical": "{\"branches\":{\"feature\":{\"hash\":\"siysxHAhM8Iq9EyhSH7PD6YHM8DEWG_xllj7fE0G214\"},\"main\":{\"hash\":\"ZE1sLIfSszf9Zd-yF-Y0_RA6q-wfxfNsotHYiu_eSY4\"}},\"prev\":\"QYtINGh_TBz_W7Qud6OrkIuECRkXMrZ7ryo_TI0AEXY\",\"seq\":2,\"specVersion\":3,\"tags\":{},\"timestamp\":\"2026-06-11T00:02:30Z\",\"treeId\":\"AuD_CMBSVyeBGtg622UN20H8FqhfTpJrl7vxsopDW4Y\",\"type\":\"refs\"}", + "hash": "r31z2JOusmRNi1VAPmlmh5B3l9mqPo4daRXREkb3ZS8", + "envelope": { + "payload": { + "type": "refs", + "specVersion": 3, + "treeId": "AuD_CMBSVyeBGtg622UN20H8FqhfTpJrl7vxsopDW4Y", + "seq": 2, + "prev": "QYtINGh_TBz_W7Qud6OrkIuECRkXMrZ7ryo_TI0AEXY", + "branches": { + "main": { + "hash": "ZE1sLIfSszf9Zd-yF-Y0_RA6q-wfxfNsotHYiu_eSY4" + }, + "feature": { + "hash": "siysxHAhM8Iq9EyhSH7PD6YHM8DEWG_xllj7fE0G214" + } + }, + "tags": {}, + "timestamp": "2026-06-11T00:02:30Z" + }, + "signerFingerprint": "AAYbPyfiQALTHkyDMLLJ4IaszXMyg-nvuTbiGQjStcQ", + "publicKey": { + "crv": "Ed25519", + "x": "XWPFEtgrDNzScCtEGr_5UhzGXXhU_QwBvUpiWvIW2yM", + "kty": "OKP" + }, + "signature": "1V3mDUerP48BWMN1cun1BBnyM2M2kRx5ZA4LXQFhDEMKamrJ6fKwOfncuc5kr-9Vz_pGXMJ7EESvWlYClmshDg" + } + }, + "chunkManifest": { + "payload": { + "type": "chunks", + "specVersion": 3, + "totalBytes": 14, + "chunks": [ + { + "hash": "um4EORd1qifznfchmLY5Tb-Z_FKjnyasvV89eJg7dFw" + }, + { + "hash": "DydqzgHFcddYf1QX228Zy5XBzkiR887phS0njKOFxEI" + } + ] + }, + "canonical": "{\"chunks\":[{\"hash\":\"um4EORd1qifznfchmLY5Tb-Z_FKjnyasvV89eJg7dFw\"},{\"hash\":\"DydqzgHFcddYf1QX228Zy5XBzkiR887phS0njKOFxEI\"}],\"specVersion\":3,\"totalBytes\":14,\"type\":\"chunks\"}", + "hash": "1_0TPMP_Jh3M5nJMZv3IyQ89832Y0foHFX2_QYO59gw", + "envelope": { + "payload": { + "type": "chunks", + "specVersion": 3, + "totalBytes": 14, + "chunks": [ + { + "hash": "um4EORd1qifznfchmLY5Tb-Z_FKjnyasvV89eJg7dFw" + }, + { + "hash": "DydqzgHFcddYf1QX228Zy5XBzkiR887phS0njKOFxEI" + } + ] + }, + "signerFingerprint": "AAYbPyfiQALTHkyDMLLJ4IaszXMyg-nvuTbiGQjStcQ", + "publicKey": { + "crv": "Ed25519", + "x": "XWPFEtgrDNzScCtEGr_5UhzGXXhU_QwBvUpiWvIW2yM", + "kty": "OKP" + }, + "signature": "BRtHcAK67hx66H5-Etf6_chFYG-Hz0JolMtEmz_s9BOgmG4K5H1cjkEj3FPcso44MyGLP-UBLC9aDvYUPYToAQ" + }, + "chunkContentsUtf8": [ + "chunk-a", + "chunk-b" + ] + } + }, + "treeId": "AuD_CMBSVyeBGtg622UN20H8FqhfTpJrl7vxsopDW4Y", + "keys": { + "owner": { + "publicJwk": { + "crv": "Ed25519", + "x": "XWPFEtgrDNzScCtEGr_5UhzGXXhU_QwBvUpiWvIW2yM", + "kty": "OKP" + }, + "fingerprint": "AAYbPyfiQALTHkyDMLLJ4IaszXMyg-nvuTbiGQjStcQ" + }, + "reviewer": { + "publicJwk": { + "crv": "Ed25519", + "x": "2T1zRwPP6b14vU52IhTKeS8rUB6etwdtrvF_658VBzE", + "kty": "OKP" + }, + "fingerprint": "ahifIojLfFyU7K8JFjexzAn-m49xBwv3X8oSKwQXf60" + } + } +} diff --git a/src/test/setup.ts b/src/test/setup.ts index 9b4df3e..95db7bf 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -4,7 +4,13 @@ import { rmSync } from 'fs'; const TEST_DB_PATH = './data/test.lmdb'; const TEST_VIDEO_PATH = './data/test-videos'; -const TEST_DB_SUFFIXES = ['', '-subdomains', '-agent-keys', '-nonces', '-audit', '-owner-index', '-recipient-index']; +const TEST_DB_SUFFIXES = [ + '', '-subdomains', '-agent-keys', '-nonces', '-audit', '-owner-index', '-recipient-index', + '-attestation-subject-index', '-attestation-type-subject-index', + // CAP-Tree v0.3 environments — content-addressed, so they MUST be cleaned + // between runs or fixed-hash objects leak across test files as "already exists". + '-objects', '-tree-index', '-refs', +]; beforeAll(() => { process.env.NODE_ENV = 'test'; diff --git a/src/vendor/cap-tree-core/LICENSE b/src/vendor/cap-tree-core/LICENSE new file mode 100644 index 0000000..30c7ad0 --- /dev/null +++ b/src/vendor/cap-tree-core/LICENSE @@ -0,0 +1,25 @@ +MIT License + +Copyright (c) 2026 Tom Wilson + +Vendored from https://github.com/twilson63/cap-tree (core/, commit +1967f2a402f453f2adc4251e6a828ed3c1d3eb1b). The CAP-Tree reference +implementation code is licensed under the MIT License. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/vendor/cap-tree-core/crypto.ts b/src/vendor/cap-tree-core/crypto.ts new file mode 100644 index 0000000..1cac8c8 --- /dev/null +++ b/src/vendor/cap-tree-core/crypto.ts @@ -0,0 +1,122 @@ +/** + * Hashing, fingerprints, and Ed25519 signature envelopes — WebCrypto only, + * so the same code runs in Node >= 20 and modern browsers. + */ +import { canonicalBytes, toBase64url, fromBase64url } from './encoding.js'; + +export interface Ed25519Jwk { + kty: 'OKP'; + crv: 'Ed25519'; + x: string; // base64url raw public key + d?: string; // base64url raw private key (never transmitted) +} + +export interface SignatureEnvelope

{ + payload: P; + signerFingerprint: string; + publicKey: Ed25519Jwk; + signature: string; // base64url Ed25519 over JCS(payload) +} + +const subtle = globalThis.crypto.subtle; + +export async function sha256(bytes: Uint8Array): Promise { + return toBase64url(new Uint8Array(await subtle.digest('SHA-256', bytes as BufferSource))); +} + +/** objectHash: SHA-256 of the JCS canonical bytes (data-model § 2.2). */ +export async function objectHash(payload: unknown): Promise { + return sha256(canonicalBytes(payload)); +} + +/** blobHash: SHA-256 of raw content bytes (data-model § 2.2). */ +export async function blobHash(bytes: Uint8Array): Promise { + return sha256(bytes); +} + +// Ed25519 SubjectPublicKeyInfo is a fixed 12-byte DER prefix + 32 raw key bytes, +// so fingerprints need no ASN.1 library. +const SPKI_PREFIX = new Uint8Array([0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00]); + +/** fingerprint: base64url(SHA-256(SPKI-DER(publicKey))) (data-model § 2.1). */ +export async function fingerprint(publicKey: Ed25519Jwk): Promise { + const raw = fromBase64url(publicKey.x); + if (raw.length !== 32) throw new Error('Ed25519 public key must be 32 bytes'); + const spki = new Uint8Array(SPKI_PREFIX.length + 32); + spki.set(SPKI_PREFIX); + spki.set(raw, SPKI_PREFIX.length); + return sha256(spki); +} + +export async function generateKeyPair(): Promise<{ publicJwk: Ed25519Jwk; privateJwk: Ed25519Jwk; fingerprint: string }> { + const pair = (await subtle.generateKey('Ed25519', true, ['sign', 'verify'])) as CryptoKeyPair; + const publicJwk = (await subtle.exportKey('jwk', pair.publicKey)) as Ed25519Jwk; + const privateJwk = (await subtle.exportKey('jwk', pair.privateKey)) as Ed25519Jwk; + return { publicJwk: stripJwk(publicJwk), privateJwk, fingerprint: await fingerprint(publicJwk) }; +} + +function stripJwk(jwk: Ed25519Jwk): Ed25519Jwk { + return { kty: 'OKP', crv: 'Ed25519', x: jwk.x }; +} + +async function importPublic(jwk: Ed25519Jwk): Promise { + return subtle.importKey('jwk', { kty: jwk.kty, crv: jwk.crv, x: jwk.x }, 'Ed25519', false, ['verify']); +} + +async function importPrivate(jwk: Ed25519Jwk): Promise { + if (!jwk.d) throw new Error('private JWK required (missing "d")'); + return subtle.importKey('jwk', { ...jwk, key_ops: ['sign'] }, 'Ed25519', false, ['sign']); +} + +/** Sign a payload into a self-contained envelope (data-model § 2.4). */ +export async function signEnvelope

( + payload: P, + privateJwk: Ed25519Jwk, + publicJwk: Ed25519Jwk +): Promise> { + const key = await importPrivate(privateJwk); + const sig = await subtle.sign('Ed25519', key, canonicalBytes(payload) as BufferSource); + return { + payload, + signerFingerprint: await fingerprint(publicJwk), + publicKey: stripJwk(publicJwk), + signature: toBase64url(new Uint8Array(sig)), + }; +} + +export interface EnvelopeVerdict { + ok: boolean; + /** objectHash of the payload — the envelope's reference identity. */ + hash: string; + errors: string[]; +} + +/** Verify an envelope per data-model § 2.4 / § 6.1 steps 1–2. */ +export async function verifyEnvelope(env: SignatureEnvelope): Promise { + const errors: string[] = []; + const hash = await objectHash(env.payload); + let fp = ''; + try { + fp = await fingerprint(env.publicKey); + } catch (e) { + errors.push(`invalid public key: ${(e as Error).message}`); + } + if (fp && fp !== env.signerFingerprint) { + errors.push('signerFingerprint does not match the embedded public key'); + } + if (errors.length === 0) { + try { + const key = await importPublic(env.publicKey); + const ok = await subtle.verify( + 'Ed25519', + key, + fromBase64url(env.signature) as BufferSource, + canonicalBytes(env.payload) as BufferSource + ); + if (!ok) errors.push('Ed25519 signature does not verify over the canonical payload bytes'); + } catch (e) { + errors.push(`signature verification failed: ${(e as Error).message}`); + } + } + return { ok: errors.length === 0, hash, errors }; +} diff --git a/src/vendor/cap-tree-core/encoding.ts b/src/vendor/cap-tree-core/encoding.ts new file mode 100644 index 0000000..8d52c21 --- /dev/null +++ b/src/vendor/cap-tree-core/encoding.ts @@ -0,0 +1,79 @@ +/** + * Base64url (RFC 4648 § 5, no padding) and JCS (RFC 8785) for the CAP-Tree + * object profile. Pure functions, no platform dependencies. + */ + +const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; +const REVERSE: Record = {}; +for (let i = 0; i < ALPHABET.length; i++) REVERSE[ALPHABET[i]!] = i; + +export function toBase64url(bytes: Uint8Array): string { + let out = ''; + for (let i = 0; i < bytes.length; i += 3) { + const a = bytes[i]!, b = bytes[i + 1], c = bytes[i + 2]; + out += ALPHABET[a >> 2]!; + out += ALPHABET[((a & 3) << 4) | ((b ?? 0) >> 4)]!; + if (b !== undefined) out += ALPHABET[((b & 15) << 2) | ((c ?? 0) >> 6)]!; + if (c !== undefined) out += ALPHABET[c & 63]!; + } + return out; +} + +export function fromBase64url(s: string): Uint8Array { + const out = new Uint8Array(Math.floor((s.length * 3) / 4)); + let o = 0; + for (let i = 0; i < s.length; i += 4) { + const a = REVERSE[s[i]!], b = REVERSE[s[i + 1]!]; + if (a === undefined || b === undefined) throw new Error('invalid base64url'); + out[o++] = (a << 2) | (b >> 4); + const cChar = s[i + 2]; + if (cChar !== undefined) { + const c = REVERSE[cChar]; + if (c === undefined) throw new Error('invalid base64url'); + out[o++] = ((b & 15) << 4) | (c >> 2); + const dChar = s[i + 3]; + if (dChar !== undefined) { + const d = REVERSE[dChar]; + if (d === undefined) throw new Error('invalid base64url'); + out[o++] = ((c & 3) << 6) | d; + } + } + } + return out.subarray(0, o); +} + +/** 43-char base64url SHA-256 string (fingerprints, hashes). */ +export const HASH_RE = /^[A-Za-z0-9_-]{43}$/; + +/** + * RFC 8785 canonicalization, restricted to the CAP-Tree object profile: + * strings, integers, booleans, null, arrays, objects. Non-integer numbers + * never appear in CAP-Tree objects and are rejected (data-model § 2.2). + * RFC 8785 string escaping matches ECMAScript JSON.stringify; property + * names sort by UTF-16 code units (the JS default string ordering). + */ +export function canonicalize(value: unknown): string { + if (value === null || typeof value === 'boolean' || typeof value === 'string') { + return JSON.stringify(value); + } + if (typeof value === 'number') { + if (!Number.isInteger(value) || !Number.isFinite(value)) { + throw new Error('CAP-Tree objects may only contain integer numbers'); + } + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map(canonicalize).join(',')}]`; + } + if (typeof value === 'object') { + const entries = Object.keys(value as object) + .sort() + .map((k) => `${JSON.stringify(k)}:${canonicalize((value as Record)[k])}`); + return `{${entries.join(',')}}`; + } + throw new Error(`cannot canonicalize value of type ${typeof value}`); +} + +export function canonicalBytes(value: unknown): Uint8Array { + return new TextEncoder().encode(canonicalize(value)); +} diff --git a/src/vendor/cap-tree-core/index.ts b/src/vendor/cap-tree-core/index.ts new file mode 100644 index 0000000..f31978b --- /dev/null +++ b/src/vendor/cap-tree-core/index.ts @@ -0,0 +1,25 @@ +/** + * cap-tree-core — reference implementation of the CAP-Tree v0.3 data model. + * https://github.com/twilson63/cap-tree + * + * VENDORED from twilson63/cap-tree `core/src/` at commit + * 1967f2a402f453f2adc4251e6a828ed3c1d3eb1b (MIT, Copyright (c) 2026 Tom Wilson). + * Do not edit these files — replace this directory with the `cap-tree-core` npm + * package (import specifier unchanged) once it is published. See ./LICENSE. + */ +export { canonicalize, canonicalBytes, toBase64url, fromBase64url, HASH_RE } from './encoding.js'; +export { + sha256, objectHash, blobHash, fingerprint, generateKeyPair, + signEnvelope, verifyEnvelope, + type Ed25519Jwk, type SignatureEnvelope, type EnvelopeVerdict, +} from './crypto.js'; +export { + SPEC_VERSION, validateObject, validatePathSegment, + type ObjectRef, type TreeEntry, type Policy, type TreeRoot, type Tree, + type Refs, type ReviewRequest, type ReviewResponse, type ReviewOutcome, + type ChunkManifest, type CapObject, +} from './objects.js'; +export { + verifyObject, verifyRootChain, verifyMerge, verifyRefs, + type Resolver, type Verdict, type ChainVerdict, type MergeVerdict, type RefsVerdict, +} from './verify.js'; diff --git a/src/vendor/cap-tree-core/objects.ts b/src/vendor/cap-tree-core/objects.ts new file mode 100644 index 0000000..4bf66ae --- /dev/null +++ b/src/vendor/cap-tree-core/objects.ts @@ -0,0 +1,228 @@ +/** + * CAP-Tree v0.3 object types and structural validation (data-model § 3–§ 5). + * Structural validation is § 6.1 step 3 — it checks shape, not signatures + * or chains; those live in verify.ts. + */ +import { HASH_RE } from './encoding.js'; + +export const SPEC_VERSION = 3; + +export interface ObjectRef { + id?: string; + hash: string; +} + +export interface TreeEntry { + path: string; + kind: 'blob' | 'tree'; + ref: ObjectRef; +} + +export interface Policy { + requiredApprovals: number; + reviewers: string[]; + selfReview: boolean; +} + +export interface TreeRoot { + type: 'tree-root'; + specVersion: number; + ownerFingerprint: string; + adminFingerprints: string[]; + entries: TreeEntry[]; + parents: ObjectRef[]; + policy: Policy | null; + approvals: ObjectRef[]; + message: string; + timestamp: string; + rotateTo?: string; +} + +export interface Tree { + type: 'tree'; + specVersion: number; + entries: TreeEntry[]; +} + +export interface Refs { + type: 'refs'; + specVersion: number; + treeId: string; + seq: number; + prev: string | null; + branches: Record; + tags: Record; + timestamp: string; +} + +export interface ReviewRequest { + type: 'review-request'; + specVersion: number; + root: ObjectRef; + target: ObjectRef; + authorFingerprint: string; + reviewerFingerprint: string; + message: string; + timestamp: string; +} + +export type ReviewOutcome = 'approved' | 'changes-requested' | 'commented'; + +export interface ReviewResponse { + type: 'review-response'; + specVersion: number; + request: ObjectRef; + root: ObjectRef; + outcome: ReviewOutcome; + reviewerFingerprint: string; + message: string; + timestamp: string; +} + +export interface ChunkManifest { + type: 'chunks'; + specVersion: number; + totalBytes: number; + chunks: ObjectRef[]; +} + +export type CapObject = TreeRoot | Tree | Refs | ReviewRequest | ReviewResponse | ChunkManifest; + +// --- Path rules (data-model § 4) --- + +/** + * Validate a single path segment. Returns the NFC-normalized segment. + * Throws on violation — materializing clients treat these as errors, + * never warnings (path-traversal defense). + */ +export function validatePathSegment(segment: string): string { + const nfc = segment.normalize('NFC'); + if (nfc.length === 0) throw new Error('path segment must not be empty'); + if (nfc === '.' || nfc === '..') throw new Error(`path segment must not be "${nfc}"`); + if (/[\/\\\u0000]/.test(nfc)) throw new Error('path segment must not contain "/", "\\\\", or NUL'); + if (new TextEncoder().encode(nfc).length > 255) throw new Error('path segment exceeds 255 bytes'); + return nfc; +} + +const enc = new TextEncoder(); +function utf8Compare(a: string, b: string): number { + const ab = enc.encode(a), bb = enc.encode(b); + const n = Math.min(ab.length, bb.length); + for (let i = 0; i < n; i++) { + const d = ab[i]! - bb[i]!; + if (d !== 0) return d; + } + return ab.length - bb.length; +} + +// --- Structural validation --- + +function isFingerprint(s: unknown): s is string { + return typeof s === 'string' && HASH_RE.test(s); +} +function isRef(r: unknown): r is ObjectRef { + return ( + typeof r === 'object' && r !== null && + isFingerprint((r as ObjectRef).hash) && + ((r as ObjectRef).id === undefined || typeof (r as ObjectRef).id === 'string') + ); +} + +function validateEntries(entries: unknown, errors: string[]): void { + if (!Array.isArray(entries)) { errors.push('entries must be an array'); return; } + const seen = new Set(); + let prev: string | null = null; + for (const e of entries as TreeEntry[]) { + if (typeof e !== 'object' || e === null) { errors.push('entry must be an object'); continue; } + try { validatePathSegment(e.path); } catch (err) { errors.push((err as Error).message); } + if (e.kind !== 'blob' && e.kind !== 'tree') errors.push(`entry "${e.path}": kind must be "blob" or "tree"`); + if (!isRef(e.ref)) errors.push(`entry "${e.path}": invalid ref`); + if (seen.has(e.path)) errors.push(`duplicate entry path "${e.path}"`); + seen.add(e.path); + if (prev !== null && utf8Compare(prev, e.path) >= 0) { + errors.push(`entries not sorted by path: "${prev}" >= "${e.path}"`); + } + prev = e.path; + } +} + +function validatePolicy(policy: unknown, errors: string[]): void { + if (policy === null) return; + const p = policy as Policy; + if (typeof p !== 'object' || p === null) { errors.push('policy must be an object or null'); return; } + if (!Number.isInteger(p.requiredApprovals) || p.requiredApprovals < 0) errors.push('policy.requiredApprovals must be an integer >= 0'); + if (!Array.isArray(p.reviewers) || !p.reviewers.every(isFingerprint)) errors.push('policy.reviewers must be an array of fingerprints'); + if (typeof p.selfReview !== 'boolean') errors.push('policy.selfReview must be a boolean'); +} + +/** + * Structural validation for any CAP-Tree object (§ 6.1 step 3). + * Returns a list of violations; empty list means structurally valid. + */ +export function validateObject(payload: unknown): string[] { + const errors: string[] = []; + const o = payload as Partial & Record; + if (typeof o !== 'object' || o === null) return ['object payload required']; + if (o.specVersion !== SPEC_VERSION) errors.push(`unsupported specVersion: ${String(o.specVersion)}`); + + switch (o.type) { + case 'tree-root': { + const r = o as TreeRoot; + if (!isFingerprint(r.ownerFingerprint)) errors.push('ownerFingerprint must be a 43-char base64url fingerprint'); + if (!Array.isArray(r.adminFingerprints) || !r.adminFingerprints.every(isFingerprint)) errors.push('adminFingerprints must be an array of fingerprints'); + validateEntries(r.entries, errors); + if (!Array.isArray(r.parents) || !r.parents.every(isRef)) errors.push('parents must be an array of ObjectRefs with hashes'); + validatePolicy(r.policy === undefined ? null : r.policy, errors); + if (r.policy === undefined) errors.push('policy is required (use null for none)'); + if (!Array.isArray(r.approvals) || !r.approvals.every(isRef)) errors.push('approvals must be an array of ObjectRefs'); + if (typeof r.message !== 'string') errors.push('message must be a string'); + if (typeof r.timestamp !== 'string') errors.push('timestamp must be a string'); + if (r.rotateTo !== undefined && !isFingerprint(r.rotateTo)) errors.push('rotateTo must be a fingerprint'); + break; + } + case 'tree': + validateEntries((o as Tree).entries, errors); + break; + case 'refs': { + const r = o as Refs; + if (!isFingerprint(r.treeId)) errors.push('treeId must be a 43-char base64url hash'); + if (!Number.isInteger(r.seq) || r.seq < 1) errors.push('seq must be an integer >= 1'); + if (r.seq === 1 ? r.prev !== null : !isFingerprint(r.prev as string)) { + errors.push('prev must be null iff seq == 1, otherwise the previous refs objectHash'); + } + for (const group of ['branches', 'tags'] as const) { + const m = r[group]; + if (typeof m !== 'object' || m === null || Object.values(m).some((v) => !isRef(v))) { + errors.push(`${group} must map names to ObjectRefs`); + } + } + if (typeof r.timestamp !== 'string') errors.push('timestamp must be a string'); + break; + } + case 'review-request': { + const r = o as ReviewRequest; + if (!isRef(r.root)) errors.push('root must be an ObjectRef'); + if (!isRef(r.target)) errors.push('target must be an ObjectRef'); + if (!isFingerprint(r.authorFingerprint)) errors.push('authorFingerprint must be a fingerprint'); + if (!isFingerprint(r.reviewerFingerprint)) errors.push('reviewerFingerprint must be a fingerprint'); + break; + } + case 'review-response': { + const r = o as ReviewResponse; + if (!isRef(r.request)) errors.push('request must be an ObjectRef'); + if (!isRef(r.root)) errors.push('root must be an ObjectRef'); + if (!['approved', 'changes-requested', 'commented'].includes(r.outcome)) errors.push('outcome must be approved | changes-requested | commented'); + if (!isFingerprint(r.reviewerFingerprint)) errors.push('reviewerFingerprint must be a fingerprint'); + break; + } + case 'chunks': { + const r = o as ChunkManifest; + if (!Number.isInteger(r.totalBytes) || r.totalBytes < 0) errors.push('totalBytes must be an integer >= 0'); + if (!Array.isArray(r.chunks) || !r.chunks.every(isRef)) errors.push('chunks must be an array of ObjectRefs'); + break; + } + default: + errors.push(`unknown object type: ${String(o.type)}`); + } + return errors; +} diff --git a/src/vendor/cap-tree-core/verify.ts b/src/vendor/cap-tree-core/verify.ts new file mode 100644 index 0000000..6f2ba6c --- /dev/null +++ b/src/vendor/cap-tree-core/verify.ts @@ -0,0 +1,278 @@ +/** + * The normative verification algorithms — data-model § 6. + * + * Everything here is courier-agnostic: the caller supplies a Resolver that + * fetches envelopes by reference, and every fetched payload is checked + * against the reference hash before it is believed. A malicious resolver + * can withhold objects; it cannot make verification pass. + */ +import { verifyEnvelope, objectHash, type SignatureEnvelope } from './crypto.js'; +import { + validateObject, + type ObjectRef, type TreeRoot, type Refs, type ReviewResponse, +} from './objects.js'; + +/** Fetch an envelope by reference. Return null if unavailable. */ +export type Resolver = (ref: ObjectRef) => Promise; + +export interface Verdict { + ok: boolean; + errors: string[]; +} + +/** § 6.1 — envelope signature + specVersion + structural validity. */ +export async function verifyObject(env: SignatureEnvelope): Promise { + const sig = await verifyEnvelope(env); + const errors = [...sig.errors, ...validateObject(env.payload)]; + return { ok: errors.length === 0, hash: sig.hash, errors }; +} + +/** Resolve a ref and require the payload to hash to ref.hash. */ +async function resolveVerified( + ref: ObjectRef, + resolve: Resolver, + errors: string[], + what: string +): Promise { + const env = await resolve(ref); + if (env === null) { + errors.push(`${what}: object ${ref.hash} is unavailable`); + return null; + } + const actual = await objectHash(env.payload); + if (actual !== ref.hash) { + errors.push(`${what}: retrieved bytes hash to ${actual}, reference pins ${ref.hash}`); + return null; + } + return env; +} + +export interface ChainVerdict extends Verdict { + /** Every verified ancestor root, keyed by objectHash. */ + roots: Map; payload: TreeRoot }>; + genesisHash: string | null; +} + +/** + * § 6.2 — verify a root's ancestry back to a trusted treeId. + * + * Walks all parents (the full DAG), verifies every ancestor's envelope and + * hash pin, requires exactly one genesis whose hash equals treeId, and + * checks each root's signer against the owner active at that point in + * history (key rotation, § 7.2: a root's owner is its first parent's owner + * unless that parent declares rotateTo). + */ +export async function verifyRootChain( + rootEnv: SignatureEnvelope, + treeId: string, + resolve: Resolver +): Promise { + const errors: string[] = []; + const roots = new Map; payload: TreeRoot }>(); + let genesisHash: string | null = null; + + // Phase 1: collect and individually verify the ancestor DAG. + const tipVerdict = await verifyObject(rootEnv); + errors.push(...tipVerdict.errors.map((e) => `root ${tipVerdict.hash}: ${e}`)); + roots.set(tipVerdict.hash, { env: rootEnv, payload: rootEnv.payload }); + + const queue: TreeRoot[] = [rootEnv.payload]; + while (queue.length > 0) { + const current = queue.pop()!; + for (const parentRef of current.parents) { + if (roots.has(parentRef.hash)) continue; + const env = (await resolveVerified(parentRef, resolve, errors, 'ancestor walk')) as SignatureEnvelope | null; + if (!env) continue; + const v = await verifyObject(env); + errors.push(...v.errors.map((e) => `root ${v.hash}: ${e}`)); + if (env.payload.type !== 'tree-root') { + errors.push(`ancestor ${parentRef.hash} is not a tree-root`); + continue; + } + roots.set(parentRef.hash, { env, payload: env.payload }); + queue.push(env.payload); + } + } + + // Phase 2: exactly one genesis, equal to treeId. + const genesisHashes: string[] = []; + for (const [hash, { payload }] of roots) { + if (payload.parents.length === 0) genesisHashes.push(hash); + } + if (genesisHashes.length !== 1) { + errors.push(`expected exactly one genesis root in the ancestry, found ${genesisHashes.length}`); + } else { + genesisHash = genesisHashes[0]!; + if (genesisHash !== treeId) { + errors.push(`genesis root hashes to ${genesisHash}, which is not the trusted treeId ${treeId}`); + } + } + + // Phase 3: signer authority with rotation along first-parent lineage. + if (genesisHash === treeId && genesisHash !== null) { + const ownerOf = new Map(); // rootHash -> active owner fingerprint + const ownerFor = (hash: string): string | null => { + if (ownerOf.has(hash)) return ownerOf.get(hash)!; + const node = roots.get(hash); + if (!node) return null; + let owner: string | null; + if (node.payload.parents.length === 0) { + owner = node.payload.ownerFingerprint; + } else { + const firstParentHash = node.payload.parents[0]!.hash; + const parent = roots.get(firstParentHash); + owner = parent ? (parent.payload.rotateTo ?? ownerFor(firstParentHash)) : null; + } + if (owner !== null) ownerOf.set(hash, owner); + return owner; + }; + for (const [hash, { env, payload }] of roots) { + const owner = ownerFor(hash); + if (owner === null) continue; // unresolvable ancestry already reported + if (env.signerFingerprint !== owner) { + errors.push(`root ${hash} signed by ${env.signerFingerprint}, but the active owner is ${owner}`); + } + if (payload.ownerFingerprint !== owner) { + errors.push(`root ${hash} declares ownerFingerprint ${payload.ownerFingerprint}, but the active owner is ${owner}`); + } + } + } + + return { ok: errors.length === 0, errors, roots, genesisHash }; +} + +export interface MergeVerdict extends Verdict { + /** Whether the merge satisfies the policy of its first parent (§ 6.3). */ + policySatisfied: boolean; + countedApprovals: number; + requiredApprovals: number; +} + +/** + * § 6.3 — verify a merge root against the declared policy of its target. + * + * A merge that fails policy is still a valid object; callers MUST surface + * `policySatisfied: false` and MUST NOT present the merge as approved. + */ +export async function verifyMerge( + mergeEnv: SignatureEnvelope, + treeId: string, + resolve: Resolver +): Promise { + const merge = mergeEnv.payload; + const chain = await verifyRootChain(mergeEnv, treeId, resolve); + const errors = [...chain.errors]; + + if (merge.parents.length < 2) { + errors.push('a merge root must have two or more parents'); + return { ok: false, errors, policySatisfied: false, countedApprovals: 0, requiredApprovals: 0 }; + } + + const target = chain.roots.get(merge.parents[0]!.hash); + const policy = target?.payload.policy ?? null; + if (policy === null) { + return { ok: errors.length === 0, errors, policySatisfied: errors.length === 0, countedApprovals: 0, requiredApprovals: 0 }; + } + + const mergedHashes = new Set(merge.parents.slice(1).map((p) => p.hash)); + const policyErrors: string[] = []; + const countedReviewers = new Set(); + + for (const ref of merge.approvals) { + const env = (await resolveVerified(ref, resolve, policyErrors, 'approval')) as SignatureEnvelope | null; + if (!env) continue; + const v = await verifyObject(env); + if (!v.ok) { policyErrors.push(`approval ${ref.hash}: ${v.errors.join('; ')}`); continue; } + const r = env.payload; + if (r.type !== 'review-response') { policyErrors.push(`approval ${ref.hash} is not a review-response`); continue; } + if (r.outcome !== 'approved') { policyErrors.push(`approval ${ref.hash} has outcome "${r.outcome}", not "approved"`); continue; } + if (env.signerFingerprint !== r.reviewerFingerprint) { policyErrors.push(`approval ${ref.hash} signer differs from its reviewerFingerprint`); continue; } + if (!mergedHashes.has(r.root.hash)) { policyErrors.push(`approval ${ref.hash} approves root ${r.root.hash}, which is not among the merged parents`); continue; } + if (policy.reviewers.length > 0 && !policy.reviewers.includes(r.reviewerFingerprint)) { policyErrors.push(`approval ${ref.hash}: reviewer is not in the policy's reviewer set`); continue; } + if (!policy.selfReview && r.reviewerFingerprint === mergeEnv.signerFingerprint) { policyErrors.push(`approval ${ref.hash}: self-review is not permitted by policy`); continue; } + if (policy.reviewers.length === 0 && r.reviewerFingerprint === mergeEnv.signerFingerprint) { policyErrors.push(`approval ${ref.hash}: with an open reviewer set, the merge signer's own approval does not count`); continue; } + countedReviewers.add(r.reviewerFingerprint); // at most one approval per reviewer + } + + const policySatisfied = countedReviewers.size >= policy.requiredApprovals; + if (!policySatisfied) { + policyErrors.push(`policy requires ${policy.requiredApprovals} approval(s), counted ${countedReviewers.size}`); + errors.push(...policyErrors); + } + + return { + ok: errors.length === 0 && policySatisfied, + errors, + policySatisfied: policySatisfied && chain.ok, + countedApprovals: countedReviewers.size, + requiredApprovals: policy.requiredApprovals, + }; +} + +export interface RefsVerdict extends Verdict { + /** True when two distinct refs objects claim the same seq (§ 3.4 / § 6.4). */ + equivocation: boolean; +} + +/** + * § 6.4 — verify a refs object, optionally against the previously observed + * refs envelope for the same tree. + */ +export async function verifyRefs( + refsEnv: SignatureEnvelope, + opts: { + treeId: string; + resolve: Resolver; + /** The last refs envelope this client observed and verified, if any. */ + previous?: SignatureEnvelope; + /** Skip branch-target chain walks (cheaper; pins still checked by callers). */ + skipTargetWalks?: boolean; + } +): Promise { + const errors: string[] = []; + let equivocation = false; + const v = await verifyObject(refsEnv); + errors.push(...v.errors); + const refs = refsEnv.payload; + + if (refs.treeId !== opts.treeId) { + errors.push(`refs object is for tree ${refs.treeId}, expected ${opts.treeId}`); + } + + // Owner check: the genesis root's owner (with rotations) must have signed. + // We verify via any branch target's chain; the cheapest authoritative + // source of current ownership is the chain walk itself. + if (opts.previous) { + const prevHash = await objectHash(opts.previous.payload); + const prev = opts.previous.payload; + if (refs.seq <= prev.seq) { + const sameObject = (await objectHash(refs)) === prevHash; + if (!sameObject) { + equivocation = true; + errors.push(`equivocation: observed refs seq ${prev.seq}, received a different object at seq ${refs.seq}`); + } + } else if (refs.seq === prev.seq + 1 && refs.prev !== prevHash) { + errors.push(`refs.prev is ${refs.prev}, expected hash of the previously observed refs object ${prevHash}`); + } + } + + if (!opts.skipTargetWalks) { + for (const [group, m] of [['branches', refs.branches], ['tags', refs.tags]] as const) { + for (const [name, ref] of Object.entries(m)) { + const env = (await resolveVerified(ref, opts.resolve, errors, `${group}.${name}`)) as SignatureEnvelope | null; + if (!env) continue; + const chain = await verifyRootChain(env, opts.treeId, opts.resolve); + if (!chain.ok) errors.push(`${group}.${name}: ${chain.errors.join('; ')}`); + if (refsEnv.signerFingerprint && env && chain.genesisHash === opts.treeId) { + // Refs must be signed by the active owner at the branch tip. + const tipOwner = env.payload.rotateTo ?? env.payload.ownerFingerprint; + if (refsEnv.signerFingerprint !== tipOwner && refsEnv.signerFingerprint !== env.payload.ownerFingerprint) { + errors.push(`refs signed by ${refsEnv.signerFingerprint}, but the tree owner is ${env.payload.ownerFingerprint}`); + } + } + } + } + } + + return { ok: errors.length === 0, errors, equivocation }; +} diff --git a/tsconfig.json b/tsconfig.json index 3719321..656da10 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM"], "types": ["node"], "outDir": "./dist", "rootDir": "./src", From 2a475c92694b2fb1645a6c977e1b6a8913398300 Mon Sep 17 00:00:00 2001 From: Tom Wilson Date: Thu, 11 Jun 2026 17:46:00 -0400 Subject: [PATCH 3/4] docs: mark CAP-Tree v0.3 PRD as implemented, add progress section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Status → Implemented (a39d5f7). New § 11 records sequencing/acceptance completion (re-verified: 429/429 tests, typecheck clean), deviations (vendored cap-tree-core, dedicated capTreeDb.ts), and open items (stretch goals, npm swap, post-deploy conformance). Co-Authored-By: Claude Fable 5 --- plan-prd.md | 52 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/plan-prd.md b/plan-prd.md index c1993f3..99007f8 100644 --- a/plan-prd.md +++ b/plan-prd.md @@ -1,6 +1,6 @@ # PRD: CAP-Tree v0.3 HTTP Binding for ZenBin -**Status:** Ready for implementation +**Status:** ✅ Implemented (commit `a39d5f7`, branch `feat/cap-tree-v0.3-host`) — see § 11 **Date:** 2026-06-11 **Spec:** https://github.com/twilson63/cap-tree — normative references below are `data-model.md` (DM §) and `http-binding.md` (HB §) in that repo. @@ -380,3 +380,53 @@ Existing test suites MUST stay green (`npx vitest run`). 6. Lifecycle test, docs template updates, typecheck/lint pass. Each step leaves the repo green; commit per step. + +## 11. Progress (updated 2026-06-11) + +Implementation complete and verified in commit `a39d5f7` on +`feat/cap-tree-v0.3-host`. Re-verified today: **429/429 tests pass (27 +files)** and `npm run typecheck` is clean. + +### Sequencing (§ 10) — all done + +| Step | Status | Notes | +|---|---|---| +| 1. Dependency + fixtures | ✅ | Vendored (§ 3 fallback) into `src/vendor/cap-tree-core/` — package was not on npm. Vectors at `src/test/fixtures/cap-tree-vectors.json`. | +| 2. Storage | ✅ | Landed as a dedicated module `src/storage/capTreeDb.ts` (objects / tree-index / refs envs) rather than additions inside `db.ts`; refs head advance uses an atomic `transactionSync`. | +| 3. `POST/GET /v1/objects` + § 5.2 validation | ✅ | `src/routes/objects.ts`, `src/services/objectService.ts`; `cap-tree-objects.test.ts`. | +| 4. Refs enforcement | ✅ | 409 conflict carries `currentSeq`/`currentHash`; `cap-tree-refs.test.ts`. | +| 5. Tree reads + reviews + discovery | ✅ | `src/routes/trees.ts`, `/.well-known/cap-tree.json`; `cap-tree-reads.test.ts`. | +| 6. Lifecycle test + docs + typecheck | ✅ | `cap-tree-lifecycle.test.ts` (build → publish → clone → verify, API-only resolver); agent docs updated in `src/docs/agentInstructions.ts`. | + +### Acceptance criteria (§ 8) — all met + +1. ✅ Four new test files pass; full suite green (429/429); typecheck clean. +2. ✅ All 8 vector envelopes + vector blob round-trip byte-exactly at their + stated hashes. +3. ✅ Publish idempotent (re-publish → 200, identical record); no mutation path. +4. ✅ Refs conflicts/gaps → 409 with recovery info; atomic writes; full history. +5. ✅ Policy-violating merge accepted (201) — regression test in place. +6. ✅ Reads open; publishes require signed requests via existing middleware. +7. ✅ Discovery doc served and accurate. +8. ✅ v0.2 suite unchanged and green. +9. ✅ No new runtime dependency (cap-tree-core is vendored). + +### Deviations from plan + +- cap-tree-core was **vendored** (`src/vendor/cap-tree-core/`, MIT, with + source-commit header) per the § 3 fallback — swap to the npm package once + `cap-tree-core@0.3.0` is published. `tsconfig` gained `DOM` in `lib` for + WebCrypto types. +- New storage lives in `src/storage/capTreeDb.ts` instead of extending + `db.ts` directly (`db.ts` only gained init wiring). +- Test keypair fixtures added (`cap-tree-keypair-owner.json`, + `cap-tree-keypair-reviewer.json`); `setup.ts` now cleans the + content-addressed envs between files so fixed-hash objects don't leak. + +### Open items + +- § 9 stretch goals not implemented (by design — non-blocking): advisory + `policyEvaluation` annotation, `HEAD /v1/objects/{hash}`, per-uploader + listing. +- Swap vendored copy for `cap-tree-core@0.3.0` from npm when published. +- HB § 8 conformance evaluation against the deployed zenbin.org (post-deploy). From 4d8f4855db045953be47f3ed65f2cfaa815fa9e4 Mon Sep 17 00:00:00 2001 From: Tom Wilson Date: Thu, 11 Jun 2026 18:01:46 -0400 Subject: [PATCH 4/4] review fixes: use cap-tree-core from npm, root-history cursor, refs crash-heal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Swap the vendored cap-tree-core copy for the published npm package (cap-tree-core@0.3.0) and delete src/vendor/cap-tree-core — one implementation, no drift. - rootHistory now accepts a cursor so next_cursor is actually consumable; GET /v1/trees/{treeId}/roots/{hash}/history takes ?cursor=. - publishRefs heals the extend-then-store crash window: re-publishing a refs object that is already the chain head but unstored succeeds and stores it instead of 409ing against itself. Co-Authored-By: Claude Fable 5 --- package-lock.json | 10 + package.json | 1 + pnpm-lock.yaml | 1579 ++++++++++++++++++++++++++ src/routes/objects.ts | 2 +- src/routes/trees.ts | 4 +- src/services/interfaces.ts | 4 +- src/services/objectService.ts | 45 +- src/storage/capTreeDb.ts | 2 +- src/test/cap-tree-lifecycle.test.ts | 2 +- src/test/cap-tree-objects.test.ts | 2 +- src/test/cap-tree-reads.test.ts | 11 +- src/test/cap-tree-refs.test.ts | 21 +- src/vendor/cap-tree-core/LICENSE | 25 - src/vendor/cap-tree-core/crypto.ts | 122 -- src/vendor/cap-tree-core/encoding.ts | 79 -- src/vendor/cap-tree-core/index.ts | 25 - src/vendor/cap-tree-core/objects.ts | 228 ---- src/vendor/cap-tree-core/verify.ts | 278 ----- 18 files changed, 1658 insertions(+), 782 deletions(-) create mode 100644 pnpm-lock.yaml delete mode 100644 src/vendor/cap-tree-core/LICENSE delete mode 100644 src/vendor/cap-tree-core/crypto.ts delete mode 100644 src/vendor/cap-tree-core/encoding.ts delete mode 100644 src/vendor/cap-tree-core/index.ts delete mode 100644 src/vendor/cap-tree-core/objects.ts delete mode 100644 src/vendor/cap-tree-core/verify.ts diff --git a/package-lock.json b/package-lock.json index 9b2800f..9494814 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@hono/node-server": "^2.0.1", "bcryptjs": "^3.0.3", + "cap-tree-core": "^0.3.0", "dotenv": "^17.4.2", "hono": "^4.12.16", "jsonwebtoken": "^9.0.2", @@ -1172,6 +1173,15 @@ "node": ">=8" } }, + "node_modules/cap-tree-core": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/cap-tree-core/-/cap-tree-core-0.3.0.tgz", + "integrity": "sha512-AjomQavm3xp+ZWLcniYAkvt53AzGIUkPZLPrjOPvw+FK6LScRMHBlfiID9F/sllh+dtyjBzPfY/0v8f699T5TA==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", diff --git a/package.json b/package.json index 26d3987..1e56de7 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "dependencies": { "@hono/node-server": "^2.0.1", "bcryptjs": "^3.0.3", + "cap-tree-core": "^0.3.0", "dotenv": "^17.4.2", "hono": "^4.12.16", "jsonwebtoken": "^9.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..860aa31 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1579 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@hono/node-server': + specifier: ^2.0.1 + version: 2.0.2(hono@4.12.18) + bcryptjs: + specifier: ^3.0.3 + version: 3.0.3 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 + hono: + specifier: ^4.12.16 + version: 4.12.18 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.3 + lmdb: + specifier: ^3.1.5 + version: 3.5.4 + posthog-node: + specifier: ^5.26.0 + version: 5.34.1 + devDependencies: + '@types/bcryptjs': + specifier: ^3.0.0 + version: 3.0.0 + '@types/jsonwebtoken': + specifier: ^9.0.5 + version: 9.0.10 + '@types/node': + specifier: ^22.10.2 + version: 22.19.19 + tsx: + specifier: ^4.21.0 + version: 4.22.0 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.19) + +packages: + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@harperfast/extended-iterable@1.0.3': + resolution: {integrity: sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw==} + + '@hono/node-server@2.0.2': + resolution: {integrity: sha512-tXlTi1h/4V7sDe7i97IVP+9re9ZU7wXZZggnR5ucCRclf1+AX6YhGStrR5w8bLj+3Mlyl0pKfBh9gqTqqnGKfQ==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@lmdb/lmdb-darwin-arm64@3.5.4': + resolution: {integrity: sha512-Kk4Kz3iyu1QiLsLZBS9Af1eSKUC8VR2T+/jyE2iAyuGw2VwK08pp5iTbZnXn6sWu0LogO/RFktMxOjiDA2sS3w==} + cpu: [arm64] + os: [darwin] + + '@lmdb/lmdb-darwin-x64@3.5.4': + resolution: {integrity: sha512-BEe5Rp3trn26oxoXOVL5HVDoiYmjUDwr8NRPkBOdUdCSBEorKI+7JrZLRKAdxO+G6cGQLgseXk0gR7qIQa7aGw==} + cpu: [x64] + os: [darwin] + + '@lmdb/lmdb-linux-arm64@3.5.4': + resolution: {integrity: sha512-cUXEengO8o60v1SWerJTH4/RH4U3+9jC0/4njp2Z9NdmvaGzhKsbRM2wpXuRYrN8tytsoJCg0SvWEWwHAwLbCA==} + cpu: [arm64] + os: [linux] + + '@lmdb/lmdb-linux-arm@3.5.4': + resolution: {integrity: sha512-SGbFR7816uBcTHc2ZY4S6WyOkl9bICnzqTQd2Mv4V/j24cfds88xx2nC6cm/y8zGQL7Ds31YF/5NGxjgcdM5Hw==} + cpu: [arm] + os: [linux] + + '@lmdb/lmdb-linux-x64@3.5.4': + resolution: {integrity: sha512-Gxq8jpgOWXwd0PUR+c9R2Ik1/uBnGd5GMIIzRRDqABCkvmjtC3KWcyhesV9jSPCz759isl0NlbsstZ2oyvk8lA==} + cpu: [x64] + os: [linux] + + '@lmdb/lmdb-win32-arm64@3.5.4': + resolution: {integrity: sha512-pKv1DJ1bPZAaHkdFsSz5IDfUG8x9vntgquXF9/Dm2xuupcIe/EkLzylpoBxppFVK5vzbV561Dq26jNY2fIMA7g==} + cpu: [arm64] + os: [win32] + + '@lmdb/lmdb-win32-x64@3.5.4': + resolution: {integrity: sha512-JF1BmLCm9kGEVZgYmJq43zeQVdHVgAJnTi/NURWEsy6L1ZrrlSmdltS+D17QN4LODwf+1LMXAA9auIZVXtWwzw==} + cpu: [x64] + os: [win32] + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} + cpu: [x64] + os: [win32] + + '@posthog/core@1.29.1': + resolution: {integrity: sha512-q+/t/DZALr50YTE0dFgfGSS9EgwcyAlqsn+JS61wLkwdcDM5yu/YTDM8oMKmJupsyjSZlVkDuHZAMd4ab7AxzQ==} + + '@posthog/types@1.373.4': + resolution: {integrity: sha512-n+0AbGRYYsbi+CQXQi2rF1lwTSyASlaogcw4YSkzB5KeMa4Y6nhNb7+TTnu9aVor+BycsQYCa2OsBrMMbaTekw==} + + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + + '@types/bcryptjs@3.0.0': + resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==} + deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed. + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@22.19.19': + resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + bcryptjs@3.0.3: + resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} + hasBin: true + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + hono@4.12.18: + resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==} + engines: {node: '>=16.9.0'} + + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + lmdb@3.5.4: + resolution: {integrity: sha512-9FKQA6G1MMtqNxfxvSBNXD/axeG2QRjYbNh0/ykRL5xYcRbCm2vXq7B9bhc7nSuKdHzr8/BHIwfPuYYH1UsXXw==} + hasBin: true + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msgpackr-extract@3.0.3: + resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} + hasBin: true + + msgpackr@1.11.12: + resolution: {integrity: sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-addon-api@6.1.0: + resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==} + + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + + ordered-binary@1.6.1: + resolution: {integrity: sha512-QkCdPooczexPLiXIrbVOPYkR3VO3T6v2OyKRkR1Xbhpy7/LAVXwahnRCgRp78Oe/Ehf0C/HATAxfSr6eA1oX+w==} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + + posthog-node@5.34.1: + resolution: {integrity: sha512-kGl0kSfh2+Ey3KL5Sji3yv9W5xwPK9sTkINRoFqCh9fbYXWWY6Zwi5Psv2QmRcbYiMJBk/iecnoOKVDRRga6PA==} + engines: {node: ^20.20.0 || >=22.22.0} + peerDependencies: + rxjs: ^7.0.0 + peerDependenciesMeta: + rxjs: + optional: true + + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tsx@4.22.0: + resolution: {integrity: sha512-8ccZMPD69s1AbKXx0C5ddTNZfNjwV04iIKgjZmKfKxMynEtSYcK0Lh7iQFh53fI5Yu4pb9usgAiqyPmEONaALg==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + weak-lru-cache@1.2.2: + resolution: {integrity: sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + +snapshots: + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.28.0': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.28.0': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.28.0': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.28.0': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.28.0': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.28.0': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.28.0': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.28.0': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.28.0': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.28.0': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.28.0': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.28.0': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.28.0': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.28.0': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.28.0': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.28.0': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.28.0': + optional: true + + '@esbuild/netbsd-arm64@0.28.0': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.28.0': + optional: true + + '@esbuild/openbsd-arm64@0.28.0': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.28.0': + optional: true + + '@esbuild/openharmony-arm64@0.28.0': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.28.0': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.28.0': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.28.0': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.28.0': + optional: true + + '@harperfast/extended-iterable@1.0.3': {} + + '@hono/node-server@2.0.2(hono@4.12.18)': + dependencies: + hono: 4.12.18 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@lmdb/lmdb-darwin-arm64@3.5.4': + optional: true + + '@lmdb/lmdb-darwin-x64@3.5.4': + optional: true + + '@lmdb/lmdb-linux-arm64@3.5.4': + optional: true + + '@lmdb/lmdb-linux-arm@3.5.4': + optional: true + + '@lmdb/lmdb-linux-x64@3.5.4': + optional: true + + '@lmdb/lmdb-win32-arm64@3.5.4': + optional: true + + '@lmdb/lmdb-win32-x64@3.5.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + optional: true + + '@posthog/core@1.29.1': + dependencies: + '@posthog/types': 1.373.4 + + '@posthog/types@1.373.4': {} + + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + + '@types/bcryptjs@3.0.0': + dependencies: + bcryptjs: 3.0.3 + + '@types/estree@1.0.8': {} + + '@types/estree@1.0.9': {} + + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 22.19.19 + + '@types/ms@2.1.0': {} + + '@types/node@22.19.19': + dependencies: + undici-types: 6.21.0 + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.19))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@22.19.19) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + assertion-error@2.0.1: {} + + bcryptjs@3.0.3: {} + + buffer-equal-constant-time@1.0.1: {} + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + detect-libc@2.1.2: {} + + dotenv@17.4.2: {} + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + es-module-lexer@1.7.0: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.3.0: {} + + fsevents@2.3.3: + optional: true + + hono@4.12.18: {} + + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.0 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + lmdb@3.5.4: + dependencies: + '@harperfast/extended-iterable': 1.0.3 + msgpackr: 1.11.12 + node-addon-api: 6.1.0 + node-gyp-build-optional-packages: 5.2.2 + ordered-binary: 1.6.1 + weak-lru-cache: 1.2.2 + optionalDependencies: + '@lmdb/lmdb-darwin-arm64': 3.5.4 + '@lmdb/lmdb-darwin-x64': 3.5.4 + '@lmdb/lmdb-linux-arm': 3.5.4 + '@lmdb/lmdb-linux-arm64': 3.5.4 + '@lmdb/lmdb-linux-x64': 3.5.4 + '@lmdb/lmdb-win32-arm64': 3.5.4 + '@lmdb/lmdb-win32-x64': 3.5.4 + + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.once@4.1.1: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + ms@2.1.3: {} + + msgpackr-extract@3.0.3: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 + optional: true + + msgpackr@1.11.12: + optionalDependencies: + msgpackr-extract: 3.0.3 + + nanoid@3.3.12: {} + + node-addon-api@6.1.0: {} + + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + + ordered-binary@1.6.1: {} + + pathe@1.1.2: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + posthog-node@5.34.1: + dependencies: + '@posthog/core': 1.29.1 + + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + + safe-buffer@5.2.1: {} + + semver@7.8.0: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + tsx@4.22.0: + dependencies: + esbuild: 0.28.0 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + vite-node@2.1.9(@types/node@22.19.19): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@22.19.19) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@22.19.19): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.14 + rollup: 4.60.4 + optionalDependencies: + '@types/node': 22.19.19 + fsevents: 2.3.3 + + vitest@2.1.9(@types/node@22.19.19): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.19)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@22.19.19) + vite-node: 2.1.9(@types/node@22.19.19) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.19 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + weak-lru-cache@1.2.2: {} + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/src/routes/objects.ts b/src/routes/objects.ts index bf55013..4d56eb7 100644 --- a/src/routes/objects.ts +++ b/src/routes/objects.ts @@ -10,7 +10,7 @@ import { Context, Hono } from 'hono'; import { config } from '../config.js'; import { requireSignedAgent } from '../middleware/signedAgent.js'; import { ErrorCodes, errorResponse } from '../errors.js'; -import { HASH_RE, type SignatureEnvelope } from '../vendor/cap-tree-core/index.js'; +import { HASH_RE, type SignatureEnvelope } from 'cap-tree-core'; import type { Services } from '../services/container.js'; const objects = new Hono(); diff --git a/src/routes/trees.ts b/src/routes/trees.ts index 1612721..e0f33ce 100644 --- a/src/routes/trees.ts +++ b/src/routes/trees.ts @@ -6,7 +6,7 @@ */ import { Context, Hono } from 'hono'; import { ErrorCodes, errorResponse } from '../errors.js'; -import { HASH_RE } from '../vendor/cap-tree-core/index.js'; +import { HASH_RE } from 'cap-tree-core'; import type { Services } from '../services/container.js'; const trees = new Hono(); @@ -55,7 +55,7 @@ trees.get('/:treeId/roots/:rootHash/history', (c) => { return errorResponse(ErrorCodes.OBJECT_NOT_FOUND, 'Root not found in this tree', 404); } const limit = parseLimit(c, 20, 100); - return c.json(services.objects.rootHistory(treeId, rootHash, limit)); + return c.json(services.objects.rootHistory(treeId, rootHash, limit, c.req.query('cursor'))); }); trees.get('/:treeId/roots/:rootHash', (c) => { diff --git a/src/services/interfaces.ts b/src/services/interfaces.ts index cf43abc..fa401e1 100644 --- a/src/services/interfaces.ts +++ b/src/services/interfaces.ts @@ -149,7 +149,7 @@ export interface IVideoService { import type { SignatureEnvelope, ObjectRef, TreeRoot, Refs, -} from '../vendor/cap-tree-core/index.js'; +} from 'cap-tree-core'; import type { StoredObject } from '../storage/capTreeDb.js'; import type { PublishResult } from './objectService.js'; @@ -162,7 +162,7 @@ export interface IObjectService { currentRefs(treeId: string): SignatureEnvelope | null; refsHistory(treeId: string, before: number | undefined, limit: number): { entries: SignatureEnvelope[]; next_cursor: number | null }; rootOfTree(treeId: string, rootHash: string): SignatureEnvelope | null; - rootHistory(treeId: string, rootHash: string, limit: number): { roots: Array<{ hash: string; parents: string[]; message: string; timestamp: string; entryCount: number }>; next_cursor: string | null }; + rootHistory(treeId: string, rootHash: string, limit: number, cursor?: string): { roots: Array<{ hash: string; parents: string[]; message: string; timestamp: string; entryCount: number }>; next_cursor: string | null }; resolvePath(treeId: string, rootHash: string, path: string): Promise<{ path: string; kind: 'blob' | 'tree'; ref: ObjectRef } | null>; queryReviews(params: { treeId: string; type?: string; recipient?: string; outcome?: string; root?: string; limit: number; cursor?: string }): { reviews: SignatureEnvelope[]; next_cursor: string | null }; } diff --git a/src/services/objectService.ts b/src/services/objectService.ts index 52fa5a8..4513f1a 100644 --- a/src/services/objectService.ts +++ b/src/services/objectService.ts @@ -21,7 +21,7 @@ import { type ReviewResponse, type Tree, type Resolver, -} from '../vendor/cap-tree-core/index.js'; +} from 'cap-tree-core'; import { getObject, hasObject, @@ -229,6 +229,12 @@ export class ObjectService implements IObjectService { // refs is always a chain member → generic idempotency stays correct. const ext = extendRefsChain(refs.treeId, refs.seq, refs.prev, hash); if (!ext.ok) { + if (ext.currentSeq === refs.seq && ext.currentHash === hash) { + // This exact refs object is already the chain head but wasn't stored — + // a crash between extend and store, or a concurrent duplicate publish. + // Heal: store it and report success rather than a conflict. + return this.store(clean, hash, refs.type, size, uploaderKeyId); + } return err(ErrorCodes.CAP_REFS_CONFLICT, 409, 'Refs chain conflict', undefined, { currentSeq: ext.currentSeq, currentHash: ext.currentHash, @@ -321,12 +327,18 @@ export class ObjectService implements IObjectService { return o?.envelope ? (o.envelope as SignatureEnvelope) : null; } - /** Ancestor walk (BFS over parents), newest-first. */ - rootHistory(treeId: string, rootHash: string, limit: number): { roots: Array<{ hash: string; parents: string[]; message: string; timestamp: string; entryCount: number }>; next_cursor: string | null } { + /** + * Ancestor walk (BFS over parents), newest-first. The BFS order is + * deterministic, so `cursor` (a previous page's next_cursor, a root hash) + * resumes the walk by skipping everything up to and including it. + */ + rootHistory(treeId: string, rootHash: string, limit: number, cursor?: string): { roots: Array<{ hash: string; parents: string[]; message: string; timestamp: string; entryCount: number }>; next_cursor: string | null } { const out: Array<{ hash: string; parents: string[]; message: string; timestamp: string; entryCount: number }> = []; const seen = new Set(); const queue: string[] = [rootHash]; - while (queue.length > 0 && out.length < limit + 1) { + let emitting = cursor === undefined; + let hasMore = false; + while (queue.length > 0) { const h = queue.shift()!; if (seen.has(h)) continue; seen.add(h); @@ -334,19 +346,22 @@ export class ObjectService implements IObjectService { const o = getObject(h); const root = o?.envelope?.payload as TreeRoot | undefined; if (!root || root.type !== 'tree-root') continue; - out.push({ - hash: h, - parents: root.parents.map((p) => p.hash), - message: root.message, - timestamp: root.timestamp, - entryCount: root.entries.length, - }); + if (emitting) { + if (out.length >= limit) { hasMore = true; break; } + out.push({ + hash: h, + parents: root.parents.map((p) => p.hash), + message: root.message, + timestamp: root.timestamp, + entryCount: root.entries.length, + }); + } else if (h === cursor) { + emitting = true; + } for (const p of root.parents) queue.push(p.hash); } - const hasMore = out.length > limit; - const page = out.slice(0, limit); - const next_cursor = hasMore ? page[page.length - 1]!.hash : null; - return { roots: page, next_cursor }; + const next_cursor = hasMore && out.length > 0 ? out[out.length - 1]!.hash : null; + return { roots: out, next_cursor }; } /** diff --git a/src/storage/capTreeDb.ts b/src/storage/capTreeDb.ts index 0a3faed..1c58142 100644 --- a/src/storage/capTreeDb.ts +++ b/src/storage/capTreeDb.ts @@ -16,7 +16,7 @@ */ import { open, Database } from 'lmdb'; import { config } from '../config.js'; -import type { SignatureEnvelope } from '../vendor/cap-tree-core/index.js'; +import type { SignatureEnvelope } from 'cap-tree-core'; // ─── Stored value shapes ──────────────────────────────────── diff --git a/src/test/cap-tree-lifecycle.test.ts b/src/test/cap-tree-lifecycle.test.ts index 3c4147d..75b07ed 100644 --- a/src/test/cap-tree-lifecycle.test.ts +++ b/src/test/cap-tree-lifecycle.test.ts @@ -15,7 +15,7 @@ import { generateKeyPair, signEnvelope, objectHash, verifyRootChain, verifyMerge, verifyRefs, type SignatureEnvelope, type TreeRoot, type Refs, type Policy, type ObjectRef, type Resolver, -} from '../vendor/cap-tree-core/index.js'; +} from 'cap-tree-core'; const CAP_CT = 'application/vnd.cap-tree+json'; const TEST_DB_PATH = './data/test-cap-tree-lifecycle.lmdb'; diff --git a/src/test/cap-tree-objects.test.ts b/src/test/cap-tree-objects.test.ts index 27da57b..4155512 100644 --- a/src/test/cap-tree-objects.test.ts +++ b/src/test/cap-tree-objects.test.ts @@ -8,7 +8,7 @@ import { createTestSigner, createCapSignedHeaders, type TestSigner } from './hel import { objectHash, blobHash, signEnvelope, type SignatureEnvelope, type TreeRoot, type Ed25519Jwk, -} from '../vendor/cap-tree-core/index.js'; +} from 'cap-tree-core'; import vectors from './fixtures/cap-tree-vectors.json'; import ownerKeys from './fixtures/cap-tree-keypair-owner.json'; import reviewerKeys from './fixtures/cap-tree-keypair-reviewer.json'; diff --git a/src/test/cap-tree-reads.test.ts b/src/test/cap-tree-reads.test.ts index 145c254..6c327b1 100644 --- a/src/test/cap-tree-reads.test.ts +++ b/src/test/cap-tree-reads.test.ts @@ -7,7 +7,7 @@ import { initDatabase, closeDatabase, updateAgentKeyPlan } from '../storage/db.j import { createServices, type Services } from '../services/container.js'; import { rmSync } from 'fs'; import { createTestSigner, createCapSignedHeaders, type TestSigner } from './helpers/signing.js'; -import { type SignatureEnvelope } from '../vendor/cap-tree-core/index.js'; +import { type SignatureEnvelope } from 'cap-tree-core'; import vectors from './fixtures/cap-tree-vectors.json'; const CAP_CT = 'application/vnd.cap-tree+json'; @@ -73,6 +73,15 @@ describe('CAP-Tree reads — tree endpoints, reviews, discovery', () => { expect(body.roots[0].entryCount).toBeGreaterThanOrEqual(0); }); + it('roots history pagination: next_cursor resumes the walk', async () => { + const page1 = await (await app.request(`http://localhost/v1/trees/${treeId}/roots/${featureHash}/history?limit=2`)).json(); + expect(page1.roots.map((r: any) => r.hash)).toEqual([featureHash, secondHash]); + expect(page1.next_cursor).toBe(secondHash); + const page2 = await (await app.request(`http://localhost/v1/trees/${treeId}/roots/${featureHash}/history?limit=2&cursor=${page1.next_cursor}`)).json(); + expect(page2.roots.map((r: any) => r.hash)).toEqual([genesisHash]); + expect(page2.next_cursor).toBeNull(); + }); + it('resolve walks a subtree path to its terminal entry', async () => { const res = await app.request(`http://localhost/v1/trees/${treeId}/resolve?root=${genesisHash}&path=docs/README.md`); expect(res.status).toBe(200); diff --git a/src/test/cap-tree-refs.test.ts b/src/test/cap-tree-refs.test.ts index 7c189db..178a931 100644 --- a/src/test/cap-tree-refs.test.ts +++ b/src/test/cap-tree-refs.test.ts @@ -6,7 +6,7 @@ import { initDatabase, closeDatabase, updateAgentKeyPlan } from '../storage/db.j import { createServices, type Services } from '../services/container.js'; import { rmSync } from 'fs'; import { createTestSigner, createCapSignedHeaders, type TestSigner } from './helpers/signing.js'; -import { signEnvelope, type SignatureEnvelope, type Refs, type Ed25519Jwk } from '../vendor/cap-tree-core/index.js'; +import { signEnvelope, type SignatureEnvelope, type Refs, type Ed25519Jwk } from 'cap-tree-core'; import vectors from './fixtures/cap-tree-vectors.json'; import ownerKeys from './fixtures/cap-tree-keypair-owner.json'; import reviewerKeys from './fixtures/cap-tree-keypair-reviewer.json'; @@ -116,4 +116,23 @@ describe('CAP-Tree refs — chain enforcement', () => { const seqs = body.refs.map((e: any) => e.payload.seq); expect(seqs).toEqual([2, 1]); }); + + it('heals a chain-extended-but-unstored refs (crash window) on re-publish', async () => { + // Simulate the crash: advance the chain directly without storing the object. + const { extendRefsChain, getObject } = await import('../storage/capTreeDb.js'); + const { objectHash } = await import('cap-tree-core'); + const payload: Refs = { ...refsPayload('refsSeq2'), seq: 3, prev: seq2Hash, timestamp: '2034-04-04T00:00:00Z' }; + const hash = await objectHash(payload); + const ext = extendRefsChain(treeId, 3, seq2Hash, hash); + expect(ext.ok).toBe(true); + expect(getObject(hash)).toBeUndefined(); // chain head exists, object missing + + // Re-publishing the same envelope must succeed and store the object, + // not 409 against itself. + const res = await publishRefs(payload, owner); + expect([200, 201]).toContain(res.status); + expect(getObject(hash)).toBeDefined(); + const cur = await app.request(`http://localhost/v1/trees/${treeId}/refs`); + expect((await cur.json()).payload.seq).toBe(3); + }); }); diff --git a/src/vendor/cap-tree-core/LICENSE b/src/vendor/cap-tree-core/LICENSE deleted file mode 100644 index 30c7ad0..0000000 --- a/src/vendor/cap-tree-core/LICENSE +++ /dev/null @@ -1,25 +0,0 @@ -MIT License - -Copyright (c) 2026 Tom Wilson - -Vendored from https://github.com/twilson63/cap-tree (core/, commit -1967f2a402f453f2adc4251e6a828ed3c1d3eb1b). The CAP-Tree reference -implementation code is licensed under the MIT License. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/src/vendor/cap-tree-core/crypto.ts b/src/vendor/cap-tree-core/crypto.ts deleted file mode 100644 index 1cac8c8..0000000 --- a/src/vendor/cap-tree-core/crypto.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Hashing, fingerprints, and Ed25519 signature envelopes — WebCrypto only, - * so the same code runs in Node >= 20 and modern browsers. - */ -import { canonicalBytes, toBase64url, fromBase64url } from './encoding.js'; - -export interface Ed25519Jwk { - kty: 'OKP'; - crv: 'Ed25519'; - x: string; // base64url raw public key - d?: string; // base64url raw private key (never transmitted) -} - -export interface SignatureEnvelope

{ - payload: P; - signerFingerprint: string; - publicKey: Ed25519Jwk; - signature: string; // base64url Ed25519 over JCS(payload) -} - -const subtle = globalThis.crypto.subtle; - -export async function sha256(bytes: Uint8Array): Promise { - return toBase64url(new Uint8Array(await subtle.digest('SHA-256', bytes as BufferSource))); -} - -/** objectHash: SHA-256 of the JCS canonical bytes (data-model § 2.2). */ -export async function objectHash(payload: unknown): Promise { - return sha256(canonicalBytes(payload)); -} - -/** blobHash: SHA-256 of raw content bytes (data-model § 2.2). */ -export async function blobHash(bytes: Uint8Array): Promise { - return sha256(bytes); -} - -// Ed25519 SubjectPublicKeyInfo is a fixed 12-byte DER prefix + 32 raw key bytes, -// so fingerprints need no ASN.1 library. -const SPKI_PREFIX = new Uint8Array([0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00]); - -/** fingerprint: base64url(SHA-256(SPKI-DER(publicKey))) (data-model § 2.1). */ -export async function fingerprint(publicKey: Ed25519Jwk): Promise { - const raw = fromBase64url(publicKey.x); - if (raw.length !== 32) throw new Error('Ed25519 public key must be 32 bytes'); - const spki = new Uint8Array(SPKI_PREFIX.length + 32); - spki.set(SPKI_PREFIX); - spki.set(raw, SPKI_PREFIX.length); - return sha256(spki); -} - -export async function generateKeyPair(): Promise<{ publicJwk: Ed25519Jwk; privateJwk: Ed25519Jwk; fingerprint: string }> { - const pair = (await subtle.generateKey('Ed25519', true, ['sign', 'verify'])) as CryptoKeyPair; - const publicJwk = (await subtle.exportKey('jwk', pair.publicKey)) as Ed25519Jwk; - const privateJwk = (await subtle.exportKey('jwk', pair.privateKey)) as Ed25519Jwk; - return { publicJwk: stripJwk(publicJwk), privateJwk, fingerprint: await fingerprint(publicJwk) }; -} - -function stripJwk(jwk: Ed25519Jwk): Ed25519Jwk { - return { kty: 'OKP', crv: 'Ed25519', x: jwk.x }; -} - -async function importPublic(jwk: Ed25519Jwk): Promise { - return subtle.importKey('jwk', { kty: jwk.kty, crv: jwk.crv, x: jwk.x }, 'Ed25519', false, ['verify']); -} - -async function importPrivate(jwk: Ed25519Jwk): Promise { - if (!jwk.d) throw new Error('private JWK required (missing "d")'); - return subtle.importKey('jwk', { ...jwk, key_ops: ['sign'] }, 'Ed25519', false, ['sign']); -} - -/** Sign a payload into a self-contained envelope (data-model § 2.4). */ -export async function signEnvelope

( - payload: P, - privateJwk: Ed25519Jwk, - publicJwk: Ed25519Jwk -): Promise> { - const key = await importPrivate(privateJwk); - const sig = await subtle.sign('Ed25519', key, canonicalBytes(payload) as BufferSource); - return { - payload, - signerFingerprint: await fingerprint(publicJwk), - publicKey: stripJwk(publicJwk), - signature: toBase64url(new Uint8Array(sig)), - }; -} - -export interface EnvelopeVerdict { - ok: boolean; - /** objectHash of the payload — the envelope's reference identity. */ - hash: string; - errors: string[]; -} - -/** Verify an envelope per data-model § 2.4 / § 6.1 steps 1–2. */ -export async function verifyEnvelope(env: SignatureEnvelope): Promise { - const errors: string[] = []; - const hash = await objectHash(env.payload); - let fp = ''; - try { - fp = await fingerprint(env.publicKey); - } catch (e) { - errors.push(`invalid public key: ${(e as Error).message}`); - } - if (fp && fp !== env.signerFingerprint) { - errors.push('signerFingerprint does not match the embedded public key'); - } - if (errors.length === 0) { - try { - const key = await importPublic(env.publicKey); - const ok = await subtle.verify( - 'Ed25519', - key, - fromBase64url(env.signature) as BufferSource, - canonicalBytes(env.payload) as BufferSource - ); - if (!ok) errors.push('Ed25519 signature does not verify over the canonical payload bytes'); - } catch (e) { - errors.push(`signature verification failed: ${(e as Error).message}`); - } - } - return { ok: errors.length === 0, hash, errors }; -} diff --git a/src/vendor/cap-tree-core/encoding.ts b/src/vendor/cap-tree-core/encoding.ts deleted file mode 100644 index 8d52c21..0000000 --- a/src/vendor/cap-tree-core/encoding.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Base64url (RFC 4648 § 5, no padding) and JCS (RFC 8785) for the CAP-Tree - * object profile. Pure functions, no platform dependencies. - */ - -const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; -const REVERSE: Record = {}; -for (let i = 0; i < ALPHABET.length; i++) REVERSE[ALPHABET[i]!] = i; - -export function toBase64url(bytes: Uint8Array): string { - let out = ''; - for (let i = 0; i < bytes.length; i += 3) { - const a = bytes[i]!, b = bytes[i + 1], c = bytes[i + 2]; - out += ALPHABET[a >> 2]!; - out += ALPHABET[((a & 3) << 4) | ((b ?? 0) >> 4)]!; - if (b !== undefined) out += ALPHABET[((b & 15) << 2) | ((c ?? 0) >> 6)]!; - if (c !== undefined) out += ALPHABET[c & 63]!; - } - return out; -} - -export function fromBase64url(s: string): Uint8Array { - const out = new Uint8Array(Math.floor((s.length * 3) / 4)); - let o = 0; - for (let i = 0; i < s.length; i += 4) { - const a = REVERSE[s[i]!], b = REVERSE[s[i + 1]!]; - if (a === undefined || b === undefined) throw new Error('invalid base64url'); - out[o++] = (a << 2) | (b >> 4); - const cChar = s[i + 2]; - if (cChar !== undefined) { - const c = REVERSE[cChar]; - if (c === undefined) throw new Error('invalid base64url'); - out[o++] = ((b & 15) << 4) | (c >> 2); - const dChar = s[i + 3]; - if (dChar !== undefined) { - const d = REVERSE[dChar]; - if (d === undefined) throw new Error('invalid base64url'); - out[o++] = ((c & 3) << 6) | d; - } - } - } - return out.subarray(0, o); -} - -/** 43-char base64url SHA-256 string (fingerprints, hashes). */ -export const HASH_RE = /^[A-Za-z0-9_-]{43}$/; - -/** - * RFC 8785 canonicalization, restricted to the CAP-Tree object profile: - * strings, integers, booleans, null, arrays, objects. Non-integer numbers - * never appear in CAP-Tree objects and are rejected (data-model § 2.2). - * RFC 8785 string escaping matches ECMAScript JSON.stringify; property - * names sort by UTF-16 code units (the JS default string ordering). - */ -export function canonicalize(value: unknown): string { - if (value === null || typeof value === 'boolean' || typeof value === 'string') { - return JSON.stringify(value); - } - if (typeof value === 'number') { - if (!Number.isInteger(value) || !Number.isFinite(value)) { - throw new Error('CAP-Tree objects may only contain integer numbers'); - } - return JSON.stringify(value); - } - if (Array.isArray(value)) { - return `[${value.map(canonicalize).join(',')}]`; - } - if (typeof value === 'object') { - const entries = Object.keys(value as object) - .sort() - .map((k) => `${JSON.stringify(k)}:${canonicalize((value as Record)[k])}`); - return `{${entries.join(',')}}`; - } - throw new Error(`cannot canonicalize value of type ${typeof value}`); -} - -export function canonicalBytes(value: unknown): Uint8Array { - return new TextEncoder().encode(canonicalize(value)); -} diff --git a/src/vendor/cap-tree-core/index.ts b/src/vendor/cap-tree-core/index.ts deleted file mode 100644 index f31978b..0000000 --- a/src/vendor/cap-tree-core/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * cap-tree-core — reference implementation of the CAP-Tree v0.3 data model. - * https://github.com/twilson63/cap-tree - * - * VENDORED from twilson63/cap-tree `core/src/` at commit - * 1967f2a402f453f2adc4251e6a828ed3c1d3eb1b (MIT, Copyright (c) 2026 Tom Wilson). - * Do not edit these files — replace this directory with the `cap-tree-core` npm - * package (import specifier unchanged) once it is published. See ./LICENSE. - */ -export { canonicalize, canonicalBytes, toBase64url, fromBase64url, HASH_RE } from './encoding.js'; -export { - sha256, objectHash, blobHash, fingerprint, generateKeyPair, - signEnvelope, verifyEnvelope, - type Ed25519Jwk, type SignatureEnvelope, type EnvelopeVerdict, -} from './crypto.js'; -export { - SPEC_VERSION, validateObject, validatePathSegment, - type ObjectRef, type TreeEntry, type Policy, type TreeRoot, type Tree, - type Refs, type ReviewRequest, type ReviewResponse, type ReviewOutcome, - type ChunkManifest, type CapObject, -} from './objects.js'; -export { - verifyObject, verifyRootChain, verifyMerge, verifyRefs, - type Resolver, type Verdict, type ChainVerdict, type MergeVerdict, type RefsVerdict, -} from './verify.js'; diff --git a/src/vendor/cap-tree-core/objects.ts b/src/vendor/cap-tree-core/objects.ts deleted file mode 100644 index 4bf66ae..0000000 --- a/src/vendor/cap-tree-core/objects.ts +++ /dev/null @@ -1,228 +0,0 @@ -/** - * CAP-Tree v0.3 object types and structural validation (data-model § 3–§ 5). - * Structural validation is § 6.1 step 3 — it checks shape, not signatures - * or chains; those live in verify.ts. - */ -import { HASH_RE } from './encoding.js'; - -export const SPEC_VERSION = 3; - -export interface ObjectRef { - id?: string; - hash: string; -} - -export interface TreeEntry { - path: string; - kind: 'blob' | 'tree'; - ref: ObjectRef; -} - -export interface Policy { - requiredApprovals: number; - reviewers: string[]; - selfReview: boolean; -} - -export interface TreeRoot { - type: 'tree-root'; - specVersion: number; - ownerFingerprint: string; - adminFingerprints: string[]; - entries: TreeEntry[]; - parents: ObjectRef[]; - policy: Policy | null; - approvals: ObjectRef[]; - message: string; - timestamp: string; - rotateTo?: string; -} - -export interface Tree { - type: 'tree'; - specVersion: number; - entries: TreeEntry[]; -} - -export interface Refs { - type: 'refs'; - specVersion: number; - treeId: string; - seq: number; - prev: string | null; - branches: Record; - tags: Record; - timestamp: string; -} - -export interface ReviewRequest { - type: 'review-request'; - specVersion: number; - root: ObjectRef; - target: ObjectRef; - authorFingerprint: string; - reviewerFingerprint: string; - message: string; - timestamp: string; -} - -export type ReviewOutcome = 'approved' | 'changes-requested' | 'commented'; - -export interface ReviewResponse { - type: 'review-response'; - specVersion: number; - request: ObjectRef; - root: ObjectRef; - outcome: ReviewOutcome; - reviewerFingerprint: string; - message: string; - timestamp: string; -} - -export interface ChunkManifest { - type: 'chunks'; - specVersion: number; - totalBytes: number; - chunks: ObjectRef[]; -} - -export type CapObject = TreeRoot | Tree | Refs | ReviewRequest | ReviewResponse | ChunkManifest; - -// --- Path rules (data-model § 4) --- - -/** - * Validate a single path segment. Returns the NFC-normalized segment. - * Throws on violation — materializing clients treat these as errors, - * never warnings (path-traversal defense). - */ -export function validatePathSegment(segment: string): string { - const nfc = segment.normalize('NFC'); - if (nfc.length === 0) throw new Error('path segment must not be empty'); - if (nfc === '.' || nfc === '..') throw new Error(`path segment must not be "${nfc}"`); - if (/[\/\\\u0000]/.test(nfc)) throw new Error('path segment must not contain "/", "\\\\", or NUL'); - if (new TextEncoder().encode(nfc).length > 255) throw new Error('path segment exceeds 255 bytes'); - return nfc; -} - -const enc = new TextEncoder(); -function utf8Compare(a: string, b: string): number { - const ab = enc.encode(a), bb = enc.encode(b); - const n = Math.min(ab.length, bb.length); - for (let i = 0; i < n; i++) { - const d = ab[i]! - bb[i]!; - if (d !== 0) return d; - } - return ab.length - bb.length; -} - -// --- Structural validation --- - -function isFingerprint(s: unknown): s is string { - return typeof s === 'string' && HASH_RE.test(s); -} -function isRef(r: unknown): r is ObjectRef { - return ( - typeof r === 'object' && r !== null && - isFingerprint((r as ObjectRef).hash) && - ((r as ObjectRef).id === undefined || typeof (r as ObjectRef).id === 'string') - ); -} - -function validateEntries(entries: unknown, errors: string[]): void { - if (!Array.isArray(entries)) { errors.push('entries must be an array'); return; } - const seen = new Set(); - let prev: string | null = null; - for (const e of entries as TreeEntry[]) { - if (typeof e !== 'object' || e === null) { errors.push('entry must be an object'); continue; } - try { validatePathSegment(e.path); } catch (err) { errors.push((err as Error).message); } - if (e.kind !== 'blob' && e.kind !== 'tree') errors.push(`entry "${e.path}": kind must be "blob" or "tree"`); - if (!isRef(e.ref)) errors.push(`entry "${e.path}": invalid ref`); - if (seen.has(e.path)) errors.push(`duplicate entry path "${e.path}"`); - seen.add(e.path); - if (prev !== null && utf8Compare(prev, e.path) >= 0) { - errors.push(`entries not sorted by path: "${prev}" >= "${e.path}"`); - } - prev = e.path; - } -} - -function validatePolicy(policy: unknown, errors: string[]): void { - if (policy === null) return; - const p = policy as Policy; - if (typeof p !== 'object' || p === null) { errors.push('policy must be an object or null'); return; } - if (!Number.isInteger(p.requiredApprovals) || p.requiredApprovals < 0) errors.push('policy.requiredApprovals must be an integer >= 0'); - if (!Array.isArray(p.reviewers) || !p.reviewers.every(isFingerprint)) errors.push('policy.reviewers must be an array of fingerprints'); - if (typeof p.selfReview !== 'boolean') errors.push('policy.selfReview must be a boolean'); -} - -/** - * Structural validation for any CAP-Tree object (§ 6.1 step 3). - * Returns a list of violations; empty list means structurally valid. - */ -export function validateObject(payload: unknown): string[] { - const errors: string[] = []; - const o = payload as Partial & Record; - if (typeof o !== 'object' || o === null) return ['object payload required']; - if (o.specVersion !== SPEC_VERSION) errors.push(`unsupported specVersion: ${String(o.specVersion)}`); - - switch (o.type) { - case 'tree-root': { - const r = o as TreeRoot; - if (!isFingerprint(r.ownerFingerprint)) errors.push('ownerFingerprint must be a 43-char base64url fingerprint'); - if (!Array.isArray(r.adminFingerprints) || !r.adminFingerprints.every(isFingerprint)) errors.push('adminFingerprints must be an array of fingerprints'); - validateEntries(r.entries, errors); - if (!Array.isArray(r.parents) || !r.parents.every(isRef)) errors.push('parents must be an array of ObjectRefs with hashes'); - validatePolicy(r.policy === undefined ? null : r.policy, errors); - if (r.policy === undefined) errors.push('policy is required (use null for none)'); - if (!Array.isArray(r.approvals) || !r.approvals.every(isRef)) errors.push('approvals must be an array of ObjectRefs'); - if (typeof r.message !== 'string') errors.push('message must be a string'); - if (typeof r.timestamp !== 'string') errors.push('timestamp must be a string'); - if (r.rotateTo !== undefined && !isFingerprint(r.rotateTo)) errors.push('rotateTo must be a fingerprint'); - break; - } - case 'tree': - validateEntries((o as Tree).entries, errors); - break; - case 'refs': { - const r = o as Refs; - if (!isFingerprint(r.treeId)) errors.push('treeId must be a 43-char base64url hash'); - if (!Number.isInteger(r.seq) || r.seq < 1) errors.push('seq must be an integer >= 1'); - if (r.seq === 1 ? r.prev !== null : !isFingerprint(r.prev as string)) { - errors.push('prev must be null iff seq == 1, otherwise the previous refs objectHash'); - } - for (const group of ['branches', 'tags'] as const) { - const m = r[group]; - if (typeof m !== 'object' || m === null || Object.values(m).some((v) => !isRef(v))) { - errors.push(`${group} must map names to ObjectRefs`); - } - } - if (typeof r.timestamp !== 'string') errors.push('timestamp must be a string'); - break; - } - case 'review-request': { - const r = o as ReviewRequest; - if (!isRef(r.root)) errors.push('root must be an ObjectRef'); - if (!isRef(r.target)) errors.push('target must be an ObjectRef'); - if (!isFingerprint(r.authorFingerprint)) errors.push('authorFingerprint must be a fingerprint'); - if (!isFingerprint(r.reviewerFingerprint)) errors.push('reviewerFingerprint must be a fingerprint'); - break; - } - case 'review-response': { - const r = o as ReviewResponse; - if (!isRef(r.request)) errors.push('request must be an ObjectRef'); - if (!isRef(r.root)) errors.push('root must be an ObjectRef'); - if (!['approved', 'changes-requested', 'commented'].includes(r.outcome)) errors.push('outcome must be approved | changes-requested | commented'); - if (!isFingerprint(r.reviewerFingerprint)) errors.push('reviewerFingerprint must be a fingerprint'); - break; - } - case 'chunks': { - const r = o as ChunkManifest; - if (!Number.isInteger(r.totalBytes) || r.totalBytes < 0) errors.push('totalBytes must be an integer >= 0'); - if (!Array.isArray(r.chunks) || !r.chunks.every(isRef)) errors.push('chunks must be an array of ObjectRefs'); - break; - } - default: - errors.push(`unknown object type: ${String(o.type)}`); - } - return errors; -} diff --git a/src/vendor/cap-tree-core/verify.ts b/src/vendor/cap-tree-core/verify.ts deleted file mode 100644 index 6f2ba6c..0000000 --- a/src/vendor/cap-tree-core/verify.ts +++ /dev/null @@ -1,278 +0,0 @@ -/** - * The normative verification algorithms — data-model § 6. - * - * Everything here is courier-agnostic: the caller supplies a Resolver that - * fetches envelopes by reference, and every fetched payload is checked - * against the reference hash before it is believed. A malicious resolver - * can withhold objects; it cannot make verification pass. - */ -import { verifyEnvelope, objectHash, type SignatureEnvelope } from './crypto.js'; -import { - validateObject, - type ObjectRef, type TreeRoot, type Refs, type ReviewResponse, -} from './objects.js'; - -/** Fetch an envelope by reference. Return null if unavailable. */ -export type Resolver = (ref: ObjectRef) => Promise; - -export interface Verdict { - ok: boolean; - errors: string[]; -} - -/** § 6.1 — envelope signature + specVersion + structural validity. */ -export async function verifyObject(env: SignatureEnvelope): Promise { - const sig = await verifyEnvelope(env); - const errors = [...sig.errors, ...validateObject(env.payload)]; - return { ok: errors.length === 0, hash: sig.hash, errors }; -} - -/** Resolve a ref and require the payload to hash to ref.hash. */ -async function resolveVerified( - ref: ObjectRef, - resolve: Resolver, - errors: string[], - what: string -): Promise { - const env = await resolve(ref); - if (env === null) { - errors.push(`${what}: object ${ref.hash} is unavailable`); - return null; - } - const actual = await objectHash(env.payload); - if (actual !== ref.hash) { - errors.push(`${what}: retrieved bytes hash to ${actual}, reference pins ${ref.hash}`); - return null; - } - return env; -} - -export interface ChainVerdict extends Verdict { - /** Every verified ancestor root, keyed by objectHash. */ - roots: Map; payload: TreeRoot }>; - genesisHash: string | null; -} - -/** - * § 6.2 — verify a root's ancestry back to a trusted treeId. - * - * Walks all parents (the full DAG), verifies every ancestor's envelope and - * hash pin, requires exactly one genesis whose hash equals treeId, and - * checks each root's signer against the owner active at that point in - * history (key rotation, § 7.2: a root's owner is its first parent's owner - * unless that parent declares rotateTo). - */ -export async function verifyRootChain( - rootEnv: SignatureEnvelope, - treeId: string, - resolve: Resolver -): Promise { - const errors: string[] = []; - const roots = new Map; payload: TreeRoot }>(); - let genesisHash: string | null = null; - - // Phase 1: collect and individually verify the ancestor DAG. - const tipVerdict = await verifyObject(rootEnv); - errors.push(...tipVerdict.errors.map((e) => `root ${tipVerdict.hash}: ${e}`)); - roots.set(tipVerdict.hash, { env: rootEnv, payload: rootEnv.payload }); - - const queue: TreeRoot[] = [rootEnv.payload]; - while (queue.length > 0) { - const current = queue.pop()!; - for (const parentRef of current.parents) { - if (roots.has(parentRef.hash)) continue; - const env = (await resolveVerified(parentRef, resolve, errors, 'ancestor walk')) as SignatureEnvelope | null; - if (!env) continue; - const v = await verifyObject(env); - errors.push(...v.errors.map((e) => `root ${v.hash}: ${e}`)); - if (env.payload.type !== 'tree-root') { - errors.push(`ancestor ${parentRef.hash} is not a tree-root`); - continue; - } - roots.set(parentRef.hash, { env, payload: env.payload }); - queue.push(env.payload); - } - } - - // Phase 2: exactly one genesis, equal to treeId. - const genesisHashes: string[] = []; - for (const [hash, { payload }] of roots) { - if (payload.parents.length === 0) genesisHashes.push(hash); - } - if (genesisHashes.length !== 1) { - errors.push(`expected exactly one genesis root in the ancestry, found ${genesisHashes.length}`); - } else { - genesisHash = genesisHashes[0]!; - if (genesisHash !== treeId) { - errors.push(`genesis root hashes to ${genesisHash}, which is not the trusted treeId ${treeId}`); - } - } - - // Phase 3: signer authority with rotation along first-parent lineage. - if (genesisHash === treeId && genesisHash !== null) { - const ownerOf = new Map(); // rootHash -> active owner fingerprint - const ownerFor = (hash: string): string | null => { - if (ownerOf.has(hash)) return ownerOf.get(hash)!; - const node = roots.get(hash); - if (!node) return null; - let owner: string | null; - if (node.payload.parents.length === 0) { - owner = node.payload.ownerFingerprint; - } else { - const firstParentHash = node.payload.parents[0]!.hash; - const parent = roots.get(firstParentHash); - owner = parent ? (parent.payload.rotateTo ?? ownerFor(firstParentHash)) : null; - } - if (owner !== null) ownerOf.set(hash, owner); - return owner; - }; - for (const [hash, { env, payload }] of roots) { - const owner = ownerFor(hash); - if (owner === null) continue; // unresolvable ancestry already reported - if (env.signerFingerprint !== owner) { - errors.push(`root ${hash} signed by ${env.signerFingerprint}, but the active owner is ${owner}`); - } - if (payload.ownerFingerprint !== owner) { - errors.push(`root ${hash} declares ownerFingerprint ${payload.ownerFingerprint}, but the active owner is ${owner}`); - } - } - } - - return { ok: errors.length === 0, errors, roots, genesisHash }; -} - -export interface MergeVerdict extends Verdict { - /** Whether the merge satisfies the policy of its first parent (§ 6.3). */ - policySatisfied: boolean; - countedApprovals: number; - requiredApprovals: number; -} - -/** - * § 6.3 — verify a merge root against the declared policy of its target. - * - * A merge that fails policy is still a valid object; callers MUST surface - * `policySatisfied: false` and MUST NOT present the merge as approved. - */ -export async function verifyMerge( - mergeEnv: SignatureEnvelope, - treeId: string, - resolve: Resolver -): Promise { - const merge = mergeEnv.payload; - const chain = await verifyRootChain(mergeEnv, treeId, resolve); - const errors = [...chain.errors]; - - if (merge.parents.length < 2) { - errors.push('a merge root must have two or more parents'); - return { ok: false, errors, policySatisfied: false, countedApprovals: 0, requiredApprovals: 0 }; - } - - const target = chain.roots.get(merge.parents[0]!.hash); - const policy = target?.payload.policy ?? null; - if (policy === null) { - return { ok: errors.length === 0, errors, policySatisfied: errors.length === 0, countedApprovals: 0, requiredApprovals: 0 }; - } - - const mergedHashes = new Set(merge.parents.slice(1).map((p) => p.hash)); - const policyErrors: string[] = []; - const countedReviewers = new Set(); - - for (const ref of merge.approvals) { - const env = (await resolveVerified(ref, resolve, policyErrors, 'approval')) as SignatureEnvelope | null; - if (!env) continue; - const v = await verifyObject(env); - if (!v.ok) { policyErrors.push(`approval ${ref.hash}: ${v.errors.join('; ')}`); continue; } - const r = env.payload; - if (r.type !== 'review-response') { policyErrors.push(`approval ${ref.hash} is not a review-response`); continue; } - if (r.outcome !== 'approved') { policyErrors.push(`approval ${ref.hash} has outcome "${r.outcome}", not "approved"`); continue; } - if (env.signerFingerprint !== r.reviewerFingerprint) { policyErrors.push(`approval ${ref.hash} signer differs from its reviewerFingerprint`); continue; } - if (!mergedHashes.has(r.root.hash)) { policyErrors.push(`approval ${ref.hash} approves root ${r.root.hash}, which is not among the merged parents`); continue; } - if (policy.reviewers.length > 0 && !policy.reviewers.includes(r.reviewerFingerprint)) { policyErrors.push(`approval ${ref.hash}: reviewer is not in the policy's reviewer set`); continue; } - if (!policy.selfReview && r.reviewerFingerprint === mergeEnv.signerFingerprint) { policyErrors.push(`approval ${ref.hash}: self-review is not permitted by policy`); continue; } - if (policy.reviewers.length === 0 && r.reviewerFingerprint === mergeEnv.signerFingerprint) { policyErrors.push(`approval ${ref.hash}: with an open reviewer set, the merge signer's own approval does not count`); continue; } - countedReviewers.add(r.reviewerFingerprint); // at most one approval per reviewer - } - - const policySatisfied = countedReviewers.size >= policy.requiredApprovals; - if (!policySatisfied) { - policyErrors.push(`policy requires ${policy.requiredApprovals} approval(s), counted ${countedReviewers.size}`); - errors.push(...policyErrors); - } - - return { - ok: errors.length === 0 && policySatisfied, - errors, - policySatisfied: policySatisfied && chain.ok, - countedApprovals: countedReviewers.size, - requiredApprovals: policy.requiredApprovals, - }; -} - -export interface RefsVerdict extends Verdict { - /** True when two distinct refs objects claim the same seq (§ 3.4 / § 6.4). */ - equivocation: boolean; -} - -/** - * § 6.4 — verify a refs object, optionally against the previously observed - * refs envelope for the same tree. - */ -export async function verifyRefs( - refsEnv: SignatureEnvelope, - opts: { - treeId: string; - resolve: Resolver; - /** The last refs envelope this client observed and verified, if any. */ - previous?: SignatureEnvelope; - /** Skip branch-target chain walks (cheaper; pins still checked by callers). */ - skipTargetWalks?: boolean; - } -): Promise { - const errors: string[] = []; - let equivocation = false; - const v = await verifyObject(refsEnv); - errors.push(...v.errors); - const refs = refsEnv.payload; - - if (refs.treeId !== opts.treeId) { - errors.push(`refs object is for tree ${refs.treeId}, expected ${opts.treeId}`); - } - - // Owner check: the genesis root's owner (with rotations) must have signed. - // We verify via any branch target's chain; the cheapest authoritative - // source of current ownership is the chain walk itself. - if (opts.previous) { - const prevHash = await objectHash(opts.previous.payload); - const prev = opts.previous.payload; - if (refs.seq <= prev.seq) { - const sameObject = (await objectHash(refs)) === prevHash; - if (!sameObject) { - equivocation = true; - errors.push(`equivocation: observed refs seq ${prev.seq}, received a different object at seq ${refs.seq}`); - } - } else if (refs.seq === prev.seq + 1 && refs.prev !== prevHash) { - errors.push(`refs.prev is ${refs.prev}, expected hash of the previously observed refs object ${prevHash}`); - } - } - - if (!opts.skipTargetWalks) { - for (const [group, m] of [['branches', refs.branches], ['tags', refs.tags]] as const) { - for (const [name, ref] of Object.entries(m)) { - const env = (await resolveVerified(ref, opts.resolve, errors, `${group}.${name}`)) as SignatureEnvelope | null; - if (!env) continue; - const chain = await verifyRootChain(env, opts.treeId, opts.resolve); - if (!chain.ok) errors.push(`${group}.${name}: ${chain.errors.join('; ')}`); - if (refsEnv.signerFingerprint && env && chain.genesisHash === opts.treeId) { - // Refs must be signed by the active owner at the branch tip. - const tipOwner = env.payload.rotateTo ?? env.payload.ownerFingerprint; - if (refsEnv.signerFingerprint !== tipOwner && refsEnv.signerFingerprint !== env.payload.ownerFingerprint) { - errors.push(`refs signed by ${refsEnv.signerFingerprint}, but the tree owner is ${env.payload.ownerFingerprint}`); - } - } - } - } - } - - return { ok: errors.length === 0, errors, equivocation }; -}