@spotify-confidence/openfeature-server-provider-local 0.12.1 → 0.13.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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.13.0](https://github.com/spotify/confidence-resolver/compare/openfeature-provider-js-v0.12.1...openfeature-provider-js-v0.13.0) (2026-05-05)
4
+
5
+
6
+ ### Features
7
+
8
+ * **js:** Next.js Pages Router support ([#393](https://github.com/spotify/confidence-resolver/issues/393)) ([8efd0ae](https://github.com/spotify/confidence-resolver/commit/8efd0aee9581edaddd531bc15ba63e741e598eff))
9
+
3
10
  ## [0.12.1](https://github.com/spotify/confidence-resolver/compare/openfeature-provider-js-v0.12.0...openfeature-provider-js-v0.12.1) (2026-04-28)
4
11
 
5
12
 
@@ -1418,7 +1418,7 @@ function isObject(value) {
1418
1418
  function isSet$3(value) {
1419
1419
  return value !== null && value !== void 0;
1420
1420
  }
1421
- const VERSION = "0.12.1";
1421
+ const VERSION = "0.13.0";
1422
1422
  const NOOP_LOG_FN = Object.assign(() => {}, { enabled: false });
1423
1423
  const debugBackend = loadDebug();
1424
1424
  const logger$2 = new class LoggerImpl {
@@ -1418,7 +1418,7 @@ function isObject(value) {
1418
1418
  function isSet$3(value) {
1419
1419
  return value !== null && value !== void 0;
1420
1420
  }
1421
- const VERSION = "0.12.1";
1421
+ const VERSION = "0.13.0";
1422
1422
  const NOOP_LOG_FN = Object.assign(() => {}, { enabled: false });
1423
1423
  const debugBackend = loadDebug();
1424
1424
  const logger$2 = new class LoggerImpl {
@@ -1421,7 +1421,7 @@ function isObject(value) {
1421
1421
  function isSet$3(value) {
1422
1422
  return value !== null && value !== void 0;
1423
1423
  }
1424
- const VERSION = "0.12.1";
1424
+ const VERSION = "0.13.0";
1425
1425
  const NOOP_LOG_FN = Object.assign(() => {}, { enabled: false });
1426
1426
  const debugBackend = loadDebug();
1427
1427
  const logger$2 = new class LoggerImpl {
@@ -0,0 +1,20 @@
1
+ import { NextApiHandler } from "next";
2
+
3
+ //#region src/pages-router/api.d.ts
4
+ interface ApplyHandlerOptions {
5
+ /** Use a non-default OpenFeature provider by name. */
6
+ providerName?: string;
7
+ }
8
+ /**
9
+ * Builds the POST handler that the client `ConfidencePagesProvider` calls to
10
+ * fire exposure events. Mount it at `/api/confidence/apply` (or pass a custom
11
+ * `apiPath` to `ConfidencePagesProvider` if you mount it elsewhere).
12
+ *
13
+ * @example
14
+ * // pages/api/confidence/apply.ts
15
+ * import { applyHandler } from '@spotify-confidence/openfeature-server-provider-local/pages-router/api';
16
+ * export default applyHandler();
17
+ */
18
+ declare function applyHandler(opts?: ApplyHandlerOptions): NextApiHandler;
19
+ //#endregion
20
+ export { ApplyHandlerOptions, applyHandler };
@@ -0,0 +1,62 @@
1
+ import { OpenFeature } from "@openfeature/server-sdk";
2
+ import { createDecipheriv, createHash } from "node:crypto";
3
+ const PROVIDER_NAME = "ConfidenceServerProviderLocal";
4
+ function getConfidenceProvider(providerName) {
5
+ const provider = providerName ? OpenFeature.getProvider(providerName) : OpenFeature.getProvider();
6
+ if (provider?.metadata?.name !== PROVIDER_NAME) return null;
7
+ return provider;
8
+ }
9
+ const ALGO = "aes-256-gcm";
10
+ const IV_LEN = 12;
11
+ const TAG_LEN = 16;
12
+ let cachedKey = null;
13
+ function getKey() {
14
+ if (cachedKey) return cachedKey;
15
+ const raw = process.env.CONFIDENCE_TOKEN_KEY;
16
+ if (!raw) throw new Error("CONFIDENCE_TOKEN_KEY is not set. Generate one with: openssl rand -hex 32 — and set it in the server environment.");
17
+ cachedKey = createHash("sha256").update(raw).digest();
18
+ return cachedKey;
19
+ }
20
+ function openResolveToken(handle) {
21
+ const buf = Buffer.from(handle, "base64url");
22
+ if (buf.length < IV_LEN + TAG_LEN) throw new Error("Invalid Confidence handle");
23
+ const iv = buf.subarray(0, IV_LEN);
24
+ const tag = buf.subarray(IV_LEN, IV_LEN + TAG_LEN);
25
+ const ciphertext = buf.subarray(IV_LEN + TAG_LEN);
26
+ const decipher = createDecipheriv(ALGO, getKey(), iv);
27
+ decipher.setAuthTag(tag);
28
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
29
+ }
30
+ function applyHandler(opts = {}) {
31
+ return async (req, res) => {
32
+ if (req.method !== "POST") {
33
+ res.setHeader("Allow", "POST");
34
+ res.status(405).end();
35
+ return;
36
+ }
37
+ const body = req.body;
38
+ if (!body || typeof body.resolveToken !== "string" || typeof body.flagName !== "string") {
39
+ res.status(400).end();
40
+ return;
41
+ }
42
+ const provider = getConfidenceProvider(opts.providerName);
43
+ if (!provider) {
44
+ res.status(503).end();
45
+ return;
46
+ }
47
+ let openedToken;
48
+ try {
49
+ openedToken = openResolveToken(body.resolveToken);
50
+ } catch {
51
+ res.status(400).end();
52
+ return;
53
+ }
54
+ if (!openedToken) {
55
+ res.status(204).end();
56
+ return;
57
+ }
58
+ provider.applyFlag(openedToken, body.flagName);
59
+ res.status(204).end();
60
+ };
61
+ }
62
+ export { applyHandler };
@@ -0,0 +1,69 @@
1
+ import { ReactNode } from "react";
2
+ import "@openfeature/core";
3
+
4
+ //#region src/types.d.ts
5
+ type ResolutionReason = "ERROR" | "FLAG_ARCHIVED" | "MATCH" | "NO_SEGMENT_MATCH" | "TARGETING_KEY_ERROR" | "NO_TREATMENT_MATCH" | "MATERIALIZATION_NOT_SUPPORTED" | "UNSPECIFIED";
6
+ declare enum ErrorCode {
7
+ PROVIDER_NOT_READY = "PROVIDER_NOT_READY",
8
+ PROVIDER_FATAL = "PROVIDER_FATAL",
9
+ FLAG_NOT_FOUND = "FLAG_NOT_FOUND",
10
+ TYPE_MISMATCH = "TYPE_MISMATCH",
11
+ GENERAL = "GENERAL",
12
+ }
13
+ interface ResolutionDetails<T> {
14
+ reason: ResolutionReason;
15
+ value: T;
16
+ variant?: string;
17
+ errorCode?: ErrorCode;
18
+ errorMessage?: string;
19
+ shouldApply: boolean;
20
+ }
21
+ type FlagPrimitive = null | boolean | string | number;
22
+ type FlagObject = {
23
+ [key: string]: FlagValue;
24
+ };
25
+ type FlagValue = FlagPrimitive | FlagObject;
26
+ //#endregion
27
+ //#region src/flag-bundle.d.ts
28
+ interface FlagBundle {
29
+ flags: Record<string, ResolutionDetails<FlagObject | null> | undefined>;
30
+ resolveId: string;
31
+ resolveToken: string;
32
+ errorCode?: ErrorCode;
33
+ errorMessage?: string;
34
+ }
35
+ //#endregion
36
+ //#region src/pages-router/types.d.ts
37
+ type FlagBundle$1 = FlagBundle;
38
+ /**
39
+ * Server-to-client transport payload returned from `withConfidence` and
40
+ * consumed by `<ConfidencePagesProvider>`. Structurally a `FlagBundle` whose
41
+ * `resolveToken` has been sealed (AES-GCM) — the apply API route is the only
42
+ * thing that opens it.
43
+ */
44
+ type ConfidencePageProps = FlagBundle$1;
45
+ //#endregion
46
+ //#region src/pages-router/client.d.ts
47
+ interface Props {
48
+ /**
49
+ * The Confidence payload returned from `withConfidence` in
50
+ * `getServerSideProps`, normally pulled out of `pageProps` in `_app.tsx`.
51
+ * Pages that don't resolve flags pass `undefined` and any `useFlag` /
52
+ * `useFlagDetails` calls in their tree return defaults.
53
+ */
54
+ confidence?: ConfidencePageProps;
55
+ /** Override the apply API route. Must match where you mount `applyHandler`. */
56
+ apiPath?: string;
57
+ children: ReactNode;
58
+ }
59
+ /**
60
+ * Place at the top of `_app.tsx`. Bridges the bundle resolved on the server
61
+ * to the client `useFlag` / `useFlagDetails` hooks.
62
+ */
63
+ declare function ConfidencePagesProvider({
64
+ confidence,
65
+ apiPath,
66
+ children
67
+ }: Props): React.ReactElement;
68
+ //#endregion
69
+ export { type ConfidencePageProps, ConfidencePagesProvider };
@@ -0,0 +1,32 @@
1
+ "use client";
2
+ import { useCallback } from "react";
3
+ import { ConfidenceClientProvider } from "@spotify-confidence/openfeature-server-provider-local/react-client";
4
+ import { Fragment, jsx } from "react/jsx-runtime";
5
+ const DEFAULT_APPLY_PATH = "/api/confidence/apply";
6
+ function ConfidencePagesProvider({ confidence, apiPath = DEFAULT_APPLY_PATH, children }) {
7
+ const resolveToken = confidence?.resolveToken;
8
+ const apply = useCallback(async (flagName) => {
9
+ if (!resolveToken) return;
10
+ try {
11
+ const res = await fetch(apiPath, {
12
+ method: "POST",
13
+ headers: { "content-type": "application/json" },
14
+ body: JSON.stringify({
15
+ resolveToken,
16
+ flagName
17
+ }),
18
+ keepalive: true
19
+ });
20
+ if (!res.ok && process.env.NODE_ENV !== "production") console.warn(`[Confidence] apply failed: ${res.status} ${res.statusText}. Mount applyHandler at ${apiPath} and ensure a ConfidenceServerProviderLocal is registered.`);
21
+ } catch (err) {
22
+ if (process.env.NODE_ENV !== "production") console.warn("[Confidence] apply request failed:", err);
23
+ }
24
+ }, [resolveToken, apiPath]);
25
+ if (!confidence) return /* @__PURE__ */ jsx(Fragment, { children });
26
+ return /* @__PURE__ */ jsx(ConfidenceClientProvider, {
27
+ bundle: confidence,
28
+ apply,
29
+ children
30
+ });
31
+ }
32
+ export { ConfidencePagesProvider };
@@ -0,0 +1,112 @@
1
+ import { EvaluationContext } from "@openfeature/server-sdk";
2
+ import { GetServerSideProps, GetServerSidePropsContext, Redirect } from "next";
3
+ import "@openfeature/core";
4
+
5
+ //#region src/types.d.ts
6
+ type ResolutionReason = "ERROR" | "FLAG_ARCHIVED" | "MATCH" | "NO_SEGMENT_MATCH" | "TARGETING_KEY_ERROR" | "NO_TREATMENT_MATCH" | "MATERIALIZATION_NOT_SUPPORTED" | "UNSPECIFIED";
7
+ declare enum ErrorCode {
8
+ PROVIDER_NOT_READY = "PROVIDER_NOT_READY",
9
+ PROVIDER_FATAL = "PROVIDER_FATAL",
10
+ FLAG_NOT_FOUND = "FLAG_NOT_FOUND",
11
+ TYPE_MISMATCH = "TYPE_MISMATCH",
12
+ GENERAL = "GENERAL",
13
+ }
14
+ interface ResolutionDetails<T> {
15
+ reason: ResolutionReason;
16
+ value: T;
17
+ variant?: string;
18
+ errorCode?: ErrorCode;
19
+ errorMessage?: string;
20
+ shouldApply: boolean;
21
+ }
22
+ type FlagPrimitive = null | boolean | string | number;
23
+ type FlagObject = {
24
+ [key: string]: FlagValue;
25
+ };
26
+ type FlagValue = FlagPrimitive | FlagObject;
27
+ //#endregion
28
+ //#region src/flag-bundle.d.ts
29
+ interface FlagBundle {
30
+ flags: Record<string, ResolutionDetails<FlagObject | null> | undefined>;
31
+ resolveId: string;
32
+ resolveToken: string;
33
+ errorCode?: ErrorCode;
34
+ errorMessage?: string;
35
+ }
36
+ //#endregion
37
+ //#region src/pages-router/types.d.ts
38
+ type FlagBundle$1 = FlagBundle;
39
+ /**
40
+ * Server-to-client transport payload returned from `withConfidence` and
41
+ * consumed by `<ConfidencePagesProvider>`. Structurally a `FlagBundle` whose
42
+ * `resolveToken` has been sealed (AES-GCM) — the apply API route is the only
43
+ * thing that opens it.
44
+ */
45
+ type ConfidencePageProps = FlagBundle$1;
46
+ //#endregion
47
+ //#region src/pages-router/server.d.ts
48
+ interface WithConfidenceOptions {
49
+ /** Use a non-default OpenFeature provider by name. */
50
+ providerName?: string;
51
+ }
52
+ /**
53
+ * Return type of the function passed to `withConfidence`. Augments the standard
54
+ * `getServerSideProps` shape with a required `context` (the evaluation context
55
+ * for flag resolution) and an optional `flags` allow-list.
56
+ */
57
+ type WithConfidenceResult<P extends {
58
+ [key: string]: any;
59
+ confidence?: never;
60
+ }> = {
61
+ props: P | Promise<P>;
62
+ /**
63
+ * Evaluation context for flag resolution. Omit to skip flag resolution
64
+ * entirely for this request — `pageProps.confidence` will be absent and
65
+ * any `useFlag` / `useFlagDetails` calls in the tree fall back to
66
+ * default values. Useful when flag access is conditional (e.g.
67
+ * unauthenticated requests).
68
+ */
69
+ context?: EvaluationContext;
70
+ /** Restrict resolution to a subset of flags. Defaults to all. */
71
+ flags?: string[];
72
+ } | {
73
+ redirect: Redirect;
74
+ } | {
75
+ notFound: true;
76
+ };
77
+ /**
78
+ * Low-level: resolve flags into a `ConfidencePageProps` payload. Use this if
79
+ * you want flag resolution outside the `withConfidence` flow (e.g. inside a
80
+ * custom decorator). Most callers should just use `withConfidence`.
81
+ */
82
+ declare function resolveConfidence(context: EvaluationContext, opts?: {
83
+ flags?: string[];
84
+ providerName?: string;
85
+ }): Promise<ConfidencePageProps>;
86
+ /**
87
+ * `getServerSideProps` decorator. Pass a single function that does your data
88
+ * fetching AND returns the evaluation context for flag resolution. The
89
+ * decorator resolves the flag bundle, seals it, and merges it into
90
+ * `pageProps.confidence`.
91
+ *
92
+ * Returning `{ redirect }` or `{ notFound }` short-circuits before flag
93
+ * resolution, just like a normal `getServerSideProps`.
94
+ *
95
+ * @example
96
+ * export const getServerSideProps = withConfidence(async ({ req }) => {
97
+ * const visitorId = req.cookies.uid ?? 'anon';
98
+ * const data = await fetchSomeData();
99
+ * return {
100
+ * props: { data },
101
+ * context: { visitor_id: visitorId },
102
+ * };
103
+ * });
104
+ */
105
+ declare function withConfidence<P extends {
106
+ [key: string]: any;
107
+ confidence?: never;
108
+ } = {}>(gssp: (ctx: GetServerSidePropsContext) => Promise<WithConfidenceResult<P>>, opts?: WithConfidenceOptions): GetServerSideProps<P & {
109
+ confidence?: ConfidencePageProps;
110
+ }>;
111
+ //#endregion
112
+ export { type ConfidencePageProps, WithConfidenceOptions, WithConfidenceResult, resolveConfidence, withConfidence };
@@ -0,0 +1,121 @@
1
+ import "@bufbuild/protobuf/wire";
2
+ import { OpenFeature } from "@openfeature/server-sdk";
3
+ import { createCipheriv, createHash, randomBytes } from "node:crypto";
4
+ const NOOP_LOG_FN = Object.assign(() => {}, { enabled: false });
5
+ const debugBackend = loadDebug();
6
+ const logger = new class LoggerImpl {
7
+ childLoggers = /* @__PURE__ */ new Map();
8
+ debug = NOOP_LOG_FN;
9
+ info = NOOP_LOG_FN;
10
+ warn = NOOP_LOG_FN;
11
+ error = NOOP_LOG_FN;
12
+ constructor(name) {
13
+ this.name = name;
14
+ this.configure();
15
+ }
16
+ async configure(backend = debugBackend) {
17
+ const debug = await backend;
18
+ if (!debug) return;
19
+ const debugFn = this.debug = (debug(this.name + ":debug"));
20
+ const infoFn = this.info = (debug(this.name + ":info"));
21
+ const warnFn = this.warn = (debug(this.name + ":warn"));
22
+ const errorFn = this.error = (debug(this.name + ":error"));
23
+ switch (true) {
24
+ case debugFn.enabled: infoFn.enabled = true;
25
+ case infoFn.enabled: warnFn.enabled = true;
26
+ case warnFn.enabled: errorFn.enabled = true;
27
+ }
28
+ for (const child of this.childLoggers.values()) child.configure(debug);
29
+ }
30
+ getLogger(name) {
31
+ let child = (this.childLoggers.get(name));
32
+ if (!child) {
33
+ child = new LoggerImpl(this.name + ":" + name);
34
+ this.childLoggers.set(name, child);
35
+ }
36
+ return child;
37
+ }
38
+ }("cnfd");
39
+ logger.getLogger.bind(logger);
40
+ async function loadDebug() {
41
+ try {
42
+ const { default: debug } = await import("debug");
43
+ if (typeof debug !== "function") return null;
44
+ return debug;
45
+ } catch (e) {
46
+ return null;
47
+ }
48
+ }
49
+ let ErrorCode = /* @__PURE__ */ function(ErrorCode$1) {
50
+ ErrorCode$1["PROVIDER_NOT_READY"] = "PROVIDER_NOT_READY";
51
+ ErrorCode$1["PROVIDER_FATAL"] = "PROVIDER_FATAL";
52
+ ErrorCode$1["FLAG_NOT_FOUND"] = "FLAG_NOT_FOUND";
53
+ ErrorCode$1["TYPE_MISMATCH"] = "TYPE_MISMATCH";
54
+ ErrorCode$1["GENERAL"] = "GENERAL";
55
+ return ErrorCode$1;
56
+ }({});
57
+ function error(errorCode, errorMessage) {
58
+ return {
59
+ flags: {},
60
+ resolveId: "",
61
+ resolveToken: "",
62
+ errorCode,
63
+ errorMessage
64
+ };
65
+ }
66
+ const PROVIDER_NAME = "ConfidenceServerProviderLocal";
67
+ function getConfidenceProvider(providerName) {
68
+ const provider = providerName ? OpenFeature.getProvider(providerName) : OpenFeature.getProvider();
69
+ if (provider?.metadata?.name !== PROVIDER_NAME) return null;
70
+ return provider;
71
+ }
72
+ const ALGO = "aes-256-gcm";
73
+ const IV_LEN = 12;
74
+ let cachedKey = null;
75
+ function getKey() {
76
+ if (cachedKey) return cachedKey;
77
+ const raw = process.env.CONFIDENCE_TOKEN_KEY;
78
+ if (!raw) throw new Error("CONFIDENCE_TOKEN_KEY is not set. Generate one with: openssl rand -hex 32 — and set it in the server environment.");
79
+ cachedKey = createHash("sha256").update(raw).digest();
80
+ return cachedKey;
81
+ }
82
+ function sealResolveToken(token) {
83
+ const iv = randomBytes(IV_LEN);
84
+ const cipher = createCipheriv(ALGO, getKey(), iv);
85
+ const ciphertext = Buffer.concat([cipher.update(token, "utf8"), cipher.final()]);
86
+ const tag = cipher.getAuthTag();
87
+ return Buffer.concat([
88
+ iv,
89
+ tag,
90
+ ciphertext
91
+ ]).toString("base64url");
92
+ }
93
+ function providerMissingBundle(providerName) {
94
+ return error(ErrorCode.GENERAL, `OpenFeature provider${providerName ? ` "${providerName}"` : ""} is not a ConfidenceServerProviderLocal. Register one with OpenFeature.setProviderAndWait — typically in instrumentation.ts.`);
95
+ }
96
+ async function resolveConfidence(context, opts = {}) {
97
+ const provider = getConfidenceProvider(opts.providerName);
98
+ if (!provider) return providerMissingBundle(opts.providerName);
99
+ const bundle = await provider.resolve(context, opts.flags ?? []);
100
+ return {
101
+ ...bundle,
102
+ resolveToken: sealResolveToken(bundle.resolveToken)
103
+ };
104
+ }
105
+ function withConfidence(gssp, opts = {}) {
106
+ return async (ctx) => {
107
+ const result = await gssp(ctx);
108
+ if (!("props" in result)) return result;
109
+ const innerProps = await Promise.resolve(result.props);
110
+ if (result.context === void 0) return { props: innerProps };
111
+ const confidence = await resolveConfidence(result.context, {
112
+ flags: result.flags,
113
+ providerName: opts.providerName
114
+ });
115
+ return { props: {
116
+ ...innerProps,
117
+ confidence
118
+ } };
119
+ };
120
+ }
121
+ export { resolveConfidence, withConfidence };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spotify-confidence/openfeature-server-provider-local",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "Spotify Confidence Open Feature provider",
5
5
  "type": "module",
6
6
  "files": [
@@ -32,6 +32,18 @@
32
32
  "types": "./dist/client.d.ts",
33
33
  "default": "./dist/client.js"
34
34
  },
35
+ "./pages-router/server": {
36
+ "types": "./dist/pages-router/server.d.ts",
37
+ "default": "./dist/pages-router/server.js"
38
+ },
39
+ "./pages-router/client": {
40
+ "types": "./dist/pages-router/client.d.ts",
41
+ "default": "./dist/pages-router/client.js"
42
+ },
43
+ "./pages-router/api": {
44
+ "types": "./dist/pages-router/api.d.ts",
45
+ "default": "./dist/pages-router/api.js"
46
+ },
35
47
  "./package.json": "./package.json"
36
48
  },
37
49
  "scripts": {
@@ -60,6 +72,7 @@
60
72
  "debug": "^4.4.3",
61
73
  "dotenv": "^17.2.2",
62
74
  "happy-dom": "^20.3.4",
75
+ "next": "^16.0.0",
63
76
  "prettier": "^2.8.8",
64
77
  "react": "^19",
65
78
  "react-dom": "^19.2.3",
@@ -72,6 +85,7 @@
72
85
  "peerDependencies": {
73
86
  "@openfeature/core": "^1.0.0",
74
87
  "debug": "^4.4.3",
88
+ "next": ">=13.0.0",
75
89
  "react": "^18.0.0 || ^19.0.0"
76
90
  },
77
91
  "peerDependenciesMeta": {
@@ -81,6 +95,9 @@
81
95
  "debug": {
82
96
  "optional": true
83
97
  },
98
+ "next": {
99
+ "optional": true
100
+ },
84
101
  "react": {
85
102
  "optional": true
86
103
  }