@shelbyb/daywalker-sdk 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -23
- package/dist/analytics/index.d.ts +16 -2
- package/dist/analytics/index.js +79 -14
- package/dist/cli.js +133 -31
- package/dist/client.d.ts +29 -2
- package/dist/client.js +314 -62
- package/dist/config.d.ts +8 -2
- package/dist/config.js +14 -0
- package/dist/crypto/encoding.d.ts +5 -0
- package/dist/crypto/encoding.js +14 -0
- package/dist/crypto/index.d.ts +3 -3
- package/dist/crypto/index.js +2 -2
- package/dist/crypto/keys.d.ts +3 -3
- package/dist/crypto/keys.js +28 -7
- package/dist/crypto/signatures.d.ts +42 -12
- package/dist/crypto/signatures.js +236 -79
- package/dist/express.d.ts +1 -0
- package/dist/express.js +8 -6
- package/dist/flags/client.d.ts +32 -4
- package/dist/flags/client.js +74 -12
- package/dist/flags/common/events.d.ts +9 -0
- package/dist/flags/common/events.js +51 -6
- package/dist/flags/common/index.d.ts +3 -3
- package/dist/flags/common/index.js +3 -3
- package/dist/flags/common/sse.d.ts +24 -3
- package/dist/flags/common/sse.js +85 -15
- package/dist/flags/common/version.d.ts +2 -0
- package/dist/flags/common/version.js +2 -0
- package/dist/flags/engine/index.d.ts +1 -1
- package/dist/flags/engine/index.js +1 -1
- package/dist/flags/engine/operators.d.ts +20 -3
- package/dist/flags/engine/operators.js +38 -32
- package/dist/flags/node.d.ts +26 -14
- package/dist/flags/node.js +51 -7
- package/dist/flags/react.d.ts +6 -4
- package/dist/flags/react.js +47 -17
- package/dist/index.d.ts +7 -6
- package/dist/index.js +4 -4
- package/dist/middleware.d.ts +18 -0
- package/dist/middleware.js +28 -7
- package/dist/next.d.ts +52 -8
- package/dist/next.js +151 -37
- package/dist/openfeature/server.d.ts +3 -0
- package/dist/openfeature/server.js +14 -2
- package/dist/openfeature/shared.d.ts +1 -1
- package/dist/openfeature/shared.js +22 -2
- package/dist/openfeature/web.js +9 -3
- package/dist/types.d.ts +56 -4
- package/dist/types.js +6 -0
- package/docs/analytics.md +1 -1
- package/docs/flags-node.md +1 -1
- package/docs/flags.md +2 -1
- package/package.json +32 -14
- package/assets/logo-512.png +0 -0
package/dist/next.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { daywalker, Daywalker } from "./client.js";
|
|
2
|
+
import { toAuthFailureResponse } from "./middleware.js";
|
|
2
3
|
/**
|
|
3
4
|
* Real Server Action IDs are hex hashes. Scanners probing for CVE-2025-55182 (React2Shell)
|
|
4
5
|
* send junk like `Next-Action: x`, which Next.js rejects with an internal error.
|
|
@@ -12,36 +13,123 @@ export const DEFAULT_BYPASS_PATHS = [
|
|
|
12
13
|
"/robots.txt",
|
|
13
14
|
"/sitemap.xml",
|
|
14
15
|
];
|
|
15
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* A suggested value for the opt-in `staticFileExtensions` option. It is NOT applied by default:
|
|
18
|
+
* an extension says nothing about whether a route is public (`/users/123.js` can be a dynamic page).
|
|
19
|
+
* Prefer excluding public files with Next's `matcher` (see `createDaywalkerMiddleware`).
|
|
20
|
+
*/
|
|
16
21
|
export const STATIC_FILE_EXTENSIONS = /\.(?:js|css|png|jpe?g|gif|webp|avif|svg|ico|woff2?|map|txt)$/i;
|
|
17
22
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
23
|
+
* Decodes and normalizes a URL pathname the way a router sees it: percent-decoded once, `//`
|
|
24
|
+
* collapsed and `.`/`..` segments resolved. Returns null (never bypass auth) for malformed
|
|
25
|
+
* encoding, encoded slashes/backslashes, raw backslashes or control characters.
|
|
20
26
|
*/
|
|
21
|
-
export function
|
|
22
|
-
if (pathname
|
|
27
|
+
export function normalizePathname(pathname) {
|
|
28
|
+
if (typeof pathname !== "string" || !pathname.startsWith("/"))
|
|
29
|
+
return null;
|
|
30
|
+
if (pathname.includes("\\") || /%(?:2f|5c)/i.test(pathname))
|
|
31
|
+
return null;
|
|
32
|
+
let decoded;
|
|
33
|
+
try {
|
|
34
|
+
decoded = decodeURIComponent(pathname);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
if (/[\u0000-\u001f\u007f\\]/.test(decoded))
|
|
40
|
+
return null;
|
|
41
|
+
const segments = [];
|
|
42
|
+
for (const segment of decoded.split("/")) {
|
|
43
|
+
if (segment === "" || segment === ".")
|
|
44
|
+
continue;
|
|
45
|
+
if (segment === "..")
|
|
46
|
+
segments.pop();
|
|
47
|
+
else
|
|
48
|
+
segments.push(segment);
|
|
49
|
+
}
|
|
50
|
+
const trailingSlash = segments.length > 0 && /\/\.{0,2}$/.test(decoded);
|
|
51
|
+
return `/${segments.join("/")}${trailingSlash ? "/" : ""}`;
|
|
52
|
+
}
|
|
53
|
+
function isApiPath(pathname) {
|
|
54
|
+
return pathname === "/api" || pathname.startsWith("/api/");
|
|
55
|
+
}
|
|
56
|
+
function extensionMatcher(extensions) {
|
|
57
|
+
if (!extensions)
|
|
58
|
+
return null;
|
|
59
|
+
if (extensions instanceof RegExp) {
|
|
60
|
+
// Copy without the stateful g/y flags so repeated .test() calls are consistent.
|
|
61
|
+
const re = new RegExp(extensions.source, extensions.flags.replace(/[gy]/g, ""));
|
|
62
|
+
return (pathname) => re.test(pathname);
|
|
63
|
+
}
|
|
64
|
+
const list = extensions.map((e) => e.replace(/^\./, "").toLowerCase()).filter(Boolean);
|
|
65
|
+
if (list.length === 0)
|
|
66
|
+
return null;
|
|
67
|
+
return (pathname) => {
|
|
68
|
+
const last = pathname.slice(pathname.lastIndexOf("/") + 1).toLowerCase();
|
|
69
|
+
return list.some((ext) => last.endsWith(`.${ext}`) && last.length > ext.length + 1);
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** `pathname` must already be normalized. */
|
|
73
|
+
function isBuiltInStaticPath(pathname) {
|
|
74
|
+
return pathname.startsWith("/_next/static/") || pathname === "/_next/image" || pathname.startsWith("/_next/image/") || pathname === "/favicon.ico";
|
|
75
|
+
}
|
|
76
|
+
function isStaticNormalized(pathname, matchesExtension) {
|
|
77
|
+
if (isApiPath(pathname))
|
|
23
78
|
return false;
|
|
24
|
-
if (
|
|
25
|
-
return true;
|
|
26
|
-
if (pathname === "/favicon.ico")
|
|
79
|
+
if (isBuiltInStaticPath(pathname))
|
|
27
80
|
return true;
|
|
28
81
|
if (pathname.startsWith("/_next/"))
|
|
29
82
|
return false;
|
|
30
|
-
return
|
|
83
|
+
return matchesExtension ? matchesExtension(pathname) : false;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Static assets that skip auth: Next's build output (`/_next/static/`), image optimizer
|
|
87
|
+
* (`/_next/image`) and `/favicon.ico`. Files matching `staticFileExtensions` are included only when
|
|
88
|
+
* that list is passed explicitly. The path is decoded and normalized first; `/_next/data/`
|
|
89
|
+
* (getServerSideProps JSON) and `/api` / `/api/*` are never static.
|
|
90
|
+
*/
|
|
91
|
+
export function isStaticAssetPath(pathname, staticFileExtensions) {
|
|
92
|
+
const normalized = normalizePathname(pathname);
|
|
93
|
+
if (normalized === null)
|
|
94
|
+
return false;
|
|
95
|
+
return isStaticNormalized(normalized, extensionMatcher(staticFileExtensions));
|
|
31
96
|
}
|
|
97
|
+
const REDIRECT_ORIGIN = "https://redirect-check.invalid";
|
|
32
98
|
/**
|
|
33
99
|
* Sanitizes open redirect path parameter (`from`).
|
|
34
|
-
*
|
|
100
|
+
* Accepts only same-origin relative paths: rejects protocol-relative URLs (`//evil.com`), any
|
|
101
|
+
* backslash, whitespace or control character (browsers strip tabs/newlines, so `/\t/evil.com` would
|
|
102
|
+
* become `//evil.com`), and anything that resolves to another origin.
|
|
35
103
|
*/
|
|
36
104
|
export function sanitizeRedirectPath(path) {
|
|
37
105
|
if (!path || typeof path !== "string")
|
|
38
106
|
return null;
|
|
39
|
-
|
|
40
|
-
if (!clean.startsWith("/") || clean.startsWith("//") || clean.startsWith("/\\")) {
|
|
107
|
+
if (/[\s\u0000-\u001f\u007f-\u009f\\]/.test(path))
|
|
41
108
|
return null;
|
|
109
|
+
if (!path.startsWith("/") || path.startsWith("//"))
|
|
110
|
+
return null;
|
|
111
|
+
let resolved;
|
|
112
|
+
try {
|
|
113
|
+
resolved = new URL(path, REDIRECT_ORIGIN);
|
|
42
114
|
}
|
|
43
|
-
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
if (resolved.origin !== REDIRECT_ORIGIN || !resolved.pathname.startsWith("/") || resolved.pathname.startsWith("//")) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
return path;
|
|
44
122
|
}
|
|
123
|
+
/** Error codes the error page may receive in its `error` query parameter. */
|
|
124
|
+
const REDIRECT_ERROR_CODES = new Set([
|
|
125
|
+
"INVALID_TOKEN",
|
|
126
|
+
"EXPIRED",
|
|
127
|
+
"REVOKED",
|
|
128
|
+
"RATE_LIMITED",
|
|
129
|
+
"NETWORK_ERROR",
|
|
130
|
+
"SERVICE_UNAVAILABLE",
|
|
131
|
+
"CONFIGURATION_ERROR",
|
|
132
|
+
]);
|
|
45
133
|
function extractToken(req) {
|
|
46
134
|
const authHeader = req.headers.get("authorization");
|
|
47
135
|
if (authHeader && authHeader.toLowerCase().startsWith("bearer ")) {
|
|
@@ -62,14 +150,17 @@ function extractToken(req) {
|
|
|
62
150
|
* 3. Per-request token auth: requests without `Authorization: Bearer` / `x-api-token` get 401
|
|
63
151
|
* 4. Distinct handling for API routes (JSON + CORS) vs UI pages (redirect to `errorPath` with `from`)
|
|
64
152
|
*
|
|
153
|
+
* Only `/_next/static/`, `/_next/image`, `/favicon.ico` and `bypassPaths` skip auth by default.
|
|
154
|
+
* Exclude other public files with Next's `matcher` (recommended) or opt into `staticFileExtensions`.
|
|
155
|
+
*
|
|
65
156
|
* Example:
|
|
66
|
-
* // middleware.ts
|
|
157
|
+
* // middleware.ts (Next.js 16+: proxy.ts, `export const proxy = createDaywalkerProxy();`)
|
|
67
158
|
* import { createDaywalkerMiddleware } from "@shelbyb/daywalker-sdk/next";
|
|
68
159
|
*
|
|
69
160
|
* export const middleware = createDaywalkerMiddleware();
|
|
70
161
|
*
|
|
71
162
|
* export const config = {
|
|
72
|
-
* matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
|
163
|
+
* matcher: ["/((?!_next/static|_next/image|favicon.ico|images/).*)"],
|
|
73
164
|
* };
|
|
74
165
|
*/
|
|
75
166
|
export function createDaywalkerMiddleware(options = {}) {
|
|
@@ -96,14 +187,21 @@ function buildMiddleware(options, authenticate) {
|
|
|
96
187
|
const errorPath = options.errorPath ?? "/service-error";
|
|
97
188
|
const homePath = options.homePath ?? "/";
|
|
98
189
|
const validateServerActions = options.validateServerActions ?? true;
|
|
190
|
+
const matchesExtension = extensionMatcher(options.staticFileExtensions);
|
|
191
|
+
const normalizedErrorPath = normalizePathname(errorPath) ?? errorPath;
|
|
99
192
|
return async function middleware(request) {
|
|
100
193
|
const url = new URL(request.url);
|
|
101
|
-
const
|
|
194
|
+
const rawPathname = url.pathname;
|
|
102
195
|
const search = url.search;
|
|
103
|
-
//
|
|
196
|
+
// Routing decisions use the decoded, normalized path (`/%61pi/x` is `/api/x`). A path that
|
|
197
|
+
// cannot be normalized safely (encoded slashes, bad encoding) is never bypassed and is treated
|
|
198
|
+
// as an API route, so it is authenticated even with `protectApiOnly`.
|
|
199
|
+
const pathname = normalizePathname(rawPathname);
|
|
200
|
+
const isApi = pathname === null || isApiPath(pathname);
|
|
201
|
+
// Host header validation if allowedHosts configured; a missing Host is refused too.
|
|
104
202
|
if (options.allowedHosts && options.allowedHosts.length > 0) {
|
|
105
|
-
const host = request.headers.get("host")?.toLowerCase().replace(/:\d+$/, "");
|
|
106
|
-
if (host
|
|
203
|
+
const host = request.headers.get("host")?.trim().toLowerCase().replace(/:\d+$/, "");
|
|
204
|
+
if (!host || !options.allowedHosts.some((h) => h.toLowerCase() === host)) {
|
|
107
205
|
return Response.json({ error: "Forbidden", message: "Invalid host header." }, { status: 403 });
|
|
108
206
|
}
|
|
109
207
|
}
|
|
@@ -115,7 +213,8 @@ function buildMiddleware(options, authenticate) {
|
|
|
115
213
|
}
|
|
116
214
|
}
|
|
117
215
|
// 1. Bypass static files and health check probes
|
|
118
|
-
if (
|
|
216
|
+
if (pathname !== null &&
|
|
217
|
+
(bypassPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`)) || isStaticNormalized(pathname, matchesExtension))) {
|
|
119
218
|
return globalThis.NextResponse?.next
|
|
120
219
|
? globalThis.NextResponse.next()
|
|
121
220
|
: new Response(null, { headers: { "x-middleware-next": "1" } });
|
|
@@ -123,7 +222,7 @@ function buildMiddleware(options, authenticate) {
|
|
|
123
222
|
// 2. Authenticate (caller's token, or the service key for the health gate)
|
|
124
223
|
const authResult = await authenticate(request);
|
|
125
224
|
// 3. Handle service error page route
|
|
126
|
-
if (pathname ===
|
|
225
|
+
if (pathname === normalizedErrorPath) {
|
|
127
226
|
if (authResult.ok) {
|
|
128
227
|
// Authorized again: heal/redirect back to home
|
|
129
228
|
const redirectUrl = new URL(homePath, request.url);
|
|
@@ -138,20 +237,21 @@ function buildMiddleware(options, authenticate) {
|
|
|
138
237
|
}
|
|
139
238
|
// 4. Handle invalid token for all other routes
|
|
140
239
|
if (!authResult.ok) {
|
|
240
|
+
const failure = toAuthFailureResponse(authResult);
|
|
141
241
|
// API routes: return JSON error response
|
|
142
|
-
if (
|
|
242
|
+
if (isApi) {
|
|
143
243
|
return Response.json({
|
|
144
|
-
error:
|
|
145
|
-
message:
|
|
146
|
-
retryAfter:
|
|
244
|
+
error: failure.error,
|
|
245
|
+
message: failure.message || "Service authorization check failed with Daywalker Auth.",
|
|
246
|
+
retryAfter: failure.retryAfter,
|
|
147
247
|
}, {
|
|
148
|
-
status:
|
|
248
|
+
status: failure.status,
|
|
149
249
|
headers: {
|
|
150
250
|
"Content-Type": "application/json",
|
|
151
251
|
"Access-Control-Allow-Origin": "*",
|
|
152
252
|
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
153
253
|
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Api-Token",
|
|
154
|
-
...(
|
|
254
|
+
...(failure.retryAfter ? { "Retry-After": String(failure.retryAfter) } : {}),
|
|
155
255
|
},
|
|
156
256
|
});
|
|
157
257
|
}
|
|
@@ -161,13 +261,14 @@ function buildMiddleware(options, authenticate) {
|
|
|
161
261
|
? globalThis.NextResponse.next()
|
|
162
262
|
: new Response(null, { headers: { "x-middleware-next": "1" } });
|
|
163
263
|
}
|
|
164
|
-
// Web UI routes: redirect to diagnostic service error screen
|
|
264
|
+
// Web UI routes: redirect to diagnostic service error screen. Only a fixed error code and
|
|
265
|
+
// status are passed: the free-text message may carry server details and would let anyone
|
|
266
|
+
// put arbitrary text on the error page via a crafted link.
|
|
165
267
|
const redirectUrl = new URL(errorPath, request.url);
|
|
166
|
-
redirectUrl.searchParams.set("error",
|
|
167
|
-
redirectUrl.searchParams.set("
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const rawTarget = `${pathname}${search}`;
|
|
268
|
+
redirectUrl.searchParams.set("error", REDIRECT_ERROR_CODES.has(failure.error) ? failure.error : "UNAUTHORIZED");
|
|
269
|
+
redirectUrl.searchParams.set("status", String(failure.status));
|
|
270
|
+
if (rawPathname !== "/" || search) {
|
|
271
|
+
const rawTarget = `${rawPathname}${search}`;
|
|
171
272
|
const sanitized = sanitizeRedirectPath(rawTarget);
|
|
172
273
|
if (sanitized) {
|
|
173
274
|
redirectUrl.searchParams.set("from", sanitized);
|
|
@@ -185,7 +286,8 @@ function buildMiddleware(options, authenticate) {
|
|
|
185
286
|
/**
|
|
186
287
|
* Next.js App Router route handler wrapper with Daywalker token validation.
|
|
187
288
|
* Extracts `Authorization: Bearer <token>` or `x-api-token`, validates via client,
|
|
188
|
-
* returns
|
|
289
|
+
* returns a JSON error on failure (401 bad token, 429 rate limited, 503 Daywalker Auth unavailable,
|
|
290
|
+
* 500 misconfiguration), or passes validated `auth` payload in context.
|
|
189
291
|
*
|
|
190
292
|
* Example:
|
|
191
293
|
* // app/api/protected/route.ts
|
|
@@ -207,9 +309,10 @@ export function withDaywalkerAuth(handler, clientOrOptions) {
|
|
|
207
309
|
}
|
|
208
310
|
const result = await client.validateToken(token, { serviceSlug });
|
|
209
311
|
if (!result.ok) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
312
|
+
const { status, ...body } = toAuthFailureResponse(result);
|
|
313
|
+
return Response.json(body, {
|
|
314
|
+
status,
|
|
315
|
+
headers: body.retryAfter ? { "Retry-After": String(body.retryAfter) } : undefined,
|
|
213
316
|
});
|
|
214
317
|
}
|
|
215
318
|
const mergedContext = {
|
|
@@ -219,3 +322,14 @@ export function withDaywalkerAuth(handler, clientOrOptions) {
|
|
|
219
322
|
return handler(req, mergedContext);
|
|
220
323
|
};
|
|
221
324
|
}
|
|
325
|
+
/**
|
|
326
|
+
* Next.js 16 renamed `middleware.ts` to `proxy.ts` (export `proxy` instead of `middleware`).
|
|
327
|
+
* These aliases are identical to the `*Middleware` factories, which remain supported:
|
|
328
|
+
*
|
|
329
|
+
* // proxy.ts
|
|
330
|
+
* import { createDaywalkerProxy } from "@shelbyb/daywalker-sdk/next";
|
|
331
|
+
* export const proxy = createDaywalkerProxy();
|
|
332
|
+
* export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };
|
|
333
|
+
*/
|
|
334
|
+
export const createDaywalkerProxy = createDaywalkerMiddleware;
|
|
335
|
+
export const createDaywalkerServiceHealthProxy = createDaywalkerServiceHealthMiddleware;
|
|
@@ -15,6 +15,9 @@ export declare class DevPulseServerProvider implements Provider {
|
|
|
15
15
|
readonly runsOn: "server";
|
|
16
16
|
readonly events: OpenFeatureEventEmitter;
|
|
17
17
|
readonly client: DevPulseNodeClient;
|
|
18
|
+
/** Only a client this provider created is closed by onClose(); an injected one belongs to the caller. */
|
|
19
|
+
private readonly ownsClient;
|
|
20
|
+
private readonly detach;
|
|
18
21
|
constructor(sdkKeyOrClient: string | DevPulseNodeClient, options?: NodeClientOptions);
|
|
19
22
|
initialize(): Promise<void>;
|
|
20
23
|
onClose(): Promise<void>;
|
|
@@ -13,15 +13,27 @@ export class DevPulseServerProvider {
|
|
|
13
13
|
runsOn = "server";
|
|
14
14
|
events = new OpenFeatureEventEmitter();
|
|
15
15
|
client;
|
|
16
|
+
/** Only a client this provider created is closed by onClose(); an injected one belongs to the caller. */
|
|
17
|
+
ownsClient;
|
|
18
|
+
detach;
|
|
16
19
|
constructor(sdkKeyOrClient, options) {
|
|
20
|
+
this.ownsClient = typeof sdkKeyOrClient === "string";
|
|
17
21
|
this.client = typeof sdkKeyOrClient === "string" ? init(sdkKeyOrClient, options) : sdkKeyOrClient;
|
|
18
|
-
|
|
22
|
+
const onUpdate = ({ key }) => this.events.emit(ProviderEvents.ConfigurationChanged, { flagsChanged: [key] });
|
|
23
|
+
// Before initialization a failure rejects initialize() instead, which OpenFeature reports itself.
|
|
24
|
+
const onFailed = (err) => this.client.isInitialized && this.events.emit(ProviderEvents.Error, { message: err?.message ?? "DevPulse client failed" });
|
|
25
|
+
const onStale = (err) => this.events.emit(ProviderEvents.Stale, { message: err?.message ?? "DevPulse connection lost; serving last known flags" });
|
|
26
|
+
const onRecovered = () => this.events.emit(ProviderEvents.Ready);
|
|
27
|
+
this.client.on("update", onUpdate).on("failed", onFailed).on("stale", onStale).on("recovered", onRecovered);
|
|
28
|
+
this.detach = () => void this.client.off("update", onUpdate).off("failed", onFailed).off("stale", onStale).off("recovered", onRecovered);
|
|
19
29
|
}
|
|
20
30
|
async initialize() {
|
|
21
31
|
await this.client.waitForInitialization();
|
|
22
32
|
}
|
|
23
33
|
async onClose() {
|
|
24
|
-
|
|
34
|
+
this.detach();
|
|
35
|
+
if (this.ownsClient)
|
|
36
|
+
await this.client.close();
|
|
25
37
|
}
|
|
26
38
|
resolve(key, def, ctx, type) {
|
|
27
39
|
const detail = this.client.variationDetailSync(key, toDevPulseContext(ctx), def);
|
|
@@ -10,7 +10,7 @@ type Detail<T> = {
|
|
|
10
10
|
reason: Reason;
|
|
11
11
|
};
|
|
12
12
|
/**
|
|
13
|
-
* OpenFeature context → DevPulse context. `targetingKey` becomes the key;
|
|
13
|
+
* OpenFeature context → DevPulse context. Dates become ISO 8601 strings. `targetingKey` becomes the key;
|
|
14
14
|
* `kind` selects the context kind (default "user"); multi-kind contexts pass through.
|
|
15
15
|
*/
|
|
16
16
|
export declare function toDevPulseContext(ctx: EvaluationContext): Record<string, unknown>;
|
|
@@ -1,10 +1,30 @@
|
|
|
1
1
|
import { ErrorCode, StandardResolutionReasons } from "@openfeature/core";
|
|
2
2
|
/**
|
|
3
|
-
* OpenFeature context
|
|
3
|
+
* OpenFeature context values may be Dates; DevPulse compares RFC 3339 strings (or epoch ms) for
|
|
4
|
+
* `before`/`after`, so Dates become ISO strings at any depth (invalid Dates are dropped).
|
|
5
|
+
*/
|
|
6
|
+
function toJsonValue(v) {
|
|
7
|
+
if (v instanceof Date)
|
|
8
|
+
return Number.isNaN(v.getTime()) ? undefined : v.toISOString();
|
|
9
|
+
if (Array.isArray(v))
|
|
10
|
+
return v.map(toJsonValue);
|
|
11
|
+
if (v && typeof v === "object") {
|
|
12
|
+
const out = {};
|
|
13
|
+
for (const [k, x] of Object.entries(v)) {
|
|
14
|
+
const j = toJsonValue(x);
|
|
15
|
+
if (j !== undefined)
|
|
16
|
+
out[k] = j;
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
return v;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* OpenFeature context → DevPulse context. Dates become ISO 8601 strings. `targetingKey` becomes the key;
|
|
4
24
|
* `kind` selects the context kind (default "user"); multi-kind contexts pass through.
|
|
5
25
|
*/
|
|
6
26
|
export function toDevPulseContext(ctx) {
|
|
7
|
-
const { targetingKey, ...rest } = ctx;
|
|
27
|
+
const { targetingKey, ...rest } = toJsonValue(ctx);
|
|
8
28
|
if (rest.kind === "multi")
|
|
9
29
|
return rest;
|
|
10
30
|
return { ...rest, kind: typeof rest.kind === "string" ? rest.kind : "user", key: targetingKey ?? rest.key };
|
package/dist/openfeature/web.js
CHANGED
|
@@ -20,9 +20,15 @@ export class DevPulseWebProvider {
|
|
|
20
20
|
this.options = options;
|
|
21
21
|
}
|
|
22
22
|
async initialize(context) {
|
|
23
|
-
this.client
|
|
24
|
-
this.
|
|
25
|
-
|
|
23
|
+
void this.client?.close(); // re-initialized: don't leak the previous client
|
|
24
|
+
const client = createClient(this.clientSideId, toDevPulseContext(context ?? {}), { ...this.options, withReasons: true });
|
|
25
|
+
this.client = client;
|
|
26
|
+
client.on("change", (changes) => this.events.emit(ProviderEvents.ConfigurationChanged, { flagsChanged: Object.keys(changes) }));
|
|
27
|
+
// Before initialization a failure rejects initialize() instead, which OpenFeature reports itself.
|
|
28
|
+
client.on("failed", (err) => client.isInitialized && this.events.emit(ProviderEvents.Error, { message: err?.message ?? "DevPulse client failed" }));
|
|
29
|
+
client.on("stale", (err) => this.events.emit(ProviderEvents.Stale, { message: err?.message ?? "DevPulse stream lost; serving last known flags" }));
|
|
30
|
+
client.on("recovered", () => this.events.emit(ProviderEvents.Ready));
|
|
31
|
+
await client.waitForInitialization();
|
|
26
32
|
}
|
|
27
33
|
async onContextChange(_old, next) {
|
|
28
34
|
await this.client?.identify(toDevPulseContext(next));
|
package/dist/types.d.ts
CHANGED
|
@@ -15,7 +15,13 @@ export interface TokenPayload {
|
|
|
15
15
|
offlineVerified?: boolean;
|
|
16
16
|
[key: string]: unknown;
|
|
17
17
|
}
|
|
18
|
-
export type ValidateErrorCode = "EXPIRED" | "REVOKED" | "INVALID_TOKEN" | "RATE_LIMITED" | "NETWORK_ERROR" | "CONFIGURATION_ERROR" | string;
|
|
18
|
+
export type ValidateErrorCode = "EXPIRED" | "REVOKED" | "INVALID_TOKEN" | "RATE_LIMITED" | "NETWORK_ERROR" | "SERVICE_UNAVAILABLE" | "CONFIGURATION_ERROR" | string;
|
|
19
|
+
/**
|
|
20
|
+
* Failures that say nothing about the token: Daywalker Auth was unreachable, timed out, answered 5xx
|
|
21
|
+
* or answered with something that isn't a validation result. Both carry status 503 and are never cached;
|
|
22
|
+
* adapters should answer 503 rather than 401 for them.
|
|
23
|
+
*/
|
|
24
|
+
export declare const UNAVAILABLE_ERROR_CODES: readonly ValidateErrorCode[];
|
|
19
25
|
export type ValidateResult = {
|
|
20
26
|
ok: true;
|
|
21
27
|
data: TokenPayload;
|
|
@@ -26,6 +32,9 @@ export type ValidateResult = {
|
|
|
26
32
|
message: string;
|
|
27
33
|
retryAfter?: number;
|
|
28
34
|
};
|
|
35
|
+
export interface PublicJwks {
|
|
36
|
+
keys: PublicJwk[];
|
|
37
|
+
}
|
|
29
38
|
export interface PublicJwk {
|
|
30
39
|
kty: "OKP";
|
|
31
40
|
crv: "Ed25519";
|
|
@@ -43,19 +52,38 @@ export interface SigningKey {
|
|
|
43
52
|
export interface RetryOptions {
|
|
44
53
|
maxRetries?: number;
|
|
45
54
|
initialDelayMs?: number;
|
|
55
|
+
/** Cap on the exponential backoff delay. Default 30000ms. */
|
|
46
56
|
maxDelayMs?: number;
|
|
47
57
|
backoffFactor?: number;
|
|
48
58
|
jitter?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Longest `Retry-After` honored on a 429 (default 60000ms). A longer one is not waited out:
|
|
61
|
+
* the 429 response is returned to the caller.
|
|
62
|
+
*/
|
|
63
|
+
maxRetryAfterMs?: number;
|
|
64
|
+
/**
|
|
65
|
+
* Retry after network errors / timeouts. Defaults to true only for GET, HEAD and OPTIONS (or when an
|
|
66
|
+
* `idempotencyKey` is given), since the server may already have processed a request whose response was lost.
|
|
67
|
+
*/
|
|
68
|
+
retryNetworkErrors?: boolean;
|
|
49
69
|
}
|
|
50
70
|
export interface DaywalkerConfig {
|
|
51
71
|
apiKey?: string;
|
|
52
72
|
signingKey?: string | SigningKey;
|
|
73
|
+
/**
|
|
74
|
+
* Previous private key during rotation (env DAYWALKER_SIGNING_KEY_PREVIOUS). Never used to sign; its public
|
|
75
|
+
* JWK is published by `keys()` next to the current one so requests from instances still on the old key verify.
|
|
76
|
+
*/
|
|
53
77
|
previousSigningKey?: string | SigningKey;
|
|
54
78
|
keyId?: string;
|
|
55
79
|
serviceSlug?: string;
|
|
56
80
|
domain?: string;
|
|
57
|
-
|
|
58
|
-
|
|
81
|
+
/** A JWK, a JWKS (`{ keys: [...] }`, selected by the lease's `kid`), PEM, or Base64 SPKI / raw key. */
|
|
82
|
+
daywalkerPublicKey?: string | PublicJwk | PublicJwks;
|
|
83
|
+
/**
|
|
84
|
+
* Expected `iss` of offline-verified JWT leases (env DAYWALKER_LEASE_ISSUER). Daywalker documents no fixed
|
|
85
|
+
* issuer, so `iss` is unchecked when unset; set it whenever leases from other issuers could share your key.
|
|
86
|
+
*/
|
|
59
87
|
leaseIssuer?: string;
|
|
60
88
|
/** Must be https:// (http:// only for localhost). */
|
|
61
89
|
authUrl?: string;
|
|
@@ -63,7 +91,10 @@ export interface DaywalkerConfig {
|
|
|
63
91
|
/** Short cache for failed validations (not 429/network errors). Default 3000ms; 0 disables. */
|
|
64
92
|
negativeCacheTtlMs?: number;
|
|
65
93
|
maxRetries?: number;
|
|
94
|
+
/** Defaults for `fetch()` retries; per-request `init.retry` overrides individual fields. */
|
|
66
95
|
retry?: RetryOptions;
|
|
96
|
+
/** Per-attempt timeout for requests to Daywalker Auth. Default 5000ms; 0 disables. */
|
|
97
|
+
timeoutMs?: number;
|
|
67
98
|
fetch?: typeof fetch;
|
|
68
99
|
env?: Record<string, string | undefined>;
|
|
69
100
|
}
|
|
@@ -85,6 +116,7 @@ export interface SignedHeaders {
|
|
|
85
116
|
}
|
|
86
117
|
export interface SignRequestParams {
|
|
87
118
|
method: string;
|
|
119
|
+
/** Request target path; a query string (`/a?b=1`) is split off and covered as "@query". */
|
|
88
120
|
path: string;
|
|
89
121
|
authority: string;
|
|
90
122
|
body?: string | Uint8Array | null;
|
|
@@ -92,19 +124,39 @@ export interface SignRequestParams {
|
|
|
92
124
|
created?: number;
|
|
93
125
|
keyid?: string;
|
|
94
126
|
alg?: string;
|
|
127
|
+
/** Sets `Authorization: Bearer <apiKey>` on the returned headers (not covered by the signature). */
|
|
95
128
|
apiKey?: string;
|
|
129
|
+
/** Must be 1-128 of `[A-Za-z0-9_.-]` so it can't inject Signature-Input parameters. */
|
|
96
130
|
nonce?: string;
|
|
97
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* Remembers verified request signatures until they expire. The default in-memory store is per process:
|
|
134
|
+
* deployments with several instances must pass a shared store (e.g. Redis `SET key 1 NX PX ttl`), or a
|
|
135
|
+
* signature replayed against another instance is accepted there.
|
|
136
|
+
*/
|
|
137
|
+
export interface ReplayStore {
|
|
138
|
+
/** True when `key` was recorded and has not expired yet. */
|
|
139
|
+
has(key: string): boolean | Promise<boolean>;
|
|
140
|
+
/**
|
|
141
|
+
* Atomically records `key` for `ttlMs` if it is absent. Returns false when the key is already present or
|
|
142
|
+
* the store can't take it (e.g. full); the signature is then rejected.
|
|
143
|
+
*/
|
|
144
|
+
record(key: string, ttlMs: number): boolean | Promise<boolean>;
|
|
145
|
+
}
|
|
98
146
|
export interface VerifySignatureParams {
|
|
99
147
|
method: string;
|
|
148
|
+
/** Request target path, including any query string (covered as "@query"). */
|
|
100
149
|
path: string;
|
|
101
150
|
authority: string;
|
|
102
151
|
headers: Record<string, string | undefined> | Headers;
|
|
103
152
|
body?: string | Uint8Array | null;
|
|
104
|
-
|
|
153
|
+
/** A JWK, a JWKS (selected by the signature's `keyid`), a CryptoKey, PEM or Base64 key. */
|
|
154
|
+
publicKey: PublicJwk | PublicJwks | CryptoKey | string;
|
|
105
155
|
currentTime?: number;
|
|
106
156
|
maxClockSkewSeconds?: number;
|
|
107
157
|
replayCheck?: boolean;
|
|
158
|
+
/** Where verified signatures are remembered; defaults to a per-process in-memory cache. */
|
|
159
|
+
replayStore?: ReplayStore;
|
|
108
160
|
}
|
|
109
161
|
export interface VerifySignatureResult {
|
|
110
162
|
valid: boolean;
|
package/dist/types.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { redactSecrets } from "./crypto/encoding.js";
|
|
2
|
+
/**
|
|
3
|
+
* Failures that say nothing about the token: Daywalker Auth was unreachable, timed out, answered 5xx
|
|
4
|
+
* or answered with something that isn't a validation result. Both carry status 503 and are never cached;
|
|
5
|
+
* adapters should answer 503 rather than 401 for them.
|
|
6
|
+
*/
|
|
7
|
+
export const UNAVAILABLE_ERROR_CODES = Object.freeze(["SERVICE_UNAVAILABLE", "NETWORK_ERROR"]);
|
|
2
8
|
export class DaywalkerError extends Error {
|
|
3
9
|
status;
|
|
4
10
|
code;
|
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), `captureFullUrls` (default false: URLs drop the fragment and all query parameters except `utm_*`, `gclid`, `fbclid`, `ref`), `onError`. `host` must be `https://` except on localhost.
|
|
30
|
+
Options: `autocapturePageviews` (default true), `flushAt` (20), `flushInterval` (2s browser / 10s Node), `maxQueueSize` (1000), `persistence` (`localStorage` | `memory`), `sessionTimeoutMinutes` (30), `respectDoNotTrack`, `optedOut` (consent; opting out also drops events already queued), `captureFullUrls` (default false: URLs drop the fragment and all query parameters except `utm_*`, `gclid`, `fbclid`, `ref`), `sanitizePath` (rewrite paths that carry secrets, e.g. `(p) => p.replace(/^\/reset\/[^/]+/, "/reset/:token")`), `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/docs/flags-node.md
CHANGED
|
@@ -46,4 +46,4 @@ See `@shelbyb/daywalker-sdk/flags/react` for the `Providers` component.
|
|
|
46
46
|
|
|
47
47
|
## API
|
|
48
48
|
|
|
49
|
-
`variation` / `variationDetail` (async) and `variationSync` / `variationDetailSync`, typed helpers (`boolVariation`, …), `allFlagsState(context, { clientSideOnly, withReasons })`, `secureModeHash(context)`, `track`, `identify`, `on("ready" | "failed" | "update" | "update:<key>")`, `flush()`, `close()`.
|
|
49
|
+
`variation` / `variationDetail` (async) and `variationSync` / `variationDetailSync`, typed helpers (`boolVariation`, …), `allFlagsState(context, { clientSideOnly, withReasons })` (`clientSideOnly` defaults to `true`, so server-only flags never reach a browser bootstrap), `secureModeHash(context)`, `track`, `identify`, `on("ready" | "failed" | "update" | "update:<key>" | "stale" | "recovered")`, `flush()`, `close()`.
|
package/docs/flags.md
CHANGED
|
@@ -43,6 +43,7 @@ Only flags marked **Available to client-side SDKs** are visible.
|
|
|
43
43
|
| `sendEvents` | `true` | Analytics for insights and experiments |
|
|
44
44
|
| `privateAttributes` / `allAttributesPrivate` | — | Attributes never sent to DevPulse |
|
|
45
45
|
| `cache` | `false` | Persist the last flags in `localStorage` and use them while loading |
|
|
46
|
+
| `offline` | `false` | Serve only `bootstrap`/cached values and open no connection (tests, server rendering) |
|
|
46
47
|
|
|
47
48
|
## Contexts
|
|
48
49
|
|
|
@@ -50,4 +51,4 @@ Single kind: `{ kind: "user", key: "u1", plan: "pro" }`. Multi-kind: `{ kind: "m
|
|
|
50
51
|
|
|
51
52
|
## API
|
|
52
53
|
|
|
53
|
-
`variation(key, default)`, `variationDetail(key, default)`, `boolVariation` / `stringVariation` / `numberVariation` / `jsonVariation`, `allFlags()`, `identify(context)`, `track(key, data?, metricValue?)`, `on("ready" | "failed" | "change" | "change:<key>", fn)
|
|
54
|
+
`variation(key, default)`, `variationDetail(key, default)`, `boolVariation` / `stringVariation` / `numberVariation` / `jsonVariation`, `allFlags()`, `identify(context)`, `track(key, data?, metricValue?)`, `on("ready" | "failed" | "change" | "change:<key>" | "stale" | "recovered", fn)` (`stale`: the stream dropped and the last flags keep serving; `recovered`: it reconnected), `flush()`, `close()`.
|