@shelbyb/daywalker-sdk 0.3.0 → 0.4.1

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.
Files changed (54) hide show
  1. package/README.md +64 -23
  2. package/dist/analytics/index.d.ts +16 -2
  3. package/dist/analytics/index.js +79 -14
  4. package/dist/cli.js +133 -31
  5. package/dist/client.d.ts +29 -2
  6. package/dist/client.js +314 -62
  7. package/dist/config.d.ts +8 -2
  8. package/dist/config.js +14 -0
  9. package/dist/crypto/encoding.d.ts +5 -0
  10. package/dist/crypto/encoding.js +14 -0
  11. package/dist/crypto/index.d.ts +3 -3
  12. package/dist/crypto/index.js +2 -2
  13. package/dist/crypto/keys.d.ts +3 -3
  14. package/dist/crypto/keys.js +28 -7
  15. package/dist/crypto/signatures.d.ts +42 -12
  16. package/dist/crypto/signatures.js +236 -79
  17. package/dist/express.d.ts +1 -0
  18. package/dist/express.js +8 -6
  19. package/dist/flags/client.d.ts +32 -4
  20. package/dist/flags/client.js +74 -12
  21. package/dist/flags/common/events.d.ts +9 -0
  22. package/dist/flags/common/events.js +51 -6
  23. package/dist/flags/common/index.d.ts +3 -3
  24. package/dist/flags/common/index.js +3 -3
  25. package/dist/flags/common/sse.d.ts +24 -3
  26. package/dist/flags/common/sse.js +85 -15
  27. package/dist/flags/common/version.d.ts +2 -0
  28. package/dist/flags/common/version.js +2 -0
  29. package/dist/flags/engine/index.d.ts +1 -1
  30. package/dist/flags/engine/index.js +1 -1
  31. package/dist/flags/engine/operators.d.ts +20 -3
  32. package/dist/flags/engine/operators.js +38 -32
  33. package/dist/flags/node.d.ts +26 -14
  34. package/dist/flags/node.js +51 -7
  35. package/dist/flags/react.d.ts +6 -4
  36. package/dist/flags/react.js +47 -17
  37. package/dist/index.d.ts +7 -6
  38. package/dist/index.js +4 -4
  39. package/dist/middleware.d.ts +18 -0
  40. package/dist/middleware.js +28 -7
  41. package/dist/next.d.ts +52 -8
  42. package/dist/next.js +151 -37
  43. package/dist/openfeature/server.d.ts +3 -0
  44. package/dist/openfeature/server.js +14 -2
  45. package/dist/openfeature/shared.d.ts +1 -1
  46. package/dist/openfeature/shared.js +22 -2
  47. package/dist/openfeature/web.js +9 -3
  48. package/dist/types.d.ts +56 -4
  49. package/dist/types.js +6 -0
  50. package/docs/analytics.md +1 -1
  51. package/docs/flags-node.md +1 -1
  52. package/docs/flags.md +2 -1
  53. package/package.json +32 -14
  54. package/assets/logo-512.png +0 -0
package/dist/client.js CHANGED
@@ -25,10 +25,156 @@ function generateNonce() {
25
25
  }
26
26
  /** Validation results kept in memory; the least recently used are evicted past this. */
27
27
  export const MAX_VALIDATION_CACHE_ENTRIES = 1000;
