@metalabel/dfos-web-relay 0.7.1 → 0.8.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/README.md +2 -34
- package/dist/index.d.ts +91 -3
- package/dist/index.js +393 -55
- package/openapi.yaml +225 -48
- package/package.json +5 -6
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @metalabel/dfos-web-relay
|
|
2
2
|
|
|
3
|
-
Portable HTTP relay for the [DFOS protocol](https://protocol.dfos.com).
|
|
3
|
+
Relays verify everything they receive and serve everything they've verified. No trust between relays, no hierarchy, no central authority. Topology is emergent. Portable HTTP relay for the [DFOS protocol](https://protocol.dfos.com).
|
|
4
4
|
|
|
5
5
|
See [WEB-RELAY.md](../../specs/WEB-RELAY.md) for the full relay specification.
|
|
6
6
|
|
|
@@ -56,39 +56,7 @@ serve({ port: 4444 });
|
|
|
56
56
|
|
|
57
57
|
**Upload**: Auth token required. Caller must be the chain creator or the signer of the referenced operation (enables delegated upload).
|
|
58
58
|
|
|
59
|
-
**Download**: Auth token required. Chain creator can download directly. Other identities must present a
|
|
60
|
-
|
|
61
|
-
## Conformance Test Suite
|
|
62
|
-
|
|
63
|
-
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.
|
|
64
|
-
|
|
65
|
-
```bash
|
|
66
|
-
# Run against a local relay
|
|
67
|
-
RELAY_URL=http://localhost:4444 go test -v -count=1 ./conformance/
|
|
68
|
-
|
|
69
|
-
# Run against a remote relay
|
|
70
|
-
RELAY_URL=https://registry.imajin.ai/relay go test -v -count=1 ./conformance/
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
77 tests covering:
|
|
74
|
-
|
|
75
|
-
- Well-known discovery
|
|
76
|
-
- Identity lifecycle (create, update, delete, batch, idempotency, controller key rotation)
|
|
77
|
-
- Content lifecycle (create, update, delete, fork acceptance, DAG logs, deterministic head selection, post-delete rejection, notes, long chains)
|
|
78
|
-
- Content update after auth key rotation, multiple independent chains
|
|
79
|
-
- Operations by CID
|
|
80
|
-
- Beacons (create, replacement, not-found, unknown/deleted identity)
|
|
81
|
-
- Countersignatures (dedup, empty result, multi-witness, self-countersign, non-existent operation)
|
|
82
|
-
- Blob upload/download (CID verification, auth, credential-based access, multi-version, idempotent upload)
|
|
83
|
-
- Delegated content operations (write credentials, delegated blob upload, delegated delete)
|
|
84
|
-
- Credentials (expiry, scope mismatch, type enforcement, deleted issuer behavior)
|
|
85
|
-
- Signature verification (tampered signature, wrong signing key)
|
|
86
|
-
- Auth edge cases (wrong audience, expired token, rotated-out key)
|
|
87
|
-
- Batch processing (3-step dependency sort, content-identity sort, large batch, dedup, mixed valid/invalid, multi-chain)
|
|
88
|
-
- Input validation (malformed JSON, empty operations, invalid JWS)
|
|
89
|
-
- Future timestamp guard (reject identity/content ops >24h ahead)
|
|
90
|
-
|
|
91
|
-
The conformance suite depends on [`dfos-protocol-go`](../dfos-protocol-go) for protocol operations.
|
|
59
|
+
**Download**: Auth token required. Chain creator can download directly. Other identities must present a DFOS read credential (issued by the creator) in the `X-Credential` header.
|
|
92
60
|
|
|
93
61
|
## Peering
|
|
94
62
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { VerifiedIdentity, VerifiedContentChain, VerifiedBeacon } from '@metalabel/dfos-protocol/chain';
|
|
2
|
+
import { Attenuation } from '@metalabel/dfos-protocol/credentials';
|
|
2
3
|
import { Hono } from 'hono';
|
|
3
4
|
|
|
4
5
|
interface RelayIdentity {
|
|
@@ -93,7 +94,7 @@ interface StoredOperation {
|
|
|
93
94
|
cid: string;
|
|
94
95
|
jwsToken: string;
|
|
95
96
|
/** Which chain type this operation belongs to */
|
|
96
|
-
chainType: 'identity' | 'content' | 'artifact' | 'beacon' | 'countersign';
|
|
97
|
+
chainType: 'identity' | 'content' | 'artifact' | 'beacon' | 'countersign' | 'revocation' | 'credential';
|
|
97
98
|
/** The chain identifier — DID for identity/beacon/artifact, contentId for content, targetCID for countersign */
|
|
98
99
|
chainId: string;
|
|
99
100
|
}
|
|
@@ -110,7 +111,27 @@ interface LogEntry {
|
|
|
110
111
|
chainId: string;
|
|
111
112
|
}
|
|
112
113
|
/** All operation kinds in the protocol */
|
|
113
|
-
type OperationKind = 'identity-op' | 'content-op' | 'beacon' | 'artifact' | 'countersign';
|
|
114
|
+
type OperationKind = 'identity-op' | 'content-op' | 'beacon' | 'artifact' | 'countersign' | 'revocation' | 'credential';
|
|
115
|
+
interface StoredRevocation {
|
|
116
|
+
cid: string;
|
|
117
|
+
issuerDID: string;
|
|
118
|
+
credentialCID: string;
|
|
119
|
+
jwsToken: string;
|
|
120
|
+
}
|
|
121
|
+
interface StoredDocument {
|
|
122
|
+
operationCID: string;
|
|
123
|
+
documentCID: string | null;
|
|
124
|
+
document: unknown | null;
|
|
125
|
+
signerDID: string;
|
|
126
|
+
createdAt: string;
|
|
127
|
+
}
|
|
128
|
+
interface StoredPublicCredential {
|
|
129
|
+
cid: string;
|
|
130
|
+
issuerDID: string;
|
|
131
|
+
att: Attenuation[];
|
|
132
|
+
exp: number;
|
|
133
|
+
jwsToken: string;
|
|
134
|
+
}
|
|
114
135
|
/**
|
|
115
136
|
* Storage backend for a DFOS web relay
|
|
116
137
|
*
|
|
@@ -164,6 +185,26 @@ interface RelayStore {
|
|
|
164
185
|
state: VerifiedContentChain;
|
|
165
186
|
lastCreatedAt: string;
|
|
166
187
|
} | null>;
|
|
188
|
+
/** Get all revoked credential CIDs for an issuer */
|
|
189
|
+
getRevocations(issuerDID: string): Promise<string[]>;
|
|
190
|
+
/** Add a revocation to the revocation set */
|
|
191
|
+
addRevocation(revocation: StoredRevocation): Promise<void>;
|
|
192
|
+
/** Check if a specific credential CID has been revoked by a specific issuer */
|
|
193
|
+
isCredentialRevoked(issuerDID: string, credentialCID: string): Promise<boolean>;
|
|
194
|
+
/** Get public credentials covering a specific resource */
|
|
195
|
+
getPublicCredentials(resource: string): Promise<string[]>;
|
|
196
|
+
/** Add a public credential as standing authorization */
|
|
197
|
+
addPublicCredential(credential: StoredPublicCredential): Promise<void>;
|
|
198
|
+
/** Remove a public credential (e.g., after revocation) */
|
|
199
|
+
removePublicCredential(credentialCID: string): Promise<void>;
|
|
200
|
+
/** Get paginated documents for a content chain */
|
|
201
|
+
getDocuments(contentId: string, params: {
|
|
202
|
+
after?: string;
|
|
203
|
+
limit: number;
|
|
204
|
+
}): Promise<{
|
|
205
|
+
documents: StoredDocument[];
|
|
206
|
+
cursor: string | null;
|
|
207
|
+
}>;
|
|
167
208
|
/** Get last-synced log cursor for a peer relay */
|
|
168
209
|
getPeerCursor(peerUrl: string): Promise<string | undefined>;
|
|
169
210
|
/** Update last-synced log cursor for a peer relay */
|
|
@@ -249,6 +290,10 @@ declare class MemoryRelayStore implements RelayStore {
|
|
|
249
290
|
private countersignatures;
|
|
250
291
|
private operationLog;
|
|
251
292
|
private peerCursors;
|
|
293
|
+
/** Keyed by `issuerDID::credentialCID` for issuer-scoped revocation */
|
|
294
|
+
private revocations;
|
|
295
|
+
/** Keyed by credential CID */
|
|
296
|
+
private publicCredentials;
|
|
252
297
|
getOperation(cid: string): Promise<StoredOperation | undefined>;
|
|
253
298
|
putOperation(op: StoredOperation): Promise<void>;
|
|
254
299
|
getIdentityChain(did: string): Promise<StoredIdentityChain | undefined>;
|
|
@@ -261,6 +306,19 @@ declare class MemoryRelayStore implements RelayStore {
|
|
|
261
306
|
putBlob(key: BlobKey, data: Uint8Array): Promise<void>;
|
|
262
307
|
getCountersignatures(operationCID: string): Promise<string[]>;
|
|
263
308
|
addCountersignature(operationCID: string, jwsToken: string): Promise<void>;
|
|
309
|
+
getRevocations(issuerDID: string): Promise<string[]>;
|
|
310
|
+
addRevocation(revocation: StoredRevocation): Promise<void>;
|
|
311
|
+
isCredentialRevoked(issuerDID: string, credentialCID: string): Promise<boolean>;
|
|
312
|
+
getPublicCredentials(resource: string): Promise<string[]>;
|
|
313
|
+
addPublicCredential(credential: StoredPublicCredential): Promise<void>;
|
|
314
|
+
removePublicCredential(credentialCID: string): Promise<void>;
|
|
315
|
+
getDocuments(contentId: string, params: {
|
|
316
|
+
after?: string;
|
|
317
|
+
limit: number;
|
|
318
|
+
}): Promise<{
|
|
319
|
+
documents: StoredDocument[];
|
|
320
|
+
cursor: string | null;
|
|
321
|
+
}>;
|
|
264
322
|
appendToLog(entry: LogEntry): Promise<void>;
|
|
265
323
|
readLog(params: {
|
|
266
324
|
after?: string;
|
|
@@ -303,6 +361,36 @@ declare class MemoryRelayStore implements RelayStore {
|
|
|
303
361
|
* deterministic CIDs for identical payloads.
|
|
304
362
|
*/
|
|
305
363
|
declare const createKeyResolver: (store: RelayStore) => (kid: string) => Promise<Uint8Array>;
|
|
364
|
+
/**
|
|
365
|
+
* Create an identity resolver that includes all historical keys.
|
|
366
|
+
*
|
|
367
|
+
* Credentials are long-lived artifacts — their validity persists across key
|
|
368
|
+
* rotations. This resolver walks the full identity chain log to collect all
|
|
369
|
+
* keys that have ever appeared in create and update operations, ensuring
|
|
370
|
+
* credentials signed by rotated-out keys still verify. Revocation (not key
|
|
371
|
+
* rotation) is the invalidation mechanism.
|
|
372
|
+
*
|
|
373
|
+
* Used for credential verification at both ingestion and access-check time.
|
|
374
|
+
*/
|
|
375
|
+
declare const createHistoricalIdentityResolver: (store: RelayStore) => (did: string) => Promise<{
|
|
376
|
+
authKeys: {
|
|
377
|
+
id: string;
|
|
378
|
+
type: "Multikey";
|
|
379
|
+
publicKeyMultibase: string;
|
|
380
|
+
}[];
|
|
381
|
+
assertKeys: {
|
|
382
|
+
id: string;
|
|
383
|
+
type: "Multikey";
|
|
384
|
+
publicKeyMultibase: string;
|
|
385
|
+
}[];
|
|
386
|
+
controllerKeys: {
|
|
387
|
+
id: string;
|
|
388
|
+
type: "Multikey";
|
|
389
|
+
publicKeyMultibase: string;
|
|
390
|
+
}[];
|
|
391
|
+
did: string;
|
|
392
|
+
isDeleted: boolean;
|
|
393
|
+
} | undefined>;
|
|
306
394
|
/**
|
|
307
395
|
* Create a key resolver that only resolves current-state keys.
|
|
308
396
|
*
|
|
@@ -339,4 +427,4 @@ declare const sequenceOps: (store: RelayStore) => Promise<{
|
|
|
339
427
|
result: SequenceResult;
|
|
340
428
|
}>;
|
|
341
429
|
|
|
342
|
-
export { type BlobKey, type CreatedRelay, type IngestionResult, type LogEntry, MemoryRelayStore, type OperationKind, type PeerClient, type PeerConfig, type PeerLogEntry, type RelayIdentity, type RelayOptions, type RelayStore, type SequenceResult, type StoredBeacon, type StoredContentChain, type StoredIdentityChain, type StoredOperation, bootstrapRelayIdentity, computeOpCID, createCurrentKeyResolver, createHttpPeerClient, createKeyResolver, createRelay, ingestOperations, isDependencyFailure, sequenceOps };
|
|
430
|
+
export { type BlobKey, type CreatedRelay, type IngestionResult, type LogEntry, MemoryRelayStore, type OperationKind, type PeerClient, type PeerConfig, type PeerLogEntry, type RelayIdentity, type RelayOptions, type RelayStore, type SequenceResult, type StoredBeacon, type StoredContentChain, type StoredIdentityChain, type StoredOperation, bootstrapRelayIdentity, computeOpCID, createCurrentKeyResolver, createHistoricalIdentityResolver, createHttpPeerClient, createKeyResolver, createRelay, ingestOperations, isDependencyFailure, sequenceOps };
|
package/dist/index.js
CHANGED
|
@@ -19,8 +19,12 @@ import {
|
|
|
19
19
|
verifyContentExtensionFromTrustedState,
|
|
20
20
|
verifyCountersignature,
|
|
21
21
|
verifyIdentityChain,
|
|
22
|
-
verifyIdentityExtensionFromTrustedState
|
|
22
|
+
verifyIdentityExtensionFromTrustedState,
|
|
23
|
+
verifyRevocation
|
|
23
24
|
} from "@metalabel/dfos-protocol/chain";
|
|
25
|
+
import {
|
|
26
|
+
verifyDFOSCredential
|
|
27
|
+
} from "@metalabel/dfos-protocol/credentials";
|
|
24
28
|
import { dagCborCanonicalEncode, decodeJwsUnsafe } from "@metalabel/dfos-protocol/crypto";
|
|
25
29
|
var MAX_FUTURE_TIMESTAMP_MS = 24 * 60 * 60 * 1e3;
|
|
26
30
|
var isFutureTimestamp = (createdAt) => {
|
|
@@ -91,6 +95,31 @@ var classify = (jwsToken) => {
|
|
|
91
95
|
previousCID: null
|
|
92
96
|
};
|
|
93
97
|
}
|
|
98
|
+
if (typ === "did:dfos:revocation") {
|
|
99
|
+
const revocationDID = typeof payload["did"] === "string" ? payload["did"] : null;
|
|
100
|
+
return {
|
|
101
|
+
...base,
|
|
102
|
+
kind: "revocation",
|
|
103
|
+
referencedDID: revocationDID,
|
|
104
|
+
signerDID: null,
|
|
105
|
+
priority: 1,
|
|
106
|
+
// same as beacons — needs identity keys to verify
|
|
107
|
+
previousCID: null
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
if (typ === "did:dfos:credential") {
|
|
111
|
+
const aud = typeof payload["aud"] === "string" ? payload["aud"] : null;
|
|
112
|
+
if (aud !== "*") return unknown;
|
|
113
|
+
return {
|
|
114
|
+
...base,
|
|
115
|
+
kind: "credential",
|
|
116
|
+
referencedDID: kidDID,
|
|
117
|
+
signerDID: null,
|
|
118
|
+
priority: 1,
|
|
119
|
+
// needs identity keys to verify
|
|
120
|
+
previousCID: null
|
|
121
|
+
};
|
|
122
|
+
}
|
|
94
123
|
return unknown;
|
|
95
124
|
};
|
|
96
125
|
var createKeyResolver = (store) => async (kid) => {
|
|
@@ -126,6 +155,42 @@ var createKeyResolver = (store) => async (kid) => {
|
|
|
126
155
|
}
|
|
127
156
|
throw new Error(`unknown key ${keyId} on identity ${did}`);
|
|
128
157
|
};
|
|
158
|
+
var createHistoricalIdentityResolver = (store) => async (did) => {
|
|
159
|
+
const chain = await store.getIdentityChain(did);
|
|
160
|
+
if (!chain) return void 0;
|
|
161
|
+
const { state, log } = chain;
|
|
162
|
+
const keyMaps = {
|
|
163
|
+
authKeys: new Map(state.authKeys.map((k) => [k.id, k])),
|
|
164
|
+
assertKeys: new Map(state.assertKeys.map((k) => [k.id, k])),
|
|
165
|
+
controllerKeys: new Map(state.controllerKeys.map((k) => [k.id, k]))
|
|
166
|
+
};
|
|
167
|
+
for (const token of log) {
|
|
168
|
+
const decoded = decodeJwsUnsafe(token);
|
|
169
|
+
if (!decoded) continue;
|
|
170
|
+
const payload = decoded.payload;
|
|
171
|
+
const opType = payload["type"];
|
|
172
|
+
if (opType !== "create" && opType !== "update") continue;
|
|
173
|
+
for (const arrayName of ["authKeys", "assertKeys", "controllerKeys"]) {
|
|
174
|
+
const keys = payload[arrayName];
|
|
175
|
+
if (!Array.isArray(keys)) continue;
|
|
176
|
+
const map = keyMaps[arrayName];
|
|
177
|
+
for (const k of keys) {
|
|
178
|
+
if (k && typeof k === "object" && "id" in k && "publicKeyMultibase" in k && !map.has(k.id)) {
|
|
179
|
+
map.set(
|
|
180
|
+
k.id,
|
|
181
|
+
k
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
...state,
|
|
189
|
+
authKeys: [...keyMaps.authKeys.values()],
|
|
190
|
+
assertKeys: [...keyMaps.assertKeys.values()],
|
|
191
|
+
controllerKeys: [...keyMaps.controllerKeys.values()]
|
|
192
|
+
};
|
|
193
|
+
};
|
|
129
194
|
var createCurrentKeyResolver = (store) => async (kid) => {
|
|
130
195
|
const hashIdx = kid.indexOf("#");
|
|
131
196
|
if (hashIdx < 0) throw new Error(`kid must be a DID URL: ${kid}`);
|
|
@@ -279,13 +344,15 @@ var ingestContentOp = async (jwsToken, store, logEnabled) => {
|
|
|
279
344
|
}
|
|
280
345
|
}
|
|
281
346
|
const resolveKey = createKeyResolver(store);
|
|
347
|
+
const resolveIdentity = createHistoricalIdentityResolver(store);
|
|
282
348
|
const opType = payload["type"];
|
|
283
349
|
const isGenesis = opType === "create";
|
|
284
350
|
if (isGenesis) {
|
|
285
351
|
const content = await verifyContentChain({
|
|
286
352
|
log: [jwsToken],
|
|
287
353
|
resolveKey,
|
|
288
|
-
enforceAuthorization: true
|
|
354
|
+
enforceAuthorization: true,
|
|
355
|
+
resolveIdentity
|
|
289
356
|
});
|
|
290
357
|
const createdAt = payload["createdAt"];
|
|
291
358
|
const chain2 = {
|
|
@@ -325,7 +392,8 @@ var ingestContentOp = async (jwsToken, store, logEnabled) => {
|
|
|
325
392
|
lastCreatedAt: chain.lastCreatedAt,
|
|
326
393
|
newOp: jwsToken,
|
|
327
394
|
resolveKey,
|
|
328
|
-
enforceAuthorization: true
|
|
395
|
+
enforceAuthorization: true,
|
|
396
|
+
resolveIdentity
|
|
329
397
|
});
|
|
330
398
|
const updated2 = {
|
|
331
399
|
contentId: chain.contentId,
|
|
@@ -353,7 +421,8 @@ var ingestContentOp = async (jwsToken, store, logEnabled) => {
|
|
|
353
421
|
lastCreatedAt: forkState.lastCreatedAt,
|
|
354
422
|
newOp: jwsToken,
|
|
355
423
|
resolveKey,
|
|
356
|
-
enforceAuthorization: true
|
|
424
|
+
enforceAuthorization: true,
|
|
425
|
+
resolveIdentity
|
|
357
426
|
});
|
|
358
427
|
const updatedLog = [...chain.log, jwsToken];
|
|
359
428
|
const head = selectDeterministicHead(updatedLog);
|
|
@@ -386,7 +455,7 @@ var ingestBeacon = async (jwsToken, store, logEnabled) => {
|
|
|
386
455
|
const message = err instanceof Error ? err.message : "verification failed";
|
|
387
456
|
return { cid: "", status: "rejected", error: message };
|
|
388
457
|
}
|
|
389
|
-
const did = verified.
|
|
458
|
+
const did = verified.did;
|
|
390
459
|
const cid = verified.beaconCID;
|
|
391
460
|
const identity = await store.getIdentityChain(did);
|
|
392
461
|
if (identity?.state.isDeleted) {
|
|
@@ -394,8 +463,8 @@ var ingestBeacon = async (jwsToken, store, logEnabled) => {
|
|
|
394
463
|
}
|
|
395
464
|
const existing = await store.getBeacon(did);
|
|
396
465
|
if (existing) {
|
|
397
|
-
const existingTime = new Date(existing.state.
|
|
398
|
-
const newTime = new Date(verified.
|
|
466
|
+
const existingTime = new Date(existing.state.createdAt).getTime();
|
|
467
|
+
const newTime = new Date(verified.createdAt).getTime();
|
|
399
468
|
if (newTime <= existingTime) {
|
|
400
469
|
return { cid, status: "duplicate", kind: "beacon", chainId: did };
|
|
401
470
|
}
|
|
@@ -498,6 +567,86 @@ var ingestArtifact = async (jwsToken, store, logEnabled) => {
|
|
|
498
567
|
}
|
|
499
568
|
return { cid, status: "new", kind: "artifact", chainId: did };
|
|
500
569
|
};
|
|
570
|
+
var ingestRevocation = async (jwsToken, store, logEnabled) => {
|
|
571
|
+
const resolveKey = createKeyResolver(store);
|
|
572
|
+
let verified;
|
|
573
|
+
try {
|
|
574
|
+
verified = await verifyRevocation({ jwsToken, resolveKey });
|
|
575
|
+
} catch (err) {
|
|
576
|
+
const message = err instanceof Error ? err.message : "verification failed";
|
|
577
|
+
return { cid: "", status: "rejected", error: message };
|
|
578
|
+
}
|
|
579
|
+
const cid = verified.revocationCID;
|
|
580
|
+
const did = verified.did;
|
|
581
|
+
const existing = await store.getOperation(cid);
|
|
582
|
+
if (existing) {
|
|
583
|
+
if (existing.jwsToken !== jwsToken) {
|
|
584
|
+
return {
|
|
585
|
+
cid,
|
|
586
|
+
status: "rejected",
|
|
587
|
+
error: "revocation already exists with a different signature"
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
return { cid, status: "duplicate", kind: "revocation", chainId: did };
|
|
591
|
+
}
|
|
592
|
+
const identity = await store.getIdentityChain(did);
|
|
593
|
+
if (identity?.state.isDeleted) {
|
|
594
|
+
return { cid, status: "rejected", error: "identity is deleted" };
|
|
595
|
+
}
|
|
596
|
+
await store.addRevocation({
|
|
597
|
+
cid,
|
|
598
|
+
issuerDID: did,
|
|
599
|
+
credentialCID: verified.credentialCID,
|
|
600
|
+
jwsToken
|
|
601
|
+
});
|
|
602
|
+
await store.removePublicCredential(verified.credentialCID);
|
|
603
|
+
await store.putOperation({ cid, jwsToken, chainType: "revocation", chainId: did });
|
|
604
|
+
if (logEnabled) {
|
|
605
|
+
await store.appendToLog({ cid, jwsToken, kind: "revocation", chainId: did });
|
|
606
|
+
}
|
|
607
|
+
return { cid, status: "new", kind: "revocation", chainId: did };
|
|
608
|
+
};
|
|
609
|
+
var ingestPublicCredential = async (jwsToken, store, logEnabled) => {
|
|
610
|
+
const resolveIdentity = createHistoricalIdentityResolver(store);
|
|
611
|
+
let verified;
|
|
612
|
+
try {
|
|
613
|
+
verified = await verifyDFOSCredential(jwsToken, { resolveIdentity });
|
|
614
|
+
} catch (err) {
|
|
615
|
+
const message = err instanceof Error ? err.message : "verification failed";
|
|
616
|
+
return { cid: "", status: "rejected", error: message };
|
|
617
|
+
}
|
|
618
|
+
const cid = verified.credentialCID;
|
|
619
|
+
if (verified.aud !== "*") {
|
|
620
|
+
return { cid: "", status: "rejected", error: "not a public credential" };
|
|
621
|
+
}
|
|
622
|
+
const existing = await store.getOperation(cid);
|
|
623
|
+
if (existing) {
|
|
624
|
+
if (existing.jwsToken !== jwsToken) {
|
|
625
|
+
return {
|
|
626
|
+
cid,
|
|
627
|
+
status: "rejected",
|
|
628
|
+
error: "credential already exists with a different signature"
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
return { cid, status: "duplicate", kind: "credential", chainId: verified.iss };
|
|
632
|
+
}
|
|
633
|
+
const revoked = await store.isCredentialRevoked(verified.iss, cid);
|
|
634
|
+
if (revoked) {
|
|
635
|
+
return { cid, status: "rejected", error: "credential is revoked" };
|
|
636
|
+
}
|
|
637
|
+
await store.addPublicCredential({
|
|
638
|
+
cid,
|
|
639
|
+
issuerDID: verified.iss,
|
|
640
|
+
att: verified.att,
|
|
641
|
+
exp: verified.exp,
|
|
642
|
+
jwsToken
|
|
643
|
+
});
|
|
644
|
+
await store.putOperation({ cid, jwsToken, chainType: "credential", chainId: verified.iss });
|
|
645
|
+
if (logEnabled) {
|
|
646
|
+
await store.appendToLog({ cid, jwsToken, kind: "credential", chainId: verified.iss });
|
|
647
|
+
}
|
|
648
|
+
return { cid, status: "new", kind: "credential", chainId: verified.iss };
|
|
649
|
+
};
|
|
501
650
|
var dependencySort = (ops) => {
|
|
502
651
|
const buckets = /* @__PURE__ */ new Map();
|
|
503
652
|
for (const op of ops) {
|
|
@@ -607,6 +756,12 @@ var ingestOperations = async (tokens, store, options) => {
|
|
|
607
756
|
case "artifact":
|
|
608
757
|
result = await ingestArtifact(op.jwsToken, store, logEnabled);
|
|
609
758
|
break;
|
|
759
|
+
case "revocation":
|
|
760
|
+
result = await ingestRevocation(op.jwsToken, store, logEnabled);
|
|
761
|
+
break;
|
|
762
|
+
case "credential":
|
|
763
|
+
result = await ingestPublicCredential(op.jwsToken, store, logEnabled);
|
|
764
|
+
break;
|
|
610
765
|
default:
|
|
611
766
|
result = { cid: "", status: "rejected", error: "unrecognized operation type" };
|
|
612
767
|
}
|
|
@@ -722,13 +877,18 @@ var createHttpPeerClient = () => {
|
|
|
722
877
|
};
|
|
723
878
|
|
|
724
879
|
// src/relay.ts
|
|
725
|
-
import {
|
|
880
|
+
import { createRequire } from "module";
|
|
726
881
|
import { dagCborCanonicalEncode as dagCborCanonicalEncode3, decodeJwsUnsafe as decodeJwsUnsafe4 } from "@metalabel/dfos-protocol/crypto";
|
|
727
882
|
import { Hono } from "hono";
|
|
728
883
|
import { z } from "zod";
|
|
729
884
|
|
|
730
885
|
// src/auth.ts
|
|
731
|
-
import {
|
|
886
|
+
import {
|
|
887
|
+
matchesResource,
|
|
888
|
+
verifyAuthToken,
|
|
889
|
+
verifyDelegationChain,
|
|
890
|
+
verifyDFOSCredential as verifyDFOSCredential2
|
|
891
|
+
} from "@metalabel/dfos-protocol/credentials";
|
|
732
892
|
import { decodeJwsUnsafe as decodeJwsUnsafe2 } from "@metalabel/dfos-protocol/crypto";
|
|
733
893
|
var authenticateRequest = async (authHeader, relayDID, store) => {
|
|
734
894
|
if (!authHeader) return null;
|
|
@@ -752,6 +912,73 @@ var authenticateRequest = async (authHeader, relayDID, store) => {
|
|
|
752
912
|
return null;
|
|
753
913
|
}
|
|
754
914
|
};
|
|
915
|
+
var verifyContentAccess = async (options) => {
|
|
916
|
+
const { credentialJWS, requestedResource, action, store, creatorDID, requesterDID } = options;
|
|
917
|
+
if (requesterDID && requesterDID === creatorDID) {
|
|
918
|
+
return { granted: true, source: "creator" };
|
|
919
|
+
}
|
|
920
|
+
const resolveIdentity = createHistoricalIdentityResolver(store);
|
|
921
|
+
const isRevoked = async (issuerDID, credentialCID) => store.isCredentialRevoked(issuerDID, credentialCID);
|
|
922
|
+
const manifestLookup = async (manifestContentId) => {
|
|
923
|
+
const chain = await store.getContentChain(manifestContentId);
|
|
924
|
+
if (!chain) return [];
|
|
925
|
+
const docCID = chain.state.currentDocumentCID;
|
|
926
|
+
if (!docCID) return [];
|
|
927
|
+
const blob = await store.getBlob({ creatorDID: chain.state.creatorDID, documentCID: docCID });
|
|
928
|
+
if (!blob) return [];
|
|
929
|
+
try {
|
|
930
|
+
const doc = JSON.parse(new TextDecoder().decode(blob));
|
|
931
|
+
const entries = doc["entries"];
|
|
932
|
+
if (!entries || typeof entries !== "object") return [];
|
|
933
|
+
return Object.values(entries).filter(
|
|
934
|
+
(v) => typeof v === "string" && !v.startsWith("did:") && !v.startsWith("bafyrei")
|
|
935
|
+
);
|
|
936
|
+
} catch {
|
|
937
|
+
return [];
|
|
938
|
+
}
|
|
939
|
+
};
|
|
940
|
+
const publicCreds = await store.getPublicCredentials(requestedResource);
|
|
941
|
+
for (const credJws of publicCreds) {
|
|
942
|
+
try {
|
|
943
|
+
const cred = await verifyDFOSCredential2(credJws, { resolveIdentity });
|
|
944
|
+
const leafRevoked = await isRevoked(cred.iss, cred.credentialCID);
|
|
945
|
+
if (leafRevoked) continue;
|
|
946
|
+
const covers = await matchesResource(cred.att, requestedResource, action, {
|
|
947
|
+
manifestLookup
|
|
948
|
+
});
|
|
949
|
+
if (!covers) continue;
|
|
950
|
+
await verifyDelegationChain(cred, { resolveIdentity, rootDID: creatorDID, isRevoked });
|
|
951
|
+
return { granted: true, source: "public-credential", credential: cred };
|
|
952
|
+
} catch {
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
if (credentialJWS) {
|
|
957
|
+
try {
|
|
958
|
+
const cred = await verifyDFOSCredential2(credentialJWS, { resolveIdentity });
|
|
959
|
+
const leafRevoked = await isRevoked(cred.iss, cred.credentialCID);
|
|
960
|
+
if (leafRevoked) {
|
|
961
|
+
return { granted: false, source: "none" };
|
|
962
|
+
}
|
|
963
|
+
await verifyDelegationChain(cred, { resolveIdentity, rootDID: creatorDID, isRevoked });
|
|
964
|
+
if (cred.aud !== "*") {
|
|
965
|
+
if (!requesterDID || cred.aud !== requesterDID) {
|
|
966
|
+
return { granted: false, source: "none" };
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
const covers = await matchesResource(cred.att, requestedResource, action, {
|
|
970
|
+
manifestLookup
|
|
971
|
+
});
|
|
972
|
+
if (!covers) {
|
|
973
|
+
return { granted: false, source: "none" };
|
|
974
|
+
}
|
|
975
|
+
return { granted: true, source: "request-credential", credential: cred };
|
|
976
|
+
} catch {
|
|
977
|
+
return { granted: false, source: "none" };
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
return { granted: false, source: "none" };
|
|
981
|
+
};
|
|
755
982
|
|
|
756
983
|
// src/sequencer.ts
|
|
757
984
|
import { dagCborCanonicalEncode as dagCborCanonicalEncode2, decodeJwsUnsafe as decodeJwsUnsafe3 } from "@metalabel/dfos-protocol/crypto";
|
|
@@ -807,6 +1034,8 @@ var sequenceOps = async (store) => {
|
|
|
807
1034
|
};
|
|
808
1035
|
|
|
809
1036
|
// src/relay.ts
|
|
1037
|
+
var require2 = createRequire(import.meta.url);
|
|
1038
|
+
var { version: RELAY_VERSION } = require2("../package.json");
|
|
810
1039
|
var IngestBody = z.object({
|
|
811
1040
|
operations: z.array(z.string()).min(1).max(100)
|
|
812
1041
|
});
|
|
@@ -856,10 +1085,13 @@ var createRelay = async (options) => {
|
|
|
856
1085
|
return c.json({
|
|
857
1086
|
did: relayDID,
|
|
858
1087
|
protocol: "dfos-web-relay",
|
|
859
|
-
version:
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
1088
|
+
version: RELAY_VERSION,
|
|
1089
|
+
capabilities: {
|
|
1090
|
+
proof: true,
|
|
1091
|
+
content: contentEnabled,
|
|
1092
|
+
documents: contentEnabled,
|
|
1093
|
+
log: logEnabled
|
|
1094
|
+
},
|
|
863
1095
|
profile: profileArtifactJws
|
|
864
1096
|
});
|
|
865
1097
|
});
|
|
@@ -953,6 +1185,33 @@ var createRelay = async (options) => {
|
|
|
953
1185
|
const cursor = page.length === limit ? page[page.length - 1].cid : null;
|
|
954
1186
|
return c.json({ entries: page, cursor });
|
|
955
1187
|
});
|
|
1188
|
+
app.get("/content/:contentId/documents", async (c) => {
|
|
1189
|
+
if (!contentEnabled) return c.json({ error: "content plane not available" }, 501);
|
|
1190
|
+
const contentId = c.req.param("contentId");
|
|
1191
|
+
const auth = await authenticateRequest(c.req.header("authorization"), relayDID, store);
|
|
1192
|
+
if (!auth) return c.json({ error: "authentication required" }, 401);
|
|
1193
|
+
const chain = await store.getContentChain(contentId);
|
|
1194
|
+
if (!chain) return c.json({ error: "not found" }, 404);
|
|
1195
|
+
const accessError = await verifyReadAccess(
|
|
1196
|
+
auth,
|
|
1197
|
+
chain,
|
|
1198
|
+
contentId,
|
|
1199
|
+
c.req.header("x-credential"),
|
|
1200
|
+
store
|
|
1201
|
+
);
|
|
1202
|
+
if (accessError) return accessError;
|
|
1203
|
+
const after = c.req.query("after");
|
|
1204
|
+
const limit = Math.min(Number(c.req.query("limit") || 100), 1e3);
|
|
1205
|
+
const result = await store.getDocuments(contentId, {
|
|
1206
|
+
...after ? { after } : {},
|
|
1207
|
+
limit
|
|
1208
|
+
});
|
|
1209
|
+
return c.json({
|
|
1210
|
+
contentId,
|
|
1211
|
+
documents: result.documents,
|
|
1212
|
+
nextCursor: result.cursor
|
|
1213
|
+
});
|
|
1214
|
+
});
|
|
956
1215
|
app.get("/content/:contentId", async (c) => {
|
|
957
1216
|
const contentId = c.req.param("contentId");
|
|
958
1217
|
let chain = await store.getContentChain(contentId);
|
|
@@ -1007,7 +1266,8 @@ var createRelay = async (options) => {
|
|
|
1007
1266
|
did: beacon.did,
|
|
1008
1267
|
jwsToken: beacon.jwsToken,
|
|
1009
1268
|
beaconCID: beacon.beaconCID,
|
|
1010
|
-
|
|
1269
|
+
manifestContentId: beacon.state.manifestContentId,
|
|
1270
|
+
createdAt: beacon.state.createdAt
|
|
1011
1271
|
});
|
|
1012
1272
|
});
|
|
1013
1273
|
app.get("/log", async (c) => {
|
|
@@ -1106,7 +1366,7 @@ var readBlob = async (params) => {
|
|
|
1106
1366
|
if (!auth) return jsonResponse({ error: "authentication required" }, 401);
|
|
1107
1367
|
const chain = await store.getContentChain(contentId);
|
|
1108
1368
|
if (!chain) return jsonResponse({ error: "content chain not found" }, 404);
|
|
1109
|
-
const credError = await
|
|
1369
|
+
const credError = await verifyReadAccess(auth, chain, contentId, credHeader, store);
|
|
1110
1370
|
if (credError) return credError;
|
|
1111
1371
|
let documentCID = null;
|
|
1112
1372
|
let operationFound = ref === "head";
|
|
@@ -1135,45 +1395,17 @@ var readBlob = async (params) => {
|
|
|
1135
1395
|
}
|
|
1136
1396
|
});
|
|
1137
1397
|
};
|
|
1138
|
-
var
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
const kidHashIdx = vcHeader.kid.indexOf("#");
|
|
1150
|
-
if (kidHashIdx < 0) throw new Error("credential kid must be a DID URL");
|
|
1151
|
-
const vcIssuerDID = vcHeader.kid.substring(0, kidHashIdx);
|
|
1152
|
-
if (vcIssuerDID !== chain.state.creatorDID) {
|
|
1153
|
-
throw new Error("credential must be issued by the chain creator");
|
|
1154
|
-
}
|
|
1155
|
-
const issuerIdentity = await store.getIdentityChain(vcIssuerDID);
|
|
1156
|
-
if (issuerIdentity?.state.isDeleted) {
|
|
1157
|
-
throw new Error("credential issuer identity is deleted");
|
|
1158
|
-
}
|
|
1159
|
-
const creatorKey = await resolveKey(vcHeader.kid);
|
|
1160
|
-
const credential = verifyCredential({
|
|
1161
|
-
token: credHeader,
|
|
1162
|
-
publicKey: creatorKey,
|
|
1163
|
-
subject: auth.iss,
|
|
1164
|
-
expectedType: VC_TYPE_CONTENT_READ
|
|
1165
|
-
});
|
|
1166
|
-
if (credential.iss !== chain.state.creatorDID) {
|
|
1167
|
-
throw new Error("credential issuer is not the chain creator");
|
|
1168
|
-
}
|
|
1169
|
-
if (credential.contentId && credential.contentId !== contentId) {
|
|
1170
|
-
return jsonResponse({ error: "credential contentId does not match" }, 403);
|
|
1171
|
-
}
|
|
1172
|
-
} catch (err) {
|
|
1173
|
-
const message = err instanceof Error ? err.message : "credential verification failed";
|
|
1174
|
-
return jsonResponse({ error: message }, 403);
|
|
1175
|
-
}
|
|
1176
|
-
return null;
|
|
1398
|
+
var verifyReadAccess = async (auth, chain, contentId, credHeader, store) => {
|
|
1399
|
+
const result = await verifyContentAccess({
|
|
1400
|
+
...credHeader ? { credentialJWS: credHeader } : {},
|
|
1401
|
+
requestedResource: `chain:${contentId}`,
|
|
1402
|
+
action: "read",
|
|
1403
|
+
store,
|
|
1404
|
+
creatorDID: chain.state.creatorDID,
|
|
1405
|
+
requesterDID: auth.iss
|
|
1406
|
+
});
|
|
1407
|
+
if (result.granted) return null;
|
|
1408
|
+
return jsonResponse({ error: "read credential required" }, 403);
|
|
1177
1409
|
};
|
|
1178
1410
|
|
|
1179
1411
|
// src/store.ts
|
|
@@ -1189,6 +1421,10 @@ var MemoryRelayStore = class {
|
|
|
1189
1421
|
countersignatures = /* @__PURE__ */ new Map();
|
|
1190
1422
|
operationLog = [];
|
|
1191
1423
|
peerCursors = /* @__PURE__ */ new Map();
|
|
1424
|
+
/** Keyed by `issuerDID::credentialCID` for issuer-scoped revocation */
|
|
1425
|
+
revocations = /* @__PURE__ */ new Map();
|
|
1426
|
+
/** Keyed by credential CID */
|
|
1427
|
+
publicCredentials = /* @__PURE__ */ new Map();
|
|
1192
1428
|
async getOperation(cid) {
|
|
1193
1429
|
return this.operations.get(cid);
|
|
1194
1430
|
}
|
|
@@ -1239,6 +1475,98 @@ var MemoryRelayStore = class {
|
|
|
1239
1475
|
existing.push(jwsToken);
|
|
1240
1476
|
this.countersignatures.set(operationCID, existing);
|
|
1241
1477
|
}
|
|
1478
|
+
// --- revocations ---
|
|
1479
|
+
async getRevocations(issuerDID) {
|
|
1480
|
+
const cids = [];
|
|
1481
|
+
for (const rev of this.revocations.values()) {
|
|
1482
|
+
if (rev.issuerDID === issuerDID) cids.push(rev.credentialCID);
|
|
1483
|
+
}
|
|
1484
|
+
return cids;
|
|
1485
|
+
}
|
|
1486
|
+
async addRevocation(revocation) {
|
|
1487
|
+
const key = `${revocation.issuerDID}::${revocation.credentialCID}`;
|
|
1488
|
+
this.revocations.set(key, revocation);
|
|
1489
|
+
}
|
|
1490
|
+
async isCredentialRevoked(issuerDID, credentialCID) {
|
|
1491
|
+
return this.revocations.has(`${issuerDID}::${credentialCID}`);
|
|
1492
|
+
}
|
|
1493
|
+
// --- public credentials ---
|
|
1494
|
+
async getPublicCredentials(resource) {
|
|
1495
|
+
const tokens = [];
|
|
1496
|
+
const isChainRequest = resource.startsWith("chain:");
|
|
1497
|
+
for (const cred of this.publicCredentials.values()) {
|
|
1498
|
+
for (const att of cred.att) {
|
|
1499
|
+
if (att.resource === resource) {
|
|
1500
|
+
tokens.push(cred.jwsToken);
|
|
1501
|
+
break;
|
|
1502
|
+
}
|
|
1503
|
+
if (isChainRequest && att.resource === "chain:*") {
|
|
1504
|
+
tokens.push(cred.jwsToken);
|
|
1505
|
+
break;
|
|
1506
|
+
}
|
|
1507
|
+
if (isChainRequest && att.resource.startsWith("manifest:")) {
|
|
1508
|
+
tokens.push(cred.jwsToken);
|
|
1509
|
+
break;
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
return tokens;
|
|
1514
|
+
}
|
|
1515
|
+
async addPublicCredential(credential) {
|
|
1516
|
+
this.publicCredentials.set(credential.cid, credential);
|
|
1517
|
+
}
|
|
1518
|
+
async removePublicCredential(credentialCID) {
|
|
1519
|
+
this.publicCredentials.delete(credentialCID);
|
|
1520
|
+
}
|
|
1521
|
+
// --- documents ---
|
|
1522
|
+
async getDocuments(contentId, params) {
|
|
1523
|
+
const chain = this.contentChains.get(contentId);
|
|
1524
|
+
if (!chain) return { documents: [], cursor: null };
|
|
1525
|
+
const entries = [];
|
|
1526
|
+
for (const jws of chain.log) {
|
|
1527
|
+
const decoded = decodeJwsUnsafe5(jws);
|
|
1528
|
+
if (!decoded) continue;
|
|
1529
|
+
const payload = decoded.payload;
|
|
1530
|
+
const cid = typeof decoded.header.cid === "string" ? decoded.header.cid : "";
|
|
1531
|
+
const documentCID = typeof payload["documentCID"] === "string" ? payload["documentCID"] : null;
|
|
1532
|
+
const signerDID = typeof payload["did"] === "string" ? payload["did"] : "";
|
|
1533
|
+
const createdAt = typeof payload["createdAt"] === "string" ? payload["createdAt"] : "";
|
|
1534
|
+
entries.push({ cid, documentCID, signerDID, createdAt });
|
|
1535
|
+
}
|
|
1536
|
+
let startIdx = 0;
|
|
1537
|
+
if (params.after) {
|
|
1538
|
+
const idx = entries.findIndex((e) => e.cid === params.after);
|
|
1539
|
+
startIdx = idx >= 0 ? idx + 1 : entries.length;
|
|
1540
|
+
}
|
|
1541
|
+
const page = entries.slice(startIdx, startIdx + params.limit);
|
|
1542
|
+
const documents = [];
|
|
1543
|
+
for (const entry of page) {
|
|
1544
|
+
let document = null;
|
|
1545
|
+
if (entry.documentCID) {
|
|
1546
|
+
const blob = await this.getBlob({
|
|
1547
|
+
creatorDID: chain.state.creatorDID,
|
|
1548
|
+
documentCID: entry.documentCID
|
|
1549
|
+
});
|
|
1550
|
+
if (blob) {
|
|
1551
|
+
try {
|
|
1552
|
+
document = JSON.parse(new TextDecoder().decode(blob));
|
|
1553
|
+
} catch {
|
|
1554
|
+
document = null;
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
documents.push({
|
|
1559
|
+
operationCID: entry.cid,
|
|
1560
|
+
documentCID: entry.documentCID,
|
|
1561
|
+
document,
|
|
1562
|
+
signerDID: entry.signerDID,
|
|
1563
|
+
createdAt: entry.createdAt
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
const cursor = page.length === params.limit ? page[page.length - 1].cid : null;
|
|
1567
|
+
return { documents, cursor };
|
|
1568
|
+
}
|
|
1569
|
+
// --- operation log ---
|
|
1242
1570
|
async appendToLog(entry) {
|
|
1243
1571
|
this.operationLog.push(entry);
|
|
1244
1572
|
}
|
|
@@ -1301,7 +1629,16 @@ var MemoryRelayStore = class {
|
|
|
1301
1629
|
currentCID = op.previousCID;
|
|
1302
1630
|
}
|
|
1303
1631
|
const resolveKey = createKeyResolver(this);
|
|
1304
|
-
const
|
|
1632
|
+
const resolveIdentity = async (did) => {
|
|
1633
|
+
const chain2 = await this.getIdentityChain(did);
|
|
1634
|
+
return chain2?.state;
|
|
1635
|
+
};
|
|
1636
|
+
const content = await verifyContentChain2({
|
|
1637
|
+
log: path,
|
|
1638
|
+
resolveKey,
|
|
1639
|
+
enforceAuthorization: true,
|
|
1640
|
+
resolveIdentity
|
|
1641
|
+
});
|
|
1305
1642
|
const targetDecoded = decodeJwsUnsafe5(opsByCID.get(cid).jws);
|
|
1306
1643
|
const lastCreatedAt = typeof targetDecoded?.payload?.["createdAt"] === "string" ? (targetDecoded?.payload)["createdAt"] : "";
|
|
1307
1644
|
return { state: content, lastCreatedAt };
|
|
@@ -1357,6 +1694,7 @@ export {
|
|
|
1357
1694
|
bootstrapRelayIdentity,
|
|
1358
1695
|
computeOpCID,
|
|
1359
1696
|
createCurrentKeyResolver,
|
|
1697
|
+
createHistoricalIdentityResolver,
|
|
1360
1698
|
createHttpPeerClient,
|
|
1361
1699
|
createKeyResolver,
|
|
1362
1700
|
createRelay,
|
package/openapi.yaml
CHANGED
|
@@ -8,7 +8,7 @@ info:
|
|
|
8
8
|
|
|
9
9
|
Two data planes:
|
|
10
10
|
- **Proof plane** (public): signed chain operations, beacons, countersignatures
|
|
11
|
-
- **Content plane** (authenticated): raw content blobs gated by DID auth tokens and
|
|
11
|
+
- **Content plane** (authenticated): raw content blobs gated by DID auth tokens and DFOS credentials
|
|
12
12
|
|
|
13
13
|
servers:
|
|
14
14
|
- url: http://localhost:3000
|
|
@@ -28,7 +28,7 @@ paths:
|
|
|
28
28
|
application/json:
|
|
29
29
|
schema:
|
|
30
30
|
type: object
|
|
31
|
-
required: [did, protocol, version,
|
|
31
|
+
required: [did, protocol, version, capabilities, profile]
|
|
32
32
|
properties:
|
|
33
33
|
did:
|
|
34
34
|
type: string
|
|
@@ -39,16 +39,23 @@ paths:
|
|
|
39
39
|
enum: [dfos-web-relay]
|
|
40
40
|
version:
|
|
41
41
|
type: string
|
|
42
|
-
example: '0.
|
|
43
|
-
|
|
44
|
-
type:
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
42
|
+
example: '0.8.0'
|
|
43
|
+
capabilities:
|
|
44
|
+
type: object
|
|
45
|
+
required: [proof, content, documents, log]
|
|
46
|
+
properties:
|
|
47
|
+
proof:
|
|
48
|
+
type: boolean
|
|
49
|
+
description: Always true — a relay without proof plane is not a relay
|
|
50
|
+
content:
|
|
51
|
+
type: boolean
|
|
52
|
+
description: Whether the relay supports content plane (blob upload/download)
|
|
53
|
+
documents:
|
|
54
|
+
type: boolean
|
|
55
|
+
description: Whether the relay serves the documents endpoint
|
|
56
|
+
log:
|
|
57
|
+
type: boolean
|
|
58
|
+
description: Whether the global operation log is available (GET /log)
|
|
52
59
|
profile:
|
|
53
60
|
type: string
|
|
54
61
|
description: The relay's profile artifact as a compact JWS token
|
|
@@ -189,13 +196,14 @@ paths:
|
|
|
189
196
|
'404':
|
|
190
197
|
$ref: '#/components/responses/NotFound'
|
|
191
198
|
|
|
192
|
-
/content/{contentId}/blob:
|
|
199
|
+
/content/{contentId}/blob/{operationCID}:
|
|
193
200
|
put:
|
|
194
201
|
operationId: uploadBlob
|
|
195
202
|
summary: Upload a content blob
|
|
196
203
|
description: |
|
|
197
|
-
Upload raw bytes for a document referenced by a content chain.
|
|
198
|
-
Requires auth token. Caller must be the chain creator
|
|
204
|
+
Upload raw bytes for a document referenced by a content chain operation.
|
|
205
|
+
Requires auth token. Caller must be the chain creator or the signer of the
|
|
206
|
+
referenced operation.
|
|
199
207
|
tags: [Content Plane]
|
|
200
208
|
security:
|
|
201
209
|
- BearerAuth: []
|
|
@@ -205,12 +213,12 @@ paths:
|
|
|
205
213
|
required: true
|
|
206
214
|
schema:
|
|
207
215
|
type: string
|
|
208
|
-
- name:
|
|
209
|
-
in:
|
|
216
|
+
- name: operationCID
|
|
217
|
+
in: path
|
|
210
218
|
required: true
|
|
211
219
|
schema:
|
|
212
220
|
type: string
|
|
213
|
-
description:
|
|
221
|
+
description: CID of the content operation whose documentCID this blob satisfies
|
|
214
222
|
requestBody:
|
|
215
223
|
required: true
|
|
216
224
|
content:
|
|
@@ -243,12 +251,13 @@ paths:
|
|
|
243
251
|
'404':
|
|
244
252
|
$ref: '#/components/responses/NotFound'
|
|
245
253
|
|
|
254
|
+
/content/{contentId}/blob:
|
|
246
255
|
get:
|
|
247
256
|
operationId: downloadBlob
|
|
248
257
|
summary: Download a content blob at head
|
|
249
258
|
description: |
|
|
250
259
|
Download the raw bytes of the current document at chain head.
|
|
251
|
-
Requires auth token. Non-creators must present a
|
|
260
|
+
Requires auth token. Non-creators must present a DFOS read credential.
|
|
252
261
|
tags: [Content Plane]
|
|
253
262
|
security:
|
|
254
263
|
- BearerAuth: []
|
|
@@ -263,7 +272,7 @@ paths:
|
|
|
263
272
|
required: false
|
|
264
273
|
schema:
|
|
265
274
|
type: string
|
|
266
|
-
description:
|
|
275
|
+
description: DFOS read credential JWS (required for non-creators)
|
|
267
276
|
responses:
|
|
268
277
|
'200':
|
|
269
278
|
description: Blob data
|
|
@@ -311,7 +320,7 @@ paths:
|
|
|
311
320
|
required: false
|
|
312
321
|
schema:
|
|
313
322
|
type: string
|
|
314
|
-
description:
|
|
323
|
+
description: DFOS read credential JWS (required for non-creators)
|
|
315
324
|
responses:
|
|
316
325
|
'200':
|
|
317
326
|
description: Blob data
|
|
@@ -331,6 +340,188 @@ paths:
|
|
|
331
340
|
'404':
|
|
332
341
|
$ref: '#/components/responses/NotFound'
|
|
333
342
|
|
|
343
|
+
/content/{contentId}/documents:
|
|
344
|
+
get:
|
|
345
|
+
operationId: getDocuments
|
|
346
|
+
summary: List documents in a content chain
|
|
347
|
+
description: |
|
|
348
|
+
Returns all documents committed to a content chain as an ordered list,
|
|
349
|
+
from genesis to head. Cursor-based pagination.
|
|
350
|
+
tags: [Content Plane]
|
|
351
|
+
security:
|
|
352
|
+
- BearerAuth: []
|
|
353
|
+
parameters:
|
|
354
|
+
- name: contentId
|
|
355
|
+
in: path
|
|
356
|
+
required: true
|
|
357
|
+
schema:
|
|
358
|
+
type: string
|
|
359
|
+
- name: after
|
|
360
|
+
in: query
|
|
361
|
+
required: false
|
|
362
|
+
schema:
|
|
363
|
+
type: string
|
|
364
|
+
description: CID cursor — start after this operation CID
|
|
365
|
+
- name: limit
|
|
366
|
+
in: query
|
|
367
|
+
required: false
|
|
368
|
+
schema:
|
|
369
|
+
type: integer
|
|
370
|
+
default: 100
|
|
371
|
+
maximum: 1000
|
|
372
|
+
- name: X-Credential
|
|
373
|
+
in: header
|
|
374
|
+
required: false
|
|
375
|
+
schema:
|
|
376
|
+
type: string
|
|
377
|
+
description: DFOS read credential JWS (required for non-creators)
|
|
378
|
+
responses:
|
|
379
|
+
'200':
|
|
380
|
+
description: Document list
|
|
381
|
+
content:
|
|
382
|
+
application/json:
|
|
383
|
+
schema:
|
|
384
|
+
type: object
|
|
385
|
+
required: [contentId, documents, nextCursor]
|
|
386
|
+
properties:
|
|
387
|
+
contentId:
|
|
388
|
+
type: string
|
|
389
|
+
documents:
|
|
390
|
+
type: array
|
|
391
|
+
items:
|
|
392
|
+
type: object
|
|
393
|
+
required: [operationCID, documentCID, document, signerDID, createdAt]
|
|
394
|
+
properties:
|
|
395
|
+
operationCID:
|
|
396
|
+
type: string
|
|
397
|
+
documentCID:
|
|
398
|
+
type: string
|
|
399
|
+
nullable: true
|
|
400
|
+
document:
|
|
401
|
+
nullable: true
|
|
402
|
+
signerDID:
|
|
403
|
+
type: string
|
|
404
|
+
createdAt:
|
|
405
|
+
type: string
|
|
406
|
+
format: date-time
|
|
407
|
+
nextCursor:
|
|
408
|
+
type: string
|
|
409
|
+
nullable: true
|
|
410
|
+
'401':
|
|
411
|
+
$ref: '#/components/responses/Unauthorized'
|
|
412
|
+
'403':
|
|
413
|
+
$ref: '#/components/responses/Forbidden'
|
|
414
|
+
'404':
|
|
415
|
+
$ref: '#/components/responses/NotFound'
|
|
416
|
+
'501':
|
|
417
|
+
description: Documents capability not enabled
|
|
418
|
+
|
|
419
|
+
/identities/{did}/log:
|
|
420
|
+
get:
|
|
421
|
+
operationId: getIdentityLog
|
|
422
|
+
summary: Paginated log of identity chain operations
|
|
423
|
+
description: |
|
|
424
|
+
Returns operations belonging to this identity chain in chain order.
|
|
425
|
+
Cursor-based pagination.
|
|
426
|
+
tags: [Proof Plane]
|
|
427
|
+
parameters:
|
|
428
|
+
- name: did
|
|
429
|
+
in: path
|
|
430
|
+
required: true
|
|
431
|
+
schema:
|
|
432
|
+
type: string
|
|
433
|
+
description: DID of the identity
|
|
434
|
+
- name: after
|
|
435
|
+
in: query
|
|
436
|
+
required: false
|
|
437
|
+
schema:
|
|
438
|
+
type: string
|
|
439
|
+
description: CID cursor — start after this operation CID
|
|
440
|
+
- name: limit
|
|
441
|
+
in: query
|
|
442
|
+
required: false
|
|
443
|
+
schema:
|
|
444
|
+
type: integer
|
|
445
|
+
default: 100
|
|
446
|
+
maximum: 1000
|
|
447
|
+
responses:
|
|
448
|
+
'200':
|
|
449
|
+
description: Identity chain log entries
|
|
450
|
+
content:
|
|
451
|
+
application/json:
|
|
452
|
+
schema:
|
|
453
|
+
type: object
|
|
454
|
+
required: [entries, cursor]
|
|
455
|
+
properties:
|
|
456
|
+
entries:
|
|
457
|
+
type: array
|
|
458
|
+
items:
|
|
459
|
+
type: object
|
|
460
|
+
required: [cid, jwsToken]
|
|
461
|
+
properties:
|
|
462
|
+
cid:
|
|
463
|
+
type: string
|
|
464
|
+
jwsToken:
|
|
465
|
+
type: string
|
|
466
|
+
cursor:
|
|
467
|
+
type: string
|
|
468
|
+
nullable: true
|
|
469
|
+
'404':
|
|
470
|
+
$ref: '#/components/responses/NotFound'
|
|
471
|
+
|
|
472
|
+
/content/{contentId}/log:
|
|
473
|
+
get:
|
|
474
|
+
operationId: getContentLog
|
|
475
|
+
summary: Paginated log of content chain operations
|
|
476
|
+
description: |
|
|
477
|
+
Returns operations belonging to this content chain in chain order.
|
|
478
|
+
Cursor-based pagination.
|
|
479
|
+
tags: [Proof Plane]
|
|
480
|
+
parameters:
|
|
481
|
+
- name: contentId
|
|
482
|
+
in: path
|
|
483
|
+
required: true
|
|
484
|
+
schema:
|
|
485
|
+
type: string
|
|
486
|
+
description: Content identifier (22-char hash)
|
|
487
|
+
- name: after
|
|
488
|
+
in: query
|
|
489
|
+
required: false
|
|
490
|
+
schema:
|
|
491
|
+
type: string
|
|
492
|
+
description: CID cursor — start after this operation CID
|
|
493
|
+
- name: limit
|
|
494
|
+
in: query
|
|
495
|
+
required: false
|
|
496
|
+
schema:
|
|
497
|
+
type: integer
|
|
498
|
+
default: 100
|
|
499
|
+
maximum: 1000
|
|
500
|
+
responses:
|
|
501
|
+
'200':
|
|
502
|
+
description: Content chain log entries
|
|
503
|
+
content:
|
|
504
|
+
application/json:
|
|
505
|
+
schema:
|
|
506
|
+
type: object
|
|
507
|
+
required: [entries, cursor]
|
|
508
|
+
properties:
|
|
509
|
+
entries:
|
|
510
|
+
type: array
|
|
511
|
+
items:
|
|
512
|
+
type: object
|
|
513
|
+
required: [cid, jwsToken]
|
|
514
|
+
properties:
|
|
515
|
+
cid:
|
|
516
|
+
type: string
|
|
517
|
+
jwsToken:
|
|
518
|
+
type: string
|
|
519
|
+
cursor:
|
|
520
|
+
type: string
|
|
521
|
+
nullable: true
|
|
522
|
+
'404':
|
|
523
|
+
$ref: '#/components/responses/NotFound'
|
|
524
|
+
|
|
334
525
|
/countersignatures/{cid}:
|
|
335
526
|
get:
|
|
336
527
|
operationId: getCountersignaturesByCID
|
|
@@ -406,7 +597,7 @@ components:
|
|
|
406
597
|
description: Error message if rejected
|
|
407
598
|
kind:
|
|
408
599
|
type: string
|
|
409
|
-
enum: [identity-op, content-op, beacon,
|
|
600
|
+
enum: [identity-op, content-op, beacon, artifact, countersign, revocation, credential]
|
|
410
601
|
chainId:
|
|
411
602
|
type: string
|
|
412
603
|
description: Chain identifier (DID or contentId)
|
|
@@ -421,21 +612,19 @@ components:
|
|
|
421
612
|
type: string
|
|
422
613
|
chainType:
|
|
423
614
|
type: string
|
|
424
|
-
enum: [identity, content]
|
|
615
|
+
enum: [identity, content, artifact, beacon, countersign, revocation, credential]
|
|
425
616
|
chainId:
|
|
426
617
|
type: string
|
|
427
618
|
|
|
428
619
|
IdentityChainResponse:
|
|
429
620
|
type: object
|
|
430
|
-
required: [did,
|
|
621
|
+
required: [did, headCID, state]
|
|
431
622
|
properties:
|
|
432
623
|
did:
|
|
433
624
|
type: string
|
|
434
|
-
|
|
435
|
-
type:
|
|
436
|
-
|
|
437
|
-
type: string
|
|
438
|
-
description: Ordered JWS tokens from genesis to head
|
|
625
|
+
headCID:
|
|
626
|
+
type: string
|
|
627
|
+
description: CID of the current head operation
|
|
439
628
|
state:
|
|
440
629
|
type: object
|
|
441
630
|
required: [did, isDeleted, authKeys, assertKeys, controllerKeys]
|
|
@@ -492,7 +681,7 @@ components:
|
|
|
492
681
|
|
|
493
682
|
BeaconResponse:
|
|
494
683
|
type: object
|
|
495
|
-
required: [did, jwsToken, beaconCID,
|
|
684
|
+
required: [did, jwsToken, beaconCID, manifestContentId, createdAt]
|
|
496
685
|
properties:
|
|
497
686
|
did:
|
|
498
687
|
type: string
|
|
@@ -500,24 +689,12 @@ components:
|
|
|
500
689
|
type: string
|
|
501
690
|
beaconCID:
|
|
502
691
|
type: string
|
|
503
|
-
|
|
504
|
-
type:
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
enum: [1]
|
|
510
|
-
type:
|
|
511
|
-
type: string
|
|
512
|
-
enum: [beacon]
|
|
513
|
-
did:
|
|
514
|
-
type: string
|
|
515
|
-
merkleRoot:
|
|
516
|
-
type: string
|
|
517
|
-
pattern: '^[0-9a-f]{64}$'
|
|
518
|
-
createdAt:
|
|
519
|
-
type: string
|
|
520
|
-
format: date-time
|
|
692
|
+
manifestContentId:
|
|
693
|
+
type: string
|
|
694
|
+
description: Content ID of the manifest chain
|
|
695
|
+
createdAt:
|
|
696
|
+
type: string
|
|
697
|
+
format: date-time
|
|
521
698
|
|
|
522
699
|
MultikeyPublicKey:
|
|
523
700
|
type: object
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metalabel/dfos-web-relay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"README.md"
|
|
42
42
|
],
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"hono": "^4.12.
|
|
44
|
+
"hono": "^4.12.9",
|
|
45
45
|
"zod": "^4.3.6"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
@@ -51,14 +51,13 @@
|
|
|
51
51
|
"@types/node": "^24.10.4",
|
|
52
52
|
"tsup": "^8.5.1",
|
|
53
53
|
"tsx": "^4.20.3",
|
|
54
|
-
"vitest": "^4.1.
|
|
55
|
-
"@metalabel/dfos-protocol": "0.
|
|
54
|
+
"vitest": "^4.1.2",
|
|
55
|
+
"@metalabel/dfos-protocol": "0.8.0"
|
|
56
56
|
},
|
|
57
57
|
"scripts": {
|
|
58
58
|
"build": "tsup",
|
|
59
59
|
"clean": "rm -rf dist",
|
|
60
60
|
"typecheck": "tsc --noEmit",
|
|
61
|
-
"test": "vitest run"
|
|
62
|
-
"test:conformance": "./tests/run-conformance.sh"
|
|
61
|
+
"test": "vitest run"
|
|
63
62
|
}
|
|
64
63
|
}
|