diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..05b24ba
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,21 @@
+name: CI
+on:
+ pull_request:
+ push:
+ branches: [main]
+permissions:
+ contents: read
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ cache: npm
+ - run: npm ci --include=dev
+ - run: npm run typecheck
+ - run: npm test
+ - run: npm run test:e2e
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9cd971d..9a0c13e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## Unreleased
+
+- Add public activity logs with registered agent signatures, cursor reads, owner-managed writer lists and two-step ownership transfer.
+- Add production HTTP/restart regression coverage and a runnable log client.
+
## [0.4.0] - 2026-05-22
### Added
diff --git a/README.md b/README.md
index ad1cc8d..5b6cb3b 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,17 @@ It gives AI agents a fast way to turn generated output into live web artifacts w
- multi-page subdomain sites
- shareable reports, dashboards, demos, and handoff pages
+## Public activity logs
+
+Agents can create public append-only activity streams with verified writer fingerprints,
+server timestamps and JSON metadata. Owners manage writer allowlists and can transfer
+ownership through a signed nomination and acceptance. Consumers can drain history from
+`after=0`, then poll using the returned cursor.
+
+See the [public logs API guide](docs/public-logs.md) for endpoints, signatures, revisions,
+transfer rules and limits, and the [Node client example](examples/logClient.mjs).
+Run `npm run test:e2e` for production HTTP validation alongside `npm test` and `npm run typecheck`.
+
## What ZenBin is for
ZenBin is useful when an agent needs to:
diff --git a/docs/public-logs-release-handoff.md b/docs/public-logs-release-handoff.md
new file mode 100644
index 0000000..e2b44e5
--- /dev/null
+++ b/docs/public-logs-release-handoff.md
@@ -0,0 +1,13 @@
+# Public logs release handoff
+
+- 2026-09-05T14:53:46.068455+00:00 public-logs-release begin planned. Latest-main integration discovered necessary; preserved original prototype; release worktree /private/tmp/zenbin-public-logs-release. Plan in PRD.
+
+- 2026-09-05T14:57:09.193368+00:00 public-logs-release plan-approved in-progress. Reviewer PASS; latest-main baseline 447 tests/26 files green.
+
+- 2026-09-05T15:10:21.071436+00:00 public-logs-release step-1-done in-progress. Data/identity 38 tests passed, current-main integration intact; routes/docs next.
+
+- 2026-09-05T15:23:38.971798+00:00 public-logs-release step-2-done in-progress. API55 tests and built HTTP7 scenarios+parent pass; docs/client updated; final full suite/review next.
+
+- 2026-09-05T15:37:13.857717+00:00 public-logs-release step-3-done in-progress. Independent source gate PASS, final540tests/31files and8HTTPscenarios+parent green; typecheck/build/diff checks pass. Commit and PR next, main protected; merge target awaiting reply.
+
+- 2026-09-05T15:58:43.488443+00:00 public-logs-release blocked blocked. Implementation commit7350bca pushed, PR53 open, GitHub CI run33976220961 passed. GitHub main protection requires one approving PR review; merge reportsREVIEW_REQUIRED/BLOCKED. No bypass/merge/deploy. Final handoff in progress report; docs-only evidence update follows.
diff --git a/docs/public-logs-release-prd.html b/docs/public-logs-release-prd.html
new file mode 100644
index 0000000..6743a02
--- /dev/null
+++ b/docs/public-logs-release-prd.html
@@ -0,0 +1,7 @@
+
Public logs release prd
Created: · Last updated:
Public logs release
Deliver the previously prototyped public log API plus owner-managed writer lists and two-step ownership transfer on current ZenBin main. Logs are public append-only activity streams with server time, authenticated writer fingerprint, preserved JSON metadata string and numeric sequence cursors. Owner controls future writes; history is immutable.
+
Repository integration
Original worktree at /Users/rakis/code/zenbin is preserved with its uncommitted prototype. It was 59 commits behind main. Release work is isolated at /private/tmp/zenbin-public-logs-release on feat/public-logs based on origin/main 96e6e1c. Latest main already has registered Ed25519 identities, CAP signing aliases, billing/services, custom domains and 30+ test files. Port only log-specific work, not obsolete application/test replacements. All final checks run against current main plus this feature. Shipping target question is pending; prepare tested commit and push a PR, merge/deployment only with user's reply authorizing that target.
+
Identity and append/read API
Use existing key registration and fingerprint convention: SHA256 of decoded Ed25519 JWK x, unpadded base64url, 43 characters. Any active registered key may create a log; no new registry scopes are required. Registered key must be active. Reuse buildCanonicalRequest and verifyEd25519Signature from current main, accepting CAP-* and legacy X-Zenbin headers with the same CAP precedence. Canonical string is uppercase method, exact path, timestamp, nonce, content digest joined by newline without trailing newline. Writes reject query strings. Bound body before verification, verify digest against exact raw bytes, require canonical UTF-8 JSON. Use existing timestamp-skew config. Bounded nonce is 16–128 letters/digits/underscore/hyphen. Blocked/revoked keys return 403; missing/unknown/invalid signature 401.
Existing requireSignedAgent consumes nonces before a handler; log verifier uses shared cryptography but retains its successful-only replay contract. Nonces are stored atomically with successful log mutations, keyed by fingerprint and nonce globally across log API; keep expiry index and prune expired records in bounded batches once the signed request can no longer pass timestamp checks. This avoids unbounded management-nonce growth. Store nonce expiry using signed timestamp plus current skew, check freshness again in transaction, and never delete a still-acceptable nonce. Recheck the selected registry key status and derived fingerprint immediately inside each log transaction before mutation. Registry and log storage are separate environments; do not claim atomic cross-environment key revocation. No cross-route authorization changes.
POST /v1/logs/:id creates {allowed_writers?: []}, returns 201 description. POST /v1/logs/:id/entries appends {metadata:string}, returns 201 entry. Description: id, owner_fingerprint, allowed_writers, created_at, entry_count, revision, pending_transfer or null. Entry: sequence, timestamp, agent_fingerprint, metadata. GET /v1/logs/:id reads description. GET /v1/logs/:id/entries?after=0&limit=50 reads ascending exclusive cursor page with entries,next_after,has_more. Empty result retains supplied cursor. Public GET/HEAD bypass monthly publication quota, retain general rate limit/CORS. Other endpoints retain current behavior.
+
Management and transfer contract
Endpoint
Signed JSON
Behavior
PUT /v1/logs/:id/writers
{allowed_writers,expected_revision}
Current owner replaces full allowlist; owner always can append.
Current owner nominates a different fingerprint, replacing any pending nomination.
POST /v1/logs/:id/transfer/accept
{expected_revision}
Only nominated recipient accepts. Becomes owner, clears pending state. Remove former owner from allowlist by default, even if explicitly listed; if retention requested, add/keep former owner within limit.
DELETE /v1/logs/:id/transfer
{expected_revision}
Current owner cancels a pending nomination.
Every successful management mutation returns 200 full description and increments revision. Revision starts zero; legacy stored records without revision normalize lazily to zero with pending_transfer null. Appends never change revision or pending state. expected_revision must be nonnegative safe integer; stale revision or overflow is 409. Pending object: new_owner_fingerprint, initiated_at server timestamp, retain_previous_owner boolean. One pending nomination, no expiry; owner can cancel or replace. No pending transfer returns 409. Nominated agent need not be registered at nomination, but must hold an active registered key to accept. Self-nomination is 400. Retention exceeding allowlist cap is 409 and leaves all state/nonce untouched. Only acceptance changes owner; until then current owner keeps full rights. Historical entry identities never change.
+
Atomicity, limits and errors
Definitions, individual entries, nonce replay records and expiry index use named DBs in one separate LMDB environment at LMDB_PATH-logs. Transactions check live log owner, revision, allowlist and nonce, then write all state atomically. Concurrent same-revision management attempts yield exactly one success; append before revocation may commit, append after revocation cannot. Cancellation/acceptance use same revision so one wins. Reads and history survive restart. Raw body max64KiB, metadata16KiB UTF-8, allowlist100 unique canonical base64url fingerprints, entries100000, read limit1–100 default50. Existing ID rules except . and .. disallowed. Unknown body/query fields rejected. Errors JSON:400 invalid input,401 signature,403 authority/key unavailable,404 missing log,409 replay/revision/state/full conflict,413 payload,429 existing quota/rate.
+
Non-goals and decisions
No UI, live subscription, timestamp/writer filters, log directory, deletion/editing, lost-key recovery, governance history, ownership expiry, registry redesign or page-auth change. Poll from cursor or drain from zero using same API. Original private prototype protocol is replaced before first release; no deployed log migration required. Legacy optional management-field normalization is tested. New owner implicit access allows removing its explicit allowlist entry during acceptance to make room for retained previous owner. No real keys or private data in artifacts.
npm test -- src/test/logSignature.test.ts src/test/logStorage.test.ts src/test/logManagement.test.ts src/test/logCapacity.test.ts; npm run typecheck. Final shared HTTP E2E mandatory for slice.
2 feat(logs): routes/client/docs
Signed PUT/DELETE and both POST transitions; strict schemas; CAP aliases; public polling and quotas; add/remove writers, cancellation/replacement, acceptance/retention; docs remove obsolete immutable rules.
npm test -- src/test/logs.test.ts; runnable signing client used by E2E; documentation search; npm run typecheck. Final shared HTTP E2E mandatory for slice.
3 test(logs): production HTTP/review/regression
Production create/two-writer/outsider/cursors/concurrency; management incl remove/cancel/accept; pending transfer survives restart; history unchanged; old owner denied; existing signed page publish/render; no regression current main.
npm test (full suite), npm run test:e2e (build + HTTP), npm run typecheck, git diff --check; independent implementation reviewer no blockers. CI workflow runs these commands.
4 docs(logs): commit and ship
Reviewed final docs/evidence, only intended changes committed, push branch and concrete PR, merge if target authorized, verify remote commit and production if deployed.
Staged diff, commit hash, GitHub PR/checks state, release probe. User asked for a commit so deliver one reviewed feature commit at completion rather than shipping incomplete boundary slices. Boundary test gates above share final E2E.
Definition of done/review
Every acceptance criterion passes; final full suite against current main is green; independent planner, plan reviewer and implementation reviewer use inherited model. Evaluate correctness, security, privacy, user coverage, simplicity, maintainability and API consistency. API taste means coherent existing identity conventions, snake_case responses and concise documented errors; visual originality not applicable. No commits while repairing. Bound repair attempts to three distinct diagnoses before true blocker escalation.
Research evidence
Local source: src/middleware/signedAgent.ts, src/utils/httpSignature.ts, src/utils/fingerprint.ts, src/storage/db.ts, src/index.ts, render.yaml, existing signing helpers and service integration. These are the source of truth for current ZenBin. LMDB transaction semantics and Node crypto inform existing implementation. No application dependency additions.
\ No newline at end of file
diff --git a/docs/public-logs-release-progress.html b/docs/public-logs-release-progress.html
new file mode 100644
index 0000000..913726c
--- /dev/null
+++ b/docs/public-logs-release-progress.html
@@ -0,0 +1 @@
+Public logs release progress
Created: · Last updated:
Public logs release progress
Status: blocked on required GitHub review; implementation complete. Scope: integrate full logs feature on current main, add allowlist management and two-step transfer, document/test/review and commit/push for shipping.
Original prototype preserved in original worktree. Release branch feat/public-logs at 96e6e1c. Planner management and registry integration advice adopted. Plan gate PASS by /root/plan_reviewer with no blockers. Current-main baseline: 447 tests passed across 26 files. Dependencies installed from existing lockfile; no dependency changes.
Step 1 data/identity: PASS. 38 focused tests in four files cover registered identity/CAP aliases, active-key checks, atomic management/revisions, revoked writers, cancellation/retention/races, expiry boundaries and lazy legacy fields. Typecheck passed; shared final E2E gate pending. Step 2 routes/docs: PASS. 55 API tests cover all methods, strict revision/body validation, public cursor polling, quotas/CORS, replay, owner-only ACL edits, recipient acceptance and cancellation. Client uses registered CAP signing. Updated current canonical agent docs, README, changelog and detailed guide. Step 3 final validation/review: PASS. Production HTTP E2E: eight scenarios plus parent passed, including real registration, existing signed pages, all management flows, pending state/replay across restart and retained former owner. Shared identity/data/API E2E gates satisfied. Independent source review PASS after preserving stricter global transport cap with Math.min. Added chunked80KiB and configured1KiB production rejection tests. Final post-repair npm test passed all 540 tests in31 files, including the447-test current-main baseline. npm run test:e2e built final source and passed8 HTTP scenarios+parent. npm run typecheck and git diff --check passed. No source blockers remain. Step 4 commit/shipping: feature committed as7350bcadc505b31df8cf5d33726f20186144c5b4 and pushed to origin/feat/public-logs. PR53 is open; GitHub CI passed the implementation commit. Merge is BLOCKED with REVIEW_REQUIRED: main requires one approving PR review. No merge, admin bypass or deployment performed. Shipping-target question also remains unanswered.
Implementation review
/root/implementation_reviewer passed registry/signing, atomic authorization/revisions/nonces, expiry equality, owner transfer/retention, docs/client, privacy, simplicity and maintainability. API taste4/5; visual originality not applicable. Found one body-cap override, repaired and independently re-reviewed with no source blockers. Final regression and shipping evidence follow.
PR53 · Successful implementation CI. Required external next action: approve the PR through GitHub review and confirm merge target. Original worktree prototype remains preserved; release worktree contains the current-main feature. No production data was modified.
\ No newline at end of file
diff --git a/docs/public-logs.md b/docs/public-logs.md
new file mode 100644
index 0000000..af1c8d1
--- /dev/null
+++ b/docs/public-logs.md
@@ -0,0 +1,207 @@
+# Public activity logs
+
+Agents can create public append-only logs, authorize other writers, and transfer ownership.
+Every entry includes a server-assigned UTC `timestamp`, authenticated `agent_fingerprint`,
+monotonic `sequence`, and the exact submitted `metadata` JSON string.
+
+All metadata, fingerprints, allowlists and pending transfers are public. Consumers should
+render metadata as text, never as trusted HTML. Fingerprints identify signing keys, not
+real-world organizations. This API is not a tamper-evident ledger.
+
+## Create, append and consume
+
+| Endpoint | JSON body | Success |
+| --- | --- | --- |
+| `POST /v1/logs/{id}` | `{"allowed_writers":[""]}` or `{}` | 201 log description |
+| `POST /v1/logs/{id}/entries` | `{"metadata":"{\"event\":\"started\"}"}` | 201 entry |
+| `GET /v1/logs/{id}` | None | 200 log description |
+| `GET /v1/logs/{id}/entries?after=0&limit=50` | None | 200 cursor page |
+
+A log description includes `id`, `owner_fingerprint`, `allowed_writers`, `created_at`,
+`entry_count`, `revision` and `pending_transfer`, which is null unless a handoff is pending.
+The owner always has write permission, even when absent from the allowlist.
+
+```json
+{
+ "entries": [{
+ "sequence": 1,
+ "timestamp": "2026-09-05T12:00:00.000Z",
+ "agent_fingerprint": "<43-character base64url fingerprint>",
+ "metadata": "{\"event\":\"started\"}"
+ }],
+ "next_after": 1,
+ "has_more": false
+}
+```
+
+Start with `after=0` to read the full history. Fetch successive pages using `next_after`
+until `has_more` is false. Then poll periodically with that saved cursor to follow new
+entries. Entries arrive in ascending sequence order, exclusively after the cursor.
+An empty page preserves the supplied cursor. `has_more: false` means caught up at the
+moment of that read, not that the log is closed.
+
+`after` is a nonnegative safe integer; `limit` is 1–100, default 50. This is a sequence
+cursor, not a positional offset. There is no timestamp/writer filter, reverse order,
+log directory, live subscription, or entry editing/deletion endpoint.
+
+Reads require no authentication, support CORS, use `Cache-Control: no-store`, and bypass
+monthly publication quotas. General request rate limits still apply. Signed writes
+retain the existing publication quota; billing API keys do not replace agent signatures.
+
+## Manage the allowlist
+
+Read the current description, then send an owner-signed request:
+
+```http
+PUT /v1/logs/{id}/writers
+Content-Type: application/json
+
+{"allowed_writers":[""],"expected_revision":0}
+```
+
+This replaces the complete list. Use `[]` to remove every additional writer.
+A successful update returns 200 with the updated description and incremented `revision`.
+Appends do not change the management revision. A stale revision returns 409: read the
+current log and reconsider your change before signing a new request.
+
+Only the current owner can edit the list. Removed agents cannot append after revocation
+commits. An append that commits before revocation remains valid. Existing entries and
+recorded identities never change.
+
+## Transfer ownership
+
+The current owner nominates a different fingerprint:
+
+```http
+POST /v1/logs/{id}/transfer
+Content-Type: application/json
+
+{"new_owner_fingerprint":"","expected_revision":1,"retain_previous_owner":false}
+```
+
+The response has a new revision and a `pending_transfer` containing
+`new_owner_fingerprint`, `initiated_at` and `retain_previous_owner`.
+The nominee gets no new rights until accepting. The nomination stays pending until
+accepted, replaced by another owner nomination, or cancelled. There is no automatic expiry.
+The fingerprint may be nominated before registration, but acceptance requires its active
+registered key. Nominating yourself is rejected.
+
+The nominated agent reads the current log and signs acceptance:
+
+```http
+POST /v1/logs/{id}/transfer/accept
+Content-Type: application/json
+
+{"expected_revision":2}
+```
+
+Acceptance returns 200, changes the owner, clears the pending transfer and increments
+revision. Only the named recipient may accept. An outdated revision cannot accept an
+obsolete or cancelled nomination. The former owner loses management and append access,
+including any explicit allowlist membership, unless `retain_previous_owner: true` was
+requested in the nomination. Retention gives writer access only. The new owner has
+implicit write access, so its redundant allowlist entry is removed during acceptance.
+Other writers remain unchanged. If retention would exceed 100 additional writers,
+acceptance returns 409 without changing state; the current owner can adjust the list first.
+
+The current owner can cancel with a signed JSON body:
+
+```http
+DELETE /v1/logs/{id}/transfer
+Content-Type: application/json
+
+{"expected_revision":2}
+```
+
+Cancellation returns 200 with updated revision and null pending state. Competing edits,
+acceptance and cancellation using the same revision have at most one successful mutation.
+This supports planned handoffs and key rotation, not recovery after losing the owner's key.
+
+## Sign requests with existing ZenBin identities
+
+Any active registered Ed25519 key can create a log. Use the existing
+`POST /v1/keys/register` registration flow and CAP signing protocol. No new scope is required.
+Fingerprints use ZenBin's existing convention: SHA-256 of the 32 decoded public JWK `x`
+bytes, encoded as **43-character unpadded base64url**, not hex or a hash of serialized JWK.
+
+```http
+Content-Type: application/json
+CAP-Version: 0.1
+CAP-Key-Id:
+CAP-Timestamp: 2026-09-05T12:00:00.000Z
+CAP-Nonce:
+CAP-Digest: sha-256=::
+CAP-Signature: ::
+```
+
+Legacy `X-Zenbin-Key-Id`, `X-Zenbin-Timestamp`, `X-Zenbin-Nonce`, `Content-Digest`
+and `X-Zenbin-Signature` headers are also accepted. CAP headers take precedence.
+There is no public-key header or separate log key registry.
+
+Sign these UTF-8 lines with no trailing newline:
+
+```text
+
+
+
+
+
+```
+
+Write URLs cannot have query parameters. Serialize the JSON once and send the exact
+bytes you signed. Use the existing timestamp window, five minutes by default, and a
+fresh nonce of 16–128 ASCII letters, digits, underscores or hyphens. Signatures are
+64-byte Ed25519 values encoded as canonical unpadded base64url with surrounding colons.
+Blocked and revoked registered keys cannot write or manage logs.
+
+Successful log mutations consume their nonce atomically with the data. Nonce uniqueness
+is by fingerprint across all log write endpoints, including alternate registrations of
+the same public key. Replays are rejected across restart. Expired replay records are
+pruned in bounded batches only after their signed timestamp can no longer be accepted;
+stale signatures remain invalid. Unlike existing page middleware, rejected log mutations
+do not consume the log nonce. Always use a fresh nonce for a new intended operation.
+If a response is lost, inspect public state before deciding whether a new mutation is needed.
+
+Registry status is checked at verification and immediately before mutation. The registry
+and log stores are separate environments; this is not an atomic cross-store revocation protocol.
+
+The dependency-free [client](../examples/logClient.mjs) exports `signLogRequest` and
+`fingerprintOf` and can use your existing registered key. Run its disposable-key demo
+against a local server:
+
+```bash
+node examples/logClient.mjs http://localhost:3000
+```
+
+The demo registers two keys, creates a log, adds a writer, appends an entry and transfers
+ownership. It does not persist the demo private keys. Never transmit private key material.
+
+## Limits, errors and operations
+
+Limits: 64 KiB raw request, or the configured global transport cap if lower; 16 KiB UTF-8 metadata string, 100 unique allowed fingerprints,
+100,000 entries per log, and 100 results per page. Metadata can contain any valid JSON
+value, including objects, arrays, null or scalars. Unknown input fields are rejected.
+IDs follow existing configured ID limits, default 128 letters/digits/dots/underscores/hyphens;
+`.` and `..` alone are invalid.
+
+Errors use `{"error":"message"}`: 400 invalid input, 401 invalid signature, 403 denied
+writer/owner or unavailable key, 404 missing log, 409 duplicate/replay/revision/transfer-state
+or capacity conflict, 413 oversized data, 429 existing quota/rate limits.
+
+Logs use named databases in `LMDB_PATH-logs`, separate from page storage. Include this
+file/environment in backups. Log state, entry sequence/count and replay records commit
+together. Data persists across restart. Legacy records missing management fields read as
+revision zero and null pending transfer. The unshipped prototype's custom signing format
+is not a supported production protocol.
+
+Validation commands:
+
+```bash
+npm test
+npm run typecheck
+npm run test:e2e
+```
+
+The E2E command builds and launches the real server with temporary registry, log, page
+and video storage, then verifies polling, multi-agent access, management, restart and
+existing signed page publishing. It requires local socket and native LMDB access.
diff --git a/examples/logClient.mjs b/examples/logClient.mjs
new file mode 100644
index 0000000..3b4ec1f
--- /dev/null
+++ b/examples/logClient.mjs
@@ -0,0 +1,56 @@
+import { createHash, generateKeyPairSync, randomUUID, sign } from 'node:crypto';
+import { pathToFileURL } from 'node:url';
+
+export function fingerprintOf(publicKey) {
+ return createHash('sha256').update(Buffer.from(publicKey.export({ format: 'jwk' }).x, 'base64url')).digest('base64url');
+}
+
+/** Use an existing registered keyId and private KeyObject; never transmit private key material. */
+export function signLogRequest(keyId, privateKey, path, data, options = {}) {
+ const body = JSON.stringify(data);
+ const method = options.method ?? 'POST';
+ const timestamp = options.timestamp ?? new Date().toISOString();
+ const nonce = options.nonce ?? randomUUID();
+ const digest = `sha-256=:${createHash('sha256').update(body).digest('base64')}:`;
+ const message = [method, path, timestamp, nonce, digest].join('\n');
+ return {
+ method, body,
+ headers: {
+ 'Content-Type': 'application/json', 'CAP-Version': '0.1', 'CAP-Key-Id': keyId,
+ 'CAP-Timestamp': timestamp, 'CAP-Nonce': nonce, 'CAP-Digest': digest,
+ 'CAP-Signature': `:${sign(null, Buffer.from(message), privateKey).toString('base64url')}:`,
+ },
+ };
+}
+
+export async function registerDemoAgent(baseUrl) {
+ const keys = generateKeyPairSync('ed25519');
+ const keyId = `log-demo-${randomUUID()}`;
+ const response = await fetch(new URL('/v1/keys/register', baseUrl), {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ keyId, publicJwk: keys.publicKey.export({ format: 'jwk' }) }),
+ });
+ if (!response.ok) throw new Error(`Registration failed (${response.status})`);
+ return { ...keys, keyId, fingerprint: fingerprintOf(keys.publicKey) };
+}
+
+// This demo registers disposable keys in the selected server. Use lasting keys for real logs.
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ const baseUrl = process.argv[2] ?? 'http://localhost:3000';
+ const owner = await registerDemoAgent(baseUrl);
+ const writer = await registerDemoAgent(baseUrl);
+ const path = `/v1/logs/demo-${randomUUID()}`;
+ async function mutate(agent, target, data, method = 'POST') {
+ const response = await fetch(new URL(target, baseUrl), signLogRequest(agent.keyId, agent.privateKey, target, data, { method }));
+ if (!response.ok) throw new Error(`Log request failed (${response.status}): ${await response.text()}`);
+ return response.json();
+ }
+ let log = await mutate(owner, path, {});
+ log = await mutate(owner, `${path}/writers`, { allowed_writers: [writer.fingerprint], expected_revision: log.revision }, 'PUT');
+ await mutate(writer, `${path}/entries`, { metadata: JSON.stringify({ event: 'started' }) });
+ log = await mutate(owner, `${path}/transfer`, { new_owner_fingerprint: writer.fingerprint, expected_revision: log.revision });
+ await mutate(writer, `${path}/transfer/accept`, { expected_revision: log.revision });
+ const response = await fetch(new URL(`${path}/entries?after=0&limit=50`, baseUrl));
+ if (!response.ok) throw new Error(`Read failed (${response.status})`);
+ console.log(JSON.stringify({ url: new URL(path, baseUrl).href, ...await response.json() }, null, 2));
+}
diff --git a/package.json b/package.json
index 52562ae..725ef30 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,8 @@
"start": "node -r dotenv/config dist/index.js",
"test": "npx vitest run",
"test:watch": "npx vitest",
- "typecheck": "tsc --noEmit"
+ "typecheck": "tsc --noEmit",
+ "test:e2e": "npm run build && node --test scripts/logs.e2e.mjs"
},
"keywords": [
"html",
diff --git a/scripts/logs.e2e.mjs b/scripts/logs.e2e.mjs
new file mode 100644
index 0000000..2837a30
--- /dev/null
+++ b/scripts/logs.e2e.mjs
@@ -0,0 +1,215 @@
+import assert from 'node:assert/strict';
+import { spawn } from 'node:child_process';
+import { generateKeyPairSync, randomUUID } from 'node:crypto';
+import { once } from 'node:events';
+import { mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { test } from 'node:test';
+import { fingerprintOf, signLogRequest } from '../examples/logClient.mjs';
+
+async function startServer(directory, overrides = {}) {
+ const child = spawn(process.execPath, ['dist/index.js'], {
+ env: {
+ ...process.env,
+ PORT: '0', HOST: '127.0.0.1', BASE_URL: 'http://localhost',
+ LMDB_PATH: join(directory, 'pages.lmdb'), VIDEO_STORAGE_PATH: join(directory, 'videos'),
+ POSTHOG_KEY: '', RATE_LIMIT_MAX_REQUESTS: '500', FREE_TIER_MONTHLY_LIMIT: '4',
+ RATE_LIMIT_WINDOW_MS: '60000', SUBDOMAINS_ENABLED: 'true',
+ ...overrides,
+ },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ // Consume output without writing request information or environment values to test reports.
+ child.stderr.resume();
+ try {
+ const baseUrl = await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error('Server startup timed out')), 15000);
+ const fail = (error) => { clearTimeout(timer); reject(error); };
+ child.once('error', fail);
+ child.once('exit', (code) => fail(new Error(`Server exited before readiness (${code})`)));
+ let output = '';
+ child.stdout.on('data', (chunk) => {
+ output = (output + chunk.toString()).slice(-8192);
+ const match = output.match(/Server running at http:\/\/127\.0\.0\.1:(\d+)/);
+ if (match) {
+ clearTimeout(timer);
+ resolve(`http://127.0.0.1:${match[1]}`);
+ }
+ });
+ });
+ return { child, baseUrl };
+ } catch (error) {
+ await stopServer(child);
+ throw error;
+ }
+}
+
+async function stopServer(child) {
+ if (child.exitCode !== null || child.signalCode !== null) return;
+ const exited = once(child, 'exit');
+ child.kill('SIGTERM');
+ const timer = setTimeout(() => child.kill('SIGKILL'), 5000);
+ try { await exited; } finally { clearTimeout(timer); }
+}
+
+test('production HTTP public logs and existing pages survive restart', { timeout: 60000 }, async (t) => {
+ const directory = await mkdtemp(join(tmpdir(), 'zenbin-logs-e2e-'));
+ let server;
+ const agent = () => ({ ...generateKeyPairSync('ed25519'), keyId: `e2e-key-${randomUUID()}` });
+ const owner = agent();
+ const writer = agent();
+ const outsider = agent();
+ const agents = [owner, writer, outsider];
+ const path = `/v1/logs/e2e-${randomUUID()}`;
+ const pageId = `e2e-page-${randomUUID()}`;
+ let replay;
+ const metadata = ' {"event":"ready","text":"雪 "} ';
+ const request = (target, options = {}) => fetch(server.baseUrl + target, { ...options, signal: AbortSignal.timeout(10000) });
+ const write = (key, target, data, method = 'POST') => {
+ const keyId = agents.find((agent) => agent.privateKey === key).keyId;
+ const options = signLogRequest(keyId, key, target, data, { method });
+ // Distinct callers avoid conflating API authorization tests with per-client free quotas.
+ options.headers['User-Agent'] = randomUUID();
+ return request(target, options);
+ };
+ try {
+ server = await startServer(directory);
+ for (const agent of agents) {
+ const registration = await request('/v1/keys/register', { method: 'POST', headers: { 'Content-Type': 'application/json', 'User-Agent': randomUUID() }, body: JSON.stringify({ keyId: agent.keyId, publicJwk: agent.publicKey.export({ format: 'jwk' }) }) });
+ assert.equal(registration.status, 201);
+ assert.equal((await registration.json()).publicKeyFingerprint, fingerprintOf(agent.publicKey));
+ }
+
+ await t.test('create, owner/writer append, outsider denial and immutable identity', async () => {
+ const created = await write(owner.privateKey, path, { allowed_writers: [fingerprintOf(writer.publicKey)] });
+ assert.equal(created.status, 201);
+ assert.deepEqual(await created.json(), {
+ id: path.split('/').at(-1), owner_fingerprint: fingerprintOf(owner.publicKey),
+ allowed_writers: [fingerprintOf(writer.publicKey)], created_at: (await (await request(path)).json()).created_at, entry_count: 0, revision: 0, pending_transfer: null,
+ });
+ assert.equal((await write(outsider.privateKey, path, {})).status, 409);
+ assert.equal((await write(owner.privateKey, `${path}/entries`, { metadata })).status, 201);
+ replay = signLogRequest(writer.keyId, writer.privateKey, `${path}/entries`, { metadata: '{"event":"done"}' });
+ const written = await request(`${path}/entries`, replay);
+ assert.equal(written.status, 201);
+ const entry = await written.json();
+ assert.equal(entry.agent_fingerprint, fingerprintOf(writer.publicKey));
+ assert.equal(entry.sequence, 2);
+ assert.ok(Math.abs(Date.now() - Date.parse(entry.timestamp)) < 5000);
+ assert.equal((await write(outsider.privateKey, `${path}/entries`, { metadata: '{}' })).status, 403);
+ assert.equal((await request(`${path}/entries`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'User-Agent': randomUUID() }, body: '{"metadata":"{}"}' })).status, 401);
+ });
+
+ await t.test('public cursor pagination, CORS and exhausted publication quota', async () => {
+ const headers = { 'User-Agent': 'e2e-exhausted', Origin: 'https://visualizer.example' };
+ for (let i = 0; i < 4; i++) assert.equal((await request('/v1/stats', { headers })).status, 200);
+ assert.equal((await request('/v1/stats', { headers })).status, 429);
+ const response = await request(`${path}/entries?limit=1`, { headers });
+ assert.equal(response.status, 200);
+ assert.equal(response.headers.get('access-control-allow-origin'), '*');
+ assert.match(response.headers.get('content-type'), /application\/json/);
+ const page = await response.json();
+ assert.equal(page.next_after, 1);
+ assert.equal(page.has_more, true);
+ assert.equal(page.entries[0].metadata, metadata);
+ const next = await (await request(`${path}/entries?after=${page.next_after}`, { headers })).json();
+ assert.equal(next.next_after, 2);
+ assert.equal(next.has_more, false);
+ assert.equal(next.entries.length, 1);
+ assert.deepEqual(await (await request(`${path}/entries?after=2`, { headers })).json(), { entries: [], next_after: 2, has_more: false });
+ assert.equal((await request(path, { method: 'HEAD', headers })).status, 200);
+ const deniedWrite = signLogRequest(owner.keyId, owner.privateKey, `${path}/entries`, { metadata: '{}' });
+ deniedWrite.headers['User-Agent'] = headers['User-Agent'];
+ assert.equal((await request(`${path}/entries`, deniedWrite)).status, 429);
+ const preflight = await request(`${path}/entries`, { method: 'OPTIONS', headers: { ...headers, 'Access-Control-Request-Method': 'POST', 'Access-Control-Request-Headers': 'content-digest,x-zenbin-signature' } });
+ assert.equal(preflight.status, 204);
+ assert.match(preflight.headers.get('access-control-allow-headers'), /x-zenbin-signature/);
+ });
+
+ await t.test('concurrent HTTP writes have contiguous unique sequences', async () => {
+ const responses = await Promise.all(Array.from({ length: 12 }, (_, i) => write(i % 2 ? owner.privateKey : writer.privateKey, `${path}/entries`, { metadata: JSON.stringify({ index: i }) })));
+ for (const response of responses) assert.equal(response.status, 201);
+ const page = await (await request(`${path}/entries`)).json();
+ assert.deepEqual(page.entries.map((entry) => entry.sequence), Array.from({ length: 14 }, (_, i) => i + 1));
+ assert.equal((await (await request(path)).json()).entry_count, 14);
+ });
+
+ await t.test('existing page publishing/rendering and agent discovery work', async () => {
+ const response = await write(owner.privateKey, `/v1/pages/${pageId}`, { html: '
Existing page regression check
' });
+ assert.equal(response.status, 201);
+ assert.match(await (await request(`/p/${pageId}/raw`)).text(), /Existing page regression check/);
+ for (const target of ['/api/agent', '/.well-known/skill.md']) {
+ const result = await request(target);
+ assert.equal(result.status, 200);
+ const text = await result.text();
+ assert.match(text, /expected_revision/);
+ assert.match(text, /allowed_writers/);
+ assert.match(text, /metadata.*string/);
+ }
+ });
+
+ await t.test('owner edits writers, cancels nominations and leaves a pending handoff', async () => {
+ assert.equal((await write(owner.privateKey, `${path}/writers`, { allowed_writers: [], expected_revision: 0 }, 'PUT')).status, 200);
+ assert.equal((await write(writer.privateKey, `${path}/entries`, { metadata: '{}' })).status, 403);
+ assert.equal((await write(owner.privateKey, `${path}/writers`, { allowed_writers: [fingerprintOf(writer.publicKey)], expected_revision: 0 }, 'PUT')).status, 409);
+ assert.equal((await write(owner.privateKey, `${path}/transfer`, { new_owner_fingerprint: fingerprintOf(writer.publicKey), expected_revision: 1 })).status, 200);
+ assert.equal((await write(owner.privateKey, `${path}/transfer`, { expected_revision: 2 }, 'DELETE')).status, 200);
+ assert.equal((await write(writer.privateKey, `${path}/transfer/accept`, { expected_revision: 2 })).status, 409);
+ assert.equal((await write(owner.privateKey, `${path}/transfer`, { new_owner_fingerprint: fingerprintOf(writer.publicKey), expected_revision: 3 })).status, 200);
+ assert.equal((await write(writer.privateKey, `${path}/entries`, { metadata: '{}' })).status, 403);
+ assert.equal((await (await request(path)).json()).revision, 4);
+ });
+
+ await t.test('restart preserves writers, records and replay protection', async () => {
+ await stopServer(server.child);
+ server = await startServer(directory);
+ const log = await (await request(path)).json();
+ assert.equal(log.entry_count, 14);
+ assert.equal(log.owner_fingerprint, fingerprintOf(owner.publicKey));
+ assert.deepEqual(log.allowed_writers, []);
+ assert.equal(log.revision, 4);
+ assert.equal(log.pending_transfer.new_owner_fingerprint, fingerprintOf(writer.publicKey));
+ assert.equal((await (await request(`${path}/entries?limit=1`)).json()).entries[0].metadata, metadata);
+ assert.equal((await request(`${path}/entries`, replay)).status, 409);
+ assert.equal((await write(outsider.privateKey, `${path}/entries`, { metadata: '{}' })).status, 403);
+ assert.equal((await write(outsider.privateKey, `${path}/transfer/accept`, { expected_revision: 4 })).status, 403);
+ const acceptance = await write(writer.privateKey, `${path}/transfer/accept`, { expected_revision: 4 });
+ assert.equal(acceptance.status, 200);
+ assert.equal((await acceptance.json()).owner_fingerprint, fingerprintOf(writer.publicKey));
+ assert.equal((await write(owner.privateKey, `${path}/entries`, { metadata: '{}' })).status, 403);
+ assert.equal((await write(owner.privateKey, `${path}/writers`, { allowed_writers: [], expected_revision: 5 }, 'PUT')).status, 403);
+ const added = await write(writer.privateKey, `${path}/entries`, { metadata: 'null' });
+ assert.equal(added.status, 201);
+ assert.equal((await added.json()).sequence, 15);
+ assert.match(await (await request(`/p/${pageId}/raw`)).text(), /Existing page regression check/);
+ });
+ await t.test('explicit retention grants former owner writer access only', async () => {
+ const retained = `${path}-retained`;
+ assert.equal((await write(owner.privateKey, retained, {})).status, 201);
+ assert.equal((await write(owner.privateKey, `${retained}/transfer`, { new_owner_fingerprint: fingerprintOf(writer.publicKey), expected_revision: 0, retain_previous_owner: true })).status, 200);
+ assert.equal((await write(writer.privateKey, `${retained}/transfer/accept`, { expected_revision: 1 })).status, 200);
+ assert.equal((await write(owner.privateKey, `${retained}/entries`, { metadata: '{}' })).status, 201);
+ assert.equal((await write(owner.privateKey, `${retained}/writers`, { allowed_writers: [], expected_revision: 2 }, 'PUT')).status, 403);
+ });
+ await t.test('transport limits reject chunked oversize and honor a stricter global cap', async () => {
+ const body = new ReadableStream({
+ start(controller) {
+ controller.enqueue(new Uint8Array(40 * 1024).fill(32));
+ controller.enqueue(new Uint8Array(40 * 1024).fill(32));
+ controller.close();
+ },
+ });
+ const chunked = await request(`${path}/entries`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'User-Agent': randomUUID() }, body, duplex: 'half' });
+ assert.equal(chunked.status, 413);
+ await stopServer(server.child);
+ server = await startServer(directory, { MAX_REQUEST_BODY_BYTES: '1024' });
+ const oversized = await write(writer.privateKey, `${path}/entries`, { metadata: JSON.stringify({ text: 'x'.repeat(1100) }) });
+ assert.equal(oversized.status, 413);
+ assert.equal((await (await request(path)).json()).entry_count, 15);
+ });
+ } finally {
+ if (server) await stopServer(server.child);
+ await rm(directory, { recursive: true, force: true });
+ }
+});
diff --git a/src/docs/agentInstructions.ts b/src/docs/agentInstructions.ts
index e563529..71e0965 100644
--- a/src/docs/agentInstructions.ts
+++ b/src/docs/agentInstructions.ts
@@ -1,3 +1,4 @@
+import { logInstructions } from './logInstructions.js';
import { config } from '../config.js';
/**
@@ -1214,5 +1215,6 @@ Agents should switch on \`error_code\` for programmatic handling. The \`error\`
- Website: ${baseUrl}
- Skill file: ${baseUrl}/.well-known/skill.md
- Agent docs endpoint: ${baseUrl}/api/agent
+${logInstructions}
`;
}
diff --git a/src/docs/logInstructions.ts b/src/docs/logInstructions.ts
new file mode 100644
index 0000000..ea493d6
--- /dev/null
+++ b/src/docs/logInstructions.ts
@@ -0,0 +1,208 @@
+export const logInstructions = `\n## Public activity logs
+
+Agents can create public append-only logs, authorize other writers, and transfer ownership.
+Every entry includes a server-assigned UTC \`timestamp\`, authenticated \`agent_fingerprint\`,
+monotonic \`sequence\`, and the exact submitted \`metadata\` JSON string.
+
+All metadata, fingerprints, allowlists and pending transfers are public. Consumers should
+render metadata as text, never as trusted HTML. Fingerprints identify signing keys, not
+real-world organizations. This API is not a tamper-evident ledger.
+
+## Create, append and consume
+
+| Endpoint | JSON body | Success |
+| --- | --- | --- |
+| \`POST /v1/logs/{id}\` | \`{"allowed_writers":[""]}\` or \`{}\` | 201 log description |
+| \`POST /v1/logs/{id}/entries\` | \`{"metadata":"{\"event\":\"started\"}"}\` | 201 entry |
+| \`GET /v1/logs/{id}\` | None | 200 log description |
+| \`GET /v1/logs/{id}/entries?after=0&limit=50\` | None | 200 cursor page |
+
+A log description includes \`id\`, \`owner_fingerprint\`, \`allowed_writers\`, \`created_at\`,
+\`entry_count\`, \`revision\` and \`pending_transfer\`, which is null unless a handoff is pending.
+The owner always has write permission, even when absent from the allowlist.
+
+\`\`\`json
+{
+ "entries": [{
+ "sequence": 1,
+ "timestamp": "2026-09-05T12:00:00.000Z",
+ "agent_fingerprint": "<43-character base64url fingerprint>",
+ "metadata": "{\"event\":\"started\"}"
+ }],
+ "next_after": 1,
+ "has_more": false
+}
+\`\`\`
+
+Start with \`after=0\` to read the full history. Fetch successive pages using \`next_after\`
+until \`has_more\` is false. Then poll periodically with that saved cursor to follow new
+entries. Entries arrive in ascending sequence order, exclusively after the cursor.
+An empty page preserves the supplied cursor. \`has_more: false\` means caught up at the
+moment of that read, not that the log is closed.
+
+\`after\` is a nonnegative safe integer; \`limit\` is 1–100, default 50. This is a sequence
+cursor, not a positional offset. There is no timestamp/writer filter, reverse order,
+log directory, live subscription, or entry editing/deletion endpoint.
+
+Reads require no authentication, support CORS, use \`Cache-Control: no-store\`, and bypass
+monthly publication quotas. General request rate limits still apply. Signed writes
+retain the existing publication quota; billing API keys do not replace agent signatures.
+
+## Manage the allowlist
+
+Read the current description, then send an owner-signed request:
+
+\`\`\`http
+PUT /v1/logs/{id}/writers
+Content-Type: application/json
+
+{"allowed_writers":[""],"expected_revision":0}
+\`\`\`
+
+This replaces the complete list. Use \`[]\` to remove every additional writer.
+A successful update returns 200 with the updated description and incremented \`revision\`.
+Appends do not change the management revision. A stale revision returns 409: read the
+current log and reconsider your change before signing a new request.
+
+Only the current owner can edit the list. Removed agents cannot append after revocation
+commits. An append that commits before revocation remains valid. Existing entries and
+recorded identities never change.
+
+## Transfer ownership
+
+The current owner nominates a different fingerprint:
+
+\`\`\`http
+POST /v1/logs/{id}/transfer
+Content-Type: application/json
+
+{"new_owner_fingerprint":"","expected_revision":1,"retain_previous_owner":false}
+\`\`\`
+
+The response has a new revision and a \`pending_transfer\` containing
+\`new_owner_fingerprint\`, \`initiated_at\` and \`retain_previous_owner\`.
+The nominee gets no new rights until accepting. The nomination stays pending until
+accepted, replaced by another owner nomination, or cancelled. There is no automatic expiry.
+The fingerprint may be nominated before registration, but acceptance requires its active
+registered key. Nominating yourself is rejected.
+
+The nominated agent reads the current log and signs acceptance:
+
+\`\`\`http
+POST /v1/logs/{id}/transfer/accept
+Content-Type: application/json
+
+{"expected_revision":2}
+\`\`\`
+
+Acceptance returns 200, changes the owner, clears the pending transfer and increments
+revision. Only the named recipient may accept. An outdated revision cannot accept an
+obsolete or cancelled nomination. The former owner loses management and append access,
+including any explicit allowlist membership, unless \`retain_previous_owner: true\` was
+requested in the nomination. Retention gives writer access only. The new owner has
+implicit write access, so its redundant allowlist entry is removed during acceptance.
+Other writers remain unchanged. If retention would exceed 100 additional writers,
+acceptance returns 409 without changing state; the current owner can adjust the list first.
+
+The current owner can cancel with a signed JSON body:
+
+\`\`\`http
+DELETE /v1/logs/{id}/transfer
+Content-Type: application/json
+
+{"expected_revision":2}
+\`\`\`
+
+Cancellation returns 200 with updated revision and null pending state. Competing edits,
+acceptance and cancellation using the same revision have at most one successful mutation.
+This supports planned handoffs and key rotation, not recovery after losing the owner's key.
+
+## Sign requests with existing ZenBin identities
+
+Any active registered Ed25519 key can create a log. Use the existing
+\`POST /v1/keys/register\` registration flow and CAP signing protocol. No new scope is required.
+Fingerprints use ZenBin's existing convention: SHA-256 of the 32 decoded public JWK \`x\`
+bytes, encoded as **43-character unpadded base64url**, not hex or a hash of serialized JWK.
+
+\`\`\`http
+Content-Type: application/json
+CAP-Version: 0.1
+CAP-Key-Id:
+CAP-Timestamp: 2026-09-05T12:00:00.000Z
+CAP-Nonce:
+CAP-Digest: sha-256=::
+CAP-Signature: ::
+\`\`\`
+
+Legacy \`X-Zenbin-Key-Id\`, \`X-Zenbin-Timestamp\`, \`X-Zenbin-Nonce\`, \`Content-Digest\`
+and \`X-Zenbin-Signature\` headers are also accepted. CAP headers take precedence.
+There is no public-key header or separate log key registry.
+
+Sign these UTF-8 lines with no trailing newline:
+
+\`\`\`text
+
+
+
+
+
+\`\`\`
+
+Write URLs cannot have query parameters. Serialize the JSON once and send the exact
+bytes you signed. Use the existing timestamp window, five minutes by default, and a
+fresh nonce of 16–128 ASCII letters, digits, underscores or hyphens. Signatures are
+64-byte Ed25519 values encoded as canonical unpadded base64url with surrounding colons.
+Blocked and revoked registered keys cannot write or manage logs.
+
+Successful log mutations consume their nonce atomically with the data. Nonce uniqueness
+is by fingerprint across all log write endpoints, including alternate registrations of
+the same public key. Replays are rejected across restart. Expired replay records are
+pruned in bounded batches only after their signed timestamp can no longer be accepted;
+stale signatures remain invalid. Unlike existing page middleware, rejected log mutations
+do not consume the log nonce. Always use a fresh nonce for a new intended operation.
+If a response is lost, inspect public state before deciding whether a new mutation is needed.
+
+Registry status is checked at verification and immediately before mutation. The registry
+and log stores are separate environments; this is not an atomic cross-store revocation protocol.
+
+The dependency-free [client](https://github.com/twilson63/zenbin/blob/main/examples/logClient.mjs) exports \`signLogRequest\` and
+\`fingerprintOf\` and can use your existing registered key. Run its disposable-key demo
+against a local server:
+
+\`\`\`bash
+node examples/logClient.mjs http://localhost:3000
+\`\`\`
+
+The demo registers two keys, creates a log, adds a writer, appends an entry and transfers
+ownership. It does not persist the demo private keys. Never transmit private key material.
+
+## Limits, errors and operations
+
+Limits: 64 KiB raw request, or the configured global transport cap if lower; 16 KiB UTF-8 metadata string, 100 unique allowed fingerprints,
+100,000 entries per log, and 100 results per page. Metadata can contain any valid JSON
+value, including objects, arrays, null or scalars. Unknown input fields are rejected.
+IDs follow existing configured ID limits, default 128 letters/digits/dots/underscores/hyphens;
+\`.\` and \`..\` alone are invalid.
+
+Errors use \`{"error":"message"}\`: 400 invalid input, 401 invalid signature, 403 denied
+writer/owner or unavailable key, 404 missing log, 409 duplicate/replay/revision/transfer-state
+or capacity conflict, 413 oversized data, 429 existing quota/rate limits.
+
+Logs use named databases in \`LMDB_PATH-logs\`, separate from page storage. Include this
+file/environment in backups. Log state, entry sequence/count and replay records commit
+together. Data persists across restart. Legacy records missing management fields read as
+revision zero and null pending transfer. The unshipped prototype's custom signing format
+is not a supported production protocol.
+
+Validation commands:
+
+\`\`\`bash
+npm test
+npm run typecheck
+npm run test:e2e
+\`\`\`
+
+The E2E command builds and launches the real server with temporary registry, log, page
+and video storage, then verifies polling, multi-agent access, management, restart and
+existing signed page publishing. It requires local socket and native LMDB access.
+`;
diff --git a/src/index.ts b/src/index.ts
index fe7caea..b38f51c 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -5,6 +5,9 @@ import { logger } from 'hono/logger';
import { bodyLimit } from 'hono/body-limit';
import dotenv from 'dotenv';
import { config } from './config.js';
+import { initLogDatabase, closeLogDatabase } from './storage/logs.js';
+import { logs } from './routes/logs.js';
+import { logLimits } from './utils/logSignature.js';
import { initDatabase, closeDatabase, backfillOwnerIndex, backfillRecipientIndex, backfillKeyFingerprints, backfillAttestationIndexes, cleanupExpiredNonces, listPublicPageIds } from './storage/db.js';
import { initVideoStorage } from './storage/video.js';
import { createServices, type Services } from './services/container.js';
@@ -44,10 +47,10 @@ app.use('*', logger());
app.use('*', cors());
// Hard transport-level body cap: reject oversized payloads before any handler
// buffers the full body (e.g. signed-agent digest verification reads it whole).
-app.use('*', bodyLimit({
- maxSize: config.maxRequestBodyBytes,
+app.use('*', (c, next) => bodyLimit({
+ maxSize: c.req.path.startsWith('/v1/logs/') ? Math.min(config.maxRequestBodyBytes, logLimits.bodyBytes) : config.maxRequestBodyBytes,
onError: (c) => c.json({ error: 'Request body too large' }, 413),
-}));
+})(c, next));
app.use('*', rateLimit);
// Inject services into request context
@@ -179,6 +182,7 @@ ${urls.join('\n')}
app.route('/v1/pages', pages);
app.route('/v1/subdomains', subdomains);
app.route('/v1/stats', stats);
+app.route('/v1/logs', logs);
app.route('/v1/keys', keys);
app.route('/v1/verify', verify);
app.route('/v1/admin/keys', adminKeys);
@@ -261,6 +265,7 @@ async function main() {
try {
console.log('Initializing database...');
initDatabase();
+ initLogDatabase();
console.log(`Database initialized at ${config.lmdbPath}`);
// Backfill owner index for pages created before the listing feature
@@ -386,6 +391,7 @@ API Key Configuration:
const shutdown = async (signal: string) => {
console.log(`\nReceived ${signal}. Shutting down gracefully...`);
await closeAnalytics();
+ await closeLogDatabase();
await closeDatabase();
process.exit(0);
};
diff --git a/src/middleware/verifyApiKey.ts b/src/middleware/verifyApiKey.ts
index c8b9b6c..31fbdd8 100644
--- a/src/middleware/verifyApiKey.ts
+++ b/src/middleware/verifyApiKey.ts
@@ -32,6 +32,8 @@ declare module 'hono' {
}
export async function verifyApiKey(c: Context, next: Next) {
+ // Public polling does not consume monthly publication quota.
+ if ((c.req.method === 'GET' || c.req.method === 'HEAD') && /^\/v1\/logs\/[^/]+(?:\/entries)?$/.test(c.req.path)) return next();
const authHeader = c.req.header('Authorization');
const apiKey = c.req.header('X-API-Key');
diff --git a/src/routes/logs.ts b/src/routes/logs.ts
new file mode 100644
index 0000000..c60019b
--- /dev/null
+++ b/src/routes/logs.ts
@@ -0,0 +1,170 @@
+import { Hono } from 'hono';
+import { acceptLogTransfer, appendLogEntry, cancelLogTransfer, createLog, getLog, nominateLogOwner, readLogEntries, updateLogWriters } from '../storage/logs.js';
+import { logLimits, verifyLogSignature, type LogSigner } from '../utils/logSignature.js';
+import { isValidFingerprint } from '../utils/fingerprint.js';
+import { validateId } from '../utils/validation.js';
+
+const logs = new Hono<{ Variables: { logSigner: LogSigner; logBody: Record } }>();
+
+logs.use('*', async (c, next) => {
+ c.header('Cache-Control', 'no-store');
+ if (!['POST', 'PUT', 'DELETE'].includes(c.req.method)) return next();
+ if (new URL(c.req.url).search) return c.json({ error: 'Log writes do not accept query parameters' }, 400);
+ if (c.req.header('Content-Type')?.split(';')[0].trim().toLowerCase() !== 'application/json') {
+ return c.json({ error: 'Content-Type must be application/json' }, 400);
+ }
+
+ // Count actual bytes, even if Content-Length is absent or inaccurate.
+ const chunks: Uint8Array[] = [];
+ let size = 0;
+ const reader = c.req.raw.body?.getReader();
+ if (reader) {
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ size += value.byteLength;
+ if (size > logLimits.bodyBytes) {
+ await reader.cancel();
+ return c.json({ error: 'Log request body exceeds 64 KiB' }, 413);
+ }
+ chunks.push(value);
+ }
+ } finally {
+ reader.releaseLock();
+ }
+ }
+ const rawBody = Buffer.concat(chunks, size);
+ const signer = verifyLogSignature(c.req.method, new URL(c.req.url).pathname, c.req.raw.headers, rawBody);
+ if ('error' in signer) return c.json({ error: signer.error }, signer.status);
+ let body: unknown;
+ try {
+ body = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(rawBody));
+ } catch {
+ return c.json({ error: 'Invalid JSON body' }, 400);
+ }
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
+ return c.json({ error: 'Request body must be a JSON object' }, 400);
+ }
+ c.set('logSigner', signer);
+ c.set('logBody', body as Record);
+ return next();
+});
+
+function validLogId(id: string): boolean {
+ return id !== '.' && id !== '..' && validateId(id) === null;
+}
+
+function validFingerprint(value: unknown): value is string {
+ return typeof value === 'string' && isValidFingerprint(value) && Buffer.from(value, 'base64url').toString('base64url') === value;
+}
+function validWriters(value: unknown): value is string[] {
+ return Array.isArray(value) && value.length <= logLimits.writers && value.every(validFingerprint) && new Set(value).size === value.length;
+}
+function validRevision(value: unknown): value is number {
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
+}
+
+logs.put('/:id/writers', async (c) => {
+ const id = c.req.param('id');
+ const body = c.get('logBody');
+ if (!validLogId(id) || Object.keys(body).some((key) => !['allowed_writers', 'expected_revision'].includes(key)) || !validWriters(body.allowed_writers) || !validRevision(body.expected_revision)) {
+ return c.json({ error: 'Provide a valid log ID, allowed_writers and expected_revision' }, 400);
+ }
+ const result = await updateLogWriters(id, body.allowed_writers, body.expected_revision, c.get('logSigner'));
+ if ('error' in result) return c.json({ error: result.error }, result.status);
+ return c.json(result.value);
+});
+
+logs.post('/:id/transfer', async (c) => {
+ const id = c.req.param('id');
+ const body = c.get('logBody');
+ if (!validLogId(id) || Object.keys(body).some((key) => !['new_owner_fingerprint', 'expected_revision', 'retain_previous_owner'].includes(key)) ||
+ !validFingerprint(body.new_owner_fingerprint) || !validRevision(body.expected_revision) ||
+ (body.retain_previous_owner !== undefined && typeof body.retain_previous_owner !== 'boolean')) {
+ return c.json({ error: 'Provide a valid new_owner_fingerprint, expected_revision and optional boolean retain_previous_owner' }, 400);
+ }
+ if (body.new_owner_fingerprint === c.get('logSigner').fingerprint) return c.json({ error: 'New owner must be a different agent' }, 400);
+ const result = await nominateLogOwner(id, body.new_owner_fingerprint, body.retain_previous_owner === true, body.expected_revision, c.get('logSigner'));
+ if ('error' in result) return c.json({ error: result.error }, result.status);
+ return c.json(result.value);
+});
+
+logs.post('/:id/transfer/accept', async (c) => {
+ const id = c.req.param('id');
+ const body = c.get('logBody');
+ if (!validLogId(id) || Object.keys(body).some((key) => key !== 'expected_revision') || !validRevision(body.expected_revision)) {
+ return c.json({ error: 'Provide a valid log ID and expected_revision' }, 400);
+ }
+ const result = await acceptLogTransfer(id, body.expected_revision, c.get('logSigner'));
+ if ('error' in result) return c.json({ error: result.error }, result.status);
+ return c.json(result.value);
+});
+
+logs.delete('/:id/transfer', async (c) => {
+ const id = c.req.param('id');
+ const body = c.get('logBody');
+ if (!validLogId(id) || Object.keys(body).some((key) => key !== 'expected_revision') || !validRevision(body.expected_revision)) {
+ return c.json({ error: 'Provide a valid log ID and expected_revision' }, 400);
+ }
+ const result = await cancelLogTransfer(id, body.expected_revision, c.get('logSigner'));
+ if ('error' in result) return c.json({ error: result.error }, result.status);
+ return c.json(result.value);
+});
+
+logs.post('/:id', async (c) => {
+ const id = c.req.param('id');
+ if (!validLogId(id)) return c.json({ error: 'Invalid log ID' }, 400);
+ const body = c.get('logBody');
+ if (Object.keys(body).some((key) => key !== 'allowed_writers')) return c.json({ error: 'Unknown log field' }, 400);
+ const writers = body.allowed_writers === undefined ? [] : body.allowed_writers;
+ if (!validWriters(writers)) return c.json({ error: 'allowed_writers must contain at most 100 unique base64url SHA-256 fingerprints' }, 400);
+ const result = await createLog(id, writers, c.get('logSigner'));
+ if ('error' in result) return c.json({ error: result.error }, result.status);
+ return c.json(result.value, 201);
+});
+
+logs.post('/:id/entries', async (c) => {
+ const id = c.req.param('id');
+ if (!validLogId(id)) return c.json({ error: 'Invalid log ID' }, 400);
+ const body = c.get('logBody');
+ if (Object.keys(body).some((key) => key !== 'metadata')) return c.json({ error: 'Unknown entry field' }, 400);
+ if (typeof body.metadata !== 'string') return c.json({ error: 'metadata must be a JSON string' }, 400);
+ if (Buffer.byteLength(body.metadata, 'utf8') > logLimits.metadataBytes) return c.json({ error: 'metadata exceeds 16 KiB' }, 413);
+ try {
+ JSON.parse(body.metadata);
+ } catch {
+ return c.json({ error: 'metadata must contain valid JSON' }, 400);
+ }
+ const result = await appendLogEntry(id, body.metadata, c.get('logSigner'));
+ if ('error' in result) return c.json({ error: result.error }, result.status);
+ return c.json(result.value, 201);
+});
+
+logs.get('/:id', (c) => {
+ const id = c.req.param('id');
+ if (!validLogId(id)) return c.json({ error: 'Invalid log ID' }, 400);
+ if (new URL(c.req.url).search) return c.json({ error: 'Log description does not accept query parameters' }, 400);
+ const log = getLog(id);
+ if (!log) return c.json({ error: 'Log not found' }, 404);
+ return c.json(log);
+});
+
+logs.get('/:id/entries', (c) => {
+ const id = c.req.param('id');
+ if (!validLogId(id)) return c.json({ error: 'Invalid log ID' }, 400);
+ const query = new URL(c.req.url).searchParams;
+ if ([...query.keys()].some((key) => !['after', 'limit'].includes(key)) || query.getAll('after').length > 1 || query.getAll('limit').length > 1) {
+ return c.json({ error: 'Use only one after and limit parameter' }, 400);
+ }
+ const after = query.get('after') ?? '0';
+ const limit = query.get('limit') ?? '50';
+ if (!/^(0|[1-9]\d*)$/.test(after) || !Number.isSafeInteger(Number(after)) ||
+ !/^[1-9]\d*$/.test(limit) || Number(limit) > logLimits.pageSize) {
+ return c.json({ error: 'after must be a nonnegative safe integer; limit must be 1–100' }, 400);
+ }
+ if (!getLog(id)) return c.json({ error: 'Log not found' }, 404);
+ return c.json(readLogEntries(id, Number(after), Number(limit)));
+});
+
+export { logs };
diff --git a/src/storage/logs.ts b/src/storage/logs.ts
new file mode 100644
index 0000000..0c51560
--- /dev/null
+++ b/src/storage/logs.ts
@@ -0,0 +1,180 @@
+import { open, Database } from 'lmdb';
+import { config } from '../config.js';
+import { checkLogSigner, logLimits, type LogSigner } from '../utils/logSignature.js';
+
+export interface PendingLogTransfer {
+ new_owner_fingerprint: string;
+ initiated_at: string;
+ retain_previous_owner: boolean;
+}
+
+export interface PublicLog {
+ id: string;
+ owner_fingerprint: string;
+ allowed_writers: string[];
+ created_at: string;
+ entry_count: number;
+ revision: number;
+ pending_transfer: PendingLogTransfer | null;
+}
+
+type StoredLog = Omit & Partial>;
+export interface LogEntry {
+ sequence: number;
+ timestamp: string;
+ agent_fingerprint: string;
+ metadata: string;
+}
+
+type MutationError = { error: string; status: 401 | 403 | 404 | 409 };
+type MutationResult = { value: T } | MutationError;
+interface LogStore {
+ logs: Database;
+ entries: Database;
+ nonces: Database;
+ expiries: Database;
+}
+let store: LogStore | undefined;
+
+export function initLogDatabase(): void {
+ if (store) return;
+ // User IDs must not collide with named-database descriptors in the unnamed DB.
+ const logs = open({ path: `${config.lmdbPath}-logs`, name: 'logs', compression: true, maxDbs: 4 });
+ store = {
+ logs,
+ entries: logs.openDB({ name: 'entries' }),
+ nonces: logs.openDB({ name: 'nonces' }),
+ expiries: logs.openDB({ name: 'expiries' }),
+ };
+}
+function getStore(): LogStore {
+ if (!store) throw new Error('Log database not initialized');
+ return store;
+}
+function normalize(log: StoredLog): PublicLog {
+ return { ...log, revision: log.revision ?? 0, pending_transfer: log.pending_transfer ?? null };
+}
+function nonceKey(signer: LogSigner): string { return `${signer.fingerprint}:${signer.nonce}`; }
+function entryKey(id: string, sequence: number): string { return `${id}:${String(sequence).padStart(16, '0')}`; }
+
+function checkWrite(db: LogStore, signer: LogSigner, now: number): MutationError | undefined {
+ const invalid = checkLogSigner(signer, now);
+ if (invalid) return invalid;
+ const expiry = db.nonces.get(nonceKey(signer));
+ if (expiry !== undefined && expiry >= now) return { error: 'Request nonce already used', status: 409 };
+}
+
+function consumeNonce(db: LogStore, signer: LogSigner, now: number): void {
+ // Only successful mutations clean a bounded batch. Equality is still in the signing window.
+ const expired = db.expiries.getKeys({ end: String(now).padStart(16, '0'), limit: 100 }).asArray;
+ for (const key of expired) {
+ const identity = key.slice(17);
+ const expiry = Number(key.slice(0, 16));
+ if (db.nonces.get(identity) === expiry) db.nonces.removeSync(identity);
+ db.expiries.removeSync(key);
+ }
+ const expiry = signer.timestamp + config.signedPublishing.maxTimestampSkewMs;
+ db.nonces.putSync(nonceKey(signer), expiry);
+ db.expiries.putSync(`${String(expiry).padStart(16, '0')}:${nonceKey(signer)}`, true);
+}
+
+export async function createLog(id: string, allowedWriters: string[], signer: LogSigner): Promise> {
+ const db = getStore();
+ return db.logs.transaction(() => {
+ const now = Date.now();
+ const invalid = checkWrite(db, signer, now);
+ if (invalid) return invalid;
+ if (db.logs.get(id)) return { error: 'Log ID already taken', status: 409 };
+ const log: PublicLog = { id, owner_fingerprint: signer.fingerprint, allowed_writers: [...allowedWriters], created_at: new Date(now).toISOString(), entry_count: 0, revision: 0, pending_transfer: null };
+ db.logs.putSync(id, log);
+ consumeNonce(db, signer, now);
+ return { value: log };
+ });
+}
+
+export async function appendLogEntry(id: string, metadata: string, signer: LogSigner): Promise> {
+ const db = getStore();
+ return db.logs.transaction(() => {
+ const now = Date.now();
+ const invalid = checkWrite(db, signer, now);
+ if (invalid) return invalid;
+ const stored = db.logs.get(id);
+ if (!stored) return { error: 'Log not found', status: 404 };
+ const log = normalize(stored);
+ if (log.owner_fingerprint !== signer.fingerprint && !log.allowed_writers.includes(signer.fingerprint)) return { error: 'Agent is not allowed to write to this log', status: 403 };
+ if (log.entry_count >= logLimits.entries) return { error: 'Log entry limit reached', status: 409 };
+ const entry: LogEntry = { sequence: log.entry_count + 1, timestamp: new Date(now).toISOString(), agent_fingerprint: signer.fingerprint, metadata };
+ db.entries.putSync(entryKey(id, entry.sequence), entry);
+ db.logs.putSync(id, { ...log, entry_count: entry.sequence });
+ consumeNonce(db, signer, now);
+ return { value: entry };
+ });
+}
+
+async function manageLog(id: string, revision: number, signer: LogSigner, recipient: boolean, change: (log: PublicLog, now: number) => PublicLog | MutationError): Promise> {
+ const db = getStore();
+ return db.logs.transaction(() => {
+ const now = Date.now();
+ const invalid = checkWrite(db, signer, now);
+ if (invalid) return invalid;
+ const stored = db.logs.get(id);
+ if (!stored) return { error: 'Log not found', status: 404 };
+ const log = normalize(stored);
+ if (recipient && !log.pending_transfer) return { error: 'No pending ownership transfer', status: 409 };
+ const authorized = recipient ? log.pending_transfer!.new_owner_fingerprint : log.owner_fingerprint;
+ if (authorized !== signer.fingerprint) return { error: recipient ? 'Only the nominated agent can accept ownership' : 'Only the current owner can manage this log', status: 403 };
+ if (log.revision !== revision || log.revision >= Number.MAX_SAFE_INTEGER) return { error: 'Log revision conflict; read the current log and retry', status: 409 };
+ const updated = change(log, now);
+ if ('error' in updated) return updated;
+ updated.revision = log.revision + 1;
+ db.logs.putSync(id, updated);
+ consumeNonce(db, signer, now);
+ return { value: updated };
+ });
+}
+
+export function updateLogWriters(id: string, writers: string[], revision: number, signer: LogSigner) {
+ return manageLog(id, revision, signer, false, (log) => ({ ...log, allowed_writers: [...writers] }));
+}
+export function nominateLogOwner(id: string, fingerprint: string, retain: boolean, revision: number, signer: LogSigner) {
+ return manageLog(id, revision, signer, false, (log, now) => {
+ if (fingerprint === log.owner_fingerprint) return { error: 'New owner must be a different agent', status: 409 };
+ return { ...log, pending_transfer: { new_owner_fingerprint: fingerprint, initiated_at: new Date(now).toISOString(), retain_previous_owner: retain } };
+ });
+}
+export function cancelLogTransfer(id: string, revision: number, signer: LogSigner) {
+ return manageLog(id, revision, signer, false, (log) => {
+ if (!log.pending_transfer) return { error: 'No pending ownership transfer', status: 409 };
+ return { ...log, pending_transfer: null };
+ });
+}
+export function acceptLogTransfer(id: string, revision: number, signer: LogSigner) {
+ return manageLog(id, revision, signer, true, (log) => {
+ const transfer = log.pending_transfer!;
+ const writers = log.allowed_writers.filter((writer) => writer !== log.owner_fingerprint && writer !== transfer.new_owner_fingerprint);
+ if (transfer.retain_previous_owner) writers.push(log.owner_fingerprint);
+ if (writers.length > logLimits.writers) return { error: 'Retaining the previous owner exceeds the writer limit', status: 409 };
+ return { ...log, owner_fingerprint: transfer.new_owner_fingerprint, allowed_writers: writers, pending_transfer: null };
+ });
+}
+export function getLog(id: string): PublicLog | undefined {
+ const log = getStore().logs.get(id);
+ return log ? normalize(log) : undefined;
+}
+export function readLogEntries(id: string, after: number, limit: number) {
+ const { entries } = getStore();
+ if (after >= logLimits.entries) return { entries: [], next_after: after, has_more: false };
+ const page = entries.getRange({ start: entryKey(id, after + 1), end: `${id};`, limit: limit + 1 }).asArray.map(({ value }) => value);
+ const hasMore = page.length > limit;
+ if (hasMore) page.pop();
+ return { entries: page, next_after: page.at(-1)?.sequence ?? after, has_more: hasMore };
+}
+export async function closeLogDatabase(): Promise {
+ if (!store) return;
+ const current = store;
+ store = undefined;
+ await current.entries.close();
+ await current.nonces.close();
+ await current.expiries.close();
+ await current.logs.close();
+}
diff --git a/src/test/logCapacity.test.ts b/src/test/logCapacity.test.ts
new file mode 100644
index 0000000..ddfcaf5
--- /dev/null
+++ b/src/test/logCapacity.test.ts
@@ -0,0 +1,32 @@
+import { beforeAll, afterAll, expect, it } from 'vitest';
+import { createAgent, registerLogAgents } from './logHelpers.js';
+const agent = createAgent();
+import { appendLogEntry, closeLogDatabase, createLog, getLog, initLogDatabase, readLogEntries } from '../storage/logs.js';
+import { logLimits } from '../utils/logSignature.js';
+
+// Exercise the actual capacity branch at a small limit without generating 100,000 writes.
+const originalLimit = logLimits.entries;
+beforeAll(async () => {
+ await registerLogAgents();
+ Object.defineProperty(logLimits, 'entries', { value: 3 });
+ initLogDatabase();
+});
+afterAll(async () => {
+ Object.defineProperty(logLimits, 'entries', { value: originalLimit });
+ await closeLogDatabase();
+});
+
+it('accepts the final slot, rejects overflow atomically, and does not consume a rejected nonce', async () => {
+ const signer = () => agent.identity();
+ await createLog('capacity', [], signer());
+ await createLog('other', [], signer());
+ for (let i = 1; i <= 2; i++) expect(await appendLogEntry('capacity', '{}', signer())).toMatchObject({ value: { sequence: i } });
+ const first = signer();
+ const second = signer();
+ const results = await Promise.all([appendLogEntry('capacity', '{}', first), appendLogEntry('capacity', '{}', second)]);
+ expect(results[0]).toMatchObject({ value: { sequence: 3 } });
+ expect(results[1]).toMatchObject({ status: 409, error: 'Log entry limit reached' });
+ expect(getLog('capacity')?.entry_count).toBe(3);
+ expect(readLogEntries('capacity', 0, 100).entries).toHaveLength(3);
+ expect(await appendLogEntry('other', '{}', second)).toMatchObject({ value: { sequence: 1 } });
+});
diff --git a/src/test/logHelpers.ts b/src/test/logHelpers.ts
new file mode 100644
index 0000000..d004b3f
--- /dev/null
+++ b/src/test/logHelpers.ts
@@ -0,0 +1,26 @@
+import { randomUUID } from 'node:crypto';
+import { saveAgentKey } from '../storage/db.js';
+import { createSignedHeaders, generateTestSigner } from './helpers/signing.js';
+
+const agents: ReturnType[] = [];
+export function createAgent() {
+ const agent = generateTestSigner(`log-agent-${randomUUID()}`);
+ agents.push(agent);
+ return {
+ ...agent,
+ fingerprint: agent.publicKeyFingerprint,
+ identity(nonce = randomUUID(), timestamp = Date.now()) {
+ return { keyId: agent.keyId, fingerprint: agent.publicKeyFingerprint, nonce, timestamp };
+ },
+ request(path: string, data: unknown, options: { nonce?: string; timestamp?: string; method?: string } = {}) {
+ const body = typeof data === 'string' ? data : JSON.stringify(data);
+ const method = options.method ?? 'POST';
+ const headers = new Headers(createSignedHeaders({ signer: agent, method, path, body, nonce: options.nonce ?? randomUUID(), timestamp: options.timestamp }));
+ headers.set('Content-Type', 'application/json');
+ return { method, body, headers };
+ },
+ };
+}
+export async function registerLogAgents() {
+ for (const agent of agents) await saveAgentKey({ keyId: agent.keyId, publicJwk: agent.publicJwk, publicKeyFingerprint: agent.publicKeyFingerprint, scopes: [], status: 'active' });
+}
diff --git a/src/test/logManagement.test.ts b/src/test/logManagement.test.ts
new file mode 100644
index 0000000..999e26a
--- /dev/null
+++ b/src/test/logManagement.test.ts
@@ -0,0 +1,145 @@
+import { beforeAll, afterAll, describe, expect, it, vi } from 'vitest';
+import { open } from 'lmdb';
+import { config } from '../config.js';
+import { createAgent, registerLogAgents } from './logHelpers.js';
+import { saveAgentKey, updateAgentKeyStatus } from '../storage/db.js';
+import { acceptLogTransfer, appendLogEntry, cancelLogTransfer, closeLogDatabase, createLog, getLog, initLogDatabase, nominateLogOwner, readLogEntries, updateLogWriters } from '../storage/logs.js';
+const owner = createAgent();
+const writer = createAgent();
+const next = createAgent();
+const outsider = createAgent();
+beforeAll(async () => { await registerLogAgents(); initLogDatabase(); });
+afterAll(closeLogDatabase);
+
+describe('Atomic log management', () => {
+ it('adds/removes writers, rejects outsiders and stale revisions, preserves entry cursors', async () => {
+ await createLog('acl', [], owner.identity());
+ expect(await updateLogWriters('acl', [writer.fingerprint], 0, outsider.identity())).toMatchObject({ status: 403 });
+ expect(await updateLogWriters('acl', [writer.fingerprint], 0, owner.identity())).toMatchObject({ value: { revision: 1 } });
+ await appendLogEntry('acl', '{"event":"before"}', writer.identity());
+ expect(getLog('acl')?.revision).toBe(1);
+ expect(await updateLogWriters('acl', [], 0, owner.identity())).toMatchObject({ status: 409 });
+ expect(await updateLogWriters('acl', [], 1, owner.identity())).toMatchObject({ value: { revision: 2 } });
+ expect(await appendLogEntry('acl', '{}', writer.identity())).toMatchObject({ status: 403 });
+ expect(await appendLogEntry('acl', '{}', owner.identity())).toMatchObject({ value: { sequence: 2 } });
+ expect(readLogEntries('acl', 0, 100).entries[0].agent_fingerprint).toBe(writer.fingerprint);
+ });
+ it('serializes competing revision edits and appends around revocation', async () => {
+ await createLog('race', [writer.fingerprint], owner.identity());
+ const results = await Promise.all([updateLogWriters('race', [], 0, owner.identity()), updateLogWriters('race', [outsider.fingerprint], 0, owner.identity()), appendLogEntry('race', '{}', writer.identity())]);
+ expect(results[0]).toMatchObject({ value: { revision: 1 } });
+ expect(results[1]).toMatchObject({ status: 409 });
+ expect(results[2]).toMatchObject({ status: 403 });
+ expect(getLog('race')?.entry_count).toBe(0);
+ });
+ it('nominates, cancels and replaces without granting premature owner rights', async () => {
+ await createLog('nomination', [], owner.identity());
+ expect(await cancelLogTransfer('nomination', 0, owner.identity())).toMatchObject({ status: 409 });
+ expect(await nominateLogOwner('nomination', next.fingerprint, false, 0, owner.identity())).toMatchObject({ value: { revision: 1, pending_transfer: { new_owner_fingerprint: next.fingerprint } } });
+ expect(await updateLogWriters('nomination', [], 1, next.identity())).toMatchObject({ status: 403 });
+ expect(await appendLogEntry('nomination', '{}', next.identity())).toMatchObject({ status: 403 });
+ await appendLogEntry('nomination', '{}', owner.identity());
+ expect(getLog('nomination')?.pending_transfer?.new_owner_fingerprint).toBe(next.fingerprint);
+ expect(await cancelLogTransfer('nomination', 1, outsider.identity())).toMatchObject({ status: 403 });
+ await cancelLogTransfer('nomination', 1, owner.identity());
+ expect(await acceptLogTransfer('nomination', 1, next.identity())).toMatchObject({ status: 409 });
+ await nominateLogOwner('nomination', next.fingerprint, false, 2, owner.identity());
+ await nominateLogOwner('nomination', writer.fingerprint, false, 3, owner.identity());
+ expect(await acceptLogTransfer('nomination', 3, next.identity())).toMatchObject({ status: 403 });
+ expect(await acceptLogTransfer('nomination', 3, writer.identity())).toMatchObject({ status: 409 });
+ expect(await acceptLogTransfer('nomination', 4, writer.identity())).toMatchObject({ value: { revision: 5, owner_fingerprint: writer.fingerprint, pending_transfer: null } });
+ });
+ it('removes former owner even if explicitly allowlisted and preserves history', async () => {
+ await createLog('handoff', [owner.fingerprint, writer.fingerprint, next.fingerprint], owner.identity());
+ await appendLogEntry('handoff', 'null', owner.identity());
+ const history = readLogEntries('handoff', 0, 100);
+ await nominateLogOwner('handoff', next.fingerprint, false, 0, owner.identity());
+ expect(await acceptLogTransfer('handoff', 1, outsider.identity())).toMatchObject({ status: 403 });
+ await acceptLogTransfer('handoff', 1, next.identity());
+ expect(getLog('handoff')?.allowed_writers).toEqual([writer.fingerprint]);
+ expect(await appendLogEntry('handoff', '{}', owner.identity())).toMatchObject({ status: 403 });
+ expect(await updateLogWriters('handoff', [], 2, owner.identity())).toMatchObject({ status: 403 });
+ expect(readLogEntries('handoff', 0, 100)).toEqual(history);
+ expect(await appendLogEntry('handoff', '{}', next.identity())).toMatchObject({ value: { sequence: 2 } });
+ });
+ it('retains the previous owner only as a writer when requested', async () => {
+ await createLog('retained', [], owner.identity());
+ await nominateLogOwner('retained', next.fingerprint, true, 0, owner.identity());
+ await acceptLogTransfer('retained', 1, next.identity());
+ expect(getLog('retained')?.allowed_writers).toEqual([owner.fingerprint]);
+ expect(await appendLogEntry('retained', '{}', owner.identity())).toHaveProperty('value');
+ expect(await updateLogWriters('retained', [], 2, owner.identity())).toMatchObject({ status: 403 });
+ });
+ it('fails over-capacity retention without consuming nonce or changing pending state', async () => {
+ const writers = Array.from({ length: 100 }, (_, i) => Buffer.from(String(i).padStart(32, '0')).toString('base64url'));
+ await createLog('retain-full', writers, owner.identity());
+ await nominateLogOwner('retain-full', next.fingerprint, true, 0, owner.identity());
+ const acceptance = next.identity();
+ expect(await acceptLogTransfer('retain-full', 1, acceptance)).toMatchObject({ status: 409 });
+ expect(getLog('retain-full')?.revision).toBe(1);
+ expect(getLog('retain-full')?.pending_transfer).not.toBeNull();
+ await updateLogWriters('retain-full', [], 1, owner.identity());
+ expect(await acceptLogTransfer('retain-full', 2, acceptance)).toHaveProperty('value');
+ });
+ it('gives exactly one winner to concurrent cancellation/acceptance', async () => {
+ await createLog('cancel-race', [], owner.identity());
+ await nominateLogOwner('cancel-race', next.fingerprint, false, 0, owner.identity());
+ const results = await Promise.all([cancelLogTransfer('cancel-race', 1, owner.identity()), acceptLogTransfer('cancel-race', 1, next.identity())]);
+ expect(results.filter((r) => 'value' in r)).toHaveLength(1);
+ expect(getLog('cancel-race')?.owner_fingerprint).toBe(owner.fingerprint);
+ });
+ it('preserves pending transfer, revisions, history and replay across restart', async () => {
+ await createLog('pending-restart', [], owner.identity());
+ const nomination = owner.identity();
+ await nominateLogOwner('pending-restart', next.fingerprint, false, 0, nomination);
+ await closeLogDatabase(); initLogDatabase();
+ expect(getLog('pending-restart')?.revision).toBe(1);
+ expect(await updateLogWriters('pending-restart', [], 1, nomination)).toMatchObject({ status: 409 });
+ expect(await appendLogEntry('pending-restart', '{}', nomination)).toMatchObject({ status: 409 });
+ expect(await acceptLogTransfer('pending-restart', 1, next.identity())).toHaveProperty('value');
+ });
+ it('does not allow alias key registration to bypass fingerprint nonce checks', async () => {
+ await saveAgentKey({ keyId: 'log-owner-alias', publicJwk: owner.publicJwk, scopes: [], status: 'active' });
+ const signed = owner.identity();
+ await createLog('alias', [], signed);
+ expect(await appendLogEntry('alias', '{}', { ...signed, keyId: 'log-owner-alias' })).toMatchObject({ status: 409 });
+ });
+ it('rechecks registry revocation at mutation time', async () => {
+ await createLog('blocked-after-verification', [], outsider.identity());
+ const signed = outsider.identity();
+ await updateAgentKeyStatus(outsider.keyId, 'revoked');
+ try {
+ expect(await updateLogWriters('blocked-after-verification', [], 0, signed)).toMatchObject({ status: 403 });
+ } finally { await updateAgentKeyStatus(outsider.keyId, 'active'); }
+ });
+ it('protects exact and future-skew expiry boundaries while allowing expired nonce cleanup', async () => {
+ const base = Date.now();
+ const skew = config.signedPublishing.maxTimestampSkewMs;
+ const clock = vi.spyOn(Date, 'now');
+ try {
+ clock.mockReturnValue(base);
+ const signed = owner.identity(undefined, base + skew);
+ await createLog('expiry', [], signed);
+ clock.mockReturnValue(base + 2 * skew);
+ await createLog('expiry-cleanup', [], owner.identity());
+ expect(await appendLogEntry('expiry', '{}', signed)).toMatchObject({ status: 409 });
+ clock.mockReturnValue(base + 2 * skew + 1);
+ await createLog('expiry-cleanup-later', [], owner.identity());
+ expect(await appendLogEntry('expiry', '{}', signed)).toMatchObject({ status: 401 });
+ expect(await appendLogEntry('expiry', '{}', owner.identity(signed.nonce))).toHaveProperty('value');
+ } finally { clock.mockRestore(); }
+ });
+ it('normalizes legacy fields lazily and guards revision overflow', async () => {
+ await createLog('legacy', [], owner.identity());
+ const saved = getLog('legacy')!;
+ await closeLogDatabase();
+ const db = open({ path: `${config.lmdbPath}-logs`, name: 'logs', compression: true });
+ const { revision, pending_transfer, ...legacy } = saved;
+ db.putSync('legacy', legacy);
+ db.putSync('overflow', { ...saved, id: 'overflow', revision: Number.MAX_SAFE_INTEGER });
+ await db.close(); initLogDatabase();
+ expect(getLog('legacy')).toMatchObject({ revision: 0, pending_transfer: null });
+ expect(await updateLogWriters('legacy', [], 0, owner.identity())).toMatchObject({ value: { revision: 1 } });
+ expect(await updateLogWriters('overflow', [], Number.MAX_SAFE_INTEGER, owner.identity())).toMatchObject({ status: 409 });
+ });
+});
diff --git a/src/test/logSignature.test.ts b/src/test/logSignature.test.ts
new file mode 100644
index 0000000..9ad32fd
--- /dev/null
+++ b/src/test/logSignature.test.ts
@@ -0,0 +1,56 @@
+import { beforeAll, describe, expect, it } from 'vitest';
+import { createAgent, registerLogAgents } from './logHelpers.js';
+import { verifyLogSignature } from '../utils/logSignature.js';
+import { updateAgentKeyStatus } from '../storage/db.js';
+import { config } from '../config.js';
+const agent = createAgent();
+const blocked = createAgent();
+const revoked = createAgent();
+const path = '/v1/logs/activity';
+beforeAll(async () => {
+ await registerLogAgents();
+ await updateAgentKeyStatus(blocked.keyId, 'blocked');
+ await updateAgentKeyStatus(revoked.keyId, 'revoked');
+});
+function verify(request: ReturnType, target = path, now = Date.now()) {
+ return verifyLogSignature(request.method, target, request.headers, Buffer.from(request.body), now);
+}
+describe('Registered log signatures', () => {
+ it('uses existing ZenBin signer and fingerprint, including CAP aliases', () => {
+ const req = agent.request(path, {});
+ expect(verify(req)).toMatchObject({ fingerprint: agent.fingerprint, keyId: agent.keyId });
+ for (const [legacy, cap] of [['X-Zenbin-Key-Id', 'CAP-Key-Id'], ['X-Zenbin-Timestamp', 'CAP-Timestamp'], ['X-Zenbin-Nonce', 'CAP-Nonce'], ['Content-Digest', 'CAP-Digest'], ['X-Zenbin-Signature', 'CAP-Signature']]) {
+ req.headers.set(cap, req.headers.get(legacy)!);
+ req.headers.delete(legacy);
+ }
+ expect(verify(req)).toMatchObject({ fingerprint: agent.fingerprint });
+ req.headers.set('CAP-Key-Id', 'unknown');
+ req.headers.set('X-Zenbin-Key-Id', agent.keyId);
+ expect(verify(req)).toMatchObject({ status: 401 });
+ });
+ it.each(['body', 'method', 'path', 'key', 'digest', 'signature', 'nonce', 'timestamp'])('rejects tampered %s', (field) => {
+ const req = agent.request(path, {});
+ if (field === 'body') req.body = '{ "allowed_writers": [] }';
+ if (field === 'method') req.method = 'DELETE';
+ if (field === 'key') req.headers.set('X-Zenbin-Key-Id', blocked.keyId);
+ if (field === 'digest') req.headers.set('Content-Digest', 'sha-256=:wrong:');
+ if (field === 'signature') req.headers.set('X-Zenbin-Signature', `:${'A'.repeat(86)}:`);
+ if (field === 'nonce') req.headers.set('X-Zenbin-Nonce', 'different-valid-nonce');
+ if (field === 'timestamp') req.headers.set('X-Zenbin-Timestamp', new Date(Date.now() + 1000).toISOString());
+ expect(verify(req, field === 'path' ? path + '/entries' : path)).toHaveProperty('error');
+ });
+ it.each([blocked, revoked])('rejects unavailable key $keyId', (key) => {
+ expect(verify(key.request(path, {}))).toMatchObject({ status: 403 });
+ });
+ it.each(['unknown', ''])('rejects missing/unknown registered key %s', (keyId) => {
+ const req = agent.request(path, {}); req.headers.set('X-Zenbin-Key-Id', keyId);
+ expect(verify(req)).toMatchObject({ status: 401 });
+ });
+ it.each([-1, 1])('rejects stale or too-far future timestamps (%i)', (direction) => {
+ const now = Date.now();
+ expect(verify(agent.request(path, {}, { timestamp: new Date(now + direction * (config.signedPublishing.maxTimestampSkewMs + 1)).toISOString() }), path, now)).toMatchObject({ status: 401 });
+ });
+ it.each(['short', 'x'.repeat(129), 'not allowed nonce'])('rejects invalid nonce %s', (nonce) => {
+ expect(verify(agent.request(path, {}, { nonce }))).toMatchObject({ status: 401 });
+ });
+});
diff --git a/src/test/logStorage.test.ts b/src/test/logStorage.test.ts
new file mode 100644
index 0000000..f0b7ca0
--- /dev/null
+++ b/src/test/logStorage.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it, beforeAll, afterAll } from 'vitest';
+import { randomUUID } from 'node:crypto';
+import { appendLogEntry, closeLogDatabase, createLog, getLog, initLogDatabase, readLogEntries } from '../storage/logs.js';
+
+import { createAgent, registerLogAgents } from './logHelpers.js';
+const agents = [createAgent(), createAgent(), createAgent()];
+const [owner, writer, outsider] = agents.map((agent) => agent.fingerprint);
+const signer = (fingerprint = owner, nonce = randomUUID()) => agents.find((agent) => agent.fingerprint === fingerprint)!.identity(nonce);
+beforeAll(async () => { await registerLogAgents(); initLogDatabase(); });
+afterAll(closeLogDatabase);
+
+describe('Atomic public log storage', () => {
+ it('allows IDs matching internal database names without collisions', async () => {
+ for (const id of ['entries', 'nonces', 'logs']) {
+ expect(await createLog(id, [], signer())).toHaveProperty('value');
+ expect(await appendLogEntry(id, '{}', signer())).toMatchObject({ value: { sequence: 1 } });
+ expect(getLog(id)?.id).toBe(id);
+ }
+ });
+ it('creates once under concurrent claims and preserves the owner', async () => {
+ const results = await Promise.all([createLog('claim', [writer], signer()), createLog('claim', [], signer(outsider))]);
+ expect(results.filter((r) => 'value' in r)).toHaveLength(1);
+ expect(results.filter((r) => 'error' in r)).toHaveLength(1);
+ expect(getLog('claim')?.owner_fingerprint).toBe(owner);
+ expect(getLog('claim')?.allowed_writers).toEqual([writer]);
+ });
+
+ it('allocates contiguous sequences under concurrent owner and allowlisted appends', async () => {
+ await createLog('parallel', [writer], signer());
+ const results = await Promise.all(Array.from({ length: 30 }, (_, index) => appendLogEntry('parallel', JSON.stringify({ index }), signer(index % 2 ? owner : writer))));
+ expect(results.every((r) => 'value' in r)).toBe(true);
+ expect(getLog('parallel')?.entry_count).toBe(30);
+ const page = readLogEntries('parallel', 0, 100);
+ expect(page.entries.map((e) => e.sequence)).toEqual(Array.from({ length: 30 }, (_, i) => i + 1));
+ expect(new Set(page.entries.map((e) => JSON.parse(e.metadata).index)).size).toBe(30);
+ expect(new Set(page.entries.map((e) => e.agent_fingerprint))).toEqual(new Set([owner, writer]));
+ });
+
+ it('rejects outsiders without consuming their nonce or changing the log', async () => {
+ await createLog('private-writers', [], signer());
+ const request = signer(outsider);
+ expect(await appendLogEntry('private-writers', '{}', request)).toMatchObject({ status: 403 });
+ expect(getLog('private-writers')?.entry_count).toBe(0);
+ expect(await createLog('outsider-own', [], request)).toHaveProperty('value');
+ expect(await appendLogEntry('missing', '{}', signer())).toMatchObject({ status: 404 });
+ });
+
+ it('atomically rejects replay across logs but permits another signer to use the nonce', async () => {
+ const request = signer();
+ await createLog('replay', [writer], request);
+ expect(await appendLogEntry('replay', '{}', request)).toMatchObject({ status: 409 });
+ expect(await createLog('replay-other', [], request)).toMatchObject({ status: 409 });
+ const duplicate = signer();
+ const results = await Promise.all([appendLogEntry('replay', '{}', duplicate), appendLogEntry('replay', '{}', duplicate)]);
+ expect(results.filter((r) => 'value' in r)).toHaveLength(1);
+ expect(results.filter((r) => 'error' in r)).toHaveLength(1);
+ expect(await appendLogEntry('replay', '{}', signer(writer, duplicate.nonce))).toHaveProperty('value');
+ expect(getLog('replay')?.entry_count).toBe(2);
+ });
+
+ it('paginates without leaking similarly prefixed logs and retains empty cursors', async () => {
+ for (const id of ['range', 'range-a', 'range.b']) {
+ await createLog(id, [], signer());
+ for (let i = 0; i < 3; i++) await appendLogEntry(id, JSON.stringify(id), signer());
+ }
+ expect(readLogEntries('range', 0, 2)).toMatchObject({ next_after: 2, has_more: true });
+ const last = readLogEntries('range', 2, 2);
+ expect(last.entries).toHaveLength(1);
+ expect(last.entries[0].metadata).toBe('"range"');
+ expect(last).toMatchObject({ next_after: 3, has_more: false });
+ expect(readLogEntries('range', 3, 2)).toEqual({ entries: [], next_after: 3, has_more: false });
+ expect(readLogEntries('range', Number.MAX_SAFE_INTEGER, 2)).toEqual({ entries: [], next_after: Number.MAX_SAFE_INTEGER, has_more: false });
+ await appendLogEntry('range', 'null', signer());
+ expect(readLogEntries('range', 3, 2).entries[0].sequence).toBe(4);
+ });
+
+ it('preserves definition, entries and nonces after close and reopen', async () => {
+ const request = signer();
+ await createLog('durable', [writer], request);
+ const metadata = ' { "event": "ready", "unicode": "雪" } ';
+ await appendLogEntry('durable', metadata, signer(writer));
+ await closeLogDatabase();
+ initLogDatabase();
+ expect(getLog('durable')).toMatchObject({ owner_fingerprint: owner, allowed_writers: [writer], entry_count: 1 });
+ expect(readLogEntries('durable', 0, 1).entries[0]).toMatchObject({ agent_fingerprint: writer, metadata });
+ expect(await appendLogEntry('durable', '{}', request)).toMatchObject({ status: 409 });
+ expect(await appendLogEntry('durable', '{}', signer())).toMatchObject({ value: { sequence: 2 } });
+ });
+});
diff --git a/src/test/logs.test.ts b/src/test/logs.test.ts
new file mode 100644
index 0000000..29c40b6
--- /dev/null
+++ b/src/test/logs.test.ts
@@ -0,0 +1,204 @@
+import { randomUUID } from 'node:crypto';
+import { Hono } from 'hono';
+import { cors } from 'hono/cors';
+import { beforeAll, afterAll, describe, expect, it } from 'vitest';
+import { logs } from '../routes/logs.js';
+import { verifyApiKey } from '../middleware/verifyApiKey.js';
+import { closeLogDatabase, initLogDatabase } from '../storage/logs.js';
+import { config } from '../config.js';
+import { createAgent, registerLogAgents } from './logHelpers.js';
+
+const app = new Hono();
+app.use('*', cors());
+app.use('/v1/*', verifyApiKey);
+app.route('/v1/logs', logs);
+app.get('/v1/quota-check', (c) => c.json({ ok: true }));
+const owner = createAgent();
+const writer = createAgent();
+const outsider = createAgent();
+const id = () => `log-${randomUUID()}`;
+beforeAll(async () => { await registerLogAgents(); initLogDatabase(); });
+afterAll(closeLogDatabase);
+
+function post(path: string, body: unknown, agent = owner, method = 'POST') {
+ const req = agent.request(path, body, { method });
+ req.headers.set('User-Agent', randomUUID());
+ return app.request(path, req);
+}
+
+describe('Public logs API', () => {
+ it('allows owner and allowlisted writer, with public identity, time and unchanged metadata', async () => {
+ const path = `/v1/logs/${id()}`;
+ const created = await post(path, { allowed_writers: [writer.fingerprint] });
+ expect(created.status).toBe(201);
+ expect(await created.json()).toMatchObject({ owner_fingerprint: owner.fingerprint, allowed_writers: [writer.fingerprint], entry_count: 0 });
+ const metadata = ' {"event":"ready", "message":"雪 "} ';
+ const before = Date.now();
+ for (const agent of [owner, writer]) {
+ const res = await post(`${path}/entries`, { metadata }, agent);
+ expect(res.status).toBe(201);
+ const entry = await res.json();
+ expect(entry.agent_fingerprint).toBe(agent.fingerprint);
+ expect(entry.metadata).toBe(metadata);
+ expect(Date.parse(entry.timestamp)).toBeGreaterThanOrEqual(before);
+ expect(Date.parse(entry.timestamp)).toBeLessThanOrEqual(Date.now());
+ }
+ expect((await post(`${path}/entries`, { metadata: '{}' }, outsider)).status).toBe(403);
+ expect((await post(path, {}, outsider)).status).toBe(409);
+ const page = await app.request(`${path}/entries?limit=1`);
+ expect(page.headers.get('Content-Type')).toContain('application/json');
+ expect(page.headers.get('Cache-Control')).toBe('no-store');
+ expect(await page.json()).toMatchObject({ next_after: 1, has_more: true, entries: [{ sequence: 1, metadata }] });
+ expect(await (await app.request(`${path}/entries?after=1`)).json()).toMatchObject({ next_after: 2, has_more: false, entries: [{ sequence: 2 }] });
+ expect(await (await app.request(`${path}/entries?after=2`)).json()).toEqual({ next_after: 2, has_more: false, entries: [] });
+ expect(await (await app.request(path)).json()).toMatchObject({ entry_count: 2 });
+ });
+
+ it('requires signatures and rejects altered and replayed requests without appending', async () => {
+ const path = `/v1/logs/${id()}`;
+ expect((await app.request(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })).status).toBe(401);
+ expect((await post(path, {})).status).toBe(201);
+ const req = owner.request(`${path}/entries`, { metadata: '{}' });
+ expect((await app.request(`${path}/entries`, { ...req, body: '{"metadata":"null"}' })).status).toBe(401);
+ expect((await app.request(`${path}/entries`, req)).status).toBe(201);
+ expect((await app.request(`${path}/entries`, req)).status).toBe(409);
+ expect(await (await app.request(path)).json()).toMatchObject({ entry_count: 1 });
+ });
+
+ it.each([null, [], 1, 'null', '{', { allowed_writers: null }, { allowed_writers: {} }, { allowed_writers: ['invalid'] },
+ { allowed_writers: ['!'.repeat(43)] }, { allowed_writers: [writer.fingerprint, writer.fingerprint] },
+ { owner_fingerprint: outsider.fingerprint }, { allowed_writers: Array.from({ length: 101 }, (_, i) => Buffer.from(String(i).padStart(32, '0')).toString('base64url')) },
+ ])('rejects invalid create input %j', async (body) => {
+ expect((await post(`/v1/logs/${id()}`, body)).status).toBe(400);
+ });
+
+ it('accepts 100 unique writers', async () => {
+ const writers = Array.from({ length: 100 }, (_, i) => Buffer.from(String(i).padStart(32, '0')).toString('base64url'));
+ expect((await post(`/v1/logs/${id()}`, { allowed_writers: writers })).status).toBe(201);
+ });
+
+ it.each([{}, { metadata: {} }, { metadata: null }, { metadata: '' }, { metadata: '{' }, { metadata: '{}', timestamp: 'forged' }, { metadata: '{}', agent_fingerprint: outsider.fingerprint }])('rejects invalid entry input %j', async (body) => {
+ expect((await post(`/v1/logs/${id()}/entries`, body)).status).toBe(400);
+ });
+
+ it('accepts JSON scalar strings and enforces metadata UTF-8 byte limit', async () => {
+ const path = `/v1/logs/${id()}`;
+ await post(path, {});
+ for (const metadata of ['null', '42', 'false', '"text"', '[1,2]', '"' + 'x'.repeat(16382) + '"']) {
+ expect((await post(`${path}/entries`, { metadata })).status).toBe(201);
+ }
+ expect((await post(`${path}/entries`, { metadata: '"' + 'x'.repeat(16383) + '"' })).status).toBe(413);
+ expect((await post(`${path}/entries`, { metadata: '"' + '雪'.repeat(6000) + '"' })).status).toBe(413);
+ });
+
+ it('counts raw bytes for oversized bodies even with understated Content-Length', async () => {
+ const path = `/v1/logs/${id()}`;
+ const req = owner.request(path, { allowed_writers: [], padding: 'a'.repeat(65536) });
+ req.headers.set('Content-Length', '1');
+ expect((await app.request(path, req)).status).toBe(413);
+ });
+
+ it.each(['after=-1', 'after=1.5', 'after=9007199254740992', 'after=01', 'limit=0', 'limit=101', 'limit=1e2', 'after=', 'limit=', 'limit=1&limit=2', 'after=0&after=1', 'unknown=1'])('rejects invalid read query %s', async (query) => {
+ expect((await app.request(`/v1/logs/${id()}/entries?${query}`)).status).toBe(400);
+ });
+
+ it('validates IDs, missing resources, content type and mutation queries', async () => {
+ expect((await post('/v1/logs/bad%3Aid', {})).status).toBe(400);
+ expect((await app.request('/v1/logs/' + 'x'.repeat(129))).status).toBe(400);
+ expect((await app.request('/v1/logs/missing')).status).toBe(404);
+ expect((await app.request('/v1/logs/missing/entries')).status).toBe(404);
+ expect((await post('/v1/logs/missing/entries', { metadata: '{}' })).status).toBe(404);
+ expect((await post('/v1/logs/query?overwrite=true', {})).status).toBe(400);
+ const req = owner.request('/v1/logs/wrong-type', {});
+ req.headers.set('Content-Type', 'text/plain');
+ expect((await app.request('/v1/logs/wrong-type', req)).status).toBe(400);
+ });
+
+ it('keeps public polling and CORS available after monthly publication quota is exhausted', async () => {
+ const path = `/v1/logs/${id()}`;
+ await post(path, {});
+ const headers = { 'User-Agent': randomUUID(), Origin: 'https://visualizer.example' };
+ for (let i = 0; i < config.freeTier.monthlyLimit; i++) expect((await app.request('/v1/quota-check', { headers })).status).toBe(200);
+ expect((await app.request('/v1/quota-check', { headers })).status).toBe(429);
+ const read = await app.request(`${path}/entries`, { headers });
+ expect(read.status).toBe(200);
+ expect(read.headers.get('Access-Control-Allow-Origin')).toBe('*');
+ expect((await app.request(path, { method: 'HEAD', headers })).status).toBe(200);
+ const write = owner.request(`${path}/entries`, { metadata: '{}' });
+ write.headers.set('User-Agent', headers['User-Agent']);
+ expect((await app.request(`${path}/entries`, write)).status).toBe(429);
+ const preflight = await app.request(`${path}/entries`, { method: 'OPTIONS', headers: { ...headers, 'Access-Control-Request-Method': 'POST', 'Access-Control-Request-Headers': 'x-zenbin-public-key,x-zenbin-signature,content-digest' } });
+ expect(preflight.status).toBe(204);
+ expect(preflight.headers.get('Access-Control-Allow-Headers')).toContain('x-zenbin-signature');
+ });
+});
+
+
+describe('Log management API', () => {
+ it('updates ACL with revision checks and transfers only after recipient accepts', async () => {
+ const path = `/v1/logs/${id()}`;
+ await post(path, {});
+ const edited = await post(`${path}/writers`, { allowed_writers: [writer.fingerprint], expected_revision: 0 }, owner, 'PUT');
+ expect(edited.status).toBe(200);
+ expect(await edited.json()).toMatchObject({ revision: 1 });
+ expect((await post(`${path}/entries`, { metadata: '{}' }, writer)).status).toBe(201);
+ expect((await post(`${path}/writers`, { allowed_writers: [], expected_revision: 0 }, owner, 'PUT')).status).toBe(409);
+ expect((await post(`${path}/writers`, { allowed_writers: [], expected_revision: 1 }, writer, 'PUT')).status).toBe(403);
+ expect((await post(`${path}/writers`, { allowed_writers: [], expected_revision: 1 }, owner, 'PUT')).status).toBe(200);
+ expect((await post(`${path}/entries`, { metadata: '{}' }, writer)).status).toBe(403);
+ const nomination = await post(`${path}/transfer`, { new_owner_fingerprint: writer.fingerprint, expected_revision: 2 });
+ expect(nomination.status).toBe(200);
+ expect(await nomination.json()).toMatchObject({ owner_fingerprint: owner.fingerprint, revision: 3, pending_transfer: { new_owner_fingerprint: writer.fingerprint, retain_previous_owner: false } });
+ expect((await post(`${path}/transfer/accept`, { expected_revision: 3 }, outsider)).status).toBe(403);
+ const accepted = await post(`${path}/transfer/accept`, { expected_revision: 3 }, writer);
+ expect(accepted.status).toBe(200);
+ expect(await accepted.json()).toMatchObject({ owner_fingerprint: writer.fingerprint, revision: 4, pending_transfer: null, entry_count: 1 });
+ expect((await post(`${path}/entries`, { metadata: '{}' }, owner)).status).toBe(403);
+ expect((await post(`${path}/writers`, { allowed_writers: [], expected_revision: 4 }, owner, 'PUT')).status).toBe(403);
+ expect((await post(`${path}/entries`, { metadata: '{}' }, writer)).status).toBe(201);
+ });
+ it('requires signed DELETE, cancels pending transfers, and rejects tampered methods', async () => {
+ const path = `/v1/logs/${id()}`;
+ await post(path, {});
+ await post(`${path}/transfer`, { new_owner_fingerprint: writer.fingerprint, expected_revision: 0 });
+ const unsigned = await app.request(`${path}/transfer`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: '{"expected_revision":1}' });
+ expect(unsigned.status).toBe(401);
+ const req = owner.request(`${path}/transfer`, { expected_revision: 1 }, { method: 'PUT' });
+ expect((await app.request(`${path}/transfer`, { ...req, method: 'DELETE' })).status).toBe(401);
+ const cancel = await post(`${path}/transfer`, { expected_revision: 1 }, owner, 'DELETE');
+ expect(cancel.status).toBe(200);
+ expect(await cancel.json()).toMatchObject({ revision: 2, pending_transfer: null });
+ expect((await post(`${path}/transfer/accept`, { expected_revision: 2 }, writer)).status).toBe(409);
+ });
+ it.each([
+ ['PUT', '/writers', {}],
+ ['PUT', '/writers', { allowed_writers: [], expected_revision: -1 }],
+ ['PUT', '/writers', { allowed_writers: [], expected_revision: '0' }],
+ ['PUT', '/writers', { allowed_writers: [], expected_revision: 0.5 }],
+ ['PUT', '/writers', { allowed_writers: [], expected_revision: 9007199254740992 }],
+ ['PUT', '/writers', { allowed_writers: [], expected_revision: 0, extra: true }],
+ ['PUT', '/writers', { allowed_writers: [writer.fingerprint, writer.fingerprint], expected_revision: 0 }],
+ ['POST', '/transfer', { new_owner_fingerprint: 'invalid', expected_revision: 0 }],
+ ['POST', '/transfer', { new_owner_fingerprint: owner.fingerprint, expected_revision: 0 }],
+ ['POST', '/transfer', { new_owner_fingerprint: writer.fingerprint, expected_revision: 0, retain_previous_owner: 'yes' }],
+ ['POST', '/transfer/accept', { expected_revision: null }],
+ ['DELETE', '/transfer', { expected_revision: 0, retain_previous_owner: true }],
+ ])('validates %s %s body %j', async (method, suffix, body) => {
+ expect((await post(`/v1/logs/${id()}${suffix}`, body, owner, method)).status).toBe(400);
+ });
+ it.each(['PUT', 'DELETE'])('supports browser preflight and denies unsigned %s', async (method) => {
+ const target = `/v1/logs/${id()}/${method === 'PUT' ? 'writers' : 'transfer'}`;
+ const response = await app.request(target, { method: 'OPTIONS', headers: { Origin: 'https://visualizer.example', 'Access-Control-Request-Method': method, 'Access-Control-Request-Headers': 'cap-key-id,cap-signature' } });
+ expect(response.status).toBe(204);
+ expect(response.headers.get('Access-Control-Allow-Methods')).toContain(method);
+ expect((await app.request(target, { method, headers: { 'Content-Type': 'application/json' }, body: '{}' })).status).toBe(401);
+ });
+ it('rejects management replay and keeps successful-only nonce semantics', async () => {
+ const path = `/v1/logs/${id()}`;
+ await post(path, {});
+ const edit = owner.request(`${path}/writers`, { allowed_writers: [], expected_revision: 0 }, { method: 'PUT' });
+ edit.headers.set('User-Agent', randomUUID());
+ expect((await app.request(`${path}/writers`, edit)).status).toBe(200);
+ expect((await app.request(`${path}/writers`, edit)).status).toBe(409);
+ });
+});
diff --git a/src/test/setup.ts b/src/test/setup.ts
index 1096f82..90cb0b6 100644
--- a/src/test/setup.ts
+++ b/src/test/setup.ts
@@ -1,10 +1,11 @@
import { beforeAll, afterAll } from 'vitest';
+import { closeLogDatabase } from '../storage/logs.js';
import { initDatabase, closeDatabase } from '../storage/db.js';
import { rmSync } from 'fs';
const TEST_DB_PATH = './data/test.lmdb';
const TEST_VIDEO_PATH = './data/test-videos';
-const TEST_DB_SUFFIXES = ['', '-subdomains', '-custom-domains', '-agent-keys', '-nonces', '-audit', '-owner-index', '-recipient-index'];
+const TEST_DB_SUFFIXES = ['', '-subdomains', '-custom-domains', '-agent-keys', '-nonces', '-audit', '-owner-index', '-recipient-index', '-logs', '-logs-lock'];
beforeAll(() => {
process.env.NODE_ENV = 'test';
@@ -33,6 +34,7 @@ beforeAll(() => {
});
afterAll(async () => {
+ await closeLogDatabase();
await closeDatabase();
// Clean up test database
diff --git a/src/utils/logSignature.ts b/src/utils/logSignature.ts
new file mode 100644
index 0000000..ba14212
--- /dev/null
+++ b/src/utils/logSignature.ts
@@ -0,0 +1,63 @@
+import { createHash } from 'node:crypto';
+import { config } from '../config.js';
+import { getAgentKey } from '../storage/db.js';
+import { computeFingerprint } from './fingerprint.js';
+import { buildCanonicalRequest, verifyEd25519Signature } from './httpSignature.js';
+
+export const logLimits = {
+ bodyBytes: 64 * 1024,
+ metadataBytes: 16 * 1024,
+ writers: 100,
+ entries: 100_000,
+ pageSize: 100,
+} as const;
+
+export interface LogSigner {
+ keyId: string;
+ fingerprint: string;
+ nonce: string;
+ timestamp: number;
+}
+
+export interface LogAuthError {
+ error: string;
+ status: 401 | 403;
+}
+
+/** Log replay records commit with the mutation, not before handler validation. */
+export function verifyLogSignature(method: string, path: string, headers: Headers, body: Uint8Array, now = Date.now()): LogSigner | LogAuthError {
+ const get = (cap: string, legacy: string) => headers.get(cap) || headers.get(legacy) || '';
+ const keyId = get('CAP-Key-Id', 'X-Zenbin-Key-Id');
+ const timestamp = get('CAP-Timestamp', 'X-Zenbin-Timestamp');
+ const nonce = get('CAP-Nonce', 'X-Zenbin-Nonce');
+ const signature = get('CAP-Signature', 'X-Zenbin-Signature');
+ const digest = get('CAP-Digest', 'Content-Digest');
+ const invalid: LogAuthError = { error: 'Valid registered Ed25519 signature required', status: 401 };
+ if (!keyId || keyId.length > 128 || !/^[A-Za-z0-9_-]{16,128}$/.test(nonce) || !/^:[A-Za-z0-9_-]{86}:$/.test(signature)) return invalid;
+ const key = getAgentKey(keyId);
+ if (!key) return invalid;
+ if (key.status !== 'active') return { error: 'Signing key is not active', status: 403 };
+ const time = Date.parse(timestamp);
+ if (!Number.isFinite(time) || Math.abs(now - time) > config.signedPublishing.maxTimestampSkewMs) return invalid;
+ if (digest !== `sha-256=:${createHash('sha256').update(body).digest('base64')}:`) return invalid;
+ const signatureBytes = Buffer.from(signature.slice(1, -1), 'base64url');
+ if (signatureBytes.length !== 64 || signatureBytes.toString('base64url') !== signature.slice(1, -1)) return invalid;
+ try {
+ const canonical = buildCanonicalRequest({ method, path, timestamp, nonce, contentDigest: digest });
+ if (!verifyEd25519Signature({ publicJwk: key.publicJwk, canonical, signature })) return invalid;
+ return { keyId, fingerprint: computeFingerprint(key.publicJwk as { x: string }), nonce, timestamp: time };
+ } catch {
+ return invalid;
+ }
+}
+
+/** Registry lives in another environment; this is a fresh check, not a cross-DB transaction. */
+export function checkLogSigner(signer: LogSigner, now: number): LogAuthError | undefined {
+ if (Math.abs(now - signer.timestamp) > config.signedPublishing.maxTimestampSkewMs) {
+ return { error: 'Signing timestamp is outside the allowed window', status: 401 };
+ }
+ const key = getAgentKey(signer.keyId);
+ if (!key || key.status !== 'active' || computeFingerprint(key.publicJwk as { x: string }) !== signer.fingerprint) {
+ return { error: 'Signing key is not active', status: 403 };
+ }
+}