@metalabel/dfos-web-relay 0.4.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,10 +84,24 @@ 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
88
 
89
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
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.
104
+
91
105
  ### Result Ordering
92
106
 
93
107
  Ingestion results are returned in the same order as the input `operations` array, regardless of internal processing order. `results[i]` corresponds to `operations[i]`.
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();
@@ -627,6 +660,10 @@ var verifyReadCredential = async (auth, chain, contentId, credHeader, store) =>
627
660
  if (vcIssuerDID !== chain.state.creatorDID) {
628
661
  throw new Error("credential must be issued by the chain creator");
629
662
  }
663
+ const issuerIdentity = await store.getIdentityChain(vcIssuerDID);
664
+ if (issuerIdentity?.state.isDeleted) {
665
+ throw new Error("credential issuer identity is deleted");
666
+ }
630
667
  const creatorKey = await resolveKey(vcHeader.kid);
631
668
  const credential = verifyCredential({
632
669
  token: credHeader,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metalabel/dfos-web-relay",
3
- "version": "0.4.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",