@oxy.so/protocol 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +16 -0
  3. package/dist/cjs/.tsbuildinfo +1 -0
  4. package/dist/cjs/chain/continuity.js +54 -0
  5. package/dist/cjs/chain/engine.js +34 -0
  6. package/dist/cjs/chain/recordStore.js +25 -0
  7. package/dist/cjs/chain/types.js +22 -0
  8. package/dist/cjs/chain/verify.js +82 -0
  9. package/dist/cjs/envelope/canonicalJson.js +107 -0
  10. package/dist/cjs/envelope/recordId.js +60 -0
  11. package/dist/cjs/envelope/sign.js +75 -0
  12. package/dist/cjs/envelope/signingInput.js +32 -0
  13. package/dist/cjs/identity/resolver.js +50 -0
  14. package/dist/cjs/index.js +71 -0
  15. package/dist/cjs/node/constants.js +85 -0
  16. package/dist/cjs/node/didWebResolver.js +126 -0
  17. package/dist/cjs/node/httpFetch.js +61 -0
  18. package/dist/cjs/node/index.js +71 -0
  19. package/dist/cjs/node/nodeApp.js +344 -0
  20. package/dist/cjs/node/nodeClient.js +204 -0
  21. package/dist/cjs/node/rateLimit.js +187 -0
  22. package/dist/cjs/node/verifyRecord.js +51 -0
  23. package/dist/cjs/platform/crypto.js +186 -0
  24. package/dist/cjs/platform/crypto.native.js +204 -0
  25. package/dist/cjs/platform/expoTypes.js +24 -0
  26. package/dist/cjs/platform/platform.js +33 -0
  27. package/dist/cjs/secp256k1.js +148 -0
  28. package/dist/cjs/transparency/checkpoint.js +79 -0
  29. package/dist/cjs/transparency/tree.js +197 -0
  30. package/dist/esm/.tsbuildinfo +1 -0
  31. package/dist/esm/chain/continuity.js +51 -0
  32. package/dist/esm/chain/engine.js +31 -0
  33. package/dist/esm/chain/recordStore.js +24 -0
  34. package/dist/esm/chain/types.js +19 -0
  35. package/dist/esm/chain/verify.js +78 -0
  36. package/dist/esm/envelope/canonicalJson.js +104 -0
  37. package/dist/esm/envelope/recordId.js +56 -0
  38. package/dist/esm/envelope/sign.js +69 -0
  39. package/dist/esm/envelope/signingInput.js +29 -0
  40. package/dist/esm/identity/resolver.js +47 -0
  41. package/dist/esm/index.js +36 -0
  42. package/dist/esm/node/constants.js +82 -0
  43. package/dist/esm/node/didWebResolver.js +122 -0
  44. package/dist/esm/node/httpFetch.js +55 -0
  45. package/dist/esm/node/index.js +28 -0
  46. package/dist/esm/node/nodeApp.js +336 -0
  47. package/dist/esm/node/nodeClient.js +198 -0
  48. package/dist/esm/node/rateLimit.js +182 -0
  49. package/dist/esm/node/verifyRecord.js +48 -0
  50. package/dist/esm/platform/crypto.js +145 -0
  51. package/dist/esm/platform/crypto.native.js +196 -0
  52. package/dist/esm/platform/expoTypes.js +23 -0
  53. package/dist/esm/platform/platform.js +29 -0
  54. package/dist/esm/secp256k1.js +137 -0
  55. package/dist/esm/transparency/checkpoint.js +73 -0
  56. package/dist/esm/transparency/tree.js +189 -0
  57. package/dist/types/.tsbuildinfo +1 -0
  58. package/dist/types/chain/continuity.d.ts +28 -0
  59. package/dist/types/chain/engine.d.ts +27 -0
  60. package/dist/types/chain/recordStore.d.ts +85 -0
  61. package/dist/types/chain/types.d.ts +79 -0
  62. package/dist/types/chain/verify.d.ts +45 -0
  63. package/dist/types/envelope/canonicalJson.d.ts +44 -0
  64. package/dist/types/envelope/recordId.d.ts +30 -0
  65. package/dist/types/envelope/sign.d.ts +47 -0
  66. package/dist/types/envelope/signingInput.d.ts +33 -0
  67. package/dist/types/identity/resolver.d.ts +67 -0
  68. package/dist/types/index.d.ts +32 -0
  69. package/dist/types/node/constants.d.ts +80 -0
  70. package/dist/types/node/didWebResolver.d.ts +47 -0
  71. package/dist/types/node/httpFetch.d.ts +60 -0
  72. package/dist/types/node/index.d.ts +28 -0
  73. package/dist/types/node/nodeApp.d.ts +120 -0
  74. package/dist/types/node/nodeClient.d.ts +135 -0
  75. package/dist/types/node/rateLimit.d.ts +95 -0
  76. package/dist/types/node/verifyRecord.d.ts +41 -0
  77. package/dist/types/platform/crypto.d.ts +93 -0
  78. package/dist/types/platform/crypto.native.d.ts +77 -0
  79. package/dist/types/platform/expoTypes.d.ts +99 -0
  80. package/dist/types/platform/platform.d.ts +25 -0
  81. package/dist/types/secp256k1.d.ts +45 -0
  82. package/dist/types/transparency/checkpoint.d.ts +71 -0
  83. package/dist/types/transparency/tree.d.ts +135 -0
  84. package/package.json +157 -0
  85. package/src/__tests__/canonicalJson.test.ts +116 -0
  86. package/src/__tests__/chain.test.ts +279 -0
  87. package/src/__tests__/didWebResolver.test.ts +132 -0
  88. package/src/__tests__/envelope.test.ts +267 -0
  89. package/src/__tests__/nodeApp.test.ts +410 -0
  90. package/src/__tests__/nodeClient.test.ts +177 -0
  91. package/src/__tests__/nodeHarness.ts +151 -0
  92. package/src/__tests__/optionalNativePeers.test.ts +233 -0
  93. package/src/__tests__/rateLimit.test.ts +268 -0
  94. package/src/__tests__/runnerGuard.test.ts +85 -0
  95. package/src/__tests__/secp256k1.test.ts +118 -0
  96. package/src/__tests__/transparency.test.ts +353 -0
  97. package/src/chain/continuity.ts +59 -0
  98. package/src/chain/engine.ts +43 -0
  99. package/src/chain/recordStore.ts +98 -0
  100. package/src/chain/types.ts +85 -0
  101. package/src/chain/verify.ts +102 -0
  102. package/src/envelope/canonicalJson.ts +120 -0
  103. package/src/envelope/recordId.ts +63 -0
  104. package/src/envelope/sign.ts +86 -0
  105. package/src/envelope/signingInput.ts +48 -0
  106. package/src/identity/resolver.ts +90 -0
  107. package/src/index.ts +101 -0
  108. package/src/node/constants.ts +105 -0
  109. package/src/node/didWebResolver.ts +162 -0
  110. package/src/node/httpFetch.ts +88 -0
  111. package/src/node/index.ts +87 -0
  112. package/src/node/nodeApp.ts +471 -0
  113. package/src/node/nodeClient.ts +322 -0
  114. package/src/node/rateLimit.ts +233 -0
  115. package/src/node/verifyRecord.ts +60 -0
  116. package/src/platform/crypto.native.ts +251 -0
  117. package/src/platform/crypto.ts +172 -0
  118. package/src/platform/expoTypes.ts +99 -0
  119. package/src/platform/platform.ts +31 -0
  120. package/src/secp256k1.ts +207 -0
  121. package/src/transparency/checkpoint.ts +109 -0
  122. package/src/transparency/tree.ts +258 -0
