@metalabel/dfos-web-relay 0.3.0 → 0.4.1

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Portable HTTP relay for the [DFOS protocol](https://protocol.dfos.com). Receives, verifies, stores, and serves identity chains, content chains, beacons, countersignatures, and content blobs.
4
4
 
5
- See [RELAY.md](./RELAY.md) for the full protocol specification.
5
+ See [RELAY.md](./RELAY.md) for the full relay specification.
6
6
 
7
7
  ## Install
8
8
 
@@ -12,6 +12,8 @@ npm install @metalabel/dfos-web-relay @metalabel/dfos-protocol
12
12
 
13
13
  ## Usage
14
14
 
15
+ ### Embedded (Hono app)
16
+
15
17
  ```typescript
16
18
  import { createRelay, MemoryRelayStore } from '@metalabel/dfos-web-relay';
17
19
 
@@ -24,6 +26,77 @@ const relay = createRelay({
24
26
  export default relay;
25
27
  ```
26
28
 
29
+ ### Standalone (Node.js)
30
+
31
+ ```typescript
32
+ import { serve } from '@metalabel/dfos-web-relay/node';
33
+
34
+ serve({ port: 4444 });
35
+ ```
36
+
37
+ ## Routes
38
+
39
+ | Method | Path | Description |
40
+ | ------ | ---------------------------------------- | ---------------------------------------------------------------- |
41
+ | `GET` | `/.well-known/dfos-relay` | Relay metadata (DID, protocol version) |
42
+ | `POST` | `/operations` | Submit signed operations (identity, content, beacon, countersig) |
43
+ | `GET` | `/identities/:did` | Get identity chain state and operation log |
44
+ | `GET` | `/content/:contentId` | Get content chain state and operation log |
45
+ | `GET` | `/operations/:cid` | Get a single operation by CID |
46
+ | `GET` | `/beacons/:did` | Get beacon for an identity |
47
+ | `GET` | `/countersignatures/:cid` | Get countersignatures for an operation |
48
+ | `GET` | `/operations/:cid/countersignatures` | Same as above (alias) |
49
+ | `PUT` | `/content/:contentId/blob/:operationCID` | Upload blob (auth required) |
50
+ | `GET` | `/content/:contentId/blob` | Download blob at head (auth + credential) |
51
+ | `GET` | `/content/:contentId/blob/:ref` | Download blob at specific operation ref |
52
+
53
+ ## Blob Authorization
54
+
55
+ **Upload**: Auth token required. Caller must be the chain creator or the signer of the referenced operation (enables delegated upload).
56
+
57
+ **Download**: Auth token required. Chain creator can download directly. Other identities must present a `DFOSContentRead` VC-JWT credential (issued by the creator) in the `X-Credential` header.
58
+
59
+ ## Conformance Test Suite
60
+
61
+ The `conformance/` directory contains a Go integration test suite that exercises the full relay HTTP surface. It runs against any live relay via the `RELAY_URL` environment variable.
62
+
63
+ ```bash
64
+ # Run against a local relay
65
+ RELAY_URL=http://localhost:4444 go test -v -count=1 ./conformance/
66
+
67
+ # Run against a remote relay
68
+ RELAY_URL=https://registry.imajin.ai/relay go test -v -count=1 ./conformance/
69
+ ```
70
+
71
+ 71 tests covering:
72
+
73
+ - Well-known discovery
74
+ - Identity lifecycle (create, update, delete, batch, idempotency, controller key rotation)
75
+ - Content lifecycle (create, update, delete, fork rejection, post-delete rejection, notes, long chains)
76
+ - Content update after auth key rotation, multiple independent chains
77
+ - Operations by CID
78
+ - Beacons (create, replacement, not-found, unknown/deleted identity)
79
+ - Countersignatures (dedup, empty result, multi-witness, self-countersign, non-existent operation)
80
+ - Blob upload/download (CID verification, auth, credential-based access, multi-version, idempotent upload)
81
+ - Delegated content operations (write credentials, delegated blob upload, delegated delete)
82
+ - Credentials (expiry, scope mismatch, type enforcement, deleted issuer behavior)
83
+ - Signature verification (tampered signature, wrong signing key)
84
+ - Auth edge cases (wrong audience, expired token, rotated-out key)
85
+ - Batch processing (3-step dependency sort, content-identity sort, large batch, dedup, mixed valid/invalid, multi-chain)
86
+ - Input validation (malformed JSON, empty operations, invalid JWS)
87
+
88
+ The conformance suite depends on [`dfos-protocol-go`](../dfos-protocol-go) for protocol operations.
89
+
90
+ ## Custom Store
91
+
92
+ Implement the `RelayStore` interface to use any persistence backend:
93
+
94
+ ```typescript
95
+ import type { RelayStore } from '@metalabel/dfos-web-relay';
96
+ ```
97
+
98
+ `MemoryRelayStore` is provided as a reference implementation and for testing.
99
+
27
100
  ## License
28
101
 
29
102
  MIT
package/RELAY.md CHANGED
@@ -84,7 +84,23 @@ Each operation is verified against the relay's stored state:
84
84
 
85
85
  First-seen-wins. If a chain head has already advanced past the `previousOperationCID` referenced by an incoming operation, the new operation is rejected. The relay does not attempt to resolve forks — the first valid extension wins.
86
86
 
87
- Duplicate submissions (same operation CID) are silently accepted (idempotent).
87
+ Duplicate submissions (same operation CID, same JWS token) are silently accepted (idempotent). Submissions with the same CID but a different JWS token are rejected — since Ed25519 is deterministic, a different token for the same payload means a different signing key, which is either a self-countersign attempt or an unauthorized re-sign.
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
+
91
+ ### Deletion Semantics
92
+
93
+ Deletion means the identity stops being an active participant. Historical operations remain verifiable — keys persist in state for signature verification — but no new acts flow from a deleted identity.
94
+
95
+ Specifically:
96
+
97
+ - **Identity operations after deletion**: Rejected. A deleted identity chain is sealed.
98
+ - **Content operations after deletion**: Rejected. A deleted content chain is sealed.
99
+ - **Beacons from deleted identities**: Rejected. A deleted identity MUST NOT publish new beacons.
100
+ - **Credentials from deleted issuers**: Rejected. Identity deletion revokes all authority, including outstanding `DFOSContentRead` and `DFOSContentWrite` credentials issued by the deleted identity. Credentials that were valid at time of issuance cease to be honored once the issuer is deleted.
101
+ - **Countersignatures on existing operations**: Still accepted. Deletion of the original author does not prevent other identities from attesting to operations that already exist in the relay.
102
+
103
+ Self-countersignatures — where the witness DID matches the operation author DID — are rejected. A countersignature's semantic is "a distinct witness attests." Signing your own operation is redundant with the original signature.
88
104
 
89
105
  ### Result Ordering
90
106
 
@@ -105,16 +121,18 @@ The relay does not currently sign operations or participate in the protocol as a
105
121
 
106
122
  ## Content Plane Access
107
123
 
108
- ### Blob Upload (`PUT /content/:contentId/blob`)
124
+ ### Blob Upload (`PUT /content/:contentId/blob/:operationCID`)
125
+
126
+ The upload path mirrors the download path — the operation CID identifies which operation's document is being uploaded.
109
127
 
110
128
  Requirements:
111
129
 
112
130
  - 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)
