@metalabel/dfos-client 0.52.1 → 0.54.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
@@ -158,7 +158,11 @@ await verifyApiRequest(client, {
158
158
  });
159
159
  ```
160
160
 
161
- It throws `ApiRequestVerifyError`, carrying `reason` (`invalid` / `unverifiable` / `config`), `phase`, and the recommended `status` — branch on those, never on message text.
161
+ It throws `ApiRequestVerifyError`, carrying `reason` (`invalid` / `replayed` / `uncovered` / `unverifiable` / `config`), `phase`, and the recommended `status` — branch on those, never on message text.
162
+
163
+ The two 403s say different things. `invalid` means the credential does not hold: a broken chain, a revocation, a public audience, an audience that is not the signer. `uncovered` means it holds and does not reach this route's resource and action. A route offering optional authentication serves its anonymous projection on `uncovered`, because a credential only ever adds, and refuses `invalid` outright.
164
+
165
+ **Space-scoped grants, and per-request uniqueness.** A route that serves one space passes `resource: 'api:<host>/spaces/<id>'`, resolving the id by its own routing; a grant naming the bare host covers it by ancestor coverage, and a grant naming one space covers only that space. The host half must match `host`, so a mismatch is a deployment error (500) rather than a verdict about the caller. A route that gates writes passes `requireJti: true` and records the returned `jti` under the presenter DID until the proof expires; the second presentation of the same value is a replay, which `replayedProof` classifies as a 409. On the signing side `createApiAuthFetch` attaches a fresh `jti` to every write by default — pass `jti: 'always'` or `jti: 'never'` to change that.
162
166
 
163
167
  `verifyApiIdentityRequest` is the same verifier for the envelope's credential-less sibling, the [identity proof](https://protocol.dfos.com/integrations#the-identity-proof) — it establishes only which DID is asking, leaving what that DID may do to the resource's own policy.
164
168
 
@@ -179,6 +183,8 @@ import {
179
183
 
180
184
  Sign In With DFOS. The three verbs above are the relying-party login kit, in the order a login uses them: `createSiwdLoginRequest` mints the challenge and builds the `/authorize` URL to redirect to, `readSiwdCallback` parses what comes back, and `verifySiwd` verifies it — mint → redirect, read → verify. The `expect` object `createSiwdLoginRequest` returns (nonce, domain, and the DID when the challenge is bound to one) is what `verifySiwd` checks against, so the relying party MUST persist it across the redirect: a verifier that takes its expectation from the callback has implemented the check and none of the protection. See [`examples/siwd-demo`](../../examples/siwd-demo) for the reference consumer.
181
185
 
186
+ `spaces` on the login request locks consent to a named set of places — `'all'`, or up to 31 distinct 31-character space ids. A host honors it only when `scope` names a space-level action, and refuses the request whole otherwise. Absent, the user chooses the places at the consent screen. Either way the returned credential's `att` is the answer: consent may narrow the set, so read it rather than assuming the ask was honored whole.
187
+
182
188
  The `nonce`/`consumeNonce` pair on the expectation (supply exactly one) maps one field each to the spec's two replay disciplines — which discipline a given scope obliges, and why, is [INTEGRATIONS § Replay prevention](https://protocol.dfos.com/integrations#replay-prevention)'s argument to make:
183
189
 
184
190
  **`expect.nonce` — flow-bound login.** For a backend granting only a browser session (`scope=identity`), source the expected nonce from state you bound to that browser at mint time — a server-side session, or the nonce sealed under your own key in an `httpOnly` cookie — and compare:
@@ -1,9 +1,9 @@
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-BbIEz7BN.js';
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_JTI_BYTES, MAX_PROOF_FRESHNESS_SPAN_SECONDS, MAX_REQUEST_PROOF_SIZE, ProofExtraMembers, REQUEST_PROOF_JWS_TYP, RequestProofFailurePhase, RequestProofFailureReason, RequestProofPayload, SignApiIdentityRequestInput, SignApiRequestInput, apiIdentitySigningInput, apiRequestSigningInput, apiResourceCovers, assertProofVerifierConfig, buildApiAuthHeaders, buildApiIdentityHeaders, generateJti, parseApiResource, parseDfosAuthorization, replayedProof, sha256BodyHash, signApiIdentityRequest, signApiRequest, uncoveredProof } from '@metalabel/dfos-protocol/credentials';
2
+ import { a as Client } from './types-DClhQw33.js';
3
3
  import '@metalabel/dfos-protocol/chain';
4
4
  import '@metalabel/dfos-web-relay/peer-client';
5
5
 
6
- /** The v0 action registry's only token. */
6
+ /** The registry's account-level default action. */
7
7
  declare const DEFAULT_API_ACTION = "read:profile";
8
8
  interface CreateApiAuthFetchOptions {
9
9
  /**
@@ -20,6 +20,13 @@ interface CreateApiAuthFetchOptions {
20
20
  sign: (message: Uint8Array) => Promise<Uint8Array>;
21
21
  /** The underlying transport. Default `globalThis.fetch`. */
22
22
  fetch?: typeof fetch;
23
+ /**
24
+ * When to attach a freshly minted `jti`. Default `'writes'` — every method
25
+ * except GET, HEAD, and OPTIONS — which is what a write-gating host requires.
26
+ * `'always'` covers a host that requires it on reads too; `'never'` is for a
27
+ * host that has no replay cache and would only be paying for the bytes.
28
+ */
29
+ jti?: 'writes' | 'always' | 'never';
23
30
  }
24
31
  /**
25
32
  * A signing `fetch`. Hand it to any API client with a fetch seam and every
@@ -70,8 +77,8 @@ interface VerifyApiRequestInput {
70
77
  * a verifier that compared the proof's `host` against a request header would
71
78
  * have no host binding at all. Include the port when it is not 443.
72
79
  *
73
- * It is also the id half of the `api:<host>` resource string this verifier
74
- * requires, so the request binding and the grant name the same origin.
80
+ * It is also the host half of the `api:` resource this verifier requires, so
81
+ * the request binding and the grant name the same origin.
75
82
  */
76
83
  host: string;
77
84
  /** The received request's method. */
@@ -92,10 +99,26 @@ interface VerifyApiRequestInput {
92
99
  maxBodyBytes?: number;
93
100
  /** The action token this route requires. Default `read:profile`. */
94
101
  action?: string;
102
+ /**
103
+ * The `api:` resource this route demands, which the leaf's attenuation must
104
+ * cover. Default `api:<host>` — the account-level form. A SPACE-ADDRESSED
105
+ * route passes `api:<host>/spaces/<id>`, resolving the id by its own routing;
106
+ * a bare-host grant satisfies it by ancestor coverage.
107
+ *
108
+ * Its host half MUST byte-equal `host`, so the binding and the grant name the
109
+ * same origin. Anything else is a deployment error (500), never a verdict.
110
+ */
111
+ resource?: string;
95
112
  /** Acceptance window `W`, seconds. Default 60. `W + S` MUST NOT exceed 300. */
96
113
  windowSeconds?: number;
97
114
  /** Clock-skew allowance `S`, seconds. Default 60. `W + S` MUST NOT exceed 300. */
98
115
  skewSeconds?: number;
116
+ /**
117
+ * Require the registered `jti` member (401 when absent). A deployment gating
118
+ * WRITES sets it on every write-shaped route and records the returned value in
119
+ * its own replay cache, keyed with the presenter DID.
120
+ */
121
+ requireJti?: boolean;
99
122
  /**
100
123
  * Accept a presenter resolution whose tip could not be verified (cache-only or
101
124
  * empty-delta-against-cache). Default FALSE: key resolution is CURRENT-STATE,
@@ -110,12 +133,18 @@ interface VerifiedRequestProof {
110
133
  subjectDID: string;
111
134
  /** The authority the grant and the binding both name. */
112
135
  host: string;
136
+ /** The `api:` resource the route demanded and the leaf was found to cover. */
137
+ resource: string;
113
138
  /** The action token the leaf's attenuation was found to cover. */
114
139
  action: string;
115
140
  /** The proof's issued-at, unix seconds. */
116
141
  iat: number;
117
142
  /** The leaf credential's CID, re-derived and equal to the proof's member. */
118
143
  credentialCID: string;
144
+ /** The registered `jti`, when the proof carried one — the replay cache's value. */
145
+ jti?: string;
146
+ /** The signing key's roles, when the presenter's state named them. */
147
+ keyRoles?: readonly ('auth' | 'assert' | 'controller')[];
119
148
  }
120
149
  /**
121
150
  * Verify a credential-gated request — INTEGRATIONS.md, Verification algorithm's
@@ -131,7 +160,13 @@ interface VerifiedRequestProof {
131
160
  *
132
161
  * Throws `ApiRequestVerifyError`; branch on `reason`/`phase`/`status`, never on
133
162
  * message text. `status` is the recommended HTTP code (401 proof-invalid, 403
134
- * credential-invalid, 503 unverifiable, 500 config).
163
+ * credential-invalid or uncovered, 503 unverifiable, 500 config).
164
+ *
165
+ * The two 403s are different answers. `invalid` means the credential itself does
166
+ * not hold — a broken chain, a revocation, a public audience, an audience that
167
+ * is not the signer. `uncovered` means it holds and does not reach this route's
168
+ * resource and action; a route offering optional authentication serves its
169
+ * anonymous projection on that verdict rather than refusing.
135
170
  *
136
171
  * Missing issuer dependencies and an unavailable revocation source are
137
172
  * unverifiable (503). The default checker throws when no relay answers; an
@@ -168,6 +203,12 @@ interface VerifyApiIdentityRequestInput {
168
203
  windowSeconds?: number;
169
204
  /** Clock-skew allowance `S`, seconds. Default 60. `W + S` MUST NOT exceed 300. */
170
205
  skewSeconds?: number;
206
+ /**
207
+ * Require the registered `jti` member (401 when absent). A deployment gating
208
+ * WRITES with a bare identity sets it on every write-shaped route — the signer
209
+ * is the principal, and the replay discipline is the same one.
210
+ */
211
+ requireJti?: boolean;
171
212
  /**
172
213
  * Accept a presenter resolution whose tip could not be verified (cache-only or
173
214
  * empty-delta-against-cache). Default FALSE: key resolution is CURRENT-STATE,
@@ -193,10 +234,18 @@ interface VerifiedIdentityProof {
193
234
  iat: number;
194
235
  /**
195
236
  * The DECODED payload, unknown members included — where a caller reads an
196
- * ADDITIVE member (`jti`) the envelope verifier ignored. The signature already
197
- * covers it; the canonical member set stays closed.
237
+ * unregistered additive member the envelope verifier ignored. The signature
238
+ * already covers it; the canonical member set stays closed.
198
239
  */
199
240
  rawPayload: Record<string, unknown>;
241
+ /** The registered `jti`, when the proof carried one — the replay cache's value. */
242
+ jti?: string;
243
+ /**
244
+ * The signing key's roles, when the presenter's state named them. A deployment
245
+ * gating writes with this artifact should refuse a key whose only effective
246
+ * role is `controller`.
247
+ */
248
+ keyRoles?: readonly ('auth' | 'assert' | 'controller')[];
200
249
  }
201
250
  /**
202
251
  * Verify an identity-proven request — INTEGRATIONS.md, Verification algorithm's
package/dist/api-auth.js CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  apiIdentitySigningInput,
5
5
  apiRequestSigningInput,
6
6
  ApiRequestVerifyError,
7
+ apiResourceCovers,
7
8
  assertProofVerifierConfig,
8
9
  buildApiAuthHeaders,
9
10
  buildApiIdentityHeaders,
@@ -13,17 +14,22 @@ import {
13
14
  DEFAULT_PROOF_WINDOW_SECONDS,
14
15
  DFOS_AUTH_SCHEME,
15
16
  EMPTY_BODY_SHA256,
17
+ generateJti,
16
18
  IDENTITY_PROOF_JWS_TYP,
17
19
  matchesResource,
18
20
  MAX_BODY_BYTES,
19
21
  MAX_CREDENTIAL_SIZE,
22
+ MAX_JTI_BYTES,
20
23
  MAX_PROOF_FRESHNESS_SPAN_SECONDS,
21
24
  MAX_REQUEST_PROOF_SIZE,
25
+ parseApiResource,
22
26
  parseDfosAuthorization,
27
+ replayedProof,
23
28
  REQUEST_PROOF_JWS_TYP,
24
29
  sha256BodyHash,
25
30
  signApiIdentityRequest,
26
31
  signApiRequest,
32
+ uncoveredProof,
27
33
  verifyDelegationChain,
28
34
  verifyDFOSCredential,
29
35
  verifyIdentityProofEnvelope,
@@ -31,6 +37,7 @@ import {
31
37
  } from "@metalabel/dfos-protocol/credentials";
32
38
  import { decodeJwsUnsafe } from "@metalabel/dfos-protocol/crypto";
33
39
  var DEFAULT_API_ACTION = "read:profile";
40
+ var SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
34
41
  var MAX_DELEGATION_DEPTH = 16;
35
42
  var credentialCIDFromHeader = (credential) => {
36
43
  const decoded = decodeJwsUnsafe(credential);
@@ -45,6 +52,7 @@ var LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::
45
52
  var createApiAuthFetch = (options) => {
46
53
  const credentialCID = credentialCIDFromHeader(options.credential);
47
54
  const send = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
55
+ const jtiMode = options.jti ?? "writes";
48
56
  return async (input, init) => {
49
57
  const request = init === void 0 && input instanceof Request ? input : new Request(input, init);
50
58
  const url = new URL(request.url);
@@ -53,6 +61,7 @@ var createApiAuthFetch = (options) => {
53
61
  `refusing to sign a ${url.protocol}// request to ${url.host}: api: surfaces are HTTPS surfaces, and a proof sent in the clear replays for its whole freshness window (plaintext is allowed only to localhost, 127.0.0.1, and [::1])`
