@metalabel/dfos-web-relay 0.9.0 → 0.10.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.d.ts CHANGED
@@ -21,6 +21,12 @@ interface RelayOptions {
21
21
  peers?: PeerConfig[];
22
22
  /** Injected peer client — if omitted, a default HTTP implementation is used */
23
23
  peerClient?: PeerClient;
24
+ /**
25
+ * Max lifetime (exp-iat, seconds) honored on a self-signed auth token.
26
+ * Default 86400 (24h); a value <= 0 disables the ceiling. Applies only to auth
27
+ * tokens, never to DFOS credentials.
28
+ */
29
+ maxAuthTokenTTLSeconds?: number;
24
30
  }
25
31
  interface PeerConfig {
26
32
  url: string;
@@ -236,6 +242,14 @@ interface IngestionResult {
236
242
  kind?: OperationKind;
237
243
  /** Chain identifier if applicable */
238
244
  chainId?: string;
245
+ /**
246
+ * Structured dependency-failure signal. When true, the rejection is due to a
247
+ * missing dependency that may arrive later via sync or gossip, so the
248
+ * sequencer must keep the op pending (retryable) rather than durably reject
249
+ * it. This is the discriminator the sequencer branches on — NOT substring
250
+ * matching of the human-readable `error` string.
251
+ */
252
+ dependencyMissing?: boolean;
239
253
  }
240
254
 
241
255
  /**
@@ -247,6 +261,20 @@ interface IngestionResult {
247
261
  * they are available via the relay's proof plane routes.
248
262
  */
249
263
  declare const bootstrapRelayIdentity: (store: RelayStore) => Promise<RelayIdentity>;
264
+ /**
265
+ * Bootstrap a relay identity from an EXISTING key + key ID, with optional pinned
266
+ * timestamps and profile name. Used for deterministic bootstrap — e.g. the
267
+ * dual-relay parity harness pins one key + one createdAt across both twins so
268
+ * the relay's own genesis + profile log entries are byte-identical, and durable
269
+ * relays that reload their key from storage. Mirrors the Go twin's
270
+ * BootstrapRelayIdentityFromKey.
271
+ */
272
+ declare const bootstrapRelayIdentityFromKey: (store: RelayStore, params: {
273
+ privateKey: Uint8Array;
274
+ keyId: string;
275
+ name?: string;
276
+ createdAt?: string;
277
+ }) => Promise<RelayIdentity>;
250
278
 
251
279
  /**
252
280
  * Create an HTTP-based PeerClient.
@@ -265,6 +293,13 @@ interface CreatedRelay {
265
293
  /** Sync operations from all configured sync peers (call on a schedule) */
266
294
  syncFromPeers: () => Promise<void>;
267
295
  }
296
+ /**
297
+ * Split items into batches of at most `size`, preserving order with no loss.
298
+ * gossip() uses this to stay within the receiver's per-batch cap; exported so
299
+ * the split behavior is directly testable (mirrors Go's maxGossipBatch chunking,
300
+ * whose TestGossipChunksLargeBatches drives the split directly).
301
+ */
302
+ declare const chunkOps: <T>(items: T[], size: number) => T[][];
268
303
  /**
269
304
  * Create a DFOS web relay Hono application
270
305
  *
@@ -346,6 +381,13 @@ declare class MemoryRelayStore implements RelayStore {
346
381
  resetSequencer(): Promise<void>;
347
382
  }
348
383
 
384
+ /**
385
+ * Derive the operation CID from a JWS token by re-encoding the decoded payload.
386
+ * Returns the empty string for an undecodable token. Used at verify-failure
387
+ * sites so a rejection still carries a CID and can be durably rejected by the
388
+ * sequencer (instead of being skipped forever by `if (!res.cid) continue`).
389
+ */
390
+ declare const computeOpCID: (jwsToken: string) => Promise<string>;
349
391
  /**
350
392
  * Create a key resolver that looks up Ed25519 public keys from identity chains
351
393
  * in the store. Used for chain re-verification during ingestion.
@@ -411,13 +453,13 @@ declare const ingestOperations: (tokens: string[], store: RelayStore, options?:
411
453
  }) => Promise<IngestionResult[]>;
412
454
 
413
455
  /**
414
- * Returns true if the rejection is due to a missing dependency that may
415
- * arrive later via sync or gossip. Only these specific patterns are
416
- * retryable everything else is treated as permanent.
456
+ * Returns true if a rejection is retryable (a missing dependency that may
457
+ * arrive later via sync or gossip). The sequencer branches on the STRUCTURED
458
+ * `dependencyMissing` flag set by the ingest producer — not on substring
459
+ * matching of the human-readable `error` string. Mirrors the Go twin's
460
+ * structured discriminator.
417
461
  */