131
+ - The operation CID must reference an operation in this content chain that has a `documentCID`
132
+ - The authenticated DID must be either the chain creator OR the signer of the referenced operation (enabling delegated uploads)
133
+ - The uploaded bytes must hash to the operation's `documentCID` (dag-cbor + sha-256 verification)
116
134
 
117
- Blobs are stored by `(creatorDID, documentCID)` — if multiple content chains by the same creator reference the same document, the blob is shared (deduplication).
135
+ 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
136
 
119
137
  ### Blob Download (`GET /content/:contentId/blob[/:ref]`)
120
138
 
@@ -196,7 +214,7 @@ The returned Hono app exposes:
196
214
  | `GET` | `/identities/:did` | proof | none |
197
215
  | `GET` | `/content/:contentId` | proof | none |
198
216
  | `GET` | `/beacons/:did` | proof | none |
199
- | `PUT` | `/content/:contentId/blob` | content | auth token |
217
+ | `PUT` | `/content/:contentId/blob/:opCID` | content | auth token |
200
218
  | `GET` | `/content/:contentId/blob[/:ref]` | content | auth token + credential |
201
219
 
202
220
  ---
package/dist/index.js CHANGED
@@ -135,7 +135,16 @@ var ingestIdentityOp = async (jwsToken, store) => {
135
135
  const encoded = await dagCborCanonicalEncode(payload);
136
136
  const cid = encoded.cid.toString();
137
137
  const existing = await store.getOperation(cid);
138
- if (existing) return { cid, status: "accepted", kind: "identity-op", chainId: existing.chainId };
138
+ if (existing) {
139
+ if (existing.jwsToken !== jwsToken) {
140
+ return {
141
+ cid,
142
+ status: "rejected",
143
+ error: "operation already exists with a different signature"
144
+ };
145
+ }
146
+ return { cid, status: "accepted", kind: "identity-op", chainId: existing.chainId };
147
+ }
139
148
  const opType = payload["type"];
140
149
  const isGenesis = opType === "create";
141
150
  if (isGenesis) {
@@ -165,7 +174,23 @@ var ingestContentOp = async (jwsToken, store) => {
165
174
  const encoded = await dagCborCanonicalEncode(payload);
166
175
  const cid = encoded.cid.toString();
167
176
  const existing = await store.getOperation(cid);
168
- if (existing) return { cid, status: "accepted", kind: "content-op", chainId: existing.chainId };
177
+ if (existing) {
178
+ if (existing.jwsToken !== jwsToken) {
179
+ return {
180
+ cid,
181
+ status: "rejected",
182
+ error: "operation already exists with a different signature"
183
+ };
184
+ }
185
+ return { cid, status: "accepted", kind: "content-op", chainId: existing.chainId };
186
+ }
187
+ const signerDID = payload["did"];
188
+ if (typeof signerDID === "string") {
189
+ const signerIdentity = await store.getIdentityChain(signerDID);
190
+ if (signerIdentity?.state.isDeleted) {
191
+ return { cid, status: "rejected", error: "signer identity is deleted" };
192
+ }
193
+ }
169
194
  const resolveKey = createKeyResolver(store);
170
195
  const opType = payload["type"];
171
196
  const isGenesis = opType === "create";
@@ -198,6 +223,10 @@ var ingestContentOp = async (jwsToken, store) => {
198
223
  const chain = await store.getContentChain(prevOp.chainId);
199
224
  if (!chain)
200
225
  return { cid, status: "rejected", error: `content chain not found: ${prevOp.chainId}` };
226
+ const creatorIdentity = await store.getIdentityChain(chain.state.creatorDID);
227
+ if (creatorIdentity?.state.isDeleted) {
228
+ return { cid, status: "rejected", error: "content creator identity is deleted" };
229
+ }
201
230
  if (chain.state.headCID !== previousCID) {
202
231
  return { cid, status: "rejected", error: "chain has diverged (first-seen-wins)" };
203
232
  }
@@ -228,6 +257,10 @@ var ingestBeacon = async (jwsToken, store) => {
228
257
  }
229
258
  const did = verified.payload.did;
230
259
  const cid = verified.beaconCID;
260
+ const identity = await store.getIdentityChain(did);
261
+ if (identity?.state.isDeleted) {
262
+ return { cid, status: "rejected", error: "identity is deleted" };
263
+ }
231
264
  const existing = await store.getBeacon(did);
232
265
  if (existing) {
233
266
  const existingTime = new Date(existing.state.payload.createdAt).getTime();
@@ -512,32 +545,29 @@ var createRelay = (options) => {
512
545
  payload: beacon.state.payload
513
546
  });
514
547
  });
515
- app.put("/content/:contentId/blob", async (c) => {
548
+ app.put("/content/:contentId/blob/:operationCID", async (c) => {
516
549
  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
- }
550
+ const operationCID = c.req.param("operationCID");
521
551
  const auth = await authenticateRequest(c.req.header("authorization"), relayDID, store);
522
552
  if (!auth) return c.json({ error: "authentication required" }, 401);
523
553
  const chain = await store.getContentChain(contentId);
524
554
  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) {
555
+ let documentCID = null;
556
+ let operationSignerDID = null;
557
+ for (const token of chain.log) {
531
558
  const decoded = decodeJwsUnsafe3(token);
532
559
  if (!decoded) continue;
560
+ if (decoded.header.cid !== operationCID) continue;
533
561
  const payload = decoded.payload;
534
- if (payload["documentCID"] === documentCID) {
535
- documentReferenced = true;
536
- break;
537
- }
562
+ documentCID = typeof payload["documentCID"] === "string" ? payload["documentCID"] : null;
563
+ operationSignerDID = typeof payload["did"] === "string" ? payload["did"] : null;
564
+ break;
565
+ }
566
+ if (!documentCID) {
567
+ return c.json({ error: "operation not found in chain or has no documentCID" }, 404);
538
568
  }
539
- if (!documentReferenced) {
540
- return c.json({ error: "documentCID not referenced in chain" }, 400);
569
+ if (auth.iss !== chain.state.creatorDID && auth.iss !== operationSignerDID) {
570
+ return c.json({ error: "not authorized \u2014 must be chain creator or operation signer" }, 403);
541
571
  }
542
572
  const bytes = new Uint8Array(await c.req.arrayBuffer());
543
573
  try {
@@ -549,8 +579,8 @@ var createRelay = (options) => {
549
579
  } catch {
550
580
  return c.json({ error: "blob bytes do not match documentCID" }, 400);
551
581
  }
552
- await store.putBlob({ creatorDID: auth.iss, documentCID }, bytes);
553
- return c.json({ status: "stored", contentId, documentCID });
582
+ await store.putBlob({ creatorDID: chain.state.creatorDID, documentCID }, bytes);
583
+ return c.json({ status: "stored", contentId, documentCID, operationCID });
554
584
  });
555
585
  app.get("/content/:contentId/blob", async (c) => {
556
586
  return await readBlob({
@@ -630,6 +660,10 @@ var verifyReadCredential = async (auth, chain, contentId, credHeader, store) =>
630
660
  if (vcIssuerDID !== chain.state.creatorDID) {
631
661
  throw new Error("credential must be issued by the chain creator");
632
662
  }
663
+ const issuerIdentity = await store.getIdentityChain(vcIssuerDID);
664
+ if (issuerIdentity?.state.isDeleted) {
665
+ throw new Error("credential issuer identity is deleted");
666
+ }
633
667
  const creatorKey = await resolveKey(vcHeader.kid);
634
668
  const credential = verifyCredential({
635
669
  token: credHeader,
@@ -651,6 +685,7 @@ var verifyReadCredential = async (auth, chain, contentId, credHeader, store) =>
651
685
  };
652
686
 
653
687
  // src/store.ts
688
+ import { decodeJwsUnsafe as decodeJwsUnsafe4 } from "@metalabel/dfos-protocol/crypto";
654
689
  var blobKeyString = (key) => `${key.creatorDID}::${key.documentCID}`;
655
690
  var MemoryRelayStore = class {
656
691
  operations = /* @__PURE__ */ new Map();
@@ -694,7 +729,18 @@ var MemoryRelayStore = class {
694
729
  }
695
730
  async addCountersignature(operationCID, jwsToken) {
696
731
  const existing = this.countersignatures.get(operationCID) ?? [];
697
- if (existing.includes(jwsToken)) return;
732
+ const decoded = decodeJwsUnsafe4(jwsToken);
733
+ if (decoded) {
734
+ const kid = decoded.header.kid;
735
+ const witnessDID = kid.includes("#") ? kid.split("#")[0] : kid;
736
+ for (const cs of existing) {
737
+ const d = decodeJwsUnsafe4(cs);
738
+ if (!d) continue;
739
+ const existingKid = d.header.kid;
740
+ const existingDID = existingKid.includes("#") ? existingKid.split("#")[0] : existingKid;
741
+ if (existingDID === witnessDID) return;
742
+ }
743
+ }
698
744
  existing.push(jwsToken);
699
745
  this.countersignatures.set(operationCID, existing);
700
746
  }
@@ -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.1",
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",