@metalabel/dfos-client 0.53.0 → 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';
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
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/siwd.d.ts CHANGED
@@ -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 } : {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metalabel/dfos-client",
3
- "version": "0.53.0",
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.53.0",
51
- "@metalabel/dfos-web-relay": "^0.53.0"
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.53.0",
59
- "@metalabel/dfos-web-relay": "0.53.0"
58
+ "@metalabel/dfos-protocol": "0.54.0",
59
+ "@metalabel/dfos-web-relay": "0.54.0"
60
60
  },
61
61
  "scripts": {
62
62
  "build": "tsup",