@metalabel/dfos-web-relay 0.3.0 → 0.4.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/RELAY.md CHANGED
@@ -86,6 +86,8 @@ First-seen-wins. If a chain head has already advanced past the `previousOperatio
86
86
 
87
87
  Duplicate submissions (same operation CID) are silently accepted (idempotent).
88
88
 
89
+ Duplicate countersignatures (same witness DID, same target CID) MUST be deduplicated. The relay MUST NOT increase the countersignature count on resubmission. Resubmission SHOULD return `accepted` (idempotent).
90
+
89
91
  ### Result Ordering
90
92
 
91
93
  Ingestion results are returned in the same order as the input `operations` array, regardless of internal processing order. `results[i]` corresponds to `operations[i]`.
@@ -105,16 +107,18 @@ The relay does not currently sign operations or participate in the protocol as a
105
107
 
106
108
  ## Content Plane Access
107
109
 
108
- ### Blob Upload (`PUT /content/:contentId/blob`)
110
+ ### Blob Upload (`PUT /content/:contentId/blob/:operationCID`)
111
+
112
+ The upload path mirrors the download path — the operation CID identifies which operation's document is being uploaded.
109
113
 
110
114
  Requirements:
111
115
 
112
116
  - Valid auth token (Bearer header)
113
- - The authenticated DID must be the content chain creator
114
- - The `X-Document-CID` header must reference a `documentCID` that appears in the chain's operation log
115
- - The uploaded bytes must hash to the claimed `documentCID` (dag-cbor + sha-256 verification)
117
+ - The operation CID must reference an operation in this content chain that has a `documentCID`
118
+ - The authenticated DID must be either the chain creator OR the signer of the referenced operation (enabling delegated uploads)
119
+ - The uploaded bytes must hash to the operation's `documentCID` (dag-cbor + sha-256 verification)
116
120
 
117
- Blobs are stored by `(creatorDID, documentCID)` — if multiple content chains by the same creator reference the same document, the blob is shared (deduplication).
121
+ Blobs are stored by `(creatorDID, documentCID)` — always keyed to the chain creator regardless of who uploads. If multiple content chains by the same creator reference the same document, the blob is shared (deduplication).
118
122
 
119
123
  ### Blob Download (`GET /content/:contentId/blob[/:ref]`)
120
124
 
@@ -196,7 +200,7 @@ The returned Hono app exposes:
196
200
  | `GET` | `/identities/:did` | proof | none |
197
201
  | `GET` | `/content/:contentId` | proof | none |
198
202
  | `GET` | `/beacons/:did` | proof | none |
199
- | `PUT` | `/content/:contentId/blob` | content | auth token |
203
+ | `PUT` | `/content/:contentId/blob/:opCID` | content | auth token |
200
204
  | `GET` | `/content/:contentId/blob[/:ref]` | content | auth token + credential |
201
205
 
202
206
  ---
package/dist/index.js CHANGED
@@ -512,32 +512,29 @@ var createRelay = (options) => {
512
512
  payload: beacon.state.payload
513
513
  });
514
514
  });
515
- app.put("/content/:contentId/blob", async (c) => {
515
+ app.put("/content/:contentId/blob/:operationCID", async (c) => {
516
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
- }
517
+ const operationCID = c.req.param("operationCID");
521
518
  const auth = await authenticateRequest(c.req.header("authorization"), relayDID, store);
522
519
  if (!auth) return c.json({ error: "authentication required" }, 401);
523
520
  const chain = await store.getContentChain(contentId);
524
521
  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) {
522
+ let documentCID = null;
523
+ let operationSignerDID = null;
524
+ for (const token of chain.log) {
531
525
  const decoded = decodeJwsUnsafe3(token);
532
526
  if (!decoded) continue;
527
+ if (decoded.header.cid !== operationCID) continue;
533
528
  const payload = decoded.payload;
534
- if (payload["documentCID"] === documentCID) {
535
- documentReferenced = true;
536
- break;
537
- }
529
+ documentCID = typeof payload["documentCID"] === "string" ? payload["documentCID"] : null;
530
+ operationSignerDID = typeof payload["did"] === "string" ? payload["did"] : null;
531
+ break;
532
+ }
533
+ if (!documentCID) {
534
+ return c.json({ error: "operation not found in chain or has no documentCID" }, 404);
538
535
  }
539
- if (!documentReferenced) {
540
- return c.json({ error: "documentCID not referenced in chain" }, 400);
536
+ if (auth.iss !== chain.state.creatorDID && auth.iss !== operationSignerDID) {
537
+ return c.json({ error: "not authorized \u2014 must be chain creator or operation signer" }, 403);
541
538
  }
542
539
  const bytes = new Uint8Array(await c.req.arrayBuffer());
543
540
  try {
@@ -549,8 +546,8 @@ var createRelay = (options) => {
549
546
  } catch {
550
547
  return c.json({ error: "blob bytes do not match documentCID" }, 400);
551
548
  }
552
- await store.putBlob({ creatorDID: auth.iss, documentCID }, bytes);
553
- return c.json({ status: "stored", contentId, documentCID });
549
+ await store.putBlob({ creatorDID: chain.state.creatorDID, documentCID }, bytes);
550
+ return c.json({ status: "stored", contentId, documentCID, operationCID });
554
551
  });
555
552
  app.get("/content/:contentId/blob", async (c) => {
556
553
  return await readBlob({
@@ -651,6 +648,7 @@ var verifyReadCredential = async (auth, chain, contentId, credHeader, store) =>
651
648
  };
652
649
 
653
650
  // src/store.ts
651
+ import { decodeJwsUnsafe as decodeJwsUnsafe4 } from "@metalabel/dfos-protocol/crypto";
654
652
  var blobKeyString = (key) => `${key.creatorDID}::${key.documentCID}`;
655
653
  var MemoryRelayStore = class {
656
654
  operations = /* @__PURE__ */ new Map();
@@ -694,7 +692,18 @@ var MemoryRelayStore = class {
694
692
  }
695
693
  async addCountersignature(operationCID, jwsToken) {
696
694
  const existing = this.countersignatures.get(operationCID) ?? [];
697
- if (existing.includes(jwsToken)) return;
695
+ const decoded = decodeJwsUnsafe4(jwsToken);
696
+ if (decoded) {
697
+ const kid = decoded.header.kid;
698
+ const witnessDID = kid.includes("#") ? kid.split("#")[0] : kid;
699
+ for (const cs of existing) {
700
+ const d = decodeJwsUnsafe4(cs);
701
+ if (!d) continue;
702
+ const existingKid = d.header.kid;
703
+ const existingDID = existingKid.includes("#") ? existingKid.split("#")[0] : existingKid;
704
+ if (existingDID === witnessDID) return;
705
+ }
706
+ }
698
707
  existing.push(jwsToken);
699
708
  this.countersignatures.set(operationCID, existing);
700
709
  }
@@ -0,0 +1,21 @@
1
+ import { Server } from 'node:http';
2
+ import { Hono } from 'hono';
3
+
4
+ interface ServeOptions {
5
+ port?: number;
6
+ hostname?: string;
7
+ }
8
+ /**
9
+ * Start a Node HTTP server for a DFOS web relay.
10
+ *
11
+ * ```ts
12
+ * import { createRelay, MemoryRelayStore } from '@metalabel/dfos-web-relay';
13
+ * import { serve } from '@metalabel/dfos-web-relay/node';
14
+ *
15
+ * const relay = createRelay({ relayDID: 'did:dfos:myrelay', store: new MemoryRelayStore() });
16
+ * serve(relay, { port: 4444 });
17
+ * ```
18
+ */
19
+ declare const serve: (app: Hono, options?: ServeOptions) => Server;
20
+
21
+ export { type ServeOptions, serve };
package/dist/serve.js ADDED
@@ -0,0 +1,31 @@
1
+ // src/serve.ts
2
+ import { createServer } from "http";
3
+ var serve = (app, options = {}) => {
4
+ const { port = 4444, hostname } = options;
5
+ const server = createServer(async (req, res) => {
6
+ const url = new URL(req.url ?? "/", `http://${hostname ?? "localhost"}:${port}`);
7
+ const chunks = [];
8
+ for await (const chunk of req) chunks.push(chunk);
9
+ const body = Buffer.concat(chunks);
10
+ const headers = new Headers();
11
+ for (const [k, v] of Object.entries(req.headers)) {
12
+ if (v) headers.set(k, Array.isArray(v) ? v.join(", ") : v);
13
+ }
14
+ const method = req.method ?? "GET";
15
+ const init = { method, headers };
16
+ if (!["GET", "HEAD"].includes(method)) {
17
+ init.body = body;
18
+ }
19
+ const response = await app.fetch(new Request(url.toString(), init));
20
+ res.writeHead(response.status, Object.fromEntries(response.headers.entries()));
21
+ const buf = Buffer.from(await response.arrayBuffer());
22
+ res.end(buf);
23
+ });
24
+ server.listen(port, hostname, () => {
25
+ console.log(`DFOS web relay listening on http://${hostname ?? "localhost"}:${port}`);
26
+ });
27
+ return server;
28
+ };
29
+ export {
30
+ serve
31
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metalabel/dfos-web-relay",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "DFOS Web Relay — verifying HTTP relay for identity chains, content chains, beacons, and content blobs",
6
6
  "license": "MIT",
@@ -28,6 +28,10 @@
28
28
  ".": {
29
29
  "import": "./dist/index.js",
30
30
  "types": "./dist/index.d.ts"
31
+ },
32
+ "./node": {
33
+ "import": "./dist/serve.js",
34
+ "types": "./dist/serve.d.ts"
31
35
  }
32
36
  },
33
37
  "files": [
@@ -42,13 +46,13 @@
42
46
  "zod": "^4.3.6"
43
47
  },
44
48
  "peerDependencies": {
45
- "@metalabel/dfos-protocol": "^0.3.0"
49
+ "@metalabel/dfos-protocol": "^0.4.0"
46
50
  },
47
51
  "devDependencies": {
48
52
  "@types/node": "^24.10.4",
49
53
  "tsup": "^8.5.1",
50
54
  "vitest": "^4.0.18",
51
- "@metalabel/dfos-protocol": "0.3.0"
55
+ "@metalabel/dfos-protocol": "0.4.0"
52
56
  },
53
57
  "scripts": {
54
58
  "build": "tsup",