@metalabel/dfos-web-relay 0.8.1 → 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/README.md CHANGED
@@ -49,14 +49,14 @@ serve({ port: 4444 });
49
49
  | `GET` | `/countersignatures/:cid` | Get countersignatures for an operation |
50
50
  | `GET` | `/operations/:cid/countersignatures` | Same as above (alias) |
51
51
  | `PUT` | `/content/:contentId/blob/:operationCID` | Upload blob (auth required) |
52
- | `GET` | `/content/:contentId/blob` | Download blob at head (auth + credential) |
52
+ | `GET` | `/content/:contentId/blob` | Download blob at head (standing auth, or auth + credential) |
53
53
  | `GET` | `/content/:contentId/blob/:ref` | Download blob at specific operation ref |
54
54
 
55
55
  ## Blob Authorization
56
56
 
57
57
  **Upload**: Auth token required. Caller must be the chain creator or the signer of the referenced operation (enables delegated upload).
58
58
 
59
- **Download**: Auth token required. Chain creator can download directly. Other identities must present a DFOS read credential (issued by the creator) in the `X-Credential` header.
59
+ **Download**: If a public credential (`aud: *`) exists as a standing authorization, the blob is served without authentication. Otherwise, auth token required chain creator can download directly, other identities must present a DFOS read credential (issued by the creator) in the `X-Credential` header.
60
60
 
61
61
  ## Peering
62
62
 
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,11 +1070,42 @@ 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
  }
914
1081
  };
