@shelbyb/daywalker-sdk 0.2.0 → 0.4.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.
Files changed (55) hide show
  1. package/README.md +38 -24
  2. package/dist/analytics/index.d.ts +26 -3
  3. package/dist/analytics/index.js +107 -23
  4. package/dist/cli.js +136 -29
  5. package/dist/client.d.ts +44 -3
  6. package/dist/client.js +372 -73
  7. package/dist/config.d.ts +14 -2
  8. package/dist/config.js +36 -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 +54 -11
  16. package/dist/crypto/signatures.js +310 -94
  17. package/dist/express.d.ts +1 -0
  18. package/dist/express.js +8 -6
  19. package/dist/flags/client.d.ts +34 -5
  20. package/dist/flags/client.js +93 -22
  21. package/dist/flags/common/events.d.ts +9 -0
  22. package/dist/flags/common/events.js +77 -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/context.js +4 -3
  30. package/dist/flags/engine/index.d.ts +1 -1
  31. package/dist/flags/engine/index.js +1 -1
  32. package/dist/flags/engine/operators.d.ts +25 -0
  33. package/dist/flags/engine/operators.js +64 -8
  34. package/dist/flags/node.d.ts +27 -15
  35. package/dist/flags/node.js +54 -7
  36. package/dist/flags/react.d.ts +6 -4
  37. package/dist/flags/react.js +47 -17
  38. package/dist/index.d.ts +7 -6
  39. package/dist/index.js +4 -4
  40. package/dist/middleware.d.ts +18 -0
  41. package/dist/middleware.js +28 -7
  42. package/dist/next.d.ts +63 -6
  43. package/dist/next.js +182 -36
  44. package/dist/openfeature/server.d.ts +3 -0
  45. package/dist/openfeature/server.js +14 -2
  46. package/dist/openfeature/shared.d.ts +1 -1
  47. package/dist/openfeature/shared.js +22 -2
  48. package/dist/openfeature/web.js +9 -3
  49. package/dist/types.d.ts +58 -3
  50. package/dist/types.js +6 -0
  51. package/docs/analytics.md +1 -1
  52. package/docs/flags-node.md +1 -1
  53. package/docs/flags.md +2 -1
  54. package/package.json +34 -16
  55. package/assets/logo-512.png +0 -0
package/dist/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { resolveConfig } from "./config.js";
2
- import { loadSigningKey, signRequest, verifyJwtLease, maskSecret, redactSecrets, } from "./crypto/index.js";
2
+ import { loadSigningKey, signRequest, verifyJwtLease, maskSecret, redactSecrets, sha256Base64Url, } from "./crypto/index.js";
3
3
  import { DaywalkerAuthError, DaywalkerError, DaywalkerRateLimitError, } from "./types.js";
