@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 +417 -0
- package/README.md +409 -3
- package/dist/chunk-YIOEKCH6.js +98 -0
- package/dist/index.d.ts +232 -0
- package/dist/index.js +274 -0
- package/dist/next/index.d.ts +42 -0
- package/dist/next/index.js +102 -0
- package/dist/resolver-B2cstRTq.d.ts +161 -0
- package/package.json +53 -6
package/README.md
CHANGED
|
@@ -1,3 +1,409 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
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
|
+
};
|