@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.
Files changed (55) hide show
  1. package/README.md +31 -2
  2. package/dist/analytics/index.d.ts +108 -0
  3. package/dist/analytics/index.js +371 -0
  4. package/dist/cli.js +5 -0
  5. package/dist/client.d.ts +15 -1
  6. package/dist/client.js +59 -12
  7. package/dist/config.d.ts +6 -0
  8. package/dist/config.js +22 -0
  9. package/dist/crypto/signatures.d.ts +18 -5
  10. package/dist/crypto/signatures.js +100 -41
  11. package/dist/flags/client.d.ts +102 -0
  12. package/dist/flags/client.js +302 -0
  13. package/dist/flags/common/events.d.ts +59 -0
  14. package/dist/flags/common/events.js +204 -0
  15. package/dist/flags/common/index.d.ts +5 -0
  16. package/dist/flags/common/index.js +12 -0
  17. package/dist/flags/common/sse.d.ts +62 -0
  18. package/dist/flags/common/sse.js +183 -0
  19. package/dist/flags/engine/bucket.d.ts +21 -0
  20. package/dist/flags/engine/bucket.js +41 -0
  21. package/dist/flags/engine/context.d.ts +30 -0
  22. package/dist/flags/engine/context.js +81 -0
  23. package/dist/flags/engine/evaluate.d.ts +54 -0
  24. package/dist/flags/engine/evaluate.js +177 -0
  25. package/dist/flags/engine/index.d.ts +7 -0
  26. package/dist/flags/engine/index.js +7 -0
  27. package/dist/flags/engine/operators.d.ts +13 -0
  28. package/dist/flags/engine/operators.js +139 -0
  29. package/dist/flags/engine/semver.d.ts +13 -0
  30. package/dist/flags/engine/semver.js +35 -0
  31. package/dist/flags/engine/sha1.d.ts +6 -0
  32. package/dist/flags/engine/sha1.js +43 -0
  33. package/dist/flags/engine/types.d.ts +120 -0
  34. package/dist/flags/engine/types.js +17 -0
  35. package/dist/flags/node.d.ts +97 -0
  36. package/dist/flags/node.js +263 -0
  37. package/dist/flags/react.d.ts +27 -0
  38. package/dist/flags/react.js +62 -0
  39. package/dist/index.d.ts +1 -1
  40. package/dist/index.js +1 -1
  41. package/dist/next.d.ts +15 -2
  42. package/dist/next.js +41 -9
  43. package/dist/openfeature/server.d.ts +26 -0
  44. package/dist/openfeature/server.js +42 -0
  45. package/dist/openfeature/shared.d.ts +19 -0
  46. package/dist/openfeature/shared.js +40 -0
  47. package/dist/openfeature/web.d.ts +29 -0
  48. package/dist/openfeature/web.js +50 -0
  49. package/dist/types.d.ts +3 -0
  50. package/docs/analytics.md +32 -0
  51. package/docs/flags-node.md +49 -0
  52. package/docs/flags-react.md +35 -0
  53. package/docs/flags.md +53 -0
  54. package/docs/openfeature.md +30 -0
  55. 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 tokens with memory caching and discriminated union return.
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 ?? this._config.apiKey ?? undefined;
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: "CONFIGURATION_ERROR",
185
- status: 400,
213
+ error: "INVALID_TOKEN",
214
+ status: 401,
186
215
  message: "Missing token to validate",
187
216
  };
188
217
  }
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;
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
- this._cache.set(cacheKey, { result, expiresAt: Date.now() + this._config.cacheTtlMs });
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._cache.set(cacheKey, { result, expiresAt: Date.now() + negTtl });
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._cache.set(cacheKey, { result, expiresAt: Date.now() + this._config.cacheTtlMs });
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 LRU / sliding cache for nonces / signature replay protection.
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
- private readonly ttlMs;
21
- constructor(ttlSeconds?: number);
22
- checkAndRecord(signatureId: string): boolean;
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 LRU / sliding cache for nonces / signature replay protection.
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
- ttlMs;
39
- constructor(ttlSeconds = 360) {
40
- this.ttlMs = ttlSeconds * 1000;
41
- }
42
- checkAndRecord(signatureId) {
43
- const now = Date.now();
44
- if (this.seen.size > 5000) {
45
- for (const [k, exp] of this.seen.entries()) {
46
- if (exp <= now)
47
- this.seen.delete(k);
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
- const expiresAt = this.seen.get(signatureId);
51
- if (expiresAt && expiresAt > now) {
52
- return false; // Replay detected
63
+ while (this.seen.size > this.maxEntries) {
64
+ this.seen.delete(this.seen.keys().next().value);
53
65
  }
54
- this.seen.set(signatureId, now + this.ttlMs);
55
- return true;
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
- const sigMatch = signatureHeader.match(new RegExp(`(?:^|,\\s*)${SIGNATURE_LABEL}=:([A-Za-z0-9+/=_-]+):`));
180
- if (!sigMatch) {
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
- if (payload.exp && typeof payload.exp === "number" && payload.exp < now) {
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 === "number" && payload.nbf > now + 60) {
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
- if (params.expectedServiceSlug) {
278
- const tokenSlug = (payload.serviceSlug || payload.aud || "").toLowerCase();
279
- if (tokenSlug && tokenSlug !== params.expectedServiceSlug.toLowerCase()) {
280
- return {
281
- valid: false,
282
- error: `JWT audience mismatch: expected ${params.expectedServiceSlug}, got ${tokenSlug}`,
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;