28
+ /** Longest 429 `Retry-After` waited out by default (`retry.maxRetryAfterMs`); longer ones return the 429. */
29
+ export const MAX_RETRY_AFTER_MS = 60_000;
30
+ /** Methods retried after a network error or timeout by default: repeating them can't change server state. */
31
+ const IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
32
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
33
+ function isPlainJsonValue(value) {
34
+ if (Array.isArray(value))
35
+ return true;
36
+ const proto = Object.getPrototypeOf(value);
37
+ return proto === Object.prototype || proto === null;
38
+ }
39
+ async function prepareBody(raw, signing) {
40
+ if (raw === undefined || raw === null)
41
+ return { bytes: null, send: null, replayable: true };
42
+ if (typeof raw === "string")
43
+ return { bytes: raw, send: raw, replayable: true };
44
+ if (raw instanceof Uint8Array)
45
+ return { bytes: raw, send: raw, replayable: true };
46
+ if (raw instanceof ArrayBuffer || ArrayBuffer.isView(raw)) {
47
+ const bytes = raw instanceof ArrayBuffer ? new Uint8Array(raw) : new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength);
48
+ return { bytes, send: bytes, replayable: true };
49
+ }
50
+ if (raw instanceof URLSearchParams) {
51
+ const str = raw.toString();
52
+ return { bytes: str, send: str, contentType: "application/x-www-form-urlencoded;charset=UTF-8", replayable: true };
53
+ }
54
+ if (typeof Blob !== "undefined" && raw instanceof Blob) {
55
+ if (!signing)
56
+ return { bytes: null, send: raw, replayable: true };
57
+ const bytes = new Uint8Array(await raw.arrayBuffer());
58
+ return { bytes, send: bytes, contentType: raw.type || undefined, replayable: true };
59
+ }
60
+ const isFormData = typeof FormData !== "undefined" && raw instanceof FormData;
61
+ const isStream = typeof ReadableStream !== "undefined" && raw instanceof ReadableStream;
62
+ if (isFormData || isStream) {
63
+ if (signing) {
64
+ throw new DaywalkerError(`${isFormData ? "FormData" : "ReadableStream"} bodies can't be signed: their exact bytes aren't known up front, so no Content-Digest can be computed. Serialize the body to a string or Uint8Array first.`);
65
+ }
66
+ return { bytes: null, send: raw, replayable: !isStream };
67
+ }
68
+ if (typeof raw === "object" && isPlainJsonValue(raw)) {
69
+ const json = JSON.stringify(raw);
70
+ return { bytes: json, send: json, contentType: "application/json", replayable: true };
71
+ }
72
+ throw new DaywalkerError(`Unsupported fetch() body (${Object.prototype.toString.call(raw)}): pass a plain object or array (sent as JSON), a string, Uint8Array, ArrayBuffer, URLSearchParams or Blob`);
73
+ }
74
+ /** Resolves after `ms`, or rejects with the signal's reason as soon as it aborts. */
75
+ function sleep(ms, signal) {
76
+ return new Promise((resolve, reject) => {
77
+ if (signal?.aborted)
78
+ return reject(signal.reason);
79
+ const onAbort = () => {
80
+ clearTimeout(timer);
81
+ reject(signal.reason);
82
+ };
83
+ const timer = setTimeout(() => {
84
+ signal?.removeEventListener("abort", onAbort);
85
+ resolve();
86
+ }, ms);
87
+ signal?.addEventListener("abort", onAbort, { once: true });
88
+ });
89
+ }
90
+ /**
91
+ * The signal for one attempt: the caller's signal combined with a `timeoutMs` timer, via `AbortSignal.any`
92
+ * (Node >= 20.3) or a manual fallback. `cleanup()` clears the timer once response headers arrive.
93
+ */
94
+ function attemptSignal(caller, timeoutMs) {
95
+ const signals = caller ? [caller] : [];
96
+ let timer;
97
+ if (timeoutMs > 0) {
98
+ const timeout = new AbortController();
99
+ timer = setTimeout(() => timeout.abort(new DOMException(`Request to Daywalker Auth timed out after ${timeoutMs}ms`, "TimeoutError")), timeoutMs);
100
+ signals.push(timeout.signal);
101
+ }
102
+ const clear = () => clearTimeout(timer);
103
+ if (signals.length <= 1)
104
+ return { signal: signals[0], cleanup: clear };
105
+ if (typeof AbortSignal.any === "function")
106
+ return { signal: AbortSignal.any(signals), cleanup: clear };
107
+ const combined = new AbortController();
108
+ const onAbort = (e) => combined.abort(e.target.reason);
109
+ for (const s of signals) {
110
+ if (s.aborted)
111
+ combined.abort(s.reason);
112
+ else
113
+ s.addEventListener("abort", onAbort, { once: true });
114
+ }
115
+ return {
116
+ signal: combined.signal,
117
+ cleanup: () => {
118
+ clear();
119
+ for (const s of signals)
120
+ s.removeEventListener("abort", onAbort);
121
+ },
122
+ };
123
+ }
124
+ /** Reads a response body, giving up (and cancelling it) after `timeoutMs` so a trickling body can't hang us. */
125
+ async function readText(res, timeoutMs) {
126
+ if (!(timeoutMs > 0))
127
+ return res.text();
128
+ let timer;
129
+ const timeout = new Promise((_resolve, reject) => {
130
+ timer = setTimeout(() => {
131
+ res.body?.cancel().catch(() => { });
132
+ reject(new DOMException(`Reading the Daywalker Auth response timed out after ${timeoutMs}ms`, "TimeoutError"));
133
+ }, timeoutMs);
134
+ });
135
+ try {
136
+ return await Promise.race([res.text(), timeout]);
137
+ }
138
+ finally {
139
+ clearTimeout(timer);
140
+ }
141
+ }
142
+ function isRedirect(res) {
143
+ return res.type === "opaqueredirect" || REDIRECT_STATUSES.has(res.status);
144
+ }
145
+ function redirectError(what, res) {
146
+ res.body?.cancel().catch(() => { });
147
+ return new DaywalkerError(`Daywalker Auth answered ${what} with a redirect (HTTP ${res.status}); refusing to follow it with credentials`, res.status || undefined, "UNEXPECTED_REDIRECT");
148
+ }
149
+ function isRateLimitWindow(value) {
150
+ if (!value || typeof value !== "object")
151
+ return false;
152
+ const w = value;
153
+ return ["limit", "remaining", "reset"].every((k) => typeof w[k] === "number" && Number.isFinite(w[k]));
154
+ }
155
+ /** Why a `valid: true` answer can't be trusted as a validation result, or null when its shape is sane. */
156
+ function malformedValidation(json, payloadData) {
157
+ for (const src of [json, payloadData]) {
158
+ for (const k of ["serviceSlug", "tenantId", "tokenId", "tokenName", "userId", "stage"]) {
159
+ if (src[k] !== undefined && src[k] !== null && typeof src[k] !== "string")
160
+ return `${k} is not a string`;
161
+ }
162
+ for (const k of ["rateLimit", "rateLimitHourly"]) {
163
+ if (src[k] !== undefined && src[k] !== null && !isRateLimitWindow(src[k]))
164
+ return `${k} is not a rate limit window`;
165
+ }
166
+ }
167
+ return null;
168
+ }
169
+ /** Results are handed out as copies, so a caller mutating one can't alter what other callers get from the cache. */
170
+ function cloneResult(result) {
171
+ return typeof structuredClone === "function" ? structuredClone(result) : JSON.parse(JSON.stringify(result));
172
+ }
28
173
  export class Daywalker {
29
174
  _config;
30
175
  _rawConfig;
31
176
  _signingKeyPromise;
177
+ _previousSigningKeyPromise;
32
178
  _cache = new Map();
33
179
  _inflight = new Map();
34
180
  constructor(config = {}) {
@@ -64,8 +210,26 @@ export class Daywalker {
64
210
  }
65
211
  return this._signingKeyPromise;
66
212
  }
213
+ async getLoadedPreviousSigningKey() {
214
+ const raw = this._config.previousSigningKey;
215
+ if (!raw)
216
+ return null;
217
+ if (!this._previousSigningKeyPromise) {
218
+ this._previousSigningKeyPromise = loadSigningKey(raw);
219
+ }
220
+ return this._previousSigningKeyPromise;
221
+ }
67
222
  /**
68
- * Signs and executes requests with automatic retry and exponential backoff on 429 responses.
223
+ * Signs and executes a request to Daywalker Auth (same origin as `authUrl` only).
224
+ * - Redirects are never followed: a 3xx throws a DaywalkerError (code UNEXPECTED_REDIRECT), so the API key,
225
+ * signature and body never reach another URL and no redirect target's answer is trusted.
226
+ * - Each attempt times out after `timeoutMs` (default 5000ms) of waiting for response headers; the caller's
227
+ * `signal` aborts immediately, including during backoff, and is never retried.
228
+ * - 429s are retried with exponential backoff, honoring `Retry-After` up to `retry.maxRetryAfterMs`. Network
229
+ * errors and timeouts are retried only for GET / HEAD / OPTIONS, with an `idempotencyKey`, or when
230
+ * `retry.retryNetworkErrors` is true. Options merge as defaults < `config.retry` < `init.retry`.
231
+ * - An explicit `Authorization` header is sent as given; otherwise `Bearer <apiKey>` is added. The signature
232
+ * covers method, path, query, authority and Content-Digest, never Authorization.
69
233
  */
70
234
  async fetch(path, init) {
71
235
  const authUrl = this._config.authUrl;
@@ -77,33 +241,35 @@ export class Daywalker {
77
241
  }
78
242
  const method = (init?.method ?? (init?.body !== undefined ? "POST" : "GET")).toUpperCase();
79
243
  const doFetch = this._rawConfig.fetch ?? globalThis.fetch;
80
- const retryOpts = {
81
- maxRetries: init?.retry?.maxRetries ?? this._config.maxRetries ?? 3,
82
- initialDelayMs: init?.retry?.initialDelayMs ?? 500,
83
- maxDelayMs: init?.retry?.maxDelayMs ?? 30_000,
84
- backoffFactor: init?.retry?.backoffFactor ?? 2,
85
- jitter: init?.retry?.jitter ?? true,
86
- ...init?.retry,
87
- };
88
- const rawBody = init?.body;
89
- let bodyString = null;
90
- if (rawBody !== undefined && rawBody !== null) {
91
- if (typeof rawBody === "string") {
92
- bodyString = rawBody;
93
- }
94
- else if (rawBody instanceof Uint8Array) {
95
- bodyString = rawBody;
96
- }
97
- else if (typeof rawBody === "object") {
98
- bodyString = JSON.stringify(rawBody);
99
- }
244
+ const { retry: _retry, timeoutMs: _timeoutMs, idempotencyKey, body: rawBody, signal: callerSignal, ...requestInit } = init ?? {};
245
+ const retryOpts = { ...this._config.retry };
246
+ for (const [k, v] of Object.entries(init?.retry ?? {})) {
247
+ if (v !== undefined)
248
+ retryOpts[k] = v;
100
249
  }
250
+ const maxRetries = retryOpts.maxRetries ?? 3;
251
+ const initialDelayMs = retryOpts.initialDelayMs ?? 500;
252
+ const maxDelayMs = retryOpts.maxDelayMs ?? 30_000;
253
+ const backoffFactor = retryOpts.backoffFactor ?? 2;
254
+ const jitter = retryOpts.jitter ?? true;
255
+ const maxRetryAfterMs = retryOpts.maxRetryAfterMs ?? MAX_RETRY_AFTER_MS;
256
+ const retryNetworkErrors = retryOpts.retryNetworkErrors ?? (IDEMPOTENT_METHODS.has(method) || Boolean(idempotencyKey));
257
+ const timeoutMs = init?.timeoutMs ?? this._config.timeoutMs;
258
+ const backoff = (n) => {
259
+ let delayMs = initialDelayMs * Math.pow(backoffFactor, n - 1);
260
+ if (jitter)
261
+ delayMs = delayMs * (0.8 + Math.random() * 0.4);
262
+ return Math.min(delayMs, maxDelayMs);
263
+ };
264
+ callerSignal?.throwIfAborted();
101
265
  const signingKey = await this.getLoadedSigningKey();
266
+ const body = await prepareBody(rawBody, Boolean(signingKey));
102
267
  let attempt = 0;
103
268
  while (true) {
269
+ callerSignal?.throwIfAborted();
104
270
  const headers = new Headers(init?.headers);
105
- if (rawBody !== undefined && !headers.has("content-type") && typeof rawBody === "object" && !(rawBody instanceof Uint8Array)) {
106
- headers.set("content-type", "application/json");
271
+ if (body.contentType && !headers.has("content-type")) {
272
+ headers.set("content-type", body.contentType);
107
273
  }
108
274
  if (!headers.has("accept")) {
109
275
  headers.set("accept", "application/json");
@@ -111,15 +277,18 @@ export class Daywalker {
111
277
  if (this._config.apiKey && !headers.has("authorization")) {
112
278
  headers.set("authorization", `Bearer ${this._config.apiKey}`);
113
279
  }
280
+ if (idempotencyKey && !headers.has("idempotency-key")) {
281
+ headers.set("idempotency-key", idempotencyKey);
282
+ }
114
283
  if (signingKey) {
284
+ // apiKey is left out so the signer can't replace an explicit Authorization header.
115
285
  const signedHeaders = await signRequest({
116
286
  method,
117
- path: url.pathname,
287
+ path: url.pathname + url.search,
118
288
  authority: url.host,
119
- body: bodyString,
289
+ body: body.bytes,
120
290
  key: signingKey,
121
291
  keyid: this._config.keyId ?? undefined,
122
- apiKey: this._config.apiKey ?? undefined,
123
292
  nonce: generateNonce(),
124
293
  });
125
294
  for (const [k, v] of Object.entries(signedHeaders)) {
@@ -128,37 +297,47 @@ export class Daywalker {
128
297
  }
129
298
  }
130
299
  }
300
+ const { signal, cleanup } = attemptSignal(callerSignal, timeoutMs);
131
301
  const reqInit = {
132
- ...init,
302
+ ...requestInit,
133
303
  method,
134
304
  headers,
135
- body: bodyString ?? undefined,
305
+ body: body.send ?? undefined,
306
+ redirect: requestInit.redirect === "error" ? "error" : "manual",
307
+ signal,
136
308
  };
137
309
  let res;
138
310
  try {
139
311
  res = await doFetch(url.toString(), reqInit);
140
312
  }
141
313
  catch (err) {
142
- if (attempt < (retryOpts.maxRetries ?? 3)) {
314
+ if (callerSignal?.aborted)
315
+ throw err;
316
+ if (retryNetworkErrors && body.replayable && attempt < maxRetries) {
143
317
  attempt++;
144
- const delay = (retryOpts.initialDelayMs ?? 500) * Math.pow(retryOpts.backoffFactor ?? 2, attempt - 1);
145
- await new Promise((r) => setTimeout(r, delay));
318
+ await sleep(backoff(attempt), callerSignal);
146
319
  continue;
147
320
  }
148
321
  throw err;
149
322
  }
150
- if (res.status === 429 && attempt < (retryOpts.maxRetries ?? 3)) {
151
- attempt++;
323
+ finally {
324
+ cleanup();
325
+ }
326
+ if (isRedirect(res)) {
327
+ throw redirectError(url.pathname, res);
328
+ }
329
+ if (res.status === 429 && body.replayable && attempt < maxRetries) {
152
330
  const retryAfterSec = parseRetryAfter(res.headers.get("retry-after"));
153
- let delayMs = (retryOpts.initialDelayMs ?? 500) * Math.pow(retryOpts.backoffFactor ?? 2, attempt - 1);
154
- if (retryOpts.jitter) {
155
- delayMs = delayMs * (0.8 + Math.random() * 0.4);
156
- }
331
+ let delayMs = backoff(attempt + 1);
157
332
  if (retryAfterSec !== undefined) {
333
+ // Retrying sooner than the server asked is pointless; waiting longer than this is the caller's call.
334
+ if (retryAfterSec * 1000 > maxRetryAfterMs)
335
+ return res;
158
336
  delayMs = Math.max(delayMs, retryAfterSec * 1000);
159
337
  }
160
- delayMs = Math.min(delayMs, retryOpts.maxDelayMs ?? 30_000);
161
- await new Promise((r) => setTimeout(r, delayMs));
338
+ attempt++;
339
+ res.body?.cancel().catch(() => { });
340
+ await sleep(delayMs, callerSignal);
162
341
  continue;
163
342
  }
164
343
  return res;
@@ -189,6 +368,9 @@ export class Daywalker {
189
368
  * Supports zero-egress offline verification for JWT token leases when Daywalker's public key is configured.
190
369
  * A missing token is always rejected (401); it never falls back to this service's own API key —
191
370
  * use `validateServiceKey()` for that.
371
+ * Online validation fails closed: only a 2xx JSON answer with `valid: true` and a sane shape is accepted.
372
+ * Outages (network errors, timeouts, 5xx, malformed answers) return status 503 with error NETWORK_ERROR or
373
+ * SERVICE_UNAVAILABLE and are never cached. Each call gets its own copy of the (cached) result.
192
374
  */
193
375
  async validateToken(tokenOrOptions, options) {
194
376
  let token;
@@ -219,7 +401,7 @@ export class Daywalker {
219
401
  const cacheKey = `${serviceSlug || "default"}:${await sha256Base64Url(token)}`;
220
402
  const cached = forceRefresh ? undefined : this._cacheGet(cacheKey);
221
403
  if (cached) {
222
- return cached;
404
+ return cloneResult(cached);
223
405
  }
224
406
  // Check for offline JWT lease verification
225
407
  if (this._config.daywalkerPublicKey && token.split(".").length === 3) {
@@ -254,7 +436,7 @@ export class Daywalker {
254
436
  // Never cache a lease past its own expiry.
255
437
  this._cacheSet(cacheKey, result, this._config.cacheTtlMs, payload.exp * 1000);
256
438
  }
257
- return result;
439
+ return cloneResult(result);
258
440
  }
259
441
  else if (offlineOnly) {
260
442
  return {
@@ -277,21 +459,28 @@ export class Daywalker {
277
459
  const inflightKey = `${cacheKey}`;
278
460
  const pending = this._inflight.get(inflightKey);
279
461
  if (pending)
280
- return pending;
462
+ return pending.then(cloneResult);
281
463
  const run = this._validateOnline(token, serviceSlug, cacheKey).finally(() => {
282
464
  this._inflight.delete(inflightKey);
283
465
  });
284
466
  this._inflight.set(inflightKey, run);
285
- return run;
467
+ return run.then(cloneResult);
286
468
  }
287
469
  async _validateOnline(token, serviceSlug, cacheKey) {
288
470
  const negTtl = this._config.negativeCacheTtlMs;
289
471
  const fail = (result) => {
290
- if (negTtl > 0 && result.error !== "RATE_LIMITED" && result.error !== "NETWORK_ERROR") {
472
+ if (negTtl > 0 && result.error !== "RATE_LIMITED" && result.error !== "NETWORK_ERROR" && result.error !== "SERVICE_UNAVAILABLE") {
291
473
  this._cacheSet(cacheKey, result, negTtl);
292
474
  }
293
475
  return result;
294
476
  };
477
+ // Says nothing about the token, so never cached.
478
+ const unavailable = (message) => ({
479
+ ok: false,
480
+ error: "SERVICE_UNAVAILABLE",
481
+ status: 503,
482
+ message: redactSecrets(message),
483
+ });
295
484
  try {
296
485
  const validateBody = { token };
297
486
  if (serviceSlug) {
@@ -300,10 +489,42 @@ export class Daywalker {
300
489
  const res = await this.fetch("/api/v1/tokens/validate", {
301
490
  method: "POST",
302
491
  body: validateBody,
492
+ // Validation is read-only, so repeating it after a lost response is safe.
493
+ retry: { retryNetworkErrors: this._config.retry.retryNetworkErrors ?? true },
303
494
  });
304
- const json = (await res.json().catch(() => ({})));
305
- if (res.status === 200 && json.valid !== false) {
306
- const payloadData = (typeof json.data === "object" && json.data ? json.data : json);
495
+ // A body that times out is an outage (thrown to NETWORK_ERROR below), never an empty answer.
496
+ const text = await readText(res, this._config.timeoutMs);
497
+ let parsed;
498
+ try {
499
+ parsed = JSON.parse(text);
500
+ }
501
+ catch {
502
+ parsed = undefined;
503
+ }
504
+ const json = (parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {});
505
+ const isObject = json === parsed;
506
+ if (res.status === 429) {
507
+ const retryAfter = parseRetryAfter(res.headers.get("retry-after")) ?? (typeof json.retryAfter === "number" ? json.retryAfter : undefined);
508
+ return {
509
+ ok: false,
510
+ error: "RATE_LIMITED",
511
+ status: 429,
512
+ message: redactSecrets((typeof json.message === "string" && json.message) || "Rate limit exceeded"),
513
+ retryAfter,
514
+ };
515
+ }
516
+ if (res.status >= 500) {
517
+ return unavailable(`Daywalker Auth is unavailable (HTTP ${res.status})`);
518
+ }
519
+ if (res.ok && isObject && json.valid === true) {
520
+ const payloadData = (json.data === undefined ? json : json.data);
521
+ if (!payloadData || typeof payloadData !== "object" || Array.isArray(payloadData)) {
522
+ return unavailable("Daywalker Auth returned a malformed validation result (data is not an object)");
523
+ }
524
+ const problem = malformedValidation(json, payloadData);
525
+ if (problem) {
526
+ return unavailable(`Daywalker Auth returned a malformed validation result (${problem})`);
527
+ }
307
528
  const data = {
308
529
  serviceSlug: json.serviceSlug || serviceSlug,
309
530
  tenantId: (payloadData.tenantId || payloadData.userId || json.tenantId || json.userId || ""),
@@ -324,18 +545,12 @@ export class Daywalker {
324
545
  }
325
546
  return result;
326
547
  }
327
- if (res.status === 429) {
328
- const retryAfter = parseRetryAfter(res.headers.get("retry-after")) ?? json.retryAfter;
329
- return {
330
- ok: false,
331
- error: "RATE_LIMITED",
332
- status: 429,
333
- message: redactSecrets(json.message || "Rate limit exceeded"),
334
- retryAfter,
335
- };
548
+ // A 2xx is only a token verdict when it says so; anything else (HTML, `{}`, a captive portal) fails closed.
549
+ if (res.ok && !(isObject && (json.valid === false || typeof json.error === "string"))) {
550
+ return unavailable(`Daywalker Auth returned no validation result (HTTP ${res.status})`);
336
551
  }
337
552
  let error = "INVALID_TOKEN";
338
- const errMsg = (json.message || json.error || "");
553
+ const errMsg = String((typeof json.message === "string" && json.message) || (typeof json.error === "string" && json.error) || "");
339
554
  if (json.error === "EXPIRED" || /expired/i.test(errMsg)) {
340
555
  error = "EXPIRED";
341
556
  }
@@ -345,7 +560,8 @@ export class Daywalker {
345
560
  return fail({
346
561
  ok: false,
347
562
  error,
348
- status: res.status,
563
+ // `{ valid: false }` may come with a 200; never hand adapters a success status for a rejection.
564
+ status: res.ok ? 401 : res.status,
349
565
  message: redactSecrets(errMsg || `Validation failed with HTTP ${res.status}`),
350
566
  });
351
567
  }
@@ -402,19 +618,52 @@ export class Daywalker {
402
618
  }
403
619
  /**
404
620
  * Fetches Daywalker's public JWKS (GET /.well-known/jwks.json) for offline lease verification.
621
+ * Uses the configured `fetch`, sends no credentials, never follows redirects and times out after `timeoutMs`.
405
622
  */
406
623
  async fetchJwks() {
407
- const res = await globalThis.fetch(`${this._config.authUrl.replace(/\/+$/, "")}/.well-known/jwks.json`, { headers: { Accept: "application/json" } });
624
+ const doFetch = this._rawConfig.fetch ?? globalThis.fetch;
625
+ const { signal, cleanup } = attemptSignal(undefined, this._config.timeoutMs);
626
+ let res;
627
+ try {
628
+ res = await doFetch(`${this._config.authUrl.replace(/\/+$/, "")}/.well-known/jwks.json`, {
629
+ headers: { Accept: "application/json" },
630
+ redirect: "manual",
631
+ signal,
632
+ });
633
+ }
634
+ finally {
635
+ cleanup();
636
+ }
637
+ if (isRedirect(res))
638
+ throw redirectError("/.well-known/jwks.json", res);
408
639
  if (!res.ok)
409
640
  throw new DaywalkerError(`Could not fetch JWKS (HTTP ${res.status})`, res.status);
410
- return (await res.json());
641
+ let json = null;
642
+ try {
643
+ json = JSON.parse(await readText(res, this._config.timeoutMs));
644
+ }
645
+ catch (err) {
646
+ if (err.name === "TimeoutError")
647
+ throw err;
648
+ }
649
+ if (!json || typeof json !== "object" || !Array.isArray(json.keys)) {
650
+ throw new DaywalkerError("JWKS response is not a JWK Set");
651
+ }
652
+ return { keys: json.keys };
411
653
  }
412
654
  /**
413
- * The public JWK(s) configured for this service.
655
+ * The public JWK(s) configured for this service: the current signing key, then the previous one
656
+ * (`previousSigningKey`) during a rotation, so requests from instances still on the old key keep verifying.
414
657
  */
415
658
  async keys() {
416
659
  const key = await this.getLoadedSigningKey();
417
- return { keys: key ? [key.publicJwk] : [] };
660
+ const keys = key ? [key.publicJwk] : [];
661
+ // A broken previous key must not take the current one offline; selfCheck() reports it.
662
+ const previous = await this.getLoadedPreviousSigningKey().catch(() => null);
663
+ if (previous && !keys.some((k) => k.kid === previous.publicJwk.kid)) {
664
+ keys.push(previous.publicJwk);
665
+ }
666
+ return { keys };
418
667
  }
419
668
  /**
420
669
  * Check configuration and verify signature readiness.
@@ -428,6 +677,9 @@ export class Daywalker {
428
677
  if (!signingKey) {
429
678
  problems.push("DAYWALKER_SIGNING_KEY is not set: requests will be sent without RFC 9421 signature");
430
679
  }
680
+ await this.getLoadedPreviousSigningKey().catch((err) => {
681
+ problems.push(`Previous signing key load error: ${err.message}`);
682
+ });
431
683
  const keyId = signingKey?.publicJwk.kid ?? null;
432
684
  const offlineVerificationReady = Boolean(this._config.daywalkerPublicKey);
433
685
  return {
package/dist/config.d.ts CHANGED
@@ -1,20 +1,26 @@
1
- import type { DaywalkerConfig, PublicJwk, SigningKey } from "./types.js";
1
+ import type { DaywalkerConfig, PublicJwk, PublicJwks, RetryOptions, SigningKey } from "./types.js";
2
2
  export interface ResolvedConfig {
3
3
  apiKey: string | null;
4
4
  signingKey: string | SigningKey | null;
5
+ /** Never signs; `keys()` publishes its public JWK alongside the current key during rotation. */
5
6
  previousSigningKey: string | SigningKey | null;
6
7
  keyId: string | null;
7
8
  serviceSlug: string | null;
8
9
  domain: string | null;
9
10
  domainSource: string | null;
10
- daywalkerPublicKey: string | PublicJwk | null;
11
+ daywalkerPublicKey: string | PublicJwk | PublicJwks | null;
11
12
  leaseIssuer: string | null;
12
13
  authUrl: string;
13
14
  cacheTtlMs: number;
14
15
  negativeCacheTtlMs: number;
15
16
  maxRetries: number;
17
+ /** `config.retry` with undefined fields dropped; per-request `init.retry` is layered on top. */
18
+ retry: RetryOptions;
19
+ timeoutMs: number;
16
20
  problems: string[];
17
21
  }
22
+ /** Default per-attempt timeout for requests to Daywalker Auth. */
23
+ export declare const DEFAULT_TIMEOUT_MS = 5000;
18
24
  /**
19
25
  * A public hostname from a URL or bare host, or null for anything that can't be fetched over
20
26
  * the internet (localhost, 0.0.0.0, IP addresses, single labels).
package/dist/config.js CHANGED
@@ -1,3 +1,5 @@
1
+ /** Default per-attempt timeout for requests to Daywalker Auth. */
2
+ export const DEFAULT_TIMEOUT_MS = 5_000;
1
3
  const URL_ENV_VARS = [
2
4
  "APP_URL",
3
5
  "NEXTAUTH_URL",
@@ -105,6 +107,16 @@ export function resolveConfig(options = {}) {
105
107
  const cacheTtlMs = options.cacheTtlMs ?? 60_000;
106
108
  const negativeCacheTtlMs = options.negativeCacheTtlMs ?? 3_000;
107
109
  const maxRetries = options.maxRetries ?? (options.retry?.maxRetries ?? 3);
110
+ const retry = {};
111
+ for (const [k, v] of Object.entries(options.retry ?? {})) {
112
+ if (v !== undefined)
113
+ retry[k] = v;
114
+ }
115
+ retry.maxRetries = maxRetries;
116
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
117
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
118
+ throw new Error(`timeoutMs must be a non-negative number of milliseconds (got ${timeoutMs})`);
119
+ }
108
120
  return {
109
121
  apiKey,
110
122
  signingKey,
@@ -119,6 +131,8 @@ export function resolveConfig(options = {}) {
119
131
  cacheTtlMs,
120
132
  negativeCacheTtlMs,
121
133
  maxRetries,
134
+ retry,
135
+ timeoutMs,
122
136
  problems,
123
137
  };
124
138
  }
@@ -8,6 +8,11 @@ export declare function bytesToBase64(bytes: Uint8Array): string;
8
8
  export declare function base64ToBytes(b64: string): Uint8Array;
9
9
  export declare function bytesToBase64Url(bytes: Uint8Array): string;
10
10
  export declare function base64UrlToBytes(b64url: string): Uint8Array;
11
+ /**
12
+ * Decodes unpadded base64url (RFC 7515 §2) strictly: only `[A-Za-z0-9_-]` and only the canonical encoding
13
+ * of the bytes, so a token or signature has exactly one accepted spelling. Throws otherwise.
14
+ */
15
+ export declare function base64UrlToBytesStrict(b64url: string): Uint8Array;
11
16
  export declare function bytesToHex(bytes: Uint8Array): string;
12
17
  export declare function hexToBytes(hex: string): Uint8Array;
13
18
  /**
@@ -47,6 +47,20 @@ export function base64UrlToBytes(b64url) {
47
47
  }
48
48
  return base64ToBytes(b64);
49
49
  }
50
+ /**
51
+ * Decodes unpadded base64url (RFC 7515 §2) strictly: only `[A-Za-z0-9_-]` and only the canonical encoding
52
+ * of the bytes, so a token or signature has exactly one accepted spelling. Throws otherwise.
53
+ */
54
+ export function base64UrlToBytesStrict(b64url) {
55
+ if (!/^[A-Za-z0-9_-]*$/.test(b64url) || b64url.length % 4 === 1) {
56
+ throw new Error("Invalid base64url encoding");
57
+ }
58
+ const bytes = base64UrlToBytes(b64url);
59
+ if (bytesToBase64Url(bytes) !== b64url) {
60
+ throw new Error("Non-canonical base64url encoding");
61
+ }
62
+ return bytes;
63
+ }
50
64
  export function bytesToHex(bytes) {
51
65
  let hex = "";
52
66
  for (let i = 0; i < bytes.length; i++) {
@@ -1,5 +1,5 @@
1
- export { stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, bytesToHex, hexToBytes, timingSafeEqual, timingSafeEqualString, maskSecret, redactSecrets, } from "./encoding.js";
1
+ export { stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, base64UrlToBytesStrict, bytesToHex, hexToBytes, timingSafeEqual, timingSafeEqualString, maskSecret, redactSecrets, } from "./encoding.js";
2
2
  export { DEFAULT_MAX_BODY_SIZE_BYTES, computeContentDigest, sha256Base64, sha256Base64Url, } from "./digest.js";
3
3
  export { calculateJwkThumbprint, generateSigningKey, loadSigningKey, loadPublicKey, } from "./keys.js";
4
- export { SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, registerPublicKey, } from "./signatures.js";
5
- export type { SignedHeaders, SignRequestParams, VerifySignatureParams, VerifySignatureResult, } from "./signatures.js";
4
+ export { SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, REPLAY_CACHE_MAX_ENTRIES, SignatureReplayCache, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, registerPublicKey, } from "./signatures.js";
5
+ export type { SignedHeaders, SignRequestParams, VerifySignatureParams, VerifySignatureResult, ReplayStore, } from "./signatures.js";
@@ -1,4 +1,4 @@
1
- export { stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, bytesToHex, hexToBytes, timingSafeEqual, timingSafeEqualString, maskSecret, redactSecrets, } from "./encoding.js";
1
+ export { stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, base64UrlToBytesStrict, bytesToHex, hexToBytes, timingSafeEqual, timingSafeEqualString, maskSecret, redactSecrets, } from "./encoding.js";
2
2
  export { DEFAULT_MAX_BODY_SIZE_BYTES, computeContentDigest, sha256Base64, sha256Base64Url, } from "./digest.js";
3
3
  export { calculateJwkThumbprint, generateSigningKey, loadSigningKey, loadPublicKey, } from "./keys.js";
4
- export { SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, registerPublicKey, } from "./signatures.js";
4
+ export { SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, REPLAY_CACHE_MAX_ENTRIES, SignatureReplayCache, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, registerPublicKey, } from "./signatures.js";
@@ -1,4 +1,4 @@
1
- import type { PublicJwk, SigningKey } from "../types.js";
1
+ import type { PublicJwk, PublicJwks, SigningKey } from "../types.js";
2
2
  /**
3
3
  * Calculate the RFC 7638 JWK thumbprint for an Ed25519 public key.
4
4
  */
@@ -17,9 +17,9 @@ export declare function generateSigningKey(): Promise<SigningKey>;
17
17
  */
18
18
  export declare function loadSigningKey(raw: string | SigningKey): Promise<SigningKey>;
19
19
  /**
20
- * Load an Ed25519 public key from JWK, PEM, Base64 SPKI, or Base64url raw public key.
20
+ * Load an Ed25519 public key from JWK, JWKS (selected by `kid`), PEM, Base64 SPKI, or Base64url raw public key.
21
21
  */
22
- export declare function loadPublicKey(raw: string | PublicJwk | CryptoKey): Promise<{
22
+ export declare function loadPublicKey(raw: string | PublicJwk | PublicJwks | CryptoKey, kid?: string): Promise<{
23
23
  publicKey: CryptoKey;
24
24
  publicJwk: PublicJwk;
25
25
  }>;