@davidlee49/launchgate 0.5.0 → 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 +417 -0
- package/README.md +2 -0
- package/dist/{chunk-34MIJ7KP.js → chunk-YIOEKCH6.js} +11 -12
- package/dist/index.js +1 -1
- package/dist/next/index.js +1 -1
- package/package.json +53 -50
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: /`.
|
package/README.md
CHANGED
|
@@ -5,6 +5,8 @@ feature without a redeploy. No platform, no service, no database required.
|
|
|
5
5
|
|
|
6
6
|
Design of record: [adr/0001-launch-gate-design.md](adr/0001-launch-gate-design.md).
|
|
7
7
|
|
|
8
|
+
API reference, every export with its signature: [API.md](API.md).
|
|
9
|
+
|
|
8
10
|
```ts
|
|
9
11
|
import { defineFlags, createResolver, envOverride, cookieOverride, envDefault } from "@davidlee49/launchgate";
|
|
10
12
|
|
|
@@ -15,18 +15,17 @@ function bytesFromB64url(value) {
|
|
|
15
15
|
return bytes;
|
|
16
16
|
}
|
|
17
17
|
var keyCache = /* @__PURE__ */ new Map();
|
|
18
|
-
function hmacKey(secret) {
|
|
19
|
-
|
|
20
|
-
if (
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
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);
|
|
30
29
|
return key;
|
|
31
30
|
}
|
|
32
31
|
async function signature(payload, secret) {
|
package/dist/index.js
CHANGED
package/dist/next/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,50 +1,53 @@
|
|
|
1
|
-
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@davidlee49/launchgate",
|
|
3
|
+
"version": "0.5.1",
|
|
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
|
+
"API.md"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./next": {
|
|
17
|
+
"types": "./dist/next/index.d.ts",
|
|
18
|
+
"import": "./dist/next/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsup",
|
|
23
|
+
"check": "tsc --noEmit && vitest run && tsup",
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"test:watch": "vitest",
|
|
26
|
+
"prepublishOnly": "pnpm check"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"tsup": "^8.5.0",
|
|
30
|
+
"typescript": "^5.9.2",
|
|
31
|
+
"vitest": "^3.2.4",
|
|
32
|
+
"next": "^16.0.0",
|
|
33
|
+
"react": "^19.0.0",
|
|
34
|
+
"react-dom": "^19.0.0",
|
|
35
|
+
"@types/react": "^19.0.0"
|
|
36
|
+
},
|
|
37
|
+
"packageManager": "pnpm@10.29.2",
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"next": ">=15"
|
|
40
|
+
},
|
|
41
|
+
"peerDependenciesMeta": {
|
|
42
|
+
"next": {
|
|
43
|
+
"optional": true
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "git+https://github.com/davidlee49/launchgate.git"
|
|
49
|
+
},
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public"
|
|
52
|
+
}
|
|
53
|
+
}
|