@davidlee49/launchgate 0.0.0-stage → 0.5.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
@@ -1,3 +1,407 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # launchgate
2
+
3
+ Hide work in progress from the public while you keep seeing it. Kill a shipped
4
+ feature without a redeploy. No platform, no service, no database required.
5
+
6
+ Design of record: [adr/0001-launch-gate-design.md](adr/0001-launch-gate-design.md).
7
+
8
+ ```ts
9
+ import { defineFlags, createResolver, envOverride, cookieOverride, envDefault } from "@davidlee49/launchgate";
10
+
11
+ export const flags = defineFlags({
12
+ newPricingPage: {
13
+ fallback: false,
14
+ description: "Rebuilt pricing page — hidden until launch",
15
+ overridable: true,
16
+ byEnv: { development: true },
17
+ },
18
+ });
19
+
20
+ export const gate = createResolver({
21
+ flags,
22
+ sources: [
23
+ envOverride(), // 1. kill switch
24
+ cookieOverride({ secret: process.env.FLAG_SECRET! }), // 2. you, on the live site
25
+ envDefault({ env: process.env.NODE_ENV! }), // 4. per-environment default
26
+ ],
27
+ onError: (error, { flag, source }) => console.error("launchgate", flag, source, error),
28
+ });
29
+
30
+ if (await gate.resolve("newPricingPage", { cookie })) { /* … */ }
31
+ ```
32
+
33
+ ## How resolution works
34
+
35
+ An **ordered list of sources**; each answers with a value or `undefined`
36
+ (*undecided — ask the next one*); the first definite answer wins. The flag's
37
+ `fallback` is terminal, so it can't be forgotten.
38
+
39
+ | # | Source | Purpose |
40
+ | --- | --- | --- |
41
+ | 0 | `retired` | Not a source. A retired flag answers with its recorded disposition and the chain never starts. |
42
+ | 1 | `envOverride()` | `FLAG_NEW_PRICING_PAGE=off`. The kill switch. |
43
+ | 2 | `cookieOverride({ secret })` | Signed per-visitor token — how you see hidden work in production. |
44
+ | 3 | *yours* | Allowlist, grant, opt-in, entitlement. Usually a database read. |
45
+ | 4 | `envDefault({ env })` | The registry's `byEnv` value. Preview deployments are just a different `env`. |
46
+ | — | `fallback` | Terminal, always present. |
47
+
48
+ `false` is a decision. `undefined` is not. That three-valued fall-through is the
49
+ whole mechanic — and there is no traversal: no flag ever consults another flag.
50
+
51
+ ### Two invariants
52
+
53
+ - **`envOverride` sits above `cookieOverride`.** A flag forced on by a cookie
54
+ must not survive the attempt to kill it.
55
+ - **`cookieOverride` honours only `overridable: true` flags.** A visitor must
56
+ never force a flag with real consequences.
57
+
58
+ ### Failure is per flag, not global
59
+
60
+ Every source call is wrapped. A source that throws **aborts to that flag's
61
+ `fallback`** and reports through `onError` — it does not fall through, so there
62
+ is exactly one degraded answer per flag and it's declared next to the flag.
63
+
64
+ That value is per flag because polarity differs: a WIP gate's safe state is
65
+ `false`, but a kill switch on a year-old feature's safe state is `true` — failing
66
+ *that* one closed would take down working functionality because a database
67
+ blipped.
68
+
69
+ ## Retiring a flag
70
+
71
+ A release toggle is temporary by definition, and so is the feature behind it
72
+ eventually. `retired` is the terminal state of both:
73
+
74
+ ```ts
75
+ collections: {
76
+ fallback: true,
77
+ description: "Collections — retired 2026-08-29, code awaiting deletion",
78
+ retired: { since: "2026-08-29", value: false },
79
+ },
80
+ ```
81
+
82
+ It resolves to `value` and **consults no source at all** — not `envOverride`,
83
+ which outranks everything else. That is the point rather than an optimisation: a
84
+ retirement is a decision someone committed to git, so the only way to reverse it
85
+ is to revert the commit. A forgotten `FLAG_COLLECTIONS=on` secret or an override
86
+ cookie an engineer is still holding must not be able to reopen it, or the
87
+ registry stops describing what the system actually does.
88
+
89
+ Note `value` is *not* `fallback`. `fallback` is where a **failure** lands; a
90
+ retirement isn't a failure, and a feature whose safe-on-failure state is `true`
91
+ still retires to `false`. Declaring it separately is also the disposition record
92
+ Unleash makes you supply when completing a flag — the cleanup PR needs to know
93
+ which branch of the code survives, and `value: "v1"` on a dynamic-config flag
94
+ says exactly that.
95
+
96
+ **Keep the entry.** Retirement archives, it doesn't delete — Unleash's rule, for
97
+ Unleash's reason: while the key is in the registry it cannot be reused by a new
98
+ feature (a duplicate key is a type error), so a stale override can never attach
99
+ to something it was never about. Delete the entry *last*, once the components,
100
+ the routes, and the API handlers are gone.
101
+
102
+ ### The clock
103
+
104
+ ```ts
105
+ const { problems, warnings } = auditRegistry(flags, { today, retiredAfterDays: 40 });
106
+ ```
107
+
108
+ Retired code that nobody deletes is the same debt as a stale release flag, seen
109
+ from the other end, so it gets the same 40-day budget. `auditRegistry` audits
110
+ only what the library owns — the retirement shape and its age. Your own
111
+ lifecycle vocabulary is yours to check; merge the two reports and print them
112
+ together in one build step.
113
+
114
+ This is deliberately a *build* step. LaunchDarkly ships `find-code-refs` to
115
+ crawl a customer's repository, because a SaaS control plane can't see the code
116
+ and can only warn on a dashboard. A registry that lives in the repo doesn't need
117
+ the scanner: it can fail the build instead.
118
+
119
+ ## Flags aren't only booleans
120
+
121
+ ```ts
122
+ renderPipeline: {
123
+ fallback: "v1" as "v1" | "v2",
124
+ description: "Which render pipeline a submitted job uses",
125
+ }
126
+ ```
127
+
128
+ `FLAG_RENDER_PIPELINE=v2` and cookie overrides carry the value. Booleans are the
129
+ common case, not the only one.
130
+
131
+ > **Typing note.** `fallback: false` is widened back to `boolean` for you.
132
+ > Strings and numbers keep what you declare, which is why the `as "v1" | "v2"`
133
+ > above gives you a checked union.
134
+
135
+ ## Custom sources
136
+
137
+ A source is `{ name, decide(flag, ctx) }`. Return a value to decide, `undefined`
138
+ to pass:
139
+
140
+ ```ts
141
+ const orgGrant: Source = {
142
+ name: "orgGrant",
143
+ async decide({ key }, ctx) {
144
+ if (!ctx.targetingKey) return undefined;
145
+ const row = await db.grantFor(ctx.targetingKey, key);
146
+ return row?.enabled; // undefined when there's no row — undecided
147
+ },
148
+ };
149
+ ```
150
+
151
+ Conjunctions ("entitled **and** opted in") are a *requirement* source: return the
152
+ off-value, or `undefined` to stay out of the way. The chain stays first-wins.
153
+
154
+ ## Gating routes
155
+
156
+ Flags answer "is this on". `createRouteGate` answers the question a web app
157
+ actually asks — **"may this path be served"** — from two lists you supply:
158
+
159
+ ```ts
160
+ const gate = createRouteGate({
161
+ resolver: launch,
162
+ routes: { workflows: ["/workflows"], network: ["/network", "/api/network"] },
163
+ allow: ["/", "/assets", "/api/assets"], // the launched surface
164
+ unlisted: "deny",
165
+ });
166
+
167
+ await gate.isPermitted("/workflows", { cookie }); // middleware
168
+ await gate.revealedPrefixes({ cookie }); // to filter nav server-side
169
+ ```
170
+
171
+ The allowlist is checked **first**, so a launched route short-circuits and
172
+ resolves no flags at all.
173
+
174
+ `unlisted` has no default, because it is the whole safety posture and the two
175
+ answers fail very differently:
176
+
177
+ - `"deny"` — a route added tomorrow is hidden until someone lists it. It 404s
178
+ loudly, and safely. Right for an app with unlaunched surfaces in the tree.
179
+ - `"allow"` — a new route ships the moment it exists, and forgetting to flag
180
+ work in progress leaks it silently.
181
+
182
+ A flag that is **off** denies its paths under either posture; `unlisted` only
183
+ decides paths nothing has an opinion about.
184
+
185
+ **It throws at construction** if a prefix is both allowed and flagged. That flag
186
+ could never change anything — the allowlist would serve the path regardless — and
187
+ a dead launch gate is precisely the bug you cannot afford to discover late.
188
+
189
+ `matchesPrefix(pathname, prefixes)` is exported standalone for client-side nav
190
+ filtering, so the menu can't disagree with the router.
191
+
192
+ ## Storage-backed flags
193
+
194
+ For projects that want to flip a flag per tenant without a deploy. `subjectStore`
195
+ owns the semantics; you own the SQL — any database, any client:
196
+
197
+ ```ts
198
+ const store = subjectStore({
199
+ // One read for the whole registry. `load: async (subject, key) => …` is the
200
+ // per-flag alternative when a batch query is awkward.
201
+ loadAll: async (subject) => {
202
+ const rows = await db.overridesFor(subject); // (subject_id, flag_key, override)
203
+ return Object.fromEntries(rows.map((r) => [r.flag_key, { override: r.override, optedIn: r.opted_in }]));
204
+ },
205
+ requiresOptIn: (flag) => flag.def.selfServe === true,
206
+ });
207
+
208
+ createResolver({
209
+ flags,
210
+ sources: [
211
+ envOverride(),
212
+ store.kill, // 1. a 'disabled' row — outranks the cookie
213
+ cookieOverride({ secret }),
214
+ store.requireOptIn, // 3a. veto anything the tenant hasn't switched on
215
+ store.grant, // 3b. an 'enabled' row
216
+ envDefault({ env }),
217
+ ],
218
+ });
219
+ ```
220
+
221
+ Three sources, one storage read: they share a cache keyed on the `EvaluationContext`
222
+ object, which is per request, so a request pays one `load` per flag no matter how
223
+ many of the three are in the chain. Swap `load` for `loadAll` and the whole
224
+ registry costs one read instead of one per flag — see **Bootstrapping a client** below.
225
+
226
+ `'disabled'` and `'enabled'` are one row but two sources because they belong in
227
+ **different slots** — a per-tenant kill has to outrank a visitor cookie for the
228
+ same reason the env kill switch does, while a grant sits below it. `requireOptIn`
229
+ sits ahead of `grant` because it must be able to veto an entitlement decided by a
230
+ *later* source (a plan lookup, a stage rule) — a veto that runs after the
231
+ decision it's vetoing never fires.
232
+
233
+ Project-specific policy stays yours. Hang your own fields on a flag def (`stage`,
234
+ `tier`, `owner`) and read them in your own source — the registry keeps their real
235
+ types.
236
+
237
+ > There is no `launchgate/postgres` subpath. Once the interface is a `load`
238
+ > function, nothing Postgres-specific is left to ship.
239
+
240
+ ## Bootstrapping a client
241
+
242
+ `resolveAll(ctx)` evaluates the whole registry at once — LaunchDarkly's
243
+ `allFlagsState()` pattern. Resolve on the server, hand the map down, and the UI
244
+ never flickers because it never had to ask:
245
+
246
+ ```ts
247
+ const enabled = await gate.resolveAll(await requestContext({ targetingKey: orgId }));
248
+ // → { newPricingPage: true, network: false, renderPipeline: "v2" }
249
+ ```
250
+
251
+ Every flag shares the one context object, which is the cache key
252
+ `subjectStore` uses — so with `loadAll` the whole registry costs **one** read.
253
+
254
+ > Pass the **values**, never the registry. Flag keys name unshipped work, and a
255
+ > viewer who can't see a surface shouldn't learn its name from the payload.
256
+
257
+ ## Testing
258
+
259
+ ```ts
260
+ import { withFlags } from "@davidlee49/launchgate";
261
+
262
+ const gate = withFlags(flags, { newPricingPage: true });
263
+ ```
264
+
265
+ Answers from a fixed map, falls back for anything unset. Use it instead of
266
+ standing up real sources — flags multiply a test matrix, and that's only
267
+ tolerable if pinning a value is one line.
268
+
269
+ ## Minting an override token
270
+
271
+ ```ts
272
+ import { signOverrides } from "@davidlee49/launchgate";
273
+
274
+ const token = await signOverrides({ newPricingPage: true }, secret);
275
+ // set it as the `lg_override` cookie
276
+ ```
277
+
278
+ HMAC-SHA256 over Web Crypto, so the same build runs on Cloudflare Workers and
279
+ Node 18+. Tampered or foreign-signed tokens are silently *undecided* — hostile
280
+ input falls through the normal chain rather than pushing the flag anywhere.
281
+
282
+ ## What this deliberately isn't
283
+
284
+ - **Not experimentation.** No assignment, no exposure logging, no metrics. At the
285
+ scale this is built for, statistical inference is noise with a dashboard.
286
+ - **Not entitlements.** Those derive from a *contract*. Put billing truth in a
287
+ flag store and it has no transactional relationship to the subscription, so a
288
+ downgrade and a flag flip can disagree — and a stale-flag sweep deletes revenue
289
+ logic that looked like dead state. Supply an entitlement source instead.
290
+ - **Not a circuit breaker.** Failing over to a backup provider is a machine
291
+ decision made in seconds from observed health; as a flag, a human has to notice
292
+ the outage first. A flag may say whether the alternative path is *permitted to
293
+ exist* — it must never *choose* it.
294
+
295
+ > A flag records a decision a person made. A circuit breaker records a fact the
296
+ > system observed.
297
+
298
+ ## Next.js
299
+
300
+ ```sh
301
+ pnpm add @davidlee49/launchgate # `next` is an optional peer dependency
302
+ ```
303
+
304
+ ```ts
305
+ // app/flag-override/route.ts ← NOT app/__flags/: see the trap below
306
+ import { createOverrideRoute } from "@davidlee49/launchgate/next";
307
+ import { gate } from "@/flags";
308
+
309
+ export const { GET } = createOverrideRoute({
310
+ resolver: gate,
311
+ secret: process.env.FLAG_SECRET!,
312
+ accessToken: process.env.FLAG_ACCESS_TOKEN!, // presented as ?token=, never the signing secret
313
+ });
314
+ ```
315
+
316
+ **If your app has an auth gate, exclude this route from it.** Inside the matcher
317
+ it redirects to your login page and the cookie is never set — the mechanic
318
+ silently does nothing. Its `?token=` is its authorization, and working logged-out
319
+ is the point.
320
+
321
+ Then `GET /flag-override?token=…&flag=newHomepage&value=on` sets the cookie,
322
+ `&value=off` flips it, omitting `value` clears that flag, and `&clear=1` drops
323
+ all of them. A wrong token gets a 404, not a 401 — an unadvertised route should
324
+ stay unadvertised.
325
+
326
+ Read a flag in a page or handler:
327
+
328
+ ```ts
329
+ import { requestContext, requireFlag } from "@davidlee49/launchgate/next";
330
+
331
+ const on = await gate.resolve("newHomepage", await requestContext()); // page
332
+ const blocked = await requireFlag(gate, "network", { targetingKey: orgId }); // handler → 404
333
+ ```
334
+
335
+ ### Static rendering — measured, not guessed
336
+
337
+ `requestContext()` calls `cookies()`, and that **opts the page into dynamic
338
+ rendering**. Verified against Next 16.3.1 by reading `next build`'s route table:
339
+
340
+ | Page | How it reads the flag | Build classification |
341
+ | --- | --- | --- |
342
+ | `/hidden` | `requestContext()` in the page | **ƒ Dynamic** |
343
+ | `/home` → `/variants/v1`,`/v2` | proxy reads the cookie and rewrites | **○ Static** (all three) |
344
+
345
+ - **Already-dynamic page** (anything authenticated): just call
346
+ `requestContext()`. The dynamic cost is already paid.
347
+ - **Page that must stay static** (marketing, landing, docs): don't read the flag
348
+ in the page. Author the variants as separate routes and rewrite in `proxy.ts`:
349
+
350
+ ```ts
351
+ // proxy.ts (Next 16's rename of middleware.ts)
352
+ import { NextResponse, type NextRequest } from "next/server";
353
+ import { readOverrides } from "@davidlee49/launchgate";
354
+
355
+ export async function proxy(request: NextRequest) {
356
+ const token = request.cookies.get("lg_override")?.value;
357
+ const overrides = token ? await readOverrides(token, process.env.FLAG_SECRET!) : undefined;
358
+ const variant = overrides?.newHomepage === true ? "v2" : "v1";
359
+ return NextResponse.rewrite(new URL(`/variants/${variant}`, request.url));
360
+ }
361
+
362
+ export const config = { matcher: "/home" };
363
+ ```
364
+
365
+ There is no third option. Resolving in the proxy and forwarding a request header
366
+ doesn't help: the page then calls `headers()`, which opts it into dynamic
367
+ rendering just the same. Forwarding moves the read, not the cost.
368
+
369
+ > **Trap.** A route handler under a `_`-prefixed directory — `app/__flags/route.ts`
370
+ > — **never registers**. `_` marks a private folder in the App Router, excluded
371
+ > from routing silently, with no build error and no 404 in the route table. Mount
372
+ > the override route at a normal path.
373
+
374
+ ## Async work
375
+
376
+ Resolve at submission and **stamp the value into the job payload**. A job
377
+ enqueued Monday and run Wednesday must use Monday's value, or you get a job
378
+ that's half one behaviour and half the other — and under a deterministic workflow
379
+ engine, a replay failure. Downstream services get resolved values, not a flag
380
+ client. Records produced by gated code should carry their own provenance rather
381
+ than being re-interpreted through the current flag value.
382
+
383
+ ## Install
384
+
385
+ ```sh
386
+ pnpm add @davidlee49/launchgate
387
+ ```
388
+
389
+ Published to npm as a public package (scoped: the bare name `launchgate` is
390
+ taken on the registry). The repo is private; the tarball is the built `dist/`.
391
+
392
+ ## Releasing
393
+
394
+ 1. Bump `version` in `package.json`.
395
+ 2. `pnpm publish` — `prepublishOnly` runs `pnpm check` (typecheck, tests, build),
396
+ so an unbuilt or failing tree never ships. `dist/` is not committed; the
397
+ registry holds it.
398
+ 3. `git tag vX.Y.Z && git push origin main vX.Y.Z`.
399
+ 4. Consumers bump the version range and `pnpm install`. A published version is
400
+ immutable: a fix is a new version, never a re-tag.
401
+
402
+ ## Development
403
+
404
+ ```sh
405
+ pnpm install
406
+ pnpm check # tsc --noEmit && vitest run
407
+ ```
@@ -0,0 +1,99 @@
1
+ // src/signing.ts
2
+ var encoder = new TextEncoder();
3
+ var decoder = new TextDecoder();
4
+ function b64urlFromBytes(bytes) {
5
+ let binary = "";
6
+ for (const byte of bytes) binary += String.fromCharCode(byte);
7
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
8
+ }
9
+ function bytesFromB64url(value) {
10
+ const b64 = value.replace(/-/g, "+").replace(/_/g, "/");
11
+ const padded = b64 + "=".repeat((4 - b64.length % 4) % 4);
12
+ const binary = atob(padded);
13
+ const bytes = new Uint8Array(binary.length);
14
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
15
+ return bytes;
16
+ }
17
+ var keyCache = /* @__PURE__ */ new Map();
18
+ function hmacKey(secret) {
19
+ let key = keyCache.get(secret);
20
+ if (!key) {
21
+ key = crypto.subtle.importKey(
22
+ "raw",
23
+ encoder.encode(secret),
24
+ { name: "HMAC", hash: "SHA-256" },
25
+ false,
26
+ ["sign"]
27
+ );
28
+ keyCache.set(secret, key);
29
+ }
30
+ return key;
31
+ }
32
+ async function signature(payload, secret) {
33
+ const key = await hmacKey(secret);
34
+ const signed = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
35
+ return b64urlFromBytes(new Uint8Array(signed));
36
+ }
37
+ function timingSafeEqual(a, b) {
38
+ if (a.length !== b.length) return false;
39
+ let diff = 0;
40
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
41
+ return diff === 0;
42
+ }
43
+ function isFlagValue(value) {
44
+ return typeof value === "boolean" || typeof value === "string" || typeof value === "number" && Number.isFinite(value);
45
+ }
46
+ async function signOverrides(overrides, secret) {
47
+ const payload = b64urlFromBytes(encoder.encode(JSON.stringify(overrides)));
48
+ return `${payload}.${await signature(payload, secret)}`;
49
+ }
50
+ async function readOverrides(token, secret) {
51
+ const split = token.lastIndexOf(".");
52
+ if (split < 1) return void 0;
53
+ const payload = token.slice(0, split);
54
+ const provided = token.slice(split + 1);
55
+ let expected;
56
+ try {
57
+ expected = await signature(payload, secret);
58
+ } catch {
59
+ return void 0;
60
+ }
61
+ if (!timingSafeEqual(provided, expected)) return void 0;
62
+ try {
63
+ const parsed = JSON.parse(decoder.decode(bytesFromB64url(payload)));
64
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
65
+ return void 0;
66
+ }
67
+ const overrides = {};
68
+ for (const [key, value] of Object.entries(parsed)) {
69
+ if (isFlagValue(value)) overrides[key] = value;
70
+ }
71
+ return overrides;
72
+ } catch {
73
+ return void 0;
74
+ }
75
+ }
76
+
77
+ // src/sources/cookie.ts
78
+ var DEFAULT_OVERRIDE_COOKIE = "lg_override";
79
+ function cookieOverride(options) {
80
+ const { secret } = options;
81
+ const cookieName = options.cookieName ?? DEFAULT_OVERRIDE_COOKIE;
82
+ return {
83
+ name: "cookieOverride",
84
+ async decide({ key, def }, ctx) {
85
+ if (!def.overridable) return void 0;
86
+ const token = ctx.cookie?.(cookieName);
87
+ if (!token) return void 0;
88
+ const overrides = await readOverrides(token, secret);
89
+ return overrides?.[key];
90
+ }
91
+ };
92
+ }
93
+
94
+ export {
95
+ signOverrides,
96
+ readOverrides,
97
+ DEFAULT_OVERRIDE_COOKIE,
98
+ cookieOverride
99
+ };
@@ -0,0 +1,232 @@
1
+ import { S as Source, F as FlagValue, a as FlagMeta, E as EvaluationContext, b as FlagRegistry, R as Resolver, c as FlagValueOf } from './resolver-B2cstRTq.js';
2
+ export { C as Context, D as Decision, d as FlagDef, e as ResolvedFlags, f as ResolverOptions, g as Retirement, h as SourceErrorInfo, i as createResolver, j as defineFlags } from './resolver-B2cstRTq.js';
3
+
4
+ interface EnvOverrideOptions {
5
+ /** How to read an environment variable. Defaults to `process.env` where it exists. */
6
+ read?: (name: string) => string | undefined;
7
+ /** Variable-name prefix. Default `FLAG_`. */
8
+ prefix?: string;
9
+ }
10
+ /**
11
+ * Slot 1 — the operator override, whose primary use is the kill switch.
12
+ *
13
+ * `FLAG_NEW_HOMEPAGE=off` forces a flag off; any value the flag's type accepts
14
+ * forces that value. This sits **above** `cookieOverride` deliberately: a flag
15
+ * forced on by a visitor cookie must not survive the attempt to kill it.
16
+ *
17
+ * An unparseable value throws, which aborts to the flag's fallback and reports
18
+ * through `onError` — an operator typo is loud rather than silently ignored.
19
+ */
20
+ declare function envOverride(options?: EnvOverrideOptions): Source;
21
+ interface EnvDefaultOptions {
22
+ /** The current environment name, matched against each flag's `byEnv`. */
23
+ env: string | (() => string);
24
+ }
25
+ /**
26
+ * Slot 4 — the registry's per-environment value, e.g. `byEnv: { development: true }`.
27
+ *
28
+ * This is also the whole of "preview deployments": a preview host is just a
29
+ * different `env` with different values, needing no runtime machinery.
30
+ */
31
+ declare function envDefault(options: EnvDefaultOptions): Source;
32
+
33
+ declare const DEFAULT_OVERRIDE_COOKIE = "lg_override";
34
+ interface CookieOverrideOptions {
35
+ /** Secret for the HMAC. Must not be readable by the client. */
36
+ secret: string;
37
+ /** Cookie holding the signed token. Default `lg_override`. */
38
+ cookieName?: string;
39
+ }
40
+ /**
41
+ * Slot 2 — the signed per-visitor override: how the author sees hidden work on
42
+ * the real production site, with no account and no database.
43
+ *
44
+ * Honours **only** flags declaring `overridable: true`. A visitor must never be
45
+ * able to force a flag with real consequences — harmless while flags are
46
+ * cosmetic, load-bearing the day a project puts an entitlement source in slot 3.
47
+ *
48
+ * A missing, malformed, or tampered token is *undecided*, not an error: hostile
49
+ * input should fall through to the normal chain, not push the flag to its
50
+ * fallback.
51
+ */
52
+ declare function cookieOverride(options: CookieOverrideOptions): Source;
53
+
54
+ /**
55
+ * What storage knows about one subject and one flag.
56
+ *
57
+ * `override` is written only by the platform side. `'disabled'` is the per-subject
58
+ * kill switch, `'enabled'` the grant (beta cohort, comp, early access) — and they
59
+ * sit in *different* slots of the chain, which is why they are one row but two
60
+ * sources.
61
+ */
62
+ interface SubjectState {
63
+ override?: "enabled" | "disabled";
64
+ /** Grant a specific value rather than `true`. For value-typed flags. */
65
+ value?: FlagValue;
66
+ /** Has the subject switched it on themselves? */
67
+ optedIn?: boolean;
68
+ /**
69
+ * Projects may return their own fields alongside these and read them back via
70
+ * `store.state()` — impartire's loader returns the org's `is_platform` here so
71
+ * its stage policy shares this read instead of issuing a second query.
72
+ */
73
+ [projectField: string]: unknown;
74
+ }
75
+ type LoadSubjectState = (subject: string, key: string, flag: FlagMeta) => Promise<SubjectState | undefined> | SubjectState | undefined;
76
+ /**
77
+ * Every flag's state for one subject, in one read. Keys absent from the result
78
+ * are treated as "no row", exactly as `load` returning `undefined` is.
79
+ */
80
+ type LoadAllSubjectState = (subject: string) => Promise<Map<string, SubjectState> | Record<string, SubjectState>> | Map<string, SubjectState> | Record<string, SubjectState>;
81
+ interface SubjectStoreOptions {
82
+ /** Per-flag read. Supply this, `loadAll`, or both. */
83
+ load?: LoadSubjectState;
84
+ /**
85
+ * Batch read for the whole registry. When present it is preferred, and one
86
+ * subject costs **one** read per request however many flags resolve — which
87
+ * is what makes `resolver.resolveAll()` affordable. Without it, evaluating
88
+ * N flags means N reads.
89
+ */
90
+ loadAll?: LoadAllSubjectState;
91
+ /**
92
+ * Flags the subject must switch on for themselves — entitlement alone isn't
93
+ * enough. Default: none.
94
+ */
95
+ requiresOptIn?: (flag: FlagMeta) => boolean;
96
+ }
97
+ interface SubjectStore {
98
+ /** Slot 1, beside `envOverride`. A per-subject kill outranks a visitor cookie. */
99
+ kill: Source;
100
+ /** Slot 3a. Vetoes a flag the subject hasn't switched on. */
101
+ requireOptIn: Source;
102
+ /** Slot 3b. The platform's grant. */
103
+ grant: Source;
104
+ /**
105
+ * The cached read, for a project's own policy source. Sharing it is what keeps
106
+ * a bespoke rule from costing an extra query.
107
+ */
108
+ state(flag: FlagMeta, ctx: EvaluationContext): Promise<SubjectState | undefined>;
109
+ }
110
+ /**
111
+ * Three sources over one storage read.
112
+ *
113
+ * They share a cache keyed on the `EvaluationContext` object, which is per request, so a
114
+ * request pays **one** load per flag however many of the three are in the chain —
115
+ * the alternative being the same row fetched three times. Supply `loadAll` and
116
+ * the whole registry collapses to one read as well, which is what makes
117
+ * `resolver.resolveAll()` affordable.
118
+ *
119
+ * The library owns the semantics; you own the SQL. `load` returns whatever your
120
+ * schema says; a row shape of `(subject_id, flag_key, override)` is all this needs.
121
+ */
122
+ declare function subjectStore(options: SubjectStoreOptions): SubjectStore;
123
+
124
+ /**
125
+ * Registry hygiene, checked at build time rather than watched on a dashboard.
126
+ *
127
+ * LaunchDarkly ships `find-code-refs` — a scanner that crawls a customer's repo
128
+ * so the SaaS control plane can warn that an archived key still appears in code.
129
+ * That tool exists to buy back something a code-first registry has for free: the
130
+ * flags *are* source, so their hygiene is a build step, and a build step can
131
+ * fail rather than warn. This is that build step.
132
+ *
133
+ * It audits only what the library owns — the retirement clock and the shape of
134
+ * a `Retirement`. A project's own lifecycle vocabulary (impartire's `stage`, a
135
+ * `since` stamp on unreleased work, an owner field) is audited by the project,
136
+ * which knows what those mean; merge the two reports and print them together.
137
+ */
138
+ interface AuditOptions {
139
+ /** Defaults to now. Injectable so the age checks are testable. */
140
+ today?: Date;
141
+ /**
142
+ * How long retired code may sit undeleted before the report warns.
143
+ *
144
+ * 40 days by default, matching Unleash's documented expected lifetime for a
145
+ * release flag: retirement is the same debt seen from the other end, and a
146
+ * budget nobody picked is a budget nobody keeps.
147
+ */
148
+ retiredAfterDays?: number;
149
+ }
150
+ interface AuditReport {
151
+ /** Registry defects. A consumer should fail the build on these. */
152
+ problems: string[];
153
+ /** Debt worth seeing every run, but not worth blocking a merge. */
154
+ warnings: string[];
155
+ }
156
+ declare function auditRegistry(flags: FlagRegistry, options?: AuditOptions): AuditReport;
157
+
158
+ /**
159
+ * Prefix match: exact, or a path segment below it.
160
+ *
161
+ * `"/"` matches only `"/"` — it falls out of the general rule rather than needing
162
+ * a special case, because no real pathname starts with `"//"`.
163
+ *
164
+ * Exported standalone because nav filtering usually runs in a client component,
165
+ * where the rule must be identical to the server's or the menu and the router
166
+ * disagree.
167
+ */
168
+ declare function matchesPrefix(pathname: string, prefixes: readonly string[]): boolean;
169
+ interface RouteGateOptions<T extends FlagRegistry> {
170
+ resolver: Resolver<T>;
171
+ /** Flag key → the path prefixes it reveals. A flag may reveal several. */
172
+ routes: Partial<Record<keyof T & string, readonly string[]>>;
173
+ /**
174
+ * Paths served regardless of any flag — the launched surface. Omit for an
175
+ * app whose routes are all either public or flagged.
176
+ */
177
+ allow?: readonly string[];
178
+ /**
179
+ * What to do with a path that is neither allowed nor covered by a flag.
180
+ *
181
+ * Required, with no default, because it is the whole safety posture and the
182
+ * two answers fail in very different ways. `"deny"` means a route added
183
+ * tomorrow is hidden until someone lists it — it 404s loudly and safely.
184
+ * `"allow"` means it ships publicly the moment it exists, and forgetting to
185
+ * flag work in progress leaks it silently.
186
+ */
187
+ unlisted: "deny" | "allow";
188
+ }
189
+ interface RouteGate {
190
+ /** May this path be served right now? */
191
+ isPermitted(pathname: string, ctx?: EvaluationContext): Promise<boolean>;
192
+ /** Prefixes currently revealed by a flag. For filtering nav server-side. */
193
+ revealedPrefixes(ctx?: EvaluationContext): Promise<string[]>;
194
+ /** Everything servable right now — `allow` plus whatever is revealed. */
195
+ permittedPrefixes(ctx?: EvaluationContext): Promise<string[]>;
196
+ }
197
+ /**
198
+ * Turns "which flags are on" into "which paths may be served".
199
+ *
200
+ * The app supplies two lists — what is launched, and which flag hides what. This
201
+ * owns the policy: check the allowlist first (so a launched route costs no flag
202
+ * resolution at all), then ask the flags.
203
+ *
204
+ * **Throws at construction** if a prefix is both allowed and flagged. That flag
205
+ * could never change anything, because the allowlist would already be letting
206
+ * the path through — a silent no-op that is very hard to spot by reading, and
207
+ * exactly the kind of thing a launch gate must not have.
208
+ */
209
+ declare function createRouteGate<T extends FlagRegistry>(options: RouteGateOptions<T>): RouteGate;
210
+
211
+ /**
212
+ * Mints the signed token that `cookieOverride` reads. Lives in the core rather
213
+ * than the framework adapter so an adapter is only cookie plumbing.
214
+ */
215
+ declare function signOverrides(overrides: Record<string, FlagValue>, secret: string): Promise<string>;
216
+ /** Verifies and parses a token. Returns `undefined` for anything not genuinely signed. */
217
+ declare function readOverrides(token: string, secret: string): Promise<Record<string, FlagValue> | undefined>;
218
+
219
+ type FlagValues<T extends FlagRegistry> = Partial<{
220
+ [K in keyof T]: FlagValueOf<T, K>;
221
+ }>;
222
+ /**
223
+ * A resolver that answers from a fixed map, falling back to each flag's declared
224
+ * `fallback` for keys left unset.
225
+ *
226
+ * Use this in consumers' tests instead of standing up real sources: flags
227
+ * multiply a test matrix, and the multiplication is only tolerable if pinning a
228
+ * value is one line.
229
+ */
230
+ declare function withFlags<T extends FlagRegistry>(flags: T, values?: FlagValues<T>): Resolver<T>;
231
+
232
+ export { type AuditOptions, type AuditReport, type CookieOverrideOptions, DEFAULT_OVERRIDE_COOKIE, type EnvDefaultOptions, type EnvOverrideOptions, EvaluationContext, FlagMeta, FlagRegistry, FlagValue, type FlagValues, type LoadAllSubjectState, type LoadSubjectState, Resolver, type RouteGate, type RouteGateOptions, Source, type SubjectState, type SubjectStore, type SubjectStoreOptions, auditRegistry, cookieOverride, createRouteGate, envDefault, envOverride, matchesPrefix, readOverrides, signOverrides, subjectStore, withFlags };
package/dist/index.js ADDED
@@ -0,0 +1,274 @@
1
+ import {
2
+ DEFAULT_OVERRIDE_COOKIE,
3
+ cookieOverride,
4
+ readOverrides,
5
+ signOverrides
6
+ } from "./chunk-34MIJ7KP.js";
7
+
8
+ // src/resolver.ts
9
+ function defineFlags(flags) {
10
+ return flags;
11
+ }
12
+ function createResolver(options) {
13
+ const { flags, sources, onError } = options;
14
+ async function resolve(key, ctx = {}) {
15
+ const def = flags[key];
16
+ if (!def) throw new Error(`launchgate: unknown flag "${key}"`);
17
+ if (def.retired) return def.retired.value;
18
+ const meta = { key, def };
19
+ for (const source of sources) {
20
+ let decision;
21
+ try {
22
+ decision = await source.decide(meta, ctx);
23
+ } catch (error) {
24
+ onError?.(error, { flag: key, source: source.name });
25
+ return def.fallback;
26
+ }
27
+ if (decision === void 0) continue;
28
+ if (typeof decision !== typeof def.fallback) {
29
+ onError?.(
30
+ new TypeError(
31
+ `launchgate: source "${source.name}" answered ${typeof decision} for flag "${key}", which is ${typeof def.fallback}`
32
+ ),
33
+ { flag: key, source: source.name }
34
+ );
35
+ return def.fallback;
36
+ }
37
+ return decision;
38
+ }
39
+ return def.fallback;
40
+ }
41
+ async function resolveAll(ctx = {}) {
42
+ const keys = Object.keys(flags);
43
+ const values = await Promise.all(keys.map((key) => resolve(key, ctx)));
44
+ return Object.fromEntries(
45
+ keys.map((key, i) => [key, values[i]])
46
+ );
47
+ }
48
+ return { flags, resolve, resolveAll };
49
+ }
50
+
51
+ // src/sources/env.ts
52
+ function envName(key) {
53
+ return key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[-\s]+/g, "_").toUpperCase();
54
+ }
55
+ function parseAs(raw, fallback, variable) {
56
+ const trimmed = raw.trim();
57
+ if (typeof fallback === "boolean") {
58
+ const v = trimmed.toLowerCase();
59
+ if (v === "on" || v === "true" || v === "1" || v === "yes") return true;
60
+ if (v === "off" || v === "false" || v === "0" || v === "no") return false;
61
+ throw new Error(
62
+ `launchgate: ${variable}="${raw}" is not on/off for a boolean flag`
63
+ );
64
+ }
65
+ if (typeof fallback === "number") {
66
+ const n = Number(trimmed);
67
+ if (!Number.isFinite(n)) {
68
+ throw new Error(`launchgate: ${variable}="${raw}" is not a number`);
69
+ }
70
+ return n;
71
+ }
72
+ return trimmed;
73
+ }
74
+ function defaultRead(name) {
75
+ const proc = globalThis.process;
76
+ return proc?.env?.[name];
77
+ }
78
+ function envOverride(options = {}) {
79
+ const read = options.read ?? defaultRead;
80
+ const prefix = options.prefix ?? "FLAG_";
81
+ return {
82
+ name: "envOverride",
83
+ decide({ key, def }) {
84
+ const variable = prefix + envName(key);
85
+ const raw = read(variable);
86
+ if (raw === void 0 || raw === "") return void 0;
87
+ return parseAs(raw, def.fallback, variable);
88
+ }
89
+ };
90
+ }
91
+ function envDefault(options) {
92
+ const { env } = options;
93
+ return {
94
+ name: "envDefault",
95
+ decide({ def }) {
96
+ const current = typeof env === "function" ? env() : env;
97
+ return def.byEnv?.[current];
98
+ }
99
+ };
100
+ }
101
+
102
+ // src/sources/subject.ts
103
+ function offValue(flag) {
104
+ return typeof flag.def.fallback === "boolean" ? false : flag.def.fallback;
105
+ }
106
+ function subjectStore(options) {
107
+ const { load, loadAll, requiresOptIn } = options;
108
+ if (!load && !loadAll) {
109
+ throw new Error("launchgate: subjectStore needs `load` or `loadAll`");
110
+ }
111
+ const caches = /* @__PURE__ */ new WeakMap();
112
+ function read(flag, ctx, subject) {
113
+ let cache = caches.get(ctx);
114
+ if (!cache) {
115
+ cache = { perFlag: /* @__PURE__ */ new Map(), batch: /* @__PURE__ */ new Map() };
116
+ caches.set(ctx, cache);
117
+ }
118
+ if (loadAll) {
119
+ let batched = cache.batch.get(subject);
120
+ if (!batched) {
121
+ batched = Promise.resolve(loadAll(subject)).then(
122
+ (result) => result instanceof Map ? result : new Map(Object.entries(result))
123
+ );
124
+ cache.batch.set(subject, batched);
125
+ }
126
+ return batched.then((states) => states.get(flag.key));
127
+ }
128
+ const cacheKey = `${subject}\0${flag.key}`;
129
+ let pending = cache.perFlag.get(cacheKey);
130
+ if (!pending) {
131
+ pending = Promise.resolve(load(subject, flag.key, flag));
132
+ cache.perFlag.set(cacheKey, pending);
133
+ }
134
+ return pending;
135
+ }
136
+ function source(name, decide, applies = () => true) {
137
+ return {
138
+ name,
139
+ async decide(flag, ctx) {
140
+ if (!ctx.targetingKey || !applies(flag)) return void 0;
141
+ const state = await read(flag, ctx, ctx.targetingKey);
142
+ return state ? decide(state, flag) : decide({}, flag);
143
+ }
144
+ };
145
+ }
146
+ return {
147
+ state: async (flag, ctx) => ctx.targetingKey ? read(flag, ctx, ctx.targetingKey) : void 0,
148
+ kill: source(
149
+ "subjectKill",
150
+ (state, flag) => state.override === "disabled" ? offValue(flag) : void 0
151
+ ),
152
+ requireOptIn: source(
153
+ "subjectOptIn",
154
+ (state, flag) => state.optedIn ? void 0 : offValue(flag),
155
+ (flag) => requiresOptIn?.(flag) === true
156
+ ),
157
+ grant: source("subjectGrant", (state, flag) => {
158
+ if (state.override !== "enabled") return void 0;
159
+ if (state.value !== void 0) return state.value;
160
+ return typeof flag.def.fallback === "boolean" ? true : void 0;
161
+ })
162
+ };
163
+ }
164
+
165
+ // src/audit.ts
166
+ var DATE = /^\d{4}-\d{2}-\d{2}$/;
167
+ function auditRegistry(flags, options = {}) {
168
+ const { today = /* @__PURE__ */ new Date(), retiredAfterDays = 40 } = options;
169
+ const problems = [];
170
+ const warnings = [];
171
+ for (const [key, def] of Object.entries(flags)) {
172
+ const retired = def.retired;
173
+ if (!retired) continue;
174
+ if (typeof retired.value !== typeof def.fallback) {
175
+ problems.push(
176
+ `flag "${key}" is retired to a ${typeof retired.value} but its fallback is ${typeof def.fallback}. Every evaluation would return the wrong type.`
177
+ );
178
+ }
179
+ if (def.overridable) {
180
+ warnings.push(
181
+ `"${key}" is retired but still declares \`overridable\`. Retirement outranks the override cookie, so it can no longer be revealed that way \u2014 drop the field.`
182
+ );
183
+ }
184
+ if (!DATE.test(retired.since)) {
185
+ problems.push(
186
+ `flag "${key}" is retired with since: "${retired.since}", which is not YYYY-MM-DD. The date is the cleanup clock, so an unparseable one silently stops it.`
187
+ );
188
+ continue;
189
+ }
190
+ const days = Math.floor(
191
+ (today.getTime() - (/* @__PURE__ */ new Date(`${retired.since}T00:00:00Z`)).getTime()) / 864e5
192
+ );
193
+ if (days > retiredAfterDays) {
194
+ warnings.push(
195
+ `"${key}" has been retired for ${days} days (budget ${retiredAfterDays}).
196
+ Its doors are dark and its code is dead: delete the components, the
197
+ routes, then the registry entry. Retiring is a state, not a resting place.`
198
+ );
199
+ }
200
+ }
201
+ return { problems, warnings };
202
+ }
203
+
204
+ // src/route-gate.ts
205
+ function matchesPrefix(pathname, prefixes) {
206
+ return prefixes.some(
207
+ (prefix) => pathname === prefix || pathname.startsWith(prefix + "/")
208
+ );
209
+ }
210
+ function createRouteGate(options) {
211
+ const { resolver, routes, allow = [], unlisted } = options;
212
+ const entries = Object.entries(routes);
213
+ for (const [key, prefixes] of entries) {
214
+ for (const prefix of prefixes) {
215
+ if (allow.includes(prefix)) {
216
+ throw new Error(
217
+ `launchgate: "${prefix}" is both allowed and gated by flag "${key}". The allowlist would serve it whatever the flag says, so the flag is dead.`
218
+ );
219
+ }
220
+ }
221
+ if (!(key in resolver.flags)) {
222
+ throw new Error(`launchgate: routes reference unknown flag "${key}"`);
223
+ }
224
+ }
225
+ async function revealedPrefixes(ctx = {}) {
226
+ const revealed = await Promise.all(
227
+ entries.map(
228
+ async ([key, prefixes]) => await resolver.resolve(key, ctx) ? prefixes : []
229
+ )
230
+ );
231
+ return revealed.flat();
232
+ }
233
+ return {
234
+ revealedPrefixes,
235
+ async permittedPrefixes(ctx) {
236
+ return [...allow, ...await revealedPrefixes(ctx)];
237
+ },
238
+ async isPermitted(pathname, ctx) {
239
+ if (matchesPrefix(pathname, allow)) return true;
240
+ if (matchesPrefix(pathname, await revealedPrefixes(ctx))) return true;
241
+ const flagged = entries.flatMap(([, prefixes]) => prefixes);
242
+ if (matchesPrefix(pathname, flagged)) return false;
243
+ return unlisted === "allow";
244
+ }
245
+ };
246
+ }
247
+
248
+ // src/testing.ts
249
+ function withFlags(flags, values = {}) {
250
+ return createResolver({
251
+ flags,
252
+ sources: [
253
+ {
254
+ name: "withFlags",
255
+ decide: ({ key }) => values[key]
256
+ }
257
+ ]
258
+ });
259
+ }
260
+ export {
261
+ DEFAULT_OVERRIDE_COOKIE,
262
+ auditRegistry,
263
+ cookieOverride,
264
+ createResolver,
265
+ createRouteGate,
266
+ defineFlags,
267
+ envDefault,
268
+ envOverride,
269
+ matchesPrefix,
270
+ readOverrides,
271
+ signOverrides,
272
+ subjectStore,
273
+ withFlags
274
+ };
@@ -0,0 +1,42 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { b as FlagRegistry, R as Resolver, E as EvaluationContext } from '../resolver-B2cstRTq.js';
3
+
4
+ /**
5
+ * Builds an `EvaluationContext` that reads cookies from the incoming request.
6
+ *
7
+ * **This opts the caller into dynamic rendering** — `cookies()` always does. On
8
+ * a page that is already dynamic (anything authenticated) that costs nothing; on
9
+ * a page you need statically rendered, don't call it, and see `staticVariant`
10
+ * in the README instead.
11
+ */
12
+ declare function requestContext(extra?: EvaluationContext): Promise<EvaluationContext>;
13
+ /**
14
+ * Route-handler guard. 404, not 403: a feature you don't have shouldn't
15
+ * advertise its own existence.
16
+ *
17
+ * const gate = await requireFlag(resolver, "network", { targetingKey: orgId });
18
+ * if (gate) return gate;
19
+ */
20
+ declare function requireFlag<T extends FlagRegistry, K extends keyof T & string>(resolver: Resolver<T>, key: K, ctx?: EvaluationContext): Promise<NextResponse | null>;
21
+ interface OverrideRouteOptions<T extends FlagRegistry> {
22
+ resolver: Resolver<T>;
23
+ /** HMAC secret. Same value `cookieOverride` was given. Never appears in a URL. */
24
+ secret: string;
25
+ /** Shared secret presented as `?token=` to use this route. Must differ from `secret`. */
26
+ accessToken: string;
27
+ cookieName?: string;
28
+ /** Cookie lifetime in seconds. Default 30 days. */
29
+ maxAge?: number;
30
+ }
31
+ /**
32
+ * `GET /__flags?token=…&flag=newHomepage&value=on` — sets the signed override
33
+ * cookie, so you see hidden work on the real production site with no account and
34
+ * no database. `&clear=1` drops everything; omitting `value` clears one flag.
35
+ *
36
+ * Mount it at a path you don't advertise, and give it a real `accessToken`.
37
+ */
38
+ declare function createOverrideRoute<T extends FlagRegistry>(options: OverrideRouteOptions<T>): {
39
+ GET: (request: Request) => Promise<NextResponse>;
40
+ };
41
+
42
+ export { type OverrideRouteOptions, createOverrideRoute, requestContext, requireFlag };
@@ -0,0 +1,102 @@
1
+ import {
2
+ DEFAULT_OVERRIDE_COOKIE,
3
+ readOverrides,
4
+ signOverrides
5
+ } from "../chunk-34MIJ7KP.js";
6
+
7
+ // src/next/index.ts
8
+ import { cookies } from "next/headers";
9
+ import { NextResponse } from "next/server";
10
+ async function requestContext(extra = {}) {
11
+ const jar = await cookies();
12
+ return { ...extra, cookie: (name) => jar.get(name)?.value };
13
+ }
14
+ async function requireFlag(resolver, key, ctx) {
15
+ const value = await resolver.resolve(key, ctx);
16
+ return value === false || value === void 0 ? NextResponse.json({ error: "Not found" }, { status: 404 }) : null;
17
+ }
18
+ function timingSafeEqual(a, b) {
19
+ if (a.length !== b.length) return false;
20
+ let diff = 0;
21
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
22
+ return diff === 0;
23
+ }
24
+ function parseAs(raw, fallback) {
25
+ if (typeof fallback === "boolean") {
26
+ const v = raw.trim().toLowerCase();
27
+ if (v === "on" || v === "true" || v === "1") return true;
28
+ if (v === "off" || v === "false" || v === "0") return false;
29
+ return void 0;
30
+ }
31
+ if (typeof fallback === "number") {
32
+ const n = Number(raw);
33
+ return Number.isFinite(n) ? n : void 0;
34
+ }
35
+ return raw;
36
+ }
37
+ function createOverrideRoute(options) {
38
+ const { resolver, secret, accessToken } = options;
39
+ const cookieName = options.cookieName ?? DEFAULT_OVERRIDE_COOKIE;
40
+ const maxAge = options.maxAge ?? 60 * 60 * 24 * 30;
41
+ async function GET(request) {
42
+ const url = new URL(request.url);
43
+ const token = url.searchParams.get("token") ?? "";
44
+ if (!timingSafeEqual(token, accessToken)) {
45
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
46
+ }
47
+ const jar = await cookies();
48
+ const current = jar.get(cookieName)?.value;
49
+ let overrides = (current ? await readOverrides(current, secret) : void 0) ?? {};
50
+ if (url.searchParams.get("clear") !== null) {
51
+ overrides = {};
52
+ } else {
53
+ const key = url.searchParams.get("flag");
54
+ if (!key) {
55
+ return NextResponse.json({ error: "flag is required" }, { status: 400 });
56
+ }
57
+ const def = resolver.flags[key];
58
+ if (!def) {
59
+ return NextResponse.json({ error: `unknown flag "${key}"` }, { status: 400 });
60
+ }
61
+ if (!def.overridable) {
62
+ return NextResponse.json(
63
+ { error: `flag "${key}" is not overridable` },
64
+ { status: 400 }
65
+ );
66
+ }
67
+ const raw = url.searchParams.get("value");
68
+ if (raw === null) {
69
+ delete overrides[key];
70
+ } else {
71
+ const value = parseAs(raw, def.fallback);
72
+ if (value === void 0) {
73
+ return NextResponse.json(
74
+ { error: `"${raw}" is not a valid value for "${key}"` },
75
+ { status: 400 }
76
+ );
77
+ }
78
+ overrides[key] = value;
79
+ }
80
+ }
81
+ const body = { overrides };
82
+ const response = NextResponse.json(body);
83
+ if (Object.keys(overrides).length === 0) {
84
+ response.cookies.delete(cookieName);
85
+ } else {
86
+ response.cookies.set(cookieName, await signOverrides(overrides, secret), {
87
+ httpOnly: true,
88
+ sameSite: "lax",
89
+ secure: url.protocol === "https:",
90
+ path: "/",
91
+ maxAge
92
+ });
93
+ }
94
+ return response;
95
+ }
96
+ return { GET };
97
+ }
98
+ export {
99
+ createOverrideRoute,
100
+ requestContext,
101
+ requireFlag
102
+ };
@@ -0,0 +1,161 @@
1
+ /** What a flag can resolve to. Boolean is the common case, not the only one (Decision 1). */
2
+ type FlagValue = boolean | string | number;
3
+ /**
4
+ * A flag's retirement — the terminal state of its lifecycle (Decision 11).
5
+ *
6
+ * Presence is the retirement: a def carrying this resolves to `value` and **no
7
+ * source is consulted**, not the env kill switch and not a signed override
8
+ * cookie. That ordering is the point. A retirement is a decision someone
9
+ * committed to git, so the only way to reverse it is to revert the commit —
10
+ * never a leftover `FLAG_X=on` secret or a cookie an engineer is still holding.
11
+ *
12
+ * The entry **stays in the registry** after retirement, which is Unleash's
13
+ * archive-rather-than-delete rule and buys the same property: the key cannot be
14
+ * silently reused by a new feature (a duplicate key in the registry literal is
15
+ * a type error), so a stale override can never attach to something it was never
16
+ * about. Delete the entry only once its code is gone.
17
+ */
18
+ interface Retirement<T extends FlagValue = FlagValue> {
19
+ /**
20
+ * When retirement was decided, `YYYY-MM-DD`. Required, because the clock is
21
+ * the whole point: retired code that nobody deletes is the flag debt this
22
+ * state exists to make visible. `auditRegistry` warns once it is older than
23
+ * the budget.
24
+ */
25
+ since: string;
26
+ /**
27
+ * What every evaluation returns from now on — and the *disposition record*:
28
+ * Unleash makes you declare an outcome when completing a flag, because the
29
+ * cleanup PR has to know which branch of the code survives. `false` retires a
30
+ * feature outright; `"v1"` on a dynamic-config flag says v1 is what the code
31
+ * keeps.
32
+ */
33
+ value: T;
34
+ }
35
+ interface FlagDef<T extends FlagValue = FlagValue> {
36
+ /**
37
+ * The value used when no source decides, and when a source throws.
38
+ *
39
+ * Per flag, never global (Decision 3): a work-in-progress gate's safe state is
40
+ * `false`, but a kill switch on a year-old feature's safe state is `true` —
41
+ * failing that one closed would take down working functionality because a
42
+ * database blipped.
43
+ */
44
+ fallback: T;
45
+ /** Why this flag exists. Greppability plus this line is the whole flag-debt mitigation. */
46
+ description: string;
47
+ /** May a signed visitor cookie force this flag? Default false — see `cookieOverride`. */
48
+ overridable?: boolean;
49
+ /** Per-environment value, consulted by the `envDefault` source. */
50
+ byEnv?: Record<string, T>;
51
+ /**
52
+ * Retired: resolve to `retired.value` and consult no source at all. See
53
+ * `Retirement` — this outranks every source, including the kill switch.
54
+ */
55
+ retired?: Retirement<T>;
56
+ /**
57
+ * Projects may hang their own fields here — a stage, a tier, an owner — and
58
+ * read them back inside a custom source. The registry keeps their real types
59
+ * (`flags.network.stage` is a `string`); only the erased view a `Source` sees
60
+ * widens to `unknown`.
61
+ */
62
+ [projectField: string]: unknown;
63
+ }
64
+ type FlagRegistry = Record<string, FlagDef>;
65
+ /**
66
+ * The type a flag resolves to.
67
+ *
68
+ * `fallback: false` infers as the *literal* `false` rather than `boolean`,
69
+ * because `boolean` is itself `true | false` and TypeScript preserves a literal
70
+ * whose contextual type contains it. Left alone, every boolean flag would be
71
+ * unable to hold the opposite value. Strings and numbers do not have this
72
+ * problem (their contextual types are not literal unions), so they keep whatever
73
+ * the author declared — which is what makes `fallback: "v1" as "v1" | "v2"` work.
74
+ */
75
+ type FlagValueOf<T extends FlagRegistry, K extends keyof T> = T[K]["fallback"] extends boolean ? boolean : T[K]["fallback"];
76
+ /**
77
+ * Data a source may target on. Named for OpenFeature's evaluation context, and
78
+ * `targetingKey` is its field — the CNCF spec every major SDK implements, so
79
+ * this reads as a familiar dialect rather than a private vocabulary.
80
+ *
81
+ * `targetingKey` is **optional** by design, where OpenFeature makes it merely
82
+ * conventional: an anonymous visitor to a static site has none, and a launch
83
+ * gate must still resolve for them.
84
+ */
85
+ interface EvaluationContext {
86
+ /** Who is being evaluated — a user id, org id, device id. Absent for anonymous visitors. */
87
+ targetingKey?: string;
88
+ /** Reads a request cookie. Supplied by a framework adapter; absent means "no cookies here". */
89
+ cookie?: (name: string) => string | undefined;
90
+ /** Anything else a project-supplied source needs. */
91
+ [key: string]: unknown;
92
+ }
93
+ /** @deprecated Renamed to `EvaluationContext` in 0.3.0 to match OpenFeature. */
94
+ type Context = EvaluationContext;
95
+ /** A source's answer. `undefined` means *undecided — ask the next source* (Decision 2). */
96
+ type Decision = FlagValue | undefined;
97
+ interface FlagMeta {
98
+ key: string;
99
+ def: FlagDef;
100
+ }
101
+ /**
102
+ * One link in the resolution chain. Sources are typed against `unknown` values
103
+ * rather than the registry's inferred types (Decision 8) — the call site keeps
104
+ * its types, the four built-in sources give theirs up.
105
+ */
106
+ interface Source {
107
+ /** Reported to `onError` when this source throws. */
108
+ readonly name: string;
109
+ decide(flag: FlagMeta, ctx: Context): Decision | Promise<Decision>;
110
+ }
111
+ interface SourceErrorInfo {
112
+ flag: string;
113
+ source: string;
114
+ }
115
+
116
+ /**
117
+ * Declares a project's flags. Identity at runtime; it exists to infer each flag's
118
+ * value type so `resolve()` is typed at the call site.
119
+ *
120
+ * The registry lives in the project that reads the flags, not in a table and not
121
+ * in library config (Decision 4): flags ship with the code they gate, so deleting
122
+ * one is a pull request and a key nobody reads is visibly dead.
123
+ */
124
+ declare function defineFlags<T extends FlagRegistry>(flags: T): T;
125
+ interface ResolverOptions<T extends FlagRegistry> {
126
+ flags: T;
127
+ /**
128
+ * Consulted in order; the first definite answer wins. Recommended ordering:
129
+ * `envOverride` (kill switch), `cookieOverride`, project sources (grants,
130
+ * opt-in, entitlement), `envDefault`. The flag's `fallback` is terminal and is
131
+ * not a source, so it cannot be left out.
132
+ */
133
+ sources: Source[];
134
+ /** Called when a source throws or answers with the wrong type. */
135
+ onError?: (error: unknown, info: SourceErrorInfo) => void;
136
+ }
137
+ /** Every flag in a registry, evaluated. */
138
+ type ResolvedFlags<T extends FlagRegistry> = {
139
+ [K in keyof T]: FlagValueOf<T, K>;
140
+ };
141
+ interface Resolver<T extends FlagRegistry> {
142
+ resolve<K extends keyof T & string>(key: K, ctx?: EvaluationContext): Promise<FlagValueOf<T, K>>;
143
+ /**
144
+ * Evaluate the whole registry at once — LaunchDarkly's `allFlagsState()`
145
+ * pattern: resolve server-side, hand the map to the client, and the UI never
146
+ * flickers because it never had to ask.
147
+ *
148
+ * Resolution is concurrent and shares the context object, so subject-backed
149
+ * sources hit their per-request cache; with `subjectStore({ loadAll })` the
150
+ * whole registry costs one read.
151
+ *
152
+ * Pass the **values** to a client, never the registry — flag keys name
153
+ * unshipped work, and a viewer who can't see a surface shouldn't learn its
154
+ * name from the payload.
155
+ */
156
+ resolveAll(ctx?: EvaluationContext): Promise<ResolvedFlags<T>>;
157
+ readonly flags: T;
158
+ }
159
+ declare function createResolver<T extends FlagRegistry>(options: ResolverOptions<T>): Resolver<T>;
160
+
161
+ export { type Context as C, type Decision as D, type EvaluationContext as E, type FlagValue as F, type Resolver as R, type Source as S, type FlagMeta as a, type FlagRegistry as b, type FlagValueOf as c, type FlagDef as d, type ResolvedFlags as e, type ResolverOptions as f, type Retirement as g, type SourceErrorInfo as h, createResolver as i, defineFlags as j };
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@davidlee49/launchgate",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.5.0",
4
+ "description": "A launch-gate library: hide work in progress, kill a shipped feature, no platform required.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "files": [
8
+ "dist"
9
+ ],
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ },
15
+ "./next": {
16
+ "types": "./dist/next/index.d.ts",
17
+ "import": "./dist/next/index.js"
18
+ }
19
+ },
20
+ "devDependencies": {
21
+ "tsup": "^8.5.0",
22
+ "typescript": "^5.9.2",
23
+ "vitest": "^3.2.4",
24
+ "next": "^16.0.0",
25
+ "react": "^19.0.0",
26
+ "react-dom": "^19.0.0",
27
+ "@types/react": "^19.0.0"
28
+ },
29
+ "peerDependencies": {
30
+ "next": ">=15"
31
+ },
32
+ "peerDependenciesMeta": {
33
+ "next": {
34
+ "optional": true
35
+ }
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/davidlee49/launchgate.git"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "scripts": {
45
+ "build": "tsup",
46
+ "check": "tsc --noEmit && vitest run && tsup",
47
+ "test": "vitest run",
48
+ "test:watch": "vitest"
49
+ }
6
50
  }