@shelbyb/daywalker-sdk 0.1.4 → 0.3.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 +31 -2
- package/dist/analytics/index.d.ts +108 -0
- package/dist/analytics/index.js +371 -0
- package/dist/cli.js +5 -0
- package/dist/client.d.ts +15 -1
- package/dist/client.js +59 -12
- package/dist/config.d.ts +6 -0
- package/dist/config.js +22 -0
- package/dist/crypto/signatures.d.ts +18 -5
- package/dist/crypto/signatures.js +100 -41
- package/dist/flags/client.d.ts +102 -0
- package/dist/flags/client.js +302 -0
- package/dist/flags/common/events.d.ts +59 -0
- package/dist/flags/common/events.js +204 -0
- package/dist/flags/common/index.d.ts +5 -0
- package/dist/flags/common/index.js +12 -0
- package/dist/flags/common/sse.d.ts +62 -0
- package/dist/flags/common/sse.js +183 -0
- package/dist/flags/engine/bucket.d.ts +21 -0
- package/dist/flags/engine/bucket.js +41 -0
- package/dist/flags/engine/context.d.ts +30 -0
- package/dist/flags/engine/context.js +81 -0
- package/dist/flags/engine/evaluate.d.ts +54 -0
- package/dist/flags/engine/evaluate.js +177 -0
- package/dist/flags/engine/index.d.ts +7 -0
- package/dist/flags/engine/index.js +7 -0
- package/dist/flags/engine/operators.d.ts +13 -0
- package/dist/flags/engine/operators.js +139 -0
- package/dist/flags/engine/semver.d.ts +13 -0
- package/dist/flags/engine/semver.js +35 -0
- package/dist/flags/engine/sha1.d.ts +6 -0
- package/dist/flags/engine/sha1.js +43 -0
- package/dist/flags/engine/types.d.ts +120 -0
- package/dist/flags/engine/types.js +17 -0
- package/dist/flags/node.d.ts +97 -0
- package/dist/flags/node.js +263 -0
- package/dist/flags/react.d.ts +27 -0
- package/dist/flags/react.js +62 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/next.d.ts +15 -2
- package/dist/next.js +41 -9
- package/dist/openfeature/server.d.ts +26 -0
- package/dist/openfeature/server.js +42 -0
- package/dist/openfeature/shared.d.ts +19 -0
- package/dist/openfeature/shared.js +40 -0
- package/dist/openfeature/web.d.ts +29 -0
- package/dist/openfeature/web.js +50 -0
- package/dist/types.d.ts +3 -0
- package/docs/analytics.md +32 -0
- package/docs/flags-node.md +49 -0
- package/docs/flags-react.md +35 -0
- package/docs/flags.md +53 -0
- package/docs/openfeature.md +30 -0
- package/package.json +62 -7
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,6 +23,8 @@ 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;
|
|
26
28
|
export class Daywalker {
|
|
27
29
|
_config;
|
|
28
30
|
_rawConfig;
|
|
@@ -68,6 +70,11 @@ export class Daywalker {
|
|
|
68
70
|
async fetch(path, init) {
|
|
69
71
|
const authUrl = this._config.authUrl;
|
|
70
72
|
const url = new URL(path, authUrl);
|
|
73
|
+
// Absolute URLs to other hosts would otherwise receive the API key and a valid signature.
|
|
74
|
+
const authOrigin = new URL(authUrl).origin;
|
|
75
|
+
if (url.origin !== authOrigin) {
|
|
76
|
+
throw new DaywalkerError(`Refusing to send Daywalker credentials to ${url.origin}; fetch() only targets ${authOrigin}`);
|
|
77
|
+
}
|
|
71
78
|
const method = (init?.method ?? (init?.body !== undefined ? "POST" : "GET")).toUpperCase();
|
|
72
79
|
const doFetch = this._rawConfig.fetch ?? globalThis.fetch;
|
|
73
80
|
const retryOpts = {
|
|
@@ -157,9 +164,31 @@ export class Daywalker {
|
|
|
157
164
|
return res;
|
|
158
165
|
}
|
|
159
166
|
}
|
|
167
|
+
_cacheGet(key) {
|
|
168
|
+
const entry = this._cache.get(key);
|
|
169
|
+
if (!entry)
|
|
170
|
+
return undefined;
|
|
171
|
+
this._cache.delete(key);
|
|
172
|
+
if (entry.expiresAt <= Date.now())
|
|
173
|
+
return undefined;
|
|
174
|
+
this._cache.set(key, entry); // most recently used goes last
|
|
175
|
+
return entry.result;
|
|
176
|
+
}
|
|
177
|
+
_cacheSet(key, result, ttlMs, notAfterMs = Infinity) {
|
|
178
|
+
const expiresAt = Math.min(Date.now() + ttlMs, notAfterMs);
|
|
179
|
+
if (expiresAt <= Date.now())
|
|
180
|
+
return;
|
|
181
|
+
this._cache.delete(key);
|
|
182
|
+
this._cache.set(key, { result, expiresAt });
|
|
183
|
+
while (this._cache.size > MAX_VALIDATION_CACHE_ENTRIES) {
|
|
184
|
+
this._cache.delete(this._cache.keys().next().value);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
160
187
|
/**
|
|
161
|
-
* Validates incoming bearer
|
|
188
|
+
* Validates an incoming bearer token with memory caching and discriminated union return.
|
|
162
189
|
* Supports zero-egress offline verification for JWT token leases when Daywalker's public key is configured.
|
|
190
|
+
* A missing token is always rejected (401); it never falls back to this service's own API key —
|
|
191
|
+
* use `validateServiceKey()` for that.
|
|
163
192
|
*/
|
|
164
193
|
async validateToken(tokenOrOptions, options) {
|
|
165
194
|
let token;
|
|
@@ -173,7 +202,7 @@ export class Daywalker {
|
|
|
173
202
|
offlineOnly = options?.offlineOnly ?? false;
|
|
174
203
|
}
|
|
175
204
|
else {
|
|
176
|
-
token = tokenOrOptions?.token ??
|
|
205
|
+
token = tokenOrOptions?.token ?? undefined;
|
|
177
206
|
serviceSlug = (tokenOrOptions?.serviceSlug ?? this._config.serviceSlug ?? "").toLowerCase();
|
|
178
207
|
forceRefresh = tokenOrOptions?.forceRefresh ?? false;
|
|
179
208
|
offlineOnly = tokenOrOptions?.offlineOnly ?? false;
|
|
@@ -181,15 +210,16 @@ export class Daywalker {
|
|
|
181
210
|
if (!token) {
|
|
182
211
|
return {
|
|
183
212
|
ok: false,
|
|
184
|
-
error: "
|
|
185
|
-
status:
|
|
213
|
+
error: "INVALID_TOKEN",
|
|
214
|
+
status: 401,
|
|
186
215
|
message: "Missing token to validate",
|
|
187
216
|
};
|
|
188
217
|
}
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
218
|
+
// Keyed by a hash so raw tokens are never held as cache keys.
|
|
219
|
+
const cacheKey = `${serviceSlug || "default"}:${await sha256Base64Url(token)}`;
|
|
220
|
+
const cached = forceRefresh ? undefined : this._cacheGet(cacheKey);
|
|
221
|
+
if (cached) {
|
|
222
|
+
return cached;
|
|
193
223
|
}
|
|
194
224
|
// Check for offline JWT lease verification
|
|
195
225
|
if (this._config.daywalkerPublicKey && token.split(".").length === 3) {
|
|
@@ -197,6 +227,7 @@ export class Daywalker {
|
|
|
197
227
|
token,
|
|
198
228
|
publicKey: this._config.daywalkerPublicKey,
|
|
199
229
|
expectedServiceSlug: serviceSlug || undefined,
|
|
230
|
+
expectedIssuer: this._config.leaseIssuer ?? undefined,
|
|
200
231
|
});
|
|
201
232
|
if (jwtResult.valid && jwtResult.payload) {
|
|
202
233
|
const payload = jwtResult.payload;
|
|
@@ -220,7 +251,8 @@ export class Daywalker {
|
|
|
220
251
|
},
|
|
221
252
|
};
|
|
222
253
|
if (this._config.cacheTtlMs > 0) {
|
|
223
|
-
|
|
254
|
+
// Never cache a lease past its own expiry.
|
|
255
|
+
this._cacheSet(cacheKey, result, this._config.cacheTtlMs, payload.exp * 1000);
|
|
224
256
|
}
|
|
225
257
|
return result;
|
|
226
258
|
}
|
|
@@ -256,7 +288,7 @@ export class Daywalker {
|
|
|
256
288
|
const negTtl = this._config.negativeCacheTtlMs;
|
|
257
289
|
const fail = (result) => {
|
|
258
290
|
if (negTtl > 0 && result.error !== "RATE_LIMITED" && result.error !== "NETWORK_ERROR") {
|
|
259
|
-
this.
|
|
291
|
+
this._cacheSet(cacheKey, result, negTtl);
|
|
260
292
|
}
|
|
261
293
|
return result;
|
|
262
294
|
};
|
|
@@ -288,7 +320,7 @@ export class Daywalker {
|
|
|
288
320
|
data,
|
|
289
321
|
};
|
|
290
322
|
if (this._config.cacheTtlMs > 0) {
|
|
291
|
-
this.
|
|
323
|
+
this._cacheSet(cacheKey, result, this._config.cacheTtlMs);
|
|
292
324
|
}
|
|
293
325
|
return result;
|
|
294
326
|
}
|
|
@@ -326,6 +358,21 @@ export class Daywalker {
|
|
|
326
358
|
};
|
|
327
359
|
}
|
|
328
360
|
}
|
|
361
|
+
/**
|
|
362
|
+
* Service health check: validates this service's own configured API key (DAYWALKER_API_KEY).
|
|
363
|
+
* Never use this to authenticate an incoming request.
|
|
364
|
+
*/
|
|
365
|
+
async validateServiceKey(options) {
|
|
366
|
+
if (!this._config.apiKey) {
|
|
367
|
+
return {
|
|
368
|
+
ok: false,
|
|
369
|
+
error: "CONFIGURATION_ERROR",
|
|
370
|
+
status: 500,
|
|
371
|
+
message: "DAYWALKER_API_KEY is not set",
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
return this.validateToken(this._config.apiKey, options);
|
|
375
|
+
}
|
|
329
376
|
/**
|
|
330
377
|
* Throws DaywalkerAuthError or DaywalkerRateLimitError if token is invalid or rate limited.
|
|
331
378
|
*/
|
package/dist/config.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export interface ResolvedConfig {
|
|
|
8
8
|
domain: string | null;
|
|
9
9
|
domainSource: string | null;
|
|
10
10
|
daywalkerPublicKey: string | PublicJwk | null;
|
|
11
|
+
leaseIssuer: string | null;
|
|
11
12
|
authUrl: string;
|
|
12
13
|
cacheTtlMs: number;
|
|
13
14
|
negativeCacheTtlMs: number;
|
|
@@ -19,4 +20,9 @@ export interface ResolvedConfig {
|
|
|
19
20
|
* the internet (localhost, 0.0.0.0, IP addresses, single labels).
|
|
20
21
|
*/
|
|
21
22
|
export declare function publicHostname(value: string | undefined | null): string | null;
|
|
23
|
+
/**
|
|
24
|
+
* Throws unless `value` is an https: URL (http: is allowed only for localhost / 127.0.0.1 / [::1]),
|
|
25
|
+
* so API keys, SDK keys and write keys never travel in cleartext.
|
|
26
|
+
*/
|
|
27
|
+
export declare function assertSecureUrl(name: string, value: string): void;
|
|
22
28
|
export declare function resolveConfig(options?: DaywalkerConfig): ResolvedConfig;
|
package/dist/config.js
CHANGED
|
@@ -45,6 +45,25 @@ export function publicHostname(value) {
|
|
|
45
45
|
}
|
|
46
46
|
return DOMAIN_PATTERN.test(host) ? host : null;
|
|
47
47
|
}
|
|
48
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
49
|
+
/**
|
|
50
|
+
* Throws unless `value` is an https: URL (http: is allowed only for localhost / 127.0.0.1 / [::1]),
|
|
51
|
+
* so API keys, SDK keys and write keys never travel in cleartext.
|
|
52
|
+
*/
|
|
53
|
+
export function assertSecureUrl(name, value) {
|
|
54
|
+
let url;
|
|
55
|
+
try {
|
|
56
|
+
url = new URL(value);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw new Error(`${name} must be an absolute https:// URL (got "${value}")`);
|
|
60
|
+
}
|
|
61
|
+
if (url.protocol === "https:")
|
|
62
|
+
return;
|
|
63
|
+
if (url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname))
|
|
64
|
+
return;
|
|
65
|
+
throw new Error(`${name} must use https:// (http:// is only allowed for localhost); got "${value}"`);
|
|
66
|
+
}
|
|
48
67
|
export function resolveConfig(options = {}) {
|
|
49
68
|
const env = options.env ?? (typeof process !== "undefined" && process.env ? process.env : {});
|
|
50
69
|
const problems = [];
|
|
@@ -80,7 +99,9 @@ export function resolveConfig(options = {}) {
|
|
|
80
99
|
}
|
|
81
100
|
}
|
|
82
101
|
const daywalkerPublicKey = options.daywalkerPublicKey ?? (env.DAYWALKER_PUBLIC_KEY ? env.DAYWALKER_PUBLIC_KEY.trim() : null);
|
|
102
|
+
const leaseIssuer = (options.leaseIssuer ?? env.DAYWALKER_LEASE_ISSUER ?? "").trim() || null;
|
|
83
103
|
const authUrl = (options.authUrl ?? env.DAYWALKER_AUTH_URL ?? "https://auth.daywalker.dev").replace(/\/+$/, "");
|
|
104
|
+
assertSecureUrl("authUrl", authUrl);
|
|
84
105
|
const cacheTtlMs = options.cacheTtlMs ?? 60_000;
|
|
85
106
|
const negativeCacheTtlMs = options.negativeCacheTtlMs ?? 3_000;
|
|
86
107
|
const maxRetries = options.maxRetries ?? (options.retry?.maxRetries ?? 3);
|
|
@@ -93,6 +114,7 @@ export function resolveConfig(options = {}) {
|
|
|
93
114
|
domain,
|
|
94
115
|
domainSource,
|
|
95
116
|
daywalkerPublicKey,
|
|
117
|
+
leaseIssuer,
|
|
96
118
|
authUrl,
|
|
97
119
|
cacheTtlMs,
|
|
98
120
|
negativeCacheTtlMs,
|
|
@@ -12,14 +12,21 @@ export declare function normalizePath(rawPath: string): string;
|
|
|
12
12
|
* Validates that an identifier (keyId, serviceSlug, etc.) contains only safe alphanumeric/hyphen characters.
|
|
13
13
|
*/
|
|
14
14
|
export declare function assertSafeIdentifier(name: string, value: string): void;
|
|
15
|
+
/** Upper bound on remembered signatures; the oldest entries are evicted past this. */
|
|
16
|
+
export declare const REPLAY_CACHE_MAX_ENTRIES = 10000;
|
|
15
17
|
/**
|
|
16
|
-
* In-memory
|
|
18
|
+
* In-memory replay cache, keyed on keyid + decoded signature bytes (so re-encodings of the
|
|
19
|
+
* same signature collide). Insertion-ordered, so eviction drops the oldest entries first.
|
|
17
20
|
*/
|
|
18
|
-
declare class SignatureReplayCache {
|
|
21
|
+
export declare class SignatureReplayCache {
|
|
22
|
+
private readonly maxEntries;
|
|
19
23
|
private seen;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
24
|
+
constructor(maxEntries?: number);
|
|
25
|
+
get size(): number;
|
|
26
|
+
/** True when the key was recorded and has not expired yet (a replay). */
|
|
27
|
+
has(key: string, now?: number): boolean;
|
|
28
|
+
record(key: string, ttlMs: number, now?: number): void;
|
|
29
|
+
clear(): void;
|
|
23
30
|
}
|
|
24
31
|
export declare const globalReplayCache: SignatureReplayCache;
|
|
25
32
|
export type { SignedHeaders, SignRequestParams, VerifySignatureParams, VerifySignatureResult, };
|
|
@@ -53,14 +60,20 @@ export declare function signRequest(params: SignRequestParams): Promise<SignedHe
|
|
|
53
60
|
* Verify an incoming RFC 9421 HTTP Message Signature.
|
|
54
61
|
*/
|
|
55
62
|
export declare function verifyRequestSignature(params: VerifySignatureParams): Promise<VerifySignatureResult>;
|
|
63
|
+
/** Clock skew tolerated on JWT lease `exp` / `nbf`. */
|
|
64
|
+
export declare const JWT_CLOCK_SKEW_SECONDS = 60;
|
|
56
65
|
/**
|
|
57
66
|
* Zero-egress offline verification for asymmetric JWT token leases.
|
|
67
|
+
* Requires a numeric `exp` and an audience (`aud` or `serviceSlug`) equal to `expectedServiceSlug`;
|
|
68
|
+
* a verifier without an expected slug rejects every lease. `iss` is checked when `expectedIssuer` is set.
|
|
58
69
|
*/
|
|
59
70
|
export declare function verifyJwtLease(params: {
|
|
60
71
|
token: string;
|
|
61
72
|
publicKey: string | PublicJwk | CryptoKey;
|
|
62
73
|
expectedServiceSlug?: string;
|
|
74
|
+
expectedIssuer?: string;
|
|
63
75
|
currentTime?: number;
|
|
76
|
+
clockSkewSeconds?: number;
|
|
64
77
|
}): Promise<{
|
|
65
78
|
valid: boolean;
|
|
66
79
|
payload?: Record<string, unknown>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { base64ToBytes, base64UrlToBytes, bytesToBase64, stringToUtf8, utf8ToString, timingSafeEqualString, } from "./encoding.js";
|
|
1
|
+
import { base64ToBytes, base64UrlToBytes, bytesToBase64, bytesToHex, stringToUtf8, utf8ToString, timingSafeEqualString, } from "./encoding.js";
|
|
2
2
|
import { computeContentDigest } from "./digest.js";
|
|
3
3
|
import { loadPublicKey, loadSigningKey } from "./keys.js";
|
|
4
|
+
import { assertSecureUrl } from "../config.js";
|
|
4
5
|
export const SIGNATURE_VERSION = "rfc9421";
|
|
5
6
|
export const SIGNATURE_LABEL = "sig1";
|
|
6
7
|
export const SIGNATURE_ALG = "ed25519";
|
|
@@ -30,29 +31,41 @@ export function assertSafeIdentifier(name, value) {
|
|
|
30
31
|
throw new Error(`Invalid ${name}: must be 1-128 safe characters (alphanumeric, '_', '-', '.')`);
|
|
31
32
|
}
|
|
32
33
|
}
|
|
34
|
+
/** Upper bound on remembered signatures; the oldest entries are evicted past this. */
|
|
35
|
+
export const REPLAY_CACHE_MAX_ENTRIES = 10_000;
|
|
33
36
|
/**
|
|
34
|
-
* In-memory
|
|
37
|
+
* In-memory replay cache, keyed on keyid + decoded signature bytes (so re-encodings of the
|
|
38
|
+
* same signature collide). Insertion-ordered, so eviction drops the oldest entries first.
|
|
35
39
|
*/
|
|
36
|
-
class SignatureReplayCache {
|
|
40
|
+
export class SignatureReplayCache {
|
|
41
|
+
maxEntries;
|
|
37
42
|
seen = new Map();
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
43
|
+
constructor(maxEntries = REPLAY_CACHE_MAX_ENTRIES) {
|
|
44
|
+
this.maxEntries = maxEntries;
|
|
45
|
+
}
|
|
46
|
+
get size() {
|
|
47
|
+
return this.seen.size;
|
|
48
|
+
}
|
|
49
|
+
/** True when the key was recorded and has not expired yet (a replay). */
|
|
50
|
+
has(key, now = Date.now()) {
|
|
51
|
+
const expiresAt = this.seen.get(key);
|
|
52
|
+
return expiresAt !== undefined && expiresAt > now;
|
|
53
|
+
}
|
|
54
|
+
record(key, ttlMs, now = Date.now()) {
|
|
55
|
+
this.seen.delete(key);
|
|
56
|
+
this.seen.set(key, now + ttlMs);
|
|
57
|
+
if (this.seen.size <= this.maxEntries)
|
|
58
|
+
return;
|
|
59
|
+
for (const [k, exp] of this.seen) {
|
|
60
|
+
if (exp <= now)
|
|
61
|
+
this.seen.delete(k);
|
|
49
62
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
return false; // Replay detected
|
|
63
|
+
while (this.seen.size > this.maxEntries) {
|
|
64
|
+
this.seen.delete(this.seen.keys().next().value);
|
|
53
65
|
}
|
|
54
|
-
|
|
55
|
-
|
|
66
|
+
}
|
|
67
|
+
clear() {
|
|
68
|
+
this.seen.clear();
|
|
56
69
|
}
|
|
57
70
|
}
|
|
58
71
|
export const globalReplayCache = new SignatureReplayCache();
|
|
@@ -176,11 +189,16 @@ export async function verifyRequestSignature(params) {
|
|
|
176
189
|
}
|
|
177
190
|
seenComponents.add(c);
|
|
178
191
|
}
|
|
179
|
-
|
|
180
|
-
|
|
192
|
+
// RFC 8941 byte sequence (`:base64:`), standard alphabet with padding only, so each signature
|
|
193
|
+
// has exactly one accepted encoding.
|
|
194
|
+
const sigMatch = signatureHeader.match(new RegExp(`(?:^|,\\s*)${SIGNATURE_LABEL}=:([A-Za-z0-9+/]*={0,2}):(?=\\s*(?:,|$))`));
|
|
195
|
+
if (!sigMatch || sigMatch[1].length % 4 !== 0) {
|
|
196
|
+
return { valid: false, error: `Signature header does not contain valid ${SIGNATURE_LABEL} byte sequence` };
|
|
197
|
+
}
|
|
198
|
+
const signatureBytes = base64ToBytes(sigMatch[1]);
|
|
199
|
+
if (bytesToBase64(signatureBytes) !== sigMatch[1]) {
|
|
181
200
|
return { valid: false, error: `Signature header does not contain valid ${SIGNATURE_LABEL} byte sequence` };
|
|
182
201
|
}
|
|
183
|
-
const signatureBase64 = sigMatch[1].trim();
|
|
184
202
|
const createdMatch = signatureParams.match(/created=(\d+)/);
|
|
185
203
|
if (!createdMatch) {
|
|
186
204
|
return { valid: false, error: "Signature-Input missing created timestamp" };
|
|
@@ -191,12 +209,6 @@ export async function verifyRequestSignature(params) {
|
|
|
191
209
|
if (Math.abs(now - created) > maxSkew) {
|
|
192
210
|
return { valid: false, error: `Signature timestamp expired or clock skew too large (created=${created}, now=${now})` };
|
|
193
211
|
}
|
|
194
|
-
if (params.replayCheck !== false) {
|
|
195
|
-
const replayKey = `${signatureBase64}:${created}`;
|
|
196
|
-
if (!globalReplayCache.checkAndRecord(replayKey)) {
|
|
197
|
-
return { valid: false, error: "Replay detected: request signature was already presented" };
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
212
|
const keyidMatch = signatureParams.match(/keyid="([^"]+)"/);
|
|
201
213
|
const keyid = keyidMatch ? keyidMatch[1] : undefined;
|
|
202
214
|
if (keyid) {
|
|
@@ -207,6 +219,12 @@ export async function verifyRequestSignature(params) {
|
|
|
207
219
|
return { valid: false, error: e.message };
|
|
208
220
|
}
|
|
209
221
|
}
|
|
222
|
+
// Entries must outlive the whole acceptance window (created ± maxSkew).
|
|
223
|
+
const replayKey = `${keyid ?? ""}:${bytesToHex(signatureBytes)}`;
|
|
224
|
+
const replayTtlMs = 2 * Math.max(maxSkew, 1) * 1000;
|
|
225
|
+
if (params.replayCheck !== false && globalReplayCache.has(replayKey)) {
|
|
226
|
+
return { valid: false, error: "Replay detected: request signature was already presented" };
|
|
227
|
+
}
|
|
210
228
|
const algMatch = signatureParams.match(/alg="([^"]+)"/);
|
|
211
229
|
const alg = algMatch ? algMatch[1] : undefined;
|
|
212
230
|
if (alg && alg.toLowerCase() !== "ed25519" && alg.toLowerCase() !== "eddsa") {
|
|
@@ -230,21 +248,29 @@ export async function verifyRequestSignature(params) {
|
|
|
230
248
|
});
|
|
231
249
|
try {
|
|
232
250
|
const { publicKey } = await loadPublicKey(params.publicKey);
|
|
233
|
-
const signatureBytes = signatureBase64.includes("-") || signatureBase64.includes("_")
|
|
234
|
-
? base64UrlToBytes(signatureBase64)
|
|
235
|
-
: base64ToBytes(signatureBase64);
|
|
236
251
|
const isValid = await globalThis.crypto.subtle.verify("Ed25519", publicKey, signatureBytes, stringToUtf8(signatureBase));
|
|
237
252
|
if (!isValid) {
|
|
238
253
|
return { valid: false, error: "Cryptographic signature verification failed" };
|
|
239
254
|
}
|
|
255
|
+
// Record only verified signatures; re-check synchronously in case a concurrent copy won.
|
|
256
|
+
if (params.replayCheck !== false) {
|
|
257
|
+
if (globalReplayCache.has(replayKey)) {
|
|
258
|
+
return { valid: false, error: "Replay detected: request signature was already presented" };
|
|
259
|
+
}
|
|
260
|
+
globalReplayCache.record(replayKey, replayTtlMs);
|
|
261
|
+
}
|
|
240
262
|
return { valid: true, keyid };
|
|
241
263
|
}
|
|
242
264
|
catch (err) {
|
|
243
265
|
return { valid: false, error: `Verification error: ${err.message}` };
|
|
244
266
|
}
|
|
245
267
|
}
|
|
268
|
+
/** Clock skew tolerated on JWT lease `exp` / `nbf`. */
|
|
269
|
+
export const JWT_CLOCK_SKEW_SECONDS = 60;
|
|
246
270
|
/**
|
|
247
271
|
* Zero-egress offline verification for asymmetric JWT token leases.
|
|
272
|
+
* Requires a numeric `exp` and an audience (`aud` or `serviceSlug`) equal to `expectedServiceSlug`;
|
|
273
|
+
* a verifier without an expected slug rejects every lease. `iss` is checked when `expectedIssuer` is set.
|
|
248
274
|
*/
|
|
249
275
|
export async function verifyJwtLease(params) {
|
|
250
276
|
try {
|
|
@@ -257,9 +283,15 @@ export async function verifyJwtLease(params) {
|
|
|
257
283
|
const payloadJson = utf8ToString(base64UrlToBytes(payloadB64));
|
|
258
284
|
const header = JSON.parse(headerJson);
|
|
259
285
|
const payload = JSON.parse(payloadJson);
|
|
286
|
+
if (!header || typeof header !== "object" || !payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
287
|
+
return { valid: false, error: "JWT header and payload must be JSON objects" };
|
|
288
|
+
}
|
|
260
289
|
if (header.alg !== "EdDSA" && header.alg !== "ed25519") {
|
|
261
290
|
return { valid: false, error: `Unsupported JWT alg: ${header.alg}` };
|
|
262
291
|
}
|
|
292
|
+
if (header.typ !== undefined && String(header.typ).toUpperCase() !== "JWT") {
|
|
293
|
+
return { valid: false, error: `Unsupported JWT typ: ${header.typ}` };
|
|
294
|
+
}
|
|
263
295
|
const { publicKey } = await loadPublicKey(params.publicKey);
|
|
264
296
|
const signedData = stringToUtf8(`${headerB64}.${payloadB64}`);
|
|
265
297
|
const signatureBytes = base64UrlToBytes(signatureB64);
|
|
@@ -268,20 +300,41 @@ export async function verifyJwtLease(params) {
|
|
|
268
300
|
return { valid: false, error: "JWT signature verification failed" };
|
|
269
301
|
}
|
|
270
302
|
const now = params.currentTime ?? Math.floor(Date.now() / 1000);
|
|
271
|
-
|
|
303
|
+
const skew = Math.max(0, Math.min(params.clockSkewSeconds ?? JWT_CLOCK_SKEW_SECONDS, 300));
|
|
304
|
+
if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp)) {
|
|
305
|
+
return { valid: false, error: "JWT token lease has no numeric exp claim" };
|
|
306
|
+
}
|
|
307
|
+
if (payload.exp + skew <= now) {
|
|
272
308
|
return { valid: false, error: "JWT token lease has expired" };
|
|
273
309
|
}
|
|
274
|
-
if (payload.nbf && typeof payload.nbf
|
|
310
|
+
if (payload.nbf !== undefined && (typeof payload.nbf !== "number" || payload.nbf > now + skew)) {
|
|
275
311
|
return { valid: false, error: "JWT token lease not active yet" };
|
|
276
312
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
313
|
+
const expected = params.expectedServiceSlug?.trim().toLowerCase();
|
|
314
|
+
if (!expected) {
|
|
315
|
+
return { valid: false, error: "JWT audience cannot be checked: no expected service slug is configured" };
|
|
316
|
+
}
|
|
317
|
+
const audiences = [];
|
|
318
|
+
if (payload.serviceSlug !== undefined)
|
|
319
|
+
audiences.push(String(payload.serviceSlug));
|
|
320
|
+
if (Array.isArray(payload.aud))
|
|
321
|
+
audiences.push(...payload.aud.map(String));
|
|
322
|
+
else if (payload.aud !== undefined)
|
|
323
|
+
audiences.push(String(payload.aud));
|
|
324
|
+
if (audiences.length === 0) {
|
|
325
|
+
return { valid: false, error: "JWT token lease has no aud or serviceSlug claim" };
|
|
326
|
+
}
|
|
327
|
+
const serviceSlugOk = payload.serviceSlug === undefined || String(payload.serviceSlug).toLowerCase() === expected;
|
|
328
|
+
const audOk = payload.aud === undefined ||
|
|
329
|
+
(Array.isArray(payload.aud) ? payload.aud : [payload.aud]).some((a) => String(a).toLowerCase() === expected);
|
|
330
|
+
if (!serviceSlugOk || !audOk) {
|
|
331
|
+
return {
|
|
332
|
+
valid: false,
|
|
333
|
+
error: `JWT audience mismatch: expected ${expected}, got ${audiences.join(", ").toLowerCase()}`,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
if (params.expectedIssuer && payload.iss !== params.expectedIssuer) {
|
|
337
|
+
return { valid: false, error: `JWT issuer mismatch: expected ${params.expectedIssuer}, got ${payload.iss ?? "(none)"}` };
|
|
285
338
|
}
|
|
286
339
|
return { valid: true, payload };
|
|
287
340
|
}
|
|
@@ -294,6 +347,12 @@ export async function verifyJwtLease(params) {
|
|
|
294
347
|
*/
|
|
295
348
|
export async function registerPublicKey(params) {
|
|
296
349
|
const authUrl = (params.authUrl ?? "https://auth.daywalker.dev").replace(/\/+$/, "");
|
|
350
|
+
try {
|
|
351
|
+
assertSecureUrl("authUrl", authUrl);
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
return { ok: false, status: 0, error: err.message };
|
|
355
|
+
}
|
|
297
356
|
const registerUrl = `${authUrl}/api/v1/keys/register`;
|
|
298
357
|
const doFetch = params.fetchFn ?? globalThis.fetch;
|
|
299
358
|
if (!params.domain) {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { ContextInput, EvaluationReason, FlagValue } from "./engine/index.js";
|
|
2
|
+
import { type Logger } from "./common/index.js";
|
|
3
|
+
export type { ContextInput, EvaluationReason, FlagValue, Logger };
|
|
4
|
+
export interface FlagState {
|
|
5
|
+
value: FlagValue;
|
|
6
|
+
variation: number | null;
|
|
7
|
+
version: number;
|
|
8
|
+
reason?: EvaluationReason;
|
|
9
|
+
trackEvents?: boolean;
|
|
10
|
+
trackReason?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export type FlagSet = Record<string, FlagState>;
|
|
13
|
+
export interface ClientOptions {
|
|
14
|
+
/** Base URL of your DevPulse deployment, e.g. https://flags.example.com (https required except on localhost). */
|
|
15
|
+
baseUrl: string;
|
|
16
|
+
/** Receive changes in real time over Server-Sent Events (default true). */
|
|
17
|
+
streaming?: boolean;
|
|
18
|
+
/** Initial flag values, e.g. from the Node SDK's allFlagsState() for SSR. */
|
|
19
|
+
bootstrap?: FlagSet | Record<string, FlagValue>;
|
|
20
|
+
/** Secure mode hash for the context (HMAC-SHA256 computed on your server). */
|
|
21
|
+
hash?: string;
|
|
22
|
+
/** Include evaluation reasons (for variationDetail). */
|
|
23
|
+
withReasons?: boolean;
|
|
24
|
+
sendEvents?: boolean;
|
|
25
|
+
flushIntervalMs?: number;
|
|
26
|
+
privateAttributes?: string[];
|
|
27
|
+
allAttributesPrivate?: boolean;
|
|
28
|
+
/** Cache the last flags in localStorage and use them while loading. */
|
|
29
|
+
cache?: boolean;
|
|
30
|
+
fetch?: typeof fetch;
|
|
31
|
+
logger?: Logger;
|
|
32
|
+
}
|
|
33
|
+
export interface EvaluationDetail<T = FlagValue> {
|
|
34
|
+
value: T;
|
|
35
|
+
variationIndex: number | null;
|
|
36
|
+
reason: EvaluationReason | null;
|
|
37
|
+
}
|
|
38
|
+
type Listener = (...args: unknown[]) => void;
|
|
39
|
+
/**
|
|
40
|
+
* A client for one evaluation context. Fetches evaluated flags, keeps them
|
|
41
|
+
* current over SSE, and reports analytics.
|
|
42
|
+
*
|
|
43
|
+
* const client = createClient("<client-side id>", { key: user.id }, { baseUrl });
|
|
44
|
+
* await client.waitForInitialization();
|
|
45
|
+
* client.on("change:new-checkout", (value) => render(value));
|
|
46
|
+
* client.variation("new-checkout", false);
|
|
47
|
+
*/
|
|
48
|
+
export declare class DevPulseClient {
|
|
49
|
+
private readonly clientSideId;
|
|
50
|
+
private context;
|
|
51
|
+
private readonly options;
|
|
52
|
+
private flags;
|
|
53
|
+
private listeners;
|
|
54
|
+
private stream;
|
|
55
|
+
private events;
|
|
56
|
+
private ready;
|
|
57
|
+
private resolveReady;
|
|
58
|
+
private rejectReady;
|
|
59
|
+
private initialized;
|
|
60
|
+
private closed;
|
|
61
|
+
private readonly logger;
|
|
62
|
+
private readonly base;
|
|
63
|
+
constructor(clientSideId: string, context: ContextInput, options: ClientOptions);
|
|
64
|
+
private query;
|
|
65
|
+
private start;
|
|
66
|
+
private fetchFlags;
|
|
67
|
+
private onStreamEvent;
|
|
68
|
+
private replaceAll;
|
|
69
|
+
private flag;
|
|
70
|
+
private markReady;
|
|
71
|
+
private fail;
|
|
72
|
+
private emitChanges;
|
|
73
|
+
private cacheKey;
|
|
74
|
+
private readCache;
|
|
75
|
+
private writeCache;
|
|
76
|
+
/** Resolves once flags are loaded (or rejects on a fatal error such as an invalid client-side ID). */
|
|
77
|
+
waitForInitialization(opts?: {
|
|
78
|
+
timeoutMs?: number;
|
|
79
|
+
}): Promise<void>;
|
|
80
|
+
get isInitialized(): boolean;
|
|
81
|
+
variation<T extends FlagValue>(key: string, defaultValue: T): T;
|
|
82
|
+
variationDetail<T extends FlagValue>(key: string, defaultValue: T): EvaluationDetail<T>;
|
|
83
|
+
boolVariation(key: string, defaultValue: boolean): boolean;
|
|
84
|
+
stringVariation(key: string, defaultValue: string): string;
|
|
85
|
+
numberVariation(key: string, defaultValue: number): number;
|
|
86
|
+
jsonVariation<T extends FlagValue>(key: string, defaultValue: T): T;
|
|
87
|
+
/** All flag values (doesn't record evaluations). */
|
|
88
|
+
allFlags(): Record<string, FlagValue>;
|
|
89
|
+
/** Switch to a different context (e.g. after login). Resolves with the new flags. */
|
|
90
|
+
identify(context: ContextInput, opts?: {
|
|
91
|
+
hash?: string;
|
|
92
|
+
}): Promise<Record<string, FlagValue>>;
|
|
93
|
+
getContext(): ContextInput;
|
|
94
|
+
/** Record a custom event for metrics and experiments. */
|
|
95
|
+
track(key: string, data?: unknown, metricValue?: number): void;
|
|
96
|
+
on(event: "ready" | "failed" | "change" | `change:${string}`, fn: Listener): () => void;
|
|
97
|
+
off(event: string, fn: Listener): void;
|
|
98
|
+
private emit;
|
|
99
|
+
flush(): Promise<void>;
|
|
100
|
+
close(): Promise<void>;
|
|
101
|
+
}
|
|
102
|
+
export declare function createClient(clientSideId: string, context: ContextInput, options: ClientOptions): DevPulseClient;
|