@shelbyb/daywalker-sdk 0.2.0 → 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 CHANGED
@@ -62,7 +62,8 @@ The private key is never printed unless you pass `--print-key`.
62
62
  | `DAYWALKER_SIGNING_KEY_PREVIOUS` | during rotation | Previous private key, maintained during deployment transitions |
63
63
  | `DAYWALKER_DOMAIN` | optional | Optional caller identifier or host domain name |
64
64
  | `DAYWALKER_PUBLIC_KEY` | optional | Daywalker Auth public key for zero-egress offline JWT lease verification |
65
- | `DAYWALKER_AUTH_URL` | optional | Defaults to `https://auth.daywalker.dev` |
65
+ | `DAYWALKER_AUTH_URL` | optional | Defaults to `https://auth.daywalker.dev`; must be `https://` (except localhost) |
66
+ | `DAYWALKER_LEASE_ISSUER` | optional | Expected `iss` of offline-verified JWT leases |
66
67
 
67
68
  ---
68
69
 
@@ -81,7 +82,8 @@ const client = new Daywalker({
81
82
  });
82
83
 
83
84
  // Option 2: Default lazy singleton (reads process.env automatically)
84
- const result = await daywalker.validateToken();
85
+ const result = await daywalker.validateToken(incomingToken);
86
+ // Service health check of this service's own DAYWALKER_API_KEY: daywalker.validateServiceKey()
85
87
 
