@shelbyb/daywalker-sdk 0.2.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 +38 -24
- package/dist/analytics/index.d.ts +26 -3
- package/dist/analytics/index.js +107 -23
- package/dist/cli.js +136 -29
- package/dist/client.d.ts +44 -3
- package/dist/client.js +372 -73
- package/dist/config.d.ts +14 -2
- package/dist/config.js +36 -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 +54 -11
- package/dist/crypto/signatures.js +310 -94
- package/dist/express.d.ts +1 -0
- package/dist/express.js +8 -6
- package/dist/flags/client.d.ts +34 -5
- package/dist/flags/client.js +93 -22
- package/dist/flags/common/events.d.ts +9 -0
- package/dist/flags/common/events.js +77 -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/context.js +4 -3
- package/dist/flags/engine/index.d.ts +1 -1
- package/dist/flags/engine/index.js +1 -1
- package/dist/flags/engine/operators.d.ts +25 -0
- package/dist/flags/engine/operators.js +64 -8
- package/dist/flags/node.d.ts +27 -15
- package/dist/flags/node.js +54 -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 +63 -6
- package/dist/next.js +182 -36
- 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 +58 -3
- 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 +34 -16
- package/assets/logo-512.png +0 -0
package/dist/flags/react.js
CHANGED
|
@@ -1,26 +1,47 @@
|
|
|
1
|
+
"use client";
|
|
1
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { createContext, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
3
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
3
4
|
import { createClient } from "./client.js";
|
|
4
5
|
const ClientContext = createContext(null);
|
|
5
6
|
const isBrowser = typeof window !== "undefined";
|
|
6
7
|
/**
|
|
7
|
-
* Provides a DevPulse client to the tree. On the server (SSR/RSC streaming)
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Provides a DevPulse client to the tree. On the server (SSR/RSC streaming) and during the
|
|
9
|
+
* first client render, an offline client serves `options.bootstrap` and makes no requests, so
|
|
10
|
+
* hydration matches the server HTML. The live client is created (and closed) in an effect,
|
|
11
|
+
* which keeps StrictMode's simulated unmount from leaving a closed client behind. Changing
|
|
12
|
+
* `context` calls identify().
|
|
10
13
|
*/
|
|
11
14
|
export function DevPulseProvider({ clientSideId, context, options, client: external, children }) {
|
|
12
|
-
const [client] = useState(() => external ?? createClient(clientSideId, context, isBrowser ? options : { ...options, streaming: false, sendEvents: false, bootstrap: options.bootstrap ?? {} }));
|
|
13
15
|
const contextJson = JSON.stringify(context);
|
|
14
|
-
|
|
16
|
+
// Render-time stand-in: no network, timers or listeners, so creating it during render is safe.
|
|
17
|
+
// No localStorage cache either, so the first client render matches the server HTML.
|
|
18
|
+
const [placeholder] = useState(() => createClient(clientSideId, context, { ...options, offline: true, sendEvents: false, cache: false }));
|
|
19
|
+
const [owned, setOwned] = useState(null);
|
|
20
|
+
const latest = useRef({ contextJson, options });
|
|
21
|
+
latest.current = { contextJson, options };
|
|
15
22
|
useEffect(() => {
|
|
16
|
-
if (
|
|
17
|
-
first.current = false;
|
|
23
|
+
if (external || !isBrowser)
|
|
18
24
|
return;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
const c = createClient(clientSideId, JSON.parse(latest.current.contextJson), latest.current.options);
|
|
26
|
+
setOwned(c);
|
|
27
|
+
return () => {
|
|
28
|
+
void c.close();
|
|
29
|
+
setOwned((cur) => (cur === c ? null : cur));
|
|
30
|
+
};
|
|
31
|
+
}, [external, clientSideId]);
|
|
32
|
+
const live = external ?? owned;
|
|
33
|
+
const seen = useRef({ client: null, contextJson });
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!live || live.isClosed)
|
|
36
|
+
return;
|
|
37
|
+
const prev = seen.current;
|
|
38
|
+
seen.current = { client: live, contextJson };
|
|
39
|
+
// A client we just created already has the current context; an injected one is taken as is.
|
|
40
|
+
const changed = prev.client === live ? prev.contextJson !== contextJson : live === owned && JSON.stringify(live.getContext()) !== contextJson;
|
|
41
|
+
if (changed)
|
|
42
|
+
void live.identify(JSON.parse(contextJson)).catch(() => undefined);
|
|
43
|
+
}, [live, owned, contextJson]);
|
|
44
|
+
return _jsx(ClientContext.Provider, { value: live ?? placeholder, children: children });
|
|
24
45
|
}
|
|
25
46
|
export function useDevPulseClient() {
|
|
26
47
|
const client = useContext(ClientContext);
|
|
@@ -39,8 +60,17 @@ export function useFlagDetail(key, defaultValue) {
|
|
|
39
60
|
// Snapshot is the serialized value so useSyncExternalStore sees stable identities.
|
|
40
61
|
const snapshot = () => JSON.stringify(client.allFlags()[key] ?? null);
|
|
41
62
|
const serialized = useSyncExternalStore(subscribe, snapshot, snapshot);
|
|
42
|
-
|
|
43
|
-
|
|
63
|
+
const detail = useMemo(() => client.peekVariationDetail(key, JSON.parse(defaultJson)), [client, key, defaultJson, serialized]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
64
|
+
// Record the evaluation (analytics) after commit, once per distinct result: renders can be
|
|
65
|
+
// repeated or thrown away (StrictMode, concurrent rendering), effects re-run in StrictMode.
|
|
66
|
+
const recorded = useRef(null);
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (recorded.current === detail)
|
|
69
|
+
return;
|
|
70
|
+
recorded.current = detail;
|
|
71
|
+
client.recordEvaluation(key, JSON.parse(defaultJson), detail);
|
|
72
|
+
}, [client, key, defaultJson, detail]);
|
|
73
|
+
return detail;
|
|
44
74
|
}
|
|
45
75
|
/** All flag values; re-renders when any flag changes. */
|
|
46
76
|
export function useFlags() {
|
|
@@ -55,8 +85,8 @@ export function useFlagsReady() {
|
|
|
55
85
|
const subscribe = useMemo(() => (cb) => client.on("ready", cb), [client]);
|
|
56
86
|
return useSyncExternalStore(subscribe, () => client.isInitialized, () => client.isInitialized);
|
|
57
87
|
}
|
|
58
|
-
/** Track a custom event for metrics and experiments. */
|
|
88
|
+
/** Track a custom event for metrics and experiments. Stable across renders (safe in effect deps). */
|
|
59
89
|
export function useTrack() {
|
|
60
90
|
const client = useDevPulseClient();
|
|
61
|
-
return (key, data, metricValue) => client.track(key, data, metricValue);
|
|
91
|
+
return useCallback((key, data, metricValue) => client.track(key, data, metricValue), [client]);
|
|
62
92
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
export { Daywalker, createDaywalker, daywalker, } from "./client.js";
|
|
2
|
-
export {
|
|
3
|
-
export
|
|
4
|
-
export {
|
|
1
|
+
export { Daywalker, createDaywalker, daywalker, MAX_RETRY_AFTER_MS, MAX_VALIDATION_CACHE_ENTRIES, } from "./client.js";
|
|
2
|
+
export type { DaywalkerFetchInit, } from "./client.js";
|
|
3
|
+
export { DaywalkerError, DaywalkerAuthError, DaywalkerRateLimitError, UNAVAILABLE_ERROR_CODES, } from "./types.js";
|
|
4
|
+
export type { DaywalkerConfig, RateLimitWindow, TokenPayload, ValidateErrorCode, ValidateResult, RetryOptions, SelfCheckResult, PublicJwk, PublicJwks, SigningKey, } from "./types.js";
|
|
5
|
+
export { resolveConfig, publicHostname, assertSecureUrl, DEFAULT_TIMEOUT_MS, } from "./config.js";
|
|
5
6
|
export type { ResolvedConfig, } from "./config.js";
|
|
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
|
-
export type { SignedHeaders, SignRequestParams, VerifySignatureParams, VerifySignatureResult, } from "./crypto/index.js";
|
|
7
|
+
export { SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, REPLAY_CACHE_MAX_ENTRIES, SignatureReplayCache, computeContentDigest, sha256Base64, sha256Base64Url, calculateJwkThumbprint, generateSigningKey, loadSigningKey, loadPublicKey, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, registerPublicKey, stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, base64UrlToBytesStrict, bytesToHex, hexToBytes, maskSecret, redactSecrets, } from "./crypto/index.js";
|
|
8
|
+
export type { SignedHeaders, SignRequestParams, VerifySignatureParams, VerifySignatureResult, ReplayStore, } from "./crypto/index.js";
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { Daywalker, createDaywalker, daywalker, } from "./client.js";
|
|
2
|
-
export { DaywalkerError, DaywalkerAuthError, DaywalkerRateLimitError, } from "./types.js";
|
|
3
|
-
export { resolveConfig, publicHostname, } from "./config.js";
|
|
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";
|
|
1
|
+
export { Daywalker, createDaywalker, daywalker, MAX_RETRY_AFTER_MS, MAX_VALIDATION_CACHE_ENTRIES, } from "./client.js";
|
|
2
|
+
export { DaywalkerError, DaywalkerAuthError, DaywalkerRateLimitError, UNAVAILABLE_ERROR_CODES, } from "./types.js";
|
|
3
|
+
export { resolveConfig, publicHostname, assertSecureUrl, DEFAULT_TIMEOUT_MS, } from "./config.js";
|
|
4
|
+
export { SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, REPLAY_CACHE_MAX_ENTRIES, SignatureReplayCache, computeContentDigest, sha256Base64, sha256Base64Url, calculateJwkThumbprint, generateSigningKey, loadSigningKey, loadPublicKey, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, registerPublicKey, stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, base64UrlToBytesStrict, bytesToHex, hexToBytes, maskSecret, redactSecrets, } from "./crypto/index.js";
|
package/dist/middleware.d.ts
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
import { Daywalker } from "./client.js";
|
|
2
|
+
import type { ValidateResult } from "./types.js";
|
|
3
|
+
type ValidateFailure = Extract<ValidateResult, {
|
|
4
|
+
ok: false;
|
|
5
|
+
}>;
|
|
6
|
+
export interface AuthFailureResponse {
|
|
7
|
+
/** 401 bad/expired/missing token, 429 rate limited, 500 server misconfiguration, 503 auth service unavailable. */
|
|
8
|
+
status: 401 | 429 | 500 | 503;
|
|
9
|
+
error: string;
|
|
10
|
+
message: string;
|
|
11
|
+
retryAfter?: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Maps a failed validation to the HTTP status an adapter should answer with. Outages (5xx,
|
|
15
|
+
* network errors, SERVICE_UNAVAILABLE) become 503 rather than 401 so clients retry instead of
|
|
16
|
+
* discarding a valid token; configuration errors become a 500 without their (internal) details.
|
|
17
|
+
* Shared by the Next, Web/Hono and Express adapters.
|
|
18
|
+
*/
|
|
19
|
+
export declare function toAuthFailureResponse(result: ValidateFailure): AuthFailureResponse;
|
|
2
20
|
export interface DaywalkerMiddlewareOptions {
|
|
3
21
|
client?: Daywalker;
|
|
4
22
|
serviceSlug?: string;
|
package/dist/middleware.js
CHANGED
|
@@ -1,4 +1,30 @@
|
|
|
1
1
|
import { daywalker } from "./client.js";
|
|
2
|
+
/** Error codes that mean Daywalker Auth itself could not be reached or is degraded. */
|
|
3
|
+
const UNAVAILABLE_ERROR_CODES = new Set(["NETWORK_ERROR", "SERVICE_UNAVAILABLE"]);
|
|
4
|
+
/**
|
|
5
|
+
* Maps a failed validation to the HTTP status an adapter should answer with. Outages (5xx,
|
|
6
|
+
* network errors, SERVICE_UNAVAILABLE) become 503 rather than 401 so clients retry instead of
|
|
7
|
+
* discarding a valid token; configuration errors become a 500 without their (internal) details.
|
|
8
|
+
* Shared by the Next, Web/Hono and Express adapters.
|
|
9
|
+
*/
|
|
10
|
+
export function toAuthFailureResponse(result) {
|
|
11
|
+
const retryAfter = result.retryAfter && result.retryAfter > 0 ? result.retryAfter : undefined;
|
|
12
|
+
if (result.error === "CONFIGURATION_ERROR") {
|
|
13
|
+
return { status: 500, error: "CONFIGURATION_ERROR", message: "Authentication is not configured correctly on this server." };
|
|
14
|
+
}
|
|
15
|
+
if (result.status === 429 || result.error === "RATE_LIMITED") {
|
|
16
|
+
return { status: 429, error: "RATE_LIMITED", message: result.message || "Rate limit exceeded", retryAfter };
|
|
17
|
+
}
|
|
18
|
+
if (result.status >= 500 || result.status === 0 || UNAVAILABLE_ERROR_CODES.has(result.error)) {
|
|
19
|
+
return {
|
|
20
|
+
status: 503,
|
|
21
|
+
error: UNAVAILABLE_ERROR_CODES.has(result.error) ? result.error : "SERVICE_UNAVAILABLE",
|
|
22
|
+
message: "Daywalker Auth is temporarily unavailable. Please retry.",
|
|
23
|
+
retryAfter,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
return { status: 401, error: result.error || "INVALID_TOKEN", message: result.message || "Unauthorized" };
|
|
27
|
+
}
|
|
2
28
|
/**
|
|
3
29
|
* Standard Web / Hono compatible middleware.
|
|
4
30
|
* Works with Hono, standard Web Request/Response routers, and middleware pipelines.
|
|
@@ -46,13 +72,8 @@ export function daywalkerAuth(options = {}) {
|
|
|
46
72
|
}
|
|
47
73
|
const result = await client.validateToken(token, { serviceSlug });
|
|
48
74
|
if (!result.ok) {
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
error: result.error,
|
|
52
|
-
message: result.message,
|
|
53
|
-
retryAfter: result.retryAfter,
|
|
54
|
-
};
|
|
55
|
-
const headers = result.retryAfter ? { "Retry-After": String(result.retryAfter) } : undefined;
|
|
75
|
+
const { status, ...body } = toAuthFailureResponse(result);
|
|
76
|
+
const headers = body.retryAfter ? { "Retry-After": String(body.retryAfter) } : undefined;
|
|
56
77
|
if (typeof c?.json === "function") {
|
|
57
78
|
return c.json(body, status, headers);
|
|
58
79
|
}
|
package/dist/next.d.ts
CHANGED
|
@@ -7,19 +7,55 @@ 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
|
+
/**
|
|
11
|
+
* A suggested value for the opt-in `staticFileExtensions` option. It is NOT applied by default:
|
|
12
|
+
* an extension says nothing about whether a route is public (`/users/123.js` can be a dynamic page).
|
|
13
|
+
* Prefer excluding public files with Next's `matcher` (see `createDaywalkerMiddleware`).
|
|
14
|
+
*/
|
|
15
|
+
export declare const STATIC_FILE_EXTENSIONS: RegExp;
|
|
16
|
+
/**
|
|
17
|
+
* Decodes and normalizes a URL pathname the way a router sees it: percent-decoded once, `//`
|
|
18
|
+
* collapsed and `.`/`..` segments resolved. Returns null (never bypass auth) for malformed
|
|
19
|
+
* encoding, encoded slashes/backslashes, raw backslashes or control characters.
|
|
20
|
+
*/
|
|
21
|
+
export declare function normalizePathname(pathname: string): string | null;
|
|
22
|
+
/**
|
|
23
|
+
* Static assets that skip auth: Next's build output (`/_next/static/`), image optimizer
|
|
24
|
+
* (`/_next/image`) and `/favicon.ico`. Files matching `staticFileExtensions` are included only when
|
|
25
|
+
* that list is passed explicitly. The path is decoded and normalized first; `/_next/data/`
|
|
26
|
+
* (getServerSideProps JSON) and `/api` / `/api/*` are never static.
|
|
27
|
+
*/
|
|
28
|
+
export declare function isStaticAssetPath(pathname: string, staticFileExtensions?: RegExp | readonly string[]): boolean;
|
|
10
29
|
export interface NextMiddlewareOptions {
|
|
11
30
|
client?: Daywalker;
|
|
12
31
|
serviceSlug?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Public paths that skip auth (exact match or `path/` prefix, compared against the decoded,
|
|
34
|
+
* normalized pathname). Defaults to `DEFAULT_BYPASS_PATHS`. Prefer Next's `matcher` config to keep
|
|
35
|
+
* public routes from reaching the middleware at all.
|
|
36
|
+
*/
|
|
13
37
|
bypassPaths?: string[];
|
|
38
|
+
/**
|
|
39
|
+
* Opt-in: also skip auth for (non-`/api`, non-`/_next/`) paths ending in one of these extensions,
|
|
40
|
+
* e.g. `STATIC_FILE_EXTENSIONS` or `["png", "css"]`. Off by default, because a dynamic route such
|
|
41
|
+
* as `/users/[id]` also answers `/users/123.js`. Prefer a `matcher` that excludes `public/` files.
|
|
42
|
+
*/
|
|
43
|
+
staticFileExtensions?: RegExp | readonly string[];
|
|
14
44
|
errorPath?: string;
|
|
15
45
|
homePath?: string;
|
|
46
|
+
/** Only enforce auth on `/api` and `/api/*`; other routes pass through. */
|
|
16
47
|
protectApiOnly?: boolean;
|
|
17
48
|
validateServerActions?: boolean;
|
|
49
|
+
/** When set, requests whose Host header is missing or not listed get a 403. */
|
|
18
50
|
allowedHosts?: string[];
|
|
19
51
|
}
|
|
52
|
+
/** Options for the Next.js 16+ `proxy.ts` aliases (identical to `NextMiddlewareOptions`). */
|
|
53
|
+
export type NextProxyOptions = NextMiddlewareOptions;
|
|
20
54
|
/**
|
|
21
55
|
* Sanitizes open redirect path parameter (`from`).
|
|
22
|
-
*
|
|
56
|
+
* Accepts only same-origin relative paths: rejects protocol-relative URLs (`//evil.com`), any
|
|
57
|
+
* backslash, whitespace or control character (browsers strip tabs/newlines, so `/\t/evil.com` would
|
|
58
|
+
* become `//evil.com`), and anything that resolves to another origin.
|
|
23
59
|
*/
|
|
24
60
|
export declare function sanitizeRedirectPath(path: string): string | null;
|
|
25
61
|
/**
|
|
@@ -28,24 +64,34 @@ export declare function sanitizeRedirectPath(path: string): string | null;
|
|
|
28
64
|
* Implements:
|
|
29
65
|
* 1. Server Action probe defense (CVE-2025-55182 scanner mitigation)
|
|
30
66
|
* 2. Static asset and health check bypass
|
|
31
|
-
* 3.
|
|
32
|
-
* 4. Distinct handling for API routes (JSON + CORS) vs UI pages (redirect)
|
|
67
|
+
* 3. Per-request token auth: requests without `Authorization: Bearer` / `x-api-token` get 401
|
|
68
|
+
* 4. Distinct handling for API routes (JSON + CORS) vs UI pages (redirect to `errorPath` with `from`)
|
|
69
|
+
*
|
|
70
|
+
* Only `/_next/static/`, `/_next/image`, `/favicon.ico` and `bypassPaths` skip auth by default.
|
|
71
|
+
* Exclude other public files with Next's `matcher` (recommended) or opt into `staticFileExtensions`.
|
|
33
72
|
*
|
|
34
73
|
* Example:
|
|
35
|
-
* // middleware.ts
|
|
74
|
+
* // middleware.ts (Next.js 16+: proxy.ts, `export const proxy = createDaywalkerProxy();`)
|
|
36
75
|
* import { createDaywalkerMiddleware } from "@shelbyb/daywalker-sdk/next";
|
|
37
76
|
*
|
|
38
77
|
* export const middleware = createDaywalkerMiddleware();
|
|
39
78
|
*
|
|
40
79
|
* export const config = {
|
|
41
|
-
* matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
|
80
|
+
* matcher: ["/((?!_next/static|_next/image|favicon.ico|images/).*)"],
|
|
42
81
|
* };
|
|
43
82
|
*/
|
|
44
83
|
export declare function createDaywalkerMiddleware(options?: NextMiddlewareOptions): (request: Request) => Promise<Response>;
|
|
84
|
+
/**
|
|
85
|
+
* Service health gate: checks this service's own DAYWALKER_API_KEY (not the caller's token) and
|
|
86
|
+
* routes every request to `errorPath` while it is invalid, redirecting back once it heals.
|
|
87
|
+
* It does NOT authenticate callers; use `createDaywalkerMiddleware` or `withDaywalkerAuth` for that.
|
|
88
|
+
*/
|
|
89
|
+
export declare function createDaywalkerServiceHealthMiddleware(options?: NextMiddlewareOptions): (request: Request) => Promise<Response>;
|
|
45
90
|
/**
|
|
46
91
|
* Next.js App Router route handler wrapper with Daywalker token validation.
|
|
47
92
|
* Extracts `Authorization: Bearer <token>` or `x-api-token`, validates via client,
|
|
48
|
-
* returns
|
|
93
|
+
* returns a JSON error on failure (401 bad token, 429 rate limited, 503 Daywalker Auth unavailable,
|
|
94
|
+
* 500 misconfiguration), or passes validated `auth` payload in context.
|
|
49
95
|
*
|
|
50
96
|
* Example:
|
|
51
97
|
* // app/api/protected/route.ts
|
|
@@ -61,3 +107,14 @@ export declare function withDaywalkerAuth<Context extends Record<string, unknown
|
|
|
61
107
|
client?: Daywalker;
|
|
62
108
|
serviceSlug?: string;
|
|
63
109
|
}): (req: Request, context?: Context) => Promise<Response>;
|
|
110
|
+
/**
|
|
111
|
+
* Next.js 16 renamed `middleware.ts` to `proxy.ts` (export `proxy` instead of `middleware`).
|
|
112
|
+
* These aliases are identical to the `*Middleware` factories, which remain supported:
|
|
113
|
+
*
|
|
114
|
+
* // proxy.ts
|
|
115
|
+
* import { createDaywalkerProxy } from "@shelbyb/daywalker-sdk/next";
|
|
116
|
+
* export const proxy = createDaywalkerProxy();
|
|
117
|
+
* export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };
|
|
118
|
+
*/
|
|
119
|
+
export declare const createDaywalkerProxy: typeof createDaywalkerMiddleware;
|
|
120
|
+
export declare const createDaywalkerServiceHealthProxy: typeof createDaywalkerServiceHealthMiddleware;
|
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,19 +13,123 @@ export const DEFAULT_BYPASS_PATHS = [
|
|
|
12
13
|
"/robots.txt",
|
|
13
14
|
"/sitemap.xml",
|
|
14
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
|
+
*/
|
|
21
|
+
export const STATIC_FILE_EXTENSIONS = /\.(?:js|css|png|jpe?g|gif|webp|avif|svg|ico|woff2?|map|txt)$/i;
|
|
22
|
+
/**
|
|
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.
|
|
26
|
+
*/
|
|
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))
|
|
78
|
+
return false;
|
|
79
|
+
if (isBuiltInStaticPath(pathname))
|
|
80
|
+
return true;
|
|
81
|
+
if (pathname.startsWith("/_next/"))
|
|
82
|
+
return false;
|
|
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));
|
|
96
|
+
}
|
|
97
|
+
const REDIRECT_ORIGIN = "https://redirect-check.invalid";
|
|
15
98
|
/**
|
|
16
99
|
* Sanitizes open redirect path parameter (`from`).
|
|
17
|
-
*
|
|
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.
|
|
18
103
|
*/
|
|
19
104
|
export function sanitizeRedirectPath(path) {
|
|
20
105
|
if (!path || typeof path !== "string")
|
|
21
106
|
return null;
|
|
22
|
-
|
|
23
|
-
|
|
107
|
+
if (/[\s\u0000-\u001f\u007f-\u009f\\]/.test(path))
|
|
108
|
+
return null;
|
|
109
|
+
if (!path.startsWith("/") || path.startsWith("//"))
|
|
110
|
+
return null;
|
|
111
|
+
let resolved;
|
|
112
|
+
try {
|
|
113
|
+
resolved = new URL(path, REDIRECT_ORIGIN);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
if (resolved.origin !== REDIRECT_ORIGIN || !resolved.pathname.startsWith("/") || resolved.pathname.startsWith("//")) {
|
|
24
119
|
return null;
|
|
25
120
|
}
|
|
26
|
-
return
|
|
121
|
+
return path;
|
|
27
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
|
+
]);
|
|
28
133
|
function extractToken(req) {
|
|
29
134
|
const authHeader = req.headers.get("authorization");
|
|
30
135
|
if (authHeader && authHeader.toLowerCase().startsWith("bearer ")) {
|
|
@@ -42,33 +147,61 @@ function extractToken(req) {
|
|
|
42
147
|
* Implements:
|
|
43
148
|
* 1. Server Action probe defense (CVE-2025-55182 scanner mitigation)
|
|
44
149
|
* 2. Static asset and health check bypass
|
|
45
|
-
* 3.
|
|
46
|
-
* 4. Distinct handling for API routes (JSON + CORS) vs UI pages (redirect)
|
|
150
|
+
* 3. Per-request token auth: requests without `Authorization: Bearer` / `x-api-token` get 401
|
|
151
|
+
* 4. Distinct handling for API routes (JSON + CORS) vs UI pages (redirect to `errorPath` with `from`)
|
|
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`.
|
|
47
155
|
*
|
|
48
156
|
* Example:
|
|
49
|
-
* // middleware.ts
|
|
157
|
+
* // middleware.ts (Next.js 16+: proxy.ts, `export const proxy = createDaywalkerProxy();`)
|
|
50
158
|
* import { createDaywalkerMiddleware } from "@shelbyb/daywalker-sdk/next";
|
|
51
159
|
*
|
|
52
160
|
* export const middleware = createDaywalkerMiddleware();
|
|
53
161
|
*
|
|
54
162
|
* export const config = {
|
|
55
|
-
* matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
|
163
|
+
* matcher: ["/((?!_next/static|_next/image|favicon.ico|images/).*)"],
|
|
56
164
|
* };
|
|
57
165
|
*/
|
|
58
166
|
export function createDaywalkerMiddleware(options = {}) {
|
|
59
167
|
const client = options.client ?? daywalker;
|
|
168
|
+
return buildMiddleware(options, async (request) => {
|
|
169
|
+
const token = extractToken(request);
|
|
170
|
+
if (!token) {
|
|
171
|
+
return { ok: false, error: "INVALID_TOKEN", status: 401, message: "Missing Authorization header or x-api-token" };
|
|
172
|
+
}
|
|
173
|
+
return client.validateToken(token, { serviceSlug: options.serviceSlug });
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Service health gate: checks this service's own DAYWALKER_API_KEY (not the caller's token) and
|
|
178
|
+
* routes every request to `errorPath` while it is invalid, redirecting back once it heals.
|
|
179
|
+
* It does NOT authenticate callers; use `createDaywalkerMiddleware` or `withDaywalkerAuth` for that.
|
|
180
|
+
*/
|
|
181
|
+
export function createDaywalkerServiceHealthMiddleware(options = {}) {
|
|
182
|
+
const client = options.client ?? daywalker;
|
|
183
|
+
return buildMiddleware(options, () => client.validateServiceKey({ serviceSlug: options.serviceSlug }));
|
|
184
|
+
}
|
|
185
|
+
function buildMiddleware(options, authenticate) {
|
|
60
186
|
const bypassPaths = options.bypassPaths ?? DEFAULT_BYPASS_PATHS;
|
|
61
187
|
const errorPath = options.errorPath ?? "/service-error";
|
|
62
188
|
const homePath = options.homePath ?? "/";
|
|
63
189
|
const validateServerActions = options.validateServerActions ?? true;
|
|
190
|
+
const matchesExtension = extensionMatcher(options.staticFileExtensions);
|
|
191
|
+
const normalizedErrorPath = normalizePathname(errorPath) ?? errorPath;
|
|
64
192
|
return async function middleware(request) {
|
|
65
193
|
const url = new URL(request.url);
|
|
66
|
-
const
|
|
194
|
+
const rawPathname = url.pathname;
|
|
67
195
|
const search = url.search;
|
|
68
|
-
//
|
|
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.
|
|
69
202
|
if (options.allowedHosts && options.allowedHosts.length > 0) {
|
|
70
|
-
const host = request.headers.get("host")?.toLowerCase().replace(/:\d+$/, "");
|
|
71
|
-
if (host
|
|
203
|
+
const host = request.headers.get("host")?.trim().toLowerCase().replace(/:\d+$/, "");
|
|
204
|
+
if (!host || !options.allowedHosts.some((h) => h.toLowerCase() === host)) {
|
|
72
205
|
return Response.json({ error: "Forbidden", message: "Invalid host header." }, { status: 403 });
|
|
73
206
|
}
|
|
74
207
|
}
|
|
@@ -80,20 +213,18 @@ export function createDaywalkerMiddleware(options = {}) {
|
|
|
80
213
|
}
|
|
81
214
|
}
|
|
82
215
|
// 1. Bypass static files and health check probes
|
|
83
|
-
if (
|
|
84
|
-
pathname.startsWith(
|
|
85
|
-
(pathname.includes(".") && !pathname.endsWith(".json"))) {
|
|
216
|
+
if (pathname !== null &&
|
|
217
|
+
(bypassPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`)) || isStaticNormalized(pathname, matchesExtension))) {
|
|
86
218
|
return globalThis.NextResponse?.next
|
|
87
219
|
? globalThis.NextResponse.next()
|
|
88
220
|
: new Response(null, { headers: { "x-middleware-next": "1" } });
|
|
89
221
|
}
|
|
90
|
-
// 2.
|
|
91
|
-
const
|
|
92
|
-
const authResult = await client.validateToken(incomingToken ? { token: incomingToken, serviceSlug: options.serviceSlug } : { serviceSlug: options.serviceSlug });
|
|
222
|
+
// 2. Authenticate (caller's token, or the service key for the health gate)
|
|
223
|
+
const authResult = await authenticate(request);
|
|
93
224
|
// 3. Handle service error page route
|
|
94
|
-
if (pathname ===
|
|
225
|
+
if (pathname === normalizedErrorPath) {
|
|
95
226
|
if (authResult.ok) {
|
|
96
|
-
//
|
|
227
|
+
// Authorized again: heal/redirect back to home
|
|
97
228
|
const redirectUrl = new URL(homePath, request.url);
|
|
98
229
|
return globalThis.NextResponse?.redirect
|
|
99
230
|
? globalThis.NextResponse.redirect(redirectUrl)
|
|
@@ -106,20 +237,21 @@ export function createDaywalkerMiddleware(options = {}) {
|
|
|
106
237
|
}
|
|
107
238
|
// 4. Handle invalid token for all other routes
|
|
108
239
|
if (!authResult.ok) {
|
|
240
|
+
const failure = toAuthFailureResponse(authResult);
|
|
109
241
|
// API routes: return JSON error response
|
|
110
|
-
if (
|
|
242
|
+
if (isApi) {
|
|
111
243
|
return Response.json({
|
|
112
|
-
error:
|
|
113
|
-
message:
|
|
114
|
-
retryAfter:
|
|
244
|
+
error: failure.error,
|
|
245
|
+
message: failure.message || "Service authorization check failed with Daywalker Auth.",
|
|
246
|
+
retryAfter: failure.retryAfter,
|
|
115
247
|
}, {
|
|
116
|
-
status:
|
|
248
|
+
status: failure.status,
|
|
117
249
|
headers: {
|
|
118
250
|
"Content-Type": "application/json",
|
|
119
251
|
"Access-Control-Allow-Origin": "*",
|
|
120
252
|
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
121
253
|
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Api-Token",
|
|
122
|
-
...(
|
|
254
|
+
...(failure.retryAfter ? { "Retry-After": String(failure.retryAfter) } : {}),
|
|
123
255
|
},
|
|
124
256
|
});
|
|
125
257
|
}
|
|
@@ -129,13 +261,14 @@ export function createDaywalkerMiddleware(options = {}) {
|
|
|
129
261
|
? globalThis.NextResponse.next()
|
|
130
262
|
: new Response(null, { headers: { "x-middleware-next": "1" } });
|
|
131
263
|
}
|
|
132
|
-
// 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.
|
|
133
267
|
const redirectUrl = new URL(errorPath, request.url);
|
|
134
|
-
redirectUrl.searchParams.set("error",
|
|
135
|
-
redirectUrl.searchParams.set("
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
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}`;
|
|
139
272
|
const sanitized = sanitizeRedirectPath(rawTarget);
|
|
140
273
|
if (sanitized) {
|
|
141
274
|
redirectUrl.searchParams.set("from", sanitized);
|
|
@@ -153,7 +286,8 @@ export function createDaywalkerMiddleware(options = {}) {
|
|
|
153
286
|
/**
|
|
154
287
|
* Next.js App Router route handler wrapper with Daywalker token validation.
|
|
155
288
|
* Extracts `Authorization: Bearer <token>` or `x-api-token`, validates via client,
|
|
156
|
-
* 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.
|
|
157
291
|
*
|
|
158
292
|
* Example:
|
|
159
293
|
* // app/api/protected/route.ts
|
|
@@ -175,9 +309,10 @@ export function withDaywalkerAuth(handler, clientOrOptions) {
|
|
|
175
309
|
}
|
|
176
310
|
const result = await client.validateToken(token, { serviceSlug });
|
|
177
311
|
if (!result.ok) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
312
|
+
const { status, ...body } = toAuthFailureResponse(result);
|
|
313
|
+
return Response.json(body, {
|
|
314
|
+
status,
|
|
315
|
+
headers: body.retryAfter ? { "Retry-After": String(body.retryAfter) } : undefined,
|
|
181
316
|
});
|
|
182
317
|
}
|
|
183
318
|
const mergedContext = {
|
|
@@ -187,3 +322,14 @@ export function withDaywalkerAuth(handler, clientOrOptions) {
|
|
|
187
322
|
return handler(req, mergedContext);
|
|
188
323
|
};
|
|
189
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>;
|