@@ -0,0 +1,122 @@
1
+ /**
2
+ * `createDidWebResolver` — a {@link VerificationMethodResolver} for arbitrary
3
+ * `did:web` subjects, backing the chain engine on multi-subject relay/ingest
4
+ * paths (where the signer is NOT a local account but any DID that publishes a
5
+ * DID document).
6
+ *
7
+ * It resolves a `did:web:<host>[:<path>]` subject to its `did.json`, fetched via
8
+ * an INJECTED {@link NodeFetch} — Oxy passes an adapter over `@oxy.so/core/server`
9
+ * `safeFetch` (so the SSRF/transport policy stays in core); a test passes a
10
+ * stub. The subject's current verification keys are read from the DID document's
11
+ * `verificationMethod[].publicKeyHex` (the active assertion methods), and the
12
+ * engine's `isAuthorizedKey` then applies the uniform self-issued rule.
13
+ *
14
+ * `did:web` → URL mapping (W3C did:web):
15
+ * - `did:web:example.com` → `https://example.com/.well-known/did.json`
16
+ * - `did:web:example.com:u:123` → `https://example.com/u/123/did.json`
17
+ * - a `%3A` in the host segment is decoded to `:` (an explicit port).
18
+ */
19
+ import { didDocumentSchema, } from '@oxy.so/contracts';
20
+ import { readBoundedJson } from './httpFetch.js';
21
+ import { DEFAULT_CLIENT_MAX_REDIRECTS, DEFAULT_CLIENT_TIMEOUT_MS, DEFAULT_DID_DOC_MAX_BYTES, } from './constants.js';
22
+ /**
23
+ * Map a `did:web` DID to its `did.json` URL, or `null` when `did` is not a
24
+ * well-formed `did:web` identifier.
25
+ */
26
+ export function didWebToUrl(did) {
27
+ const prefix = 'did:web:';
28
+ if (!did.startsWith(prefix)) {
29
+ return null;
30
+ }
31
+ const msi = did.slice(prefix.length);
32
+ if (msi.length === 0) {
33
+ return null;
34
+ }
35
+ const [domainPart, ...pathParts] = msi.split(':');
36
+ const host = domainPart.replace(/%3A/gi, ':');
37
+ if (host.length === 0 || host.includes('/')) {
38
+ return null;
39
+ }
40
+ const base = `https://${host}`;
41
+ if (pathParts.length === 0) {
42
+ return `${base}/.well-known/did.json`;
43
+ }
44
+ if (pathParts.some((part) => part.length === 0)) {
45
+ return null;
46
+ }
47
+ return `${base}/${pathParts.join('/')}/did.json`;
48
+ }
49
+ /** True for a secp256k1 verification method (carries `publicKeyHex`). */
50
+ function isSecp256k1Vm(vm) {
51
+ return vm.type === 'EcdsaSecp256k1VerificationKey2019';
52
+ }
53
+ /**
54
+ * Collect the subject's current verification keys from its DID document: the
55
+ * `publicKeyHex` of every secp256k1 verification method referenced by
56
+ * `assertionMethod` (the keys that may sign assertions/records), deduped. Falls
57
+ * back to ALL secp256k1 `verificationMethod[]` keys when `assertionMethod`
58
+ * references nothing local. Non-secp256k1 methods (e.g. the atproto `Multikey`,
59
+ * which carries the SAME key in multibase form, not hex) are skipped — record
60
+ * signatures verify against the hex key.
61
+ */
62
+ function collectCurrentPublicKeys(doc) {
63
+ const byId = new Map(doc.verificationMethod
64
+ .filter(isSecp256k1Vm)
65
+ .map((vm) => [vm.id, vm.publicKeyHex]));
66
+ const keys = [];
67
+ for (const id of doc.assertionMethod) {
68
+ const key = byId.get(id);
69
+ if (key && !keys.includes(key)) {
70
+ keys.push(key);
71
+ }
72
+ }
73
+ if (keys.length === 0) {
74
+ for (const key of byId.values()) {
75
+ if (!keys.includes(key)) {
76
+ keys.push(key);
77
+ }
78
+ }
79
+ }
80
+ return keys;
81
+ }
82
+ /**
83
+ * Build a {@link VerificationMethodResolver} that resolves `did:web` subjects via
84
+ * the injected `fetch`. Returns `null` for any subject that is not a `did:web`
85
+ * DID, whose `did.json` cannot be fetched, or whose document fails schema
86
+ * validation — the engine then treats the signer as unauthorized.
87
+ */
88
+ export function createDidWebResolver(fetch, options = {}) {
89
+ const headersTimeoutMs = options.headersTimeoutMs ?? DEFAULT_CLIENT_TIMEOUT_MS;
90
+ const maxRedirects = options.maxRedirects ?? DEFAULT_CLIENT_MAX_REDIRECTS;
91
+ const maxBytes = options.maxBytes ?? DEFAULT_DID_DOC_MAX_BYTES;
92
+ return {
93
+ async resolve(subjectDid) {
94
+ const url = didWebToUrl(subjectDid);
95
+ if (!url) {
96
+ return null;
97
+ }
98
+ try {
99
+ const res = await fetch(url, { method: 'GET', headersTimeoutMs, maxRedirects });
100
+ if (res.status < 200 || res.status >= 300) {
101
+ res.destroy();
102
+ return null;
103
+ }
104
+ const body = await readBoundedJson(res, maxBytes);
105
+ const parsed = didDocumentSchema.safeParse(body);
106
+ if (!parsed.success) {
107
+ return null;
108
+ }
109
+ // The DID document `id` MUST match the subject we asked for — a document
110
+ // served at the subject's URL but claiming another id is not authoritative.
111
+ if (parsed.data.id !== subjectDid) {
112
+ return null;
113
+ }
114
+ return { currentPublicKeys: collectCurrentPublicKeys(parsed.data) };
115
+ }
116
+ catch (err) {
117
+ options.onError?.(err, subjectDid);
118
+ return null;
119
+ }
120
+ },
121
+ };
122
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The injected HTTP transport the node {@link ./nodeClient.NodeClient} and the
3
+ * {@link ./didWebResolver} drive — plus the bounded-read helpers that make a
4
+ * response stream safe to consume.
5
+ *
6
+ * The protocol package is app-agnostic and MUST NOT depend on `@oxy.so/core`
7
+ * (core depends on protocol). So instead of importing `@oxy.so/core/server`'s
8
+ * `safeFetch` directly, the node client/resolver accept a {@link NodeFetch} —
9
+ * Oxy supplies an adapter over `safeFetch` (HTTPS-only, DNS-pinned, private-IP
10
+ * denylist, bounded redirects); a test supplies an in-process stub. Either way
11
+ * the SSRF/transport policy stays in the injected implementation, and the
12
+ * bounded body read (the cap that stops a malicious node streaming forever)
13
+ * stays here, close to the parsing.
14
+ *
15
+ * A Node `IncomingMessage` (what `safeFetch` returns) satisfies
16
+ * {@link NodeFetchResponse.body} directly — it is an `AsyncIterable<Buffer>`,
17
+ * and `Buffer` is a `Uint8Array`.
18
+ */
19
+ /** Thrown when a response body exceeds the caller's byte ceiling. */
20
+ export class ResponseTooLargeError extends Error {
21
+ constructor(maxBytes) {
22
+ super(`response exceeded ${maxBytes} bytes`);
23
+ this.maxBytes = maxBytes;
24
+ this.name = 'ResponseTooLargeError';
25
+ }
26
+ }
27
+ /**
28
+ * Read a response body into a single buffer, aborting (and destroying the
29
+ * stream) the moment it exceeds `maxBytes`. The bound is the defence against a
30
+ * node that streams an unbounded body.
31
+ */
32
+ export async function readBoundedBytes(res, maxBytes) {
33
+ const chunks = [];
34
+ let total = 0;
35
+ try {
36
+ for await (const chunk of res.body) {
37
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
38
+ total += buf.length;
39
+ if (total > maxBytes) {
40
+ throw new ResponseTooLargeError(maxBytes);
41
+ }
42
+ chunks.push(buf);
43
+ }
44
+ }
45
+ catch (err) {
46
+ res.destroy();
47
+ throw err;
48
+ }
49
+ return Buffer.concat(chunks);
50
+ }
51
+ /** Read a bounded response body and parse it as JSON. */
52
+ export async function readBoundedJson(res, maxBytes) {
53
+ const bytes = await readBoundedBytes(res, maxBytes);
54
+ return JSON.parse(bytes.toString('utf8'));
55
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `@oxy.so/protocol/node` — the runnable node substrate.
3
+ *
4
+ * The Node-only half of the protocol: the Express app factory that backs any
5
+ * Oxy-protocol data node ({@link createNodeApp}), the HTTP {@link NodeClient}
6
+ * that drives a node's routes, the `did:web` verification-method resolver, the
7
+ * record verifier, and the node-protocol shape constants. A SEPARATE subpath
8
+ * from the package root so this Express/Node-only code never enters React
9
+ * Native / web bundles that import `@oxy.so/protocol`.
10
+ *
11
+ * Reused by `@oxy.so/node` (the runnable node), a future `mention-node` (an
12
+ * env-only deployment of the same base), and oxy-api's node sync (which drives
13
+ * a node via `NodeClient`).
14
+ */
15
+ // ── Express app factory ───────────────────────────────────────────────────────
16
+ export { createNodeApp, BlobHashMismatchError } from './nodeApp.js';
17
+ // ── Per-IP write rate limiter ──────────────────────────────────────────────────
18
+ export { createRateLimiter, DEFAULT_WRITE_RATE_LIMIT, DEFAULT_MAX_RATE_LIMIT_ENTRIES, } from './rateLimit.js';
19
+ // ── Record verification (signature + v2 + content address) ─────────────────────
20
+ export { verifyNodeRecordEnvelope } from './verifyRecord.js';
21
+ // ── HTTP client ────────────────────────────────────────────────────────────────
22
+ export { NodeClient, NodeClientError, trimTrailingSlashes } from './nodeClient.js';
23
+ // ── Injected transport contract + bounded readers ──────────────────────────────
24
+ export { readBoundedBytes, readBoundedJson, ResponseTooLargeError } from './httpFetch.js';
25
+ // ── did:web verification-method resolver ───────────────────────────────────────
26
+ export { createDidWebResolver, didWebToUrl } from './didWebResolver.js';
27
+ // ── Node-protocol shape constants ──────────────────────────────────────────────
28
+ export { PROTOCOL_VERSION, DEFAULT_WELL_KNOWN_PATH, DEFAULT_SERVICE_TYPE, DEFAULT_APP_NAMESPACE, DEFAULT_PORT, DEFAULT_LOG_LIMIT, MAX_LOG_LIMIT, DEFAULT_MAX_BLOB_BYTES, MAX_SYNC_BATCH, JSON_BODY_LIMIT, OWNER_AUTH_HEADERS, OWNER_AUTH_MAX_AGE_MS, NODE_MODES, OWNER_ACTION_BLOB_PIN, SHA256_HEX, NODE_HEAD_PATH, NODE_LOG_PATH, NODE_RECORDS_PATH, NODE_SYNC_PUSH_PATH, NODE_BLOBS_PATH, DEFAULT_CLIENT_TIMEOUT_MS, DEFAULT_CLIENT_MAX_REDIRECTS, DEFAULT_HEAD_MAX_BYTES, DEFAULT_LOG_MAX_BYTES, DEFAULT_WRITE_RESPONSE_MAX_BYTES, DEFAULT_DID_DOC_MAX_BYTES, } from './constants.js';
@@ -0,0 +1,336 @@
1
+ /**
2
+ * `createNodeApp` — the app-agnostic Express factory for an Oxy-protocol data
3
+ * node. A node stores and serves ONE owner's append-only signed-record log
4
+ * (their "personal repo") plus the content-addressed blobs the records point at.
5
+ *
6
+ * This is the engine extracted from `@oxy.so/node` so the SAME code can back many
7
+ * app-node deployments (the Oxy identity node, a future Mention node) that
8
+ * differ only by ENV: the namespace they serve, their well-known manifest path,
9
+ * their advertised protocol id + service-type, and their owner key. Everything
10
+ * app-specific is INJECTED:
11
+ *
12
+ * - `store` — a {@link RecordStore} + {@link BlobStore} (the node's SQLite
13
+ * store, or a test stub). The node holds exactly one subject's
14
+ * repo, so the store keys a single global chain and ignores the
15
+ * subject argument; `createNodeApp` passes the node's own key as
16
+ * a stable sentinel.
17
+ * - `ownerAuth` — the single write authority. Records and blob pins are
18
+ * authorized against the node's configured owner key. This is
19
+ * injected (rather than importing `@oxy.so/core/server`) so the
20
+ * protocol package never depends on core.
21
+ * - `config` — the wire-shape knobs (well-known path, protocol id,
22
+ * service-type, mode, blob ceiling, collection allowlist).
23
+ * - `logger` — structured logging for the terminal error handler.
24
+ *
25
+ * Endpoints:
26
+ * - `GET <wellKnownPath>` — node identity + liveness (a probe target).
27
+ * - `GET /oxy/head` — chain head `{ seq, headRecordId, recordCount }`.
28
+ * - `GET /oxy/log` — ordered envelopes from a cursor (ingest).
29
+ * - `POST /records` — owner writes a single signed envelope.
30
+ * - `POST /sync/push` — owner pushes a batch of signed envelopes.
31
+ * - `GET /blobs/:hash` — serve a content-addressed blob.
32
+ * - `PUT /blobs/:hash` — owner pins a blob (signed-header auth).
33
+ * - `GET /health` — container liveness.
34
+ */
35
+ import express from 'express';
36
+ import { computeRecordId } from '../envelope/recordId.js';
37
+ import { createRateLimiter, DEFAULT_WRITE_RATE_LIMIT } from './rateLimit.js';
38
+ import { verifyNodeRecordEnvelope } from './verifyRecord.js';
39
+ import { DEFAULT_LOG_LIMIT, JSON_BODY_LIMIT, MAX_LOG_LIMIT, MAX_SYNC_BATCH, NODE_BLOBS_PATH, NODE_HEAD_PATH, NODE_LOG_PATH, NODE_RECORDS_PATH, NODE_SYNC_PUSH_PATH, OWNER_AUTH_HEADERS, SHA256_HEX, } from './constants.js';
40
+ /**
41
+ * Thrown by a {@link BlobStore.putBlob} implementation when bytes do not hash to
42
+ * the supplied address. Defined here (rather than in `@oxy.so/node`) so the node
43
+ * app can map it to `hash_mismatch` without importing the store implementation.
44
+ */
45
+ export class BlobHashMismatchError extends Error {
46
+ constructor(expected, actual) {
47
+ super(`blob hash mismatch: expected ${expected}, computed ${actual}`);
48
+ this.expected = expected;
49
+ this.actual = actual;
50
+ this.name = 'BlobHashMismatchError';
51
+ }
52
+ }
53
+ /**
54
+ * Coerce a raw Express query value (`string | string[] | ParsedQs | undefined`)
55
+ * to a single string. A repeated/array param (`?since[]=a&since[]=b`) yields a
56
+ * non-string under qs; take its first string element so a tampered array can
57
+ * never reach a `typeof === 'string'` check as a non-string and cause type
58
+ * confusion. Returns `undefined` for anything that is not a string or a
59
+ * string-first array.
60
+ */
61
+ function firstQueryValue(raw) {
62
+ if (typeof raw === 'string') {
63
+ return raw;
64
+ }
65
+ if (Array.isArray(raw) && raw.length > 0 && typeof raw[0] === 'string') {
66
+ return raw[0];
67
+ }
68
+ return undefined;
69
+ }
70
+ /** Clamp the `limit` query param into `[1, MAX_LOG_LIMIT]`, defaulting when absent/invalid. */
71
+ function parseLimit(raw) {
72
+ const value = firstQueryValue(raw);
73
+ if (value === undefined || value.trim() === '') {
74
+ return DEFAULT_LOG_LIMIT;
75
+ }
76
+ const parsed = Number(value);
77
+ if (!Number.isInteger(parsed) || parsed <= 0) {
78
+ return DEFAULT_LOG_LIMIT;
79
+ }
80
+ return Math.min(parsed, MAX_LOG_LIMIT);
81
+ }
82
+ /** HTTP status for a chain-append rejection. */
83
+ function appendStatus(reason) {
84
+ return reason === 'chain_conflict' ? 409 : 422;
85
+ }
86
+ /** True when `collection` is writable under the node's allowlist (empty = all). */
87
+ function isCollectionAllowed(config, collection) {
88
+ return config.collections.length === 0 || config.collections.includes(collection);
89
+ }
90
+ /**
91
+ * Resolve the `/oxy/log` `since` query param to an exclusive lower-bound `seq`.
92
+ * Returns `null` when the page should be EMPTY (an unknown `recordId` cursor or
93
+ * an unrecognized cursor shape). Mirrors the legacy in-store cursor resolver,
94
+ * now split across the protocol `RecordStore` (numeric `getLogSince` +
95
+ * `resolveCursorSeq`).
96
+ */
97
+ async function resolveSinceSeq(store, subject, since) {
98
+ if (since === undefined || since === '') {
99
+ return -1;
100
+ }
101
+ const lower = since.toLowerCase();
102
+ if (SHA256_HEX.test(lower)) {
103
+ return store.resolveCursorSeq(subject, lower);
104
+ }
105
+ if (/^\d+$/.test(since)) {
106
+ const parsed = Number(since);
107
+ return Number.isSafeInteger(parsed) ? parsed : -1;
108
+ }
109
+ return null;
110
+ }
111
+ /** Map a stored envelope to the `/oxy/log` wire record. */
112
+ async function toLogWireRecord(env) {
113
+ return {
114
+ seq: env.seq ?? 0,
115
+ recordId: await computeRecordId(env),
116
+ prev: env.prev ?? null,
117
+ issuedAt: env.issuedAt,
118
+ envelope: env,
119
+ };
120
+ }
121
+ export function createNodeApp(deps) {
122
+ const { store, config, ownerAuth, logger } = deps;
123
+ // The node holds one subject's repo; the store keys a single global chain and
124
+ // ignores the subject argument. Pass the node's own key as a stable sentinel.
125
+ const subject = config.nodePublicKey;
126
+ const app = express();
127
+ app.disable('x-powered-by');
128
+ // JSON parser applies only to JSON bodies (it checks Content-Type), so the raw
129
+ // blob upload below is untouched by it.
130
+ const jsonParser = express.json({ limit: JSON_BODY_LIMIT });
131
+ // Per-IP rate limiter on the owner-authorized write routes. Caps request rate
132
+ // BEFORE signature verification so a flood of bogus envelopes can't pin CPU.
133
+ // It owns a background sweep timer; expose its teardown as `app.stop()`.
134
+ const writeRateLimit = createRateLimiter(config.writeRateLimit ?? DEFAULT_WRITE_RATE_LIMIT);
135
+ app.stop = () => writeRateLimit.stop();
136
+ app.get('/health', (_req, res) => {
137
+ res.json({ status: 'ok' });
138
+ });
139
+ // ── Node identity / liveness ────────────────────────────────────────────────
140
+ app.get(config.wellKnownPath, async (_req, res, next) => {
141
+ try {
142
+ const head = await store.getHead(subject);
143
+ res.json({
144
+ nodePublicKey: config.nodePublicKey,
145
+ mode: config.mode,
146
+ version: config.protocolId,
147
+ serviceType: config.serviceType,
148
+ head: head && head.headRecordId !== null ? { seq: head.seq, headRecordId: head.headRecordId } : null,
149
+ });
150
+ }
151
+ catch (error) {
152
+ next(error);
153
+ }
154
+ });
155
+ // ── Chain head ──────────────────────────────────────────────────────────────
156
+ app.get(NODE_HEAD_PATH, async (_req, res, next) => {
157
+ try {
158
+ const head = await store.getHead(subject);
159
+ if (!head || head.headRecordId === null) {
160
+ res.json({ seq: null, headRecordId: null, recordCount: 0 });
161
+ return;
162
+ }
163
+ res.json({ seq: head.seq, headRecordId: head.headRecordId, recordCount: head.recordCount });
164
+ }
165
+ catch (error) {
166
+ next(error);
167
+ }
168
+ });
169
+ // ── Ordered log (ingest) ──────────────────────────────────────────────────────
170
+ app.get(NODE_LOG_PATH, async (req, res, next) => {
171
+ try {
172
+ const since = firstQueryValue(req.query.since);
173
+ const limit = parseLimit(req.query.limit);
174
+ const head = await store.getHead(subject);
175
+ const headWire = head && head.headRecordId !== null ? { seq: head.seq, headRecordId: head.headRecordId } : null;
176
+ const sinceSeq = await resolveSinceSeq(store, subject, since);
177
+ if (sinceSeq === null) {
178
+ res.json({ records: [], count: 0, head: headWire });
179
+ return;
180
+ }
181
+ const envelopes = await store.getLogSince(subject, sinceSeq, limit);
182
+ const records = await Promise.all(envelopes.map(toLogWireRecord));
183
+ res.json({ records, count: records.length, head: headWire });
184
+ }
185
+ catch (error) {
186
+ next(error);
187
+ }
188
+ });
189
+ // ── Owner write: a single signed envelope ────────────────────────────────────
190
+ app.post(NODE_RECORDS_PATH, writeRateLimit, jsonParser, async (req, res, next) => {
191
+ try {
192
+ const verification = await verifyNodeRecordEnvelope(req.body);
193
+ if (!verification.ok) {
194
+ res.status(400).json({ error: verification.reason });
195
+ return;
196
+ }
197
+ if (!ownerAuth.isOwnerKey(verification.envelope.publicKey)) {
198
+ res.status(403).json({ error: 'not_owner' });
199
+ return;
200
+ }
201
+ if (!isCollectionAllowed(config, verification.envelope.collection ?? '')) {
202
+ res.status(403).json({ error: 'foreign_collection' });
203
+ return;
204
+ }
205
+ const outcome = await store.append(subject, verification.envelope, verification.recordId);
206
+ if (!outcome.ok) {
207
+ res.status(appendStatus(outcome.reason)).json({ error: outcome.reason });
208
+ return;
209
+ }
210
+ res.status(201).json({ recordId: outcome.recordId, seq: outcome.seq });
211
+ }
212
+ catch (error) {
213
+ next(error);
214
+ }
215
+ });
216
+ // ── Owner write: a batch push (verified + appended in order) ──────────────────
217
+ app.post(NODE_SYNC_PUSH_PATH, writeRateLimit, jsonParser, async (req, res, next) => {
218
+ try {
219
+ const body = req.body;
220
+ const items = typeof body === 'object' && body !== null && Array.isArray(body.records)
221
+ ? body.records
222
+ : null;
223
+ if (!items) {
224
+ res.status(400).json({ error: 'invalid_batch' });
225
+ return;
226
+ }
227
+ if (items.length > MAX_SYNC_BATCH) {
228
+ res.status(400).json({ error: 'batch_too_large' });
229
+ return;
230
+ }
231
+ const results = [];
232
+ for (const item of items) {
233
+ const verification = await verifyNodeRecordEnvelope(item);
234
+ if (!verification.ok) {
235
+ results.push({ ok: false, reason: verification.reason });
236
+ continue;
237
+ }
238
+ if (!ownerAuth.isOwnerKey(verification.envelope.publicKey)) {
239
+ results.push({ ok: false, reason: 'not_owner' });
240
+ continue;
241
+ }
242
+ if (!isCollectionAllowed(config, verification.envelope.collection ?? '')) {
243
+ results.push({ ok: false, reason: 'foreign_collection' });
244
+ continue;
245
+ }
246
+ const outcome = await store.append(subject, verification.envelope, verification.recordId);
247
+ results.push(outcome.ok
248
+ ? { ok: true, recordId: outcome.recordId, seq: outcome.seq }
249
+ : { ok: false, reason: outcome.reason });
250
+ }
251
+ const accepted = results.filter((result) => result.ok).length;
252
+ res.json({ accepted, results });
253
+ }
254
+ catch (error) {
255
+ next(error);
256
+ }
257
+ });
258
+ // ── Serve a content-addressed blob ───────────────────────────────────────────
259
+ app.get(`${NODE_BLOBS_PATH}/:hash`, async (req, res, next) => {
260
+ try {
261
+ const hash = req.params.hash.toLowerCase();
262
+ if (!SHA256_HEX.test(hash)) {
263
+ res.status(400).json({ error: 'invalid_hash' });
264
+ return;
265
+ }
266
+ const bytes = await store.getBlob(hash);
267
+ if (!bytes) {
268
+ res.status(404).json({ error: 'not_found' });
269
+ return;
270
+ }
271
+ res.setHeader('Content-Type', 'application/octet-stream');
272
+ // Content-addressed → immutable, safe to cache aggressively.
273
+ res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
274
+ res.send(Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes));
275
+ }
276
+ catch (error) {
277
+ next(error);
278
+ }
279
+ });
280
+ // ── Owner pins a blob (signed-header authorization) ──────────────────────────
281
+ app.put(`${NODE_BLOBS_PATH}/:hash`, writeRateLimit, express.raw({ type: () => true, limit: config.maxBlobBytes }), async (req, res, next) => {
282
+ try {
283
+ const rawHash = req.params.hash;
284
+ if (typeof rawHash !== 'string') {
285
+ res.status(400).json({ error: 'invalid_hash' });
286
+ return;
287
+ }
288
+ const hash = rawHash.toLowerCase();
289
+ if (!SHA256_HEX.test(hash)) {
290
+ res.status(400).json({ error: 'invalid_hash' });
291
+ return;
292
+ }
293
+ const publicKey = req.header(OWNER_AUTH_HEADERS.publicKey);
294
+ const signature = req.header(OWNER_AUTH_HEADERS.signature);
295
+ const timestampRaw = req.header(OWNER_AUTH_HEADERS.timestamp);
296
+ if (!publicKey || !signature || !timestampRaw) {
297
+ res.status(401).json({ error: 'missing_owner_auth' });
298
+ return;
299
+ }
300
+ const authorized = await ownerAuth.verifyBlobPin(hash, {
301
+ publicKey,
302
+ signature,
303
+ timestamp: Number(timestampRaw),
304
+ });
305
+ if (!authorized) {
306
+ res.status(403).json({ error: 'unauthorized' });
307
+ return;
308
+ }
309
+ const bytes = req.body;
310
+ if (!Buffer.isBuffer(bytes) || bytes.length === 0) {
311
+ res.status(400).json({ error: 'empty_blob' });
312
+ return;
313
+ }
314
+ try {
315
+ await store.putBlob(hash, bytes);
316
+ }
317
+ catch (error) {
318
+ if (error instanceof BlobHashMismatchError) {
319
+ res.status(400).json({ error: 'hash_mismatch' });
320
+ return;
321
+ }
322
+ throw error;
323
+ }
324
+ res.status(201).json({ hash, size: bytes.length });
325
+ }
326
+ catch (error) {
327
+ next(error);
328
+ }
329
+ });
330
+ // ── Terminal error handler ───────────────────────────────────────────────────
331
+ app.use((error, _req, res, _next) => {
332
+ logger.error({ err: error }, 'unhandled request error');
333
+ res.status(500).json({ error: 'internal_error' });
334
+ });
335
+ return app;
336
+ }