@nanoflags/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 CHANGED
@@ -10,7 +10,11 @@ learn about new revisions, and to send sampled telemetry. An accessor never perf
10
10
  - Entry points: `@nanoflags/sdk` (Node, browsers, edge runtimes), `@nanoflags/sdk/react`, and
11
11
  `@nanoflags/sdk/node` (test-session propagation with `AsyncLocalStorage`).
12
12
  - v2 adds [signature verification](#signature-verification) and [test sessions](#test-sessions).
13
- Both are opt-in; existing code keeps working unchanged.
13
+ - **0.4** verifies signatures **by default** when `baseUrl` is the hosted edge (`https://edge.nanoflags.io`), with
14
+ the production public keys embedded in the SDK, and refuses unsigned bundles there (see
15
+ [Signature verification](#signature-verification)). It also refuses bundles produced for a newer evaluation engine
16
+ (`bundle.engine`, see [Engine version](#engine-version)) and stops reconnecting when the edge reports that the
17
+ environment was deleted. Self-hosted edges (any other `baseUrl`) behave as in 0.3 unless `verify` is set.
14
18
 
15
19
  ## Install
16
20
 
@@ -107,7 +111,7 @@ createClient({
107
111
  bootstrap?: Bundle,
108
112
  store?: { get(): Promise<string | null>; set(v: string): Promise<void> } | false,
109
113
  events?: { enabled?: boolean /* true */, sampleRate?: number /* 0.1 */, flushIntervalMs?: number /* 10000 */, reportIntervalMs?: number /* 60000 */, redact?: (ctx) => object } | false,
110
- verify?: { publicKeys: JsonWebKey[] | "fetch", require?: boolean },
114
+ verify?: { publicKeys?: JsonWebKey[] | "fetch", require?: boolean } | false, // default: see Signature verification
111
115
  fetch?: typeof fetch,
112
116
  })
113
117
  ```
@@ -122,7 +126,7 @@ createClient({
122
126
  | `evaluate(key, fallback, ctx?)` | The engine `Evaluation`: `value`, `reason`, `ruleIndex`, `ruleId`, `inRollout`, `bucket`, `revision`, `trace`. No type check. |
123
127
  | `session(token, { service? })` | `Promise<SessionEvaluator>` for a test session. Never rejects. See [Test sessions](#test-sessions). |
124
128
  | `useSession(token \| null)` / `sessionHeaders()` | Browser only: join or leave a test session for this page session; headers for downstream calls. |
125
- | `on("update" \| "error", cb)` | Returns an unsubscribe function. `update` receives `{ revision, previousRevision, checksum, source, transport, latencyMs }` (see [Updates](#updates)). `error` receives a `NanoFlagsError` with `code`: `network`, `unauthorized`, `http`, `invalid_bundle`, `store`, `config`, `signature`, `session`. |
129
+ | `on("update" \| "error", cb)` | Returns an unsubscribe function. `update` receives `{ revision, previousRevision, checksum, source, transport, latencyMs }` (see [Updates](#updates)). `error` receives a `NanoFlagsError` with `code`: `network`, `unauthorized`, `http`, `invalid_bundle`, `store`, `config`, `signature`, `session`, `engine` (bundle for a newer engine, refused), `environment_deleted`. |
126
130
  | `refresh()` / `flush()` / `close()` | Revalidate now / send queued telemetry now / stop all background work. |
127
131
 
128
132
  ## Fallbacks and staleness
@@ -149,7 +153,9 @@ A **disabled** flag is not a fallback case: it returns the flag's configured `of
149
153
  that bundle; the SDK keeps retrying (after a failure: 1 s, 2 s, 4 s … up to the poll interval).
150
154
 
151
155
  The SDK never discards a working bundle because of an error, and never replaces a bundle with an older
152
- one served as last-known-good. If a key is revoked, the client reports `error` (`code: "unauthorized"`)
156
+ one served as last-known-good. If the environment is deleted, the edge closes the live socket with code `4004`: the client reports
157
+ `error` (`code: "environment_deleted"`), turns `stale`, keeps evaluating the last bundle and stops reconnecting
158
+ (only the regular poll continues, which then reports the key as rejected). If a key is revoked, the client reports `error` (`code: "unauthorized"`)
153
159
  and keeps evaluating the last bundle as `stale` — revocation stops updates, it does not erase
154
160
  configuration that already reached a process.
155
161
 
@@ -194,8 +200,17 @@ carries the signature, key id and environment id). A push that does not verify c
194
200
 
195
201
  ## Signature verification
196
202
 
197
- Published bundles are signed by the NanoFlags publisher with Ed25519. With `verify`, the SDK checks
198
- the signature **before** a new bundle replaces the one in use:
203
+ Published bundles are signed by the NanoFlags publisher with Ed25519. The SDK checks the signature **before** a new
204
+ bundle replaces the one in use.
205
+
206
+ **Default (0.4):**
207
+
208
+ | `baseUrl` | `verify` not set | |
209
+ | --- | --- | --- |
210
+ | `https://edge.nanoflags.io` | verified against the embedded production keys (`OFFICIAL_SIGNING_KEYS`), `require: true` | pushed (WebSocket) and fetched bundles must be signed and verify |
211
+ | any other edge | not verified (as in 0.3) | configure `verify` for a self-hosted edge |
212
+
213
+ `verify: false` turns verification off explicitly. To configure it yourself:
199
214
 
200
215
  ```ts
201
216
  const flags = createClient({
@@ -207,34 +222,48 @@ const flags = createClient({
207
222
  });
208
223
  ```
209
224
 
210
- - A response with `X-NanoFlags-Signed: true` is verified against the canonical string
225
+ - A response with `X-NanoFlags-Signed: true` (and every pushed bundle) is verified against the canonical string
211
226
  `nanoflags-bundle-v1\n<environment id>\n<revision>\n<checksum>\n<sha256 hex of the bundle JSON text>`
212
- (the bundle text exactly as the edge sent it), using WebCrypto.
227
+ (the bundle text exactly as the edge sent it), using WebCrypto, with the key whose `kid` the edge names
228
+ (`X-NanoFlags-Key-Id`, or `kid` in the push).
213
229
  - **Bad signature → the update is rejected.** The previous bundle stays in use, `status` does not
214
230
  change, and `error` fires with `code: "signature"`. The SDK retries on its normal schedule.
215
231
  - **Unsigned bundles** (the edge sends `X-NanoFlags-Signed: false` while the publisher is catching up,
216
- typically for under a minute after a publish) are accepted unless `require: true`. With `require`
217
- they are rejected the same way, so a new revision arrives only once it is signed. Without `require`,
218
- someone who can alter responses can also strip the signature — set `require: true` when you verify
219
- for security rather than for integrity checking.
220
- - `publicKeys: "fetch"` loads the keys once from `GET /v1/keys` on the same edge (reloaded at most once
221
- a minute when a bundle names an unknown key id). This detects corruption and tampering in storage
222
- and caches, but the keys are only as trustworthy as the edge that served them. **Pin the keys in
223
- your build** if the edge itself is in your threat model.
232
+ typically for well under a minute after a publish) are rejected when `require` is true — the default on the hosted
233
+ edge: the bundle in use stays, `error` (`signature`) fires, and the SDK fetches again with a short backoff (1 s,
234
+ 2 s, 4 s … up to the poll interval) until the signed revision is there. With `require: false` (the default for
235
+ other edges) they are accepted; then someone who can alter responses can also strip the signature.
236
+ - **Keys and rotation.** Keys are selected by `kid` and cached. With the embedded keys, a bundle signed with a key id
237
+ the SDK does not know (the hosted edge rotated its key after this SDK version was built) makes the SDK load
238
+ `GET /v1/keys` once and verify against those keys as well; an id that is still unknown is rejected, and the reload
239
+ is not repeated within a minute. `publicKeys: "fetch"` loads all keys from `GET /v1/keys` (again when an unknown
240
+ key id appears, at most once a minute) — this detects corruption and tampering in storage and caches, but fetched
241
+ keys are only as trustworthy as the edge that served them. Explicitly pinned `publicKeys` are never extended from
242
+ the network: **pin the keys in your build** if the edge itself is in your threat model, and add the new key before
243
+ a rotation (the edge lists the old key as `status: "retiring"` during the overlap).
224
244
  - After the first verified bundle the client remembers the environment id and rejects bundles signed
225
245
  for a different environment.
226
246
  - A persisted bundle (see below) is stored with its signature and verified again when restored; with
227
247
  `require`, an unsigned stored copy is ignored. A `bootstrap` bundle is trusted as given.
228
248
  With `publicKeys: "fetch"` a stored copy can only be verified once the keys were loaded, so an
229
- offline start does not restore it; pinned keys do not have this limitation.
249
+ offline start does not restore it; pinned and embedded keys do not have this limitation.
230
250
  - Only public keys are accepted. A JWK with a private part (`d`) is ignored and reported as a `config`
231
251
  error. Private signing keys and admin keys never belong in an application.
232
252
  - Requires Ed25519 in WebCrypto: Node 20+, current Chrome, Firefox and Safari, Cloudflare Workers,
233
- Deno, Bun. Where it is missing, signed bundles are rejected (they cannot be checked).
253
+ Deno, Bun. Where it is missing, signed bundles are rejected (they cannot be checked) — on the hosted edge pass
254
+ `verify: false` for such runtimes (Node 18) if you accept that.
234
255
 
235
256
  Not covered: test-session bundles (`GET /v1/session`) are not signed, and verification says nothing
236
257
  about *freshness* — it does not stop an attacker from withholding updates.
237
258
 
259
+ ## Engine version
260
+
261
+ Bundles produced by the publisher carry `engine`, the version of the evaluation engine they were produced for
262
+ (`1` today; `SUPPORTED_ENGINE_VERSION` is what this SDK evaluates). A bundle with a **newer** `engine` — a rule format
263
+ this SDK does not understand — is refused wherever it comes from (fetch, push, store, `bootstrap`): the bundle in use
264
+ stays, `status` does not change, and `error` fires with `code: "engine"`. Upgrade the SDK to receive it. Bundles
265
+ without the field (older revisions, the edge's unsigned fallback) are evaluated as engine 1.
266
+
238
267
  ## Test sessions
239
268
 
240
269
  A test session pins a configuration snapshot and a synthetic context to a token (`nf_ts_…`, created in
@@ -51,6 +51,11 @@ interface Segment {
51
51
  }
52
52
  interface Bundle {
53
53
  schema: 1;
54
+ /**
55
+ * Engine version the bundle was produced for (`ENGINE_VERSION` of the publisher). Absent in bundles from before it
56
+ * existed (treated as 1). SDKs refuse a bundle whose `engine` is newer than the engine they ship.
57
+ */
58
+ engine?: number;
54
59
  project: string;
55
60
  environment: string;
56
61
  /** Published revision number. 0 means "draft / never published". */
@@ -98,14 +103,35 @@ interface Store {
98
103
  interface VerifyOptions {
99
104
  /**
100
105
  * Public Ed25519 keys as JWK (`{ kty: "OKP", crv: "Ed25519", x, kid? }`), or `"fetch"` to load them
101
- * once from `GET /v1/keys` on the configured edge. Pinning keys in your build is stronger than
102
- * `"fetch"`: fetched keys are only as trustworthy as the connection they arrived over.
106
+ * once from `GET /v1/keys` on the configured edge (again, at most once a minute, when a bundle names an unknown
107
+ * key id). Pinning keys in your build is stronger than `"fetch"`: fetched keys are only as trustworthy as the
108
+ * connection they arrived over. Omitted: the embedded production keys for `https://edge.nanoflags.io`, `"fetch"`
109
+ * for any other edge.
110
+ */
111
+ publicKeys?: readonly JsonWebKey[] | "fetch";
112
+ /**
113
+ * Also reject bundles that carry no signature (the edge's unsigned fallback, `X-NanoFlags-Signed: false`): the SDK
114
+ * keeps its bundle and fetches again shortly instead. Default true for `https://edge.nanoflags.io`, false otherwise.
103
115
  */
104
- publicKeys: JsonWebKey[] | "fetch";
105
- /** Also reject bundles that carry no signature. Default false. */
106
116
  require?: boolean;
107
117
  }
108
- declare const SDK_VERSION = "0.3.0";
118
+ /**
119
+ * The hosted NanoFlags edge and the public halves of its bundle-signing keys (Ed25519, safe to embed).
120
+ *
121
+ * With `baseUrl` equal to `OFFICIAL_EDGE_URL` the SDK verifies every bundle against these keys by default
122
+ * (`verify.require` defaults to true there). A bundle signed with a key id that is not listed here — a rotation that
123
+ * happened after this SDK version was built — makes the SDK load `GET /v1/keys` from the edge once and verify against
124
+ * the keys it lists as well. Rotation procedure: RUNTIME.md, "Signing-key rotation".
125
+ */
126
+ declare const OFFICIAL_EDGE_URL = "https://edge.nanoflags.io";
127
+ declare const OFFICIAL_SIGNING_KEYS: readonly (JsonWebKey & {
128
+ kid: string;
129
+ })[];
130
+ declare const SDK_VERSION = "0.4.0";
131
+ /** Newest bundle format (`bundle.engine`) this SDK can evaluate. Newer bundles are refused; the previous one stays in use. */
132
+ declare const SUPPORTED_ENGINE_VERSION = 1;
133
+ /** WebSocket close code of the edge for an environment that was deleted: the SDK stops reconnecting. */
134
+ declare const CLOSE_ENVIRONMENT_DELETED = 4004;
109
135
  /** Header that carries a test-session token between services. */
110
136
  declare const SESSION_HEADER = "x-nanoflags-session";
111
137
  type ClientStatus = "not-ready" | "ready" | "stale";
@@ -173,8 +199,12 @@ interface ClientOptions {
173
199
  * Verify bundle signatures before a new bundle replaces the current one. A bundle whose signature
174
200
  * does not verify is rejected: the previous bundle stays in use and an `error` (`code: "signature"`)
175
201
  * is emitted. Without `require`, bundles the edge marks as unsigned are still accepted.
202
+ *
203
+ * Default: with `baseUrl` = `https://edge.nanoflags.io` the SDK verifies against the embedded production keys
204
+ * (`OFFICIAL_SIGNING_KEYS`, plus `GET /v1/keys` once when a bundle names an unknown key id) with `require: true`;
205
+ * with any other `baseUrl` nothing is verified unless configured. `false` turns verification off explicitly.
176
206
  */
177
- verify?: VerifyOptions;
207
+ verify?: VerifyOptions | false;
178
208
  fetch?: typeof fetch;
179
209
  }
180
210
  /** The flag accessors shared by the client and by session-scoped evaluators. */
@@ -262,11 +292,11 @@ interface NanoFlagsClient {
262
292
  readonly version: number;
263
293
  }
264
294
  declare class NanoFlagsError extends Error {
265
- readonly code: "network" | "unauthorized" | "http" | "invalid_bundle" | "store" | "config" | "signature" | "session";
295
+ readonly code: "network" | "unauthorized" | "http" | "invalid_bundle" | "store" | "config" | "signature" | "session" | "engine" | "environment_deleted";
266
296
  readonly httpStatus?: number | undefined;
267
- constructor(message: string, code: "network" | "unauthorized" | "http" | "invalid_bundle" | "store" | "config" | "signature" | "session", httpStatus?: number | undefined);
297
+ constructor(message: string, code: "network" | "unauthorized" | "http" | "invalid_bundle" | "store" | "config" | "signature" | "session" | "engine" | "environment_deleted", httpStatus?: number | undefined);
268
298
  }
269
299
  /** A session token is opaque; it only has to be safe to put in a header. */
270
300
  declare function isSessionToken(v: unknown): v is string;
271
301
  declare function createClient(options: ClientOptions): NanoFlagsClient;
272
- export { JsonValue as C, Evaluation as S, isSessionToken as _, LIVE_PROTOCOL as a, Bundle as b, SDK_VERSION as c, SessionOptions as d, Transport as f, createClient as g, WebSocketLike as h, FlagEvaluator as i, SESSION_HEADER as l, WebSocketConstructor as m, ClientStatus as n, NanoFlagsClient as o, UpdateEvent as p, EventsOptions as r, NanoFlagsError as s, ClientOptions as t, SessionEvaluator as u, VerifyOptions as v, Reason as w, Context as x, Store as y };
302
+ export { Store as C, JsonValue as D, Evaluation as E, Reason as O, VerifyOptions as S, Context as T, WebSocketLike as _, FlagEvaluator as a, OFFICIAL_EDGE_URL as b, NanoFlagsError as c, SUPPORTED_ENGINE_VERSION as d, SessionEvaluator as f, WebSocketConstructor as g, UpdateEvent as h, EventsOptions as i, SDK_VERSION as l, Transport as m, ClientOptions as n, LIVE_PROTOCOL as o, SessionOptions as p, ClientStatus as r, NanoFlagsClient as s, CLOSE_ENVIRONMENT_DELETED as t, SESSION_HEADER as u, createClient as v, Bundle as w, OFFICIAL_SIGNING_KEYS as x, isSessionToken as y };
@@ -51,6 +51,11 @@ interface Segment {
51
51
  }
52
52
  interface Bundle {
53
53
  schema: 1;
54
+ /**
55
+ * Engine version the bundle was produced for (`ENGINE_VERSION` of the publisher). Absent in bundles from before it
56
+ * existed (treated as 1). SDKs refuse a bundle whose `engine` is newer than the engine they ship.
57
+ */
58
+ engine?: number;
54
59
  project: string;
55
60
  environment: string;
56
61
  /** Published revision number. 0 means "draft / never published". */
@@ -98,14 +103,35 @@ interface Store {
98
103
  interface VerifyOptions {
99
104
  /**
100
105
  * Public Ed25519 keys as JWK (`{ kty: "OKP", crv: "Ed25519", x, kid? }`), or `"fetch"` to load them
101
- * once from `GET /v1/keys` on the configured edge. Pinning keys in your build is stronger than
102
- * `"fetch"`: fetched keys are only as trustworthy as the connection they arrived over.
106
+ * once from `GET /v1/keys` on the configured edge (again, at most once a minute, when a bundle names an unknown
107
+ * key id). Pinning keys in your build is stronger than `"fetch"`: fetched keys are only as trustworthy as the
108
+ * connection they arrived over. Omitted: the embedded production keys for `https://edge.nanoflags.io`, `"fetch"`
109
+ * for any other edge.
110
+ */
111
+ publicKeys?: readonly JsonWebKey[] | "fetch";
112
+ /**
113
+ * Also reject bundles that carry no signature (the edge's unsigned fallback, `X-NanoFlags-Signed: false`): the SDK
114
+ * keeps its bundle and fetches again shortly instead. Default true for `https://edge.nanoflags.io`, false otherwise.
103
115
  */
104
- publicKeys: JsonWebKey[] | "fetch";
105
- /** Also reject bundles that carry no signature. Default false. */
106
116
  require?: boolean;
107
117
  }
108
- declare const SDK_VERSION = "0.3.0";
118
+ /**
119
+ * The hosted NanoFlags edge and the public halves of its bundle-signing keys (Ed25519, safe to embed).
120
+ *
121
+ * With `baseUrl` equal to `OFFICIAL_EDGE_URL` the SDK verifies every bundle against these keys by default
122
+ * (`verify.require` defaults to true there). A bundle signed with a key id that is not listed here — a rotation that
123
+ * happened after this SDK version was built — makes the SDK load `GET /v1/keys` from the edge once and verify against
124
+ * the keys it lists as well. Rotation procedure: RUNTIME.md, "Signing-key rotation".
125
+ */
126
+ declare const OFFICIAL_EDGE_URL = "https://edge.nanoflags.io";
127
+ declare const OFFICIAL_SIGNING_KEYS: readonly (JsonWebKey & {
128
+ kid: string;
129
+ })[];
130
+ declare const SDK_VERSION = "0.4.0";
131
+ /** Newest bundle format (`bundle.engine`) this SDK can evaluate. Newer bundles are refused; the previous one stays in use. */
132
+ declare const SUPPORTED_ENGINE_VERSION = 1;
133
+ /** WebSocket close code of the edge for an environment that was deleted: the SDK stops reconnecting. */
134
+ declare const CLOSE_ENVIRONMENT_DELETED = 4004;
109
135
  /** Header that carries a test-session token between services. */
110
136
  declare const SESSION_HEADER = "x-nanoflags-session";
111
137
  type ClientStatus = "not-ready" | "ready" | "stale";
@@ -173,8 +199,12 @@ interface ClientOptions {
173
199
  * Verify bundle signatures before a new bundle replaces the current one. A bundle whose signature
174
200
  * does not verify is rejected: the previous bundle stays in use and an `error` (`code: "signature"`)
175
201
  * is emitted. Without `require`, bundles the edge marks as unsigned are still accepted.
202
+ *
203
+ * Default: with `baseUrl` = `https://edge.nanoflags.io` the SDK verifies against the embedded production keys
204
+ * (`OFFICIAL_SIGNING_KEYS`, plus `GET /v1/keys` once when a bundle names an unknown key id) with `require: true`;
205
+ * with any other `baseUrl` nothing is verified unless configured. `false` turns verification off explicitly.
176
206
  */
177
- verify?: VerifyOptions;
207
+ verify?: VerifyOptions | false;
178
208
  fetch?: typeof fetch;
179
209
  }
180
210
  /** The flag accessors shared by the client and by session-scoped evaluators. */
@@ -262,11 +292,11 @@ interface NanoFlagsClient {
262
292
  readonly version: number;
263
293
  }
264
294
  declare class NanoFlagsError extends Error {
265
- readonly code: "network" | "unauthorized" | "http" | "invalid_bundle" | "store" | "config" | "signature" | "session";
295
+ readonly code: "network" | "unauthorized" | "http" | "invalid_bundle" | "store" | "config" | "signature" | "session" | "engine" | "environment_deleted";
266
296
  readonly httpStatus?: number | undefined;
267
- constructor(message: string, code: "network" | "unauthorized" | "http" | "invalid_bundle" | "store" | "config" | "signature" | "session", httpStatus?: number | undefined);
297
+ constructor(message: string, code: "network" | "unauthorized" | "http" | "invalid_bundle" | "store" | "config" | "signature" | "session" | "engine" | "environment_deleted", httpStatus?: number | undefined);
268
298
  }
269
299
  /** A session token is opaque; it only has to be safe to put in a header. */
270
300
  declare function isSessionToken(v: unknown): v is string;
271
301
  declare function createClient(options: ClientOptions): NanoFlagsClient;
272
- export { JsonValue as C, Evaluation as S, isSessionToken as _, LIVE_PROTOCOL as a, Bundle as b, SDK_VERSION as c, SessionOptions as d, Transport as f, createClient as g, WebSocketLike as h, FlagEvaluator as i, SESSION_HEADER as l, WebSocketConstructor as m, ClientStatus as n, NanoFlagsClient as o, UpdateEvent as p, EventsOptions as r, NanoFlagsError as s, ClientOptions as t, SessionEvaluator as u, VerifyOptions as v, Reason as w, Context as x, Store as y };
302
+ export { Store as C, JsonValue as D, Evaluation as E, Reason as O, VerifyOptions as S, Context as T, WebSocketLike as _, FlagEvaluator as a, OFFICIAL_EDGE_URL as b, NanoFlagsError as c, SUPPORTED_ENGINE_VERSION as d, SessionEvaluator as f, WebSocketConstructor as g, UpdateEvent as h, EventsOptions as i, SDK_VERSION as l, Transport as m, ClientOptions as n, LIVE_PROTOCOL as o, SessionOptions as p, ClientStatus as r, NanoFlagsClient as s, CLOSE_ENVIRONMENT_DELETED as t, SESSION_HEADER as u, createClient as v, Bundle as w, OFFICIAL_SIGNING_KEYS as x, isSessionToken as y };
package/dist/index.cjs CHANGED
@@ -1,8 +1,12 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_src = require("./src-B58PLGGE.cjs");
2
+ const require_src = require("./src-IVd-Wps4.cjs");
3
+ exports.CLOSE_ENVIRONMENT_DELETED = require_src.CLOSE_ENVIRONMENT_DELETED;
3
4
  exports.LIVE_PROTOCOL = require_src.LIVE_PROTOCOL;
4
5
  exports.NanoFlagsError = require_src.NanoFlagsError;
6
+ exports.OFFICIAL_EDGE_URL = require_src.OFFICIAL_EDGE_URL;
7
+ exports.OFFICIAL_SIGNING_KEYS = require_src.OFFICIAL_SIGNING_KEYS;
5
8
  exports.SDK_VERSION = require_src.SDK_VERSION;
6
9
  exports.SESSION_HEADER = require_src.SESSION_HEADER;
10
+ exports.SUPPORTED_ENGINE_VERSION = require_src.SUPPORTED_ENGINE_VERSION;
7
11
  exports.createClient = require_src.createClient;
8
12
  exports.isSessionToken = require_src.isSessionToken;
package/dist/index.d.cts CHANGED
@@ -9,5 +9,5 @@
9
9
  * the edge pushes the signed bundle itself — no request follows), the SSE stream (`/v1/stream`,
10
10
  * a notification followed by a conditional fetch), and polling (`/v1/bundle`).
11
11
  */
12
- import { C as JsonValue, S as Evaluation, _ as isSessionToken, a as LIVE_PROTOCOL, b as Bundle, c as SDK_VERSION, d as SessionOptions, f as Transport, g as createClient, h as WebSocketLike, i as FlagEvaluator, l as SESSION_HEADER, m as WebSocketConstructor, n as ClientStatus, o as NanoFlagsClient, p as UpdateEvent, r as EventsOptions, s as NanoFlagsError, t as ClientOptions, u as SessionEvaluator, v as VerifyOptions, w as Reason, x as Context, y as Store } from "./index-DZj6xbyg.cjs";
13
- export { type Bundle, ClientOptions, ClientStatus, type Context, type Evaluation, EventsOptions, FlagEvaluator, type JsonValue, LIVE_PROTOCOL, NanoFlagsClient, NanoFlagsError, type Reason, SDK_VERSION, SESSION_HEADER, SessionEvaluator, SessionOptions, type Store, Transport, UpdateEvent, type VerifyOptions, WebSocketConstructor, WebSocketLike, createClient, isSessionToken };
12
+ import { C as Store, D as JsonValue, E as Evaluation, O as Reason, S as VerifyOptions, T as Context, _ as WebSocketLike, a as FlagEvaluator, b as OFFICIAL_EDGE_URL, c as NanoFlagsError, d as SUPPORTED_ENGINE_VERSION, f as SessionEvaluator, g as WebSocketConstructor, h as UpdateEvent, i as EventsOptions, l as SDK_VERSION, m as Transport, n as ClientOptions, o as LIVE_PROTOCOL, p as SessionOptions, r as ClientStatus, s as NanoFlagsClient, t as CLOSE_ENVIRONMENT_DELETED, u as SESSION_HEADER, v as createClient, w as Bundle, x as OFFICIAL_SIGNING_KEYS, y as isSessionToken } from "./index-D5qa8ji-.cjs";
13
+ export { type Bundle, CLOSE_ENVIRONMENT_DELETED, ClientOptions, ClientStatus, type Context, type Evaluation, EventsOptions, FlagEvaluator, type JsonValue, LIVE_PROTOCOL, NanoFlagsClient, NanoFlagsError, OFFICIAL_EDGE_URL, OFFICIAL_SIGNING_KEYS, type Reason, SDK_VERSION, SESSION_HEADER, SUPPORTED_ENGINE_VERSION, SessionEvaluator, SessionOptions, type Store, Transport, UpdateEvent, type VerifyOptions, WebSocketConstructor, WebSocketLike, createClient, isSessionToken };
package/dist/index.d.ts CHANGED
@@ -9,5 +9,5 @@
9
9
  * the edge pushes the signed bundle itself — no request follows), the SSE stream (`/v1/stream`,
10
10
  * a notification followed by a conditional fetch), and polling (`/v1/bundle`).
11
11
  */
12
- import { C as JsonValue, S as Evaluation, _ as isSessionToken, a as LIVE_PROTOCOL, b as Bundle, c as SDK_VERSION, d as SessionOptions, f as Transport, g as createClient, h as WebSocketLike, i as FlagEvaluator, l as SESSION_HEADER, m as WebSocketConstructor, n as ClientStatus, o as NanoFlagsClient, p as UpdateEvent, r as EventsOptions, s as NanoFlagsError, t as ClientOptions, u as SessionEvaluator, v as VerifyOptions, w as Reason, x as Context, y as Store } from "./index-DZj6xbyg.js";
13
- export { type Bundle, ClientOptions, ClientStatus, type Context, type Evaluation, EventsOptions, FlagEvaluator, type JsonValue, LIVE_PROTOCOL, NanoFlagsClient, NanoFlagsError, type Reason, SDK_VERSION, SESSION_HEADER, SessionEvaluator, SessionOptions, type Store, Transport, UpdateEvent, type VerifyOptions, WebSocketConstructor, WebSocketLike, createClient, isSessionToken };
12
+ import { C as Store, D as JsonValue, E as Evaluation, O as Reason, S as VerifyOptions, T as Context, _ as WebSocketLike, a as FlagEvaluator, b as OFFICIAL_EDGE_URL, c as NanoFlagsError, d as SUPPORTED_ENGINE_VERSION, f as SessionEvaluator, g as WebSocketConstructor, h as UpdateEvent, i as EventsOptions, l as SDK_VERSION, m as Transport, n as ClientOptions, o as LIVE_PROTOCOL, p as SessionOptions, r as ClientStatus, s as NanoFlagsClient, t as CLOSE_ENVIRONMENT_DELETED, u as SESSION_HEADER, v as createClient, w as Bundle, x as OFFICIAL_SIGNING_KEYS, y as isSessionToken } from "./index-D5qa8ji-.js";
13
+ export { type Bundle, CLOSE_ENVIRONMENT_DELETED, ClientOptions, ClientStatus, type Context, type Evaluation, EventsOptions, FlagEvaluator, type JsonValue, LIVE_PROTOCOL, NanoFlagsClient, NanoFlagsError, OFFICIAL_EDGE_URL, OFFICIAL_SIGNING_KEYS, type Reason, SDK_VERSION, SESSION_HEADER, SUPPORTED_ENGINE_VERSION, SessionEvaluator, SessionOptions, type Store, Transport, UpdateEvent, type VerifyOptions, WebSocketConstructor, WebSocketLike, createClient, isSessionToken };
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export { t as LIVE_PROTOCOL, n as NanoFlagsError, r as SDK_VERSION, i as SESSION_HEADER, a as createClient, o as isSessionToken } from "./src-DUF_NkBY.js";
1
+ export { t as CLOSE_ENVIRONMENT_DELETED, n as LIVE_PROTOCOL, r as NanoFlagsError, l as OFFICIAL_EDGE_URL, u as OFFICIAL_SIGNING_KEYS, i as SDK_VERSION, a as SESSION_HEADER, o as SUPPORTED_ENGINE_VERSION, s as createClient, c as isSessionToken } from "./src-CVTvEKbT.js";
package/dist/node.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_src = require("./src-B58PLGGE.cjs");
2
+ const require_src = require("./src-IVd-Wps4.cjs");
3
3
  // src/node.ts
4
4
  /**
5
5
  * @nanoflags/sdk/node — test-session propagation for Node servers.
package/dist/node.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { d as SessionOptions, i as FlagEvaluator, l as SESSION_HEADER, o as NanoFlagsClient, u as SessionEvaluator } from "./index-DZj6xbyg.cjs";
1
+ import { a as FlagEvaluator, f as SessionEvaluator, p as SessionOptions, s as NanoFlagsClient, u as SESSION_HEADER } from "./index-D5qa8ji-.cjs";
2
2
  type SessionClient = Pick<NanoFlagsClient, "session"> & FlagEvaluator & Pick<NanoFlagsClient, "revision">;
3
3
  /** Run `fn` with `token` as the current test session. An absent or malformed token runs `fn` outside any session. */
4
4
  export declare function withSession<T>(token: string | null | undefined, fn: () => T): T;
package/dist/node.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { d as SessionOptions, i as FlagEvaluator, l as SESSION_HEADER, o as NanoFlagsClient, u as SessionEvaluator } from "./index-DZj6xbyg.js";
1
+ import { a as FlagEvaluator, f as SessionEvaluator, p as SessionOptions, s as NanoFlagsClient, u as SESSION_HEADER } from "./index-D5qa8ji-.js";
2
2
  type SessionClient = Pick<NanoFlagsClient, "session"> & FlagEvaluator & Pick<NanoFlagsClient, "revision">;
3
3
  /** Run `fn` with `token` as the current test session. An absent or malformed token runs `fn` outside any session. */
4
4
  export declare function withSession<T>(token: string | null | undefined, fn: () => T): T;
package/dist/node.js CHANGED
@@ -1,4 +1,4 @@
1
- import { i as SESSION_HEADER, o as isSessionToken } from "./src-DUF_NkBY.js";
1
+ import { a as SESSION_HEADER, c as isSessionToken } from "./src-CVTvEKbT.js";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
3
  // src/node.ts
4
4
  /**
package/dist/react.d.cts CHANGED
@@ -2,7 +2,7 @@
2
2
  * @nanoflags/sdk/react — bindings built on useSyncExternalStore.
3
3
  * Components re-render when the bundle or the client status changes; evaluation stays local.
4
4
  */
5
- import { C as JsonValue, n as ClientStatus, o as NanoFlagsClient, x as Context } from "./index-DZj6xbyg.cjs";
5
+ import { D as JsonValue, T as Context, r as ClientStatus, s as NanoFlagsClient } from "./index-D5qa8ji-.cjs";
6
6
  import { ReactElement, ReactNode } from "react";
7
7
  export interface NanoFlagsProviderProps {
8
8
  client: NanoFlagsClient;
package/dist/react.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * @nanoflags/sdk/react — bindings built on useSyncExternalStore.
3
3
  * Components re-render when the bundle or the client status changes; evaluation stays local.
4
4
  */
5
- import { C as JsonValue, n as ClientStatus, o as NanoFlagsClient, x as Context } from "./index-DZj6xbyg.js";
5
+ import { D as JsonValue, T as Context, r as ClientStatus, s as NanoFlagsClient } from "./index-D5qa8ji-.js";
6
6
  import { ReactElement, ReactNode } from "react";
7
7
  export interface NanoFlagsProviderProps {
8
8
  client: NanoFlagsClient;
@@ -1,4 +1,3 @@
1
- // ../engine/src/index.ts
2
1
  const encoder$1 = new TextEncoder();
3
2
  /** MurmurHash3 x86 32-bit over UTF-8 bytes. Used for distribution only — never for security. */
4
3
  function murmur3(input, seed = 0) {
@@ -264,6 +263,27 @@ function evaluate(bundle, flagKey, ctx, fallback) {
264
263
  function fmt(v) {
265
264
  return typeof v === "string" ? v : JSON.stringify(v);
266
265
  }
266
+ /** True when this engine can evaluate `bundle` (its `engine` is absent or not newer than `ENGINE_VERSION`). */
267
+ function supportsEngine(bundle) {
268
+ const e = bundle.engine;
269
+ return e === void 0 || typeof e === "number" && Number.isInteger(e) && e >= 1 && e <= 1;
270
+ }
271
+ // src/keys.ts
272
+ /**
273
+ * The hosted NanoFlags edge and the public halves of its bundle-signing keys (Ed25519, safe to embed).
274
+ *
275
+ * With `baseUrl` equal to `OFFICIAL_EDGE_URL` the SDK verifies every bundle against these keys by default
276
+ * (`verify.require` defaults to true there). A bundle signed with a key id that is not listed here — a rotation that
277
+ * happened after this SDK version was built — makes the SDK load `GET /v1/keys` from the edge once and verify against
278
+ * the keys it lists as well. Rotation procedure: RUNTIME.md, "Signing-key rotation".
279
+ */
280
+ const OFFICIAL_EDGE_URL = "https://edge.nanoflags.io";
281
+ const OFFICIAL_SIGNING_KEYS = Object.freeze([Object.freeze({
282
+ kty: "OKP",
283
+ crv: "Ed25519",
284
+ x: "wQu1EsaE-yTjqpUbibGyBrajy3IiRb7LyaiqeYRxG4U",
285
+ kid: "k1"
286
+ })]);
267
287
  // src/store.ts
268
288
  const DB = "nanoflags";
269
289
  const OBJECT_STORE = "bundles";
@@ -496,7 +516,11 @@ function rawMember(text, member) {
496
516
  * the edge pushes the signed bundle itself — no request follows), the SSE stream (`/v1/stream`,
497
517
  * a notification followed by a conditional fetch), and polling (`/v1/bundle`).
498
518
  */
499
- const SDK_VERSION = "0.3.0";
519
+ const SDK_VERSION = "0.4.0";
520
+ /** Newest bundle format (`bundle.engine`) this SDK can evaluate. Newer bundles are refused; the previous one stays in use. */
521
+ const SUPPORTED_ENGINE_VERSION = 1;
522
+ /** WebSocket close code of the edge for an environment that was deleted: the SDK stops reconnecting. */
523
+ const CLOSE_ENVIRONMENT_DELETED = 4004;
500
524
  /** Header that carries a test-session token between services. */
501
525
  const SESSION_HEADER = "x-nanoflags-session";
502
526
  /** WebSocket subprotocol of `/v1/live`. The key travels as a second protocol entry, never in the URL. */
@@ -539,6 +563,9 @@ function isBundle(v) {
539
563
  const b = v;
540
564
  return b.schema === 1 && typeof b.revision === "number" && typeof b.flags === "object" && b.flags !== null && !Array.isArray(b.flags) && typeof b.segments === "object" && b.segments !== null;
541
565
  }
566
+ function engineError(b) {
567
+ return new NanoFlagsError(`Bundle r${String(b.revision)} needs evaluation engine ${JSON.stringify(b.engine)}; this SDK supports up to 1. Upgrade @nanoflags/sdk. The configuration in use is unchanged.`, "engine");
568
+ }
542
569
  function typeOf(v) {
543
570
  return v === null ? "null" : Array.isArray(v) ? "array" : typeof v;
544
571
  }
@@ -583,15 +610,38 @@ function createClient(options) {
583
610
  let liveOpen = false;
584
611
  let liveAttempt = 0;
585
612
  let liveTimer = null;
613
+ /** The edge closed the socket with 4004: the environment was deleted. No more reconnects. */
614
+ let environmentDeleted = false;
586
615
  /** Highest revision adopted from a push: a slower HTTP answer must never replace it with something older. */
587
616
  let liveRevision = 0;
588
617
  let pushChain = Promise.resolve();
589
618
  let lastReportAt = 0;
590
619
  const usedAttrs = /* @__PURE__ */ new WeakMap();
591
- const verifyOpt = options.verify;
620
+ /**
621
+ * Effective verification: `keys` to verify with (`"fetch"` = only `/v1/keys`), `require` = refuse unsigned bundles,
622
+ * `refetch` = an unknown key id loads `/v1/keys` once (official edge with the embedded keys, or `"fetch"`).
623
+ */
624
+ const official = baseUrl === OFFICIAL_EDGE_URL;
625
+ const verifyOpt = (() => {
626
+ const v = options.verify;
627
+ if (v === false) return null;
628
+ if (v === void 0 || v === null) return official ? {
629
+ keys: OFFICIAL_SIGNING_KEYS,
630
+ require: true,
631
+ refetch: true
632
+ } : null;
633
+ const keys = v.publicKeys === "fetch" ? "fetch" : Array.isArray(v.publicKeys) ? v.publicKeys : official ? OFFICIAL_SIGNING_KEYS : "fetch";
634
+ return {
635
+ keys,
636
+ require: v.require ?? official,
637
+ refetch: keys === "fetch" || official && v.publicKeys === void 0
638
+ };
639
+ })();
592
640
  const isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
593
- let keysPromise = null;
594
- let keysLoadedAt = 0;
641
+ let pinnedKeys = null;
642
+ let fetchedKeys = [];
643
+ let keysFetchedAt = 0;
644
+ let keysFetching = null;
595
645
  let pinnedEnvironment = null;
596
646
  let sig = null;
597
647
  let rawText = null;
@@ -667,6 +717,7 @@ function createClient(options) {
667
717
  if (!raw || closed) return;
668
718
  const p = JSON.parse(raw);
669
719
  if (p.v !== 1 || p.id !== id || !isBundle(p.bundle)) return;
720
+ if (!supportsEngine(p.bundle)) return void emitError(engineError(p.bundle));
670
721
  if (bundle && bundle.revision >= p.bundle.revision) return;
671
722
  let restored = p.bundle;
672
723
  let restoredSig = null;
@@ -752,20 +803,38 @@ function createClient(options) {
752
803
  clearTimeout(t);
753
804
  }
754
805
  }
755
- function loadKeys(kid) {
756
- if (!verifyOpt) return Promise.resolve([]);
757
- const start = () => {
758
- keysLoadedAt = Date.now();
759
- const p = verifyOpt.publicKeys === "fetch" ? fetchKeys() : importPublicKeys(Array.isArray(verifyOpt.publicKeys) ? verifyOpt.publicKeys : []);
760
- keysPromise = p;
806
+ /**
807
+ * Keys to verify a signature by `kid` with: the pinned / embedded keys, plus those fetched from `/v1/keys`. With
808
+ * `refetch`, a key id none of them has (rotation) loads `/v1/keys` once — again at most once a minute.
809
+ */
810
+ async function loadKeys(kid) {
811
+ if (!verifyOpt) return [];
812
+ let base = [];
813
+ if (verifyOpt.keys !== "fetch") {
814
+ const p = pinnedKeys ??= importPublicKeys(verifyOpt.keys);
761
815
  p.catch(() => {
762
- if (keysPromise === p) keysPromise = null;
816
+ if (pinnedKeys === p) pinnedKeys = null;
763
817
  });
764
- return p;
765
- };
766
- if (!keysPromise) return start();
767
- if (verifyOpt.publicKeys !== "fetch" || kid === null) return keysPromise;
768
- return keysPromise.then((keys) => keys.some((k) => k.kid === kid) || Date.now() - keysLoadedAt < KEYS_REFETCH_MS ? keys : start());
818
+ base = await p;
819
+ }
820
+ const all = () => [...base, ...fetchedKeys];
821
+ if (!verifyOpt.refetch) return base;
822
+ const known = all();
823
+ if (known.length > 0 && (kid === null || known.some((k) => k.kid === kid))) return known;
824
+ if (keysFetchedAt !== 0 && Date.now() - keysFetchedAt < KEYS_REFETCH_MS) return known;
825
+ keysFetching ??= fetchKeys().then((keys) => {
826
+ fetchedKeys = keys;
827
+ keysFetchedAt = Date.now();
828
+ }).finally(() => {
829
+ keysFetching = null;
830
+ });
831
+ try {
832
+ await keysFetching;
833
+ } catch (e) {
834
+ if (base.length === 0) throw e;
835
+ keysFetchedAt = Date.now();
836
+ }
837
+ return all();
769
838
  }
770
839
  /** Returns the bundle to adopt, or throws a NanoFlagsError with code "signature". */
771
840
  async function checkSignature(res, text, body) {
@@ -776,7 +845,7 @@ function createClient(options) {
776
845
  };
777
846
  const reject = (why) => new NanoFlagsError(`Bundle r${body.bundle.revision} was rejected: ${why} The configuration in use is unchanged.`, "signature");
778
847
  if (res.headers.get("X-NanoFlags-Signed") !== "true") {
779
- if (verifyOpt.require) throw reject("it is not signed and `verify.require` is set.");
848
+ if (verifyOpt.require) throw reject("it is not signed (the edge is still distributing it) and unsigned bundles are refused (`verify.require`); fetching again shortly.");
780
849
  return {
781
850
  bundle: body.bundle,
782
851
  sig: null,
@@ -860,6 +929,7 @@ function createClient(options) {
860
929
  const verified = await checkSignature(res, text, body);
861
930
  if (closed) return;
862
931
  const next = verified.bundle;
932
+ if (!supportsEngine(next)) throw engineError(next);
863
933
  failures = 0;
864
934
  if (servedStale && bundle && bundle.revision >= next.revision) {
865
935
  setStatus("stale");
@@ -878,6 +948,7 @@ function createClient(options) {
878
948
  persist();
879
949
  } catch (e) {
880
950
  if (closed) return;
951
+ if (e instanceof NanoFlagsError && e.code === "engine") return void emitError(e);
881
952
  failures++;
882
953
  if (e instanceof NanoFlagsError && e.code === "signature") return void emitError(e);
883
954
  if (e instanceof NanoFlagsError && e.code === "unauthorized") dropLive();
@@ -924,7 +995,7 @@ function createClient(options) {
924
995
  } catch {}
925
996
  }
926
997
  function startStream() {
927
- if (closed || options.streaming === false || !doFetch || streamActive) return;
998
+ if (closed || options.streaming === false || !doFetch || streamActive || environmentDeleted) return;
928
999
  streamActive = true;
929
1000
  const ES = typeof EventSource === "function" ? EventSource : void 0;
930
1001
  if (ES && isBrowserKey) {
@@ -1023,6 +1094,7 @@ function createClient(options) {
1023
1094
  parsed = null;
1024
1095
  }
1025
1096
  if (!isBundle(parsed) || parsed.revision !== revision) return void emitError(new NanoFlagsError("The edge pushed an invalid bundle; it was ignored.", "invalid_bundle"));
1097
+ if (!supportsEngine(parsed)) return void emitError(engineError(parsed));
1026
1098
  if (bundle && revision < bundle.revision) return;
1027
1099
  failures = 0;
1028
1100
  if (bundle && revision === bundle.revision) {
@@ -1051,7 +1123,7 @@ function createClient(options) {
1051
1123
  } catch {}
1052
1124
  }
1053
1125
  function connectLive() {
1054
- if (closed || !liveWanted || socket) return;
1126
+ if (closed || !liveWanted || socket || environmentDeleted) return;
1055
1127
  let ws;
1056
1128
  try {
1057
1129
  ws = new WS(liveUrl, [LIVE_PROTOCOL, `key.${sdkKey}`]);
@@ -1121,11 +1193,21 @@ function createClient(options) {
1121
1193
  if (typeof m !== "object" || m === null || Array.isArray(m)) return;
1122
1194
  pushChain = pushChain.then(() => applyPush(m)).catch(() => void 0);
1123
1195
  };
1124
- ws.onclose = end;
1196
+ ws.onclose = (event) => {
1197
+ if (event?.code === 4004) environmentDeleted = true;
1198
+ end();
1199
+ };
1125
1200
  ws.onerror = end;
1126
1201
  }
1127
1202
  function liveEnded(opened, openMs, rotating) {
1128
1203
  if (closed) return;
1204
+ if (environmentDeleted) {
1205
+ stopStream();
1206
+ if (bundle) setStatus("stale");
1207
+ emitError(new NanoFlagsError("The environment of this SDK key was deleted. The last configuration stays in use; it will not be updated.", "environment_deleted"));
1208
+ schedulePoll();
1209
+ return;
1210
+ }
1129
1211
  if (rotating) return connectLive();
1130
1212
  liveAttempt = opened && openMs > 3e4 ? 1 : liveAttempt + 1;
1131
1213
  if (!opened) startStream();
@@ -1439,7 +1521,10 @@ function createClient(options) {
1439
1521
  }
1440
1522
  };
1441
1523
  if (options.bootstrap !== void 0) {
1442
- if (isBundle(options.bootstrap)) adopt(options.bootstrap, {
1524
+ if (isBundle(options.bootstrap) && !supportsEngine(options.bootstrap)) {
1525
+ const err = engineError(options.bootstrap);
1526
+ queueMicrotask(() => emitError(err));
1527
+ } else if (isBundle(options.bootstrap)) adopt(options.bootstrap, {
1443
1528
  etag: null,
1444
1529
  checksum: null,
1445
1530
  status: "ready",
@@ -1447,7 +1532,7 @@ function createClient(options) {
1447
1532
  });
1448
1533
  else queueMicrotask(() => emitError(new NanoFlagsError("The bootstrap bundle is invalid and was ignored.", "invalid_bundle")));
1449
1534
  }
1450
- if (verifyOpt && Array.isArray(verifyOpt.publicKeys) && verifyOpt.publicKeys.some((k) => !isPublicEd25519Jwk(k))) queueMicrotask(() => emitError(new NanoFlagsError("`verify.publicKeys` must contain public Ed25519 JWKs only. Other entries (including private keys) were ignored.", "config")));
1535
+ if (verifyOpt && Array.isArray(verifyOpt.keys) && verifyOpt.keys.some((k) => !isPublicEd25519Jwk(k))) queueMicrotask(() => emitError(new NanoFlagsError("`verify.publicKeys` must contain public Ed25519 JWKs only. Other entries (including private keys) were ignored.", "config")));
1451
1536
  if (typeof sdkKey !== "string" || sdkKey === "" || baseUrl === "") {
1452
1537
  queueMicrotask(() => emitError(new NanoFlagsError("`sdkKey` and `baseUrl` are required.", "config")));
1453
1538
  resolveReady();
@@ -1468,39 +1553,4 @@ function createClient(options) {
1468
1553
  if (doc && typeof addEventListener === "function") addEventListener("online", onOnline);
1469
1554
  return client;
1470
1555
  }
1471
- Object.defineProperty(exports, "LIVE_PROTOCOL", {
1472
- enumerable: true,
1473
- get: function() {
1474
- return LIVE_PROTOCOL;
1475
- }
1476
- });
1477
- Object.defineProperty(exports, "NanoFlagsError", {
1478
- enumerable: true,
1479
- get: function() {
1480
- return NanoFlagsError;
1481
- }
1482
- });
1483
- Object.defineProperty(exports, "SDK_VERSION", {
1484
- enumerable: true,
1485
- get: function() {
1486
- return SDK_VERSION;
1487
- }
1488
- });
1489
- Object.defineProperty(exports, "SESSION_HEADER", {
1490
- enumerable: true,
1491
- get: function() {
1492
- return SESSION_HEADER;
1493
- }
1494
- });
1495
- Object.defineProperty(exports, "createClient", {
1496
- enumerable: true,
1497
- get: function() {
1498
- return createClient;
1499
- }
1500
- });
1501
- Object.defineProperty(exports, "isSessionToken", {
1502
- enumerable: true,
1503
- get: function() {
1504
- return isSessionToken;
1505
- }
1506
- });
1556
+ export { SESSION_HEADER as a, isSessionToken as c, SDK_VERSION as i, OFFICIAL_EDGE_URL as l, LIVE_PROTOCOL as n, SUPPORTED_ENGINE_VERSION as o, NanoFlagsError as r, createClient as s, CLOSE_ENVIRONMENT_DELETED as t, OFFICIAL_SIGNING_KEYS as u };
@@ -1,4 +1,3 @@
1
- // ../engine/src/index.ts
2
1
  const encoder$1 = new TextEncoder();
3
2
  /** MurmurHash3 x86 32-bit over UTF-8 bytes. Used for distribution only — never for security. */
4
3
  function murmur3(input, seed = 0) {
@@ -264,6 +263,27 @@ function evaluate(bundle, flagKey, ctx, fallback) {
264
263
  function fmt(v) {
265
264
  return typeof v === "string" ? v : JSON.stringify(v);
266
265
  }
266
+ /** True when this engine can evaluate `bundle` (its `engine` is absent or not newer than `ENGINE_VERSION`). */
267
+ function supportsEngine(bundle) {
268
+ const e = bundle.engine;
269
+ return e === void 0 || typeof e === "number" && Number.isInteger(e) && e >= 1 && e <= 1;
270
+ }
271
+ // src/keys.ts
272
+ /**
273
+ * The hosted NanoFlags edge and the public halves of its bundle-signing keys (Ed25519, safe to embed).
274
+ *
275
+ * With `baseUrl` equal to `OFFICIAL_EDGE_URL` the SDK verifies every bundle against these keys by default
276
+ * (`verify.require` defaults to true there). A bundle signed with a key id that is not listed here — a rotation that
277
+ * happened after this SDK version was built — makes the SDK load `GET /v1/keys` from the edge once and verify against
278
+ * the keys it lists as well. Rotation procedure: RUNTIME.md, "Signing-key rotation".
279
+ */
280
+ const OFFICIAL_EDGE_URL = "https://edge.nanoflags.io";
281
+ const OFFICIAL_SIGNING_KEYS = Object.freeze([Object.freeze({
282
+ kty: "OKP",
283
+ crv: "Ed25519",
284
+ x: "wQu1EsaE-yTjqpUbibGyBrajy3IiRb7LyaiqeYRxG4U",
285
+ kid: "k1"
286
+ })]);
267
287
  // src/store.ts
268
288
  const DB = "nanoflags";
269
289
  const OBJECT_STORE = "bundles";
@@ -496,7 +516,11 @@ function rawMember(text, member) {
496
516
  * the edge pushes the signed bundle itself — no request follows), the SSE stream (`/v1/stream`,
497
517
  * a notification followed by a conditional fetch), and polling (`/v1/bundle`).
498
518
  */
499
- const SDK_VERSION = "0.3.0";
519
+ const SDK_VERSION = "0.4.0";
520
+ /** Newest bundle format (`bundle.engine`) this SDK can evaluate. Newer bundles are refused; the previous one stays in use. */
521
+ const SUPPORTED_ENGINE_VERSION = 1;
522
+ /** WebSocket close code of the edge for an environment that was deleted: the SDK stops reconnecting. */
523
+ const CLOSE_ENVIRONMENT_DELETED = 4004;
500
524
  /** Header that carries a test-session token between services. */
501
525
  const SESSION_HEADER = "x-nanoflags-session";
502
526
  /** WebSocket subprotocol of `/v1/live`. The key travels as a second protocol entry, never in the URL. */
@@ -539,6 +563,9 @@ function isBundle(v) {
539
563
  const b = v;
540
564
  return b.schema === 1 && typeof b.revision === "number" && typeof b.flags === "object" && b.flags !== null && !Array.isArray(b.flags) && typeof b.segments === "object" && b.segments !== null;
541
565
  }
566
+ function engineError(b) {
567
+ return new NanoFlagsError(`Bundle r${String(b.revision)} needs evaluation engine ${JSON.stringify(b.engine)}; this SDK supports up to 1. Upgrade @nanoflags/sdk. The configuration in use is unchanged.`, "engine");
568
+ }
542
569
  function typeOf(v) {
543
570
  return v === null ? "null" : Array.isArray(v) ? "array" : typeof v;
544
571
  }
@@ -583,15 +610,38 @@ function createClient(options) {
583
610
  let liveOpen = false;
584
611
  let liveAttempt = 0;
585
612
  let liveTimer = null;
613
+ /** The edge closed the socket with 4004: the environment was deleted. No more reconnects. */
614
+ let environmentDeleted = false;
586
615
  /** Highest revision adopted from a push: a slower HTTP answer must never replace it with something older. */
587
616
  let liveRevision = 0;
588
617
  let pushChain = Promise.resolve();
589
618
  let lastReportAt = 0;
590
619
  const usedAttrs = /* @__PURE__ */ new WeakMap();
591
- const verifyOpt = options.verify;
620
+ /**
621
+ * Effective verification: `keys` to verify with (`"fetch"` = only `/v1/keys`), `require` = refuse unsigned bundles,
622
+ * `refetch` = an unknown key id loads `/v1/keys` once (official edge with the embedded keys, or `"fetch"`).
623
+ */
624
+ const official = baseUrl === OFFICIAL_EDGE_URL;
625
+ const verifyOpt = (() => {
626
+ const v = options.verify;
627
+ if (v === false) return null;
628
+ if (v === void 0 || v === null) return official ? {
629
+ keys: OFFICIAL_SIGNING_KEYS,
630
+ require: true,
631
+ refetch: true
632
+ } : null;
633
+ const keys = v.publicKeys === "fetch" ? "fetch" : Array.isArray(v.publicKeys) ? v.publicKeys : official ? OFFICIAL_SIGNING_KEYS : "fetch";
634
+ return {
635
+ keys,
636
+ require: v.require ?? official,
637
+ refetch: keys === "fetch" || official && v.publicKeys === void 0
638
+ };
639
+ })();
592
640
  const isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
593
- let keysPromise = null;
594
- let keysLoadedAt = 0;
641
+ let pinnedKeys = null;
642
+ let fetchedKeys = [];
643
+ let keysFetchedAt = 0;
644
+ let keysFetching = null;
595
645
  let pinnedEnvironment = null;
596
646
  let sig = null;
597
647
  let rawText = null;
@@ -667,6 +717,7 @@ function createClient(options) {
667
717
  if (!raw || closed) return;
668
718
  const p = JSON.parse(raw);
669
719
  if (p.v !== 1 || p.id !== id || !isBundle(p.bundle)) return;
720
+ if (!supportsEngine(p.bundle)) return void emitError(engineError(p.bundle));
670
721
  if (bundle && bundle.revision >= p.bundle.revision) return;
671
722
  let restored = p.bundle;
672
723
  let restoredSig = null;
@@ -752,20 +803,38 @@ function createClient(options) {
752
803
  clearTimeout(t);
753
804
  }
754
805
  }
755
- function loadKeys(kid) {
756
- if (!verifyOpt) return Promise.resolve([]);
757
- const start = () => {
758
- keysLoadedAt = Date.now();
759
- const p = verifyOpt.publicKeys === "fetch" ? fetchKeys() : importPublicKeys(Array.isArray(verifyOpt.publicKeys) ? verifyOpt.publicKeys : []);
760
- keysPromise = p;
806
+ /**
807
+ * Keys to verify a signature by `kid` with: the pinned / embedded keys, plus those fetched from `/v1/keys`. With
808
+ * `refetch`, a key id none of them has (rotation) loads `/v1/keys` once — again at most once a minute.
809
+ */
810
+ async function loadKeys(kid) {
811
+ if (!verifyOpt) return [];
812
+ let base = [];
813
+ if (verifyOpt.keys !== "fetch") {
814
+ const p = pinnedKeys ??= importPublicKeys(verifyOpt.keys);
761
815
  p.catch(() => {
762
- if (keysPromise === p) keysPromise = null;
816
+ if (pinnedKeys === p) pinnedKeys = null;
763
817
  });
764
- return p;
765
- };
766
- if (!keysPromise) return start();
767
- if (verifyOpt.publicKeys !== "fetch" || kid === null) return keysPromise;
768
- return keysPromise.then((keys) => keys.some((k) => k.kid === kid) || Date.now() - keysLoadedAt < KEYS_REFETCH_MS ? keys : start());
818
+ base = await p;
819
+ }
820
+ const all = () => [...base, ...fetchedKeys];
821
+ if (!verifyOpt.refetch) return base;
822
+ const known = all();
823
+ if (known.length > 0 && (kid === null || known.some((k) => k.kid === kid))) return known;
824
+ if (keysFetchedAt !== 0 && Date.now() - keysFetchedAt < KEYS_REFETCH_MS) return known;
825
+ keysFetching ??= fetchKeys().then((keys) => {
826
+ fetchedKeys = keys;
827
+ keysFetchedAt = Date.now();
828
+ }).finally(() => {
829
+ keysFetching = null;
830
+ });
831
+ try {
832
+ await keysFetching;
833
+ } catch (e) {
834
+ if (base.length === 0) throw e;
835
+ keysFetchedAt = Date.now();
836
+ }
837
+ return all();
769
838
  }
770
839
  /** Returns the bundle to adopt, or throws a NanoFlagsError with code "signature". */
771
840
  async function checkSignature(res, text, body) {
@@ -776,7 +845,7 @@ function createClient(options) {
776
845
  };
777
846
  const reject = (why) => new NanoFlagsError(`Bundle r${body.bundle.revision} was rejected: ${why} The configuration in use is unchanged.`, "signature");
778
847
  if (res.headers.get("X-NanoFlags-Signed") !== "true") {
779
- if (verifyOpt.require) throw reject("it is not signed and `verify.require` is set.");
848
+ if (verifyOpt.require) throw reject("it is not signed (the edge is still distributing it) and unsigned bundles are refused (`verify.require`); fetching again shortly.");
780
849
  return {
781
850
  bundle: body.bundle,
782
851
  sig: null,
@@ -860,6 +929,7 @@ function createClient(options) {
860
929
  const verified = await checkSignature(res, text, body);
861
930
  if (closed) return;
862
931
  const next = verified.bundle;
932
+ if (!supportsEngine(next)) throw engineError(next);
863
933
  failures = 0;
864
934
  if (servedStale && bundle && bundle.revision >= next.revision) {
865
935
  setStatus("stale");
@@ -878,6 +948,7 @@ function createClient(options) {
878
948
  persist();
879
949
  } catch (e) {
880
950
  if (closed) return;
951
+ if (e instanceof NanoFlagsError && e.code === "engine") return void emitError(e);
881
952
  failures++;
882
953
  if (e instanceof NanoFlagsError && e.code === "signature") return void emitError(e);
883
954
  if (e instanceof NanoFlagsError && e.code === "unauthorized") dropLive();
@@ -924,7 +995,7 @@ function createClient(options) {
924
995
  } catch {}
925
996
  }
926
997
  function startStream() {
927
- if (closed || options.streaming === false || !doFetch || streamActive) return;
998
+ if (closed || options.streaming === false || !doFetch || streamActive || environmentDeleted) return;
928
999
  streamActive = true;
929
1000
  const ES = typeof EventSource === "function" ? EventSource : void 0;
930
1001
  if (ES && isBrowserKey) {
@@ -1023,6 +1094,7 @@ function createClient(options) {
1023
1094
  parsed = null;
1024
1095
  }
1025
1096
  if (!isBundle(parsed) || parsed.revision !== revision) return void emitError(new NanoFlagsError("The edge pushed an invalid bundle; it was ignored.", "invalid_bundle"));
1097
+ if (!supportsEngine(parsed)) return void emitError(engineError(parsed));
1026
1098
  if (bundle && revision < bundle.revision) return;
1027
1099
  failures = 0;
1028
1100
  if (bundle && revision === bundle.revision) {
@@ -1051,7 +1123,7 @@ function createClient(options) {
1051
1123
  } catch {}
1052
1124
  }
1053
1125
  function connectLive() {
1054
- if (closed || !liveWanted || socket) return;
1126
+ if (closed || !liveWanted || socket || environmentDeleted) return;
1055
1127
  let ws;
1056
1128
  try {
1057
1129
  ws = new WS(liveUrl, [LIVE_PROTOCOL, `key.${sdkKey}`]);
@@ -1121,11 +1193,21 @@ function createClient(options) {
1121
1193
  if (typeof m !== "object" || m === null || Array.isArray(m)) return;
1122
1194
  pushChain = pushChain.then(() => applyPush(m)).catch(() => void 0);
1123
1195
  };
1124
- ws.onclose = end;
1196
+ ws.onclose = (event) => {
1197
+ if (event?.code === 4004) environmentDeleted = true;
1198
+ end();
1199
+ };
1125
1200
  ws.onerror = end;
1126
1201
  }
1127
1202
  function liveEnded(opened, openMs, rotating) {
1128
1203
  if (closed) return;
1204
+ if (environmentDeleted) {
1205
+ stopStream();
1206
+ if (bundle) setStatus("stale");
1207
+ emitError(new NanoFlagsError("The environment of this SDK key was deleted. The last configuration stays in use; it will not be updated.", "environment_deleted"));
1208
+ schedulePoll();
1209
+ return;
1210
+ }
1129
1211
  if (rotating) return connectLive();
1130
1212
  liveAttempt = opened && openMs > 3e4 ? 1 : liveAttempt + 1;
1131
1213
  if (!opened) startStream();
@@ -1439,7 +1521,10 @@ function createClient(options) {
1439
1521
  }
1440
1522
  };
1441
1523
  if (options.bootstrap !== void 0) {
1442
- if (isBundle(options.bootstrap)) adopt(options.bootstrap, {
1524
+ if (isBundle(options.bootstrap) && !supportsEngine(options.bootstrap)) {
1525
+ const err = engineError(options.bootstrap);
1526
+ queueMicrotask(() => emitError(err));
1527
+ } else if (isBundle(options.bootstrap)) adopt(options.bootstrap, {
1443
1528
  etag: null,
1444
1529
  checksum: null,
1445
1530
  status: "ready",
@@ -1447,7 +1532,7 @@ function createClient(options) {
1447
1532
  });
1448
1533
  else queueMicrotask(() => emitError(new NanoFlagsError("The bootstrap bundle is invalid and was ignored.", "invalid_bundle")));
1449
1534
  }
1450
- if (verifyOpt && Array.isArray(verifyOpt.publicKeys) && verifyOpt.publicKeys.some((k) => !isPublicEd25519Jwk(k))) queueMicrotask(() => emitError(new NanoFlagsError("`verify.publicKeys` must contain public Ed25519 JWKs only. Other entries (including private keys) were ignored.", "config")));
1535
+ if (verifyOpt && Array.isArray(verifyOpt.keys) && verifyOpt.keys.some((k) => !isPublicEd25519Jwk(k))) queueMicrotask(() => emitError(new NanoFlagsError("`verify.publicKeys` must contain public Ed25519 JWKs only. Other entries (including private keys) were ignored.", "config")));
1451
1536
  if (typeof sdkKey !== "string" || sdkKey === "" || baseUrl === "") {
1452
1537
  queueMicrotask(() => emitError(new NanoFlagsError("`sdkKey` and `baseUrl` are required.", "config")));
1453
1538
  resolveReady();
@@ -1468,4 +1553,63 @@ function createClient(options) {
1468
1553
  if (doc && typeof addEventListener === "function") addEventListener("online", onOnline);
1469
1554
  return client;
1470
1555
  }
1471
- export { createClient as a, SESSION_HEADER as i, NanoFlagsError as n, isSessionToken as o, SDK_VERSION as r, LIVE_PROTOCOL as t };
1556
+ Object.defineProperty(exports, "CLOSE_ENVIRONMENT_DELETED", {
1557
+ enumerable: true,
1558
+ get: function() {
1559
+ return CLOSE_ENVIRONMENT_DELETED;
1560
+ }
1561
+ });
1562
+ Object.defineProperty(exports, "LIVE_PROTOCOL", {
1563
+ enumerable: true,
1564
+ get: function() {
1565
+ return LIVE_PROTOCOL;
1566
+ }
1567
+ });
1568
+ Object.defineProperty(exports, "NanoFlagsError", {
1569
+ enumerable: true,
1570
+ get: function() {
1571
+ return NanoFlagsError;
1572
+ }
1573
+ });
1574
+ Object.defineProperty(exports, "OFFICIAL_EDGE_URL", {
1575
+ enumerable: true,
1576
+ get: function() {
1577
+ return OFFICIAL_EDGE_URL;
1578
+ }
1579
+ });
1580
+ Object.defineProperty(exports, "OFFICIAL_SIGNING_KEYS", {
1581
+ enumerable: true,
1582
+ get: function() {
1583
+ return OFFICIAL_SIGNING_KEYS;
1584
+ }
1585
+ });
1586
+ Object.defineProperty(exports, "SDK_VERSION", {
1587
+ enumerable: true,
1588
+ get: function() {
1589
+ return SDK_VERSION;
1590
+ }
1591
+ });
1592
+ Object.defineProperty(exports, "SESSION_HEADER", {
1593
+ enumerable: true,
1594
+ get: function() {
1595
+ return SESSION_HEADER;
1596
+ }
1597
+ });
1598
+ Object.defineProperty(exports, "SUPPORTED_ENGINE_VERSION", {
1599
+ enumerable: true,
1600
+ get: function() {
1601
+ return SUPPORTED_ENGINE_VERSION;
1602
+ }
1603
+ });
1604
+ Object.defineProperty(exports, "createClient", {
1605
+ enumerable: true,
1606
+ get: function() {
1607
+ return createClient;
1608
+ }
1609
+ });
1610
+ Object.defineProperty(exports, "isSessionToken", {
1611
+ enumerable: true,
1612
+ get: function() {
1613
+ return isSessionToken;
1614
+ }
1615
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanoflags/sdk",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "NanoFlags JavaScript/TypeScript SDK: local, synchronous flag evaluation with background updates (Node, browsers, edge runtimes, React).",
5
5
  "keywords": [
6
6
  "feature-flags",