4
4
  function parseRetryAfter(headerValue) {
5
5
  if (!headerValue)
@@ -23,10 +23,158 @@ function generateNonce() {
23
23
  bin += String.fromCharCode(b);
24
24
  return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
25
25
  }
26
+ /** Validation results kept in memory; the least recently used are evicted past this. */
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
+ }
26
173
  export class Daywalker {
27
174
  _config;
28
175
  _rawConfig;
29
176
  _signingKeyPromise;
177
+ _previousSigningKeyPromise;
30
178
  _cache = new Map();
31
179
  _inflight = new Map();
32
180
  constructor(config = {}) {
@@ -62,41 +210,66 @@ export class Daywalker {
62
210
  }
63
211
  return this._signingKeyPromise;
64
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
+ }
65
222
  /**
66
- * 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.
67
233
  */
68
234
  async fetch(path, init) {
69
235
  const authUrl = this._config.authUrl;
70
236
  const url = new URL(path, authUrl);
237
+ // Absolute URLs to other hosts would otherwise receive the API key and a valid signature.
238
+ const authOrigin = new URL(authUrl).origin;
239
+ if (url.origin !== authOrigin) {
240
+ throw new DaywalkerError(`Refusing to send Daywalker credentials to ${url.origin}; fetch() only targets ${authOrigin}`);
241
+ }
71
242
  const method = (init?.method ?? (init?.body !== undefined ? "POST" : "GET")).toUpperCase();
72
243
  const doFetch = this._rawConfig.fetch ?? globalThis.fetch;
73
- const retryOpts = {
74
- maxRetries: init?.retry?.maxRetries ?? this._config.maxRetries ?? 3,
75
- initialDelayMs: init?.retry?.initialDelayMs ?? 500,
76
- maxDelayMs: init?.retry?.maxDelayMs ?? 30_000,
77
- backoffFactor: init?.retry?.backoffFactor ?? 2,
78
- jitter: init?.retry?.jitter ?? true,
79
- ...init?.retry,
80
- };
81
- const rawBody = init?.body;
82
- let bodyString = null;
83
- if (rawBody !== undefined && rawBody !== null) {
84
- if (typeof rawBody === "string") {
85
- bodyString = rawBody;
86
- }
87
- else if (rawBody instanceof Uint8Array) {
88
- bodyString = rawBody;
89
- }
90
- else if (typeof rawBody === "object") {
91
- bodyString = JSON.stringify(rawBody);
92
- }
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;
93
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();
94
265
  const signingKey = await this.getLoadedSigningKey();
266
+ const body = await prepareBody(rawBody, Boolean(signingKey));
95
267
  let attempt = 0;
96
268
  while (true) {
269
+ callerSignal?.throwIfAborted();
97
270
  const headers = new Headers(init?.headers);
98
- if (rawBody !== undefined && !headers.has("content-type") && typeof rawBody === "object" && !(rawBody instanceof Uint8Array)) {
99
- headers.set("content-type", "application/json");
271
+ if (body.contentType && !headers.has("content-type")) {
272
+ headers.set("content-type", body.contentType);
100
273
  }
101
274
  if (!headers.has("accept")) {
102
275
  headers.set("accept", "application/json");
@@ -104,15 +277,18 @@ export class Daywalker {
104
277
  if (this._config.apiKey && !headers.has("authorization")) {
105
278
  headers.set("authorization", `Bearer ${this._config.apiKey}`);
106
279
  }
280
+ if (idempotencyKey && !headers.has("idempotency-key")) {
281
+ headers.set("idempotency-key", idempotencyKey);
282
+ }
107
283
  if (signingKey) {
284
+ // apiKey is left out so the signer can't replace an explicit Authorization header.
108
285
  const signedHeaders = await signRequest({
109
286
  method,
110
- path: url.pathname,
287
+ path: url.pathname + url.search,
111
288
  authority: url.host,
112
- body: bodyString,
289
+ body: body.bytes,
113
290
  key: signingKey,
114
291
  keyid: this._config.keyId ?? undefined,
115
- apiKey: this._config.apiKey ?? undefined,
116
292
  nonce: generateNonce(),
117
293
  });
118
294
  for (const [k, v] of Object.entries(signedHeaders)) {
@@ -121,45 +297,80 @@ export class Daywalker {
121
297
  }
122
298
  }
123
299
  }
300
+ const { signal, cleanup } = attemptSignal(callerSignal, timeoutMs);
124
301
  const reqInit = {
125
- ...init,
302
+ ...requestInit,
126
303
  method,
127
304
  headers,
128
- body: bodyString ?? undefined,
305
+ body: body.send ?? undefined,
306
+ redirect: requestInit.redirect === "error" ? "error" : "manual",
307
+ signal,
129
308
  };
130
309
  let res;
131
310
  try {
132
311
  res = await doFetch(url.toString(), reqInit);
133
312
  }
134
313
  catch (err) {
135
- if (attempt < (retryOpts.maxRetries ?? 3)) {
314
+ if (callerSignal?.aborted)
315
+ throw err;
316
+ if (retryNetworkErrors && body.replayable && attempt < maxRetries) {
136
317
  attempt++;
137
- const delay = (retryOpts.initialDelayMs ?? 500) * Math.pow(retryOpts.backoffFactor ?? 2, attempt - 1);
138
- await new Promise((r) => setTimeout(r, delay));
318
+ await sleep(backoff(attempt), callerSignal);
139
319
  continue;
140
320
  }
141
321
  throw err;
142
322
  }
143
- if (res.status === 429 && attempt < (retryOpts.maxRetries ?? 3)) {
144
- 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) {
145
330
  const retryAfterSec = parseRetryAfter(res.headers.get("retry-after"));
146
- let delayMs = (retryOpts.initialDelayMs ?? 500) * Math.pow(retryOpts.backoffFactor ?? 2, attempt - 1);
147
- if (retryOpts.jitter) {
148
- delayMs = delayMs * (0.8 + Math.random() * 0.4);
149
- }
331
+ let delayMs = backoff(attempt + 1);
150
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;
151
336
  delayMs = Math.max(delayMs, retryAfterSec * 1000);
152
337
  }
153
- delayMs = Math.min(delayMs, retryOpts.maxDelayMs ?? 30_000);
154
- await new Promise((r) => setTimeout(r, delayMs));
338
+ attempt++;
339
+ res.body?.cancel().catch(() => { });
340
+ await sleep(delayMs, callerSignal);
155
341
  continue;
156
342
  }
157
343
  return res;
158
344
  }
159
345
  }
346
+ _cacheGet(key) {
347
+ const entry = this._cache.get(key);
348
+ if (!entry)
349
+ return undefined;
350
+ this._cache.delete(key);
351
+ if (entry.expiresAt <= Date.now())
352
+ return undefined;
353
+ this._cache.set(key, entry); // most recently used goes last
354
+ return entry.result;
355
+ }
356
+ _cacheSet(key, result, ttlMs, notAfterMs = Infinity) {
357
+ const expiresAt = Math.min(Date.now() + ttlMs, notAfterMs);
358
+ if (expiresAt <= Date.now())
359
+ return;
360
+ this._cache.delete(key);
361
+ this._cache.set(key, { result, expiresAt });
362
+ while (this._cache.size > MAX_VALIDATION_CACHE_ENTRIES) {
363
+ this._cache.delete(this._cache.keys().next().value);
364
+ }
365
+ }
160
366
  /**
161
- * Validates incoming bearer tokens with memory caching and discriminated union return.
367
+ * Validates an incoming bearer token with memory caching and discriminated union return.
162
368
  * Supports zero-egress offline verification for JWT token leases when Daywalker's public key is configured.
369
+ * A missing token is always rejected (401); it never falls back to this service's own API key —
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.
163
374
  */
164
375
  async validateToken(tokenOrOptions, options) {
165
376
  let token;
@@ -173,7 +384,7 @@ export class Daywalker {
173
384
  offlineOnly = options?.offlineOnly ?? false;
174
385
  }
175
386
  else {
176
- token = tokenOrOptions?.token ?? this._config.apiKey ?? undefined;
387
+ token = tokenOrOptions?.token ?? undefined;
177
388
  serviceSlug = (tokenOrOptions?.serviceSlug ?? this._config.serviceSlug ?? "").toLowerCase();
178
389
  forceRefresh = tokenOrOptions?.forceRefresh ?? false;
179
390
  offlineOnly = tokenOrOptions?.offlineOnly ?? false;
@@ -181,15 +392,16 @@ export class Daywalker {
181
392
  if (!token) {
182
393
  return {
183
394
  ok: false,
184
- error: "CONFIGURATION_ERROR",
185
- status: 400,
395
+ error: "INVALID_TOKEN",
396
+ status: 401,
186
397
  message: "Missing token to validate",
187
398
  };
188
399
  }
189
- const cacheKey = `${serviceSlug || "default"}:${token}`;
190
- const cached = this._cache.get(cacheKey);
191
- if (!forceRefresh && cached && cached.expiresAt > Date.now()) {
192
- return cached.result;
400
+ // Keyed by a hash so raw tokens are never held as cache keys.
401
+ const cacheKey = `${serviceSlug || "default"}:${await sha256Base64Url(token)}`;
402
+ const cached = forceRefresh ? undefined : this._cacheGet(cacheKey);
403
+ if (cached) {
404
+ return cloneResult(cached);
193
405
  }
194
406
  // Check for offline JWT lease verification
195
407
  if (this._config.daywalkerPublicKey && token.split(".").length === 3) {
@@ -197,6 +409,7 @@ export class Daywalker {
197
409
  token,
198
410
  publicKey: this._config.daywalkerPublicKey,
199
411
  expectedServiceSlug: serviceSlug || undefined,
412
+ expectedIssuer: this._config.leaseIssuer ?? undefined,
200
413
  });
201
414
  if (jwtResult.valid && jwtResult.payload) {
202
415
  const payload = jwtResult.payload;
@@ -220,9 +433,10 @@ export class Daywalker {
220
433
  },
221
434
  };
222
435
  if (this._config.cacheTtlMs > 0) {
223
- this._cache.set(cacheKey, { result, expiresAt: Date.now() + this._config.cacheTtlMs });
436
+ // Never cache a lease past its own expiry.
437
+ this._cacheSet(cacheKey, result, this._config.cacheTtlMs, payload.exp * 1000);
224
438
  }
225
- return result;
439
+ return cloneResult(result);
226
440
  }
227
441
  else if (offlineOnly) {
228
442
  return {
@@ -245,21 +459,28 @@ export class Daywalker {
245
459
  const inflightKey = `${cacheKey}`;
246
460
  const pending = this._inflight.get(inflightKey);
247
461
  if (pending)
248
- return pending;
462
+ return pending.then(cloneResult);
249
463
  const run = this._validateOnline(token, serviceSlug, cacheKey).finally(() => {
250
464
  this._inflight.delete(inflightKey);
251
465
  });
252
466
  this._inflight.set(inflightKey, run);
253
- return run;
467
+ return run.then(cloneResult);
254
468
  }
255
469
  async _validateOnline(token, serviceSlug, cacheKey) {
256
470
  const negTtl = this._config.negativeCacheTtlMs;
257
471
  const fail = (result) => {
258
- if (negTtl > 0 && result.error !== "RATE_LIMITED" && result.error !== "NETWORK_ERROR") {
259
- this._cache.set(cacheKey, { result, expiresAt: Date.now() + negTtl });
472
+ if (negTtl > 0 && result.error !== "RATE_LIMITED" && result.error !== "NETWORK_ERROR" && result.error !== "SERVICE_UNAVAILABLE") {
473
+ this._cacheSet(cacheKey, result, negTtl);
260
474
  }
261
475
  return result;
262
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
+ });
263
484
  try {
264
485
  const validateBody = { token };
265
486
  if (serviceSlug) {
@@ -268,10 +489,42 @@ export class Daywalker {
268
489
  const res = await this.fetch("/api/v1/tokens/validate", {
269
490
  method: "POST",
270
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 },
271
494
  });
272
- const json = (await res.json().catch(() => ({})));
273
- if (res.status === 200 && json.valid !== false) {
274
- 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
+ }
275
528
  const data = {
276
529
  serviceSlug: json.serviceSlug || serviceSlug,
277
530
  tenantId: (payloadData.tenantId || payloadData.userId || json.tenantId || json.userId || ""),
@@ -288,22 +541,16 @@ export class Daywalker {
288
541
  data,
289
542
  };
290
543
  if (this._config.cacheTtlMs > 0) {
291
- this._cache.set(cacheKey, { result, expiresAt: Date.now() + this._config.cacheTtlMs });
544
+ this._cacheSet(cacheKey, result, this._config.cacheTtlMs);
292
545
  }
293
546
  return result;
294
547
  }
295
- if (res.status === 429) {
296
- const retryAfter = parseRetryAfter(res.headers.get("retry-after")) ?? json.retryAfter;
297
- return {
298
- ok: false,
299
- error: "RATE_LIMITED",
300
- status: 429,
301
- message: redactSecrets(json.message || "Rate limit exceeded"),
302
- retryAfter,
303
- };
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})`);
304
551
  }
305
552
  let error = "INVALID_TOKEN";
306
- const errMsg = (json.message || json.error || "");
553
+ const errMsg = String((typeof json.message === "string" && json.message) || (typeof json.error === "string" && json.error) || "");
307
554
  if (json.error === "EXPIRED" || /expired/i.test(errMsg)) {
308
555
  error = "EXPIRED";
309
556
  }
@@ -313,7 +560,8 @@ export class Daywalker {
313
560
  return fail({
314
561
  ok: false,
315
562
  error,
316
- 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,
317
565
  message: redactSecrets(errMsg || `Validation failed with HTTP ${res.status}`),
318
566
  });
319
567
  }
@@ -326,6 +574,21 @@ export class Daywalker {
326
574
  };
327
575
  }
328
576
  }
577
+ /**
578
+ * Service health check: validates this service's own configured API key (DAYWALKER_API_KEY).
579
+ * Never use this to authenticate an incoming request.
580
+ */
581
+ async validateServiceKey(options) {
582
+ if (!this._config.apiKey) {
583
+ return {
584
+ ok: false,
585
+ error: "CONFIGURATION_ERROR",
586
+ status: 500,
587
+ message: "DAYWALKER_API_KEY is not set",
588
+ };
589
+ }
590
+ return this.validateToken(this._config.apiKey, options);
591
+ }
329
592
  /**
330
593
  * Throws DaywalkerAuthError or DaywalkerRateLimitError if token is invalid or rate limited.
331
594
  */
@@ -355,19 +618,52 @@ export class Daywalker {
355
618
  }
356
619
  /**
357
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`.
358
622
  */
359
623
  async fetchJwks() {
360
- 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);
361
639
  if (!res.ok)
362
640
  throw new DaywalkerError(`Could not fetch JWKS (HTTP ${res.status})`, res.status);
363
- 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 };
364
653
  }
365
654
  /**
366
- * 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.
367
657
  */
368
658
  async keys() {
369
659
  const key = await this.getLoadedSigningKey();
370
- 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 };
371
667
  }
372
668
  /**
373
669
  * Check configuration and verify signature readiness.
@@ -381,6 +677,9 @@ export class Daywalker {
381
677
  if (!signingKey) {
382
678
  problems.push("DAYWALKER_SIGNING_KEY is not set: requests will be sent without RFC 9421 signature");
383
679
  }
680
+ await this.getLoadedPreviousSigningKey().catch((err) => {
681
+ problems.push(`Previous signing key load error: ${err.message}`);
682
+ });
384
683
  const keyId = signingKey?.publicJwk.kid ?? null;
385
684
  const offlineVerificationReady = Boolean(this._config.daywalkerPublicKey);
386
685
  return {
package/dist/config.d.ts CHANGED
@@ -1,22 +1,34 @@
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;
12
+ leaseIssuer: string | null;
11
13
  authUrl: string;
12
14
  cacheTtlMs: number;
13
15
  negativeCacheTtlMs: number;
14
16
  maxRetries: number;
17
+ /** `config.retry` with undefined fields dropped; per-request `init.retry` is layered on top. */
18
+ retry: RetryOptions;
19
+ timeoutMs: number;
15
20
  problems: string[];
16
21
  }
22
+ /** Default per-attempt timeout for requests to Daywalker Auth. */
23
+ export declare const DEFAULT_TIMEOUT_MS = 5000;
17
24
  /**
18
25
  * A public hostname from a URL or bare host, or null for anything that can't be fetched over
19
26
  * the internet (localhost, 0.0.0.0, IP addresses, single labels).
20
27
  */
21
28
  export declare function publicHostname(value: string | undefined | null): string | null;
29
+ /**
30
+ * Throws unless `value` is an https: URL (http: is allowed only for localhost / 127.0.0.1 / [::1]),
31
+ * so API keys, SDK keys and write keys never travel in cleartext.
32
+ */
33
+ export declare function assertSecureUrl(name: string, value: string): void;
22
34
  export declare function resolveConfig(options?: DaywalkerConfig): ResolvedConfig;