@metalabel/dfos-client 0.46.0 → 0.48.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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Metalabel
3
+ Copyright (c) 2026 DFOS Inc
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -57,6 +57,34 @@ createClient({ relays: [...], quorum: 2 }); // require 2 relays to return the sa
57
57
 
58
58
  `quorum: 1` (default) is first-wins with failover. `provenance.agreed` reports whether the threshold was met.
59
59
 
60
+ ### Divergence — and the way out of it
61
+
62
+ The cached prefix is a **pin**: once a chain's first N operations have verified here, a later read has to extend them. A relay answering with a _different_ operation at a position the pin covers is contradicting proof rather than carrying news, so the read fails with a `DivergenceError`.
63
+
64
+ ```typescript
65
+ import { divergenceErrorFrom } from '@metalabel/dfos-client';
66
+
67
+ try {
68
+ await client.identity(did);
69
+ } catch (err) {
70
+ const d = divergenceErrorFrom(err); // walks `cause` — the fan-out wraps its candidate's failure
71
+ if (d) {
72
+ d.chainType; // 'identity' | 'content' — a content read can diverge on its CREATOR's chain
73
+ d.chainId;
74
+ d.cachedHeadCID; // the tip this client proved
75
+ d.liveHeadCID; // the tip the relays serve now
76
+ }
77
+ }
78
+ ```
79
+
80
+ **Nothing is discarded automatically.** A client that healed itself would erase the only evidence a rewrite happened, which is the reason the prefix is pinned at all. The undo is explicit and per-chain:
81
+
82
+ ```typescript
83
+ await client.discardCachedChain('identity', did); // this chain's prefix only; nothing else, nowhere else
84
+ ```
85
+
86
+ The next read of that chain folds cold from genesis and recomputes every signature and CID. Deleting is local: no relay is touched and no operation is destroyed. It is idempotent, safe for a chain that was never cached, and answers `false` only when the configured `Store` exposes no `delete` (both bundled stores do).
87
+
60
88
  ### The free floor
61
89
 
