@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.
- package/README.md +38 -24
- package/dist/analytics/index.d.ts +26 -3
- package/dist/analytics/index.js +107 -23
- package/dist/cli.js +136 -29
- package/dist/client.d.ts +44 -3
- package/dist/client.js +372 -73
- package/dist/config.d.ts +14 -2
- package/dist/config.js +36 -0
- package/dist/crypto/encoding.d.ts +5 -0
- package/dist/crypto/encoding.js +14 -0
- package/dist/crypto/index.d.ts +3 -3
- package/dist/crypto/index.js +2 -2
- package/dist/crypto/keys.d.ts +3 -3
- package/dist/crypto/keys.js +28 -7
- package/dist/crypto/signatures.d.ts +54 -11
- package/dist/crypto/signatures.js +310 -94
- package/dist/express.d.ts +1 -0
- package/dist/express.js +8 -6
- package/dist/flags/client.d.ts +34 -5
- package/dist/flags/client.js +93 -22
- package/dist/flags/common/events.d.ts +9 -0
- package/dist/flags/common/events.js +77 -6
- package/dist/flags/common/index.d.ts +3 -3
- package/dist/flags/common/index.js +3 -3
- package/dist/flags/common/sse.d.ts +24 -3
- package/dist/flags/common/sse.js +85 -15
- package/dist/flags/common/version.d.ts +2 -0
- package/dist/flags/common/version.js +2 -0
- package/dist/flags/engine/context.js +4 -3
- package/dist/flags/engine/index.d.ts +1 -1
- package/dist/flags/engine/index.js +1 -1
- package/dist/flags/engine/operators.d.ts +25 -0
- package/dist/flags/engine/operators.js +64 -8
- package/dist/flags/node.d.ts +27 -15
- package/dist/flags/node.js +54 -7
- package/dist/flags/react.d.ts +6 -4
- package/dist/flags/react.js +47 -17
- package/dist/index.d.ts +7 -6
- package/dist/index.js +4 -4
- package/dist/middleware.d.ts +18 -0
- package/dist/middleware.js +28 -7
- package/dist/next.d.ts +63 -6
- package/dist/next.js +182 -36
- package/dist/openfeature/server.d.ts +3 -0
- package/dist/openfeature/server.js +14 -2
- package/dist/openfeature/shared.d.ts +1 -1
- package/dist/openfeature/shared.js +22 -2
- package/dist/openfeature/web.js +9 -3
- package/dist/types.d.ts +58 -3
- package/dist/types.js +6 -0
- package/docs/analytics.md +1 -1
- package/docs/flags-node.md +1 -1
- package/docs/flags.md +2 -1
- package/package.json +34 -16
- 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
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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 (
|
|
99
|
-
headers.set("content-type",
|
|
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:
|
|
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
|
-
...
|
|
302
|
+
...requestInit,
|
|
126
303
|
method,
|
|
127
304
|
headers,
|
|
128
|
-
body:
|
|
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 (
|
|
314
|
+
if (callerSignal?.aborted)
|
|
315
|
+
throw err;
|
|
316
|
+
if (retryNetworkErrors && body.replayable && attempt < maxRetries) {
|
|
136
317
|
attempt++;
|
|
137
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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 = (
|
|
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
|
-
|
|
154
|
-
|
|
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
|
|
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 ??
|
|
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: "
|
|
185
|
-
status:
|
|
395
|
+
error: "INVALID_TOKEN",
|
|
396
|
+
status: 401,
|
|
186
397
|
message: "Missing token to validate",
|
|
187
398
|
};
|
|
188
399
|
}
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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.
|
|
544
|
+
this._cacheSet(cacheKey, result, this._config.cacheTtlMs);
|
|
292
545
|
}
|
|
293
546
|
return result;
|
|
294
547
|
}
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|