@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,471 @@
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
+
36
+ import express, { type Express, type NextFunction, type Request, type Response } from 'express';
37
+ import type { SignedRecordEnvelope } from '@oxy.so/contracts';
38
+ import { computeRecordId } from '../envelope/recordId';
39
+ import type { RecordStore, BlobStore } from '../chain/recordStore';
40
+ import { createRateLimiter, DEFAULT_WRITE_RATE_LIMIT, type RateLimitConfig } from './rateLimit';
41
+ import { verifyNodeRecordEnvelope } from './verifyRecord';
42
+ import {
43
+ DEFAULT_LOG_LIMIT,
44
+ JSON_BODY_LIMIT,
45
+ MAX_LOG_LIMIT,
46
+ MAX_SYNC_BATCH,
47
+ NODE_BLOBS_PATH,
48
+ NODE_HEAD_PATH,
49
+ NODE_LOG_PATH,
50
+ NODE_RECORDS_PATH,
51
+ NODE_SYNC_PUSH_PATH,
52
+ OWNER_AUTH_HEADERS,
53
+ SHA256_HEX,
54
+ } from './constants';
55
+
56
+ /**
57
+ * The Express app returned by {@link createNodeApp}, augmented with a {@link stop}
58
+ * hook that releases the app's background resources (currently the rate-limiter's
59
+ * sweep timer). The node bootstrap calls it from the graceful-shutdown path.
60
+ */
61
+ export interface NodeApp extends Express {
62
+ /**
63
+ * Release the app's background resources (the rate-limiter sweep timer).
64
+ * Idempotent and safe to call on shutdown; not required for process exit (the
65
+ * timer is `unref()`'d) but keeps long-lived test harnesses leak-free.
66
+ */
67
+ stop(): void;
68
+ }
69
+
70
+ /**
71
+ * Thrown by a {@link BlobStore.putBlob} implementation when bytes do not hash to
72
+ * the supplied address. Defined here (rather than in `@oxy.so/node`) so the node
73
+ * app can map it to `hash_mismatch` without importing the store implementation.
74
+ */
75
+ export class BlobHashMismatchError extends Error {
76
+ constructor(
77
+ public readonly expected: string,
78
+ public readonly actual: string,
79
+ ) {
80
+ super(`blob hash mismatch: expected ${expected}, computed ${actual}`);
81
+ this.name = 'BlobHashMismatchError';
82
+ }
83
+ }
84
+
85
+ /** The store a node app drives — the chain log plus the blob store. */
86
+ export type NodeStoreLike = RecordStore & BlobStore;
87
+
88
+ /**
89
+ * The owner authority for node writes (the single write principal). Injected so
90
+ * the protocol package stays free of `@oxy.so/core` — `@oxy.so/node` provides an
91
+ * implementation bound to its configured owner key.
92
+ */
93
+ export interface OwnerAuth {
94
+ /** True iff `publicKey` is the node's configured owner key (constant-time). */
95
+ isOwnerKey(publicKey: string): boolean;
96
+ /**
97
+ * Verify an owner-signed authorization for a blob pin over `hash` (a fresh
98
+ * signed header proving control, since the body is raw bytes not an envelope).
99
+ */
100
+ verifyBlobPin(
101
+ hash: string,
102
+ auth: { publicKey: string; signature: string; timestamp: number },
103
+ ): Promise<boolean>;
104
+ }
105
+
106
+ /** The wire-shape configuration a node app advertises + enforces. */
107
+ export interface NodeAppConfig {
108
+ /** Path the liveness manifest is served at (e.g. `/.well-known/oxy-node.json`). */
109
+ readonly wellKnownPath: string;
110
+ /** Node-protocol id advertised as `version` in the manifest (e.g. `oxy-node/1`). */
111
+ readonly protocolId: string;
112
+ /** Service-type label advertised in the manifest (e.g. `OxyPersonalDataNode`). */
113
+ readonly serviceType: string;
114
+ /** Operating mode advertised in the manifest (`self-hosted` / `managed`). */
115
+ readonly mode: string;
116
+ /** The node's advertised public key (its single-chain subject sentinel). */
117
+ readonly nodePublicKey: string;
118
+ /** Upper bound on a single pinned blob, in bytes. */
119
+ readonly maxBlobBytes: number;
120
+ /**
121
+ * Collection allowlist. EMPTY = accept any collection (the existing Oxy node
122
+ * behaviour). NON-EMPTY = only these collections may be written (else
123
+ * `foreign_collection`) and the public log is filtered to them.
124
+ */
125
+ readonly collections: readonly string[];
126
+ /**
127
+ * Per-IP rate budget for the owner-authorized WRITE routes (`POST /records`,
128
+ * `POST /sync/push`, `PUT /blobs/:hash`). Defence-in-depth on the
129
+ * unauthenticated edge, capping request rate BEFORE signature verification.
130
+ * Defaults to {@link DEFAULT_WRITE_RATE_LIMIT} (60/min) — generous for the
131
+ * single-writer model.
132
+ */
133
+ readonly writeRateLimit?: RateLimitConfig;
134
+ }
135
+
136
+ /** Minimal structured logger (a pino `Logger` satisfies this structurally). */
137
+ export interface NodeLogger {
138
+ error(obj: object, msg?: string): void;
139
+ }
140
+
141
+ export interface NodeAppDependencies {
142
+ store: NodeStoreLike;
143
+ config: NodeAppConfig;
144
+ ownerAuth: OwnerAuth;
145
+ logger: NodeLogger;
146
+ }
147
+
148
+ /**
149
+ * Coerce a raw Express query value (`string | string[] | ParsedQs | undefined`)
150
+ * to a single string. A repeated/array param (`?since[]=a&since[]=b`) yields a
151
+ * non-string under qs; take its first string element so a tampered array can
152
+ * never reach a `typeof === 'string'` check as a non-string and cause type
153
+ * confusion. Returns `undefined` for anything that is not a string or a
154
+ * string-first array.
155
+ */
156
+ function firstQueryValue(raw: unknown): string | undefined {
157
+ if (typeof raw === 'string') {
158
+ return raw;
159
+ }
160
+ if (Array.isArray(raw) && raw.length > 0 && typeof raw[0] === 'string') {
161
+ return raw[0];
162
+ }
163
+ return undefined;
164
+ }
165
+
166
+ /** Clamp the `limit` query param into `[1, MAX_LOG_LIMIT]`, defaulting when absent/invalid. */
167
+ function parseLimit(raw: unknown): number {
168
+ const value = firstQueryValue(raw);
169
+ if (value === undefined || value.trim() === '') {
170
+ return DEFAULT_LOG_LIMIT;
171
+ }
172
+ const parsed = Number(value);
173
+ if (!Number.isInteger(parsed) || parsed <= 0) {
174
+ return DEFAULT_LOG_LIMIT;
175
+ }
176
+ return Math.min(parsed, MAX_LOG_LIMIT);
177
+ }
178
+
179
+ /** HTTP status for a chain-append rejection. */
180
+ function appendStatus(reason: string): number {
181
+ return reason === 'chain_conflict' ? 409 : 422;
182
+ }
183
+
184
+ /** True when `collection` is writable under the node's allowlist (empty = all). */
185
+ function isCollectionAllowed(config: NodeAppConfig, collection: string): boolean {
186
+ return config.collections.length === 0 || config.collections.includes(collection);
187
+ }
188
+
189
+ /**
190
+ * Resolve the `/oxy/log` `since` query param to an exclusive lower-bound `seq`.
191
+ * Returns `null` when the page should be EMPTY (an unknown `recordId` cursor or
192
+ * an unrecognized cursor shape). Mirrors the legacy in-store cursor resolver,
193
+ * now split across the protocol `RecordStore` (numeric `getLogSince` +
194
+ * `resolveCursorSeq`).
195
+ */
196
+ async function resolveSinceSeq(
197
+ store: RecordStore,
198
+ subject: string,
199
+ since: string | undefined,
200
+ ): Promise<number | null> {
201
+ if (since === undefined || since === '') {
202
+ return -1;
203
+ }
204
+ const lower = since.toLowerCase();
205
+ if (SHA256_HEX.test(lower)) {
206
+ return store.resolveCursorSeq(subject, lower);
207
+ }
208
+ if (/^\d+$/.test(since)) {
209
+ const parsed = Number(since);
210
+ return Number.isSafeInteger(parsed) ? parsed : -1;
211
+ }
212
+ return null;
213
+ }
214
+
215
+ /** Map a stored envelope to the `/oxy/log` wire record. */
216
+ async function toLogWireRecord(env: SignedRecordEnvelope): Promise<{
217
+ seq: number;
218
+ recordId: string;
219
+ prev: string | null;
220
+ issuedAt: number;
221
+ envelope: SignedRecordEnvelope;
222
+ }> {
223
+ return {
224
+ seq: env.seq ?? 0,
225
+ recordId: await computeRecordId(env),
226
+ prev: env.prev ?? null,
227
+ issuedAt: env.issuedAt,
228
+ envelope: env,
229
+ };
230
+ }
231
+
232
+ export function createNodeApp(deps: NodeAppDependencies): NodeApp {
233
+ const { store, config, ownerAuth, logger } = deps;
234
+ // The node holds one subject's repo; the store keys a single global chain and
235
+ // ignores the subject argument. Pass the node's own key as a stable sentinel.
236
+ const subject = config.nodePublicKey;
237
+
238
+ const app = express() as NodeApp;
239
+ app.disable('x-powered-by');
240
+
241
+ // JSON parser applies only to JSON bodies (it checks Content-Type), so the raw
242
+ // blob upload below is untouched by it.
243
+ const jsonParser = express.json({ limit: JSON_BODY_LIMIT });
244
+
245
+ // Per-IP rate limiter on the owner-authorized write routes. Caps request rate
246
+ // BEFORE signature verification so a flood of bogus envelopes can't pin CPU.
247
+ // It owns a background sweep timer; expose its teardown as `app.stop()`.
248
+ const writeRateLimit = createRateLimiter(config.writeRateLimit ?? DEFAULT_WRITE_RATE_LIMIT);
249
+ app.stop = (): void => writeRateLimit.stop();
250
+
251
+ app.get('/health', (_req: Request, res: Response) => {
252
+ res.json({ status: 'ok' });
253
+ });
254
+
255
+ // ── Node identity / liveness ────────────────────────────────────────────────
256
+ app.get(config.wellKnownPath, async (_req: Request, res: Response, next: NextFunction) => {
257
+ try {
258
+ const head = await store.getHead(subject);
259
+ res.json({
260
+ nodePublicKey: config.nodePublicKey,
261
+ mode: config.mode,
262
+ version: config.protocolId,
263
+ serviceType: config.serviceType,
264
+ head: head && head.headRecordId !== null ? { seq: head.seq, headRecordId: head.headRecordId } : null,
265
+ });
266
+ } catch (error) {
267
+ next(error);
268
+ }
269
+ });
270
+
271
+ // ── Chain head ──────────────────────────────────────────────────────────────
272
+ app.get(NODE_HEAD_PATH, async (_req: Request, res: Response, next: NextFunction) => {
273
+ try {
274
+ const head = await store.getHead(subject);
275
+ if (!head || head.headRecordId === null) {
276
+ res.json({ seq: null, headRecordId: null, recordCount: 0 });
277
+ return;
278
+ }
279
+ res.json({ seq: head.seq, headRecordId: head.headRecordId, recordCount: head.recordCount });
280
+ } catch (error) {
281
+ next(error);
282
+ }
283
+ });
284
+
285
+ // ── Ordered log (ingest) ──────────────────────────────────────────────────────
286
+ app.get(NODE_LOG_PATH, async (req: Request, res: Response, next: NextFunction) => {
287
+ try {
288
+ const since = firstQueryValue(req.query.since);
289
+ const limit = parseLimit(req.query.limit);
290
+ const head = await store.getHead(subject);
291
+ const headWire = head && head.headRecordId !== null ? { seq: head.seq, headRecordId: head.headRecordId } : null;
292
+
293
+ const sinceSeq = await resolveSinceSeq(store, subject, since);
294
+ if (sinceSeq === null) {
295
+ res.json({ records: [], count: 0, head: headWire });
296
+ return;
297
+ }
298
+
299
+ const envelopes = await store.getLogSince(subject, sinceSeq, limit);
300
+ const records = await Promise.all(envelopes.map(toLogWireRecord));
301
+ res.json({ records, count: records.length, head: headWire });
302
+ } catch (error) {
303
+ next(error);
304
+ }
305
+ });
306
+
307
+ // ── Owner write: a single signed envelope ────────────────────────────────────
308
+ app.post(NODE_RECORDS_PATH, writeRateLimit, jsonParser, async (req: Request, res: Response, next: NextFunction) => {
309
+ try {
310
+ const verification = await verifyNodeRecordEnvelope(req.body);
311
+ if (!verification.ok) {
312
+ res.status(400).json({ error: verification.reason });
313
+ return;
314
+ }
315
+ if (!ownerAuth.isOwnerKey(verification.envelope.publicKey)) {
316
+ res.status(403).json({ error: 'not_owner' });
317
+ return;
318
+ }
319
+ if (!isCollectionAllowed(config, verification.envelope.collection ?? '')) {
320
+ res.status(403).json({ error: 'foreign_collection' });
321
+ return;
322
+ }
323
+ const outcome = await store.append(subject, verification.envelope, verification.recordId);
324
+ if (!outcome.ok) {
325
+ res.status(appendStatus(outcome.reason)).json({ error: outcome.reason });
326
+ return;
327
+ }
328
+ res.status(201).json({ recordId: outcome.recordId, seq: outcome.seq });
329
+ } catch (error) {
330
+ next(error);
331
+ }
332
+ });
333
+
334
+ // ── Owner write: a batch push (verified + appended in order) ──────────────────
335
+ app.post(NODE_SYNC_PUSH_PATH, writeRateLimit, jsonParser, async (req: Request, res: Response, next: NextFunction) => {
336
+ try {
337
+ const body: unknown = req.body;
338
+ const items =
339
+ typeof body === 'object' && body !== null && Array.isArray((body as { records?: unknown }).records)
340
+ ? (body as { records: unknown[] }).records
341
+ : null;
342
+ if (!items) {
343
+ res.status(400).json({ error: 'invalid_batch' });
344
+ return;
345
+ }
346
+ if (items.length > MAX_SYNC_BATCH) {
347
+ res.status(400).json({ error: 'batch_too_large' });
348
+ return;
349
+ }
350
+
351
+ const results: Array<
352
+ { ok: true; recordId: string; seq: number } | { ok: false; reason: string }
353
+ > = [];
354
+ for (const item of items) {
355
+ const verification = await verifyNodeRecordEnvelope(item);
356
+ if (!verification.ok) {
357
+ results.push({ ok: false, reason: verification.reason });
358
+ continue;
359
+ }
360
+ if (!ownerAuth.isOwnerKey(verification.envelope.publicKey)) {
361
+ results.push({ ok: false, reason: 'not_owner' });
362
+ continue;
363
+ }
364
+ if (!isCollectionAllowed(config, verification.envelope.collection ?? '')) {
365
+ results.push({ ok: false, reason: 'foreign_collection' });
366
+ continue;
367
+ }
368
+ const outcome = await store.append(subject, verification.envelope, verification.recordId);
369
+ results.push(
370
+ outcome.ok
371
+ ? { ok: true, recordId: outcome.recordId, seq: outcome.seq }
372
+ : { ok: false, reason: outcome.reason },
373
+ );
374
+ }
375
+
376
+ const accepted = results.filter((result) => result.ok).length;
377
+ res.json({ accepted, results });
378
+ } catch (error) {
379
+ next(error);
380
+ }
381
+ });
382
+
383
+ // ── Serve a content-addressed blob ───────────────────────────────────────────
384
+ app.get(`${NODE_BLOBS_PATH}/:hash`, async (req: Request, res: Response, next: NextFunction) => {
385
+ try {
386
+ const hash = req.params.hash.toLowerCase();
387
+ if (!SHA256_HEX.test(hash)) {
388
+ res.status(400).json({ error: 'invalid_hash' });
389
+ return;
390
+ }
391
+ const bytes = await store.getBlob(hash);
392
+ if (!bytes) {
393
+ res.status(404).json({ error: 'not_found' });
394
+ return;
395
+ }
396
+ res.setHeader('Content-Type', 'application/octet-stream');
397
+ // Content-addressed → immutable, safe to cache aggressively.
398
+ res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
399
+ res.send(Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes));
400
+ } catch (error) {
401
+ next(error);
402
+ }
403
+ });
404
+
405
+ // ── Owner pins a blob (signed-header authorization) ──────────────────────────
406
+ app.put(
407
+ `${NODE_BLOBS_PATH}/:hash`,
408
+ writeRateLimit,
409
+ express.raw({ type: () => true, limit: config.maxBlobBytes }),
410
+ async (req: Request, res: Response, next: NextFunction) => {
411
+ try {
412
+ const rawHash: unknown = req.params.hash;
413
+ if (typeof rawHash !== 'string') {
414
+ res.status(400).json({ error: 'invalid_hash' });
415
+ return;
416
+ }
417
+ const hash = rawHash.toLowerCase();
418
+ if (!SHA256_HEX.test(hash)) {
419
+ res.status(400).json({ error: 'invalid_hash' });
420
+ return;
421
+ }
422
+
423
+ const publicKey = req.header(OWNER_AUTH_HEADERS.publicKey);
424
+ const signature = req.header(OWNER_AUTH_HEADERS.signature);
425
+ const timestampRaw = req.header(OWNER_AUTH_HEADERS.timestamp);
426
+ if (!publicKey || !signature || !timestampRaw) {
427
+ res.status(401).json({ error: 'missing_owner_auth' });
428
+ return;
429
+ }
430
+
431
+ const authorized = await ownerAuth.verifyBlobPin(hash, {
432
+ publicKey,
433
+ signature,
434
+ timestamp: Number(timestampRaw),
435
+ });
436
+ if (!authorized) {
437
+ res.status(403).json({ error: 'unauthorized' });
438
+ return;
439
+ }
440
+
441
+ const bytes = req.body;
442
+ if (!Buffer.isBuffer(bytes) || bytes.length === 0) {
443
+ res.status(400).json({ error: 'empty_blob' });
444
+ return;
445
+ }
446
+
447
+ try {
448
+ await store.putBlob(hash, bytes);
449
+ } catch (error) {
450
+ if (error instanceof BlobHashMismatchError) {
451
+ res.status(400).json({ error: 'hash_mismatch' });
452
+ return;
453
+ }
454
+ throw error;
455
+ }
456
+
457
+ res.status(201).json({ hash, size: bytes.length });
458
+ } catch (error) {
459
+ next(error);
460
+ }
461
+ },
462
+ );
463
+
464
+ // ── Terminal error handler ───────────────────────────────────────────────────
465
+ app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
466
+ logger.error({ err: error }, 'unhandled request error');
467
+ res.status(500).json({ error: 'internal_error' });
468
+ });
469
+
470
+ return app;
471
+ }