@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/dist/index.d.ts
ADDED
|
@@ -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-YIOEKCH6.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-YIOEKCH6.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
|
+
};
|