@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/API.md ADDED
@@ -0,0 +1,417 @@
1
+ # API reference — `@davidlee49/launchgate`
2
+
3
+ Every export, by entry point. The README explains *why* the pieces are shaped this way; this file
4
+ says exactly what each one takes and returns. Types are TypeScript as declared in `src/`.
5
+
6
+ - [`@davidlee49/launchgate`](#davidlee49launchgate) — the core: registry, resolver, sources, route gate, audit, signing, testing
7
+ - [`@davidlee49/launchgate/next`](#davidlee49launchgatenext) — the Next.js adapter (optional peer `next >= 15`)
8
+
9
+ The core imports nothing from Node: it runs unchanged on Cloudflare Workers, Node 18+, and the edge
10
+ runtime. Signing uses Web Crypto.
11
+
12
+ ---
13
+
14
+ ## `@davidlee49/launchgate`
15
+
16
+ ### Vocabulary (types)
17
+
18
+ #### `FlagValue`
19
+
20
+ ```ts
21
+ type FlagValue = boolean | string | number;
22
+ ```
23
+
24
+ What a flag resolves to. Boolean is the common case, not the only one.
25
+
26
+ #### `FlagDef<T extends FlagValue = FlagValue>`
27
+
28
+ | Field | Type | Meaning |
29
+ | --- | --- | --- |
30
+ | `fallback` | `T` | **Required.** The value when no source decides, *and* when a source throws. Per flag, never global: a work-in-progress gate fails to `false`, a kill switch on a shipped feature fails to `true`. |
31
+ | `description` | `string` | **Required.** Why the flag exists. |
32
+ | `overridable` | `boolean` | May a signed visitor cookie force this flag? Default `false`. Only `cookieOverride` reads it. |
33
+ | `byEnv` | `Record<string, T>` | Per-environment value, read by `envDefault`. |
34
+ | `retired` | `Retirement<T>` | Terminal state. Present means: resolve to `retired.value`, consult **no** source, not even `envOverride`. |
35
+ | `[projectField]` | `unknown` | Any extra fields a project wants (`stage`, `tier`, `owner`). The registry keeps their real types; a `Source` sees them as `unknown`. |
36
+
37
+ **Typing note.** `fallback: false` is widened to `boolean`; strings and numbers keep the declared
38
+ type, so `fallback: "v1" as "v1" | "v2"` gives a checked union at every call site.
39
+
40
+ #### `Retirement<T>`
41
+
42
+ | Field | Type | Meaning |
43
+ | --- | --- | --- |
44
+ | `since` | `string` | `YYYY-MM-DD`, required. The cleanup clock `auditRegistry` reads. |
45
+ | `value` | `T` | What every evaluation returns from now on — the disposition record. Not the same thing as `fallback`. |
46
+
47
+ #### `FlagRegistry`
48
+
49
+ ```ts
50
+ type FlagRegistry = Record<string, FlagDef>;
51
+ ```
52
+
53
+ #### `FlagValueOf<T extends FlagRegistry, K extends keyof T>`
54
+
55
+ The resolved type of flag `K` in registry `T` (`boolean` for boolean fallbacks, the declared type
56
+ otherwise). Internal to the typing of `resolve`; exported for consumers who type their own helpers.
57
+
58
+ #### `EvaluationContext`
59
+
60
+ | Field | Type | Meaning |
61
+ | --- | --- | --- |
62
+ | `targetingKey` | `string?` | Who is being evaluated — user id, org id, device id. Optional by design: anonymous visitors have none. |
63
+ | `cookie` | `(name: string) => string \| undefined` | Reads a request cookie. Supplied by a framework adapter (`requestContext` in `/next`). Absent means "no cookies here". |
64
+ | `[key]` | `unknown` | Anything a project-supplied source needs. |
65
+
66
+ The context **object identity** is the per-request cache key for `subjectStore`. Build one per
67
+ request and pass the same object to every `resolve` / `resolveAll` call in that request.
68
+
69
+ `Context` is a deprecated alias (renamed in 0.3.0 to match OpenFeature).
70
+
71
+ #### `Decision`
72
+
73
+ ```ts
74
+ type Decision = FlagValue | undefined;
75
+ ```
76
+
77
+ A source's answer. `undefined` means *undecided — ask the next source*. `false` is a decision.
78
+
79
+ #### `FlagMeta`
80
+
81
+ ```ts
82
+ interface FlagMeta { key: string; def: FlagDef }
83
+ ```
84
+
85
+ What a source receives: the flag's key and its definition (erased to `FlagDef`).
86
+
87
+ #### `Source`
88
+
89
+ ```ts
90
+ interface Source {
91
+ readonly name: string; // reported to onError
92
+ decide(flag: FlagMeta, ctx: EvaluationContext): Decision | Promise<Decision>;
93
+ }
94
+ ```
95
+
96
+ One link in the chain. Return a value to decide, `undefined` to pass. Throwing aborts the flag to
97
+ its `fallback` (see `createResolver`).
98
+
99
+ #### `SourceErrorInfo`
100
+
101
+ ```ts
102
+ interface SourceErrorInfo { flag: string; source: string }
103
+ ```
104
+
105
+ ---
106
+
107
+ ### Registry and resolver
108
+
109
+ #### `defineFlags(flags)`
110
+
111
+ ```ts
112
+ function defineFlags<T extends FlagRegistry>(flags: T): T
113
+ ```
114
+
115
+ Identity at runtime. Exists to infer each flag's value type so `resolve()` is typed at the call
116
+ site. The registry lives in the project that reads the flags.
117
+
118
+ #### `createResolver(options)`
119
+
120
+ ```ts
121
+ function createResolver<T extends FlagRegistry>(options: ResolverOptions<T>): Resolver<T>
122
+
123
+ interface ResolverOptions<T> {
124
+ flags: T;
125
+ sources: Source[]; // consulted in order; first definite answer wins
126
+ onError?: (error: unknown, info: SourceErrorInfo) => void;
127
+ }
128
+ ```
129
+
130
+ Resolution order for one flag:
131
+
132
+ 1. **Unknown key** → throws `Error("launchgate: unknown flag …")`. The only thing that throws; a
133
+ programming error the types prevent everywhere but an unchecked cast.
134
+ 2. **`def.retired` present** → returns `retired.value`. No source runs.
135
+ 3. Each source in order:
136
+ - throws → `onError(error, { flag, source })`, return `def.fallback`. It does **not** fall
137
+ through to the next source.
138
+ - returns `undefined` → next source.
139
+ - returns a value of the wrong type (`typeof` differs from `fallback`) → `onError` with a
140
+ `TypeError`, return `def.fallback`.
141
+ - returns a value → that is the answer.
142
+ 4. Nothing decided → `def.fallback`.
143
+
144
+ Recommended source order: `envOverride`, `cookieOverride`, project sources (grants, opt-in,
145
+ entitlement), `envDefault`.
146
+
147
+ #### `Resolver<T>`
148
+
149
+ ```ts
150
+ interface Resolver<T extends FlagRegistry> {
151
+ resolve<K extends keyof T & string>(key: K, ctx?: EvaluationContext): Promise<FlagValueOf<T, K>>;
152
+ resolveAll(ctx?: EvaluationContext): Promise<ResolvedFlags<T>>;
153
+ readonly flags: T;
154
+ }
155
+
156
+ type ResolvedFlags<T> = { [K in keyof T]: FlagValueOf<T, K> };
157
+ ```
158
+
159
+ - `resolve(key, ctx)` — one flag. `ctx` defaults to `{}`.
160
+ - `resolveAll(ctx)` — every flag, concurrently, sharing the one `ctx` object (so a `subjectStore`
161
+ with `loadAll` costs one read for the whole registry). Hand the **values** to a client, never
162
+ the registry: flag keys name unshipped work.
163
+ - `flags` — the registry, for introspection (the override route uses it).
164
+
165
+ ---
166
+
167
+ ### Built-in sources
168
+
169
+ All four give up the registry's inferred types and work on `FlagDef` directly.
170
+
171
+ #### `envOverride(options?)`
172
+
173
+ ```ts
174
+ function envOverride(options?: { read?: (name: string) => string | undefined; prefix?: string }): Source
175
+ ```
176
+
177
+ Slot 1, the operator override and kill switch. Reads `${prefix}${NAME}` where `prefix` defaults to
178
+ `FLAG_` and `NAME` is the flag key in SCREAMING_SNAKE (`newHomepage` → `NEW_HOMEPAGE`; kebab and
179
+ snake keys are tolerated). `read` defaults to `process.env` where it exists (reached through
180
+ `globalThis`, so Workers without `process` work too).
181
+
182
+ Parsing follows the flag's `fallback` type:
183
+
184
+ | Flag type | Accepted | Result |
185
+ | --- | --- | --- |
186
+ | boolean | `on` `true` `1` `yes` / `off` `false` `0` `no` (case-insensitive) | `true` / `false` |
187
+ | number | anything `Number()` makes finite | the number |
188
+ | string | anything | trimmed string |
189
+
190
+ An unset or empty variable is *undecided*. An **unparseable** value throws, which aborts to the
191
+ flag's `fallback` and reports through `onError`: an operator typo is loud. Source name:
192
+ `"envOverride"`.
193
+
194
+ #### `envDefault(options)`
195
+
196
+ ```ts
197
+ function envDefault(options: { env: string | (() => string) }): Source
198
+ ```
199
+
200
+ Slot 4. Answers `def.byEnv[env]`, or *undecided* when the flag has no entry for the current
201
+ environment. A preview deployment is just a different `env`. Source name: `"envDefault"`.
202
+
203
+ #### `cookieOverride(options)`
204
+
205
+ ```ts
206
+ function cookieOverride(options: { secret: string; cookieName?: string }): Source
207
+ const DEFAULT_OVERRIDE_COOKIE = "lg_override";
208
+ ```
209
+
210
+ Slot 2, the signed per-visitor override. Reads `ctx.cookie(cookieName)`, verifies the HMAC with
211
+ `secret` (see `readOverrides`), and answers the flag's entry in the token.
212
+
213
+ - Honours **only** flags with `overridable: true`; any other flag is *undecided* here.
214
+ - A missing, malformed, tampered, or foreign-signed token is *undecided*, never an error.
215
+ - Source name: `"cookieOverride"`.
216
+
217
+ #### `subjectStore(options)`
218
+
219
+ ```ts
220
+ function subjectStore(options: SubjectStoreOptions): SubjectStore
221
+
222
+ interface SubjectStoreOptions {
223
+ load?: (subject: string, key: string, flag: FlagMeta) => SubjectState | undefined | Promise<SubjectState | undefined>;
224
+ loadAll?: (subject: string) => Map<string, SubjectState> | Record<string, SubjectState> | Promise<…>;
225
+ requiresOptIn?: (flag: FlagMeta) => boolean; // default: no flag requires opt-in
226
+ }
227
+
228
+ interface SubjectState {
229
+ override?: "enabled" | "disabled";
230
+ value?: FlagValue; // grant a specific value, for value-typed flags
231
+ optedIn?: boolean;
232
+ [projectField: string]: unknown;
233
+ }
234
+
235
+ interface SubjectStore {
236
+ kill: Source; // slot 1 — a 'disabled' row → the flag's off-value
237
+ requireOptIn: Source; // slot 3a — flag requires opt-in and the subject hasn't → off-value
238
+ grant: Source; // slot 3b — an 'enabled' row → `value`, else `true` for boolean flags
239
+ state(flag: FlagMeta, ctx: EvaluationContext): Promise<SubjectState | undefined>;
240
+ }
241
+ ```
242
+
243
+ Three sources over one storage read. You own the SQL; the library owns the semantics.
244
+
245
+ - Throws at construction if neither `load` nor `loadAll` is given. `loadAll` is preferred when
246
+ both are present.
247
+ - Every source is *undecided* when `ctx.targetingKey` is absent.
248
+ - Reads are cached per **context object** (a `WeakMap` keyed on `ctx`): per subject with
249
+ `loadAll`, per subject + flag with `load`. One request, one read, however many of the three
250
+ sources are in the chain.
251
+ - The off-value is `false` for boolean flags and the `fallback` for everything else.
252
+ - `grant` on a non-boolean flag with no `value` is *undecided*.
253
+ - `state(flag, ctx)` exposes the same cached read to a project's own policy source.
254
+ - Source names: `"subjectKill"`, `"subjectOptIn"`, `"subjectGrant"`.
255
+
256
+ ---
257
+
258
+ ### Route gating
259
+
260
+ #### `createRouteGate(options)`
261
+
262
+ ```ts
263
+ function createRouteGate<T extends FlagRegistry>(options: RouteGateOptions<T>): RouteGate
264
+
265
+ interface RouteGateOptions<T> {
266
+ resolver: Resolver<T>;
267
+ routes: Partial<Record<keyof T & string, readonly string[]>>; // flag → prefixes it reveals
268
+ allow?: readonly string[]; // served regardless of flags
269
+ unlisted: "deny" | "allow"; // required, no default
270
+ }
271
+
272
+ interface RouteGate {
273
+ isPermitted(pathname: string, ctx?: EvaluationContext): Promise<boolean>;
274
+ revealedPrefixes(ctx?: EvaluationContext): Promise<string[]>; // prefixes whose flag is on
275
+ permittedPrefixes(ctx?: EvaluationContext): Promise<string[]>; // allow + revealed
276
+ }
277
+ ```
278
+
279
+ `isPermitted` decides in this order: on the allowlist → `true` (no flag resolved); under a
280
+ prefix whose flag is on → `true`; under a prefix whose flag is off → `false` regardless of
281
+ `unlisted`; otherwise `unlisted === "allow"`.
282
+
283
+ **Throws at construction** when a prefix appears in both `allow` and a flag's routes (the flag
284
+ would be dead), or when `routes` names a flag the resolver does not have.
285
+
286
+ #### `matchesPrefix(pathname, prefixes)`
287
+
288
+ ```ts
289
+ function matchesPrefix(pathname: string, prefixes: readonly string[]): boolean
290
+ ```
291
+
292
+ Exact match or a path segment below it (`/a` matches `/a` and `/a/b`, not `/ab`). `"/"` matches
293
+ only `"/"`. Exported for client-side nav filtering so the menu and the router agree.
294
+
295
+ ---
296
+
297
+ ### Registry hygiene
298
+
299
+ #### `auditRegistry(flags, options?)`
300
+
301
+ ```ts
302
+ function auditRegistry(flags: FlagRegistry, options?: { today?: Date; retiredAfterDays?: number }): AuditReport
303
+
304
+ interface AuditReport { problems: string[]; warnings: string[] }
305
+ ```
306
+
307
+ Audits what the library owns: retirements. Defaults: `today = new Date()`, `retiredAfterDays = 40`.
308
+
309
+ | Finding | Kind |
310
+ | --- | --- |
311
+ | `retired.value` has a different type from `fallback` | problem |
312
+ | `retired.since` is not `YYYY-MM-DD` | problem (and the age check is skipped) |
313
+ | retired flag still declares `overridable` | warning |
314
+ | retired for more than `retiredAfterDays` | warning |
315
+
316
+ Fail the build on `problems`; print `warnings` every run. Merge with your own lifecycle checks.
317
+
318
+ ---
319
+
320
+ ### Signing
321
+
322
+ #### `signOverrides(overrides, secret)`
323
+
324
+ ```ts
325
+ function signOverrides(overrides: Record<string, FlagValue>, secret: string): Promise<string>
326
+ ```
327
+
328
+ Mints the token `cookieOverride` reads: `base64url(JSON) + "." + base64url(HMAC-SHA256)`.
329
+
330
+ #### `readOverrides(token, secret)`
331
+
332
+ ```ts
333
+ function readOverrides(token: string, secret: string): Promise<Record<string, FlagValue> | undefined>
334
+ ```
335
+
336
+ Verifies (timing-safe) and parses. `undefined` for anything not genuinely signed, not an object,
337
+ or not parseable; non-`FlagValue` entries are dropped. Use it directly in a Next `proxy.ts` to
338
+ pick a static variant without opting the page into dynamic rendering.
339
+
340
+ ---
341
+
342
+ ### Testing
343
+
344
+ #### `withFlags(flags, values?)`
345
+
346
+ ```ts
347
+ function withFlags<T extends FlagRegistry>(flags: T, values?: FlagValues<T>): Resolver<T>
348
+ type FlagValues<T> = Partial<{ [K in keyof T]: FlagValueOf<T, K> }>
349
+ ```
350
+
351
+ A resolver with one source that answers from `values`; anything unset falls back to the flag's
352
+ declared `fallback`. Retirement still applies.
353
+
354
+ ---
355
+
356
+ ## `@davidlee49/launchgate/next`
357
+
358
+ Requires `next >= 15`. Everything here is cookie plumbing over the core.
359
+
360
+ #### `requestContext(extra?)`
361
+
362
+ ```ts
363
+ function requestContext(extra?: EvaluationContext): Promise<EvaluationContext>
364
+ ```
365
+
366
+ Returns `{ ...extra, cookie }` where `cookie` reads the incoming request's cookies via
367
+ `next/headers`. **Opts the caller into dynamic rendering** (`cookies()` always does). Fine on
368
+ already-dynamic pages; on a page that must stay static, use `readOverrides` in `proxy.ts` instead.
369
+
370
+ #### `requireFlag(resolver, key, ctx?)`
371
+
372
+ ```ts
373
+ function requireFlag<T, K extends keyof T & string>(
374
+ resolver: Resolver<T>, key: K, ctx?: EvaluationContext,
375
+ ): Promise<NextResponse | null>
376
+ ```
377
+
378
+ Route-handler guard. Returns a `404 { error: "Not found" }` response when the flag resolves to
379
+ `false` or `undefined`, else `null`. 404 rather than 403: a feature you don't have should not
380
+ advertise its existence.
381
+
382
+ ```ts
383
+ const blocked = await requireFlag(gate, "network", { targetingKey: orgId });
384
+ if (blocked) return blocked;
385
+ ```
386
+
387
+ #### `createOverrideRoute(options)`
388
+
389
+ ```ts
390
+ function createOverrideRoute<T extends FlagRegistry>(options: OverrideRouteOptions<T>): { GET: (request: Request) => Promise<NextResponse> }
391
+
392
+ interface OverrideRouteOptions<T> {
393
+ resolver: Resolver<T>;
394
+ secret: string; // HMAC secret — the same one cookieOverride has; never in a URL
395
+ accessToken: string; // presented as ?token= ; must differ from `secret`
396
+ cookieName?: string; // default "lg_override"
397
+ maxAge?: number; // cookie lifetime in seconds; default 30 days
398
+ }
399
+ ```
400
+
401
+ A `GET` handler that sets the signed override cookie so you can see hidden work on the live site
402
+ with no account. Mount it at an unadvertised path **outside** your auth gate, and never under a
403
+ `_`-prefixed directory (the App Router silently drops those).
404
+
405
+ Query parameters:
406
+
407
+ | Query | Effect |
408
+ | --- | --- |
409
+ | `token=…` | Required. Compared timing-safe to `accessToken`; wrong → `404`. |
410
+ | `flag=KEY&value=V` | Set `KEY` to `V` (parsed by the flag's type: booleans accept `on/true/1` and `off/false/0`). |
411
+ | `flag=KEY` (no `value`) | Clear that one override. |
412
+ | `clear=1` | Drop every override. |
413
+
414
+ Errors are `400` with `{ error }`: missing `flag`, unknown flag, flag not `overridable`, value
415
+ not parseable for the flag's type. The response body is `{ overrides }` — the full map now in the
416
+ cookie. An empty map deletes the cookie; otherwise it is set `httpOnly`, `sameSite: lax`,
417
+ `secure` on https, `path: /`.