1082
+ var hasPublicStandingAuth = async (contentId, action, store) => {
1083
+ const resource = `chain:${contentId}`;
1084
+ const publicCreds = await store.getPublicCredentials(resource);
1085
+ if (publicCreds.length === 0) return false;
1086
+ const chain = await store.getContentChain(contentId);
1087
+ if (!chain) return false;
1088
+ const resolveIdentity = createHistoricalIdentityResolver(store);
1089
+ const isRevoked = async (issuerDID, credentialCID) => store.isCredentialRevoked(issuerDID, credentialCID);
1090
+ for (const credJws of publicCreds) {
1091
+ try {
1092
+ const cred = await verifyDFOSCredential2(credJws, { resolveIdentity });
1093
+ const leafRevoked = await isRevoked(cred.iss, cred.credentialCID);
1094
+ if (leafRevoked) continue;
1095
+ const covers = await matchesResource(cred.att, resource, action);
1096
+ if (!covers) continue;
1097
+ await verifyDelegationChain(cred, {
1098
+ resolveIdentity,
1099
+ rootDID: chain.state.creatorDID,
1100
+ isRevoked
1101
+ });
1102
+ return true;
1103
+ } catch {
1104
+ continue;
1105
+ }
1106
+ }
1107
+ return false;
1108
+ };
915
1109
  var verifyContentAccess = async (options) => {
916
1110
  const { credentialJWS, requestedResource, action, store, creatorDID, requesterDID } = options;
917
1111
  if (requesterDID && requesterDID === creatorDID) {
@@ -919,33 +1113,13 @@ var verifyContentAccess = async (options) => {
919
1113
  }
920
1114
  const resolveIdentity = createHistoricalIdentityResolver(store);
921
1115
  const isRevoked = async (issuerDID, credentialCID) => store.isCredentialRevoked(issuerDID, credentialCID);
922
- const manifestLookup = async (manifestContentId) => {
923
- const chain = await store.getContentChain(manifestContentId);
924
- if (!chain) return [];
925
- const docCID = chain.state.currentDocumentCID;
926
- if (!docCID) return [];
927
- const blob = await store.getBlob({ creatorDID: chain.state.creatorDID, documentCID: docCID });
928
- if (!blob) return [];
929
- try {
930
- const doc = JSON.parse(new TextDecoder().decode(blob));
931
- const entries = doc["entries"];
932
- if (!entries || typeof entries !== "object") return [];
933
- return Object.values(entries).filter(
934
- (v) => typeof v === "string" && !v.startsWith("did:") && !v.startsWith("bafyrei")
935
- );
936
- } catch {
937
- return [];
938
- }
939
- };
940
1116
  const publicCreds = await store.getPublicCredentials(requestedResource);
941
1117
  for (const credJws of publicCreds) {
942
1118
  try {
943
1119
  const cred = await verifyDFOSCredential2(credJws, { resolveIdentity });
944
1120
  const leafRevoked = await isRevoked(cred.iss, cred.credentialCID);
945
1121
  if (leafRevoked) continue;
946
- const covers = await matchesResource(cred.att, requestedResource, action, {
947
- manifestLookup
948
- });
1122
+ const covers = await matchesResource(cred.att, requestedResource, action);
949
1123
  if (!covers) continue;
950
1124
  await verifyDelegationChain(cred, { resolveIdentity, rootDID: creatorDID, isRevoked });
951
1125
  return { granted: true, source: "public-credential", credential: cred };
@@ -966,9 +1140,7 @@ var verifyContentAccess = async (options) => {
966
1140
  return { granted: false, source: "none" };
967
1141
  }
968
1142
  }
969
- const covers = await matchesResource(cred.att, requestedResource, action, {
970
- manifestLookup
971
- });
1143
+ const covers = await matchesResource(cred.att, requestedResource, action);
972
1144
  if (!covers) {
973
1145
  return { granted: false, source: "none" };
974
1146
  }
@@ -981,22 +1153,7 @@ var verifyContentAccess = async (options) => {
981
1153
  };
982
1154
 
983
1155
  // src/sequencer.ts
984
- import { dagCborCanonicalEncode as dagCborCanonicalEncode2, decodeJwsUnsafe as decodeJwsUnsafe3 } from "@metalabel/dfos-protocol/crypto";
985
- var isDependencyFailure = (error) => {
986
- const patterns = [
987
- "unknown previous operation",
988
- "unknown identity:",
989
- "content chain not found:",
990
- "failed to compute state at fork point:"
991
- ];
992
- return patterns.some((p) => error.includes(p));
993
- };
994
- var computeOpCID = async (jwsToken) => {
995
- const decoded = decodeJwsUnsafe3(jwsToken);
996
- if (!decoded) return void 0;
997
- const encoded = await dagCborCanonicalEncode2(decoded.payload);
998
- return encoded.cid.toString();
999
- };
1156
+ var isDependencyFailure = (res) => res.dependencyMissing === true;
1000
1157
  var sequenceOps = async (store) => {
1001
1158
  const newOps = [];
1002
1159
  const result = { sequenced: 0, rejected: 0, pending: 0 };
@@ -1017,7 +1174,7 @@ var sequenceOps = async (store) => {
1017
1174
  } else if (res.status === "duplicate") {
1018
1175
  sequencedCIDs.push(res.cid);
1019
1176
  progress = true;
1020
- } else if (res.status === "rejected" && !isDependencyFailure(res.error ?? "")) {
1177
+ } else if (res.status === "rejected" && !isDependencyFailure(res)) {
1021
1178
  await store.markOpRejected(res.cid, res.error ?? "unknown");
1022
1179
  result.rejected++;
1023
1180
  progress = true;
@@ -1036,13 +1193,37 @@ var sequenceOps = async (store) => {
1036
1193
  // src/relay.ts
1037
1194
  var require2 = createRequire(import.meta.url);
1038
1195
  var { version: RELAY_VERSION } = require2("../package.json");
1196
+ var MAX_OPERATIONS_PER_BATCH = 100;
1039
1197
  var IngestBody = z.object({
1040
- operations: z.array(z.string()).min(1).max(100)
1198
+ operations: z.array(z.string()).min(1).max(MAX_OPERATIONS_PER_BATCH)
1041
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
+ };
1042
1222
  var createRelay = async (options) => {
1043
1223
  const { store } = options;
1044
1224
  const contentEnabled = options.content !== false;
1045
1225
  const logEnabled = options.log !== false;
1226
+ const maxAuthTokenTTLSeconds = options.maxAuthTokenTTLSeconds ?? DEFAULT_MAX_AUTH_TOKEN_TTL_SECONDS;
1046
1227
  const peers = options.peers ?? [];
1047
1228
  const peerClient = options.peerClient;
1048
1229
  const gossipPeers = peers.filter((p) => p.gossip !== false);
@@ -1054,8 +1235,10 @@ var createRelay = async (options) => {
1054
1235
  const gossip = (ops) => {
1055
1236
  if (ops.length === 0 || gossipPeers.length === 0 || !peerClient) return;
1056
1237
  for (const peer of gossipPeers) {
1057
- peerClient.submitOperations(peer.url, ops).catch(() => {
1058
- });
1238
+ for (const chunk of chunkOps(ops, MAX_GOSSIP_BATCH)) {
1239
+ peerClient.submitOperations(peer.url, chunk).catch(() => {
1240
+ });
1241
+ }
1059
1242
  }
1060
1243
  };
1061
1244
  const ingestWithGossip = async (tokens) => {
@@ -1081,6 +1264,20 @@ var createRelay = async (options) => {
1081
1264
  return results;
1082
1265
  };
1083
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
+ });
1084
1281
  app.get("/.well-known/dfos-relay", (c) => {
1085
1282
  return c.json({
1086
1283
  did: relayDID,
@@ -1096,6 +1293,9 @@ var createRelay = async (options) => {
1096
1293
  });
1097
1294
  });
1098
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
+ }
1099
1299
  let body;
1100
1300
  try {
1101
1301
  body = await c.req.json();
@@ -1125,9 +1325,9 @@ var createRelay = async (options) => {
1125
1325
  const chain = await store.getIdentityChain(did);
1126
1326
  if (!chain) return c.json({ error: "not found" }, 404);
1127
1327
  const after = c.req.query("after");
1128
- const limit = Math.min(Number(c.req.query("limit") || 100), 1e3);
1328
+ const limit = parseLimit(c.req.query("limit"), 100, 1e3);
1129
1329
  const entries = chain.log.map((jws) => {
1130
- const decoded = decodeJwsUnsafe4(jws);
1330
+ const decoded = decodeJwsUnsafe3(jws);
1131
1331
  return { cid: decoded?.header.cid || "", jwsToken: jws };
1132
1332
  });
1133
1333
  let startIdx = 0;
@@ -1171,9 +1371,9 @@ var createRelay = async (options) => {
1171
1371
  const chain = await store.getContentChain(contentId);
1172
1372
  if (!chain) return c.json({ error: "not found" }, 404);
1173
1373
  const after = c.req.query("after");
1174
- const limit = Math.min(Number(c.req.query("limit") || 100), 1e3);
1374
+ const limit = parseLimit(c.req.query("limit"), 100, 1e3);
1175
1375
  const entries = chain.log.map((jws) => {
1176
- const decoded = decodeJwsUnsafe4(jws);
1376
+ const decoded = decodeJwsUnsafe3(jws);
1177
1377
  return { cid: decoded?.header.cid || "", jwsToken: jws };
1178
1378
  });
1179
1379
  let startIdx = 0;
@@ -1188,20 +1388,28 @@ var createRelay = async (options) => {
1188
1388
  app.get("/content/:contentId/documents", async (c) => {
1189
1389
  if (!contentEnabled) return c.json({ error: "content plane not available" }, 501);
1190
1390
  const contentId = c.req.param("contentId");
1191
- const auth = await authenticateRequest(c.req.header("authorization"), relayDID, store);
1192
- if (!auth) return c.json({ error: "authentication required" }, 401);
1193
1391
  const chain = await store.getContentChain(contentId);
1194
1392
  if (!chain) return c.json({ error: "not found" }, 404);
1195
- const accessError = await verifyReadAccess(
1196
- auth,
1197
- chain,
1198
- contentId,
1199
- c.req.header("x-credential"),
1200
- store
1201
- );
1202
- if (accessError) return accessError;
1393
+ const publicAccess = await hasPublicStandingAuth(contentId, "read", store);
1394
+ if (!publicAccess) {
1395
+ const auth = await authenticateRequest(
1396
+ c.req.header("authorization"),
1397
+ relayDID,
1398
+ store,
1399
+ maxAuthTokenTTLSeconds
1400
+ );
1401
+ if (!auth) return c.json({ error: "authentication required" }, 401);
1402
+ const accessError = await verifyReadAccess(
1403
+ auth,
1404
+ chain,
1405
+ contentId,
1406
+ c.req.header("x-credential"),
1407
+ store
1408
+ );
1409
+ if (accessError) return accessError;
1410
+ }
1203
1411
  const after = c.req.query("after");
1204
- const limit = Math.min(Number(c.req.query("limit") || 100), 1e3);
1412
+ const limit = parseLimit(c.req.query("limit"), 100, 1e3);
1205
1413
  const result = await store.getDocuments(contentId, {
1206
1414
  ...after ? { after } : {},
1207
1415
  limit
@@ -1273,7 +1481,7 @@ var createRelay = async (options) => {
1273
1481
  app.get("/log", async (c) => {
1274
1482
  if (!logEnabled) return c.json({ error: "global log not available" }, 501);
1275
1483
  const afterParam = c.req.query("after");
1276
- const limit = Math.min(Number(c.req.query("limit") || 100), 1e3);
1484
+ const limit = parseLimit(c.req.query("limit"), 100, 1e3);
1277
1485
  const result = await store.readLog(afterParam ? { after: afterParam, limit } : { limit });
1278
1486
  return c.json(result);
1279
1487
  });
@@ -1281,14 +1489,22 @@ var createRelay = async (options) => {
1281
1489
  if (!contentEnabled) return c.json({ error: "content plane not available" }, 501);
1282
1490
  const contentId = c.req.param("contentId");
1283
1491
  const operationCID = c.req.param("operationCID");
1284
- 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
+ );
1285
1501
  if (!auth) return c.json({ error: "authentication required" }, 401);
1286
1502
  const chain = await store.getContentChain(contentId);
1287
1503
  if (!chain) return c.json({ error: "content chain not found" }, 404);
1288
1504
  let documentCID = null;
1289
1505
  let operationSignerDID = null;
1290
1506
  for (const token of chain.log) {
1291
- const decoded = decodeJwsUnsafe4(token);
1507
+ const decoded = decodeJwsUnsafe3(token);
1292
1508
  if (!decoded) continue;
1293
1509
  if (decoded.header.cid !== operationCID) continue;
1294
1510
  const payload = decoded.payload;
@@ -1303,9 +1519,12 @@ var createRelay = async (options) => {
1303
1519
  return c.json({ error: "not authorized \u2014 must be chain creator or operation signer" }, 403);
1304
1520
  }
1305
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
+ }
1306
1525
  try {
1307
1526
  const parsed = JSON.parse(new TextDecoder().decode(bytes));
1308
- const encoded = await dagCborCanonicalEncode3(parsed);
1527
+ const encoded = await dagCborCanonicalEncode2(parsed);
1309
1528
  if (encoded.cid.toString() !== documentCID) {
1310
1529
  return c.json({ error: "blob bytes do not match documentCID" }, 400);
1311
1530
  }
@@ -1323,7 +1542,8 @@ var createRelay = async (options) => {
1323
1542
  authHeader: c.req.header("authorization"),
1324
1543
  credHeader: c.req.header("x-credential"),
1325
1544
  relayDID,
1326
- store
1545
+ store,
1546
+ maxAuthTokenTTLSeconds
1327
1547
  });
1328
1548
  });
1329
1549
  app.get("/content/:contentId/blob/:ref", async (c) => {
@@ -1334,20 +1554,24 @@ var createRelay = async (options) => {
1334
1554
  authHeader: c.req.header("authorization"),
1335
1555
  credHeader: c.req.header("x-credential"),
1336
1556
  relayDID,
1337
- store
1557
+ store,
1558
+ maxAuthTokenTTLSeconds
1338
1559
  });
1339
1560
  });
1561
+ const maxOpsPerSyncCycle = 5e3;
1340
1562
  const syncFromPeers = async () => {
1341
1563
  if (!peerClient) return;
1342
1564
  for (const peer of syncPeers) {
1343
1565
  let cursor = await store.getPeerCursor(peer.url);
1344
- while (true) {
1566
+ let fetched = 0;
1567
+ while (fetched < maxOpsPerSyncCycle) {
1345
1568
  const page = await peerClient.getOperationLog(peer.url, {
1346
1569
  ...cursor ? { after: cursor } : {},
1347
1570
  limit: 1e3
1348
1571
  });
1349
1572
  if (!page || page.entries.length === 0) break;
1350
1573
  await ingestWithGossip(page.entries.map((e) => e.jwsToken));
1574
+ fetched += page.entries.length;
1351
1575
  cursor = page.cursor ?? page.entries[page.entries.length - 1].cid;
1352
1576
  await store.setPeerCursor(peer.url, cursor);
1353
1577
  if (!page.cursor) break;
@@ -1361,20 +1585,23 @@ var jsonResponse = (body, status = 200) => new Response(JSON.stringify(body), {
1361
1585
  headers: { "content-type": "application/json" }
1362
1586
  });
1363
1587
  var readBlob = async (params) => {
1364
- const { contentId, ref, authHeader, credHeader, relayDID, store } = params;
1365
- const auth = await authenticateRequest(authHeader, relayDID, store);
1366
- if (!auth) return jsonResponse({ error: "authentication required" }, 401);
1588
+ const { contentId, ref, authHeader, credHeader, relayDID, store, maxAuthTokenTTLSeconds } = params;
1367
1589
  const chain = await store.getContentChain(contentId);
1368
1590
  if (!chain) return jsonResponse({ error: "content chain not found" }, 404);
1369
- const credError = await verifyReadAccess(auth, chain, contentId, credHeader, store);
1370
- if (credError) return credError;
1591
+ const publicAccess = await hasPublicStandingAuth(contentId, "read", store);
1592
+ if (!publicAccess) {
1593
+ const auth = await authenticateRequest(authHeader, relayDID, store, maxAuthTokenTTLSeconds);
1594
+ if (!auth) return jsonResponse({ error: "authentication required" }, 401);
1595
+ const credError = await verifyReadAccess(auth, chain, contentId, credHeader, store);
1596
+ if (credError) return credError;
1597
+ }
1371
1598
  let documentCID = null;
1372
1599
  let operationFound = ref === "head";
1373
1600
  if (ref === "head") {
1374
1601
  documentCID = chain.state.currentDocumentCID;
1375
1602
  } else {
1376
1603
  for (const token of chain.log) {
1377
- const decoded = decodeJwsUnsafe4(token);
1604
+ const decoded = decodeJwsUnsafe3(token);
1378
1605
  if (!decoded) continue;
1379
1606
  if (decoded.header.cid === ref) {
1380
1607
  operationFound = true;
@@ -1410,7 +1637,7 @@ var verifyReadAccess = async (auth, chain, contentId, credHeader, store) => {
1410
1637
 
1411
1638
  // src/store.ts
1412
1639
  import { verifyContentChain as verifyContentChain2, verifyIdentityChain as verifyIdentityChain2 } from "@metalabel/dfos-protocol/chain";
1413
- import { decodeJwsUnsafe as decodeJwsUnsafe5 } from "@metalabel/dfos-protocol/crypto";
1640
+ import { decodeJwsUnsafe as decodeJwsUnsafe4 } from "@metalabel/dfos-protocol/crypto";
1414
1641
  var blobKeyString = (key) => `${key.creatorDID}::${key.documentCID}`;
1415
1642
  var MemoryRelayStore = class {
1416
1643
  operations = /* @__PURE__ */ new Map();
@@ -1460,12 +1687,12 @@ var MemoryRelayStore = class {
1460
1687
  }
1461
1688
  async addCountersignature(operationCID, jwsToken) {
1462
1689
  const existing = this.countersignatures.get(operationCID) ?? [];
1463
- const decoded = decodeJwsUnsafe5(jwsToken);
1690
+ const decoded = decodeJwsUnsafe4(jwsToken);
1464
1691
  if (decoded) {
1465
1692
  const kid = decoded.header.kid;
1466
1693
  const witnessDID = kid.includes("#") ? kid.split("#")[0] : kid;
1467
1694
  for (const cs of existing) {
1468
- const d = decodeJwsUnsafe5(cs);
1695
+ const d = decodeJwsUnsafe4(cs);
1469
1696
  if (!d) continue;
1470
1697
  const existingKid = d.header.kid;
1471
1698
  const existingDID = existingKid.includes("#") ? existingKid.split("#")[0] : existingKid;
@@ -1504,10 +1731,6 @@ var MemoryRelayStore = class {
1504
1731
  tokens.push(cred.jwsToken);
1505
1732
  break;
1506
1733
  }
1507
- if (isChainRequest && att.resource.startsWith("manifest:")) {
1508
- tokens.push(cred.jwsToken);
1509
- break;
1510
- }
1511
1734
  }
1512
1735
  }
1513
1736
  return tokens;
@@ -1524,7 +1747,7 @@ var MemoryRelayStore = class {
1524
1747
  if (!chain) return { documents: [], cursor: null };
1525
1748
  const entries = [];
1526
1749
  for (const jws of chain.log) {
1527
- const decoded = decodeJwsUnsafe5(jws);
1750
+ const decoded = decodeJwsUnsafe4(jws);
1528
1751
  if (!decoded) continue;
1529
1752
  const payload = decoded.payload;
1530
1753
  const cid = typeof decoded.header.cid === "string" ? decoded.header.cid : "";
@@ -1586,7 +1809,7 @@ var MemoryRelayStore = class {
1586
1809
  if (!chain) return null;
1587
1810
  const opsByCID = /* @__PURE__ */ new Map();
1588
1811
  for (const jws of chain.log) {
1589
- const decoded = decodeJwsUnsafe5(jws);
1812
+ const decoded = decodeJwsUnsafe4(jws);
1590
1813
  if (!decoded) continue;
1591
1814
  const payload = decoded.payload;
1592
1815
  const opCID = typeof decoded.header.cid === "string" ? decoded.header.cid : "";
@@ -1603,7 +1826,7 @@ var MemoryRelayStore = class {
1603
1826
  currentCID = op.previousCID;
1604
1827
  }
1605
1828
  const identity = await verifyIdentityChain2({ didPrefix: "did:dfos", log: path });
1606
- const targetDecoded = decodeJwsUnsafe5(opsByCID.get(cid).jws);
1829
+ const targetDecoded = decodeJwsUnsafe4(opsByCID.get(cid).jws);
1607
1830
  const lastCreatedAt = typeof targetDecoded?.payload?.["createdAt"] === "string" ? (targetDecoded?.payload)["createdAt"] : "";
1608
1831
  return { state: identity, lastCreatedAt };
1609
1832
  }
@@ -1612,7 +1835,7 @@ var MemoryRelayStore = class {
1612
1835
  if (!chain) return null;
1613
1836
  const opsByCID = /* @__PURE__ */ new Map();
1614
1837
  for (const jws of chain.log) {
1615
- const decoded = decodeJwsUnsafe5(jws);
1838
+ const decoded = decodeJwsUnsafe4(jws);
1616
1839
  if (!decoded) continue;
1617
1840
  const payload = decoded.payload;
1618
1841
  const opCID = typeof decoded.header.cid === "string" ? decoded.header.cid : "";
@@ -1639,7 +1862,7 @@ var MemoryRelayStore = class {
1639
1862
  enforceAuthorization: true,
1640
1863
  resolveIdentity
1641
1864
  });
1642
- const targetDecoded = decodeJwsUnsafe5(opsByCID.get(cid).jws);
1865
+ const targetDecoded = decodeJwsUnsafe4(opsByCID.get(cid).jws);
1643
1866
  const lastCreatedAt = typeof targetDecoded?.payload?.["createdAt"] === "string" ? (targetDecoded?.payload)["createdAt"] : "";
1644
1867
  return { state: content, lastCreatedAt };
1645
1868
  }
@@ -1673,8 +1896,7 @@ var MemoryRelayStore = class {
1673
1896
  }
1674
1897
  }
1675
1898
  async markOpRejected(cid, _reason) {
1676
- const entry = this.rawOps.get(cid);
1677
- if (entry) entry.status = "rejected";
1899
+ this.rawOps.delete(cid);
1678
1900
  }
1679
1901
  async countUnsequenced() {
1680
1902
  let count = 0;
@@ -1692,6 +1914,8 @@ var MemoryRelayStore = class {
1692
1914
  export {
1693
1915
  MemoryRelayStore,
1694
1916
  bootstrapRelayIdentity,
1917
+ bootstrapRelayIdentityFromKey,
1918
+ chunkOps,
1695
1919
  computeOpCID,
1696
1920
  createCurrentKeyResolver,
1697
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.8.1",
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.8.1"
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",