@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 +74 -1
- package/RELAY.md +15 -1
- package/dist/index.js +39 -2
- package/package.json +1 -1
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
|
|
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)
|
|
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)
|
|
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