@metalabel/dfos-web-relay 0.3.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.
package/dist/index.js ADDED
@@ -0,0 +1,708 @@
1
+ // src/relay.ts
2
+ import { VC_TYPE_CONTENT_READ, verifyCredential } from "@metalabel/dfos-protocol/credentials";
3
+ import { dagCborCanonicalEncode as dagCborCanonicalEncode2, decodeJwsUnsafe as decodeJwsUnsafe3 } from "@metalabel/dfos-protocol/crypto";
4
+ import { Hono } from "hono";
5
+ import { z } from "zod";
6
+
7
+ // src/auth.ts
8
+ import { verifyAuthToken } from "@metalabel/dfos-protocol/credentials";
9
+ import { decodeJwsUnsafe as decodeJwsUnsafe2 } from "@metalabel/dfos-protocol/crypto";
10
+
11
+ // src/ingest.ts
12
+ import {
13
+ decodeMultikey,
14
+ verifyBeacon,
15
+ verifyBeaconCountersignature,
16
+ verifyContentChain,
17
+ verifyCountersignature,
18
+ verifyIdentityChain
19
+ } from "@metalabel/dfos-protocol/chain";
20
+ import { dagCborCanonicalEncode, decodeJwsUnsafe } from "@metalabel/dfos-protocol/crypto";
21
+ var classify = (jwsToken) => {
22
+ const unknown = {
23
+ jwsToken,
24
+ kind: "unknown",
25
+ referencedDID: null,
26
+ signerDID: null,
27
+ priority: 99,
28
+ operationCID: null,
29
+ previousCID: null,
30
+ originalIndex: 0
31
+ };
32
+ const decoded = decodeJwsUnsafe(jwsToken);
33
+ if (!decoded) return unknown;
34
+ const typ = decoded.header.typ;
35
+ const payload = decoded.payload;
36
+ const kid = decoded.header.kid;
37
+ if (!kid || typeof kid !== "string") return unknown;
38
+ const kidDID = kid.includes("#") ? kid.substring(0, kid.indexOf("#")) : null;
39
+ const operationCID = typeof decoded.header.cid === "string" ? decoded.header.cid : null;
40
+ const previousCID = typeof payload["previousOperationCID"] === "string" ? payload["previousOperationCID"] : null;
41
+ const base = { jwsToken, operationCID, previousCID, originalIndex: 0 };
42
+ if (typ === "did:dfos:identity-op") {
43
+ return { ...base, kind: "identity-op", referencedDID: kidDID, signerDID: null, priority: 0 };
44
+ }
45
+ if (typ === "did:dfos:content-op") {
46
+ const opDID = typeof payload["did"] === "string" ? payload["did"] : null;
47
+ if (opDID && kidDID && kidDID !== opDID) {
48
+ return {
49
+ ...base,
50
+ kind: "countersig",
51
+ referencedDID: opDID,
52
+ signerDID: kidDID,
53
+ priority: 3,
54
+ previousCID: null
55
+ };
56
+ }
57
+ return { ...base, kind: "content-op", referencedDID: null, signerDID: opDID, priority: 2 };
58
+ }
59
+ if (typ === "did:dfos:beacon") {
60
+ const beaconDID = typeof payload["did"] === "string" ? payload["did"] : null;
61
+ if (beaconDID && kidDID && kidDID !== beaconDID) {
62
+ return {
63
+ ...base,
64
+ kind: "beacon-countersig",
65
+ referencedDID: beaconDID,
66
+ signerDID: kidDID,
67
+ priority: 3,
68
+ previousCID: null
69
+ };
70
+ }
71
+ return {
72
+ ...base,
73
+ kind: "beacon",
74
+ referencedDID: beaconDID,
75
+ signerDID: null,
76
+ priority: 1,
77
+ previousCID: null
78
+ };
79
+ }
80
+ return unknown;
81
+ };
82
+ var createKeyResolver = (store) => async (kid) => {
83
+ const hashIdx = kid.indexOf("#");
84
+ if (hashIdx < 0) throw new Error(`kid must be a DID URL: ${kid}`);
85
+ const did = kid.substring(0, hashIdx);
86
+ const keyId = kid.substring(hashIdx + 1);
87
+ const identity = await store.getIdentityChain(did);
88
+ if (!identity) throw new Error(`unknown identity: ${did}`);
89
+ const currentKeys = [
90
+ ...identity.state.authKeys,
91
+ ...identity.state.assertKeys,
92
+ ...identity.state.controllerKeys
93
+ ];
94
+ const currentKey = currentKeys.find((k) => k.id === keyId);
95
+ if (currentKey) return decodeMultikey(currentKey.publicKeyMultibase).keyBytes;
96
+ for (const token of identity.log) {
97
+ const decoded = decodeJwsUnsafe(token);
98
+ if (!decoded) continue;
99
+ const payload = decoded.payload;
100
+ const opType = payload["type"];
101
+ if (opType !== "create" && opType !== "update") continue;
102
+ const keyArrays = ["authKeys", "assertKeys", "controllerKeys"];
103
+ for (const arrayName of keyArrays) {
104
+ const keys = payload[arrayName];
105
+ if (!Array.isArray(keys)) continue;
106
+ for (const k of keys) {
107
+ if (k && typeof k === "object" && "id" in k && k.id === keyId && "publicKeyMultibase" in k) {
108
+ return decodeMultikey(k.publicKeyMultibase).keyBytes;
109
+ }
110
+ }
111
+ }
112
+ }
113
+ throw new Error(`unknown key ${keyId} on identity ${did}`);
114
+ };
115
+ var createCurrentKeyResolver = (store) => async (kid) => {
116
+ const hashIdx = kid.indexOf("#");
117
+ if (hashIdx < 0) throw new Error(`kid must be a DID URL: ${kid}`);
118
+ const did = kid.substring(0, hashIdx);
119
+ const keyId = kid.substring(hashIdx + 1);
120
+ const identity = await store.getIdentityChain(did);
121
+ if (!identity) throw new Error(`unknown identity: ${did}`);
122
+ const currentKeys = [
123
+ ...identity.state.authKeys,
124
+ ...identity.state.assertKeys,
125
+ ...identity.state.controllerKeys
126
+ ];
127
+ const currentKey = currentKeys.find((k) => k.id === keyId);
128
+ if (currentKey) return decodeMultikey(currentKey.publicKeyMultibase).keyBytes;
129
+ throw new Error(`unknown key ${keyId} on identity ${did}`);
130
+ };
131
+ var ingestIdentityOp = async (jwsToken, store) => {
132
+ const decoded = decodeJwsUnsafe(jwsToken);
133
+ if (!decoded) return { cid: "", status: "rejected", error: "failed to decode JWS" };
134
+ const payload = decoded.payload;
135
+ const encoded = await dagCborCanonicalEncode(payload);
136
+ const cid = encoded.cid.toString();
137
+ const existing = await store.getOperation(cid);
138
+ if (existing) return { cid, status: "accepted", kind: "identity-op", chainId: existing.chainId };
139
+ const opType = payload["type"];
140
+ const isGenesis = opType === "create";
141
+ if (isGenesis) {
142
+ const identity2 = await verifyIdentityChain({ didPrefix: "did:dfos", log: [jwsToken] });
143
+ const chain2 = { did: identity2.did, log: [jwsToken], state: identity2 };
144
+ await store.putIdentityChain(chain2);
145
+ await store.putOperation({ cid, jwsToken, chainType: "identity", chainId: identity2.did });
146
+ return { cid, status: "accepted", kind: "identity-op", chainId: identity2.did };
147
+ }
148
+ const kid = decoded.header.kid;
149
+ const hashIdx = kid.indexOf("#");
150
+ if (hashIdx < 0) return { cid, status: "rejected", error: "non-genesis kid must be a DID URL" };
151
+ const did = kid.substring(0, hashIdx);
152
+ const chain = await store.getIdentityChain(did);
153
+ if (!chain) return { cid, status: "rejected", error: `unknown identity: ${did}` };
154
+ const newLog = [...chain.log, jwsToken];
155
+ const identity = await verifyIdentityChain({ didPrefix: "did:dfos", log: newLog });
156
+ const updated = { did: identity.did, log: newLog, state: identity };
157
+ await store.putIdentityChain(updated);
158
+ await store.putOperation({ cid, jwsToken, chainType: "identity", chainId: did });
159
+ return { cid, status: "accepted", kind: "identity-op", chainId: did };
160
+ };
161
+ var ingestContentOp = async (jwsToken, store) => {
162
+ const decoded = decodeJwsUnsafe(jwsToken);
163
+ if (!decoded) return { cid: "", status: "rejected", error: "failed to decode JWS" };
164
+ const payload = decoded.payload;
165
+ const encoded = await dagCborCanonicalEncode(payload);
166
+ const cid = encoded.cid.toString();
167
+ const existing = await store.getOperation(cid);
168
+ if (existing) return { cid, status: "accepted", kind: "content-op", chainId: existing.chainId };
169
+ const resolveKey = createKeyResolver(store);
170
+ const opType = payload["type"];
171
+ const isGenesis = opType === "create";
172
+ if (isGenesis) {
173
+ const content2 = await verifyContentChain({
174
+ log: [jwsToken],
175
+ resolveKey,
176
+ enforceAuthorization: true
177
+ });
178
+ const chain2 = {
179
+ contentId: content2.contentId,
180
+ genesisCID: content2.genesisCID,
181
+ log: [jwsToken],
182
+ state: content2
183
+ };
184
+ await store.putContentChain(chain2);
185
+ await store.putOperation({ cid, jwsToken, chainType: "content", chainId: content2.contentId });
186
+ return { cid, status: "accepted", kind: "content-op", chainId: content2.contentId };
187
+ }
188
+ const previousCID = payload["previousOperationCID"];
189
+ if (typeof previousCID !== "string") {
190
+ return { cid, status: "rejected", error: "missing previousOperationCID" };
191
+ }
192
+ const prevOp = await store.getOperation(previousCID);
193
+ if (!prevOp)
194
+ return { cid, status: "rejected", error: `unknown previous operation: ${previousCID}` };
195
+ if (prevOp.chainType !== "content") {
196
+ return { cid, status: "rejected", error: "previousOperationCID is not a content operation" };
197
+ }
198
+ const chain = await store.getContentChain(prevOp.chainId);
199
+ if (!chain)
200
+ return { cid, status: "rejected", error: `content chain not found: ${prevOp.chainId}` };
201
+ if (chain.state.headCID !== previousCID) {
202
+ return { cid, status: "rejected", error: "chain has diverged (first-seen-wins)" };
203
+ }
204
+ const newLog = [...chain.log, jwsToken];
205
+ const content = await verifyContentChain({
206
+ log: newLog,
207
+ resolveKey,
208
+ enforceAuthorization: true
209
+ });
210
+ const updated = {
211
+ contentId: content.contentId,
212
+ genesisCID: content.genesisCID,
213
+ log: newLog,
214
+ state: content
215
+ };
216
+ await store.putContentChain(updated);
217
+ await store.putOperation({ cid, jwsToken, chainType: "content", chainId: content.contentId });
218
+ return { cid, status: "accepted", kind: "content-op", chainId: content.contentId };
219
+ };
220
+ var ingestBeacon = async (jwsToken, store) => {
221
+ const resolveKey = createKeyResolver(store);
222
+ let verified;
223
+ try {
224
+ verified = await verifyBeacon({ jwsToken, resolveKey });
225
+ } catch (err) {
226
+ const message = err instanceof Error ? err.message : "verification failed";
227
+ return { cid: "", status: "rejected", error: message };
228
+ }
229
+ const did = verified.payload.did;
230
+ const cid = verified.beaconCID;
231
+ const existing = await store.getBeacon(did);
232
+ if (existing) {
233
+ const existingTime = new Date(existing.state.payload.createdAt).getTime();
234
+ const newTime = new Date(verified.payload.createdAt).getTime();
235
+ if (newTime <= existingTime) {
236
+ return { cid, status: "accepted", kind: "beacon", chainId: did };
237
+ }
238
+ }
239
+ await store.putBeacon({ did, jwsToken, beaconCID: cid, state: verified });
240
+ return { cid, status: "accepted", kind: "beacon", chainId: did };
241
+ };
242
+ var ingestCountersig = async (jwsToken, store) => {
243
+ const decoded = decodeJwsUnsafe(jwsToken);
244
+ if (!decoded) return { cid: "", status: "rejected", error: "failed to decode JWS" };
245
+ const payload = decoded.payload;
246
+ const encoded = await dagCborCanonicalEncode(payload);
247
+ const operationCID = encoded.cid.toString();
248
+ const existingOp = await store.getOperation(operationCID);
249
+ if (!existingOp) {
250
+ return { cid: operationCID, status: "rejected", error: `unknown operation: ${operationCID}` };
251
+ }
252
+ const resolveKey = createKeyResolver(store);
253
+ try {
254
+ await verifyCountersignature({ jwsToken, expectedCID: operationCID, resolveKey });
255
+ } catch (err) {
256
+ const message = err instanceof Error ? err.message : "verification failed";
257
+ return { cid: operationCID, status: "rejected", error: message };
258
+ }
259
+ await store.addCountersignature(operationCID, jwsToken);
260
+ return {
261
+ cid: operationCID,
262
+ status: "accepted",
263
+ kind: "countersig",
264
+ chainId: existingOp.chainId
265
+ };
266
+ };
267
+ var ingestBeaconCountersig = async (jwsToken, store) => {
268
+ const decoded = decodeJwsUnsafe(jwsToken);
269
+ if (!decoded) return { cid: "", status: "rejected", error: "failed to decode JWS" };
270
+ const payload = decoded.payload;
271
+ const encoded = await dagCborCanonicalEncode(payload);
272
+ const beaconCID = encoded.cid.toString();
273
+ const beaconDID = typeof payload["did"] === "string" ? payload["did"] : null;
274
+ if (!beaconDID) {
275
+ return { cid: beaconCID, status: "rejected", error: "missing beacon DID" };
276
+ }
277
+ const existingBeacon = await store.getBeacon(beaconDID);
278
+ if (!existingBeacon) {
279
+ return { cid: beaconCID, status: "rejected", error: `unknown beacon for DID: ${beaconDID}` };
280
+ }
281
+ if (existingBeacon.beaconCID !== beaconCID) {
282
+ return {
283
+ cid: beaconCID,
284
+ status: "rejected",
285
+ error: "beacon countersignature CID does not match current beacon"
286
+ };
287
+ }
288
+ const resolveKey = createKeyResolver(store);
289
+ try {
290
+ await verifyBeaconCountersignature({
291
+ jwsToken,
292
+ expectedCID: beaconCID,
293
+ resolveKey
294
+ });
295
+ } catch (err) {
296
+ const message = err instanceof Error ? err.message : "verification failed";
297
+ return { cid: beaconCID, status: "rejected", error: message };
298
+ }
299
+ await store.addCountersignature(beaconCID, jwsToken);
300
+ return {
301
+ cid: beaconCID,
302
+ status: "accepted",
303
+ kind: "beacon-countersig",
304
+ chainId: beaconDID
305
+ };
306
+ };
307
+ var dependencySort = (ops) => {
308
+ const buckets = /* @__PURE__ */ new Map();
309
+ for (const op of ops) {
310
+ const bucket = buckets.get(op.priority) ?? [];
311
+ bucket.push(op);
312
+ buckets.set(op.priority, bucket);
313
+ }
314
+ const result = [];
315
+ const sortedPriorities = [...buckets.keys()].sort((a, b) => a - b);
316
+ for (const priority of sortedPriorities) {
317
+ const bucket = buckets.get(priority);
318
+ if ((priority === 0 || priority === 2) && bucket.length > 1) {
319
+ result.push(...topologicalSortBucket(bucket));
320
+ } else {
321
+ result.push(...bucket);
322
+ }
323
+ }
324
+ return result;
325
+ };
326
+ var topologicalSortBucket = (ops) => {
327
+ if (ops.length <= 1) return ops;
328
+ const cidToOp = /* @__PURE__ */ new Map();
329
+ for (const op of ops) {
330
+ if (op.operationCID) cidToOp.set(op.operationCID, op);
331
+ }
332
+ const inDegree = /* @__PURE__ */ new Map();
333
+ const dependents = /* @__PURE__ */ new Map();
334
+ for (const op of ops) {
335
+ const depInBatch = op.previousCID !== null && cidToOp.has(op.previousCID);
336
+ inDegree.set(op, depInBatch ? 1 : 0);
337
+ if (depInBatch) {
338
+ const list = dependents.get(op.previousCID) ?? [];
339
+ list.push(op);
340
+ dependents.set(op.previousCID, list);
341
+ }
342
+ }
343
+ const queue = ops.filter((op) => inDegree.get(op) === 0);
344
+ const sorted = [];
345
+ while (queue.length > 0) {
346
+ const op = queue.shift();
347
+ sorted.push(op);
348
+ if (op.operationCID) {
349
+ for (const dep of dependents.get(op.operationCID) ?? []) {
350
+ const deg = inDegree.get(dep) - 1;
351
+ inDegree.set(dep, deg);
352
+ if (deg === 0) queue.push(dep);
353
+ }
354
+ }
355
+ }
356
+ if (sorted.length < ops.length) {
357
+ const placed = new Set(sorted);
358
+ for (const op of ops) {
359
+ if (!placed.has(op)) sorted.push(op);
360
+ }
361
+ }
362
+ return sorted;
363
+ };
364
+ var ingestOperations = async (tokens, store) => {
365
+ const classified = tokens.map((token, i) => ({ ...classify(token), originalIndex: i }));
366
+ const sorted = dependencySort(classified);
367
+ const indexedResults = [];
368
+ for (const op of sorted) {
369
+ try {
370
+ let result;
371
+ switch (op.kind) {
372
+ case "identity-op":
373
+ result = await ingestIdentityOp(op.jwsToken, store);
374
+ break;
375
+ case "content-op":
376
+ result = await ingestContentOp(op.jwsToken, store);
377
+ break;
378
+ case "beacon":
379
+ result = await ingestBeacon(op.jwsToken, store);
380
+ break;
381
+ case "countersig":
382
+ result = await ingestCountersig(op.jwsToken, store);
383
+ break;
384
+ case "beacon-countersig":
385
+ result = await ingestBeaconCountersig(op.jwsToken, store);
386
+ break;
387
+ default:
388
+ result = { cid: "", status: "rejected", error: "unrecognized operation type" };
389
+ }
390
+ indexedResults.push({ index: op.originalIndex, result });
391
+ } catch (err) {
392
+ const message = err instanceof Error ? err.message : "unexpected error";
393
+ indexedResults.push({
394
+ index: op.originalIndex,
395
+ result: { cid: "", status: "rejected", error: message }
396
+ });
397
+ }
398
+ }
399
+ return indexedResults.sort((a, b) => a.index - b.index).map((r) => r.result);
400
+ };
401
+
402
+ // src/auth.ts
403
+ var authenticateRequest = async (authHeader, relayDID, store) => {
404
+ if (!authHeader) return null;
405
+ if (!authHeader.startsWith("Bearer ")) return null;
406
+ const token = authHeader.substring(7);
407
+ if (!token) return null;
408
+ const decoded = decodeJwsUnsafe2(token);
409
+ if (!decoded) return null;
410
+ const kid = decoded.header.kid;
411
+ if (!kid || !kid.includes("#")) return null;
412
+ const resolveKey = createCurrentKeyResolver(store);
413
+ let publicKey;
414
+ try {
415
+ publicKey = await resolveKey(kid);
416
+ } catch {
417
+ return null;
418
+ }
419
+ try {
420
+ return verifyAuthToken({ token, publicKey, audience: relayDID });
421
+ } catch {
422
+ return null;
423
+ }
424
+ };
425
+
426
+ // src/relay.ts
427
+ var IngestBody = z.object({
428
+ operations: z.array(z.string()).min(1).max(100)
429
+ });
430
+ var createRelay = (options) => {
431
+ const { relayDID, store } = options;
432
+ const app = new Hono();
433
+ app.get("/.well-known/dfos-relay", (c) => {
434
+ return c.json({
435
+ did: relayDID,
436
+ protocol: "dfos-web-relay",
437
+ version: "0.1.0"
438
+ });
439
+ });
440
+ app.post("/operations", async (c) => {
441
+ let body;
442
+ try {
443
+ body = await c.req.json();
444
+ } catch {
445
+ return c.json({ error: "invalid JSON body" }, 400);
446
+ }
447
+ const parsed = IngestBody.safeParse(body);
448
+ if (!parsed.success) {
449
+ return c.json({ error: "invalid request", details: parsed.error.issues }, 400);
450
+ }
451
+ const results = await ingestOperations(parsed.data.operations, store);
452
+ return c.json({ results });
453
+ });
454
+ app.get("/operations/:cid", async (c) => {
455
+ const cid = c.req.param("cid");
456
+ const op = await store.getOperation(cid);
457
+ if (!op) return c.json({ error: "not found" }, 404);
458
+ return c.json({
459
+ cid: op.cid,
460
+ jwsToken: op.jwsToken,
461
+ chainType: op.chainType,
462
+ chainId: op.chainId
463
+ });
464
+ });
465
+ app.get("/identities/:did{.+}", async (c) => {
466
+ const did = c.req.param("did");
467
+ const chain = await store.getIdentityChain(did);
468
+ if (!chain) return c.json({ error: "not found" }, 404);
469
+ return c.json({
470
+ did: chain.did,
471
+ log: chain.log,
472
+ state: chain.state
473
+ });
474
+ });
475
+ app.get("/content/:contentId", async (c) => {
476
+ const contentId = c.req.param("contentId");
477
+ const chain = await store.getContentChain(contentId);
478
+ if (!chain) return c.json({ error: "not found" }, 404);
479
+ return c.json({
480
+ contentId: chain.contentId,
481
+ genesisCID: chain.genesisCID,
482
+ log: chain.log,
483
+ state: chain.state
484
+ });
485
+ });
486
+ app.get("/countersignatures/:cid", async (c) => {
487
+ const cid = c.req.param("cid");
488
+ const op = await store.getOperation(cid);
489
+ if (!op) {
490
+ const countersigs2 = await store.getCountersignatures(cid);
491
+ if (countersigs2.length === 0) return c.json({ error: "not found" }, 404);
492
+ return c.json({ cid, countersignatures: countersigs2 });
493
+ }
494
+ const countersigs = await store.getCountersignatures(cid);
495
+ return c.json({ operationCID: cid, countersignatures: countersigs });
496
+ });
497
+ app.get("/operations/:cid/countersignatures", async (c) => {
498
+ const cid = c.req.param("cid");
499
+ const op = await store.getOperation(cid);
500
+ if (!op) return c.json({ error: "not found" }, 404);
501
+ const countersigs = await store.getCountersignatures(cid);
502
+ return c.json({ operationCID: cid, countersignatures: countersigs });
503
+ });
504
+ app.get("/beacons/:did{.+}", async (c) => {
505
+ const did = c.req.param("did");
506
+ const beacon = await store.getBeacon(did);
507
+ if (!beacon) return c.json({ error: "not found" }, 404);
508
+ return c.json({
509
+ did: beacon.did,
510
+ jwsToken: beacon.jwsToken,
511
+ beaconCID: beacon.beaconCID,
512
+ payload: beacon.state.payload
513
+ });
514
+ });
515
+ app.put("/content/:contentId/blob", async (c) => {
516
+ const contentId = c.req.param("contentId");
517
+ const documentCID = c.req.header("x-document-cid");
518
+ if (!documentCID) {
519
+ return c.json({ error: "missing X-Document-CID header" }, 400);
520
+ }
521
+ const auth = await authenticateRequest(c.req.header("authorization"), relayDID, store);
522
+ if (!auth) return c.json({ error: "authentication required" }, 401);
523
+ const chain = await store.getContentChain(contentId);
524
+ if (!chain) return c.json({ error: "content chain not found" }, 404);
525
+ if (chain.state.creatorDID !== auth.iss) {
526
+ return c.json({ error: "not the chain creator" }, 403);
527
+ }
528
+ const chainLog = chain.log;
529
+ let documentReferenced = false;
530
+ for (const token of chainLog) {
531
+ const decoded = decodeJwsUnsafe3(token);
532
+ if (!decoded) continue;
533
+ const payload = decoded.payload;
534
+ if (payload["documentCID"] === documentCID) {
535
+ documentReferenced = true;
536
+ break;
537
+ }
538
+ }
539
+ if (!documentReferenced) {
540
+ return c.json({ error: "documentCID not referenced in chain" }, 400);
541
+ }
542
+ const bytes = new Uint8Array(await c.req.arrayBuffer());
543
+ try {
544
+ const parsed = JSON.parse(new TextDecoder().decode(bytes));
545
+ const encoded = await dagCborCanonicalEncode2(parsed);
546
+ if (encoded.cid.toString() !== documentCID) {
547
+ return c.json({ error: "blob bytes do not match documentCID" }, 400);
548
+ }
549
+ } catch {
550
+ return c.json({ error: "blob bytes do not match documentCID" }, 400);
551
+ }
552
+ await store.putBlob({ creatorDID: auth.iss, documentCID }, bytes);
553
+ return c.json({ status: "stored", contentId, documentCID });
554
+ });
555
+ app.get("/content/:contentId/blob", async (c) => {
556
+ return await readBlob({
557
+ contentId: c.req.param("contentId"),
558
+ ref: "head",
559
+ authHeader: c.req.header("authorization"),
560
+ credHeader: c.req.header("x-credential"),
561
+ relayDID,
562
+ store
563
+ });
564
+ });
565
+ app.get("/content/:contentId/blob/:ref", async (c) => {
566
+ return await readBlob({
567
+ contentId: c.req.param("contentId"),
568
+ ref: c.req.param("ref"),
569
+ authHeader: c.req.header("authorization"),
570
+ credHeader: c.req.header("x-credential"),
571
+ relayDID,
572
+ store
573
+ });
574
+ });
575
+ return app;
576
+ };
577
+ var jsonResponse = (body, status = 200) => new Response(JSON.stringify(body), {
578
+ status,
579
+ headers: { "content-type": "application/json" }
580
+ });
581
+ var readBlob = async (params) => {
582
+ const { contentId, ref, authHeader, credHeader, relayDID, store } = params;
583
+ const auth = await authenticateRequest(authHeader, relayDID, store);
584
+ if (!auth) return jsonResponse({ error: "authentication required" }, 401);
585
+ const chain = await store.getContentChain(contentId);
586
+ if (!chain) return jsonResponse({ error: "content chain not found" }, 404);
587
+ const credError = await verifyReadCredential(auth, chain, contentId, credHeader, store);
588
+ if (credError) return credError;
589
+ let documentCID = null;
590
+ let operationFound = ref === "head";
591
+ if (ref === "head") {
592
+ documentCID = chain.state.currentDocumentCID;
593
+ } else {
594
+ for (const token of chain.log) {
595
+ const decoded = decodeJwsUnsafe3(token);
596
+ if (!decoded) continue;
597
+ if (decoded.header.cid === ref) {
598
+ operationFound = true;
599
+ const payload = decoded.payload;
600
+ documentCID = typeof payload["documentCID"] === "string" ? payload["documentCID"] : null;
601
+ break;
602
+ }
603
+ }
604
+ }
605
+ if (!operationFound) return jsonResponse({ error: "operation not found in chain" }, 404);
606
+ if (!documentCID) return jsonResponse({ error: "no document at this ref" }, 404);
607
+ const blob = await store.getBlob({ creatorDID: chain.state.creatorDID, documentCID });
608
+ if (!blob) return jsonResponse({ error: "blob not found" }, 404);
609
+ return new Response(blob, {
610
+ headers: {
611
+ "content-type": "application/octet-stream",
612
+ "x-document-cid": documentCID
613
+ }
614
+ });
615
+ };
616
+ var verifyReadCredential = async (auth, chain, contentId, credHeader, store) => {
617
+ if (auth.iss === chain.state.creatorDID) return null;
618
+ if (!credHeader) {
619
+ return jsonResponse({ error: "DFOSContentRead credential required" }, 403);
620
+ }
621
+ const resolveKey = createCurrentKeyResolver(store);
622
+ try {
623
+ const vcDecoded = decodeJwsUnsafe3(credHeader);
624
+ if (!vcDecoded) throw new Error("invalid credential format");
625
+ const vcHeader = vcDecoded.header;
626
+ if (!vcHeader.kid) throw new Error("credential missing kid");
627
+ const kidHashIdx = vcHeader.kid.indexOf("#");
628
+ if (kidHashIdx < 0) throw new Error("credential kid must be a DID URL");
629
+ const vcIssuerDID = vcHeader.kid.substring(0, kidHashIdx);
630
+ if (vcIssuerDID !== chain.state.creatorDID) {
631
+ throw new Error("credential must be issued by the chain creator");
632
+ }
633
+ const creatorKey = await resolveKey(vcHeader.kid);
634
+ const credential = verifyCredential({
635
+ token: credHeader,
636
+ publicKey: creatorKey,
637
+ subject: auth.iss,
638
+ expectedType: VC_TYPE_CONTENT_READ
639
+ });
640
+ if (credential.iss !== chain.state.creatorDID) {
641
+ throw new Error("credential issuer is not the chain creator");
642
+ }
643
+ if (credential.contentId && credential.contentId !== contentId) {
644
+ return jsonResponse({ error: "credential contentId does not match" }, 403);
645
+ }
646
+ } catch (err) {
647
+ const message = err instanceof Error ? err.message : "credential verification failed";
648
+ return jsonResponse({ error: message }, 403);
649
+ }
650
+ return null;
651
+ };
652
+
653
+ // src/store.ts
654
+ var blobKeyString = (key) => `${key.creatorDID}::${key.documentCID}`;
655
+ var MemoryRelayStore = class {
656
+ operations = /* @__PURE__ */ new Map();
657
+ identityChains = /* @__PURE__ */ new Map();
658
+ contentChains = /* @__PURE__ */ new Map();
659
+ beacons = /* @__PURE__ */ new Map();
660
+ blobs = /* @__PURE__ */ new Map();
661
+ countersignatures = /* @__PURE__ */ new Map();
662
+ async getOperation(cid) {
663
+ return this.operations.get(cid);
664
+ }
665
+ async putOperation(op) {
666
+ this.operations.set(op.cid, op);
667
+ }
668
+ async getIdentityChain(did) {
669
+ return this.identityChains.get(did);
670
+ }
671
+ async putIdentityChain(chain) {
672
+ this.identityChains.set(chain.did, chain);
673
+ }
674
+ async getContentChain(contentId) {
675
+ return this.contentChains.get(contentId);
676
+ }
677
+ async putContentChain(chain) {
678
+ this.contentChains.set(chain.contentId, chain);
679
+ }
680
+ async getBeacon(did) {
681
+ return this.beacons.get(did);
682
+ }
683
+ async putBeacon(beacon) {
684
+ this.beacons.set(beacon.did, beacon);
685
+ }
686
+ async getBlob(key) {
687
+ return this.blobs.get(blobKeyString(key));
688
+ }
689
+ async putBlob(key, data) {
690
+ this.blobs.set(blobKeyString(key), data);
691
+ }
692
+ async getCountersignatures(operationCID) {
693
+ return this.countersignatures.get(operationCID) ?? [];
694
+ }
695
+ async addCountersignature(operationCID, jwsToken) {
696
+ const existing = this.countersignatures.get(operationCID) ?? [];
697
+ if (existing.includes(jwsToken)) return;
698
+ existing.push(jwsToken);
699
+ this.countersignatures.set(operationCID, existing);
700
+ }
701
+ };
702
+ export {
703
+ MemoryRelayStore,
704
+ createCurrentKeyResolver,
705
+ createKeyResolver,
706
+ createRelay,
707
+ ingestOperations
708
+ };