@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 +8 -2
- package/dist/analytics/index.d.ts +10 -1
- package/dist/analytics/index.js +29 -10
- 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 +2 -1
- package/dist/flags/client.js +19 -10
- package/dist/flags/common/events.js +26 -0
- package/dist/flags/engine/context.js +4 -3
- package/dist/flags/engine/operators.d.ts +8 -0
- package/dist/flags/engine/operators.js +54 -4
- package/dist/flags/node.d.ts +1 -1
- package/dist/flags/node.js +3 -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/types.d.ts +3 -0
- package/docs/analytics.md +1 -1
- package/package.json +3 -3
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;
|
package/dist/analytics/index.js
CHANGED
|
@@ -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
|
-
|
|
107
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) {
|
package/dist/flags/client.d.ts
CHANGED
|
@@ -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;
|
package/dist/flags/client.js
CHANGED
|
@@ -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
|
-
?
|
|
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.
|
|
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.
|
|
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.
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
10
|
-
|
|
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":
|
package/dist/flags/node.d.ts
CHANGED
|
@@ -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;
|
package/dist/flags/node.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
91
|
-
const
|
|
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
|
-
//
|
|
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.
|
|
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": "^
|
|
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": "^
|
|
91
|
+
"typescript": "^7.0.2"
|
|
92
92
|
},
|
|
93
93
|
"keywords": [
|
|
94
94
|
"api-token",
|