418
- declare const isDependencyFailure: (error: string) => boolean;
419
- /** Derive the operation CID from a JWS token */
420
- declare const computeOpCID: (jwsToken: string) => Promise<string | undefined>;
462
+ declare const isDependencyFailure: (res: Pick<IngestionResult, "dependencyMissing">) => boolean;
421
463
  /**
422
464
  * Process unsequenced raw ops in a fixed-point loop until no more progress
423
465
  * is made. Returns the JWS tokens of newly sequenced ops and aggregate stats.
@@ -427,4 +469,4 @@ declare const sequenceOps: (store: RelayStore) => Promise<{
427
469
  result: SequenceResult;
428
470
  }>;
429
471
 
430
- export { type BlobKey, type CreatedRelay, type IngestionResult, type LogEntry, MemoryRelayStore, type OperationKind, type PeerClient, type PeerConfig, type PeerLogEntry, type RelayIdentity, type RelayOptions, type RelayStore, type SequenceResult, type StoredBeacon, type StoredContentChain, type StoredIdentityChain, type StoredOperation, bootstrapRelayIdentity, computeOpCID, createCurrentKeyResolver, createHistoricalIdentityResolver, createHttpPeerClient, createKeyResolver, createRelay, ingestOperations, isDependencyFailure, sequenceOps };
472
+ export { type BlobKey, type CreatedRelay, type IngestionResult, type LogEntry, MemoryRelayStore, type OperationKind, type PeerClient, type PeerConfig, type PeerLogEntry, type RelayIdentity, type RelayOptions, type RelayStore, type SequenceResult, type StoredBeacon, type StoredContentChain, type StoredIdentityChain, type StoredOperation, bootstrapRelayIdentity, bootstrapRelayIdentityFromKey, chunkOps, computeOpCID, createCurrentKeyResolver, createHistoricalIdentityResolver, createHttpPeerClient, createKeyResolver, createRelay, ingestOperations, isDependencyFailure, sequenceOps };
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  import {
8
8
  createNewEd25519Keypair,
9
9
  generateId,
10
+ importEd25519Keypair,
10
11
  signPayloadEd25519
11
12
  } from "@metalabel/dfos-protocol/crypto";
12
13
 
@@ -26,6 +27,16 @@ import {
26
27
  verifyDFOSCredential
27
28
  } from "@metalabel/dfos-protocol/credentials";
28
29
  import { dagCborCanonicalEncode, decodeJwsUnsafe } from "@metalabel/dfos-protocol/crypto";
30
+ var FORK_POINT_STATE_ERROR_PREFIX = "failed to compute state at fork point: ";
31
+ var DEPENDENCY_FAILURE_SUBSTRINGS = ["unknown identity:", "unknown key "];
32
+ var isKeyResolutionFailure = (message) => DEPENDENCY_FAILURE_SUBSTRINGS.some((s) => message.includes(s));
33
+ var isCredentialDependencyFailure = (message) => message.includes("issuer identity not found:") || message.includes(" not found on identity ");
34
+ var computeOpCID = async (jwsToken) => {
35
+ const decoded = decodeJwsUnsafe(jwsToken);
36
+ if (!decoded) return "";
37
+ const encoded = await dagCborCanonicalEncode(decoded.payload);
38
+ return encoded.cid.toString();
39
+ };
29
40
  var MAX_FUTURE_TIMESTAMP_MS = 24 * 60 * 60 * 1e3;
30
41
  var isFutureTimestamp = (createdAt) => {
31
42
  const ts = new Date(createdAt).getTime();
@@ -231,7 +242,13 @@ var ingestIdentityOp = async (jwsToken, store, logEnabled) => {
231
242
  const opType = payload["type"];
232
243
  const isGenesis = opType === "create";
233
244
  if (isGenesis) {
234
- const identity = await verifyIdentityChain({ didPrefix: "did:dfos", log: [jwsToken] });
245
+ let identity;
246
+ try {
247
+ identity = await verifyIdentityChain({ didPrefix: "did:dfos", log: [jwsToken] });
248
+ } catch (err) {
249
+ const message = err instanceof Error ? err.message : "verification failed";
250
+ return { cid, status: "rejected", error: message };
251
+ }
235
252
  const createdAt = payload["createdAt"];
236
253
  const chain2 = {
237
254
  did: identity.did,
@@ -252,15 +269,27 @@ var ingestIdentityOp = async (jwsToken, store, logEnabled) => {
252
269
  if (hashIdx < 0) return { cid, status: "rejected", error: "non-genesis kid must be a DID URL" };
253
270
  const did = kid.substring(0, hashIdx);
254
271
  const chain = await store.getIdentityChain(did);
255
- if (!chain) return { cid, status: "rejected", error: `unknown identity: ${did}` };
272
+ if (!chain)
273
+ return {
274
+ cid,
275
+ status: "rejected",
276
+ error: `unknown identity: ${did}`,
277
+ dependencyMissing: true
278
+ };
256
279
  const previousCID = typeof payload["previousOperationCID"] === "string" ? payload["previousOperationCID"] : null;
257
280
  if (previousCID === chain.headCID) {
258
- const extResult2 = await verifyIdentityExtensionFromTrustedState({
259
- currentState: chain.state,
260
- headCID: chain.headCID,
261
- lastCreatedAt: chain.lastCreatedAt,
262
- newOp: jwsToken
263
- });
281
+ let extResult2;
282
+ try {
283
+ extResult2 = await verifyIdentityExtensionFromTrustedState({
284
+ currentState: chain.state,
285
+ headCID: chain.headCID,
286
+ lastCreatedAt: chain.lastCreatedAt,
287
+ newOp: jwsToken
288
+ });
289
+ } catch (err) {
290
+ const message = err instanceof Error ? err.message : "verification failed";
291
+ return { cid, status: "rejected", error: message };
292
+ }
264
293
  const updated2 = {
265
294
  did: chain.did,
266
295
  log: [...chain.log, jwsToken],
@@ -276,18 +305,34 @@ var ingestIdentityOp = async (jwsToken, store, logEnabled) => {
276
305
  return { cid, status: "new", kind: "identity-op", chainId: did };
277
306
  }
278
307
  if (!previousCID || !chainLogContainsCID(chain.log, previousCID)) {
279
- return { cid, status: "rejected", error: "unknown previous operation in identity chain" };
308
+ return {
309
+ cid,
310
+ status: "rejected",
311
+ error: "unknown previous operation in identity chain",
312
+ dependencyMissing: true
313
+ };
280
314
  }
281
315
  const forkState = await store.getIdentityStateAtCID(did, previousCID);
282
316
  if (!forkState) {
283
- return { cid, status: "rejected", error: "failed to compute state at fork point" };
317
+ return {
318
+ cid,
319
+ status: "rejected",
320
+ error: `${FORK_POINT_STATE_ERROR_PREFIX}${previousCID}`,
321
+ dependencyMissing: true
322
+ };
323
+ }
324
+ let extResult;
325
+ try {
326
+ extResult = await verifyIdentityExtensionFromTrustedState({
327
+ currentState: forkState.state,
328
+ headCID: previousCID,
329
+ lastCreatedAt: forkState.lastCreatedAt,
330
+ newOp: jwsToken
331
+ });
332
+ } catch (err) {
333
+ const message = err instanceof Error ? err.message : "verification failed";
334
+ return { cid, status: "rejected", error: message };
284
335
  }
285
- const extResult = await verifyIdentityExtensionFromTrustedState({
286
- currentState: forkState.state,
287
- headCID: previousCID,
288
- lastCreatedAt: forkState.lastCreatedAt,
289
- newOp: jwsToken
290
- });
291
336
  const updatedLog = [...chain.log, jwsToken];
292
337
  const head = selectDeterministicHead(updatedLog);
293
338
  let headState = chain.state;
@@ -345,15 +390,28 @@ var ingestContentOp = async (jwsToken, store, logEnabled) => {
345
390
  }
346
391
  const resolveKey = createKeyResolver(store);
347
392
  const resolveIdentity = createHistoricalIdentityResolver(store);
393
+ const isRevoked = (issuerDID, credentialCID) => store.isCredentialRevoked(issuerDID, credentialCID);
348
394
  const opType = payload["type"];
349
395
  const isGenesis = opType === "create";
350
396
  if (isGenesis) {
351
- const content = await verifyContentChain({
352
- log: [jwsToken],
353
- resolveKey,
354
- enforceAuthorization: true,
355
- resolveIdentity
356
- });
397
+ let content;
398
+ try {
399
+ content = await verifyContentChain({
400
+ log: [jwsToken],
401
+ resolveKey,
402
+ enforceAuthorization: true,
403
+ resolveIdentity,
404
+ isRevoked
405
+ });
406
+ } catch (err) {
407
+ const message = err instanceof Error ? err.message : "verification failed";
408
+ return {
409
+ cid,
410
+ status: "rejected",
411
+ error: message,
412
+ dependencyMissing: isKeyResolutionFailure(message)
413
+ };
414
+ }
357
415
  const createdAt = payload["createdAt"];
358
416
  const chain2 = {
359
417
  contentId: content.contentId,
@@ -375,26 +433,48 @@ var ingestContentOp = async (jwsToken, store, logEnabled) => {
375
433
  }
376
434
  const prevOp = await store.getOperation(previousCID);
377
435
  if (!prevOp)
378
- return { cid, status: "rejected", error: `unknown previous operation: ${previousCID}` };
436
+ return {
437
+ cid,
438
+ status: "rejected",
439
+ error: `unknown previous operation: ${previousCID}`,
440
+ dependencyMissing: true
441
+ };
379
442
  if (prevOp.chainType !== "content") {
380
443
  return { cid, status: "rejected", error: "previousOperationCID is not a content operation" };
381
444
  }
382
445
  const chain = await store.getContentChain(prevOp.chainId);
383
446
  if (!chain)
384
- return { cid, status: "rejected", error: `content chain not found: ${prevOp.chainId}` };
447
+ return {
448
+ cid,
449
+ status: "rejected",
450
+ error: `content chain not found: ${prevOp.chainId}`,
451
+ dependencyMissing: true
452
+ };
385
453
  const creatorIdentity = await store.getIdentityChain(chain.state.creatorDID);
386
454
  if (creatorIdentity?.state.isDeleted) {
387
455
  return { cid, status: "rejected", error: "content creator identity is deleted" };
388
456
  }
389
457
  if (chain.state.headCID === previousCID) {
390
- const extResult2 = await verifyContentExtensionFromTrustedState({
391
- currentState: chain.state,
392
- lastCreatedAt: chain.lastCreatedAt,
393
- newOp: jwsToken,
394
- resolveKey,
395
- enforceAuthorization: true,
396
- resolveIdentity
397
- });
458
+ let extResult2;
459
+ try {
460
+ extResult2 = await verifyContentExtensionFromTrustedState({
461
+ currentState: chain.state,
462
+ lastCreatedAt: chain.lastCreatedAt,
463
+ newOp: jwsToken,
464
+ resolveKey,
465
+ enforceAuthorization: true,
466
+ resolveIdentity,
467
+ isRevoked
468
+ });
469
+ } catch (err) {
470
+ const message = err instanceof Error ? err.message : "verification failed";
471
+ return {
472
+ cid,
473
+ status: "rejected",
474
+ error: message,
475
+ dependencyMissing: isKeyResolutionFailure(message)
476
+ };
477
+ }
398
478
  const updated2 = {
399
479
  contentId: chain.contentId,
400
480
  genesisCID: chain.genesisCID,
@@ -410,20 +490,42 @@ var ingestContentOp = async (jwsToken, store, logEnabled) => {
410
490
  return { cid, status: "new", kind: "content-op", chainId: chain.contentId };
411
491
  }
412
492
  if (!chainLogContainsCID(chain.log, previousCID)) {
413
- return { cid, status: "rejected", error: "unknown previous operation in content chain" };
493
+ return {
494
+ cid,
495
+ status: "rejected",
496
+ error: "unknown previous operation in content chain",
497
+ dependencyMissing: true
498
+ };
414
499
  }
415
500
  const forkState = await store.getContentStateAtCID(chain.contentId, previousCID);
416
501
  if (!forkState) {
417
- return { cid, status: "rejected", error: "failed to compute state at fork point" };
418
- }
419
- const extResult = await verifyContentExtensionFromTrustedState({
420
- currentState: forkState.state,
421
- lastCreatedAt: forkState.lastCreatedAt,
422
- newOp: jwsToken,
423
- resolveKey,
424
- enforceAuthorization: true,
425
- resolveIdentity
426
- });
502
+ return {
503
+ cid,
504
+ status: "rejected",
505
+ error: `${FORK_POINT_STATE_ERROR_PREFIX}${previousCID}`,
506
+ dependencyMissing: true
507
+ };
508
+ }
509
+ let extResult;
510
+ try {
511
+ extResult = await verifyContentExtensionFromTrustedState({
512
+ currentState: forkState.state,
513
+ lastCreatedAt: forkState.lastCreatedAt,
514
+ newOp: jwsToken,
515
+ resolveKey,
516
+ enforceAuthorization: true,
517
+ resolveIdentity,
518
+ isRevoked
519
+ });
520
+ } catch (err) {
521
+ const message = err instanceof Error ? err.message : "verification failed";
522
+ return {
523
+ cid,
524
+ status: "rejected",
525
+ error: message,
526
+ dependencyMissing: isKeyResolutionFailure(message)
527
+ };
528
+ }
427
529
  const updatedLog = [...chain.log, jwsToken];
428
530
  const head = selectDeterministicHead(updatedLog);
429
531
  let headState = chain.state;
@@ -447,13 +549,18 @@ var ingestContentOp = async (jwsToken, store, logEnabled) => {
447
549
  return { cid, status: "new", kind: "content-op", chainId: chain.contentId };
448
550
  };
449
551
  var ingestBeacon = async (jwsToken, store, logEnabled) => {
450
- const resolveKey = createKeyResolver(store);
552
+ const resolveKey = createCurrentKeyResolver(store);
451
553
  let verified;
452
554
  try {
453
555
  verified = await verifyBeacon({ jwsToken, resolveKey });
454
556
  } catch (err) {
455
557
  const message = err instanceof Error ? err.message : "verification failed";
456
- return { cid: "", status: "rejected", error: message };
558
+ return {
559
+ cid: await computeOpCID(jwsToken),
560
+ status: "rejected",
561
+ error: message,
562
+ dependencyMissing: isKeyResolutionFailure(message)
563
+ };
457
564
  }
458
565
  const did = verified.did;
459
566
  const cid = verified.beaconCID;
@@ -465,7 +572,7 @@ var ingestBeacon = async (jwsToken, store, logEnabled) => {
465
572
  if (existing) {
466
573
  const existingTime = new Date(existing.state.createdAt).getTime();
467
574
  const newTime = new Date(verified.createdAt).getTime();
468
- if (newTime <= existingTime) {
575
+ if (newTime < existingTime || newTime === existingTime && cid <= existing.beaconCID) {
469
576
  return { cid, status: "duplicate", kind: "beacon", chainId: did };
470
577
  }
471
578
  }
@@ -483,7 +590,12 @@ var ingestCountersign = async (jwsToken, store, logEnabled) => {
483
590
  verified = await verifyCountersignature({ jwsToken, resolveKey });
484
591
  } catch (err) {
485
592
  const message = err instanceof Error ? err.message : "verification failed";
486
- return { cid: "", status: "rejected", error: message };
593
+ return {
594
+ cid: await computeOpCID(jwsToken),
595
+ status: "rejected",
596
+ error: message,
597
+ dependencyMissing: isKeyResolutionFailure(message)
598
+ };
487
599
  }
488
600
  const cid = verified.countersignCID;
489
601
  const { witnessDID, targetCID } = verified;
@@ -500,7 +612,12 @@ var ingestCountersign = async (jwsToken, store, logEnabled) => {
500
612
  }
501
613
  const targetOp = await store.getOperation(targetCID);
502
614
  if (!targetOp) {
503
- return { cid, status: "rejected", error: `unknown target operation: ${targetCID}` };
615
+ return {
616
+ cid,
617
+ status: "rejected",
618
+ error: `unknown target operation: ${targetCID}`,
619
+ dependencyMissing: true
620
+ };
504
621
  }
505
622
  let targetAuthorDID = null;
506
623
  if (targetOp.chainType === "identity") {
@@ -542,7 +659,12 @@ var ingestArtifact = async (jwsToken, store, logEnabled) => {
542
659
  verified = await verifyArtifact({ jwsToken, resolveKey });
543
660
  } catch (err) {
544
661
  const message = err instanceof Error ? err.message : "verification failed";
545
- return { cid: "", status: "rejected", error: message };
662
+ return {
663
+ cid: await computeOpCID(jwsToken),
664
+ status: "rejected",
665
+ error: message,
666
+ dependencyMissing: isKeyResolutionFailure(message)
667
+ };
546
668
  }
547
669
  const cid = verified.artifactCID;
548
670
  const did = verified.payload.did;
@@ -574,7 +696,12 @@ var ingestRevocation = async (jwsToken, store, logEnabled) => {
574
696
  verified = await verifyRevocation({ jwsToken, resolveKey });
575
697
  } catch (err) {
576
698
  const message = err instanceof Error ? err.message : "verification failed";
577
- return { cid: "", status: "rejected", error: message };
699
+ return {
700
+ cid: await computeOpCID(jwsToken),
701
+ status: "rejected",
702
+ error: message,
703
+ dependencyMissing: isKeyResolutionFailure(message)
704
+ };
578
705
  }
579
706
  const cid = verified.revocationCID;
580
707
  const did = verified.did;
@@ -613,11 +740,16 @@ var ingestPublicCredential = async (jwsToken, store, logEnabled) => {
613
740
  verified = await verifyDFOSCredential(jwsToken, { resolveIdentity });
614
741
  } catch (err) {
615
742
  const message = err instanceof Error ? err.message : "verification failed";
616
- return { cid: "", status: "rejected", error: message };
743
+ return {
744
+ cid: await computeOpCID(jwsToken),
745
+ status: "rejected",
746
+ error: message,
747
+ dependencyMissing: isCredentialDependencyFailure(message)
748
+ };
617
749
  }
618
750
  const cid = verified.credentialCID;
619
751
  if (verified.aud !== "*") {
620
- return { cid: "", status: "rejected", error: "not a public credential" };
752
+ return { cid, status: "rejected", error: "not a public credential" };
621
753
  }
622
754
  const existing = await store.getOperation(cid);
623
755
  if (existing) {
@@ -726,9 +858,10 @@ var selectDeterministicHead = (log) => {
726
858
  if (previousCID) hasChild.add(previousCID);
727
859
  }
728
860
  const tips = ops.filter((op) => !hasChild.has(op.cid));
861
+ const cmp = (x, y) => x < y ? -1 : x > y ? 1 : 0;
729
862
  tips.sort((a, b) => {
730
- if (a.createdAt !== b.createdAt) return b.createdAt.localeCompare(a.createdAt);
731
- return b.cid.localeCompare(a.cid);
863
+ if (a.createdAt !== b.createdAt) return cmp(b.createdAt, a.createdAt);
864
+ return cmp(b.cid, a.cid);
732
865
  });
733
866
  return tips[0] ?? { cid: "", createdAt: "" };
734
867
  };
@@ -763,14 +896,18 @@ var ingestOperations = async (tokens, store, options) => {
763
896
  result = await ingestPublicCredential(op.jwsToken, store, logEnabled);
764
897
  break;
765
898
  default:
766
- result = { cid: "", status: "rejected", error: "unrecognized operation type" };
899
+ result = {
900
+ cid: await computeOpCID(op.jwsToken),
901
+ status: "rejected",
902
+ error: "unrecognized operation type"
903
+ };
767
904
  }
768
905
  indexedResults.push({ index: op.originalIndex, result });
769
906
  } catch (err) {
770
907
  const message = err instanceof Error ? err.message : "unexpected error";
771
908
  indexedResults.push({
772
909
  index: op.originalIndex,
773
- result: { cid: "", status: "rejected", error: message }
910
+ result: { cid: await computeOpCID(op.jwsToken), status: "rejected", error: message }
774
911
  });
775
912
  }
776
913
  }
@@ -781,8 +918,28 @@ var ingestOperations = async (tokens, store, options) => {
781
918
  var bootstrapRelayIdentity = async (store) => {
782
919
  const keypair = createNewEd25519Keypair();
783
920
  const keyId = generateId("key");
784
- const multibase = encodeEd25519Multikey(keypair.publicKey);
785
- const signer = async (msg) => signPayloadEd25519(msg, keypair.privateKey);
921
+ return bootstrapWithKeyMaterial(store, {
922
+ privateKey: keypair.privateKey,
923
+ publicKey: keypair.publicKey,
924
+ keyId
925
+ });
926
+ };
927
+ var bootstrapRelayIdentityFromKey = async (store, params) => {
928
+ const { publicKey } = importEd25519Keypair(params.privateKey);
929
+ return bootstrapWithKeyMaterial(store, {
930
+ privateKey: params.privateKey,
931
+ publicKey,
932
+ keyId: params.keyId,
933
+ ...params.name !== void 0 ? { name: params.name } : {},
934
+ ...params.createdAt !== void 0 ? { createdAt: params.createdAt } : {}
935
+ });
936
+ };
937
+ var bootstrapWithKeyMaterial = async (store, params) => {
938
+ const { privateKey, publicKey, keyId } = params;
939
+ const name = params.name ?? "DFOS Relay";
940
+ const createdAt = params.createdAt ?? (/* @__PURE__ */ new Date()).toISOString();
941
+ const multibase = encodeEd25519Multikey(publicKey);
942
+ const signer = async (msg) => signPayloadEd25519(msg, privateKey);
786
943
  const key = { id: keyId, type: "Multikey", publicKeyMultibase: multibase };