54
62
  );
55
63
  }
64
+ const attachJti = jtiMode === "always" || jtiMode === "writes" && !SAFE_METHODS.has(request.method);
56
65
  const { proof } = await signApiRequest({
57
66
  method: request.method,
58
67
  // `host`, never `hostname`: the authority carries the port when there is
@@ -69,7 +78,8 @@ var createApiAuthFetch = (options) => {
69
78
  body: new Uint8Array(await request.clone().arrayBuffer()),
70
79
  credentialCID,
71
80
  kid: options.kid,
72
- sign: options.sign
81
+ sign: options.sign,
82
+ ...attachJti ? { jti: generateJti() } : {}
73
83
  });
74
84
  const headers = new Headers(request.headers);
75
85
  for (const [name, value] of Object.entries(
@@ -94,10 +104,24 @@ var clientPresenterResolver = (client, allowStale) => async (did) => {
94
104
  );
95
105
  }
96
106
  const state = resolved.value;
97
- return {
98
- isDeleted: state.isDeleted,
99
- keys: [...state.authKeys, ...state.assertKeys, ...state.controllerKeys]
100
- };
107
+ const byId = /* @__PURE__ */ new Map();
108
+ for (const [role, keys] of [
109
+ ["auth", state.authKeys],
110
+ ["assert", state.assertKeys],
111
+ ["controller", state.controllerKeys]
112
+ ]) {
113
+ for (const key of keys) {
114
+ const seen = byId.get(key.id);
115
+ if (seen) seen.roles.push(role);
116
+ else
117
+ byId.set(key.id, {
118
+ id: key.id,
119
+ publicKeyMultibase: key.publicKeyMultibase,
120
+ roles: [role]
121
+ });
122
+ }
123
+ }
124
+ return { isDeleted: state.isDeleted, keys: [...byId.values()] };
101
125
  };
102
126
  var discoverChainRoot = (leafToken) => {
103
127
  let token = leafToken;
@@ -118,12 +142,24 @@ var verifyApiRequest = async (client, input) => {
118
142
  if (action.split(",").every((token) => token.trim() === "")) {
119
143
  throw misconfigured("required action must name a non-empty token");
120
144
  }
145
+ const resource = input.resource ?? `api:${input.host}`;
146
+ const parsedResource = parseApiResource(resource);
147
+ if (!parsedResource) {
148
+ throw misconfigured(
149
+ `required resource ${resource} is not a well-formed api: resource (api:<host> or api:<host>/spaces/<id>)`
150
+ );
151
+ }
152
+ if (parsedResource.host !== input.host) {
153
+ throw misconfigured(
154
+ `required resource ${resource} names a different authority than the verifier's host ${input.host}`
155
+ );
156
+ }
121
157
  if (input.credential.length > MAX_CREDENTIAL_SIZE) {
122
158
  throw invalidProof(
123
159
  `credential exceeds max size: ${input.credential.length} > ${MAX_CREDENTIAL_SIZE}`
124
160
  );
125
161
  }
126
- const { payload, presenterDID, now } = await verifyRequestProofEnvelope(
162
+ const { payload, presenterDID, now, keyRoles } = await verifyRequestProofEnvelope(
127
163
  input,
128
164
  clientPresenterResolver(client, input.allowStale === true)
129
165
  );
@@ -167,23 +203,34 @@ var verifyApiRequest = async (client, input) => {
167
203
  if (leaf.aud !== presenterDID) {
168
204
  throw invalidCredential("credential audience does not match the request proof signing key");
169
205
  }
170
- if (!await matchesResource(leaf.att, `api:${input.host}`, action)) {
171
- throw invalidCredential(`credential does not cover ${action} on api:${input.host}`);
206
+ if (!await matchesResource(leaf.att, resource, action)) {
207
+ throw uncoveredProof(`credential does not cover ${action} on ${resource}`);
172
208
  }
173
209
  return {
174
210
  subjectDID: rootDID,
175
211
  host: input.host,
212
+ resource,
176
213
  action,
177
214
  iat: payload.iat,
178
- credentialCID: leaf.credentialCID
215
+ credentialCID: leaf.credentialCID,
216
+ ...payload.jti !== void 0 ? { jti: payload.jti } : {},
217
+ ...keyRoles !== void 0 ? { keyRoles } : {}
179
218
  };
180
219
  };
181
220
  var verifyApiIdentityRequest = async (client, input) => {
182
- const { payload, rawPayload, presenterDID, kid } = await verifyIdentityProofEnvelope(
221
+ const { payload, rawPayload, presenterDID, kid, keyRoles } = await verifyIdentityProofEnvelope(
183
222
  input,
184
223
  clientPresenterResolver(client, input.allowStale === true)
185
224
  );
186
- return { presenterDID, kid, host: input.host, iat: payload.iat, rawPayload };
225
+ return {
226
+ presenterDID,
227
+ kid,
228
+ host: input.host,
229
+ iat: payload.iat,
230
+ rawPayload,
231
+ ...payload.jti !== void 0 ? { jti: payload.jti } : {},
232
+ ...keyRoles !== void 0 ? { keyRoles } : {}
233
+ };
187
234
  };
188
235
  export {
189
236
  ApiRequestVerifyError,
@@ -194,19 +241,25 @@ export {
194
241
  EMPTY_BODY_SHA256,
195
242
  IDENTITY_PROOF_JWS_TYP,
196
243
  MAX_BODY_BYTES,
244
+ MAX_JTI_BYTES,
197
245
  MAX_PROOF_FRESHNESS_SPAN_SECONDS,
198
246
  MAX_REQUEST_PROOF_SIZE,
199
247
  REQUEST_PROOF_JWS_TYP,
200
248
  apiIdentitySigningInput,
201
249
  apiRequestSigningInput,
250
+ apiResourceCovers,
202
251
  assertProofVerifierConfig,
203
252
  buildApiAuthHeaders,
204
253
  buildApiIdentityHeaders,
205
254
  createApiAuthFetch,
255
+ generateJti,
256
+ parseApiResource,
206
257
  parseDfosAuthorization,
258
+ replayedProof,
207
259
  sha256BodyHash,
208
260
  signApiIdentityRequest,
209
261
  signApiRequest,
262
+ uncoveredProof,
210
263
  verifyApiIdentityRequest,
211
264
  verifyApiRequest
212
265
  };
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-BbIEz7BN.js';
2
- export { c as CallOptions, D as DocumentBlob, E as EffectiveIdentity, d as EverProvedIdentity, G as GlobalLogOptions, e as GlobalLogPage, f as GlobalLogResult, I as IndexCapabilities, g as IndexContentPage, h as IndexContentRow, i as IndexCountersignatureRow, j as IndexCountersignaturesPage, k as IndexCredentialRow, l as IndexCredentialsPage, m as IndexIdentitiesPage, n as IndexIdentityProfile, o as IndexIdentityRow, p as IndexOrder, q as IndexRecencyOrder, L as LogOp, P as Provenance, r as RelayHealth, s as RelayResponse, t as Resolution, u as Resolved, v as ResolvedContent, w as ResolvedCredential, S as Store, T as Trust, U as UnverifiableAxis, V as VerifyResult } from './types-BbIEz7BN.js';
3
- export { m as memoryStore } from './memory-B5WMnEUQ.js';
1
+ import { C as ClientConfig, a as Client, b as Callbacks, R as RevChecker } from './types-DClhQw33.js';
2
+ export { c as CallOptions, D as DocumentBlob, E as EffectiveIdentity, d as EverProvedIdentity, G as GlobalLogOptions, e as GlobalLogPage, f as GlobalLogResult, I as IndexCapabilities, g as IndexContentPage, h as IndexContentRow, i as IndexCountersignatureRow, j as IndexCountersignaturesPage, k as IndexCredentialRow, l as IndexCredentialsPage, m as IndexIdentitiesPage, n as IndexIdentityProfile, o as IndexIdentityRow, p as IndexOrder, q as IndexRecencyOrder, L as LogOp, P as Provenance, r as RelayHealth, s as RelayResponse, t as Resolution, u as Resolved, v as ResolvedContent, w as ResolvedCredential, S as Store, T as Trust, U as UnverifiableAxis, V as VerifyResult } from './types-DClhQw33.js';
3
+ export { m as memoryStore } from './memory-aYqSas8m.js';
4
4
  import '@metalabel/dfos-protocol/chain';
5
5
  import '@metalabel/dfos-protocol/credentials';
6
6
  import '@metalabel/dfos-web-relay/peer-client';
@@ -65,7 +65,9 @@ declare const divergenceErrorFrom: (err: unknown) => DivergenceError | undefined
65
65
  * payload binds exactly the queried (issuerDID, credentialCID). Anything less —
66
66
  * unreachable relay, negative answer, forged or mismatched proof — moves on to
67
67
  * the next relay; false only after the full set has been consulted and at least
68
- * one relay answered with a parseable status body. Zero answers throws.
68
+ * one relay answered with a parseable status body. Zero answers throws, and so
69
+ * does a positive answer whose signing key this client could not resolve: a
70
+ * dependency failure is never a negative status.
69
71
  *
70
72
  * When the caller supplies `asOfUnix` (the protocol does, on every cold fold, with
71
73
  * each operation's own `createdAt`), a verified revocation only counts if its own
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ import { dagCborCanonicalEncode, decodeJwsUnsafe as decodeJwsUnsafe2 } from "@me
12
12
  import { createHttpPeerClient } from "@metalabel/dfos-web-relay/peer-client";
13
13
 
14
14
  // src/transport.ts
15
+ import { isDependencyMissing, markDependencyMissing } from "@metalabel/dfos-protocol";
15
16
  var PAGE_LIMIT = 1e3;
16
17
  var MAX_PAGES = 1e4;
17
18
  var normalizeRelays = (relays) => {
@@ -62,6 +63,14 @@ var fanOutLog = async (fetchPage, relays, quorum, verifyCandidate) => {
62
63
  const byDigest = /* @__PURE__ */ new Map();
63
64
  const badDigests = /* @__PURE__ */ new Set();
64
65
  let lastVerifyError;
66
+ let lastDependencyError;
67
+ const recordFailure = (err, digest) => {
68
+ if (!(err instanceof StaleAnswerError)) {
69
+ if (isDependencyMissing(err)) lastDependencyError = err;
70
+ else lastVerifyError = err;
71
+ }
72
+ badDigests.add(digest);
73
+ };
65
74
  for (const url of relays) {
66
75
  let entries = null;
67
76
  try {
@@ -89,8 +98,7 @@ var fanOutLog = async (fetchPage, relays, quorum, verifyCandidate) => {
89
98
  provenance: { answeredBy: group.url, responses, agreed: true, fromCache: false }
90
99
  };
91
100
  } catch (err) {
92
- if (!(err instanceof StaleAnswerError)) lastVerifyError = err;
93
- badDigests.add(digest);
101
+ recordFailure(err, digest);
94
102
  }
95
103
  }
96
104
  }
@@ -105,8 +113,7 @@ var fanOutLog = async (fetchPage, relays, quorum, verifyCandidate) => {
105
113
  provenance: { answeredBy: group.url, responses, agreed: false, fromCache: false }
106
114
  };
107
115
  } catch (err) {
108
- if (!(err instanceof StaleAnswerError)) lastVerifyError = err;
109
- badDigests.add(digest);
116
+ recordFailure(err, digest);
110
117
  }
111
118
  }
112
119
  if (lastVerifyError !== void 0) {
@@ -115,6 +122,14 @@ var fanOutLog = async (fetchPage, relays, quorum, verifyCandidate) => {
115
122
  cause: lastVerifyError
116
123
  });
117
124
  }
125
+ if (lastDependencyError !== void 0) {
126
+ const message = lastDependencyError instanceof Error ? lastDependencyError.message : "unknown error";
127
+ throw markDependencyMissing(
128
+ new Error(`candidate log verification could not complete: ${message}`, {
129
+ cause: lastDependencyError
130
+ })
131
+ );
132
+ }
118
133
  return {
119
134
  outcome: "unreachable",
120
135
  entries: [],
@@ -234,11 +249,13 @@ var createIndexQueries = (relays, fetchImpl) => {
234
249
  // src/resolvers.ts
235
250
  import {
236
251
  decodeMultikey,
252
+ IdentityStateNoSeenKeysError,
237
253
  verifyContentChain,
238
254
  verifyContentExtensionFromTrustedState,
239
255
  verifyIdentityChain,
240
256
  verifyIdentityExtensionFromTrustedState
241
257
  } from "@metalabel/dfos-protocol/chain";
258
+ import { CredentialVerificationError } from "@metalabel/dfos-protocol/credentials";
242
259
  import { decodeJwsUnsafe } from "@metalabel/dfos-protocol/crypto";
243
260
  var DID_PREFIX = "did:dfos";
244
261
  var cacheKey = (kind, id) => `${kind}:${id}`;
@@ -292,6 +309,8 @@ var keyBytesFor = (state, keyId) => {
292
309
  );
293
310
  return key ? decodeMultikey(key.publicKeyMultibase).keyBytes : null;
294
311
  };
312
+ var NoStateAsOfError = class extends CredentialVerificationError {
313
+ };
295
314
  var createResolvers = (deps) => {
296
315
  const relaysFor = (o) => normalizeRelays(o?.relays ?? deps.relays);
297
316
  const getIdentityChain = async (did, options) => {
@@ -323,17 +342,28 @@ var createResolvers = (deps) => {
323
342
  let headCID = cached.headCID;
324
343
  let lastCreatedAt = cached.lastCreatedAt;
325
344
  const log = [...cached.log];
326
- for (const entry of entries.slice(cached.log.length)) {
327
- const r = await verifyIdentityExtensionFromTrustedState({
328
- currentState: state,
329
- headCID,
330
- lastCreatedAt,
331
- newOp: entry.jwsToken
332
- });
333
- state = r.state;
334
- headCID = r.operationCID;
335
- lastCreatedAt = r.createdAt;
336
- log.push(entry.jwsToken);
345
+ try {
346
+ for (const entry of entries.slice(cached.log.length)) {
347
+ const r = await verifyIdentityExtensionFromTrustedState({
348
+ currentState: state,
349
+ headCID,
350
+ lastCreatedAt,
351
+ newOp: entry.jwsToken
352
+ });
353
+ state = r.state;
354
+ headCID = r.operationCID;
355
+ lastCreatedAt = r.createdAt;
356
+ log.push(entry.jwsToken);
357
+ }
358
+ } catch (e) {
359
+ if (!(e instanceof IdentityStateNoSeenKeysError)) throw e;
360
+ const full = entries.map((entry) => entry.jwsToken);
361
+ const replayed = await verifyIdentityChain({ didPrefix: DID_PREFIX, log: full });
362
+ if (replayed.did !== did) {
363
+ throw new Error(`relay served a mismatched identity: asked ${did}, got ${replayed.did}`);
364
+ }
365
+ const last = opMeta(full[full.length - 1]);
366
+ return { state: replayed, log: full, headCID: last.cid, lastCreatedAt: last.createdAt };
337
367
  }
338
368
  return { state, log, headCID, lastCreatedAt };
339
369
  };
@@ -375,13 +405,18 @@ var createResolvers = (deps) => {
375
405
  const { state, log } = resolution;
376
406
  const last = log[log.length - 1];
377
407
  if (basis === void 0 || last === void 0 || opMeta(last).createdAt <= basis) return state;
408
+ const first = log[0];
409
+ if (first !== void 0 && basis < opMeta(first).createdAt) {
410
+ throw new NoStateAsOfError(`identity has no state as of ${basis}`);
411
+ }
378
412
  const asOf = await verifyIdentityChain({ didPrefix: DID_PREFIX, log, asOf: basis });
379
- return { ...asOf, isDeleted: state.isDeleted };
413
+ return { ...asOf, isDeleted: state.isDeleted, basisDeterminate: true };
380
414
  };
381
415
  const resolveIdentity = async (did, basis) => {
382
416
  try {
383
417
  return await stateAsOf(await getIdentityChain(did), basis);
384
- } catch {
418
+ } catch (err) {
419
+ if (err instanceof NoStateAsOfError) throw err;
385
420
  return void 0;
386
421
  }
387
422
  };
@@ -506,12 +541,29 @@ var createResolvers = (deps) => {
506
541
  };
507
542
 
508
543
  // src/revocation.ts
544
+ import { isDependencyMissing as isDependencyMissing2 } from "@metalabel/dfos-protocol";
509
545
  import { parseProtocolTimestampUnix, verifyRevocation } from "@metalabel/dfos-protocol/chain";
546
+ import { CredentialVerificationError as CredentialVerificationError2 } from "@metalabel/dfos-protocol/credentials";
510
547
  import { REVOCATIONS_BASE_PATH } from "@metalabel/dfos-web-relay/peer-client";
548
+ var unavailable = (why) => new Error(`revocation status unavailable: ${why}`);
549
+ var KeyUnresolvableError = class extends Error {
550
+ };
551
+ var isResolverVerdict = (err) => err instanceof CredentialVerificationError2 && !isDependencyMissing2(err);
511
552
  var createRevocationChecker = (relays, fetchImpl, resolveKey) => {
512
553
  const relaySet = normalizeRelays(relays);
554
+ const guardedResolveKey = async (kid, basis) => {
555
+ try {
556
+ return await resolveKey(kid, basis);
557
+ } catch (err) {
558
+ if (isResolverVerdict(err)) throw err;
559
+ throw new KeyUnresolvableError(`could not resolve the revocation signing key ${kid}`, {
560
+ cause: err
561
+ });
562
+ }
563
+ };
513
564
  return async (issuerDID, credentialCID, asOfUnix) => {
514
565
  let answered = false;
566
+ let unresolvable;
515
567
  for (const url of relaySet) {
516
568
  let body = null;
517
569
  try {
@@ -532,17 +584,22 @@ var createRevocationChecker = (relays, fetchImpl, resolveKey) => {
532
584
  }
533
585
  if (!body?.revoked || !body.revocation) continue;
534
586
  try {
535
- const verified = await verifyRevocation({ jwsToken: body.revocation, resolveKey });
587
+ const verified = await verifyRevocation({
588
+ jwsToken: body.revocation,
589
+ resolveKey: guardedResolveKey
590
+ });
536
591
  if (verified.did === issuerDID && verified.credentialCID === credentialCID) {
537
592
  if (asOfUnix === void 0 || asOfUnix <= 0) return true;
538
593
  const revokedAtUnix = parseProtocolTimestampUnix(verified.createdAt);
539
594
  if (revokedAtUnix === null || revokedAtUnix <= asOfUnix) return true;
540
595
  continue;
541
596
  }
542
- } catch {
597
+ } catch (err) {
598
+ if (err instanceof KeyUnresolvableError) unresolvable = err;
543
599
  }
544
600
  }
545
- if (!answered) throw new Error("revocation status unavailable: no relay answered");
601
+ if (!answered) throw unavailable("no relay answered");
602
+ if (unresolvable) throw unavailable(unresolvable.message);
546
603
  return false;
547
604
  };
548
605
  };
@@ -1,4 +1,4 @@
1
- import { S as Store } from './types-BbIEz7BN.js';
1
+ import { S as Store } from './types-DClhQw33.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-BbIEz7BN.js';
2
+ import { a as Client, V as VerifyResult } from './types-DClhQw33.js';
3
3
  import '@metalabel/dfos-protocol/credentials';
4
4
  import '@metalabel/dfos-web-relay/peer-client';
5
5
 
@@ -96,6 +96,16 @@ interface SiwdLoginRequestInput {
96
96
  * would describe something other than what was asked for.
97
97
  */
98
98
  scope: string;
99
+ /**
100
+ * Lock consent to a named set of places: `all`, or up to 31 distinct 31-char
101
+ * space ids. Absent, the user chooses at the consent screen.
102
+ *
103
+ * A host honors it only when `scope` names a space-level action, and refuses
104
+ * the request whole otherwise. The returned credential's `att` is the answer —
105
+ * consent MAY narrow the set, so read it rather than assuming the ask was
106
+ * honored whole.
107
+ */
108
+ spaces?: readonly string[] | 'all';
99
109
  /** Consent-screen prose. A host MAY decline to render it; see specs/INTEGRATIONS.md. */
100
110
  statement?: string;
101
111
  /**
@@ -177,8 +187,8 @@ interface SiwdLoginRequest {
177
187
  * client identity the tier is open and every scope is available.
178
188
  *
179
189
  * It also owns the WIRE PARAM NAMES (`challenge`, `redirect_uri`, `scope`,
180
- * `client_did`, and — via `createSiwdLoopbackLoginRequest` — `client_proof` and
181
- * `client_chain`) as their single source in this package. They are snake_case
190
+ * `spaces`, `client_did`, and — via `createSiwdLoopbackLoginRequest` —
191
+ * `client_proof` and `client_chain`) as their single source in this package. They are snake_case
182
192
  * on the wire and camelCase everywhere else, which is exactly the kind of seam
183
193
  * every hand-rolled RP re-implements and eventually gets wrong.
184
194
  *
@@ -329,6 +339,13 @@ interface SiwdLoopbackLoginRequestInput {
329
339
  * a credential to.
330
340
  */
331
341
  scope: string;
342
+ /**
343
+ * Lock consent to a named set of places: `all`, or up to 31 distinct 31-char
344
+ * space ids. Same rule as `SiwdLoginRequestInput.spaces` — the host honors it
345
+ * only when `scope` names a space-level action, and refuses the request whole
346
+ * otherwise.
347
+ */
348
+ spaces?: readonly string[] | 'all';
332
349
  /** Consent-screen prose. A host MAY decline to render it; see specs/INTEGRATIONS.md. */
333
350
  statement?: string;
334
351
  /** Bind the challenge to ONE subject DID (sign in as this DID or not at all). */
package/dist/siwd.js CHANGED
@@ -122,6 +122,18 @@ var bareHostname = (url) => {
122
122
  const host = url.hostname.toLowerCase();
123
123
  return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
124
124
  };
125
+ var SIWD_SPACE_ID_RE = /^[2346789acdefhknrtvz]{31}$/;
126
+ var MAX_SIWD_SPACES = 31;
127
+ var siwdSpacesParam = (spaces) => {
128
+ if (spaces === "all") return "all";
129
+ const malformed = !Array.isArray(spaces) || spaces.length === 0 || spaces.length > MAX_SIWD_SPACES || spaces.some((id) => typeof id !== "string" || !SIWD_SPACE_ID_RE.test(id)) || new Set(spaces).size !== spaces.length;
130
+ if (malformed) {
131
+ throw new Error(
132
+ "invalid SIWD login request: spaces must be 'all' or distinct 31-character space ids"
133
+ );
134
+ }
135
+ return spaces.join(",");
136
+ };
125
137
  var parseUrlOrThrow = (value, field) => {
126
138
  try {
127
139
  return new URL(value);
@@ -138,6 +150,7 @@ var createSiwdLoginRequest = (input) => {
138
150
  "invalid SIWD login request: loopback redirects support scope=identity only without a client identity \u2014 a credential scope needs a client_did proven under the loopback credential tier (specs/INTEGRATIONS.md \xA7Loopback clients)"
139
151
  );
140
152
  }
153
+ const spaces = input.spaces !== void 0 ? siwdSpacesParam(input.spaces) : void 0;
141
154
  const { challenge, encoded, nonce } = createSiwdChallenge({
142
155
  domain: input.domain,
143
156
  ...input.statement !== void 0 ? { statement: input.statement } : {},
@@ -148,6 +161,9 @@ var createSiwdLoginRequest = (input) => {
148
161
  url.searchParams.set("challenge", encoded);
149
162
  url.searchParams.set("redirect_uri", input.redirectUri);
150
163
  url.searchParams.set("scope", input.scope);
164
+ if (spaces !== void 0) {
165
+ url.searchParams.set("spaces", spaces);
166
+ }
151
167
  if (input.clientDid !== void 0) {
152
168
  url.searchParams.set("client_did", input.clientDid);
153
169
  }
@@ -263,6 +279,7 @@ var createSiwdLoopbackLoginRequest = async (input) => {
263
279
  redirectUri: input.redirectUri,
264
280
  scope: input.scope,
265
281
  clientDid: input.client.did,
282
+ ...input.spaces !== void 0 ? { spaces: input.spaces } : {},
266
283
  ...input.statement !== void 0 ? { statement: input.statement } : {},
267
284
  ...input.did !== void 0 ? { did: input.did } : {},
268
285
  ...input.nonce !== void 0 ? { nonce: input.nonce } : {}
@@ -1,5 +1,5 @@
1
- export { m as memoryStore } from '../memory-B5WMnEUQ.js';
2
- import { S as Store } from '../types-BbIEz7BN.js';
1
+ export { m as memoryStore } from '../memory-aYqSas8m.js';
2
+ import { S as Store } from '../types-DClhQw33.js';
3
3
  import '@metalabel/dfos-protocol/chain';
4
4
  import '@metalabel/dfos-protocol/credentials';
5
5
  import '@metalabel/dfos-web-relay/peer-client';
@@ -5,6 +5,12 @@ import { PeerClient } from '@metalabel/dfos-web-relay/peer-client';
5
5
  /** Head/as-of state; excludes the credit-claim projection. */
6
6
  type EffectiveIdentity = VerifiedIdentity & {
7
7
  readonly resolution?: 'effective';
8
+ /**
9
+ * True when no operation dated at or before the basis can still arrive and
10
+ * change this key state — the protocol's `ResolvedIdentity.basisDeterminate`,
11
+ * which decides whether a missing key is a verdict or a retryable miss.
12
+ */
13
+ basisDeterminate?: boolean;
8
14
  };
9
15
  /** Every key ever proved, including memberships absent from effective state. */
10
16
  type EverProvedIdentity = VerifiedIdentity & {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metalabel/dfos-client",
3
- "version": "0.52.1",
3
+ "version": "0.54.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",
@@ -47,16 +47,16 @@
47
47
  "README.md"
48
48
  ],
49
49
  "peerDependencies": {
50
- "@metalabel/dfos-protocol": "^0.52.1",
51
- "@metalabel/dfos-web-relay": "^0.52.1"
50
+ "@metalabel/dfos-protocol": "^0.54.0",
51
+ "@metalabel/dfos-web-relay": "^0.54.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-protocol": "0.52.1",
59
- "@metalabel/dfos-web-relay": "0.52.1"
58
+ "@metalabel/dfos-protocol": "0.54.0",
59
+ "@metalabel/dfos-web-relay": "0.54.0"
60
60
  },
61
61
  "scripts": {
62
62
  "build": "tsup",