@davidlee49/launchgate 0.0.0-stage → 0.5.1

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,409 @@
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
+ API reference, every export with its signature: [API.md](API.md).
9
+
10
+ ```ts
11
+ import { defineFlags, createResolver, envOverride, cookieOverride, envDefault } from "@davidlee49/launchgate";
12
+
13
+ export const flags = defineFlags({
14
+ newPricingPage: {
15
+ fallback: false,
16
+ description: "Rebuilt pricing page — hidden until launch",
17
+ overridable: true,
18
+ byEnv: { development: true },
19
+ },
20
+ });
21
+
22
+ export const gate = createResolver({
23
+ flags,
24
+ sources: [
25
+ envOverride(), // 1. kill switch
26
+ cookieOverride({ secret: process.env.FLAG_SECRET! }), // 2. you, on the live site
27
+ envDefault({ env: process.env.NODE_ENV! }), // 4. per-environment default
28
+ ],
29
+ onError: (error, { flag, source }) => console.error("launchgate", flag, source, error),
30
+ });
31
+
32
+ if (await gate.resolve("newPricingPage", { cookie })) { /* … */ }
33
+ ```
34
+
35
+ ## How resolution works
36
+
37
+ An **ordered list of sources**; each answers with a value or `undefined`
38
+ (*undecided — ask the next one*); the first definite answer wins. The flag's
39
+ `fallback` is terminal, so it can't be forgotten.
40
+
41
+ | # | Source | Purpose |
42
+ | --- | --- | --- |
43
+ | 0 | `retired` | Not a source. A retired flag answers with its recorded disposition and the chain never starts. |
44
+ | 1 | `envOverride()` | `FLAG_NEW_PRICING_PAGE=off`. The kill switch. |
45
+ | 2 | `cookieOverride({ secret })` | Signed per-visitor token — how you see hidden work in production. |
46
+ | 3 | *yours* | Allowlist, grant, opt-in, entitlement. Usually a database read. |
47
+ | 4 | `envDefault({ env })` | The registry's `byEnv` value. Preview deployments are just a different `env`. |
48
+ | — | `fallback` | Terminal, always present. |
49
+
50
+ `false` is a decision. `undefined` is not. That three-valued fall-through is the
51
+ whole mechanic — and there is no traversal: no flag ever consults another flag.
52
+
53
+ ### Two invariants
54
+
55
+ - **`envOverride` sits above `cookieOverride`.** A flag forced on by a cookie
56
+ must not survive the attempt to kill it.
57
+ - **`cookieOverride` honours only `overridable: true` flags.** A visitor must
58
+ never force a flag with real consequences.
59
+
60
+ ### Failure is per flag, not global
61
+
62
+ Every source call is wrapped. A source that throws **aborts to that flag's
63
+ `fallback`** and reports through `onError` — it does not fall through, so there
64
+ is exactly one degraded answer per flag and it's declared next to the flag.
65
+
66
+ That value is per flag because polarity differs: a WIP gate's safe state is
67
+ `false`, but a kill switch on a year-old feature's safe state is `true` — failing
68
+ *that* one closed would take down working functionality because a database
69
+ blipped.
70
+
71
+ ## Retiring a flag
72
+
73
+ A release toggle is temporary by definition, and so is the feature behind it
74
+ eventually. `retired` is the terminal state of both:
75
+
76
+ ```ts
77
+ collections: {
78
+ fallback: true,
79
+ description: "Collections — retired 2026-08-29, code awaiting deletion",
80
+ retired: { since: "2026-08-29", value: false },
81
+ },
82
+ ```
83
+
84
+ It resolves to `value` and **consults no source at all** — not `envOverride`,
85
+ which outranks everything else. That is the point rather than an optimisation: a
86
+ retirement is a decision someone committed to git, so the only way to reverse it
87
+ is to revert the commit. A forgotten `FLAG_COLLECTIONS=on` secret or an override
88
+ cookie an engineer is still holding must not be able to reopen it, or the
89
+ registry stops describing what the system actually does.
90
+
91
+ Note `value` is *not* `fallback`. `fallback` is where a **failure** lands; a
92
+ retirement isn't a failure, and a feature whose safe-on-failure state is `true`
93
+ still retires to `false`. Declaring it separately is also the disposition record
94
+ Unleash makes you supply when completing a flag — the cleanup PR needs to know
95
+ which branch of the code survives, and `value: "v1"` on a dynamic-config flag
96
+ says exactly that.
97
+
98
+ **Keep the entry.** Retirement archives, it doesn't delete — Unleash's rule, for
99
+ Unleash's reason: while the key is in the registry it cannot be reused by a new
100
+ feature (a duplicate key is a type error), so a stale override can never attach
101
+ to something it was never about. Delete the entry *last*, once the components,
102
+ the routes, and the API handlers are gone.
103
+
104
+ ### The clock
105
+
106
+ ```ts
107
+ const { problems, warnings } = auditRegistry(flags, { today, retiredAfterDays: 40 });
108
+ ```
109
+
110
+ Retired code that nobody deletes is the same debt as a stale release flag, seen
111
+ from the other end, so it gets the same 40-day budget. `auditRegistry` audits
112
+ only what the library owns — the retirement shape and its age. Your own
113
+ lifecycle vocabulary is yours to check; merge the two reports and print them
114
+ together in one build step.
115
+
116
+ This is deliberately a *build* step. LaunchDarkly ships `find-code-refs` to
117
+ crawl a customer's repository, because a SaaS control plane can't see the code
118
+ and can only warn on a dashboard. A registry that lives in the repo doesn't need
119
+ the scanner: it can fail the build instead.
120
+
121
+ ## Flags aren't only booleans
122
+
123
+ ```ts
124
+ renderPipeline: {
125
+ fallback: "v1" as "v1" | "v2",
126
+ description: "Which render pipeline a submitted job uses",
127
+ }
128
+ ```
129
+
130
+ `FLAG_RENDER_PIPELINE=v2` and cookie overrides carry the value. Booleans are the
131
+ common case, not the only one.
132
+
133
+ > **Typing note.** `fallback: false` is widened back to `boolean` for you.
134
+ > Strings and numbers keep what you declare, which is why the `as "v1" | "v2"`
135
+ > above gives you a checked union.
136
+
137
+ ## Custom sources
138
+
139
+ A source is `{ name, decide(flag, ctx) }`. Return a value to decide, `undefined`
140
+ to pass:
141
+
142
+ ```ts
143
+ const orgGrant: Source = {
144
+ name: "orgGrant",
145
+ async decide({ key }, ctx) {
146
+ if (!ctx.targetingKey) return undefined;
147
+ const row = await db.grantFor(ctx.targetingKey, key);
148
+ return row?.enabled; // undefined when there's no row — undecided
149
+ },
150
+ };
151
+ ```
152
+
153
+ Conjunctions ("entitled **and** opted in") are a *requirement* source: return the
154
+ off-value, or `undefined` to stay out of the way. The chain stays first-wins.
155
+
156
+ ## Gating routes
157
+
158
+ Flags answer "is this on". `createRouteGate` answers the question a web app
159
+ actually asks — **"may this path be served"** — from two lists you supply:
160
+
161
+ ```ts
162
+ const gate = createRouteGate({
163
+ resolver: launch,
164
+ routes: { workflows: ["/workflows"], network: ["/network", "/api/network"] },
165
+ allow: ["/", "/assets", "/api/assets"], // the launched surface
166
+ unlisted: "deny",
167
+ });
168
+
169
+ await gate.isPermitted("/workflows", { cookie }); // middleware
170
+ await gate.revealedPrefixes({ cookie }); // to filter nav server-side
171
+ ```
172
+
173
+ The allowlist is checked **first**, so a launched route short-circuits and
174
+ resolves no flags at all.
175
+
176
+ `unlisted` has no default, because it is the whole safety posture and the two
177
+ answers fail very differently:
178
+
179
+ - `"deny"` — a route added tomorrow is hidden until someone lists it. It 404s
180
+ loudly, and safely. Right for an app with unlaunched surfaces in the tree.
181
+ - `"allow"` — a new route ships the moment it exists, and forgetting to flag
182
+ work in progress leaks it silently.
183
+
184
+ A flag that is **off** denies its paths under either posture; `unlisted` only
185
+ decides paths nothing has an opinion about.
186
+
187
+ **It throws at construction** if a prefix is both allowed and flagged. That flag
188
+ could never change anything — the allowlist would serve the path regardless — and
189
+ a dead launch gate is precisely the bug you cannot afford to discover late.
190
+
191
+ `matchesPrefix(pathname, prefixes)` is exported standalone for client-side nav
192
+ filtering, so the menu can't disagree with the router.
193
+
194
+ ## Storage-backed flags
195
+
196
+ For projects that want to flip a flag per tenant without a deploy. `subjectStore`
197
+ owns the semantics; you own the SQL — any database, any client:
198
+
199
+ ```ts
200
+ const store = subjectStore({
201
+ // One read for the whole registry. `load: async (subject, key) => …` is the
202
+ // per-flag alternative when a batch query is awkward.
203
+ loadAll: async (subject) => {
204
+ const rows = await db.overridesFor(subject); // (subject_id, flag_key, override)
205
+ return Object.fromEntries(rows.map((r) => [r.flag_key, { override: r.override, optedIn: r.opted_in }]));
206
+ },
207
+ requiresOptIn: (flag) => flag.def.selfServe === true,
208
+ });
209
+
210
+ createResolver({
211
+ flags,
212
+ sources: [
213
+ envOverride(),
214
+ store.kill, // 1. a 'disabled' row — outranks the cookie
215
+ cookieOverride({ secret }),
216
+ store.requireOptIn, // 3a. veto anything the tenant hasn't switched on
217
+ store.grant, // 3b. an 'enabled' row
218
+ envDefault({ env }),
219
+ ],
220
+ });
221
+ ```
222
+
223
+ Three sources, one storage read: they share a cache keyed on the `EvaluationContext`
224
+ object, which is per request, so a request pays one `load` per flag no matter how
225
+ many of the three are in the chain. Swap `load` for `loadAll` and the whole
226
+ registry costs one read instead of one per flag — see **Bootstrapping a client** below.
227
+
228
+ `'disabled'` and `'enabled'` are one row but two sources because they belong in
229
+ **different slots** — a per-tenant kill has to outrank a visitor cookie for the
230
+ same reason the env kill switch does, while a grant sits below it. `requireOptIn`
231
+ sits ahead of `grant` because it must be able to veto an entitlement decided by a
232
+ *later* source (a plan lookup, a stage rule) — a veto that runs after the
233
+ decision it's vetoing never fires.
234
+
235
+ Project-specific policy stays yours. Hang your own fields on a flag def (`stage`,
236
+ `tier`, `owner`) and read them in your own source — the registry keeps their real
237
+ types.
238
+
239
+ > There is no `launchgate/postgres` subpath. Once the interface is a `load`
240
+ > function, nothing Postgres-specific is left to ship.
241
+
242
+ ## Bootstrapping a client
243
+
244
+ `resolveAll(ctx)` evaluates the whole registry at once — LaunchDarkly's
245
+ `allFlagsState()` pattern. Resolve on the server, hand the map down, and the UI
246
+ never flickers because it never had to ask:
247
+
248
+ ```ts
249
+ const enabled = await gate.resolveAll(await requestContext({ targetingKey: orgId }));
250
+ // → { newPricingPage: true, network: false, renderPipeline: "v2" }
251
+ ```
252
+
253
+ Every flag shares the one context object, which is the cache key
254
+ `subjectStore` uses — so with `loadAll` the whole registry costs **one** read.
255
+
256
+ > Pass the **values**, never the registry. Flag keys name unshipped work, and a
257
+ > viewer who can't see a surface shouldn't learn its name from the payload.
258
+
259
+ ## Testing
260
+
261
+ ```ts
262
+ import { withFlags } from "@davidlee49/launchgate";
263
+
264
+ const gate = withFlags(flags, { newPricingPage: true });
265
+ ```
266
+
267
+ Answers from a fixed map, falls back for anything unset. Use it instead of
268
+ standing up real sources — flags multiply a test matrix, and that's only
269
+ tolerable if pinning a value is one line.
270
+
271
+ ## Minting an override token
272
+
273
+ ```ts
274
+ import { signOverrides } from "@davidlee49/launchgate";
275
+
276
+ const token = await signOverrides({ newPricingPage: true }, secret);
277
+ // set it as the `lg_override` cookie
278
+ ```
279
+
280
+ HMAC-SHA256 over Web Crypto, so the same build runs on Cloudflare Workers and
281
+ Node 18+. Tampered or foreign-signed tokens are silently *undecided* — hostile
282
+ input falls through the normal chain rather than pushing the flag anywhere.
283
+
284
+ ## What this deliberately isn't
285
+
286
+ - **Not experimentation.** No assignment, no exposure logging, no metrics. At the
287
+ scale this is built for, statistical inference is noise with a dashboard.
288
+ - **Not entitlements.** Those derive from a *contract*. Put billing truth in a
289
+ flag store and it has no transactional relationship to the subscription, so a
290
+ downgrade and a flag flip can disagree — and a stale-flag sweep deletes revenue
291
+ logic that looked like dead state. Supply an entitlement source instead.
292
+ - **Not a circuit breaker.** Failing over to a backup provider is a machine
293
+ decision made in seconds from observed health; as a flag, a human has to notice
294
+ the outage first. A flag may say whether the alternative path is *permitted to
295
+ exist* — it must never *choose* it.
296
+
297
+ > A flag records a decision a person made. A circuit breaker records a fact the
298
+ > system observed.
299
+
300
+ ## Next.js
301
+
302
+ ```sh
303
+ pnpm add @davidlee49/launchgate # `next` is an optional peer dependency
304
+ ```
305
+
306
+ ```ts
307
+ // app/flag-override/route.ts ← NOT app/__flags/: see the trap below
308
+ import { createOverrideRoute } from "@davidlee49/launchgate/next";
309
+ import { gate } from "@/flags";
310
+
311
+ export const { GET } = createOverrideRoute({
312
+ resolver: gate,
313
+ secret: process.env.FLAG_SECRET!,
314
+ accessToken: process.env.FLAG_ACCESS_TOKEN!, // presented as ?token=, never the signing secret
315
+ });
316
+ ```
317
+
318
+ **If your app has an auth gate, exclude this route from it.** Inside the matcher
319
+ it redirects to your login page and the cookie is never set — the mechanic
320
+ silently does nothing. Its `?token=` is its authorization, and working logged-out
321
+ is the point.
322
+
323
+ Then `GET /flag-override?token=…&flag=newHomepage&value=on` sets the cookie,
324
+ `&value=off` flips it, omitting `value` clears that flag, and `&clear=1` drops
325
+ all of them. A wrong token gets a 404, not a 401 — an unadvertised route should
326
+ stay unadvertised.
327
+
328
+ Read a flag in a page or handler:
329
+
330
+ ```ts
331
+ import { requestContext, requireFlag } from "@davidlee49/launchgate/next";
332
+
333
+ const on = await gate.resolve("newHomepage", await requestContext()); // page
334
+ const blocked = await requireFlag(gate, "network", { targetingKey: orgId }); // handler → 404
335
+ ```
336
+
337
+ ### Static rendering — measured, not guessed
338
+
339
+ `requestContext()` calls `cookies()`, and that **opts the page into dynamic
340
+ rendering**. Verified against Next 16.3.1 by reading `next build`'s route table:
341
+
342
+ | Page | How it reads the flag | Build classification |
343
+ | --- | --- | --- |
344
+ | `/hidden` | `requestContext()` in the page | **ƒ Dynamic** |
345
+ | `/home` → `/variants/v1`,`/v2` | proxy reads the cookie and rewrites | **○ Static** (all three) |
346
+
347
+ - **Already-dynamic page** (anything authenticated): just call
348
+ `requestContext()`. The dynamic cost is already paid.
349
+ - **Page that must stay static** (marketing, landing, docs): don't read the flag
350
+ in the page. Author the variants as separate routes and rewrite in `proxy.ts`:
351
+
352
+ ```ts
353
+ // proxy.ts (Next 16's rename of middleware.ts)
354
+ import { NextResponse, type NextRequest } from "next/server";
355
+ import { readOverrides } from "@davidlee49/launchgate";
356
+
357
+ export async function proxy(request: NextRequest) {
358
+ const token = request.cookies.get("lg_override")?.value;
359
+ const overrides = token ? await readOverrides(token, process.env.FLAG_SECRET!) : undefined;
360
+ const variant = overrides?.newHomepage === true ? "v2" : "v1";
361
+ return NextResponse.rewrite(new URL(`/variants/${variant}`, request.url));
362
+ }
363
+
364
+ export const config = { matcher: "/home" };
365
+ ```
366
+
367
+ There is no third option. Resolving in the proxy and forwarding a request header
368
+ doesn't help: the page then calls `headers()`, which opts it into dynamic
369
+ rendering just the same. Forwarding moves the read, not the cost.
370
+
371
+ > **Trap.** A route handler under a `_`-prefixed directory — `app/__flags/route.ts`
372
+ > — **never registers**. `_` marks a private folder in the App Router, excluded
373
+ > from routing silently, with no build error and no 404 in the route table. Mount
374
+ > the override route at a normal path.
375
+
376
+ ## Async work
377
+
378
+ Resolve at submission and **stamp the value into the job payload**. A job
379
+ enqueued Monday and run Wednesday must use Monday's value, or you get a job
380
+ that's half one behaviour and half the other — and under a deterministic workflow
381
+ engine, a replay failure. Downstream services get resolved values, not a flag
382
+ client. Records produced by gated code should carry their own provenance rather
383
+ than being re-interpreted through the current flag value.
384
+
385
+ ## Install
386
+
387
+ ```sh
388
+ pnpm add @davidlee49/launchgate
389
+ ```
390
+
391
+ Published to npm as a public package (scoped: the bare name `launchgate` is
392
+ taken on the registry). The repo is private; the tarball is the built `dist/`.
393
+
394
+ ## Releasing
395
+
396
+ 1. Bump `version` in `package.json`.
397
+ 2. `pnpm publish` — `prepublishOnly` runs `pnpm check` (typecheck, tests, build),
398
+ so an unbuilt or failing tree never ships. `dist/` is not committed; the
399
+ registry holds it.
400
+ 3. `git tag vX.Y.Z && git push origin main vX.Y.Z`.
401
+ 4. Consumers bump the version range and `pnpm install`. A published version is
402
+ immutable: a fix is a new version, never a re-tag.
403
+
404
+ ## Development
405
+
406
+ ```sh
407
+ pnpm install
408
+ pnpm check # tsc --noEmit && vitest run
409
+ ```
@@ -0,0 +1,98 @@
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
+ async function hmacKey(secret) {
19
+ const cached = keyCache.get(secret);
20
+ if (cached) return cached;
21
+ const key = await 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
+ return key;
30
+ }
31
+ async function signature(payload, secret) {
32
+ const key = await hmacKey(secret);
33
+ const signed = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
34
+ return b64urlFromBytes(new Uint8Array(signed));
35
+ }
36
+ function timingSafeEqual(a, b) {
37
+ if (a.length !== b.length) return false;
38
+ let diff = 0;
39
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
40
+ return diff === 0;
41
+ }
42
+ function isFlagValue(value) {
43
+ return typeof value === "boolean" || typeof value === "string" || typeof value === "number" && Number.isFinite(value);
44
+ }
45
+ async function signOverrides(overrides, secret) {
46
+ const payload = b64urlFromBytes(encoder.encode(JSON.stringify(overrides)));
47
+ return `${payload}.${await signature(payload, secret)}`;
48
+ }
49
+ async function readOverrides(token, secret) {
50
+ const split = token.lastIndexOf(".");
51
+ if (split < 1) return void 0;
52
+ const payload = token.slice(0, split);
53
+ const provided = token.slice(split + 1);
54
+ let expected;
55
+ try {
56
+ expected = await signature(payload, secret);
57
+ } catch {
58
+ return void 0;
59
+ }
60
+ if (!timingSafeEqual(provided, expected)) return void 0;
61
+ try {
62
+ const parsed = JSON.parse(decoder.decode(bytesFromB64url(payload)));
63
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
64
+ return void 0;
65
+ }
66
+ const overrides = {};
67
+ for (const [key, value] of Object.entries(parsed)) {
68
+ if (isFlagValue(value)) overrides[key] = value;
69
+ }
70
+ return overrides;
71
+ } catch {
72
+ return void 0;
73
+ }
74
+ }
75
+
76
+ // src/sources/cookie.ts
77
+ var DEFAULT_OVERRIDE_COOKIE = "lg_override";
78
+ function cookieOverride(options) {
79
+ const { secret } = options;
80
+ const cookieName = options.cookieName ?? DEFAULT_OVERRIDE_COOKIE;
81
+ return {
82
+ name: "cookieOverride",
83
+ async decide({ key, def }, ctx) {
84
+ if (!def.overridable) return void 0;
85
+ const token = ctx.cookie?.(cookieName);
86
+ if (!token) return void 0;
87
+ const overrides = await readOverrides(token, secret);
88
+ return overrides?.[key];
89
+ }
90
+ };
91
+ }
92
+
93
+ export {
94
+ signOverrides,
95
+ readOverrides,
96
+ DEFAULT_OVERRIDE_COOKIE,
97
+ cookieOverride
98
+ };