@metalabel/dfos-web-relay 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/RELAY.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # DFOS Web Relay
2
2
 
3
- An HTTP relay for the DFOS protocol — receives, verifies, stores, and serves identity chains, content chains, beacons, countersignatures, and content blobs.
3
+ An HTTP relay for the DFOS protocol — receives, verifies, stores, and serves identity chains, content chains, artifacts, beacons, countersignatures, and content blobs.
4
4
 
5
5
  This spec is under active review. Discuss it in the [clear.txt](https://clear.dfos.com) space on DFOS.
6
6
 
@@ -24,7 +24,7 @@ The relay serves two distinct planes of data with different access models:
24
24
 
25
25
  ### Proof Plane (public)
26
26
 
27
- Signed chain operations, beacons, and countersignatures. These are cryptographic proofs — anyone can verify them with a public key. The proof plane gossips freely: relays push operations to peers, peers verify and store independently.
27
+ Signed chain operations, artifacts, beacons, and countersignatures. These are cryptographic proofs — anyone can verify them with a public key. The proof plane gossips freely: relays push operations to peers, peers verify and store independently.
28
28
 
29
29
  All proof plane routes are unauthenticated. The operations themselves carry their own authentication (Ed25519 signatures).
30
30
 
@@ -39,34 +39,36 @@ Content plane access requires two credentials:
39
39
 
40
40
  The content creator (the DID that signed the genesis content operation) can always read their own blobs with just an auth token.
41
41
 
42
+ Content plane support is optional per relay. When disabled (`content: false` in the well-known response), all content plane routes return **501 Not Implemented** — not 404 (resource doesn't exist), but 501 (capability not supported).
43
+
42
44
  ---
43
45
 
44
46
  ## Operation Ingestion
45
47
 
46
- All proof plane artifacts enter through a single endpoint: `POST /operations`. The request body is an array of JWS tokens — identity operations, content operations, beacons, and countersignatures can be mixed freely in the same batch.
48
+ All proof plane operations enter through a single endpoint: `POST /operations`. The request body is an array of JWS tokens — identity operations, content operations, artifacts, beacons, and countersignatures can be mixed freely in the same batch.
47
49
 
48
50
  ### Classification
49
51
 
50
52
  Each token is classified by its JWS `typ` header:
51
53
 
52
- | `typ` header | kid DID == payload DID? | Classification |
53
- | ---------------------- | ----------------------- | ------------------------ |
54
- | `did:dfos:identity-op` | — | Identity chain operation |
55
- | `did:dfos:content-op` | yes | Content chain operation |
56
- | `did:dfos:content-op` | no | Countersignature |
57
- | `did:dfos:beacon` | yes | Beacon announcement |
58
- | `did:dfos:beacon` | no | Beacon countersignature |
54
+ | `typ` header | Classification |
55
+ | ---------------------- | ------------------------ |
56
+ | `did:dfos:identity-op` | Identity chain operation |
57
+ | `did:dfos:content-op` | Content chain operation |
58
+ | `did:dfos:beacon` | Beacon announcement |
59
+ | `did:dfos:artifact` | Artifact |
60
+ | `did:dfos:countersign` | Countersignature |
59
61
 
60
- When the signing key's DID differs from the payload DID, the operation is classified as a countersignature (witness attestation) rather than a primary operation.
62
+ Each operation type has its own `typ` header. Classification is unambiguous no DID comparison needed.
61
63
 
62
64
  ### Dependency Sort
63
65
 
64
66
  Within a batch, operations are sorted by dependency priority before processing:
65
67
 
66
68
  1. **Identity operations** — must be processed first so their keys are available
67
- 2. **Beacons** — reference identity keys
68
- 3. **Content operations** — reference identity keys for signature verification
69
- 4. **Countersignatures** — reference both identity keys and existing operations
69
+ 2. **Beacons and artifacts** — reference identity keys for signature verification
70
+ 3. **Content operations** — reference identity keys, may have chain dependencies
71
+ 4. **Countersignatures** — reference identity keys and existing operations (target must exist)
70
72
 
71
73
  Within each priority level, genesis operations (no `previousOperationCID`) are processed before extensions. This ensures that a single batch can bootstrap an entire identity-and-content lifecycle — including chained create + update operations — without multiple round trips.
72
74
 
@@ -74,11 +76,11 @@ Within each priority level, genesis operations (no `previousOperationCID`) are p
74
76
 
75
77
  Each operation is verified against the relay's stored state:
76
78
 
77
- - **Identity operations**: The full chain (stored log + new operation) is re-verified from genesis. The relay uses `verifyIdentityChain()` from the protocol library
78
- - **Content operations**: The full chain is re-verified with `enforceAuthorization: true`. Non-creator signers must include a `DFOSContentWrite` VC-JWT
79
+ - **Identity operations**: Extension operations are verified against the relay's current trusted state using O(1) extension verification — the trusted head state plus the new operation is sufficient. Genesis operations verify the single-operation chain. The relay uses `verifyIdentityChain()` / `verifyIdentityExtensionFromTrustedState()` from the protocol library
80
+ - **Content operations**: Extension operations are verified against trusted state with `enforceAuthorization: true`. Non-creator signers must include a `DFOSContentWrite` VC-JWT. The relay uses `verifyContentChain()` / `verifyContentExtensionFromTrustedState()` from the protocol library
81
+ - **Artifacts**: Signature is verified against the signing DID's current identity state. CID integrity is checked. Payload must conform to the declared `$schema`. CBOR-encoded payload must not exceed 16384 bytes
79
82
  - **Beacons**: Signature, CID integrity, and clock skew are verified. Replace-on-newer: only the most recent beacon per DID is retained
80
- - **Countersignatures**: The referenced operation or beacon must already exist. Signature is verified against the witness DID's identity chain
81
- - **Beacon countersignatures**: The referenced beacon must exist and the countersignature CID must match the current beacon CID
83
+ - **Countersignatures**: Two-phase verification. Protocol-level (stateless): signature, CID integrity, payload schema. Relay-level (stateful): target CID must exist in the relay, witness DID must differ from the target's author DID, one countersign per witness per target
82
84
 
83
85
  ### Fork Policy
84
86
 
@@ -86,7 +88,7 @@ First-seen-wins. If a chain head has already advanced past the `previousOperatio
86
88
 
87
89
  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
90
 
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).
91
+ Duplicate countersignatures (same witness DID, same target CID) MUST be deduplicated — one countersign per witness per target. The relay MUST NOT store multiple attestations from the same witness for the same target. Resubmission SHOULD return `accepted` (idempotent).
90
92
 
91
93
  ### Deletion Semantics
92
94
 
@@ -97,10 +99,11 @@ Specifically:
97
99
  - **Identity operations after deletion**: Rejected. A deleted identity chain is sealed.
98
100
  - **Content operations after deletion**: Rejected. A deleted content chain is sealed.
99
101
  - **Beacons from deleted identities**: Rejected. A deleted identity MUST NOT publish new beacons.
102
+ - **Artifacts from deleted identities**: Rejected. A deleted identity MUST NOT publish new artifacts.
100
103
  - **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.
104
+ - **Countersignatures from deleted witnesses**: Rejected. A deleted identity MUST NOT publish new countersignatures. Countersignatures on operations by deleted authors are still accepted — deletion of the target's author does not prevent other identities from attesting.
102
105
 
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.
106
+ Self-countersignatures — where the witness DID matches the target's author DID — are rejected at the relay level. A countersignature's semantic is "a distinct witness attests." The protocol-level verifier is stateless and does not enforce this; the relay resolves the target's author and rejects self-attestation.
104
107
 
105
108
  ### Result Ordering
106
109
 
@@ -108,14 +111,226 @@ Ingestion results are returned in the same order as the input `operations` array
108
111
 
109
112
  ---
110
113
 
114
+ ## Artifacts
115
+
116
+ Artifacts are standalone signed inline documents — immutable, CID-addressable proof plane primitives. Unlike chain operations which extend a sequence, an artifact is a single signed statement with no predecessor or successor.
117
+
118
+ ### Payload
119
+
120
+ ```json
121
+ {
122
+ "version": 1,
123
+ "type": "artifact",
124
+ "did": "did:dfos:...",
125
+ "content": {
126
+ "$schema": "https://schemas.dfos.com/profile/v1",
127
+ "name": "My Relay",
128
+ "description": "A relay for the dark forest"
129
+ },
130
+ "createdAt": "2026-03-25T00:00:00.000Z"
131
+ }
132
+ ```
133
+
134
+ The `content` object MUST include a `$schema` string that identifies the artifact's schema. The schema acts as a discriminator — consumers use it to determine how to interpret the artifact's content. Schema names are free-form strings (no protocol-level registry). Communities may establish conventions for well-known schemas.
135
+
136
+ ### Constraints
137
+
138
+ - **JWS `typ` header**: `did:dfos:artifact`
139
+ - **Max payload size**: 16384 bytes CBOR-encoded. This is a protocol constant — not configurable per relay
140
+ - **Immutability**: Once ingested, an artifact is never updated or replaced. To "update" an artifact's content, publish a new artifact
141
+ - **CID-addressable**: Each artifact is addressed by the CID of its CBOR-encoded payload
142
+
143
+ ### Verification
144
+
145
+ 1. JWS signature verification against the signing DID's current key state
146
+ 2. CID integrity — the payload CID matches the computed CID from the raw payload bytes
147
+ 3. Payload schema validation — the payload conforms to the artifact structure (`version`, `type`, `did`, `content` with `$schema`, `createdAt`)
148
+ 4. Size limit — CBOR-encoded payload does not exceed 16384 bytes
149
+
150
+ ---
151
+
152
+ ## Countersignatures
153
+
154
+ A countersignature is a standalone witness attestation — a signed statement that references a target operation by CID. Unlike the original operation primitives (which carry the data itself), a countersign is pure attestation: "I, witness W, attest to operation X."
155
+
156
+ ### Payload
157
+
158
+ ```json
159
+ {
160
+ "version": 1,
161
+ "type": "countersign",
162
+ "did": "did:dfos:witness...",
163
+ "targetCID": "bafy...",
164
+ "createdAt": "2026-03-25T00:00:00.000Z"
165
+ }
166
+ ```
167
+
168
+ ### Properties
169
+
170
+ - **JWS `typ` header**: `did:dfos:countersign`
171
+ - **Own CID**: Each countersign has its own CID, distinct from the target. This avoids the ambiguity of multiple JWS tokens sharing the same CID
172
+ - **Stateless verification**: Signature + CID integrity + payload schema. No relay state required to verify the cryptographic validity of a countersign
173
+ - **Composable**: The `targetCID` can reference any CID-addressable operation — content ops, beacons, artifacts, identity ops, even other countersigns
174
+ - **Immutable**: Once published, a countersign is permanent
175
+
176
+ ### Relay-Level Checks
177
+
178
+ The relay enforces semantic rules beyond cryptographic validity:
179
+
180
+ 1. **Target exists**: The `targetCID` must reference an operation already stored in the relay
181
+ 2. **Witness ≠ author**: The countersign's `did` (witness) must differ from the target operation's author DID
182
+ 3. **Deduplication**: One countersign per witness per target. If the same witness submits a second countersign for the same target, the relay accepts idempotently
183
+ 4. **Deleted witness rejection**: Countersigns from deleted identities are rejected
184
+
185
+ ---
186
+
111
187
  ## Relay Identity
112
188
 
113
- Every relay has a DID, published at `GET /.well-known/dfos-relay`. The relay DID serves as:
189
+ Every relay has a DID that resolves on its own proof plane. The relay DID serves as:
114
190
 
115
191
  - **Auth token audience**: Auth tokens are scoped to a specific relay via the JWT `aud` claim, preventing cross-relay token replay
116
192
  - **Peer identity**: When relays gossip proof plane data to each other, the relay DID identifies the peer
193
+ - **Self-proof anchor**: The relay's identity chain lives in its own store, verifiable by anyone querying the relay
194
+
195
+ ### Relay Profile
196
+
197
+ The relay MUST publish a profile artifact signed by its own DID using the HEAD key state. The profile artifact uses the `https://schemas.dfos.com/profile/v1` schema:
198
+
199
+ ```json
200
+ {
201
+ "$schema": "https://schemas.dfos.com/profile/v1",
202
+ "name": "edge.relay.dfos.com",
203
+ "description": "Cloudflare edge relay for the DFOS network",
204
+ "image": { "id": "relay-avatar", "uri": "https://cdn.example.com/avatar.png" },
205
+ "operator": "Metalabel",
206
+ "motd": "Welcome to the dark forest"
207
+ }
208
+ ```
209
+
210
+ All fields are optional except `name`, which SHOULD be present. The `image.uri` field is any valid URI (operator choice — CDN, content-plane reference, or any resolvable URL). The profile JWS token is inlined in the well-known response — self-proving, no extra fetch needed.
211
+
212
+ ### Well-Known Endpoint (`GET /.well-known/dfos-relay`)
117
213
 
118
- The relay does not currently sign operations or participate in the protocol as an identity. It's a passive verifier and store.
214
+ Returns relay metadata. All fields are required `profile` is the relay's proof of DID controllership (an artifact JWS signed by the relay DID's controller key).
215
+
216
+ ```json
217
+ {
218
+ "did": "did:dfos:edgerelay0000000000000",
219
+ "protocol": "dfos-web-relay",
220
+ "version": "0.1.0",
221
+ "proof": true,
222
+ "content": false,
223
+ "profile": "eyJhbGciOiJFZERTQSIs..."
224
+ }
225
+ ```
226
+
227
+ | Field | Type | Description |
228
+ | ---------- | ------- | -------------------------------------------------------------------------- |
229
+ | `did` | string | The relay's DID, resolvable on this relay's proof plane |
230
+ | `protocol` | string | Protocol identifier, always `"dfos-web-relay"` |
231
+ | `version` | string | Relay protocol version (semver) |
232
+ | `proof` | boolean | MUST be `true`. A relay without proof plane capability is not a relay |
233
+ | `content` | boolean | Whether the relay supports content plane (blob upload/download) |
234
+ | `profile` | string | The relay's profile artifact as a compact JWS token — self-proving payload |
235
+
236
+ `proof: false` is not a valid value. A compliant relay always serves the proof plane.
237
+
238
+ ---
239
+
240
+ ## Operation Log
241
+
242
+ The relay maintains a global append-only operation log. Every successfully ingested operation (identity ops, content ops, artifacts, beacons, countersignatures) is appended to the log in ingestion order.
243
+
244
+ ### Global Log (`GET /log?after={cid}&limit=N`)
245
+
246
+ Returns log entries starting after the given CID cursor.
247
+
248
+ ```json
249
+ {
250
+ "entries": [
251
+ {
252
+ "cid": "bafy...",
253
+ "jwsToken": "eyJhbGciOiJFZERTQSIs...",
254
+ "kind": "identity-op",
255
+ "chainId": "did:dfos:..."
256
+ },
257
+ {
258
+ "cid": "bafy...",
259
+ "jwsToken": "eyJhbGciOiJFZERTQSIs...",
260
+ "kind": "artifact",
261
+ "chainId": "did:dfos:..."
262
+ }
263
+ ],
264
+ "cursor": "bafy..."
265
+ }
266
+ ```
267
+
268
+ | Field | Type | Description |
269
+ | -------------------- | ------------ | -------------------------------------------------------------------------------- |
270
+ | `entries[].cid` | string | Operation CID |
271
+ | `entries[].jwsToken` | string | The full compact JWS token — makes the log self-contained for sync |
272
+ | `entries[].kind` | string | Operation kind: `identity-op`, `content-op`, `beacon`, `artifact`, `countersign` |
273
+ | `entries[].chainId` | string | DID (identity/beacon/artifact), contentId (content), or targetCID (countersign) |
274
+ | `cursor` | string\|null | CID to pass as `after` for the next page. `null` means caught up |
275
+
276
+ Parameters:
277
+
278
+ - **`after`** (optional): CID cursor. Omit to start from the beginning of the log
279
+ - **`limit`** (optional): Max entries to return. Default: 100. Max: 1000
280
+
281
+ Pagination is forward-only. The log is ordered by ingestion time. JWS tokens are included in every entry because proof-plane JWS payloads are bounded (chain operations and artifacts have finite size), keeping the log self-contained — a syncing peer can replay the log without separate fetches.
282
+
283
+ ### Per-Chain Logs
284
+
285
+ Identity and content chains expose their own log views with the same cursor-based pagination:
286
+
287
+ - `GET /identities/:did/log?after={cid}&limit=N`
288
+ - `GET /content/:contentId/log?after={cid}&limit=N`
289
+
290
+ Same cursor-based pagination parameters as the global log. Per-chain log entries include `{ cid, jwsToken }` — the chain-specific subset of the global log entry shape. Returns operations belonging to that chain in chain order.
291
+
292
+ ---
293
+
294
+ ## Identity and Content State
295
+
296
+ State endpoints return projected state — the computed result of replaying the chain — without embedding the full operation log.
297
+
298
+ ### Identity State (`GET /identities/:did`)
299
+
300
+ ```json
301
+ {
302
+ "did": "did:dfos:abc123...",
303
+ "headCID": "bafy...",
304
+ "state": {
305
+ "did": "did:dfos:abc123...",
306
+ "isDeleted": false,
307
+ "authKeys": [...],
308
+ "assertKeys": [...],
309
+ "controllerKeys": [...]
310
+ }
311
+ }
312
+ ```
313
+
314
+ ### Content State (`GET /content/:contentId`)
315
+
316
+ ```json
317
+ {
318
+ "contentId": "abc123...",
319
+ "genesisCID": "bafy...",
320
+ "headCID": "bafy...",
321
+ "state": {
322
+ "contentId": "abc123...",
323
+ "genesisCID": "bafy...",
324
+ "headCID": "bafy...",
325
+ "isDeleted": false,
326
+ "currentDocumentCID": "bafy...",
327
+ "length": 1,
328
+ "creatorDID": "did:dfos:..."
329
+ }
330
+ }
331
+ ```
332
+
333
+ Chain history is available via the per-chain log routes described above.
119
334
 
120
335
  ---
121
336
 
@@ -181,10 +396,15 @@ interface RelayStore {
181
396
 
182
397
  getCountersignatures(operationCID: string): Promise<string[]>;
183
398
  addCountersignature(operationCID: string, jwsToken: string): Promise<void>;
399
+
400
+ appendToLog(entry: LogEntry): Promise<void>;
401
+ readLog(params: { after?: string; limit: number }): Promise<LogPage>;
184
402
  }
185
403
  ```
186
404
 
187
- The package includes `MemoryRelayStore` for development and testing. Production deployments would implement the interface over Postgres, SQLite, S3, or any durable store.
405
+ The `appendToLog` / `readLog` pair supports both the global log and per-chain log queries. The store implementation determines how to scope queries (e.g., by filtering on `chainId`).
406
+
407
+ The package includes `MemoryRelayStore` for development and testing. Production deployments would implement the interface over Postgres, SQLite, D1, or any durable store.
188
408
 
189
409
  ---
190
410
 
@@ -193,9 +413,15 @@ The package includes `MemoryRelayStore` for development and testing. Production
193
413
  ```typescript
194
414
  import { createRelay, MemoryRelayStore } from '@metalabel/dfos-web-relay';
195
415
 
196
- const relay = createRelay({
197
- relayDID: 'did:dfos:myrelay00000000000000',
416
+ // JIT mode — generates relay identity + profile artifact at startup
417
+ const relay = await createRelay({
418
+ store: new MemoryRelayStore(),
419
+ });
420
+
421
+ // Or provide a pre-created identity (production)
422
+ const relay = await createRelay({
198
423
  store: new MemoryRelayStore(),
424
+ identity: { did: 'did:dfos:myrelay...', profileArtifactJws: '...' },
199
425
  });
200
426
 
201
427
  // Mount on any Hono-compatible runtime
@@ -212,8 +438,11 @@ The returned Hono app exposes:
212
438
  | `GET` | `/operations/:cid/countersignatures` | proof | none |
213
439
  | `GET` | `/countersignatures/:cid` | proof | none |
214
440
  | `GET` | `/identities/:did` | proof | none |
441
+ | `GET` | `/identities/:did/log` | proof | none |
215
442
  | `GET` | `/content/:contentId` | proof | none |
443
+ | `GET` | `/content/:contentId/log` | proof | none |
216
444
  | `GET` | `/beacons/:did` | proof | none |
445
+ | `GET` | `/log` | proof | none |
217
446
  | `PUT` | `/content/:contentId/blob/:opCID` | content | auth token |
218
447
  | `GET` | `/content/:contentId/blob[/:ref]` | content | auth token + credential |
219
448
 
@@ -224,5 +453,5 @@ The returned Hono app exposes:
224
453
  - **Peer gossip**: Proactive push of proof plane operations to other relays
225
454
  - **Rate limiting / anti-spam**: Operational concern, not protocol concern
226
455
  - **Docker/CF reference deployments**: Focus on the core library first
227
- - **Pagination**: Chain logs are returned in full — fine for v1, needs pagination at scale
228
456
  - **Blob size limits**: No enforcement yet — production deployments should add limits at the middleware layer
457
+ - **Artifact `$schema` registry**: Schema names are free-form strings for now — no formal registry or validation beyond structural checks
package/dist/index.d.ts CHANGED
@@ -1,16 +1,28 @@
1
- import { Hono } from 'hono';
2
1
  import { VerifiedIdentity, VerifiedContentChain, VerifiedBeacon } from '@metalabel/dfos-protocol/chain';
2
+ import { Hono } from 'hono';
3
3
 
4
+ interface RelayIdentity {
5
+ /** The relay's DID */
6
+ did: string;
7
+ /** Profile artifact JWS token (signed by the relay DID) */
8
+ profileArtifactJws: string;
9
+ }
4
10
  interface RelayOptions {
5
- /** The relay's DID — used as auth token audience and published in well-known */
6
- relayDID: string;
7
11
  /** Storage backend */
8
12
  store: RelayStore;
13
+ /** Pre-created relay identity — if omitted, a JIT identity and profile are generated */
14
+ identity?: RelayIdentity;
15
+ /** Whether content plane routes are enabled (default: true) */
16
+ content?: boolean;
9
17
  }
10
18
  interface StoredIdentityChain {
11
19
  did: string;
12
20
  /** Ordered JWS tokens from genesis to head */
13
21
  log: string[];
22
+ /** CID of the most recent operation */
23
+ headCID: string;
24
+ /** createdAt timestamp of the most recent operation */
25
+ lastCreatedAt: string;
14
26
  state: VerifiedIdentity;
15
27
  }
16
28
  interface StoredContentChain {
@@ -18,6 +30,8 @@ interface StoredContentChain {
18
30
  genesisCID: string;
19
31
  /** Ordered JWS tokens from genesis to head */
20
32
  log: string[];
33
+ /** createdAt timestamp of the most recent operation */
34
+ lastCreatedAt: string;
21
35
  state: VerifiedContentChain;
22
36
  }
23
37
  interface StoredBeacon {
@@ -30,8 +44,8 @@ interface StoredOperation {
30
44
  cid: string;
31
45
  jwsToken: string;
32
46
  /** Which chain type this operation belongs to */
33
- chainType: 'identity' | 'content';
34
- /** The chain identifier — DID for identity, contentId for content */
47
+ chainType: 'identity' | 'content' | 'artifact' | 'beacon' | 'countersign';
48
+ /** The chain identifier — DID for identity/beacon/artifact, contentId for content, targetCID for countersign */
35
49
  chainId: string;
36
50
  }
37
51
  /** Key for blob storage — deduplicates across chains sharing the same document */
@@ -39,6 +53,15 @@ interface BlobKey {
39
53
  creatorDID: string;
40
54
  documentCID: string;
41
55
  }
56
+ /** A single entry in the global append-only operation log */
57
+ interface LogEntry {
58
+ cid: string;
59
+ jwsToken: string;
60
+ kind: OperationKind;
61
+ chainId: string;
62
+ }
63
+ /** All operation kinds in the protocol */
64
+ type OperationKind = 'identity-op' | 'content-op' | 'beacon' | 'artifact' | 'countersign';
42
65
  /**
43
66
  * Storage backend for a DFOS web relay
44
67
  *
@@ -63,24 +86,45 @@ interface RelayStore {
63
86
  putBlob(key: BlobKey, data: Uint8Array): Promise<void>;
64
87
  getCountersignatures(operationCID: string): Promise<string[]>;
65
88
  addCountersignature(operationCID: string, jwsToken: string): Promise<void>;
89
+ appendToLog(entry: LogEntry): Promise<void>;
90
+ readLog(params: {
91
+ after?: string;
92
+ limit: number;
93
+ }): Promise<{
94
+ entries: LogEntry[];
95
+ cursor: string | null;
96
+ }>;
66
97
  }
67
98
  interface IngestionResult {
68
99
  cid: string;
69
100
  status: 'accepted' | 'rejected';
70
101
  error?: string;
71
102
  /** What was ingested */
72
- kind?: 'identity-op' | 'content-op' | 'beacon' | 'countersig' | 'beacon-countersig';
103
+ kind?: OperationKind;
73
104
  /** Chain identifier if applicable */
74
105
  chainId?: string;
75
106
  }
76
107
 
108
+ /**
109
+ * Generate a relay identity and profile artifact, ingest both into the store
110
+ *
111
+ * Creates an Ed25519 keypair, signs an identity genesis operation, derives
112
+ * the DID, then signs a profile artifact with the relay's name. Both the
113
+ * identity genesis and profile artifact are ingested into the store so
114
+ * they are available via the relay's proof plane routes.
115
+ */
116
+ declare const bootstrapRelayIdentity: (store: RelayStore) => Promise<RelayIdentity>;
117
+
77
118
  /**
78
119
  * Create a DFOS web relay Hono application
79
120
  *
80
121
  * The returned app is portable — mount it on any Hono-compatible runtime
81
122
  * (Node.js, Cloudflare Workers, Deno, Bun, etc.).
123
+ *
124
+ * When `identity` is provided, the relay uses the given DID and profile. When
125
+ * omitted, a JIT identity and profile artifact are generated at startup.
82
126
  */
83
- declare const createRelay: (options: RelayOptions) => Hono;
127
+ declare const createRelay: (options: RelayOptions) => Promise<Hono>;
84
128
 
85
129
  /**
86
130
  * In-memory relay store — all data lives in Maps, lost on restart
@@ -94,6 +138,7 @@ declare class MemoryRelayStore implements RelayStore {
94
138
  private beacons;
95
139
  private blobs;
96
140
  private countersignatures;
141
+ private operationLog;
97
142
  getOperation(cid: string): Promise<StoredOperation | undefined>;
98
143
  putOperation(op: StoredOperation): Promise<void>;
99
144
  getIdentityChain(did: string): Promise<StoredIdentityChain | undefined>;
@@ -106,6 +151,14 @@ declare class MemoryRelayStore implements RelayStore {
106
151
  putBlob(key: BlobKey, data: Uint8Array): Promise<void>;
107
152
  getCountersignatures(operationCID: string): Promise<string[]>;
108
153
  addCountersignature(operationCID: string, jwsToken: string): Promise<void>;
154
+ appendToLog(entry: LogEntry): Promise<void>;
155
+ readLog(params: {
156
+ after?: string;
157
+ limit: number;
158
+ }): Promise<{
159
+ entries: LogEntry[];
160
+ cursor: string | null;
161
+ }>;
109
162
  }
110
163
 
111
164
  /**
@@ -140,4 +193,4 @@ declare const createCurrentKeyResolver: (store: RelayStore) => (kid: string) =>
140
193
  */
141
194
  declare const ingestOperations: (tokens: string[], store: RelayStore) => Promise<IngestionResult[]>;
142
195
 
143
- export { type BlobKey, type IngestionResult, MemoryRelayStore, type RelayOptions, type RelayStore, type StoredBeacon, type StoredContentChain, type StoredIdentityChain, type StoredOperation, createCurrentKeyResolver, createKeyResolver, createRelay, ingestOperations };
196
+ export { type BlobKey, type IngestionResult, type LogEntry, MemoryRelayStore, type OperationKind, type RelayIdentity, type RelayOptions, type RelayStore, type StoredBeacon, type StoredContentChain, type StoredIdentityChain, type StoredOperation, bootstrapRelayIdentity, createCurrentKeyResolver, createKeyResolver, createRelay, ingestOperations };
package/dist/index.js CHANGED
@@ -1,21 +1,25 @@
1
- // src/relay.ts
2
- import { VC_TYPE_CONTENT_READ, verifyCredential } from "@metalabel/dfos-protocol/credentials";
3
- import { dagCborCanonicalEncode as dagCborCanonicalEncode2, decodeJwsUnsafe as decodeJwsUnsafe3 } from "@metalabel/dfos-protocol/crypto";
4
- import { Hono } from "hono";
5
- import { z } from "zod";
6
-
7
- // src/auth.ts
8
- import { verifyAuthToken } from "@metalabel/dfos-protocol/credentials";
9
- import { decodeJwsUnsafe as decodeJwsUnsafe2 } from "@metalabel/dfos-protocol/crypto";
1
+ // src/bootstrap.ts
2
+ import {
3
+ encodeEd25519Multikey,
4
+ signArtifact,
5
+ signIdentityOperation
6
+ } from "@metalabel/dfos-protocol/chain";
7
+ import {
8
+ createNewEd25519Keypair,
9
+ generateId,
10
+ signPayloadEd25519
11
+ } from "@metalabel/dfos-protocol/crypto";
10
12
 
11
13
  // src/ingest.ts
12
14
  import {
13
15
  decodeMultikey,
16
+ verifyArtifact,
14
17
  verifyBeacon,
15
- verifyBeaconCountersignature,
16
18
  verifyContentChain,
19
+ verifyContentExtensionFromTrustedState,
17
20
  verifyCountersignature,
18
- verifyIdentityChain
21
+ verifyIdentityChain,
22
+ verifyIdentityExtensionFromTrustedState
19
23
  } from "@metalabel/dfos-protocol/chain";
20
24
  import { dagCborCanonicalEncode, decodeJwsUnsafe } from "@metalabel/dfos-protocol/crypto";
21
25
  var classify = (jwsToken) => {
@@ -44,30 +48,10 @@ var classify = (jwsToken) => {
44
48
  }
45
49
  if (typ === "did:dfos:content-op") {
46
50
  const opDID = typeof payload["did"] === "string" ? payload["did"] : null;
47
- if (opDID && kidDID && kidDID !== opDID) {
48
- return {
49
- ...base,
50
- kind: "countersig",
51
- referencedDID: opDID,
52
- signerDID: kidDID,
53
- priority: 3,
54
- previousCID: null
55
- };
56
- }
57
51
  return { ...base, kind: "content-op", referencedDID: null, signerDID: opDID, priority: 2 };
58
52
  }
59
53
  if (typ === "did:dfos:beacon") {
60
54
  const beaconDID = typeof payload["did"] === "string" ? payload["did"] : null;
61
- if (beaconDID && kidDID && kidDID !== beaconDID) {
62
- return {
63
- ...base,
64
- kind: "beacon-countersig",
65
- referencedDID: beaconDID,
66
- signerDID: kidDID,
67
- priority: 3,
68
- previousCID: null
69
- };
70
- }
71
55
  return {
72
56
  ...base,
73
57
  kind: "beacon",
@@ -77,6 +61,30 @@ var classify = (jwsToken) => {
77
61
  previousCID: null
78
62
  };
79
63
  }
64
+ if (typ === "did:dfos:countersign") {
65
+ const witnessDID = typeof payload["did"] === "string" ? payload["did"] : null;
66
+ return {
67
+ ...base,
68
+ kind: "countersign",
69
+ referencedDID: witnessDID,
70
+ signerDID: null,
71
+ priority: 3,
72
+ // processed last — target must already be ingested
73
+ previousCID: null
74
+ };
75
+ }
76
+ if (typ === "did:dfos:artifact") {
77
+ const artifactDID = typeof payload["did"] === "string" ? payload["did"] : null;
78
+ return {
79
+ ...base,
80
+ kind: "artifact",
81
+ referencedDID: artifactDID,
82
+ signerDID: null,
83
+ priority: 1,
84
+ // same as beacons — needs identity keys resolved first
85
+ previousCID: null
86
+ };
87
+ }
80
88
  return unknown;
81
89
  };
82
90
  var createKeyResolver = (store) => async (kid) => {
@@ -148,11 +156,19 @@ var ingestIdentityOp = async (jwsToken, store) => {
148
156
  const opType = payload["type"];
149
157
  const isGenesis = opType === "create";
150
158
  if (isGenesis) {
151
- const identity2 = await verifyIdentityChain({ didPrefix: "did:dfos", log: [jwsToken] });
152
- const chain2 = { did: identity2.did, log: [jwsToken], state: identity2 };
159
+ const identity = await verifyIdentityChain({ didPrefix: "did:dfos", log: [jwsToken] });
160
+ const createdAt = payload["createdAt"];
161
+ const chain2 = {
162
+ did: identity.did,
163
+ log: [jwsToken],
164
+ headCID: cid,
165
+ lastCreatedAt: createdAt,
166
+ state: identity
167
+ };
153
168
  await store.putIdentityChain(chain2);
154
- await store.putOperation({ cid, jwsToken, chainType: "identity", chainId: identity2.did });
155
- return { cid, status: "accepted", kind: "identity-op", chainId: identity2.did };
169
+ await store.putOperation({ cid, jwsToken, chainType: "identity", chainId: identity.did });
170
+ await store.appendToLog({ cid, jwsToken, kind: "identity-op", chainId: identity.did });
171
+ return { cid, status: "accepted", kind: "identity-op", chainId: identity.did };
156
172
  }
157
173
  const kid = decoded.header.kid;
158
174
  const hashIdx = kid.indexOf("#");
@@ -160,11 +176,22 @@ var ingestIdentityOp = async (jwsToken, store) => {
160
176
  const did = kid.substring(0, hashIdx);
161
177
  const chain = await store.getIdentityChain(did);
162
178
  if (!chain) return { cid, status: "rejected", error: `unknown identity: ${did}` };
163
- const newLog = [...chain.log, jwsToken];
164
- const identity = await verifyIdentityChain({ didPrefix: "did:dfos", log: newLog });
165
- const updated = { did: identity.did, log: newLog, state: identity };
179
+ const extResult = await verifyIdentityExtensionFromTrustedState({
180
+ currentState: chain.state,
181
+ headCID: chain.headCID,
182
+ lastCreatedAt: chain.lastCreatedAt,
183
+ newOp: jwsToken
184
+ });
185
+ const updated = {
186
+ did: chain.did,
187
+ log: [...chain.log, jwsToken],
188
+ headCID: extResult.operationCID,
189
+ lastCreatedAt: extResult.createdAt,
190
+ state: extResult.state
191
+ };
166
192
  await store.putIdentityChain(updated);
167
193
  await store.putOperation({ cid, jwsToken, chainType: "identity", chainId: did });
194
+ await store.appendToLog({ cid, jwsToken, kind: "identity-op", chainId: did });
168
195
  return { cid, status: "accepted", kind: "identity-op", chainId: did };
169
196
  };
170
197
  var ingestContentOp = async (jwsToken, store) => {
@@ -195,20 +222,23 @@ var ingestContentOp = async (jwsToken, store) => {
195
222
  const opType = payload["type"];
196
223
  const isGenesis = opType === "create";
197
224
  if (isGenesis) {
198
- const content2 = await verifyContentChain({
225
+ const content = await verifyContentChain({
199
226
  log: [jwsToken],
200
227
  resolveKey,
201
228
  enforceAuthorization: true
202
229
  });
230
+ const createdAt = payload["createdAt"];
203
231
  const chain2 = {
204
- contentId: content2.contentId,
205
- genesisCID: content2.genesisCID,
232
+ contentId: content.contentId,
233
+ genesisCID: content.genesisCID,
206
234
  log: [jwsToken],
207
- state: content2
235
+ lastCreatedAt: createdAt,
236
+ state: content
208
237
  };
209
238
  await store.putContentChain(chain2);
210
- await store.putOperation({ cid, jwsToken, chainType: "content", chainId: content2.contentId });
211
- return { cid, status: "accepted", kind: "content-op", chainId: content2.contentId };
239
+ await store.putOperation({ cid, jwsToken, chainType: "content", chainId: content.contentId });
240
+ await store.appendToLog({ cid, jwsToken, kind: "content-op", chainId: content.contentId });
241
+ return { cid, status: "accepted", kind: "content-op", chainId: content.contentId };
212
242
  }
213
243
  const previousCID = payload["previousOperationCID"];
214
244
  if (typeof previousCID !== "string") {
@@ -230,21 +260,24 @@ var ingestContentOp = async (jwsToken, store) => {
230
260
  if (chain.state.headCID !== previousCID) {
231
261
  return { cid, status: "rejected", error: "chain has diverged (first-seen-wins)" };
232
262
  }
233
- const newLog = [...chain.log, jwsToken];
234
- const content = await verifyContentChain({
235
- log: newLog,
263
+ const extResult = await verifyContentExtensionFromTrustedState({
264
+ currentState: chain.state,
265
+ lastCreatedAt: chain.lastCreatedAt,
266
+ newOp: jwsToken,
236
267
  resolveKey,
237
268
  enforceAuthorization: true
238
269
  });
239
270
  const updated = {
240
- contentId: content.contentId,
241
- genesisCID: content.genesisCID,
242
- log: newLog,
243
- state: content
271
+ contentId: chain.contentId,
272
+ genesisCID: chain.genesisCID,
273
+ log: [...chain.log, jwsToken],
274
+ lastCreatedAt: extResult.createdAt,
275
+ state: extResult.state
244
276
  };
245
277
  await store.putContentChain(updated);
246
- await store.putOperation({ cid, jwsToken, chainType: "content", chainId: content.contentId });
247
- return { cid, status: "accepted", kind: "content-op", chainId: content.contentId };
278
+ await store.putOperation({ cid, jwsToken, chainType: "content", chainId: chain.contentId });
279
+ await store.appendToLog({ cid, jwsToken, kind: "content-op", chainId: chain.contentId });
280
+ return { cid, status: "accepted", kind: "content-op", chainId: chain.contentId };
248
281
  };
249
282
  var ingestBeacon = async (jwsToken, store) => {
250
283
  const resolveKey = createKeyResolver(store);
@@ -270,72 +303,96 @@ var ingestBeacon = async (jwsToken, store) => {
270
303
  }
271
304
  }
272
305
  await store.putBeacon({ did, jwsToken, beaconCID: cid, state: verified });
306
+ await store.putOperation({ cid, jwsToken, chainType: "beacon", chainId: did });
307
+ await store.appendToLog({ cid, jwsToken, kind: "beacon", chainId: did });
273
308
  return { cid, status: "accepted", kind: "beacon", chainId: did };
274
309
  };
275
- var ingestCountersig = async (jwsToken, store) => {
276
- const decoded = decodeJwsUnsafe(jwsToken);
277
- if (!decoded) return { cid: "", status: "rejected", error: "failed to decode JWS" };
278
- const payload = decoded.payload;
279
- const encoded = await dagCborCanonicalEncode(payload);
280
- const operationCID = encoded.cid.toString();
281
- const existingOp = await store.getOperation(operationCID);
282
- if (!existingOp) {
283
- return { cid: operationCID, status: "rejected", error: `unknown operation: ${operationCID}` };
284
- }
310
+ var ingestCountersign = async (jwsToken, store) => {
285
311
  const resolveKey = createKeyResolver(store);
312
+ let verified;
286
313
  try {
287
- await verifyCountersignature({ jwsToken, expectedCID: operationCID, resolveKey });
314
+ verified = await verifyCountersignature({ jwsToken, resolveKey });
288
315
  } catch (err) {
289
316
  const message = err instanceof Error ? err.message : "verification failed";
290
- return { cid: operationCID, status: "rejected", error: message };
291
- }
292
- await store.addCountersignature(operationCID, jwsToken);
293
- return {
294
- cid: operationCID,
295
- status: "accepted",
296
- kind: "countersig",
297
- chainId: existingOp.chainId
298
- };
299
- };
300
- var ingestBeaconCountersig = async (jwsToken, store) => {
301
- const decoded = decodeJwsUnsafe(jwsToken);
302
- if (!decoded) return { cid: "", status: "rejected", error: "failed to decode JWS" };
303
- const payload = decoded.payload;
304
- const encoded = await dagCborCanonicalEncode(payload);
305
- const beaconCID = encoded.cid.toString();
306
- const beaconDID = typeof payload["did"] === "string" ? payload["did"] : null;
307
- if (!beaconDID) {
308
- return { cid: beaconCID, status: "rejected", error: "missing beacon DID" };
317
+ return { cid: "", status: "rejected", error: message };
309
318
  }
310
- const existingBeacon = await store.getBeacon(beaconDID);
311
- if (!existingBeacon) {
312
- return { cid: beaconCID, status: "rejected", error: `unknown beacon for DID: ${beaconDID}` };
319
+ const cid = verified.countersignCID;
320
+ const { witnessDID, targetCID } = verified;
321
+ const existing = await store.getOperation(cid);
322
+ if (existing) {
323
+ if (existing.jwsToken !== jwsToken) {
324
+ return {
325
+ cid,
326
+ status: "rejected",
327
+ error: "countersign already exists with a different signature"
328
+ };
329
+ }
330
+ return { cid, status: "accepted", kind: "countersign", chainId: targetCID };
313
331
  }
314
- if (existingBeacon.beaconCID !== beaconCID) {
315
- return {
316
- cid: beaconCID,
317
- status: "rejected",
318
- error: "beacon countersignature CID does not match current beacon"
319
- };
332
+ const targetOp = await store.getOperation(targetCID);
333
+ if (!targetOp) {
334
+ return { cid, status: "rejected", error: `unknown target operation: ${targetCID}` };
335
+ }
336
+ let targetAuthorDID = null;
337
+ if (targetOp.chainType === "identity") {
338
+ targetAuthorDID = targetOp.chainId;
339
+ } else {
340
+ const targetDecoded = decodeJwsUnsafe(targetOp.jwsToken);
341
+ if (targetDecoded) {
342
+ const targetPayload = targetDecoded.payload;
343
+ targetAuthorDID = typeof targetPayload["did"] === "string" ? targetPayload["did"] : null;
344
+ }
345
+ }
346
+ if (targetAuthorDID && witnessDID === targetAuthorDID) {
347
+ return { cid, status: "rejected", error: "witness DID must differ from target author DID" };
348
+ }
349
+ const witnessIdentity = await store.getIdentityChain(witnessDID);
350
+ if (witnessIdentity?.state.isDeleted) {
351
+ return { cid, status: "rejected", error: "witness identity is deleted" };
352
+ }
353
+ const existingCountersigns = await store.getCountersignatures(targetCID);
354
+ for (const csJws of existingCountersigns) {
355
+ const csDecoded = decodeJwsUnsafe(csJws);
356
+ if (!csDecoded) continue;
357
+ const csPayload = csDecoded.payload;
358
+ if (csPayload["did"] === witnessDID) {
359
+ return { cid, status: "accepted", kind: "countersign", chainId: targetCID };
360
+ }
320
361
  }
362
+ await store.putOperation({ cid, jwsToken, chainType: "countersign", chainId: targetCID });
363
+ await store.addCountersignature(targetCID, jwsToken);
364
+ await store.appendToLog({ cid, jwsToken, kind: "countersign", chainId: targetCID });
365
+ return { cid, status: "accepted", kind: "countersign", chainId: targetCID };
366
+ };
367
+ var ingestArtifact = async (jwsToken, store) => {
321
368
  const resolveKey = createKeyResolver(store);
369
+ let verified;
322
370
  try {
323
- await verifyBeaconCountersignature({
324
- jwsToken,
325
- expectedCID: beaconCID,
326
- resolveKey
327
- });
371
+ verified = await verifyArtifact({ jwsToken, resolveKey });
328
372
  } catch (err) {
329
373
  const message = err instanceof Error ? err.message : "verification failed";
330
- return { cid: beaconCID, status: "rejected", error: message };
331
- }
332
- await store.addCountersignature(beaconCID, jwsToken);
333
- return {
334
- cid: beaconCID,
335
- status: "accepted",
336
- kind: "beacon-countersig",
337
- chainId: beaconDID
338
- };
374
+ return { cid: "", status: "rejected", error: message };
375
+ }
376
+ const cid = verified.artifactCID;
377
+ const did = verified.payload.did;
378
+ const existing = await store.getOperation(cid);
379
+ if (existing) {
380
+ if (existing.jwsToken !== jwsToken) {
381
+ return {
382
+ cid,
383
+ status: "rejected",
384
+ error: "artifact already exists with a different signature"
385
+ };
386
+ }
387
+ return { cid, status: "accepted", kind: "artifact", chainId: did };
388
+ }
389
+ const identity = await store.getIdentityChain(did);
390
+ if (identity?.state.isDeleted) {
391
+ return { cid, status: "rejected", error: "identity is deleted" };
392
+ }
393
+ await store.putOperation({ cid, jwsToken, chainType: "artifact", chainId: did });
394
+ await store.appendToLog({ cid, jwsToken, kind: "artifact", chainId: did });
395
+ return { cid, status: "accepted", kind: "artifact", chainId: did };
339
396
  };
340
397
  var dependencySort = (ops) => {
341
398
  const buckets = /* @__PURE__ */ new Map();
@@ -411,11 +468,11 @@ var ingestOperations = async (tokens, store) => {
411
468
  case "beacon":
412
469
  result = await ingestBeacon(op.jwsToken, store);
413
470
  break;
414
- case "countersig":
415
- result = await ingestCountersig(op.jwsToken, store);
471
+ case "countersign":
472
+ result = await ingestCountersign(op.jwsToken, store);
416
473
  break;
417
- case "beacon-countersig":
418
- result = await ingestBeaconCountersig(op.jwsToken, store);
474
+ case "artifact":
475
+ result = await ingestArtifact(op.jwsToken, store);
419
476
  break;
420
477
  default:
421
478
  result = { cid: "", status: "rejected", error: "unrecognized operation type" };
@@ -432,7 +489,65 @@ var ingestOperations = async (tokens, store) => {
432
489
  return indexedResults.sort((a, b) => a.index - b.index).map((r) => r.result);
433
490
  };
434
491
 
492
+ // src/bootstrap.ts
493
+ var bootstrapRelayIdentity = async (store) => {
494
+ const keypair = createNewEd25519Keypair();
495
+ const keyId = generateId("key");
496
+ const multibase = encodeEd25519Multikey(keypair.publicKey);
497
+ const signer = async (msg) => signPayloadEd25519(msg, keypair.privateKey);
498
+ const key = { id: keyId, type: "Multikey", publicKeyMultibase: multibase };
499
+ const identityOp = {
500
+ version: 1,
501
+ type: "create",
502
+ authKeys: [key],
503
+ assertKeys: [key],
504
+ controllerKeys: [key],
505
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
506
+ };
507
+ const { jwsToken: identityJws } = await signIdentityOperation({
508
+ operation: identityOp,
509
+ signer,
510
+ keyId
511
+ });
512
+ const [identityResult] = await ingestOperations([identityJws], store);
513
+ if (!identityResult || identityResult.status !== "accepted" || !identityResult.chainId) {
514
+ throw new Error(`failed to bootstrap relay identity: ${identityResult?.error ?? "unknown"}`);
515
+ }
516
+ const did = identityResult.chainId;
517
+ const profilePayload = {
518
+ version: 1,
519
+ type: "artifact",
520
+ did,
521
+ content: {
522
+ $schema: "https://schemas.dfos.com/profile/v1",
523
+ name: "DFOS Relay"
524
+ },
525
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
526
+ };
527
+ const kid = `${did}#${keyId}`;
528
+ const { jwsToken: profileArtifactJws } = await signArtifact({
529
+ payload: profilePayload,
530
+ signer,
531
+ kid
532
+ });
533
+ const [artifactResult] = await ingestOperations([profileArtifactJws], store);
534
+ if (!artifactResult || artifactResult.status !== "accepted") {
535
+ throw new Error(
536
+ `failed to ingest relay profile artifact: ${artifactResult?.error ?? "unknown"}`
537
+ );
538
+ }
539
+ return { did, profileArtifactJws };
540
+ };
541
+
542
+ // src/relay.ts
543
+ import { VC_TYPE_CONTENT_READ, verifyCredential } from "@metalabel/dfos-protocol/credentials";
544
+ import { dagCborCanonicalEncode as dagCborCanonicalEncode2, decodeJwsUnsafe as decodeJwsUnsafe3 } from "@metalabel/dfos-protocol/crypto";
545
+ import { Hono } from "hono";
546
+ import { z } from "zod";
547
+
435
548
  // src/auth.ts
549
+ import { verifyAuthToken } from "@metalabel/dfos-protocol/credentials";
550
+ import { decodeJwsUnsafe as decodeJwsUnsafe2 } from "@metalabel/dfos-protocol/crypto";
436
551
  var authenticateRequest = async (authHeader, relayDID, store) => {
437
552
  if (!authHeader) return null;
438
553
  if (!authHeader.startsWith("Bearer ")) return null;
@@ -460,14 +575,21 @@ var authenticateRequest = async (authHeader, relayDID, store) => {
460
575
  var IngestBody = z.object({
461
576
  operations: z.array(z.string()).min(1).max(100)
462
577
  });
463
- var createRelay = (options) => {
464
- const { relayDID, store } = options;
578
+ var createRelay = async (options) => {
579
+ const { store } = options;
580
+ const contentEnabled = options.content !== false;
581
+ const identity = options.identity ?? await bootstrapRelayIdentity(store);
582
+ const relayDID = identity.did;
583
+ const profileArtifactJws = identity.profileArtifactJws;
465
584
  const app = new Hono();
466
585
  app.get("/.well-known/dfos-relay", (c) => {
467
586
  return c.json({
468
587
  did: relayDID,
469
588
  protocol: "dfos-web-relay",
470
- version: "0.1.0"
589
+ version: "0.1.0",
590
+ proof: true,
591
+ content: contentEnabled,
592
+ profile: profileArtifactJws
471
593
  });
472
594
  });
473
595
  app.post("/operations", async (c) => {
@@ -495,16 +617,54 @@ var createRelay = (options) => {
495
617
  chainId: op.chainId
496
618
  });
497
619
  });
620
+ app.get("/identities/:did/log", async (c) => {
621
+ const did = c.req.param("did");
622
+ const chain = await store.getIdentityChain(did);
623
+ if (!chain) return c.json({ error: "not found" }, 404);
624
+ const after = c.req.query("after");
625
+ const limit = Math.min(Number(c.req.query("limit") || 100), 1e3);
626
+ const entries = chain.log.map((jws) => {
627
+ const decoded = decodeJwsUnsafe3(jws);
628
+ return { cid: decoded?.header.cid || "", jwsToken: jws };
629
+ });
630
+ let startIdx = 0;
631
+ if (after) {
632
+ const idx = entries.findIndex((e) => e.cid === after);
633
+ startIdx = idx >= 0 ? idx + 1 : entries.length;
634
+ }
635
+ const page = entries.slice(startIdx, startIdx + limit);
636
+ const cursor = page.length === limit ? page[page.length - 1].cid : null;
637
+ return c.json({ entries: page, cursor });
638
+ });
498
639
  app.get("/identities/:did{.+}", async (c) => {
499
640
  const did = c.req.param("did");
500
641
  const chain = await store.getIdentityChain(did);
501
642
  if (!chain) return c.json({ error: "not found" }, 404);
502
643
  return c.json({
503
644
  did: chain.did,
504
- log: chain.log,
645
+ headCID: chain.headCID,
505
646
  state: chain.state
506
647
  });
507
648
  });
649
+ app.get("/content/:contentId/log", async (c) => {
650
+ const contentId = c.req.param("contentId");
651
+ const chain = await store.getContentChain(contentId);
652
+ if (!chain) return c.json({ error: "not found" }, 404);
653
+ const after = c.req.query("after");
654
+ const limit = Math.min(Number(c.req.query("limit") || 100), 1e3);
655
+ const entries = chain.log.map((jws) => {
656
+ const decoded = decodeJwsUnsafe3(jws);
657
+ return { cid: decoded?.header.cid || "", jwsToken: jws };
658
+ });
659
+ let startIdx = 0;
660
+ if (after) {
661
+ const idx = entries.findIndex((e) => e.cid === after);
662
+ startIdx = idx >= 0 ? idx + 1 : entries.length;
663
+ }
664
+ const page = entries.slice(startIdx, startIdx + limit);
665
+ const cursor = page.length === limit ? page[page.length - 1].cid : null;
666
+ return c.json({ entries: page, cursor });
667
+ });
508
668
  app.get("/content/:contentId", async (c) => {
509
669
  const contentId = c.req.param("contentId");
510
670
  const chain = await store.getContentChain(contentId);
@@ -512,7 +672,7 @@ var createRelay = (options) => {
512
672
  return c.json({
513
673
  contentId: chain.contentId,
514
674
  genesisCID: chain.genesisCID,
515
- log: chain.log,
675
+ headCID: chain.state.headCID,
516
676
  state: chain.state
517
677
  });
518
678
  });
@@ -545,7 +705,14 @@ var createRelay = (options) => {
545
705
  payload: beacon.state.payload
546
706
  });
547
707
  });
708
+ app.get("/log", async (c) => {
709
+ const afterParam = c.req.query("after");
710
+ const limit = Math.min(Number(c.req.query("limit") || 100), 1e3);
711
+ const result = await store.readLog(afterParam ? { after: afterParam, limit } : { limit });
712
+ return c.json(result);
713
+ });
548
714
  app.put("/content/:contentId/blob/:operationCID", async (c) => {
715
+ if (!contentEnabled) return c.json({ error: "content plane not available" }, 501);
549
716
  const contentId = c.req.param("contentId");
550
717
  const operationCID = c.req.param("operationCID");
551
718
  const auth = await authenticateRequest(c.req.header("authorization"), relayDID, store);
@@ -583,6 +750,7 @@ var createRelay = (options) => {
583
750
  return c.json({ status: "stored", contentId, documentCID, operationCID });
584
751
  });
585
752
  app.get("/content/:contentId/blob", async (c) => {
753
+ if (!contentEnabled) return c.json({ error: "content plane not available" }, 501);
586
754
  return await readBlob({
587
755
  contentId: c.req.param("contentId"),
588
756
  ref: "head",
@@ -593,6 +761,7 @@ var createRelay = (options) => {
593
761
  });
594
762
  });
595
763
  app.get("/content/:contentId/blob/:ref", async (c) => {
764
+ if (!contentEnabled) return c.json({ error: "content plane not available" }, 501);
596
765
  return await readBlob({
597
766
  contentId: c.req.param("contentId"),
598
767
  ref: c.req.param("ref"),
@@ -694,6 +863,7 @@ var MemoryRelayStore = class {
694
863
  beacons = /* @__PURE__ */ new Map();
695
864
  blobs = /* @__PURE__ */ new Map();
696
865
  countersignatures = /* @__PURE__ */ new Map();
866
+ operationLog = [];
697
867
  async getOperation(cid) {
698
868
  return this.operations.get(cid);
699
869
  }
@@ -744,9 +914,24 @@ var MemoryRelayStore = class {
744
914
  existing.push(jwsToken);
745
915
  this.countersignatures.set(operationCID, existing);
746
916
  }
917
+ async appendToLog(entry) {
918
+ this.operationLog.push(entry);
919
+ }
920
+ async readLog(params) {
921
+ let startIdx = 0;
922
+ if (params.after) {
923
+ const idx = this.operationLog.findIndex((e) => e.cid === params.after);
924
+ if (idx >= 0) startIdx = idx + 1;
925
+ else startIdx = this.operationLog.length;
926
+ }
927
+ const entries = this.operationLog.slice(startIdx, startIdx + params.limit);
928
+ const cursor = entries.length === params.limit ? entries[entries.length - 1].cid : null;
929
+ return { entries, cursor };
930
+ }
747
931
  };
748
932
  export {
749
933
  MemoryRelayStore,
934
+ bootstrapRelayIdentity,
750
935
  createCurrentKeyResolver,
751
936
  createKeyResolver,
752
937
  createRelay,
package/dist/serve.d.ts CHANGED
@@ -12,7 +12,7 @@ interface ServeOptions {
12
12
  * import { createRelay, MemoryRelayStore } from '@metalabel/dfos-web-relay';
13
13
  * import { serve } from '@metalabel/dfos-web-relay/node';
14
14
  *
15
- * const relay = createRelay({ relayDID: 'did:dfos:myrelay', store: new MemoryRelayStore() });
15
+ * const relay = await createRelay({ store: new MemoryRelayStore() });
16
16
  * serve(relay, { port: 4444 });
17
17
  * ```
18
18
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metalabel/dfos-web-relay",
3
- "version": "0.4.1",
3
+ "version": "0.5.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",
@@ -42,7 +42,7 @@
42
42
  "README.md"
43
43
  ],
44
44
  "dependencies": {
45
- "hono": "^4.12.5",
45
+ "hono": "^4.12.8",
46
46
  "zod": "^4.3.6"
47
47
  },
48
48
  "peerDependencies": {
@@ -51,13 +51,15 @@
51
51
  "devDependencies": {
52
52
  "@types/node": "^24.10.4",
53
53
  "tsup": "^8.5.1",
54
- "vitest": "^4.0.18",
55
- "@metalabel/dfos-protocol": "0.4.0"
54
+ "tsx": "^4.20.3",
55
+ "vitest": "^4.1.0",
56
+ "@metalabel/dfos-protocol": "0.5.0"
56
57
  },
57
58
  "scripts": {
58
59
  "build": "tsup",
59
60
  "clean": "rm -rf dist",
60
61
  "typecheck": "tsc --noEmit",
61
- "test": "vitest run"
62
+ "test": "vitest run",
63
+ "test:conformance": "./tests/run-conformance.sh"
62
64
  }
63
65
  }