787
944
  const identityOp = {
788
945
  version: 1,
@@ -790,7 +947,7 @@ var bootstrapRelayIdentity = async (store) => {
790
947
  authKeys: [key],
791
948
  assertKeys: [key],
792
949
  controllerKeys: [key],
793
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
950
+ createdAt
794
951
  };
795
952
  const { jwsToken: identityJws } = await signIdentityOperation({
796
953
  operation: identityOp,
@@ -808,9 +965,9 @@ var bootstrapRelayIdentity = async (store) => {
808
965
  did,
809
966
  content: {
810
967
  $schema: "https://schemas.dfos.com/profile/v1",
811
- name: "DFOS Relay"
968
+ name
812
969
  },
813
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
970
+ createdAt
814
971
  };
815
972
  const kid = `${did}#${keyId}`;
816
973
  const { jwsToken: profileArtifactJws } = await signArtifact({
@@ -865,11 +1022,16 @@ var createHttpPeerClient = () => {
865
1022
  },
866
1023
  async submitOperations(peerUrl, operations) {
867
1024
  try {
868
- await fetch(new URL("/operations", peerUrl).toString(), {
1025
+ const res = await fetch(new URL("/operations", peerUrl).toString(), {
869
1026
  method: "POST",
870
1027
  headers: { "Content-Type": "application/json" },
871
1028
  body: JSON.stringify({ operations })
872
1029
  });
1030
+ if (!res.ok) {
1031
+ console.warn(
1032
+ `gossip submitOperations to ${peerUrl} returned ${res.status} (${operations.length} ops dropped)`
1033
+ );
1034
+ }
873
1035
  } catch {
874
1036
  }
875
1037
  }
@@ -878,7 +1040,7 @@ var createHttpPeerClient = () => {
878
1040
 
879
1041
  // src/relay.ts
880
1042
  import { createRequire } from "module";
881
- import { dagCborCanonicalEncode as dagCborCanonicalEncode3, decodeJwsUnsafe as decodeJwsUnsafe4 } from "@metalabel/dfos-protocol/crypto";
1043
+ import { dagCborCanonicalEncode as dagCborCanonicalEncode2, decodeJwsUnsafe as decodeJwsUnsafe3 } from "@metalabel/dfos-protocol/crypto";
882
1044
  import { Hono } from "hono";
883
1045
  import { z } from "zod";
884
1046
 
@@ -890,7 +1052,8 @@ import {
890
1052
  verifyDFOSCredential as verifyDFOSCredential2
891
1053
  } from "@metalabel/dfos-protocol/credentials";
892
1054
  import { decodeJwsUnsafe as decodeJwsUnsafe2 } from "@metalabel/dfos-protocol/crypto";
893
- var authenticateRequest = async (authHeader, relayDID, store) => {
1055
+ var DEFAULT_MAX_AUTH_TOKEN_TTL_SECONDS = 86400;
1056
+ var authenticateRequest = async (authHeader, relayDID, store, maxAuthTokenTTLSeconds = DEFAULT_MAX_AUTH_TOKEN_TTL_SECONDS) => {
894
1057
  if (!authHeader) return null;
895
1058
  if (!authHeader.startsWith("Bearer ")) return null;
896
1059
  const token = authHeader.substring(7);
@@ -907,7 +1070,11 @@ var authenticateRequest = async (authHeader, relayDID, store) => {
907
1070
  return null;
908
1071
  }
909
1072
  try {
910
- return verifyAuthToken({ token, publicKey, audience: relayDID });
1073
+ const verified = verifyAuthToken({ token, publicKey, audience: relayDID });
1074
+ if (maxAuthTokenTTLSeconds > 0 && verified.exp - verified.iat > maxAuthTokenTTLSeconds) {
1075
+ return null;
1076
+ }
1077
+ return verified;
911
1078
  } catch {
912
1079
  return null;
913
1080
  }
@@ -986,22 +1153,7 @@ var verifyContentAccess = async (options) => {
986
1153
  };
987
1154
 
988
1155
  // src/sequencer.ts
989
- import { dagCborCanonicalEncode as dagCborCanonicalEncode2, decodeJwsUnsafe as decodeJwsUnsafe3 } from "@metalabel/dfos-protocol/crypto";
990
- var isDependencyFailure = (error) => {
991
- const patterns = [
992
- "unknown previous operation",
993
- "unknown identity:",
994
- "content chain not found:",
995
- "failed to compute state at fork point:"
996
- ];
997
- return patterns.some((p) => error.includes(p));
998
- };
999
- var computeOpCID = async (jwsToken) => {
1000
- const decoded = decodeJwsUnsafe3(jwsToken);
1001
- if (!decoded) return void 0;
1002
- const encoded = await dagCborCanonicalEncode2(decoded.payload);
1003
- return encoded.cid.toString();
1004
- };
1156
+ var isDependencyFailure = (res) => res.dependencyMissing === true;
1005
1157
  var sequenceOps = async (store) => {
1006
1158
  const newOps = [];
1007
1159
  const result = { sequenced: 0, rejected: 0, pending: 0 };
@@ -1022,7 +1174,7 @@ var sequenceOps = async (store) => {
1022
1174
  } else if (res.status === "duplicate") {
1023
1175
  sequencedCIDs.push(res.cid);
1024
1176
  progress = true;
1025
- } else if (res.status === "rejected" && !isDependencyFailure(res.error ?? "")) {
1177
+ } else if (res.status === "rejected" && !isDependencyFailure(res)) {
1026
1178
  await store.markOpRejected(res.cid, res.error ?? "unknown");
1027
1179
  result.rejected++;
1028
1180
  progress = true;
@@ -1041,13 +1193,37 @@ var sequenceOps = async (store) => {
1041
1193
  // src/relay.ts
1042
1194
  var require2 = createRequire(import.meta.url);
1043
1195
  var { version: RELAY_VERSION } = require2("../package.json");
1196
+ var MAX_OPERATIONS_PER_BATCH = 100;
1044
1197
  var IngestBody = z.object({
1045
- operations: z.array(z.string()).min(1).max(100)
1198
+ operations: z.array(z.string()).min(1).max(MAX_OPERATIONS_PER_BATCH)
1046
1199
  });
1200
+ var MAX_GOSSIP_BATCH = MAX_OPERATIONS_PER_BATCH;
1201
+ var chunkOps = (items, size) => {
1202
+ const chunks = [];
1203
+ for (let start = 0; start < items.length; start += size) {
1204
+ chunks.push(items.slice(start, start + size));
1205
+ }
1206
+ return chunks;
1207
+ };
1208
+ var MAX_BODY_BYTES = 16 << 20;
1209
+ var exceedsBodyCap = (contentLength) => {
1210
+ if (!contentLength) return false;
1211
+ const n = Number(contentLength);
1212
+ return Number.isFinite(n) && n > MAX_BODY_BYTES;
1213
+ };
1214
+ var parseLimit = (raw, defaultLimit, maxLimit) => {
1215
+ if (raw === void 0 || raw === "") return defaultLimit;
1216
+ if (!/^-?\d+$/.test(raw)) return defaultLimit;
1217
+ const n = Number(raw);
1218
+ if (!Number.isSafeInteger(n) || n < 1) return defaultLimit;
1219
+ if (n > maxLimit) return maxLimit;
1220
+ return n;
1221
+ };
1047
1222
  var createRelay = async (options) => {
1048
1223
  const { store } = options;
1049
1224
  const contentEnabled = options.content !== false;
1050
1225
  const logEnabled = options.log !== false;
1226
+ const maxAuthTokenTTLSeconds = options.maxAuthTokenTTLSeconds ?? DEFAULT_MAX_AUTH_TOKEN_TTL_SECONDS;
1051
1227
  const peers = options.peers ?? [];
1052
1228
  const peerClient = options.peerClient;
1053
1229
  const gossipPeers = peers.filter((p) => p.gossip !== false);
@@ -1059,8 +1235,10 @@ var createRelay = async (options) => {
1059
1235
  const gossip = (ops) => {
1060
1236
  if (ops.length === 0 || gossipPeers.length === 0 || !peerClient) return;
1061
1237
  for (const peer of gossipPeers) {
1062
- peerClient.submitOperations(peer.url, ops).catch(() => {
1063
- });
1238
+ for (const chunk of chunkOps(ops, MAX_GOSSIP_BATCH)) {
1239
+ peerClient.submitOperations(peer.url, chunk).catch(() => {
1240
+ });
1241
+ }
1064
1242
  }
1065
1243
  };
1066
1244
  const ingestWithGossip = async (tokens) => {
@@ -1086,6 +1264,20 @@ var createRelay = async (options) => {
1086
1264
  return results;
1087
1265
  };
1088
1266
  const app = new Hono();
1267
+ app.use("*", async (c, next) => {
1268
+ if (c.req.method === "OPTIONS") {
1269
+ return c.body(null, 204, {
1270
+ "Access-Control-Allow-Origin": "*",
1271
+ "Access-Control-Allow-Methods": "GET, POST, PUT, OPTIONS",
1272
+ "Access-Control-Allow-Headers": "Content-Type, Authorization"
1273
+ });
1274
+ }
1275
+ await next();
1276
+ c.res.headers.set("Access-Control-Allow-Origin", "*");
1277
+ c.res.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS");
1278
+ c.res.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
1279
+ return;
1280
+ });
1089
1281
  app.get("/.well-known/dfos-relay", (c) => {
1090
1282
  return c.json({
1091
1283
  did: relayDID,
@@ -1101,6 +1293,9 @@ var createRelay = async (options) => {
1101
1293
  });
1102
1294
  });
1103
1295
  app.post("/operations", async (c) => {
1296
+ if (exceedsBodyCap(c.req.header("content-length"))) {
1297
+ return c.json({ error: "request body too large" }, 413);
1298
+ }
1104
1299
  let body;
1105
1300
  try {
1106
1301
  body = await c.req.json();
@@ -1130,9 +1325,9 @@ var createRelay = async (options) => {
1130
1325
  const chain = await store.getIdentityChain(did);
1131
1326
  if (!chain) return c.json({ error: "not found" }, 404);
1132
1327
  const after = c.req.query("after");
1133
- const limit = Math.min(Number(c.req.query("limit") || 100), 1e3);
1328
+ const limit = parseLimit(c.req.query("limit"), 100, 1e3);
1134
1329
  const entries = chain.log.map((jws) => {
1135
- const decoded = decodeJwsUnsafe4(jws);
1330
+ const decoded = decodeJwsUnsafe3(jws);
1136
1331
  return { cid: decoded?.header.cid || "", jwsToken: jws };
1137
1332
  });
1138
1333
  let startIdx = 0;
@@ -1176,9 +1371,9 @@ var createRelay = async (options) => {
1176
1371
  const chain = await store.getContentChain(contentId);
1177
1372
  if (!chain) return c.json({ error: "not found" }, 404);
1178
1373
  const after = c.req.query("after");
1179
- const limit = Math.min(Number(c.req.query("limit") || 100), 1e3);
1374
+ const limit = parseLimit(c.req.query("limit"), 100, 1e3);
1180
1375
  const entries = chain.log.map((jws) => {
1181
- const decoded = decodeJwsUnsafe4(jws);
1376
+ const decoded = decodeJwsUnsafe3(jws);
1182
1377
  return { cid: decoded?.header.cid || "", jwsToken: jws };
1183
1378
  });
1184
1379
  let startIdx = 0;
@@ -1197,7 +1392,12 @@ var createRelay = async (options) => {
1197
1392
  if (!chain) return c.json({ error: "not found" }, 404);
1198
1393
  const publicAccess = await hasPublicStandingAuth(contentId, "read", store);
1199
1394
  if (!publicAccess) {
1200
- const auth = await authenticateRequest(c.req.header("authorization"), relayDID, store);
1395
+ const auth = await authenticateRequest(
1396
+ c.req.header("authorization"),
1397
+ relayDID,
1398
+ store,
1399
+ maxAuthTokenTTLSeconds
1400
+ );
1201
1401
  if (!auth) return c.json({ error: "authentication required" }, 401);
1202
1402
  const accessError = await verifyReadAccess(
1203
1403
  auth,
@@ -1209,7 +1409,7 @@ var createRelay = async (options) => {
1209
1409
  if (accessError) return accessError;
1210
1410
  }
1211
1411
  const after = c.req.query("after");
1212
- const limit = Math.min(Number(c.req.query("limit") || 100), 1e3);
1412
+ const limit = parseLimit(c.req.query("limit"), 100, 1e3);
1213
1413
  const result = await store.getDocuments(contentId, {
1214
1414
  ...after ? { after } : {},
1215
1415
  limit
@@ -1281,7 +1481,7 @@ var createRelay = async (options) => {
1281
1481
  app.get("/log", async (c) => {
1282
1482
  if (!logEnabled) return c.json({ error: "global log not available" }, 501);
1283
1483
  const afterParam = c.req.query("after");
1284
- const limit = Math.min(Number(c.req.query("limit") || 100), 1e3);
1484
+ const limit = parseLimit(c.req.query("limit"), 100, 1e3);
1285
1485
  const result = await store.readLog(afterParam ? { after: afterParam, limit } : { limit });
1286
1486
  return c.json(result);
1287
1487
  });
@@ -1289,14 +1489,22 @@ var createRelay = async (options) => {
1289
1489
  if (!contentEnabled) return c.json({ error: "content plane not available" }, 501);
1290
1490
  const contentId = c.req.param("contentId");
1291
1491
  const operationCID = c.req.param("operationCID");
1292
- const auth = await authenticateRequest(c.req.header("authorization"), relayDID, store);
1492
+ if (exceedsBodyCap(c.req.header("content-length"))) {
1493
+ return c.json({ error: "request body too large" }, 413);
1494
+ }
1495
+ const auth = await authenticateRequest(
1496
+ c.req.header("authorization"),
1497
+ relayDID,
1498
+ store,
1499
+ maxAuthTokenTTLSeconds
1500
+ );
1293
1501
  if (!auth) return c.json({ error: "authentication required" }, 401);
1294
1502
  const chain = await store.getContentChain(contentId);
1295
1503
  if (!chain) return c.json({ error: "content chain not found" }, 404);
1296
1504
  let documentCID = null;
1297
1505
  let operationSignerDID = null;
1298
1506
  for (const token of chain.log) {
1299
- const decoded = decodeJwsUnsafe4(token);
1507
+ const decoded = decodeJwsUnsafe3(token);
1300
1508
  if (!decoded) continue;
1301
1509
  if (decoded.header.cid !== operationCID) continue;
1302
1510
  const payload = decoded.payload;
@@ -1311,9 +1519,12 @@ var createRelay = async (options) => {
1311
1519
  return c.json({ error: "not authorized \u2014 must be chain creator or operation signer" }, 403);
1312
1520
  }
1313
1521
  const bytes = new Uint8Array(await c.req.arrayBuffer());
1522
+ if (bytes.byteLength > MAX_BODY_BYTES) {
1523
+ return c.json({ error: "request body too large" }, 413);
1524
+ }
1314
1525
  try {
1315
1526
  const parsed = JSON.parse(new TextDecoder().decode(bytes));
1316
- const encoded = await dagCborCanonicalEncode3(parsed);
1527
+ const encoded = await dagCborCanonicalEncode2(parsed);
1317
1528
  if (encoded.cid.toString() !== documentCID) {
1318
1529
  return c.json({ error: "blob bytes do not match documentCID" }, 400);
1319
1530
  }
@@ -1331,7 +1542,8 @@ var createRelay = async (options) => {
1331
1542
  authHeader: c.req.header("authorization"),
1332
1543
  credHeader: c.req.header("x-credential"),
1333
1544
  relayDID,
1334
- store
1545
+ store,
1546
+ maxAuthTokenTTLSeconds
1335
1547
  });
1336
1548
  });
1337
1549
  app.get("/content/:contentId/blob/:ref", async (c) => {
@@ -1342,20 +1554,24 @@ var createRelay = async (options) => {
1342
1554
  authHeader: c.req.header("authorization"),
1343
1555
  credHeader: c.req.header("x-credential"),
1344
1556
  relayDID,
1345
- store
1557
+ store,
1558
+ maxAuthTokenTTLSeconds
1346
1559
  });
1347
1560
  });
1561
+ const maxOpsPerSyncCycle = 5e3;
1348
1562
  const syncFromPeers = async () => {
1349
1563
  if (!peerClient) return;
1350
1564
  for (const peer of syncPeers) {
1351
1565
  let cursor = await store.getPeerCursor(peer.url);
1352
- while (true) {
1566
+ let fetched = 0;
1567
+ while (fetched < maxOpsPerSyncCycle) {
1353
1568
  const page = await peerClient.getOperationLog(peer.url, {
1354
1569
  ...cursor ? { after: cursor } : {},
1355
1570
  limit: 1e3
1356
1571
  });
1357
1572
  if (!page || page.entries.length === 0) break;
1358
1573
  await ingestWithGossip(page.entries.map((e) => e.jwsToken));
1574
+ fetched += page.entries.length;
1359
1575
  cursor = page.cursor ?? page.entries[page.entries.length - 1].cid;
1360
1576
  await store.setPeerCursor(peer.url, cursor);
1361
1577
  if (!page.cursor) break;
@@ -1369,12 +1585,12 @@ var jsonResponse = (body, status = 200) => new Response(JSON.stringify(body), {
1369
1585
  headers: { "content-type": "application/json" }
1370
1586
  });
1371
1587
  var readBlob = async (params) => {
1372
- const { contentId, ref, authHeader, credHeader, relayDID, store } = params;
1588
+ const { contentId, ref, authHeader, credHeader, relayDID, store, maxAuthTokenTTLSeconds } = params;
1373
1589
  const chain = await store.getContentChain(contentId);
1374
1590
  if (!chain) return jsonResponse({ error: "content chain not found" }, 404);
1375
1591
  const publicAccess = await hasPublicStandingAuth(contentId, "read", store);
1376
1592
  if (!publicAccess) {
1377
- const auth = await authenticateRequest(authHeader, relayDID, store);
1593
+ const auth = await authenticateRequest(authHeader, relayDID, store, maxAuthTokenTTLSeconds);
1378
1594
  if (!auth) return jsonResponse({ error: "authentication required" }, 401);
1379
1595
  const credError = await verifyReadAccess(auth, chain, contentId, credHeader, store);
1380
1596
  if (credError) return credError;
@@ -1385,7 +1601,7 @@ var readBlob = async (params) => {
1385
1601
  documentCID = chain.state.currentDocumentCID;
1386
1602
  } else {
1387
1603
  for (const token of chain.log) {
1388
- const decoded = decodeJwsUnsafe4(token);
1604
+ const decoded = decodeJwsUnsafe3(token);
1389
1605
  if (!decoded) continue;
1390
1606
  if (decoded.header.cid === ref) {
1391
1607
  operationFound = true;
@@ -1421,7 +1637,7 @@ var verifyReadAccess = async (auth, chain, contentId, credHeader, store) => {
1421
1637
 
1422
1638
  // src/store.ts
1423
1639
  import { verifyContentChain as verifyContentChain2, verifyIdentityChain as verifyIdentityChain2 } from "@metalabel/dfos-protocol/chain";
1424
- import { decodeJwsUnsafe as decodeJwsUnsafe5 } from "@metalabel/dfos-protocol/crypto";
1640
+ import { decodeJwsUnsafe as decodeJwsUnsafe4 } from "@metalabel/dfos-protocol/crypto";
1425
1641
  var blobKeyString = (key) => `${key.creatorDID}::${key.documentCID}`;
1426
1642
  var MemoryRelayStore = class {
1427
1643
  operations = /* @__PURE__ */ new Map();
@@ -1471,12 +1687,12 @@ var MemoryRelayStore = class {
1471
1687
  }
1472
1688
  async addCountersignature(operationCID, jwsToken) {
1473
1689
  const existing = this.countersignatures.get(operationCID) ?? [];
1474
- const decoded = decodeJwsUnsafe5(jwsToken);
1690
+ const decoded = decodeJwsUnsafe4(jwsToken);
1475
1691
  if (decoded) {
1476
1692
  const kid = decoded.header.kid;
1477
1693
  const witnessDID = kid.includes("#") ? kid.split("#")[0] : kid;
1478
1694
  for (const cs of existing) {
1479
- const d = decodeJwsUnsafe5(cs);
1695
+ const d = decodeJwsUnsafe4(cs);
1480
1696
  if (!d) continue;
1481
1697
  const existingKid = d.header.kid;
1482
1698
  const existingDID = existingKid.includes("#") ? existingKid.split("#")[0] : existingKid;
@@ -1531,7 +1747,7 @@ var MemoryRelayStore = class {
1531
1747
  if (!chain) return { documents: [], cursor: null };
1532
1748
  const entries = [];
1533
1749
  for (const jws of chain.log) {
1534
- const decoded = decodeJwsUnsafe5(jws);
1750
+ const decoded = decodeJwsUnsafe4(jws);
1535
1751
  if (!decoded) continue;
1536
1752
  const payload = decoded.payload;
1537
1753
  const cid = typeof decoded.header.cid === "string" ? decoded.header.cid : "";
@@ -1593,7 +1809,7 @@ var MemoryRelayStore = class {
1593
1809
  if (!chain) return null;
1594
1810
  const opsByCID = /* @__PURE__ */ new Map();
1595
1811
  for (const jws of chain.log) {
1596
- const decoded = decodeJwsUnsafe5(jws);
1812
+ const decoded = decodeJwsUnsafe4(jws);
1597
1813
  if (!decoded) continue;
1598
1814
  const payload = decoded.payload;
1599
1815
  const opCID = typeof decoded.header.cid === "string" ? decoded.header.cid : "";
@@ -1610,7 +1826,7 @@ var MemoryRelayStore = class {
1610
1826
  currentCID = op.previousCID;
1611
1827
  }
1612
1828
  const identity = await verifyIdentityChain2({ didPrefix: "did:dfos", log: path });
1613
- const targetDecoded = decodeJwsUnsafe5(opsByCID.get(cid).jws);
1829
+ const targetDecoded = decodeJwsUnsafe4(opsByCID.get(cid).jws);
1614
1830
  const lastCreatedAt = typeof targetDecoded?.payload?.["createdAt"] === "string" ? (targetDecoded?.payload)["createdAt"] : "";
1615
1831
  return { state: identity, lastCreatedAt };
1616
1832
  }
@@ -1619,7 +1835,7 @@ var MemoryRelayStore = class {
1619
1835
  if (!chain) return null;
1620
1836
  const opsByCID = /* @__PURE__ */ new Map();
1621
1837
  for (const jws of chain.log) {
1622
- const decoded = decodeJwsUnsafe5(jws);
1838
+ const decoded = decodeJwsUnsafe4(jws);
1623
1839
  if (!decoded) continue;
1624
1840
  const payload = decoded.payload;
1625
1841
  const opCID = typeof decoded.header.cid === "string" ? decoded.header.cid : "";
@@ -1646,7 +1862,7 @@ var MemoryRelayStore = class {
1646
1862
  enforceAuthorization: true,
1647
1863
  resolveIdentity
1648
1864
  });
1649
- const targetDecoded = decodeJwsUnsafe5(opsByCID.get(cid).jws);
1865
+ const targetDecoded = decodeJwsUnsafe4(opsByCID.get(cid).jws);
1650
1866
  const lastCreatedAt = typeof targetDecoded?.payload?.["createdAt"] === "string" ? (targetDecoded?.payload)["createdAt"] : "";
1651
1867
  return { state: content, lastCreatedAt };
1652
1868
  }
@@ -1680,8 +1896,7 @@ var MemoryRelayStore = class {
1680
1896
  }
1681
1897
  }
1682
1898
  async markOpRejected(cid, _reason) {
1683
- const entry = this.rawOps.get(cid);
1684
- if (entry) entry.status = "rejected";
1899
+ this.rawOps.delete(cid);
1685
1900
  }
1686
1901
  async countUnsequenced() {
1687
1902
  let count = 0;
@@ -1699,6 +1914,8 @@ var MemoryRelayStore = class {
1699
1914
  export {
1700
1915
  MemoryRelayStore,
1701
1916
  bootstrapRelayIdentity,
1917
+ bootstrapRelayIdentityFromKey,
1918
+ chunkOps,
1702
1919
  computeOpCID,
1703
1920
  createCurrentKeyResolver,
1704
1921
  createHistoricalIdentityResolver,
package/dist/serve.js CHANGED
@@ -1,11 +1,38 @@
1
1
  // src/serve.ts
2
2
  import { createServer } from "http";
3
+ var MAX_STREAM_BODY_BYTES = (16 << 20) + (1 << 20);
3
4
  var serve = (app, options = {}) => {
4
5
  const { port = 4444, hostname } = options;
5
6
  const server = createServer(async (req, res) => {
6
7
  const url = new URL(req.url ?? "/", `http://${hostname ?? "localhost"}:${port}`);
8
+ const reject413 = () => {
9
+ if (res.headersSent) return;
10
+ res.writeHead(413, {
11
+ "content-type": "application/json",
12
+ connection: "close"
13
+ });
14
+ res.end(JSON.stringify({ error: "request body too large" }));
15
+ };
16
+ const declaredLength = Number(req.headers["content-length"]);
17
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_STREAM_BODY_BYTES) {
18
+ reject413();
19
+ return;
20
+ }
7
21
  const chunks = [];
8
- for await (const chunk of req) chunks.push(chunk);
22
+ let total = 0;
23
+ let aborted = false;
24
+ for await (const chunk of req) {
25
+ total += chunk.length;
26
+ if (total > MAX_STREAM_BODY_BYTES) {
27
+ aborted = true;
28
+ break;
29
+ }
30
+ chunks.push(chunk);
31
+ }
32
+ if (aborted) {
33
+ reject413();
34
+ return;
35
+ }
9
36
  const body = Buffer.concat(chunks);
10
37
  const headers = new Headers();
11
38
  for (const [k, v] of Object.entries(req.headers)) {
package/openapi.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  openapi: 3.1.0
2
2
  info:
3
3
  title: DFOS Web Relay
4
- version: 0.1.0
4
+ version: 0.9.0
5
5
  description: |
6
6
  HTTP relay for the DFOS protocol. Receives, verifies, stores, and serves
7
7
  identity chains, content chains, beacons, countersignatures, and content blobs.
@@ -185,7 +185,7 @@ paths:
185
185
  required: true
186
186
  schema:
187
187
  type: string
188
- description: Content identifier (22-char hash)
188
+ description: Content identifier (31-char hash)
189
189
  responses:
190
190
  '200':
191
191
  description: Content chain state and log
@@ -416,6 +416,70 @@ paths:
416
416
  '501':
417
417
  description: Documents capability not enabled
418
418
 
419
+ /log:
420
+ get:
421
+ operationId: getLog
422
+ summary: Paginated global log of all accepted operations
423
+ description: |
424
+ Returns every operation the relay has accepted — across all identity and
425
+ content chains, plus beacons and countersignatures — in acceptance order.
426
+ Cursor-based pagination. Used by peer relays to background-sync. Available
427
+ only when the relay advertises the `log` capability; otherwise returns 501.
428
+ tags: [Proof Plane]
429
+ parameters:
430
+ - name: after
431
+ in: query
432
+ required: false
433
+ schema:
434
+ type: string
435
+ description: CID cursor — start after this operation CID
436
+ - name: limit
437
+ in: query
438
+ required: false
439
+ schema:
440
+ type: integer
441
+ default: 100
442
+ maximum: 1000
443
+ responses:
444
+ '200':
445
+ description: Global log entries
446
+ content:
447
+ application/json:
448
+ schema:
449
+ type: object
450
+ required: [entries, cursor]
451
+ properties:
452
+ entries:
453
+ type: array
454
+ items:
455
+ type: object
456
+ required: [cid, jwsToken, kind, chainId]
457
+ properties:
458
+ cid:
459
+ type: string
460
+ jwsToken:
461
+ type: string
462
+ kind:
463
+ type: string
464
+ enum:
465
+ [
466
+ identity-op,
467
+ content-op,
468
+ beacon,
469
+ artifact,
470
+ countersign,
471
+ revocation,
472
+ credential,
473
+ ]
474
+ chainId:
475
+ type: string
476
+ description: Chain identifier (DID or contentId)
477
+ cursor:
478
+ type: string
479
+ nullable: true
480
+ '501':
481
+ description: Global log capability not enabled
482
+
419
483
  /identities/{did}/log:
420
484
  get:
421
485
  operationId: getIdentityLog
@@ -483,7 +547,7 @@ paths:
483
547
  required: true
484
548
  schema:
485
549
  type: string
486
- description: Content identifier (22-char hash)
550
+ description: Content identifier (31-char hash)
487
551
  - name: after
488
552
  in: query
489
553
  required: false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metalabel/dfos-web-relay",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "description": "DFOS Web Relay — verifying HTTP relay for identity chains, content chains, beacons, and content blobs",
6
6
  "license": "MIT",
@@ -41,18 +41,18 @@
41
41
  "README.md"
42
42
  ],
43
43
  "dependencies": {
44
- "hono": "^4.12.9",
45
- "zod": "^4.3.6"
44
+ "hono": "^4.12.23",
45
+ "zod": "^4.4.3"
46
46
  },
47
47
  "peerDependencies": {
48
- "@metalabel/dfos-protocol": "^0.6.0"
48
+ "@metalabel/dfos-protocol": "^0.10.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@types/node": "^24.10.4",
52
52
  "tsup": "^8.5.1",
53
- "tsx": "^4.20.3",
54
- "vitest": "^4.1.2",
55
- "@metalabel/dfos-protocol": "0.9.0"
53
+ "tsx": "^4.22.4",
54
+ "vitest": "^4.1.8",
55
+ "@metalabel/dfos-protocol": "0.10.0"
56
56
  },
57
57
  "scripts": {
58
58
  "build": "tsup",