@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 +49 -7
- package/dist/index.js +328 -111
- package/dist/serve.js +28 -1
- package/openapi.yaml +67 -3
- package/package.json +7 -7
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
|
|
415
|
-
* arrive later via sync or gossip.
|
|
416
|
-
*
|
|
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: (
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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 {
|
|
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 {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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 =
|
|
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 {
|
|
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 <=
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
|
731
|
-
return b.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 = {
|
|
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:
|
|
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
|
-
|
|
785
|
-
|
|
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
|
|
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
|
|
968
|
+
name
|
|
812
969
|
},
|
|
813
|
-
createdAt
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
1328
|
+
const limit = parseLimit(c.req.query("limit"), 100, 1e3);
|
|
1134
1329
|
const entries = chain.log.map((jws) => {
|
|
1135
|
-
const decoded =
|
|
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 =
|
|
1374
|
+
const limit = parseLimit(c.req.query("limit"), 100, 1e3);
|
|
1180
1375
|
const entries = chain.log.map((jws) => {
|
|
1181
|
-
const decoded =
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
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 (
|
|
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.
|
|
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.
|
|
45
|
-
"zod": "^4.3
|
|
44
|
+
"hono": "^4.12.23",
|
|
45
|
+
"zod": "^4.4.3"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"@metalabel/dfos-protocol": "^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.
|
|
54
|
-
"vitest": "^4.1.
|
|
55
|
-
"@metalabel/dfos-protocol": "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",
|