62
90
  ```typescript
@@ -1,5 +1,5 @@
1
1
  export { ApiRequestVerifyError, DEFAULT_PROOF_SKEW_SECONDS, DEFAULT_PROOF_WINDOW_SECONDS, DFOS_AUTH_SCHEME, EMPTY_BODY_SHA256, IDENTITY_PROOF_JWS_TYP, IdentityProofPayload, MAX_BODY_BYTES, MAX_PROOF_FRESHNESS_SPAN_SECONDS, MAX_REQUEST_PROOF_SIZE, ProofExtraMembers, REQUEST_PROOF_JWS_TYP, RequestProofFailurePhase, RequestProofFailureReason, RequestProofPayload, SignApiIdentityRequestInput, SignApiRequestInput, apiIdentitySigningInput, apiRequestSigningInput, assertProofVerifierConfig, buildApiAuthHeaders, buildApiIdentityHeaders, parseDfosAuthorization, sha256BodyHash, signApiIdentityRequest, signApiRequest } from '@metalabel/dfos-protocol/credentials';
2
- import { a as Client } from './types-ByxTj1u-.js';
2
+ import { a as Client } from './types-BfzEg_gw.js';
3
3
  import '@metalabel/dfos-protocol/chain';
4
4
  import '@metalabel/dfos-web-relay/peer-client';
5
5
 
@@ -7,6 +7,9 @@ var memoryStore = () => {
7
7
  },
8
8
  async set(key, value) {
9
9
  map.set(key, value);
10
+ },
11
+ async delete(key) {
12
+ map.delete(key);
10
13
  }
11
14
  };
12
15
  };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { C as ClientConfig, a as Client, b as Callbacks, R as RevChecker } from './types-ByxTj1u-.js';
2
- export { c as CallOptions, D as DocumentBlob, G as GlobalLogOptions, d as GlobalLogPage, e as GlobalLogResult, I as IndexCapabilities, f as IndexContentPage, g as IndexContentRow, h as IndexCountersignatureRow, i as IndexCountersignaturesPage, j as IndexCredentialRow, k as IndexCredentialsPage, l as IndexIdentitiesPage, m as IndexIdentityProfile, n as IndexIdentityRow, o as IndexOrder, p as IndexRecencyOrder, L as LogOp, P as Provenance, q as RelayHealth, r as RelayResponse, s as Resolution, t as Resolved, u as ResolvedContent, v as ResolvedCredential, S as Store, T as Trust, U as UnverifiableAxis, V as VerifyResult } from './types-ByxTj1u-.js';
3
- export { m as memoryStore } from './memory-CL1DM6Ud.js';
1
+ import { C as ClientConfig, a as Client, b as Callbacks, R as RevChecker } from './types-BfzEg_gw.js';
2
+ export { c as CallOptions, D as DocumentBlob, G as GlobalLogOptions, d as GlobalLogPage, e as GlobalLogResult, I as IndexCapabilities, f as IndexContentPage, g as IndexContentRow, h as IndexCountersignatureRow, i as IndexCountersignaturesPage, j as IndexCredentialRow, k as IndexCredentialsPage, l as IndexIdentitiesPage, m as IndexIdentityProfile, n as IndexIdentityRow, o as IndexOrder, p as IndexRecencyOrder, L as LogOp, P as Provenance, q as RelayHealth, r as RelayResponse, s as Resolution, t as Resolved, u as ResolvedContent, v as ResolvedCredential, S as Store, T as Trust, U as UnverifiableAxis, V as VerifyResult } from './types-BfzEg_gw.js';
3
+ export { m as memoryStore } from './memory-BuTsEPZI.js';
4
4
  import '@metalabel/dfos-protocol/chain';
5
5
  import '@metalabel/dfos-protocol/credentials';
6
6
  import '@metalabel/dfos-web-relay/peer-client';
@@ -12,6 +12,51 @@ declare const createClient: (config: ClientConfig) => Client;
12
12
  */
13
13
  declare const resolvers: (relays: string[]) => Callbacks;
14
14
 
15
+ /**
16
+ * The relays' log for a chain disagrees with the verified prefix this client
17
+ * cached: at a position the cache already covers, the operation served is not
18
+ * the operation this client proved. Distinct from `StaleAnswerError` (a relay
19
+ * merely behind) and from a failed proof (a chain that does not verify) — here
20
+ * two histories contradict each other about what already happened, so verifying
21
+ * forward from the trusted prefix is not a thing that can be done.
22
+ *
23
+ * NOTHING IS DISCARDED HERE. Dropping a verified prefix is a decision about
24
+ * trust, not an error path: a client that healed itself silently would erase the
25
+ * only evidence that a rewrite happened, which is the whole reason the prefix is
26
+ * pinned. `client.discardCachedChain()` is the explicit undo, and it is the
27
+ * caller's to call.
28
+ *
29
+ * The error is thrown as a CANDIDATE FILTER, inside the fan-out. A fan-out where
30
+ * every relay diverges surfaces its own error carrying this one as `cause`, so
31
+ * callers reach it with `divergenceErrorFrom(err)` rather than a bare
32
+ * `instanceof` on what they caught.
33
+ */
34
+ declare class DivergenceError extends Error {
35
+ readonly chainType: 'identity' | 'content';
36
+ readonly chainId: string;
37
+ /** Head CID of the verified prefix this client holds. */
38
+ readonly cachedHeadCID: string;
39
+ /**
40
+ * Head CID of the log the relays served, as that operation's own JWS header
41
+ * names it — orientation for a human, never a proof. Empty when the served log
42
+ * was empty or its head named no CID.
43
+ */
44
+ readonly liveHeadCID: string;
45
+ constructor(input: {
46
+ chainType: 'identity' | 'content';
47
+ chainId: string;
48
+ cachedHeadCID: string;
49
+ liveHeadCID: string;
50
+ });
51
+ }
52
+ /**
53
+ * Find the `DivergenceError` behind a caught error, walking the `cause` chain —
54
+ * the fan-out wraps a candidate's verification failure, and a content read wraps
55
+ * the identity read inside it. Returns `undefined` when the failure was anything
56
+ * else, so a caller can branch on the answer instead of matching message text.
57
+ */
58
+ declare const divergenceErrorFrom: (err: unknown) => DivergenceError | undefined;
59
+
15
60
  /**
16
61
  * Build the default revocation checker over an ordered relay set.
17
62
  *
@@ -29,4 +74,4 @@ declare const resolvers: (relays: string[]) => Callbacks;
29
74
  */
30
75
  declare const createRevocationChecker: (relays: string[], fetchImpl: typeof fetch, resolveKey: (kid: string) => Promise<Uint8Array>) => RevChecker;
31
76
 
32
- export { Callbacks, Client, ClientConfig, RevChecker, createClient, createRevocationChecker, resolvers };
77
+ export { Callbacks, Client, ClientConfig, DivergenceError, RevChecker, createClient, createRevocationChecker, divergenceErrorFrom, resolvers };
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  memoryStore
3
- } from "./chunk-63XCFYOE.js";
3
+ } from "./chunk-KY632J74.js";
4
4
 
5
5
  // src/client.ts
6
6
  import {
@@ -111,7 +111,9 @@ var fanOutLog = async (fetchPage, relays, quorum, verifyCandidate) => {
111
111
  }
112
112
  if (lastVerifyError !== void 0) {
113
113
  const message = lastVerifyError instanceof Error ? lastVerifyError.message : "unknown error";
114
- throw new Error(`all candidate logs failed verification: ${message}`);
114
+ throw new Error(`all candidate logs failed verification: ${message}`, {
115
+ cause: lastVerifyError
116
+ });
115
117
  }
116
118
  return {
117
119
  outcome: "unreachable",
@@ -238,6 +240,36 @@ import {
238
240
  } from "@metalabel/dfos-protocol/chain";
239
241
  import { decodeJwsUnsafe } from "@metalabel/dfos-protocol/crypto";
240
242
  var DID_PREFIX = "did:dfos";
243
+ var cacheKey = (kind, id) => `${kind}:${id}`;
244
+ var DivergenceError = class extends Error {
245
+ chainType;
246
+ chainId;
247
+ /** Head CID of the verified prefix this client holds. */
248
+ cachedHeadCID;
249
+ /**
250
+ * Head CID of the log the relays served, as that operation's own JWS header
251
+ * names it — orientation for a human, never a proof. Empty when the served log
252
+ * was empty or its head named no CID.
253
+ */
254
+ liveHeadCID;
255
+ constructor(input) {
256
+ super(`${input.chainType} log diverges from the verified cached prefix: ${input.chainId}`);
257
+ this.name = "DivergenceError";
258
+ this.chainType = input.chainType;
259
+ this.chainId = input.chainId;
260
+ this.cachedHeadCID = input.cachedHeadCID;
261
+ this.liveHeadCID = input.liveHeadCID;
262
+ }
263
+ };
264
+ var CAUSE_DEPTH = 8;
265
+ var divergenceErrorFrom = (err) => {
266
+ let cursor = err;
267
+ for (let depth = 0; depth < CAUSE_DEPTH && cursor instanceof Error; depth++) {
268
+ if (cursor instanceof DivergenceError) return cursor;
269
+ cursor = cursor.cause;
270
+ }
271
+ return void 0;
272
+ };
241
273
  var opMeta = (jws) => {
242
274
  const decoded = decodeJwsUnsafe(jws);
243
275
  const cid = typeof decoded?.header.cid === "string" ? decoded.header.cid : "";
@@ -263,7 +295,7 @@ var keyBytesFor = (state, keyId) => {
263
295
  var createResolvers = (deps) => {
264
296
  const relaysFor = (o) => normalizeRelays(o?.relays ?? deps.relays);
265
297
  const getIdentityChain = async (did, options) => {
266
- const key = `identity:${did}`;
298
+ const key = cacheKey("identity", did);
267
299
  const cached = options?.fresh ? void 0 : await deps.store.get(key);
268
300
  const verifyCandidate = async (entries) => {
269
301
  if (!cached) {
@@ -277,7 +309,12 @@ var createResolvers = (deps) => {
277
309
  return { state: state2, log: log2, headCID: last.cid, lastCreatedAt: last.createdAt };
278
310
  }
279
311
  if (cached.log.some((jws, index) => index < entries.length && entries[index].jwsToken !== jws)) {
280
- throw new Error(`identity log diverges from the verified cached prefix: ${did}`);
312
+ throw new DivergenceError({
313
+ chainType: "identity",
314
+ chainId: did,
315
+ cachedHeadCID: cached.headCID,
316
+ liveHeadCID: opMeta(entries[entries.length - 1]?.jwsToken ?? "").cid
317
+ });
281
318
  }
282
319
  if (entries.length < cached.log.length) {
283
320
  throw new StaleAnswerError(`identity log is behind the verified cached prefix: ${did}`);
@@ -358,7 +395,7 @@ var createResolvers = (deps) => {
358
395
  isRevoked: deps.isRevoked
359
396
  });
360
397
  const getContentChain = async (contentId, options) => {
361
- const key = `content:${contentId}`;
398
+ const key = cacheKey("content", contentId);
362
399
  const cached = options?.fresh ? void 0 : await deps.store.get(key);
363
400
  const verifyCandidate = async (entries) => {
364
401
  if (!cached) {
@@ -380,7 +417,12 @@ var createResolvers = (deps) => {
380
417
  return { state: state2, log: log2, headCID: last.cid, lastCreatedAt: last.createdAt };
381
418
  }
382
419
  if (cached.log.some((jws, index) => index < entries.length && entries[index].jwsToken !== jws)) {
383
- throw new Error(`content log diverges from the verified cached prefix: ${contentId}`);
420
+ throw new DivergenceError({
421
+ chainType: "content",
422
+ chainId: contentId,
423
+ cachedHeadCID: cached.headCID,
424
+ liveHeadCID: opMeta(entries[entries.length - 1]?.jwsToken ?? "").cid
425
+ });
384
426
  }
385
427
  if (entries.length < cached.log.length) {
386
428
  throw new StaleAnswerError(
@@ -439,7 +481,13 @@ var createResolvers = (deps) => {
439
481
  tipUnverified: cached !== void 0 && candidate.log.length === cached.log.length
440
482
  };
441
483
  };
442
- return { getIdentityChain, getContentChain, callbacks };
484
+ const discardCachedChain = async (kind, id) => {
485
+ const remove = deps.store.delete?.bind(deps.store);
486
+ if (!remove) return false;
487
+ await remove(cacheKey(kind, id));
488
+ return true;
489
+ };
490
+ return { getIdentityChain, getContentChain, discardCachedChain, callbacks };
443
491
  };
444
492
 
445
493
  // src/revocation.ts
@@ -690,6 +738,7 @@ var createClient = (config) => {
690
738
  return {
691
739
  callbacks,
692
740
  resolve,
741
+ discardCachedChain: resolvers2.discardCachedChain,
693
742
  identity,
694
743
  content,
695
744
  credential,
@@ -721,8 +770,10 @@ var fetchBlob = async (contentId, relays, fetchImpl) => {
721
770
  };
722
771
  var resolvers = (relays) => createClient({ relays }).callbacks();
723
772
  export {
773
+ DivergenceError,
724
774
  createClient,
725
775
  createRevocationChecker,
776
+ divergenceErrorFrom,
726
777
  memoryStore,
727
778
  resolvers
728
779
  };
@@ -1,4 +1,4 @@
1
- import { S as Store } from './types-ByxTj1u-.js';
1
+ import { S as Store } from './types-BfzEg_gw.js';
2
2
 
3
3
  declare const memoryStore: () => Store;
4
4
 
package/dist/siwd.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Signer, VerifiedIdentity, VerifiedSignRequest } from '@metalabel/dfos-protocol/chain';
2
- import { a as Client, V as VerifyResult } from './types-ByxTj1u-.js';
2
+ import { a as Client, V as VerifyResult } from './types-BfzEg_gw.js';
3
3
  import '@metalabel/dfos-protocol/credentials';
4
4
  import '@metalabel/dfos-web-relay/peer-client';
5
5
 
@@ -1,5 +1,5 @@
1
- export { m as memoryStore } from '../memory-CL1DM6Ud.js';
2
- import { S as Store } from '../types-ByxTj1u-.js';
1
+ export { m as memoryStore } from '../memory-BuTsEPZI.js';
2
+ import { S as Store } from '../types-BfzEg_gw.js';
3
3
  import '@metalabel/dfos-protocol/chain';
4
4
  import '@metalabel/dfos-protocol/credentials';
5
5
  import '@metalabel/dfos-web-relay/peer-client';
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  memoryStore
3
- } from "../chunk-63XCFYOE.js";
3
+ } from "../chunk-KY632J74.js";
4
4
 
5
5
  // src/store/indexeddb.ts
6
6
  var STORE_NAME = "dfos-cache";
@@ -40,6 +40,14 @@ var indexedDbStore = (dbName = "dfos-client") => {
40
40
  const db = await open();
41
41
  const store = db.transaction(STORE_NAME, "readwrite").objectStore(STORE_NAME);
42
42
  await promisify(store.put(value, key));
43
+ },
44
+ // IndexedDB's own delete succeeds on a key that is not there, which is
45
+ // exactly the idempotence the caller wants: discarding a chain that was
46
+ // never cached is not a failure, it is already the requested state.
47
+ async delete(key) {
48
+ const db = await open();
49
+ const store = db.transaction(STORE_NAME, "readwrite").objectStore(STORE_NAME);
50
+ await promisify(store.delete(key));
43
51
  }
44
52
  };
45
53
  };
@@ -104,6 +104,13 @@ interface Callbacks {
104
104
  interface Store {
105
105
  get(key: string): Promise<unknown | undefined>;
106
106
  set(key: string, value: unknown): Promise<void>;
107
+ /**
108
+ * Forget one key. Idempotent, and absent keys are not an error. Optional so a
109
+ * store written against the two-method shape still satisfies this interface;
110
+ * a store without it makes `client.discardCachedChain()` answer `false`
111
+ * instead of quietly doing nothing. Both bundled stores implement it.
112
+ */
113
+ delete?(key: string): Promise<void>;
107
114
  }
108
115
  /** A raw JWS operation from a relay log — cid + token, unverified until folded. */
109
116
  interface LogOp {
@@ -275,6 +282,17 @@ interface Client {
275
282
  document(contentId: string, options?: CallOptions): Promise<Resolved<DocumentBlob>>;
276
283
  /** No-throw, self-routing "is this legit". */
277
284
  verify(jws: string, options?: CallOptions): Promise<VerifyResult<unknown>>;
285
+ /**
286
+ * Forget the verified prefix cached for ONE chain, so the next read of it
287
+ * folds cold from genesis. The explicit way out of a `DivergenceError`: the
288
+ * client never discards a pinned prefix on its own, because a contradiction
289
+ * that heals itself is a contradiction nobody sees. Narrow (this chain only),
290
+ * idempotent, and local — no relay is touched and no operation is destroyed.
291
+ *
292
+ * `false` means the configured store exposes no `delete` and nothing was
293
+ * forgotten; both bundled stores return `true`.
294
+ */
295
+ discardCachedChain(kind: 'identity' | 'content', id: string): Promise<boolean>;
278
296
  /** Raw floor. */
279
297
  log(kind: 'identity' | 'content', id: string, options?: CallOptions): Promise<Resolved<LogOp[]>>;
280
298
  globalLog(after?: string, options?: GlobalLogOptions): Promise<GlobalLogResult>;
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@metalabel/dfos-client",
3
- "version": "0.46.0",
3
+ "version": "0.48.0",
4
4
  "type": "module",
5
5
  "description": "DFOS Client — the client-side kit for participating in the protocol: resolve, verify, prove. Fetch, resolve, verify-orchestration and cache over untrusted relays, plus the SIWD and API-AUTH proof surfaces. Holds no keys; all crypto truth comes from @metalabel/dfos-protocol",
6
6
  "license": "MIT",
7
- "author": "Metalabel <hello@metalabel.com> (https://metalabel.com)",
7
+ "author": "DFOS Inc (https://dfos.com)",
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "https://github.com/metalabel/dfos.git",
@@ -47,16 +47,16 @@
47
47
  "README.md"
48
48
  ],
49
49
  "peerDependencies": {
50
- "@metalabel/dfos-protocol": "^0.46.0",
51
- "@metalabel/dfos-web-relay": "^0.46.0"
50
+ "@metalabel/dfos-protocol": "^0.48.0",
51
+ "@metalabel/dfos-web-relay": "^0.48.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@types/node": "^24.10.4",
55
55
  "tsup": "^8.5.1",
56
56
  "typescript": "^5.9.3",
57
57
  "vitest": "^4.1.8",
58
- "@metalabel/dfos-web-relay": "0.46.0",
59
- "@metalabel/dfos-protocol": "0.46.0"
58
+ "@metalabel/dfos-protocol": "0.48.0",
59
+ "@metalabel/dfos-web-relay": "0.48.0"
60
60
  },
61
61
  "scripts": {
62
62
  "build": "tsup",