86
88
  if (result.ok) {
87
89
  console.log("Validated tenant:", result.data.tenantId);
@@ -135,6 +137,8 @@ export const config = {
135
137
  };
136
138
  ```
137
139
 
140
+ Each request must carry `Authorization: Bearer <token>` or `x-api-token`; without one, API routes get `401` and pages redirect to `errorPath`. Only `/_next/static/`, `/_next/image`, the favicon and static file extensions outside `/api/` skip auth. To gate the app on this service's own API key instead (the self-healing error page), use `createDaywalkerServiceHealthMiddleware` with the same options.
141
+
138
142
  ### Route Handler Wrapper
139
143
 
140
144
  ```ts
@@ -181,6 +185,8 @@ const client = new Daywalker({
181
185
  const result = await client.validateToken(incomingJwt);
182
186
  ```
183
187
 
188
+ Leases must have a numeric `exp` and an `aud` (or `serviceSlug`) equal to your service slug, so `serviceSlug` must be configured. Set `leaseIssuer` (or `DAYWALKER_LEASE_ISSUER`) to also check `iss`.
189
+
184
190
  ---
185
191
 
186
192
  ## CLI Reference
@@ -11,7 +11,7 @@ export type FlagSource = {
11
11
  export type AnalyticsOptions = {
12
12
  /** The project's public write key (dpa_…). */
13
13
  writeKey: string;
14
- /** Your DevPulse URL, e.g. https://devpulse.example.com */
14
+ /** Your DevPulse URL, e.g. https://devpulse.example.com (https required except on localhost). */
15
15
  host: string;
16
16
  /** Capture $pageview on load and on client-side navigation (browsers). Default true. */
17
17
  autocapturePageviews?: boolean;
@@ -31,6 +31,12 @@ export type AnalyticsOptions = {
31
31
  respectDoNotTrack?: boolean;
32
32
  /** Start opted out (e.g. until the user accepts a consent banner). */
33
33
  optedOut?: boolean;
34
+ /**
35
+ * Send $current_url / $referrer with their full query string and fragment. Default false:
36
+ * the fragment is dropped and only campaign parameters (utm_*, gclid, fbclid, ref) are kept,
37
+ * so OAuth codes, reset tokens and the like never leave the page.
38
+ */
39
+ captureFullUrls?: boolean;
34
40
  fetch?: typeof fetch;
35
41
  onError?: (err: Error) => void;
36
42
  };
@@ -42,6 +48,8 @@ export type CaptureOptions = {
42
48
  $set_once?: Properties;
43
49
  };
44
50
  export declare const VERSION = "0.1.0";
51
+ /** Drop the fragment and every query parameter outside the campaign allowlist. */
52
+ export declare function sanitizeUrl(raw: string): string;
45
53
  /** Time-ordered UUID (v7). */
46
54
  export declare function uuidv7(ms?: number): string;
47
55
  export declare class Analytics {
@@ -85,6 +93,7 @@ export declare class Analytics {
85
93
  shutdown(): Promise<void>;
86
94
  private disabled;
87
95
  private context;
96
+ private url;
88
97
  private flagProps;
89
98
  /** Rolling session: renewed after inactivity or at most every 24 hours. */
90
99
  private session;
@@ -1,9 +1,24 @@
1
- /**
2
- * DevPulse product analytics. Works in browsers (automatic page views,
3
- * sessions, persistence, sendBeacon on page hide) and in Node (pass a
4
- * distinctId per call). Events are batched and retried with backoff.
5
- */
1
+ import { assertSecureUrl } from "../config.js";
6
2
  export const VERSION = "0.1.0";
3
+ const KEPT_QUERY_PARAMS = new Set(["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "gclid", "fbclid", "ref"]);
4
+ /** Drop the fragment and every query parameter outside the campaign allowlist. */
5
+ export function sanitizeUrl(raw) {
6
+ if (!raw)
7
+ return raw;
8
+ let url;
9
+ try {
10
+ url = new URL(raw);
11
+ }
12
+ catch {
13
+ return raw.split(/[?#]/)[0];
14
+ }
15
+ url.hash = "";
16
+ for (const key of [...url.searchParams.keys()]) {
17
+ if (!KEPT_QUERY_PARAMS.has(key))
18
+ url.searchParams.delete(key);
19
+ }
20
+ return url.toString();
21
+ }
7
22
  const isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
8
23
  const MAX_SESSION_MS = 24 * 3_600_000;
9
24
  /** Time-ordered UUID (v7). */
@@ -73,6 +88,7 @@ export class Analytics {
73
88
  throw new Error("writeKey is required");
74
89
  if (!options.host)
75
90
  throw new Error("host is required");
91
+ assertSecureUrl("host", options.host);
76
92
  this.opts = { flushAt: 20, flushInterval: isBrowser ? 2000 : 10000, maxQueueSize: 1000, sessionTimeoutMinutes: 30, ...options };
77
93
  this.endpoint = `${options.host.replace(/\/+$/, "")}/a/batch`;
78
94
  this.store = new Store(`dp_analytics_${options.writeKey}`, (options.persistence ?? (isBrowser ? "localStorage" : "memory")) === "localStorage" && isBrowser);
@@ -103,13 +119,13 @@ export class Analytics {
103
119
  pageview(properties = {}) {
104
120
  if (!isBrowser)
105
121
  return this.capture("$pageview", properties);
106
- const url = location.href;
107
- this.lastUrl = url;
122
+ this.lastUrl = location.href;
123
+ const url = this.url(location.href);
108
124
  const utm = Object.fromEntries([...new URLSearchParams(location.search)].filter(([k]) => /^utm_(source|medium|campaign|term|content)$/.test(k)));
109
125
  let setOnce;
110
126
  if (!this.state.initialSent) {
111
127
  setOnce = {
112
- $initial_referrer: document.referrer || "$direct",
128
+ $initial_referrer: document.referrer ? this.url(document.referrer) : "$direct",
113
129
  $initial_referring_domain: referringDomain(document.referrer) ?? "$direct",
114
130
  $initial_current_url: url,
115
131
  $initial_pathname: location.pathname,
@@ -212,10 +228,10 @@ export class Analytics {
212
228
  return base;
213
229
  return {
214
230
  ...base,
215
- $current_url: location.href,
231
+ $current_url: this.url(location.href),
216
232
  $host: location.host,
217
233
  $pathname: location.pathname,
218
- $referrer: document.referrer || "$direct",
234
+ $referrer: document.referrer ? this.url(document.referrer) : "$direct",
219
235
  $referring_domain: referringDomain(document.referrer) ?? "$direct",
220
236
  $screen_width: screen.width,
221
237
  $screen_height: screen.height,
@@ -225,6 +241,9 @@ export class Analytics {
225
241
  $device_id: this.state.identified ? undefined : this.state.distinctId,
226
242
  };
227
243
  }
244
+ url(raw) {
245
+ return this.opts.captureFullUrls ? raw : sanitizeUrl(raw);
246
+ }
228
247
  flagProps() {
229
248
  if (!this.opts.flags)
230
249
  return {};
package/dist/cli.js CHANGED
@@ -76,6 +76,8 @@ function writeEnvFile(file, values) {
76
76
  out.push("# Daywalker Auth (managed by `daywalker init`)", ...added.map(([k, v]) => `${k}="${v}"`));
77
77
  }
78
78
  fs.writeFileSync(file, out.join("\n") + "\n", { mode: 0o600 });
79
+ // `mode` only applies when the file is created; tighten an existing file too.
80
+ fs.chmodSync(file, 0o600);
79
81
  }
80
82
  function ensureGitignored(dir, entry) {
81
83
  const file = path.join(dir, ".gitignore");
@@ -233,6 +235,9 @@ async function check() {
233
235
  async function main() {
234
236
  const [cmd, ...rest] = process.argv.slice(2);
235
237
  const parsedArgs = args(rest);
238
+ if (typeof parsedArgs["api-key"] === "string") {
239
+ console.warn("! --api-key exposes the key in shell history and process listings; prefer DAYWALKER_API_KEY in the environment or .env.local.");
240
+ }
236
241
  if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "help") {
237
242
  console.log(HELP);
238
243
  return;
package/dist/client.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { type ResolvedConfig } from "./config.js";
2
2
  import { type DaywalkerConfig, type PublicJwk, type RetryOptions, type SelfCheckResult, type TokenPayload, type ValidateResult } from "./types.js";
3
+ /** Validation results kept in memory; the least recently used are evicted past this. */
4
+ export declare const MAX_VALIDATION_CACHE_ENTRIES = 1000;
3
5
  export type DaywalkerFetchInit = Omit<RequestInit, "body"> & {
4
6
  body?: BodyInit | object | null;
5
7
  retry?: RetryOptions;
@@ -18,9 +20,13 @@ export declare class Daywalker {
18
20
  * Signs and executes requests with automatic retry and exponential backoff on 429 responses.
19
21
  */
20
22
  fetch(path: string, init?: DaywalkerFetchInit): Promise<Response>;
23
+ private _cacheGet;
24
+ private _cacheSet;
21
25
  /**
22
- * Validates incoming bearer tokens with memory caching and discriminated union return.
26
+ * Validates an incoming bearer token with memory caching and discriminated union return.
23
27
  * Supports zero-egress offline verification for JWT token leases when Daywalker's public key is configured.
28
+ * A missing token is always rejected (401); it never falls back to this service's own API key —
29
+ * use `validateServiceKey()` for that.
24
30
  */
25
31
  validateToken(tokenOrOptions?: string | {
26
32
  token?: string;
@@ -33,6 +39,14 @@ export declare class Daywalker {
33
39
  offlineOnly?: boolean;
34
40
  }): Promise<ValidateResult>;
35
41
  private _validateOnline;
42
+ /**
43
+ * Service health check: validates this service's own configured API key (DAYWALKER_API_KEY).
44
+ * Never use this to authenticate an incoming request.
45
+ */
46
+ validateServiceKey(options?: {
47
+ serviceSlug?: string;
48
+ forceRefresh?: boolean;
49
+ }): Promise<ValidateResult>;
36
50
  /**
37
51
  * Throws DaywalkerAuthError or DaywalkerRateLimitError if token is invalid or rate limited.
38
52
  */
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) {
@@ -11,7 +11,7 @@ export interface FlagState {
11
11
  }
12
12
  export type FlagSet = Record<string, FlagState>;
13
13
  export interface ClientOptions {
14
- /** Base URL of your DevPulse deployment, e.g. https://flags.example.com */
14
+ /** Base URL of your DevPulse deployment, e.g. https://flags.example.com (https required except on localhost). */
15
15
  baseUrl: string;
16
16
  /** Receive changes in real time over Server-Sent Events (default true). */
17
17
  streaming?: boolean;
@@ -66,6 +66,7 @@ export declare class DevPulseClient {
66
66
  private fetchFlags;
67
67
  private onStreamEvent;
68
68
  private replaceAll;
69
+ private flag;
69
70
  private markReady;
70
71
  private fail;
71
72
  private emitChanges;
@@ -1,4 +1,9 @@
1
1
  import { EventProcessor, SseClient, encodeContext, noopLogger } from "./common/index.js";
2
+ import { assertSecureUrl } from "../config.js";
3
+ /** A flag map without a prototype, so keys like `__proto__` or `toString` never resolve to inherited values. */
4
+ function flagMap(entries = {}) {
5
+ return Object.assign(Object.create(null), entries);
6
+ }
2
7
  function isFlagSet(x) {
3
8
  return !!x && typeof x === "object" && Object.values(x).every((v) => v && typeof v === "object" && "value" in v && "version" in v);
4
9
  }
@@ -15,7 +20,7 @@ export class DevPulseClient {
15
20
  clientSideId;
16
21
  context;
17
22
  options;
18
- flags = {};
23
+ flags = flagMap();
19
24
  listeners = new Map();
20
25
  stream = null;
21
26
  events = null;
@@ -31,6 +36,7 @@ export class DevPulseClient {
31
36
  this.context = context;
32
37
  this.options = options;
33
38
  this.logger = options.logger ?? noopLogger;
39
+ assertSecureUrl("baseUrl", options.baseUrl);
34
40
  this.base = options.baseUrl.replace(/\/$/, "");
35
41
  this.ready = new Promise((res, rej) => {
36
42
  this.resolveReady = res;
@@ -39,14 +45,14 @@ export class DevPulseClient {
39
45
  this.ready.catch(() => undefined);
40
46
  if (options.bootstrap) {
41
47
  this.flags = isFlagSet(options.bootstrap)
42
- ? { ...options.bootstrap }
43
- : Object.fromEntries(Object.entries(options.bootstrap).map(([k, v]) => [k, { value: v, variation: null, version: 0 }]));
48
+ ? flagMap(options.bootstrap)
49
+ : flagMap(Object.fromEntries(Object.entries(options.bootstrap).map(([k, v]) => [k, { value: v, variation: null, version: 0 }])));
44
50
  this.markReady();
45
51
  }
46
52
  else if (options.cache) {
47
53
  const cached = this.readCache();
48
54
  if (cached)
49
- this.flags = cached;
55
+ this.flags = flagMap(cached);
50
56
  }
51
57
  if (options.sendEvents !== false && typeof globalThis.fetch === "function") {
52
58
  this.events = new EventProcessor({
@@ -117,7 +123,7 @@ export class DevPulseClient {
117
123
  this.replaceAll(payload);
118
124
  else if (type === "patch") {
119
125
  const { key, ...state } = payload;
120
- const prev = this.flags[key];
126
+ const prev = this.flag(key);
121
127
  if (prev && prev.version > state.version)
122
128
  return;
123
129
  this.flags[key] = state;
@@ -126,7 +132,7 @@ export class DevPulseClient {
126
132
  }
127
133
  else if (type === "delete") {
128
134
  const { key, version } = payload;
129
- const prev = this.flags[key];
135
+ const prev = this.flag(key);
130
136
  if (!prev || prev.version > version)
131
137
  return;
132
138
  delete this.flags[key];
@@ -141,17 +147,20 @@ export class DevPulseClient {
141
147
  replaceAll(next) {
142
148
  const changes = {};
143
149
  for (const key of new Set([...Object.keys(this.flags), ...Object.keys(next)])) {
144
- const a = this.flags[key]?.value;
145
- const b = next[key]?.value;
150
+ const a = this.flag(key)?.value;
151
+ const b = Object.hasOwn(next, key) ? next[key]?.value : undefined;
146
152
  if (JSON.stringify(a) !== JSON.stringify(b))
147
153
  changes[key] = { current: b ?? null, previous: a };
148
154
  }
149
- this.flags = { ...next };
155
+ this.flags = flagMap(next);
150
156
  this.writeCache();
151
157
  this.markReady();
152
158
  if (Object.keys(changes).length)
153
159
  this.emitChanges(changes);
154
160
  }
161
+ flag(key) {
162
+ return Object.hasOwn(this.flags, key) ? this.flags[key] : undefined;
163
+ }
155
164
  markReady() {
156
165
  if (this.initialized)
157
166
  return;
@@ -209,7 +218,7 @@ export class DevPulseClient {
209
218
  return this.variationDetail(key, defaultValue).value;
210
219
  }
211
220
  variationDetail(key, defaultValue) {
212
- const f = this.flags[key];
221
+ const f = this.flag(key);
213
222
  let detail;
214
223
  if (!f)
215
224
  detail = { value: defaultValue, variationIndex: null, reason: { kind: "ERROR", errorKind: this.initialized ? "FLAG_NOT_FOUND" : "CLIENT_NOT_READY" } };
@@ -1,5 +1,23 @@
1
1
  import { parseContext } from "../engine/index.js";
2
2
  export const noopLogger = { debug() { }, info() { }, warn() { }, error() { } };
3
+ /** Delete the value at `tokens` inside `target`, copying (not mutating) nested objects. */
4
+ function removePointer(target, tokens) {
5
+ const [head, ...rest] = tokens;
6
+ if (!Object.hasOwn(target, head))
7
+ return false;
8
+ if (!rest.length) {
9
+ delete target[head];
10
+ return true;
11
+ }
12
+ const child = target[head];
13
+ if (!child || typeof child !== "object" || Array.isArray(child))
14
+ return false;
15
+ const copy = { ...child };
16
+ if (!removePointer(copy, rest))
17
+ return false;
18
+ target[head] = copy;
19
+ return true;
20
+ }
3
21
  /**
4
22
  * Buffers analytics: per-flag summaries (counts per variation), context
5
23
  * index events (deduplicated), full feature events for tracked flags and
@@ -35,6 +53,14 @@ export class EventProcessor {
35
53
  else
36
54
  out[k] = v;
37
55
  }
56
+ // Nested JSON pointers (/address/zip): remove the leaf from a copy of each object on the path.
57
+ for (const ref of priv) {
58
+ const tokens = ref.startsWith("/") ? ref.slice(1).split("/").map((t) => t.replace(/~1/g, "/").replace(/~0/g, "~")) : [];
59
+ if (tokens.length < 2 || ["key", "kind", "anonymous", "_meta"].includes(tokens[0]))
60
+ continue;
61
+ if (removePointer(out, tokens))
62
+ redacted.push(ref);
63
+ }
38
64
  if (redacted.length)
39
65
  out.redactedAttributes = redacted;
40
66
  return out;
@@ -11,7 +11,8 @@ function parseSingle(kind, raw) {
11
11
  throw new ContextError(`Context "${kind}" needs a non-empty string key`);
12
12
  const meta = (obj._meta ?? {});
13
13
  const privateAttributes = Array.isArray(meta.privateAttributes) ? meta.privateAttributes.filter((a) => typeof a === "string") : [];
14
- const attributes = {};
14
+ // No prototype: `constructor`, `toString`, `__proto__` resolve only if the context sets them.
15
+ const attributes = Object.create(null);
15
16
  for (const [k, v] of Object.entries(obj))
16
17
  if (k !== "_meta" && k !== "kind")
17
18
  attributes[k] = v;
@@ -53,7 +54,7 @@ export function getAttribute(ctx, ref) {
53
54
  if (ref === "key")
54
55
  return ctx.key;
55
56
  if (!ref.startsWith("/"))
56
- return ctx.attributes[ref];
57
+ return Object.hasOwn(ctx.attributes, ref) ? ctx.attributes[ref] : undefined;
57
58
  const tokens = ref
58
59
  .slice(1)
59
60
  .split("/")
@@ -70,7 +71,7 @@ export function getAttribute(ctx, ref) {
70
71
  cur = cur[Number(t)];
71
72
  }
72
73
  else {
73
- cur = cur[t];
74
+ cur = Object.hasOwn(cur, t) ? cur[t] : undefined;
74
75
  }
75
76
  }
76
77
  return cur;
@@ -1,4 +1,12 @@
1
1
  import type { FlagValue, Operator } from "./types.js";
2
+ /** `matches` limits: longer inputs or patterns never match. */
3
+ export declare const MAX_MATCH_INPUT_LENGTH = 1024;
4
+ export declare const MAX_MATCH_PATTERN_LENGTH = 500;
5
+ /**
6
+ * Conservative ReDoS heuristic: a group that contains a quantifier or alternation and is itself
7
+ * repeated (`(a+)+`, `(a*)*`, `(a|aa)+`, `(?:x+y)*`, `(a+){2,}`). Character classes and escapes are skipped.
8
+ */
9
+ export declare function hasNestedQuantifier(pattern: string): boolean;
2
10
  /** RFC 3339 timestamp or Unix milliseconds → epoch ms. */
3
11
  export declare function parseTime(v: unknown): number | null;
4
12
  /** Compare one context value with one clause value. Type mismatches never match. */
@@ -1,13 +1,61 @@
1
1
  import { compareSemVer, parseSemVer } from "./semver.js";
2
2
  const regexCache = new Map();
3
+ /** `matches` limits: longer inputs or patterns never match. */
4
+ export const MAX_MATCH_INPUT_LENGTH = 1024;
5
+ export const MAX_MATCH_PATTERN_LENGTH = 500;
6
+ let warnedUnsafePattern = false;
7
+ /**
8
+ * Conservative ReDoS heuristic: a group that contains a quantifier or alternation and is itself
9
+ * repeated (`(a+)+`, `(a*)*`, `(a|aa)+`, `(?:x+y)*`, `(a+){2,}`). Character classes and escapes are skipped.
10
+ */
11
+ export function hasNestedQuantifier(pattern) {
12
+ const stack = []; // per open group: contains a quantifier / alternation
13
+ for (let i = 0; i < pattern.length; i++) {
14
+ const ch = pattern[i];
15
+ if (ch === "\\") {
16
+ i++;
17
+ }
18
+ else if (ch === "[") {
19
+ for (i++; i < pattern.length && pattern[i] !== "]"; i++)
20
+ if (pattern[i] === "\\")
21
+ i++;
22
+ }
23
+ else if (ch === "(") {
24
+ stack.push(false);
25
+ if (pattern[i + 1] === "?")
26
+ i++; // group modifier, not a quantifier
27
+ }
28
+ else if (ch === ")") {
29
+ const risky = stack.pop() ?? false;
30
+ const next = pattern[i + 1];
31
+ const repeated = next === "*" || next === "+" || next === "{";
32
+ if (risky && repeated)
33
+ return true;
34
+ if ((risky || repeated) && stack.length)
35
+ stack[stack.length - 1] = true;
36
+ }
37
+ else if ((ch === "*" || ch === "+" || ch === "?" || ch === "{" || ch === "|") && stack.length) {
38
+ stack[stack.length - 1] = true;
39
+ }
40
+ }
41
+ return false;
42
+ }
3
43
  function regex(pattern) {
4
44
  if (!regexCache.has(pattern)) {
5
45
  let re = null;
6
- try {
7
- re = new RegExp(pattern);
46
+ if (pattern.length > MAX_MATCH_PATTERN_LENGTH || hasNestedQuantifier(pattern)) {
47
+ if (!warnedUnsafePattern) {
48
+ warnedUnsafePattern = true;
49
+ console.warn("DevPulse: a `matches` pattern was too long or had nested quantifiers and is treated as no match");
50
+ }
8
51
  }
9
- catch {
10
- re = null;
52
+ else {
53
+ try {
54
+ re = new RegExp(pattern);
55
+ }
56
+ catch {
57
+ re = null;
58
+ }
11
59
  }
12
60
  if (regexCache.size > 1000)
13
61
  regexCache.clear();
@@ -51,6 +99,8 @@ export function applyOperator(op, actual, expected) {
51
99
  case "matches": {
52
100
  if (typeof actual !== "string" || typeof expected !== "string")
53
101
  return false;
102
+ if (actual.length > MAX_MATCH_INPUT_LENGTH)
103
+ return false;
54
104
  return regex(expected)?.test(actual) ?? false;
55
105
  }
56
106
  case "lessThan":
@@ -3,7 +3,7 @@ import { type ContextInput, type EvaluationDetail, type FlagConfig, type FlagVal
3
3
  import { type Logger } from "./common/index.js";
4
4
  export type { ContextInput, EvaluationDetail, FlagValue, Logger };
5
5
  export interface NodeClientOptions {
6
- /** Base URL of your DevPulse deployment. */
6
+ /** Base URL of your DevPulse deployment (https required except on localhost). */
7
7
  baseUrl: string;
8
8
  /** Real-time updates over SSE (default). Set false to poll instead. */
9
9
  stream?: boolean;
@@ -2,6 +2,7 @@ import { createHmac } from "node:crypto";
2
2
  import { EventEmitter } from "node:events";
3
3
  import { Evaluator, MemoryStore, evaluateFlag, parseContext } from "./engine/index.js";
4
4
  import { EventProcessor, SseClient, noopLogger } from "./common/index.js";
5
+ import { assertSecureUrl } from "../config.js";
5
6
  /**
6
7
  * Server-side client. One instance per process and SDK key.
7
8
  *
@@ -41,6 +42,8 @@ export class DevPulseNodeClient extends EventEmitter {
41
42
  this.markReady();
42
43
  return;
43
44
  }
45
+ // Checked once the SDK key will actually be sent (offline / fixture clients make no requests).
46
+ assertSecureUrl("baseUrl", options.baseUrl);
44
47
  if (options.sendEvents !== false) {
45
48
  this.events = new EventProcessor({
46
49
  url: `${this.base}/sdk/v1/events`,
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { Daywalker, createDaywalker, daywalker, } from "./client.js";
2
2
  export { DaywalkerError, DaywalkerAuthError, DaywalkerRateLimitError, } from "./types.js";
3
3
  export type { DaywalkerConfig, RateLimitWindow, TokenPayload, ValidateErrorCode, ValidateResult, RetryOptions, SelfCheckResult, PublicJwk, SigningKey, } from "./types.js";
4
- export { resolveConfig, publicHostname, } from "./config.js";
4
+ export { resolveConfig, publicHostname, assertSecureUrl, } from "./config.js";
5
5
  export type { ResolvedConfig, } from "./config.js";
6
6
  export { SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, computeContentDigest, sha256Base64, sha256Base64Url, calculateJwkThumbprint, generateSigningKey, loadSigningKey, loadPublicKey, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, registerPublicKey, stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, bytesToHex, hexToBytes, maskSecret, redactSecrets, } from "./crypto/index.js";
7
7
  export type { SignedHeaders, SignRequestParams, VerifySignatureParams, VerifySignatureResult, } from "./crypto/index.js";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { Daywalker, createDaywalker, daywalker, } from "./client.js";
2
2
  export { DaywalkerError, DaywalkerAuthError, DaywalkerRateLimitError, } from "./types.js";
3
- export { resolveConfig, publicHostname, } from "./config.js";
3
+ export { resolveConfig, publicHostname, assertSecureUrl, } from "./config.js";
4
4
  export { SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, computeContentDigest, sha256Base64, sha256Base64Url, calculateJwkThumbprint, generateSigningKey, loadSigningKey, loadPublicKey, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, registerPublicKey, stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, bytesToHex, hexToBytes, maskSecret, redactSecrets, } from "./crypto/index.js";
package/dist/next.d.ts CHANGED
@@ -7,6 +7,13 @@ import type { TokenPayload } from "./types.js";
7
7
  */
8
8
  export declare const SERVER_ACTION_ID_PATTERN: RegExp;
9
9
  export declare const DEFAULT_BYPASS_PATHS: string[];
10
+ /** File extensions treated as static assets (outside /api/) and passed through without auth. */
11
+ export declare const STATIC_FILE_EXTENSIONS: RegExp;
12
+ /**
13
+ * Static assets that skip auth: Next's build output and image optimizer, the favicon, and files with
14
+ * an allowlisted extension. `/_next/data/` (getServerSideProps JSON) and `/api/*` are never static.
15
+ */
16
+ export declare function isStaticAssetPath(pathname: string): boolean;
10
17
  export interface NextMiddlewareOptions {
11
18
  client?: Daywalker;
12
19
  serviceSlug?: string;
@@ -28,8 +35,8 @@ export declare function sanitizeRedirectPath(path: string): string | null;
28
35
  * Implements:
29
36
  * 1. Server Action probe defense (CVE-2025-55182 scanner mitigation)
30
37
  * 2. Static asset and health check bypass
31
- * 3. Self-healing service error page routing with `from` back-redirect
32
- * 4. Distinct handling for API routes (JSON + CORS) vs UI pages (redirect)
38
+ * 3. Per-request token auth: requests without `Authorization: Bearer` / `x-api-token` get 401
39
+ * 4. Distinct handling for API routes (JSON + CORS) vs UI pages (redirect to `errorPath` with `from`)
33
40
  *
34
41
  * Example:
35
42
  * // middleware.ts
@@ -42,6 +49,12 @@ export declare function sanitizeRedirectPath(path: string): string | null;
42
49
  * };
43
50
  */
44
51
  export declare function createDaywalkerMiddleware(options?: NextMiddlewareOptions): (request: Request) => Promise<Response>;
52
+ /**
53
+ * Service health gate: checks this service's own DAYWALKER_API_KEY (not the caller's token) and
54
+ * routes every request to `errorPath` while it is invalid, redirecting back once it heals.
55
+ * It does NOT authenticate callers; use `createDaywalkerMiddleware` or `withDaywalkerAuth` for that.
56
+ */
57
+ export declare function createDaywalkerServiceHealthMiddleware(options?: NextMiddlewareOptions): (request: Request) => Promise<Response>;
45
58
  /**
46
59
  * Next.js App Router route handler wrapper with Daywalker token validation.
47
60
  * Extracts `Authorization: Bearer <token>` or `x-api-token`, validates via client,
package/dist/next.js CHANGED
@@ -12,6 +12,23 @@ export const DEFAULT_BYPASS_PATHS = [
12
12
  "/robots.txt",
13
13
  "/sitemap.xml",
14
14
  ];
15
+ /** File extensions treated as static assets (outside /api/) and passed through without auth. */
16
+ export const STATIC_FILE_EXTENSIONS = /\.(?:js|css|png|jpe?g|gif|webp|avif|svg|ico|woff2?|map|txt)$/i;
17
+ /**
18
+ * Static assets that skip auth: Next's build output and image optimizer, the favicon, and files with
19
+ * an allowlisted extension. `/_next/data/` (getServerSideProps JSON) and `/api/*` are never static.
20
+ */
21
+ export function isStaticAssetPath(pathname) {
22
+ if (pathname === "/api" || pathname.startsWith("/api/"))
23
+ return false;
24
+ if (pathname.startsWith("/_next/static/") || pathname === "/_next/image" || pathname.startsWith("/_next/image/"))
25
+ return true;
26
+ if (pathname === "/favicon.ico")
27
+ return true;
28
+ if (pathname.startsWith("/_next/"))
29
+ return false;
30
+ return STATIC_FILE_EXTENSIONS.test(pathname);
31
+ }
15
32
  /**
16
33
  * Sanitizes open redirect path parameter (`from`).
17
34
  * Prevents protocol-relative URLs (`//evil.com`), backslashes (`/\evil.com`), and control characters.
@@ -42,8 +59,8 @@ function extractToken(req) {
42
59
  * Implements:
43
60
  * 1. Server Action probe defense (CVE-2025-55182 scanner mitigation)
44
61
  * 2. Static asset and health check bypass
45
- * 3. Self-healing service error page routing with `from` back-redirect
46
- * 4. Distinct handling for API routes (JSON + CORS) vs UI pages (redirect)
62
+ * 3. Per-request token auth: requests without `Authorization: Bearer` / `x-api-token` get 401
63
+ * 4. Distinct handling for API routes (JSON + CORS) vs UI pages (redirect to `errorPath` with `from`)
47
64
  *
48
65
  * Example:
49
66
  * // middleware.ts
@@ -57,6 +74,24 @@ function extractToken(req) {
57
74
  */
58
75
  export function createDaywalkerMiddleware(options = {}) {
59
76
  const client = options.client ?? daywalker;
77
+ return buildMiddleware(options, async (request) => {
78
+ const token = extractToken(request);
79
+ if (!token) {
80
+ return { ok: false, error: "INVALID_TOKEN", status: 401, message: "Missing Authorization header or x-api-token" };
81
+ }
82
+ return client.validateToken(token, { serviceSlug: options.serviceSlug });
83
+ });
84
+ }
85
+ /**
86
+ * Service health gate: checks this service's own DAYWALKER_API_KEY (not the caller's token) and
87
+ * routes every request to `errorPath` while it is invalid, redirecting back once it heals.
88
+ * It does NOT authenticate callers; use `createDaywalkerMiddleware` or `withDaywalkerAuth` for that.
89
+ */
90
+ export function createDaywalkerServiceHealthMiddleware(options = {}) {
91
+ const client = options.client ?? daywalker;
92
+ return buildMiddleware(options, () => client.validateServiceKey({ serviceSlug: options.serviceSlug }));
93
+ }
94
+ function buildMiddleware(options, authenticate) {
60
95
  const bypassPaths = options.bypassPaths ?? DEFAULT_BYPASS_PATHS;
61
96
  const errorPath = options.errorPath ?? "/service-error";
62
97
  const homePath = options.homePath ?? "/";
@@ -80,20 +115,17 @@ export function createDaywalkerMiddleware(options = {}) {
80
115
  }
81
116
  }
82
117
  // 1. Bypass static files and health check probes
83
- if (bypassPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`)) ||
84
- pathname.startsWith("/_next") ||
85
- (pathname.includes(".") && !pathname.endsWith(".json"))) {
118
+ if (bypassPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`)) || isStaticAssetPath(pathname)) {
86
119
  return globalThis.NextResponse?.next
87
120
  ? globalThis.NextResponse.next()
88
121
  : new Response(null, { headers: { "x-middleware-next": "1" } });
89
122
  }
90
- // 2. Validate token (from request header, or service's configured DAYWALKER_API_KEY)
91
- const incomingToken = extractToken(request);
92
- const authResult = await client.validateToken(incomingToken ? { token: incomingToken, serviceSlug: options.serviceSlug } : { serviceSlug: options.serviceSlug });
123
+ // 2. Authenticate (caller's token, or the service key for the health gate)
124
+ const authResult = await authenticate(request);
93
125
  // 3. Handle service error page route
94
126
  if (pathname === errorPath) {
95
127
  if (authResult.ok) {
96
- // If the service token is now valid, heal/redirect back to home or referer
128
+ // Authorized again: heal/redirect back to home
97
129
  const redirectUrl = new URL(homePath, request.url);
98
130
  return globalThis.NextResponse?.redirect
99
131
  ? globalThis.NextResponse.redirect(redirectUrl)
package/dist/types.d.ts CHANGED
@@ -55,6 +55,9 @@ export interface DaywalkerConfig {
55
55
  serviceSlug?: string;
56
56
  domain?: string;
57
57
  daywalkerPublicKey?: string | PublicJwk;
58
+ /** Expected `iss` of offline-verified JWT leases (env DAYWALKER_LEASE_ISSUER). Unchecked when unset. */
59
+ leaseIssuer?: string;
60
+ /** Must be https:// (http:// only for localhost). */
58
61
  authUrl?: string;
59
62
  cacheTtlMs?: number;
60
63
  /** Short cache for failed validations (not 429/network errors). Default 3000ms; 0 disables. */
package/docs/analytics.md CHANGED
@@ -27,6 +27,6 @@ await analytics.shutdown();
27
27
 
28
28
  Add each flag's value to events (for breakdowns by variation) with `flags: client` where `client` is a `@shelbyb/daywalker-sdk/flags` instance (anything with `allFlags()`).
29
29
 
30
- Options: `autocapturePageviews` (default true), `flushAt` (20), `flushInterval` (2s browser / 10s Node), `maxQueueSize` (1000), `persistence` (`localStorage` | `memory`), `sessionTimeoutMinutes` (30), `respectDoNotTrack`, `optedOut` (consent), `onError`.
30
+ Options: `autocapturePageviews` (default true), `flushAt` (20), `flushInterval` (2s browser / 10s Node), `maxQueueSize` (1000), `persistence` (`localStorage` | `memory`), `sessionTimeoutMinutes` (30), `respectDoNotTrack`, `optedOut` (consent), `captureFullUrls` (default false: URLs drop the fragment and all query parameters except `utm_*`, `gclid`, `fbclid`, `ref`), `onError`. `host` must be `https://` except on localhost.
31
31
 
32
32
  Events are sent to `POST {host}/a/batch`, which also accepts PostHog's capture format.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shelbyb/daywalker-sdk",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Daywalker Auth service validation plus the DevPulse client SDKs (feature flags, OpenFeature, product analytics) in one zero-dependency package.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -84,11 +84,11 @@
84
84
  "@openfeature/core": "^1.12.0",
85
85
  "@openfeature/server-sdk": "^1.23.0",
86
86
  "@openfeature/web-sdk": "^1.10.0",
87
- "@types/node": "^22.13.5",
87
+ "@types/node": "^26.6.4",
88
88
  "@types/react": "^19.3.0",
89
89
  "react": "^19.3.0",
90
90
  "tsx": "^4.23.15",
91
- "typescript": "^5.9.3"
91
+ "typescript": "^7.0.2"
92
92
  },
93
93
  "keywords": [
94
94
  "api-token",