@metalabel/dfos-web-relay 0.4.1 → 0.6.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/dist/index.js CHANGED
@@ -1,23 +1,33 @@
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";
25
+ var MAX_FUTURE_TIMESTAMP_MS = 24 * 60 * 60 * 1e3;
26
+ var isFutureTimestamp = (createdAt) => {
27
+ const ts = new Date(createdAt).getTime();
28
+ if (isNaN(ts)) return false;
29
+ return ts > Date.now() + MAX_FUTURE_TIMESTAMP_MS;
30
+ };
21
31
  var classify = (jwsToken) => {
22
32
  const unknown = {
23
33
  jwsToken,
@@ -44,30 +54,10 @@ var classify = (jwsToken) => {
44
54
  }
45
55
  if (typ === "did:dfos:content-op") {
46
56
  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
57
  return { ...base, kind: "content-op", referencedDID: null, signerDID: opDID, priority: 2 };
58
58
  }
59
59
  if (typ === "did:dfos:beacon") {
60
60
  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
61
  return {
72
62
  ...base,
73
63
  kind: "beacon",
@@ -77,6 +67,30 @@ var classify = (jwsToken) => {
77
67
  previousCID: null
78
68
  };
79
69
  }
70
+ if (typ === "did:dfos:countersign") {
71
+ const witnessDID = typeof payload["did"] === "string" ? payload["did"] : null;
72
+ return {
73
+ ...base,
74
+ kind: "countersign",
75
+ referencedDID: witnessDID,
76
+ signerDID: null,
77
+ priority: 3,
78
+ // processed last — target must already be ingested
79
+ previousCID: null
80
+ };
81
+ }
82
+ if (typ === "did:dfos:artifact") {
83
+ const artifactDID = typeof payload["did"] === "string" ? payload["did"] : null;
84
+ return {
85
+ ...base,
86
+ kind: "artifact",
87
+ referencedDID: artifactDID,
88
+ signerDID: null,
89
+ priority: 1,
90
+ // same as beacons — needs identity keys resolved first
91
+ previousCID: null
92
+ };
93
+ }
80
94
  return unknown;
81
95
  };
82
96
  var createKeyResolver = (store) => async (kid) => {
@@ -128,12 +142,16 @@ var createCurrentKeyResolver = (store) => async (kid) => {
128
142
  if (currentKey) return decodeMultikey(currentKey.publicKeyMultibase).keyBytes;
129
143
  throw new Error(`unknown key ${keyId} on identity ${did}`);
130
144
  };
131
- var ingestIdentityOp = async (jwsToken, store) => {
145
+ var ingestIdentityOp = async (jwsToken, store, logEnabled) => {
132
146
  const decoded = decodeJwsUnsafe(jwsToken);
133
147
  if (!decoded) return { cid: "", status: "rejected", error: "failed to decode JWS" };
134
148
  const payload = decoded.payload;
135
149
  const encoded = await dagCborCanonicalEncode(payload);
136
150
  const cid = encoded.cid.toString();
151
+ const createdAtVal = payload["createdAt"];
152
+ if (typeof createdAtVal === "string" && isFutureTimestamp(createdAtVal)) {
153
+ return { cid, status: "rejected", error: "createdAt is too far in the future" };
154
+ }
137
155
  const existing = await store.getOperation(cid);
138
156
  if (existing) {
139
157
  if (existing.jwsToken !== jwsToken) {
@@ -143,16 +161,26 @@ var ingestIdentityOp = async (jwsToken, store) => {
143
161
  error: "operation already exists with a different signature"
144
162
  };
145
163
  }
146
- return { cid, status: "accepted", kind: "identity-op", chainId: existing.chainId };
164
+ return { cid, status: "duplicate", kind: "identity-op", chainId: existing.chainId };
147
165
  }
148
166
  const opType = payload["type"];
149
167
  const isGenesis = opType === "create";
150
168
  if (isGenesis) {
151
- const identity2 = await verifyIdentityChain({ didPrefix: "did:dfos", log: [jwsToken] });
152
- const chain2 = { did: identity2.did, log: [jwsToken], state: identity2 };
169
+ const identity = await verifyIdentityChain({ didPrefix: "did:dfos", log: [jwsToken] });
170
+ const createdAt = payload["createdAt"];
171
+ const chain2 = {
172
+ did: identity.did,
173
+ log: [jwsToken],
174
+ headCID: cid,
175
+ lastCreatedAt: createdAt,
176
+ state: identity
177
+ };
153
178
  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 };
179
+ await store.putOperation({ cid, jwsToken, chainType: "identity", chainId: identity.did });
180
+ if (logEnabled) {
181
+ await store.appendToLog({ cid, jwsToken, kind: "identity-op", chainId: identity.did });
182
+ }
183
+ return { cid, status: "new", kind: "identity-op", chainId: identity.did };
156
184
  }
157
185
  const kid = decoded.header.kid;
158
186
  const hashIdx = kid.indexOf("#");
@@ -160,19 +188,78 @@ var ingestIdentityOp = async (jwsToken, store) => {
160
188
  const did = kid.substring(0, hashIdx);
161
189
  const chain = await store.getIdentityChain(did);
162
190
  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 };
191
+ const previousCID = typeof payload["previousOperationCID"] === "string" ? payload["previousOperationCID"] : null;
192
+ if (previousCID === chain.headCID) {
193
+ const extResult2 = await verifyIdentityExtensionFromTrustedState({
194
+ currentState: chain.state,
195
+ headCID: chain.headCID,
196
+ lastCreatedAt: chain.lastCreatedAt,
197
+ newOp: jwsToken
198
+ });
199
+ const updated2 = {
200
+ did: chain.did,
201
+ log: [...chain.log, jwsToken],
202
+ headCID: extResult2.operationCID,
203
+ lastCreatedAt: extResult2.createdAt,
204
+ state: extResult2.state
205
+ };
206
+ await store.putIdentityChain(updated2);
207
+ await store.putOperation({ cid, jwsToken, chainType: "identity", chainId: did });
208
+ if (logEnabled) {
209
+ await store.appendToLog({ cid, jwsToken, kind: "identity-op", chainId: did });
210
+ }
211
+ return { cid, status: "new", kind: "identity-op", chainId: did };
212
+ }
213
+ if (!previousCID || !chainLogContainsCID(chain.log, previousCID)) {
214
+ return { cid, status: "rejected", error: "unknown previous operation in identity chain" };
215
+ }
216
+ const forkState = await store.getIdentityStateAtCID(did, previousCID);
217
+ if (!forkState) {
218
+ return { cid, status: "rejected", error: "failed to compute state at fork point" };
219
+ }
220
+ const extResult = await verifyIdentityExtensionFromTrustedState({
221
+ currentState: forkState.state,
222
+ headCID: previousCID,
223
+ lastCreatedAt: forkState.lastCreatedAt,
224
+ newOp: jwsToken
225
+ });
226
+ const updatedLog = [...chain.log, jwsToken];
227
+ const head = selectDeterministicHead(updatedLog);
228
+ let headState = chain.state;
229
+ let headLastCreatedAt = chain.lastCreatedAt;
230
+ let headCID = chain.headCID;
231
+ if (head.cid === cid) {
232
+ headState = extResult.state;
233
+ headLastCreatedAt = extResult.createdAt;
234
+ headCID = cid;
235
+ } else {
236
+ headCID = head.cid;
237
+ headLastCreatedAt = head.createdAt;
238
+ }
239
+ const updated = {
240
+ did: chain.did,
241
+ log: updatedLog,
242
+ headCID,
243
+ lastCreatedAt: headLastCreatedAt,
244
+ state: headState
245
+ };
166
246
  await store.putIdentityChain(updated);
167
247
  await store.putOperation({ cid, jwsToken, chainType: "identity", chainId: did });
168
- return { cid, status: "accepted", kind: "identity-op", chainId: did };
248
+ if (logEnabled) {
249
+ await store.appendToLog({ cid, jwsToken, kind: "identity-op", chainId: did });
250
+ }
251
+ return { cid, status: "new", kind: "identity-op", chainId: did };
169
252
  };
170
- var ingestContentOp = async (jwsToken, store) => {
253
+ var ingestContentOp = async (jwsToken, store, logEnabled) => {
171
254
  const decoded = decodeJwsUnsafe(jwsToken);
172
255
  if (!decoded) return { cid: "", status: "rejected", error: "failed to decode JWS" };
173
256
  const payload = decoded.payload;
174
257
  const encoded = await dagCborCanonicalEncode(payload);
175
258
  const cid = encoded.cid.toString();
259
+ const createdAtVal = payload["createdAt"];
260
+ if (typeof createdAtVal === "string" && isFutureTimestamp(createdAtVal)) {
261
+ return { cid, status: "rejected", error: "createdAt is too far in the future" };
262
+ }
176
263
  const existing = await store.getOperation(cid);
177
264
  if (existing) {
178
265
  if (existing.jwsToken !== jwsToken) {
@@ -182,7 +269,7 @@ var ingestContentOp = async (jwsToken, store) => {
182
269
  error: "operation already exists with a different signature"
183
270
  };
184
271
  }
185
- return { cid, status: "accepted", kind: "content-op", chainId: existing.chainId };
272
+ return { cid, status: "duplicate", kind: "content-op", chainId: existing.chainId };
186
273
  }
187
274
  const signerDID = payload["did"];
188
275
  if (typeof signerDID === "string") {
@@ -195,20 +282,25 @@ var ingestContentOp = async (jwsToken, store) => {
195
282
  const opType = payload["type"];
196
283
  const isGenesis = opType === "create";
197
284
  if (isGenesis) {
198
- const content2 = await verifyContentChain({
285
+ const content = await verifyContentChain({
199
286
  log: [jwsToken],
200
287
  resolveKey,
201
288
  enforceAuthorization: true
202
289
  });
290
+ const createdAt = payload["createdAt"];
203
291
  const chain2 = {
204
- contentId: content2.contentId,
205
- genesisCID: content2.genesisCID,
292
+ contentId: content.contentId,
293
+ genesisCID: content.genesisCID,
206
294
  log: [jwsToken],
207
- state: content2
295
+ lastCreatedAt: createdAt,
296
+ state: content
208
297
  };
209
298
  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 };
299
+ await store.putOperation({ cid, jwsToken, chainType: "content", chainId: content.contentId });
300
+ if (logEnabled) {
301
+ await store.appendToLog({ cid, jwsToken, kind: "content-op", chainId: content.contentId });
302
+ }
303
+ return { cid, status: "new", kind: "content-op", chainId: content.contentId };
212
304
  }
213
305
  const previousCID = payload["previousOperationCID"];
214
306
  if (typeof previousCID !== "string") {
@@ -227,26 +319,65 @@ var ingestContentOp = async (jwsToken, store) => {
227
319
  if (creatorIdentity?.state.isDeleted) {
228
320
  return { cid, status: "rejected", error: "content creator identity is deleted" };
229
321
  }
230
- if (chain.state.headCID !== previousCID) {
231
- return { cid, status: "rejected", error: "chain has diverged (first-seen-wins)" };
322
+ if (chain.state.headCID === previousCID) {
323
+ const extResult2 = await verifyContentExtensionFromTrustedState({
324
+ currentState: chain.state,
325
+ lastCreatedAt: chain.lastCreatedAt,
326
+ newOp: jwsToken,
327
+ resolveKey,
328
+ enforceAuthorization: true
329
+ });
330
+ const updated2 = {
331
+ contentId: chain.contentId,
332
+ genesisCID: chain.genesisCID,
333
+ log: [...chain.log, jwsToken],
334
+ lastCreatedAt: extResult2.createdAt,
335
+ state: extResult2.state
336
+ };
337
+ await store.putContentChain(updated2);
338
+ await store.putOperation({ cid, jwsToken, chainType: "content", chainId: chain.contentId });
339
+ if (logEnabled) {
340
+ await store.appendToLog({ cid, jwsToken, kind: "content-op", chainId: chain.contentId });
341
+ }
342
+ return { cid, status: "new", kind: "content-op", chainId: chain.contentId };
343
+ }
344
+ if (!chainLogContainsCID(chain.log, previousCID)) {
345
+ return { cid, status: "rejected", error: "unknown previous operation in content chain" };
346
+ }
347
+ const forkState = await store.getContentStateAtCID(chain.contentId, previousCID);
348
+ if (!forkState) {
349
+ return { cid, status: "rejected", error: "failed to compute state at fork point" };
232
350
  }
233
- const newLog = [...chain.log, jwsToken];
234
- const content = await verifyContentChain({
235
- log: newLog,
351
+ const extResult = await verifyContentExtensionFromTrustedState({
352
+ currentState: forkState.state,
353
+ lastCreatedAt: forkState.lastCreatedAt,
354
+ newOp: jwsToken,
236
355
  resolveKey,
237
356
  enforceAuthorization: true
238
357
  });
358
+ const updatedLog = [...chain.log, jwsToken];
359
+ const head = selectDeterministicHead(updatedLog);
360
+ let headState = chain.state;
361
+ let headLastCreatedAt = chain.lastCreatedAt;
362
+ if (head.cid === cid) {
363
+ headState = extResult.state;
364
+ headLastCreatedAt = extResult.createdAt;
365
+ }
239
366
  const updated = {
240
- contentId: content.contentId,
241
- genesisCID: content.genesisCID,
242
- log: newLog,
243
- state: content
367
+ contentId: chain.contentId,
368
+ genesisCID: chain.genesisCID,
369
+ log: updatedLog,
370
+ lastCreatedAt: headLastCreatedAt,
371
+ state: headState
244
372
  };
245
373
  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 };
374
+ await store.putOperation({ cid, jwsToken, chainType: "content", chainId: chain.contentId });
375
+ if (logEnabled) {
376
+ await store.appendToLog({ cid, jwsToken, kind: "content-op", chainId: chain.contentId });
377
+ }
378
+ return { cid, status: "new", kind: "content-op", chainId: chain.contentId };
248
379
  };
249
- var ingestBeacon = async (jwsToken, store) => {
380
+ var ingestBeacon = async (jwsToken, store, logEnabled) => {
250
381
  const resolveKey = createKeyResolver(store);
251
382
  let verified;
252
383
  try {
@@ -266,76 +397,106 @@ var ingestBeacon = async (jwsToken, store) => {
266
397
  const existingTime = new Date(existing.state.payload.createdAt).getTime();
267
398
  const newTime = new Date(verified.payload.createdAt).getTime();
268
399
  if (newTime <= existingTime) {
269
- return { cid, status: "accepted", kind: "beacon", chainId: did };
400
+ return { cid, status: "duplicate", kind: "beacon", chainId: did };
270
401
  }
271
402
  }
272
403
  await store.putBeacon({ did, jwsToken, beaconCID: cid, state: verified });
273
- return { cid, status: "accepted", kind: "beacon", chainId: did };
274
- };
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}` };
404
+ await store.putOperation({ cid, jwsToken, chainType: "beacon", chainId: did });
405
+ if (logEnabled) {
406
+ await store.appendToLog({ cid, jwsToken, kind: "beacon", chainId: did });
284
407
  }
408
+ return { cid, status: "new", kind: "beacon", chainId: did };
409
+ };
410
+ var ingestCountersign = async (jwsToken, store, logEnabled) => {
285
411
  const resolveKey = createKeyResolver(store);
412
+ let verified;
286
413
  try {
287
- await verifyCountersignature({ jwsToken, expectedCID: operationCID, resolveKey });
414
+ verified = await verifyCountersignature({ jwsToken, resolveKey });
288
415
  } catch (err) {
289
416
  const message = err instanceof Error ? err.message : "verification failed";
290
- return { cid: operationCID, status: "rejected", error: message };
417
+ return { cid: "", status: "rejected", error: message };
291
418
  }
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" };
419
+ const cid = verified.countersignCID;
420
+ const { witnessDID, targetCID } = verified;
421
+ const existing = await store.getOperation(cid);
422
+ if (existing) {
423
+ if (existing.jwsToken !== jwsToken) {
424
+ return {
425
+ cid,
426
+ status: "rejected",
427
+ error: "countersign already exists with a different signature"
428
+ };
429
+ }
430
+ return { cid, status: "duplicate", kind: "countersign", chainId: targetCID };
309
431
  }
310
- const existingBeacon = await store.getBeacon(beaconDID);
311
- if (!existingBeacon) {
312
- return { cid: beaconCID, status: "rejected", error: `unknown beacon for DID: ${beaconDID}` };
432
+ const targetOp = await store.getOperation(targetCID);
433
+ if (!targetOp) {
434
+ return { cid, status: "rejected", error: `unknown target operation: ${targetCID}` };
313
435
  }
314
- if (existingBeacon.beaconCID !== beaconCID) {
315
- return {
316
- cid: beaconCID,
317
- status: "rejected",
318
- error: "beacon countersignature CID does not match current beacon"
319
- };
436
+ let targetAuthorDID = null;
437
+ if (targetOp.chainType === "identity") {
438
+ targetAuthorDID = targetOp.chainId;
439
+ } else {
440
+ const targetDecoded = decodeJwsUnsafe(targetOp.jwsToken);
441
+ if (targetDecoded) {
442
+ const targetPayload = targetDecoded.payload;
443
+ targetAuthorDID = typeof targetPayload["did"] === "string" ? targetPayload["did"] : null;
444
+ }
445
+ }
446
+ if (targetAuthorDID && witnessDID === targetAuthorDID) {
447
+ return { cid, status: "rejected", error: "witness DID must differ from target author DID" };
320
448
  }
449
+ const witnessIdentity = await store.getIdentityChain(witnessDID);
450
+ if (witnessIdentity?.state.isDeleted) {
451
+ return { cid, status: "rejected", error: "witness identity is deleted" };
452
+ }
453
+ const existingCountersigns = await store.getCountersignatures(targetCID);
454
+ for (const csJws of existingCountersigns) {
455
+ const csDecoded = decodeJwsUnsafe(csJws);
456
+ if (!csDecoded) continue;
457
+ const csPayload = csDecoded.payload;
458
+ if (csPayload["did"] === witnessDID) {
459
+ return { cid, status: "duplicate", kind: "countersign", chainId: targetCID };
460
+ }
461
+ }
462
+ await store.putOperation({ cid, jwsToken, chainType: "countersign", chainId: targetCID });
463
+ await store.addCountersignature(targetCID, jwsToken);
464
+ if (logEnabled) {
465
+ await store.appendToLog({ cid, jwsToken, kind: "countersign", chainId: targetCID });
466
+ }
467
+ return { cid, status: "new", kind: "countersign", chainId: targetCID };
468
+ };
469
+ var ingestArtifact = async (jwsToken, store, logEnabled) => {
321
470
  const resolveKey = createKeyResolver(store);
471
+ let verified;
322
472
  try {
323
- await verifyBeaconCountersignature({
324
- jwsToken,
325
- expectedCID: beaconCID,
326
- resolveKey
327
- });
473
+ verified = await verifyArtifact({ jwsToken, resolveKey });
328
474
  } catch (err) {
329
475
  const message = err instanceof Error ? err.message : "verification failed";
330
- return { cid: beaconCID, status: "rejected", error: message };
476
+ return { cid: "", status: "rejected", error: message };
331
477
  }
332
- await store.addCountersignature(beaconCID, jwsToken);
333
- return {
334
- cid: beaconCID,
335
- status: "accepted",
336
- kind: "beacon-countersig",
337
- chainId: beaconDID
338
- };
478
+ const cid = verified.artifactCID;
479
+ const did = verified.payload.did;
480
+ const existing = await store.getOperation(cid);
481
+ if (existing) {
482
+ if (existing.jwsToken !== jwsToken) {
483
+ return {
484
+ cid,
485
+ status: "rejected",
486
+ error: "artifact already exists with a different signature"
487
+ };
488
+ }
489
+ return { cid, status: "duplicate", kind: "artifact", chainId: did };
490
+ }
491
+ const identity = await store.getIdentityChain(did);
492
+ if (identity?.state.isDeleted) {
493
+ return { cid, status: "rejected", error: "identity is deleted" };
494
+ }
495
+ await store.putOperation({ cid, jwsToken, chainType: "artifact", chainId: did });
496
+ if (logEnabled) {
497
+ await store.appendToLog({ cid, jwsToken, kind: "artifact", chainId: did });
498
+ }
499
+ return { cid, status: "new", kind: "artifact", chainId: did };
339
500
  };
340
501
  var dependencySort = (ops) => {
341
502
  const buckets = /* @__PURE__ */ new Map();
@@ -394,7 +555,36 @@ var topologicalSortBucket = (ops) => {
394
555
  }
395
556
  return sorted;
396
557
  };
397
- var ingestOperations = async (tokens, store) => {
558
+ var chainLogContainsCID = (log, targetCID) => {
559
+ for (const jws of log) {
560
+ const decoded = decodeJwsUnsafe(jws);
561
+ if (!decoded) continue;
562
+ if (decoded.header.cid === targetCID) return true;
563
+ }
564
+ return false;
565
+ };
566
+ var selectDeterministicHead = (log) => {
567
+ const ops = [];
568
+ const hasChild = /* @__PURE__ */ new Set();
569
+ for (const jws of log) {
570
+ const decoded = decodeJwsUnsafe(jws);
571
+ if (!decoded) continue;
572
+ const payload = decoded.payload;
573
+ const cid = typeof decoded.header.cid === "string" ? decoded.header.cid : "";
574
+ const previousCID = typeof payload["previousOperationCID"] === "string" ? payload["previousOperationCID"] : null;
575
+ const createdAt = typeof payload["createdAt"] === "string" ? payload["createdAt"] : "";
576
+ ops.push({ cid, previousCID, createdAt });
577
+ if (previousCID) hasChild.add(previousCID);
578
+ }
579
+ const tips = ops.filter((op) => !hasChild.has(op.cid));
580
+ tips.sort((a, b) => {
581
+ if (a.createdAt !== b.createdAt) return b.createdAt.localeCompare(a.createdAt);
582
+ return b.cid.localeCompare(a.cid);
583
+ });
584
+ return tips[0] ?? { cid: "", createdAt: "" };
585
+ };
586
+ var ingestOperations = async (tokens, store, options) => {
587
+ const logEnabled = options?.logEnabled !== false;
398
588
  const classified = tokens.map((token, i) => ({ ...classify(token), originalIndex: i }));
399
589
  const sorted = dependencySort(classified);
400
590
  const indexedResults = [];
@@ -403,19 +593,19 @@ var ingestOperations = async (tokens, store) => {
403
593
  let result;
404
594
  switch (op.kind) {
405
595
  case "identity-op":
406
- result = await ingestIdentityOp(op.jwsToken, store);
596
+ result = await ingestIdentityOp(op.jwsToken, store, logEnabled);
407
597
  break;
408
598
  case "content-op":
409
- result = await ingestContentOp(op.jwsToken, store);
599
+ result = await ingestContentOp(op.jwsToken, store, logEnabled);
410
600
  break;
411
601
  case "beacon":
412
- result = await ingestBeacon(op.jwsToken, store);
602
+ result = await ingestBeacon(op.jwsToken, store, logEnabled);
413
603
  break;
414
- case "countersig":
415
- result = await ingestCountersig(op.jwsToken, store);
604
+ case "countersign":
605
+ result = await ingestCountersign(op.jwsToken, store, logEnabled);
416
606
  break;
417
- case "beacon-countersig":
418
- result = await ingestBeaconCountersig(op.jwsToken, store);
607
+ case "artifact":
608
+ result = await ingestArtifact(op.jwsToken, store, logEnabled);
419
609
  break;
420
610
  default:
421
611
  result = { cid: "", status: "rejected", error: "unrecognized operation type" };
@@ -432,7 +622,114 @@ var ingestOperations = async (tokens, store) => {
432
622
  return indexedResults.sort((a, b) => a.index - b.index).map((r) => r.result);
433
623
  };
434
624
 
625
+ // src/bootstrap.ts
626
+ var bootstrapRelayIdentity = async (store) => {
627
+ const keypair = createNewEd25519Keypair();
628
+ const keyId = generateId("key");
629
+ const multibase = encodeEd25519Multikey(keypair.publicKey);
630
+ const signer = async (msg) => signPayloadEd25519(msg, keypair.privateKey);
631
+ const key = { id: keyId, type: "Multikey", publicKeyMultibase: multibase };
632
+ const identityOp = {
633
+ version: 1,
634
+ type: "create",
635
+ authKeys: [key],
636
+ assertKeys: [key],
637
+ controllerKeys: [key],
638
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
639
+ };
640
+ const { jwsToken: identityJws } = await signIdentityOperation({
641
+ operation: identityOp,
642
+ signer,
643
+ keyId
644
+ });
645
+ const [identityResult] = await ingestOperations([identityJws], store);
646
+ if (!identityResult || identityResult.status === "rejected" || !identityResult.chainId) {
647
+ throw new Error(`failed to bootstrap relay identity: ${identityResult?.error ?? "unknown"}`);
648
+ }
649
+ const did = identityResult.chainId;
650
+ const profilePayload = {
651
+ version: 1,
652
+ type: "artifact",
653
+ did,
654
+ content: {
655
+ $schema: "https://schemas.dfos.com/profile/v1",
656
+ name: "DFOS Relay"
657
+ },
658
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
659
+ };
660
+ const kid = `${did}#${keyId}`;
661
+ const { jwsToken: profileArtifactJws } = await signArtifact({
662
+ payload: profilePayload,
663
+ signer,
664
+ kid
665
+ });
666
+ const [artifactResult] = await ingestOperations([profileArtifactJws], store);
667
+ if (!artifactResult || artifactResult.status === "rejected") {
668
+ throw new Error(
669
+ `failed to ingest relay profile artifact: ${artifactResult?.error ?? "unknown"}`
670
+ );
671
+ }
672
+ return { did, profileArtifactJws };
673
+ };
674
+
675
+ // src/peer-client.ts
676
+ var createHttpPeerClient = () => {
677
+ const fetchJSON = async (url) => {
678
+ try {
679
+ const res = await fetch(url);
680
+ if (!res.ok) return null;
681
+ return await res.json();
682
+ } catch {
683
+ return null;
684
+ }
685
+ };
686
+ return {
687
+ async getIdentityLog(peerUrl, did, params) {
688
+ const url = new URL(`/identities/${encodeURIComponent(did)}/log`, peerUrl);
689
+ if (params?.after) url.searchParams.set("after", params.after);
690
+ if (params?.limit) url.searchParams.set("limit", String(params.limit));
691
+ const data = await fetchJSON(url.toString());
692
+ if (!data?.entries) return null;
693
+ return { entries: data.entries, cursor: data.cursor ?? null };
694
+ },
695
+ async getContentLog(peerUrl, contentId, params) {
696
+ const url = new URL(`/content/${encodeURIComponent(contentId)}/log`, peerUrl);
697
+ if (params?.after) url.searchParams.set("after", params.after);
698
+ if (params?.limit) url.searchParams.set("limit", String(params.limit));
699
+ const data = await fetchJSON(url.toString());
700
+ if (!data?.entries) return null;
701
+ return { entries: data.entries, cursor: data.cursor ?? null };
702
+ },
703
+ async getOperationLog(peerUrl, params) {
704
+ const url = new URL("/log", peerUrl);
705
+ if (params?.after) url.searchParams.set("after", params.after);
706
+ if (params?.limit) url.searchParams.set("limit", String(params.limit));
707
+ const data = await fetchJSON(url.toString());
708
+ if (!data?.entries) return null;
709
+ return { entries: data.entries, cursor: data.cursor ?? null };
710
+ },
711
+ async submitOperations(peerUrl, operations) {
712
+ try {
713
+ await fetch(new URL("/operations", peerUrl).toString(), {
714
+ method: "POST",
715
+ headers: { "Content-Type": "application/json" },
716
+ body: JSON.stringify({ operations })
717
+ });
718
+ } catch {
719
+ }
720
+ }
721
+ };
722
+ };
723
+
724
+ // src/relay.ts
725
+ import { VC_TYPE_CONTENT_READ, verifyCredential } from "@metalabel/dfos-protocol/credentials";
726
+ import { dagCborCanonicalEncode as dagCborCanonicalEncode2, decodeJwsUnsafe as decodeJwsUnsafe3 } from "@metalabel/dfos-protocol/crypto";
727
+ import { Hono } from "hono";
728
+ import { z } from "zod";
729
+
435
730
  // src/auth.ts
731
+ import { verifyAuthToken } from "@metalabel/dfos-protocol/credentials";
732
+ import { decodeJwsUnsafe as decodeJwsUnsafe2 } from "@metalabel/dfos-protocol/crypto";
436
733
  var authenticateRequest = async (authHeader, relayDID, store) => {
437
734
  if (!authHeader) return null;
438
735
  if (!authHeader.startsWith("Bearer ")) return null;
@@ -460,14 +757,44 @@ var authenticateRequest = async (authHeader, relayDID, store) => {
460
757
  var IngestBody = z.object({
461
758
  operations: z.array(z.string()).min(1).max(100)
462
759
  });
463
- var createRelay = (options) => {
464
- const { relayDID, store } = options;
760
+ var createRelay = async (options) => {
761
+ const { store } = options;
762
+ const contentEnabled = options.content !== false;
763
+ const logEnabled = options.log !== false;
764
+ const peers = options.peers ?? [];
765
+ const peerClient = options.peerClient;
766
+ const gossipPeers = peers.filter((p) => p.gossip !== false);
767
+ const readThroughPeers = peers.filter((p) => p.readThrough !== false);
768
+ const syncPeers = peers.filter((p) => p.sync !== false);
769
+ const identity = options.identity ?? await bootstrapRelayIdentity(store);
770
+ const relayDID = identity.did;
771
+ const profileArtifactJws = identity.profileArtifactJws;
772
+ const ingestWithGossip = async (tokens) => {
773
+ const results = await ingestOperations(tokens, store, { logEnabled });
774
+ if (gossipPeers.length > 0 && peerClient) {
775
+ const newOps = [];
776
+ for (let i = 0; i < results.length; i++) {
777
+ if (results[i].status === "new") newOps.push(tokens[i]);
778
+ }
779
+ if (newOps.length > 0) {
780
+ for (const peer of gossipPeers) {
781
+ peerClient.submitOperations(peer.url, newOps).catch(() => {
782
+ });
783
+ }
784
+ }
785
+ }
786
+ return results;
787
+ };
465
788
  const app = new Hono();
466
789
  app.get("/.well-known/dfos-relay", (c) => {
467
790
  return c.json({
468
791
  did: relayDID,
469
792
  protocol: "dfos-web-relay",
470
- version: "0.1.0"
793
+ version: "0.6.0",
794
+ proof: true,
795
+ content: contentEnabled,
796
+ log: logEnabled,
797
+ profile: profileArtifactJws
471
798
  });
472
799
  });
473
800
  app.post("/operations", async (c) => {
@@ -481,7 +808,7 @@ var createRelay = (options) => {
481
808
  if (!parsed.success) {
482
809
  return c.json({ error: "invalid request", details: parsed.error.issues }, 400);
483
810
  }
484
- const results = await ingestOperations(parsed.data.operations, store);
811
+ const results = await ingestWithGossip(parsed.data.operations);
485
812
  return c.json({ results });
486
813
  });
487
814
  app.get("/operations/:cid", async (c) => {
@@ -495,24 +822,96 @@ var createRelay = (options) => {
495
822
  chainId: op.chainId
496
823
  });
497
824
  });
498
- app.get("/identities/:did{.+}", async (c) => {
825
+ app.get("/identities/:did/log", async (c) => {
499
826
  const did = c.req.param("did");
500
827
  const chain = await store.getIdentityChain(did);
501
828
  if (!chain) return c.json({ error: "not found" }, 404);
829
+ const after = c.req.query("after");
830
+ const limit = Math.min(Number(c.req.query("limit") || 100), 1e3);
831
+ const entries = chain.log.map((jws) => {
832
+ const decoded = decodeJwsUnsafe3(jws);
833
+ return { cid: decoded?.header.cid || "", jwsToken: jws };
834
+ });
835
+ let startIdx = 0;
836
+ if (after) {
837
+ const idx = entries.findIndex((e) => e.cid === after);
838
+ startIdx = idx >= 0 ? idx + 1 : entries.length;
839
+ }
840
+ const page = entries.slice(startIdx, startIdx + limit);
841
+ const cursor = page.length === limit ? page[page.length - 1].cid : null;
842
+ return c.json({ entries: page, cursor });
843
+ });
844
+ app.get("/identities/:did{.+}", async (c) => {
845
+ const did = c.req.param("did");
846
+ let chain = await store.getIdentityChain(did);
847
+ if (!chain && readThroughPeers.length > 0 && peerClient) {
848
+ for (const peer of readThroughPeers) {
849
+ let after;
850
+ while (true) {
851
+ const logPage = await peerClient.getIdentityLog(peer.url, did, {
852
+ ...after ? { after } : {},
853
+ limit: 1e3
854
+ });
855
+ if (!logPage || logPage.entries.length === 0) break;
856
+ await ingestWithGossip(logPage.entries.map((e) => e.jwsToken));
857
+ if (!logPage.cursor) break;
858
+ after = logPage.cursor;
859
+ }
860
+ chain = await store.getIdentityChain(did);
861
+ if (chain) break;
862
+ }
863
+ }
864
+ if (!chain) return c.json({ error: "not found" }, 404);
502
865
  return c.json({
503
866
  did: chain.did,
504
- log: chain.log,
867
+ headCID: chain.headCID,
505
868
  state: chain.state
506
869
  });
507
870
  });
508
- app.get("/content/:contentId", async (c) => {
871
+ app.get("/content/:contentId/log", async (c) => {
509
872
  const contentId = c.req.param("contentId");
510
873
  const chain = await store.getContentChain(contentId);
511
874
  if (!chain) return c.json({ error: "not found" }, 404);
875
+ const after = c.req.query("after");
876
+ const limit = Math.min(Number(c.req.query("limit") || 100), 1e3);
877
+ const entries = chain.log.map((jws) => {
878
+ const decoded = decodeJwsUnsafe3(jws);
879
+ return { cid: decoded?.header.cid || "", jwsToken: jws };
880
+ });
881
+ let startIdx = 0;
882
+ if (after) {
883
+ const idx = entries.findIndex((e) => e.cid === after);
884
+ startIdx = idx >= 0 ? idx + 1 : entries.length;
885
+ }
886
+ const page = entries.slice(startIdx, startIdx + limit);
887
+ const cursor = page.length === limit ? page[page.length - 1].cid : null;
888
+ return c.json({ entries: page, cursor });
889
+ });
890
+ app.get("/content/:contentId", async (c) => {
891
+ const contentId = c.req.param("contentId");
892
+ let chain = await store.getContentChain(contentId);
893
+ if (!chain && readThroughPeers.length > 0 && peerClient) {
894
+ for (const peer of readThroughPeers) {
895
+ let after;
896
+ while (true) {
897
+ const logPage = await peerClient.getContentLog(peer.url, contentId, {
898
+ ...after ? { after } : {},
899
+ limit: 1e3
900
+ });
901
+ if (!logPage || logPage.entries.length === 0) break;
902
+ await ingestWithGossip(logPage.entries.map((e) => e.jwsToken));
903
+ if (!logPage.cursor) break;
904
+ after = logPage.cursor;
905
+ }
906
+ chain = await store.getContentChain(contentId);
907
+ if (chain) break;
908
+ }
909
+ }
910
+ if (!chain) return c.json({ error: "not found" }, 404);
512
911
  return c.json({
513
912
  contentId: chain.contentId,
514
913
  genesisCID: chain.genesisCID,
515
- log: chain.log,
914
+ headCID: chain.state.headCID,
516
915
  state: chain.state
517
916
  });
518
917
  });
@@ -545,7 +944,15 @@ var createRelay = (options) => {
545
944
  payload: beacon.state.payload
546
945
  });
547
946
  });
947
+ app.get("/log", async (c) => {
948
+ if (!logEnabled) return c.json({ error: "global log not available" }, 501);
949
+ const afterParam = c.req.query("after");
950
+ const limit = Math.min(Number(c.req.query("limit") || 100), 1e3);
951
+ const result = await store.readLog(afterParam ? { after: afterParam, limit } : { limit });
952
+ return c.json(result);
953
+ });
548
954
  app.put("/content/:contentId/blob/:operationCID", async (c) => {
955
+ if (!contentEnabled) return c.json({ error: "content plane not available" }, 501);
549
956
  const contentId = c.req.param("contentId");
550
957
  const operationCID = c.req.param("operationCID");
551
958
  const auth = await authenticateRequest(c.req.header("authorization"), relayDID, store);
@@ -583,6 +990,7 @@ var createRelay = (options) => {
583
990
  return c.json({ status: "stored", contentId, documentCID, operationCID });
584
991
  });
585
992
  app.get("/content/:contentId/blob", async (c) => {
993
+ if (!contentEnabled) return c.json({ error: "content plane not available" }, 501);
586
994
  return await readBlob({
587
995
  contentId: c.req.param("contentId"),
588
996
  ref: "head",
@@ -593,6 +1001,7 @@ var createRelay = (options) => {
593
1001
  });
594
1002
  });
595
1003
  app.get("/content/:contentId/blob/:ref", async (c) => {
1004
+ if (!contentEnabled) return c.json({ error: "content plane not available" }, 501);
596
1005
  return await readBlob({
597
1006
  contentId: c.req.param("contentId"),
598
1007
  ref: c.req.param("ref"),
@@ -602,7 +1011,24 @@ var createRelay = (options) => {
602
1011
  store
603
1012
  });
604
1013
  });
605
- return app;
1014
+ const syncFromPeers = async () => {
1015
+ if (!peerClient) return;
1016
+ for (const peer of syncPeers) {
1017
+ let cursor = await store.getPeerCursor(peer.url);
1018
+ while (true) {
1019
+ const page = await peerClient.getOperationLog(peer.url, {
1020
+ ...cursor ? { after: cursor } : {},
1021
+ limit: 1e3
1022
+ });
1023
+ if (!page || page.entries.length === 0) break;
1024
+ await ingestWithGossip(page.entries.map((e) => e.jwsToken));
1025
+ cursor = page.cursor ?? page.entries[page.entries.length - 1].cid;
1026
+ await store.setPeerCursor(peer.url, cursor);
1027
+ if (!page.cursor) break;
1028
+ }
1029
+ }
1030
+ };
1031
+ return { app, did: relayDID, syncFromPeers };
606
1032
  };
607
1033
  var jsonResponse = (body, status = 200) => new Response(JSON.stringify(body), {
608
1034
  status,
@@ -685,6 +1111,7 @@ var verifyReadCredential = async (auth, chain, contentId, credHeader, store) =>
685
1111
  };
686
1112
 
687
1113
  // src/store.ts
1114
+ import { verifyContentChain as verifyContentChain2, verifyIdentityChain as verifyIdentityChain2 } from "@metalabel/dfos-protocol/chain";
688
1115
  import { decodeJwsUnsafe as decodeJwsUnsafe4 } from "@metalabel/dfos-protocol/crypto";
689
1116
  var blobKeyString = (key) => `${key.creatorDID}::${key.documentCID}`;
690
1117
  var MemoryRelayStore = class {
@@ -694,6 +1121,8 @@ var MemoryRelayStore = class {
694
1121
  beacons = /* @__PURE__ */ new Map();
695
1122
  blobs = /* @__PURE__ */ new Map();
696
1123
  countersignatures = /* @__PURE__ */ new Map();
1124
+ operationLog = [];
1125
+ peerCursors = /* @__PURE__ */ new Map();
697
1126
  async getOperation(cid) {
698
1127
  return this.operations.get(cid);
699
1128
  }
@@ -744,10 +1173,85 @@ var MemoryRelayStore = class {
744
1173
  existing.push(jwsToken);
745
1174
  this.countersignatures.set(operationCID, existing);
746
1175
  }
1176
+ async appendToLog(entry) {
1177
+ this.operationLog.push(entry);
1178
+ }
1179
+ async readLog(params) {
1180
+ let startIdx = 0;
1181
+ if (params.after) {
1182
+ const idx = this.operationLog.findIndex((e) => e.cid === params.after);
1183
+ if (idx >= 0) startIdx = idx + 1;
1184
+ else startIdx = this.operationLog.length;
1185
+ }
1186
+ const entries = this.operationLog.slice(startIdx, startIdx + params.limit);
1187
+ const cursor = entries.length === params.limit ? entries[entries.length - 1].cid : null;
1188
+ return { entries, cursor };
1189
+ }
1190
+ async getIdentityStateAtCID(did, cid) {
1191
+ const chain = this.identityChains.get(did);
1192
+ if (!chain) return null;
1193
+ const opsByCID = /* @__PURE__ */ new Map();
1194
+ for (const jws of chain.log) {
1195
+ const decoded = decodeJwsUnsafe4(jws);
1196
+ if (!decoded) continue;
1197
+ const payload = decoded.payload;
1198
+ const opCID = typeof decoded.header.cid === "string" ? decoded.header.cid : "";
1199
+ const prevCID = typeof payload["previousOperationCID"] === "string" ? payload["previousOperationCID"] : null;
1200
+ opsByCID.set(opCID, { jws, previousCID: prevCID });
1201
+ }
1202
+ if (!opsByCID.has(cid)) return null;
1203
+ const path = [];
1204
+ let currentCID = cid;
1205
+ while (currentCID) {
1206
+ const op = opsByCID.get(currentCID);
1207
+ if (!op) return null;
1208
+ path.unshift(op.jws);
1209
+ currentCID = op.previousCID;
1210
+ }
1211
+ const identity = await verifyIdentityChain2({ didPrefix: "did:dfos", log: path });
1212
+ const targetDecoded = decodeJwsUnsafe4(opsByCID.get(cid).jws);
1213
+ const lastCreatedAt = typeof targetDecoded?.payload?.["createdAt"] === "string" ? (targetDecoded?.payload)["createdAt"] : "";
1214
+ return { state: identity, lastCreatedAt };
1215
+ }
1216
+ async getContentStateAtCID(contentId, cid) {
1217
+ const chain = this.contentChains.get(contentId);
1218
+ if (!chain) return null;
1219
+ const opsByCID = /* @__PURE__ */ new Map();
1220
+ for (const jws of chain.log) {
1221
+ const decoded = decodeJwsUnsafe4(jws);
1222
+ if (!decoded) continue;
1223
+ const payload = decoded.payload;
1224
+ const opCID = typeof decoded.header.cid === "string" ? decoded.header.cid : "";
1225
+ const prevCID = typeof payload["previousOperationCID"] === "string" ? payload["previousOperationCID"] : null;
1226
+ opsByCID.set(opCID, { jws, previousCID: prevCID });
1227
+ }
1228
+ if (!opsByCID.has(cid)) return null;
1229
+ const path = [];
1230
+ let currentCID = cid;
1231
+ while (currentCID) {
1232
+ const op = opsByCID.get(currentCID);
1233
+ if (!op) return null;
1234
+ path.unshift(op.jws);
1235
+ currentCID = op.previousCID;
1236
+ }
1237
+ const resolveKey = createKeyResolver(this);
1238
+ const content = await verifyContentChain2({ log: path, resolveKey, enforceAuthorization: true });
1239
+ const targetDecoded = decodeJwsUnsafe4(opsByCID.get(cid).jws);
1240
+ const lastCreatedAt = typeof targetDecoded?.payload?.["createdAt"] === "string" ? (targetDecoded?.payload)["createdAt"] : "";
1241
+ return { state: content, lastCreatedAt };
1242
+ }
1243
+ async getPeerCursor(peerUrl) {
1244
+ return this.peerCursors.get(peerUrl);
1245
+ }
1246
+ async setPeerCursor(peerUrl, cursor) {
1247
+ this.peerCursors.set(peerUrl, cursor);
1248
+ }
747
1249
  };
748
1250
  export {
749
1251
  MemoryRelayStore,
1252
+ bootstrapRelayIdentity,
750
1253
  createCurrentKeyResolver,
1254
+ createHttpPeerClient,
751
1255
  createKeyResolver,
752
1256
  createRelay,
753
1257
  ingestOperations