@flaghoist/server 0.3.1 → 0.4.0

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/dist/index.js CHANGED
@@ -1,433 +1,1157 @@
1
1
  // src/index.ts
2
- import { evaluate } from "@flaghoist/core";
2
+ import {
3
+ auditCategory as auditCategory2,
4
+ evaluate,
5
+ WEBHOOK_EVENTS as WEBHOOK_EVENTS2
6
+ } from "@flaghoist/core";
3
7
  import { Hono } from "hono";
4
8
 
5
- // src/cache.ts
6
- function createDefinitionCache() {
7
- let entry = null;
8
- return {
9
- async load(storage, ttlMs2) {
10
- const now = Date.now();
11
- if (entry && entry.expiresAt > now) return entry.flags;
12
- const flags = await storage.list();
13
- entry = { flags, expiresAt: now + ttlMs2 };
14
- return flags;
15
- },
16
- invalidate() {
17
- entry = null;
9
+ // src/permissions.ts
10
+ var ROLES = ["viewer", "editor", "admin", "owner"];
11
+ var MINIMUM_ROLE = {
12
+ "flags:read": "viewer",
13
+ "audit:read": "viewer",
14
+ "flags:write": "editor",
15
+ "flags:delete": "admin",
16
+ "flags:import": "admin",
17
+ "audit:security": "admin",
18
+ "webhooks:manage": "admin",
19
+ "members:manage": "admin"
20
+ };
21
+ function isRole(value) {
22
+ return typeof value === "string" && ROLES.includes(value);
23
+ }
24
+ function minimumRole(permission) {
25
+ return MINIMUM_ROLE[permission];
26
+ }
27
+ function can(role, permission) {
28
+ if (!isRole(role)) return false;
29
+ return ROLES.indexOf(role) >= ROLES.indexOf(MINIMUM_ROLE[permission]);
30
+ }
31
+ var ENVIRONMENT_PERMISSIONS = [
32
+ "flags:read",
33
+ "flags:write",
34
+ "flags:delete",
35
+ "flags:import",
36
+ "audit:read"
37
+ ];
38
+ function isEnvironmentPermission(permission) {
39
+ return ENVIRONMENT_PERMISSIONS.includes(permission);
40
+ }
41
+ function lowerRole(a, b) {
42
+ return ROLES.indexOf(a) <= ROLES.indexOf(b) ? a : b;
43
+ }
44
+
45
+ // src/sealed.ts
46
+ var encoder = new TextEncoder();
47
+ var sealKeys = /* @__PURE__ */ new Map();
48
+ function sealKey(pepper) {
49
+ let key = sealKeys.get(pepper);
50
+ if (!key) {
51
+ key = (async () => {
52
+ const material = await crypto.subtle.importKey("raw", encoder.encode(pepper), "HKDF", false, [
53
+ "deriveKey"
54
+ ]);
55
+ return crypto.subtle.deriveKey(
56
+ {
57
+ name: "HKDF",
58
+ hash: "SHA-256",
59
+ salt: new Uint8Array(0),
60
+ info: encoder.encode("flaghoist sso seal v1")
61
+ },
62
+ material,
63
+ { name: "AES-GCM", length: 256 },
64
+ false,
65
+ ["encrypt", "decrypt"]
66
+ );
67
+ })();
68
+ sealKeys.set(pepper, key);
69
+ }
70
+ return key;
71
+ }
72
+ async function seal(pepper, purpose, value) {
73
+ const iv = crypto.getRandomValues(new Uint8Array(12));
74
+ const plain = encoder.encode(JSON.stringify({ ...value, purpose }));
75
+ const cipher = new Uint8Array(
76
+ await crypto.subtle.encrypt({ name: "AES-GCM", iv }, await sealKey(pepper), plain)
77
+ );
78
+ const out = new Uint8Array(iv.length + cipher.length);
79
+ out.set(iv);
80
+ out.set(cipher, iv.length);
81
+ return toBase64Url(out);
82
+ }
83
+ async function unseal(pepper, purpose, sealed) {
84
+ const bytes = fromBase64Url(sealed);
85
+ if (!bytes || bytes.length < 29) return null;
86
+ try {
87
+ const plain = await crypto.subtle.decrypt(
88
+ { name: "AES-GCM", iv: bytes.slice(0, 12) },
89
+ await sealKey(pepper),
90
+ bytes.slice(12)
91
+ );
92
+ const value = JSON.parse(new TextDecoder().decode(plain));
93
+ if (value.purpose !== purpose || typeof value.exp !== "number" || Date.now() > value.exp) {
94
+ return null;
18
95
  }
19
- };
96
+ return value;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+ async function sha256Url(text) {
102
+ return toBase64Url(new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(text))));
20
103
  }
21
104
 
22
- // src/flags.ts
23
- import { clampPercentage, LIMITS, parseFlag } from "@flaghoist/core";
24
- function buildFlag(key, body, identity, existing) {
25
- if (typeof body !== "object" || body === null) {
26
- return { ok: false, error: "Request body must be a JSON object" };
105
+ // src/sso.ts
106
+ import { createLocalJWKSet, errors as joseErrors, jwtVerify } from "jose";
107
+ var SSO_CALLBACK_PATH = "/api/v1/auth/sso/callback";
108
+ var STATE_TTL_MS = 10 * 6e4;
109
+ var EXCHANGE_TTL_MS = 6e4;
110
+ var DISCOVERY_TTL_MS = 60 * 6e4;
111
+ var JWKS_TTL_MS = 10 * 6e4;
112
+ var ALGORITHMS = ["RS256", "RS384", "RS512", "PS256", "ES256", "ES384", "EdDSA"];
113
+ function assertSsoConfig(sso) {
114
+ const problems = [];
115
+ try {
116
+ const url = new URL(sso.issuer);
117
+ if (url.protocol !== "https:" && url.hostname !== "localhost")
118
+ problems.push("issuer must use https");
119
+ } catch {
120
+ problems.push("issuer must be a URL");
27
121
  }
28
- const b = body;
29
- const enabled = typeof b.enabled === "boolean" ? b.enabled : false;
30
- const rollout = typeof b.rollout === "object" && b.rollout !== null ? b.rollout : {};
31
- const percentage = typeof rollout.percentage === "number" ? clampPercentage(rollout.percentage) : 0;
32
- const description = typeof b.description === "string" ? b.description : "";
33
- if (description.length > LIMITS.maxDescriptionLength) {
34
- return { ok: false, error: `Description exceeds ${LIMITS.maxDescriptionLength} characters` };
122
+ if (typeof sso.clientId !== "string" || !sso.clientId) problems.push("clientId is required");
123
+ for (const [group, role] of Object.entries(sso.roleMapping ?? {})) {
124
+ if (!isRole(role)) problems.push(`roleMapping["${group}"] is not a role`);
35
125
  }
36
- const inputRules = Array.isArray(b.rules) ? b.rules : [];
37
- const nowMs = Date.now();
38
- const prevMs = existing ? Date.parse(existing.metadata.updatedAt) : 0;
39
- const updatedAt = new Date(nowMs > prevMs ? nowMs : prevMs + 1).toISOString();
40
- const createdAt = existing?.metadata.createdAt ?? new Date(nowMs).toISOString();
41
- const candidate = {
42
- key,
43
- enabled,
44
- rollout: { percentage },
45
- rules: inputRules,
46
- description,
47
- metadata: {
48
- createdBy: existing?.metadata.createdBy ?? identity,
49
- createdAt,
50
- updatedBy: identity,
51
- updatedAt
126
+ if (sso.defaultRole !== void 0 && !isRole(sso.defaultRole)) {
127
+ problems.push("defaultRole is not a role");
128
+ }
129
+ if (problems.length > 0) {
130
+ throw new Error(`[flaghoist] users.sso: ${problems.join("; ")}.`);
131
+ }
132
+ }
133
+ function randomUrl(bytes = 32) {
134
+ return toBase64Url(crypto.getRandomValues(new Uint8Array(bytes)));
135
+ }
136
+ var discoveries = /* @__PURE__ */ new Map();
137
+ var keySets = /* @__PURE__ */ new Map();
138
+ async function fetchJson(url, what) {
139
+ const res = await fetch(url, {
140
+ headers: { accept: "application/json" },
141
+ signal: AbortSignal.timeout(1e4)
142
+ });
143
+ if (!res.ok) throw new SsoError(`Could not load the provider's ${what} (${res.status}).`);
144
+ return res.json();
145
+ }
146
+ function discover(issuer) {
147
+ const cached = discoveries.get(issuer);
148
+ if (cached && Date.now() - cached.at < DISCOVERY_TTL_MS) return cached.value;
149
+ const url = `${issuer.replace(/\/+$/, "")}/.well-known/openid-configuration`;
150
+ const value = fetchJson(url, "configuration").then((body) => {
151
+ const d = body;
152
+ if (!d.authorization_endpoint || !d.token_endpoint || !d.jwks_uri) {
153
+ throw new SsoError("The provider's configuration is missing its endpoints.");
52
154
  }
155
+ return d;
156
+ });
157
+ value.catch(() => discoveries.delete(issuer));
158
+ discoveries.set(issuer, { at: Date.now(), value });
159
+ return value;
160
+ }
161
+ function keySet(uri, refresh = false) {
162
+ const cached = keySets.get(uri);
163
+ if (!refresh && cached && Date.now() - cached.at < JWKS_TTL_MS) return cached.value;
164
+ const value = fetchJson(uri, "signing keys");
165
+ value.catch(() => keySets.delete(uri));
166
+ keySets.set(uri, { at: Date.now(), value });
167
+ return value;
168
+ }
169
+ var SsoError = class extends Error {
170
+ };
171
+ async function buildAuthorizationUrl(input) {
172
+ const d = await discover(input.sso.issuer);
173
+ const verifier = randomUrl();
174
+ const nonce = randomUrl(16);
175
+ const state = await seal(input.pepper, "sso-state", {
176
+ exp: Date.now() + STATE_TTL_MS,
177
+ verifier,
178
+ nonce,
179
+ browser: input.browserHash,
180
+ returnTo: input.returnTo,
181
+ redirectUri: input.redirectUri
182
+ });
183
+ const url = new URL(d.authorization_endpoint);
184
+ url.searchParams.set("response_type", "code");
185
+ url.searchParams.set("client_id", input.sso.clientId);
186
+ url.searchParams.set("redirect_uri", input.redirectUri);
187
+ url.searchParams.set("scope", (input.sso.scopes ?? ["openid", "email", "profile"]).join(" "));
188
+ url.searchParams.set("state", state);
189
+ url.searchParams.set("nonce", nonce);
190
+ url.searchParams.set("code_challenge", await sha256Url(verifier));
191
+ url.searchParams.set("code_challenge_method", "S256");
192
+ return url.toString();
193
+ }
194
+ function readState(pepper, state) {
195
+ return unseal(pepper, "sso-state", state);
196
+ }
197
+ async function completeSignIn(input) {
198
+ const { sso, state } = input;
199
+ const d = await discover(sso.issuer);
200
+ const form = new URLSearchParams({
201
+ grant_type: "authorization_code",
202
+ code: input.code,
203
+ redirect_uri: state.redirectUri,
204
+ client_id: sso.clientId,
205
+ code_verifier: state.verifier
206
+ });
207
+ const headers = {
208
+ "content-type": "application/x-www-form-urlencoded",
209
+ accept: "application/json"
53
210
  };
54
- const validated = parseFlag(candidate);
55
- if (!validated) return { ok: false, error: "Invalid flag definition" };
56
- if ((validated.rules?.length ?? 0) !== inputRules.length) {
57
- return { ok: false, error: "One or more targeting rules are invalid" };
211
+ if (sso.clientSecret) {
212
+ const basic = `${encodeURIComponent(sso.clientId)}:${encodeURIComponent(sso.clientSecret)}`;
213
+ headers.authorization = `Basic ${btoa(basic)}`;
58
214
  }
59
- return { ok: true, flag: validated };
215
+ const res = await fetch(d.token_endpoint, {
216
+ method: "POST",
217
+ headers,
218
+ body: form,
219
+ signal: AbortSignal.timeout(1e4)
220
+ });
221
+ const body = await res.json().catch(() => ({}));
222
+ if (!res.ok || typeof body.id_token !== "string") {
223
+ throw new SsoError(
224
+ `The provider did not complete the sign-in${typeof body.error === "string" ? ` (${body.error})` : ""}.`
225
+ );
226
+ }
227
+ const supported = d.id_token_signing_alg_values_supported;
228
+ const algorithms = supported ? ALGORITHMS.filter((a) => supported.includes(a)) : ALGORITHMS;
229
+ const verify = async (refresh) => jwtVerify(body.id_token, createLocalJWKSet(await keySet(d.jwks_uri, refresh)), {
230
+ issuer: sso.issuer,
231
+ audience: sso.clientId,
232
+ algorithms
233
+ });
234
+ let payload;
235
+ try {
236
+ payload = (await verify(false)).payload;
237
+ } catch (err) {
238
+ if (!(err instanceof joseErrors.JWKSNoMatchingKey)) {
239
+ throw new SsoError("The sign-in could not be verified.");
240
+ }
241
+ try {
242
+ payload = (await verify(true)).payload;
243
+ } catch {
244
+ throw new SsoError("The sign-in could not be verified.");
245
+ }
246
+ }
247
+ if (payload.nonce !== state.nonce) throw new SsoError("The sign-in could not be verified.");
248
+ const email = typeof payload.email === "string" ? payload.email.trim().toLowerCase() : "";
249
+ if (!email || typeof payload.sub !== "string") {
250
+ throw new SsoError("The provider did not share an email address. Check the email scope.");
251
+ }
252
+ const claim = payload[sso.groupsClaim ?? "groups"];
253
+ const groups = Array.isArray(claim) ? claim.filter((g) => typeof g === "string") : typeof claim === "string" ? claim.split(/[,\s]+/).filter(Boolean) : [];
254
+ const name = typeof payload.name === "string" ? payload.name : [payload.given_name, payload.family_name].filter((p) => typeof p === "string").join(" ");
255
+ return {
256
+ subject: payload.sub,
257
+ email,
258
+ emailVerified: payload.email_verified === true || payload.email_verified === "true",
259
+ name,
260
+ groups
261
+ };
60
262
  }
61
- function flagEtag(flag) {
62
- return `"${flag.metadata.updatedAt}"`;
263
+ function roleFor(sso, groups) {
264
+ let best = -1;
265
+ for (const group of groups) {
266
+ const role = sso.roleMapping?.[group];
267
+ if (role && ROLES.indexOf(role) > best) best = ROLES.indexOf(role);
268
+ }
269
+ if (best >= 0) return ROLES[best];
270
+ return sso.defaultRole ?? null;
271
+ }
272
+ function domainAllowed(sso, email) {
273
+ if (!sso.allowedDomains || sso.allowedDomains.length === 0) return true;
274
+ const domain = email.slice(email.lastIndexOf("@") + 1);
275
+ return sso.allowedDomains.some((d) => d.toLowerCase() === domain);
276
+ }
277
+ function exchangeCode(pepper, userId, browserHash) {
278
+ return seal(pepper, "sso-exchange", {
279
+ exp: Date.now() + EXCHANGE_TTL_MS,
280
+ userId,
281
+ browser: browserHash
282
+ });
283
+ }
284
+ async function redeemExchangeCode(pepper, code, browserSecret) {
285
+ const value = await unseal(pepper, "sso-exchange", code);
286
+ if (!value || await sha256Url(browserSecret) !== value.browser) return null;
287
+ return value.userId;
63
288
  }
64
289
 
65
- // src/openapi.ts
66
- var openApiDocument = {
67
- openapi: "3.1.0",
68
- info: {
69
- title: "Flaghoist API",
70
- version: "0.1.0",
71
- description: "Manage and evaluate feature flags. The admin API (`/api/v1/flags`) is Flaghoist's own; the read API (`/ofrep/v1`) follows the OpenFeature Remote Evaluation Protocol (OFREP). The unversioned `/flags` paths remain as a legacy alias of `/api/v1/flags`.",
72
- license: { name: "Apache-2.0", url: "https://www.apache.org/licenses/LICENSE-2.0" }
73
- },
74
- servers: [{ url: "/", description: "This Flaghoist server" }],
75
- tags: [
76
- { name: "admin", description: "Manage flag definitions (admin auth)." },
77
- {
78
- name: "evaluate",
79
- description: "Evaluate flags for a context (OFREP read path, API-key auth)."
290
+ // src/totp.ts
291
+ var TOTP_PERIOD_SECONDS = 30;
292
+ var TOTP_DIGITS = 6;
293
+ var SECRET_BYTES = 20;
294
+ var DRIFT_STEPS = 1;
295
+ var BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
296
+ function base32Encode(bytes) {
297
+ let bits = 0;
298
+ let value = 0;
299
+ let out = "";
300
+ for (const byte of bytes) {
301
+ value = value << 8 | byte;
302
+ bits += 8;
303
+ while (bits >= 5) {
304
+ out += BASE32[value >>> bits - 5 & 31];
305
+ bits -= 5;
306
+ }
307
+ }
308
+ if (bits > 0) out += BASE32[value << 5 - bits & 31];
309
+ return out;
310
+ }
311
+ function base32Decode(text) {
312
+ const clean = text.toUpperCase().replace(/[\s=-]/g, "");
313
+ let bits = 0;
314
+ let value = 0;
315
+ const out = [];
316
+ for (const ch of clean) {
317
+ const index = BASE32.indexOf(ch);
318
+ if (index < 0) return null;
319
+ value = value << 5 | index;
320
+ bits += 5;
321
+ if (bits >= 8) {
322
+ out.push(value >>> bits - 8 & 255);
323
+ bits -= 8;
324
+ }
325
+ }
326
+ return new Uint8Array(out);
327
+ }
328
+ function newTotpSecret() {
329
+ return base32Encode(crypto.getRandomValues(new Uint8Array(SECRET_BYTES)));
330
+ }
331
+ function currentStep(now = Date.now()) {
332
+ return Math.floor(now / 1e3 / TOTP_PERIOD_SECONDS);
333
+ }
334
+ async function totpAt(secret, step) {
335
+ const key = base32Decode(secret);
336
+ if (!key) throw new Error("Invalid two-factor secret");
337
+ const counter = new Uint8Array(8);
338
+ let rest = step;
339
+ for (let i = 7; i >= 0; i--) {
340
+ counter[i] = rest & 255;
341
+ rest = Math.floor(rest / 256);
342
+ }
343
+ const hmacKey = await crypto.subtle.importKey(
344
+ "raw",
345
+ key,
346
+ { name: "HMAC", hash: "SHA-1" },
347
+ false,
348
+ ["sign"]
349
+ );
350
+ const mac = new Uint8Array(await crypto.subtle.sign("HMAC", hmacKey, counter));
351
+ const offset = mac[mac.length - 1] & 15;
352
+ const binary = (mac[offset] & 127) << 24 | mac[offset + 1] << 16 | mac[offset + 2] << 8 | mac[offset + 3];
353
+ return String(binary % 10 ** TOTP_DIGITS).padStart(TOTP_DIGITS, "0");
354
+ }
355
+ async function matchTotp(secret, code, now = Date.now()) {
356
+ const digits = code.replace(/\s/g, "");
357
+ if (!/^\d{6}$/.test(digits)) return null;
358
+ const step = currentStep(now);
359
+ for (let d = -DRIFT_STEPS; d <= DRIFT_STEPS; d++) {
360
+ if (await totpAt(secret, step + d) === digits) return step + d;
361
+ }
362
+ return null;
363
+ }
364
+ function otpauthUri(secret, account, issuer = "Flaghoist") {
365
+ const label = encodeURIComponent(`${issuer}:${account}`);
366
+ const params = new URLSearchParams({
367
+ secret,
368
+ issuer,
369
+ algorithm: "SHA1",
370
+ digits: String(TOTP_DIGITS),
371
+ period: String(TOTP_PERIOD_SECONDS)
372
+ });
373
+ return `otpauth://totp/${label}?${params}`;
374
+ }
375
+ var RECOVERY_CODE_COUNT = 10;
376
+ function newRecoveryCodes() {
377
+ return Array.from({ length: RECOVERY_CODE_COUNT }, () => {
378
+ const raw = base32Encode(crypto.getRandomValues(new Uint8Array(7))).slice(0, 10);
379
+ return `${raw.slice(0, 5)}-${raw.slice(5)}`;
380
+ });
381
+ }
382
+ function normalizeRecoveryCode(code) {
383
+ return code.toUpperCase().replace(/[\s-]/g, "");
384
+ }
385
+ function looksLikeRecoveryCode(code) {
386
+ return /^[A-Z2-7]{10}$/.test(normalizeRecoveryCode(code));
387
+ }
388
+
389
+ // src/accounts.ts
390
+ var PASSWORD_KDF = "pbkdf2-sha256";
391
+ var PASSWORD_ITERATIONS = 6e5;
392
+ var SESSION_PREFIX = "fh_sess_";
393
+ var INVITE_PREFIX = "fh_inv_";
394
+ var RESET_PREFIX = "fh_rst_";
395
+ var TOKEN_PREFIX = "fh_pat_";
396
+ var DEFAULT_TOKEN_DAYS = 90;
397
+ var MAX_TOKEN_DAYS = 3650;
398
+ var RESET_LINK_MS = 24 * 36e5;
399
+ var SALT_BYTES = 16;
400
+ var CLIENT_KEY_BYTES = 32;
401
+ var MIN_PEPPER_LENGTH = 32;
402
+ var MAX_EMAIL_LENGTH = 254;
403
+ var MAX_NAME_LENGTH = 100;
404
+ var MAX_USER_AGENT_LENGTH = 200;
405
+ var USERS = "users";
406
+ var USERS_BY_EMAIL = "users-email";
407
+ var SESSIONS = "sessions";
408
+ var LOGIN_ATTEMPTS = "login-attempts";
409
+ var INVITES = "invites";
410
+ var TOKENS = "tokens";
411
+ var USERS_BY_SSO = "users-sso";
412
+ var ABSENT_USER_ID = "usr_absent";
413
+ function publicToken(token) {
414
+ const { userId: _, ...rest } = token;
415
+ return rest;
416
+ }
417
+ function hasRecordStore(storage) {
418
+ return typeof storage.getRecord === "function" && typeof storage.putRecord === "function" && typeof storage.deleteRecord === "function" && typeof storage.listRecords === "function";
419
+ }
420
+ function assertUsersConfig(users, storage) {
421
+ if (!hasRecordStore(storage)) {
422
+ throw new Error(
423
+ "[flaghoist] `users` needs a storage adapter with the record store (getRecord, putRecord, deleteRecord, listRecords). Every bundled adapter has it; a custom adapter must add it, since accounts cannot be kept in memory."
424
+ );
425
+ }
426
+ if (typeof users.pepper !== "string" || users.pepper.length < MIN_PEPPER_LENGTH) {
427
+ throw new Error(
428
+ `[flaghoist] \`users.pepper\` must be at least ${MIN_PEPPER_LENGTH} characters. Generate one with \`openssl rand -hex 32\` and keep it in a secret, not in code.`
429
+ );
430
+ }
431
+ if (users.sso) assertSsoConfig(users.sso);
432
+ }
433
+ var encoder2 = new TextEncoder();
434
+ function toBase64Url(bytes) {
435
+ let binary = "";
436
+ for (const b of bytes) binary += String.fromCharCode(b);
437
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
438
+ }
439
+ function fromBase64Url(text) {
440
+ if (!/^[A-Za-z0-9_-]*$/.test(text)) return null;
441
+ try {
442
+ const padded = text.replace(/-/g, "+").replace(/_/g, "/");
443
+ const binary = atob(padded + "=".repeat((4 - padded.length % 4) % 4));
444
+ const out = new Uint8Array(binary.length);
445
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
446
+ return out;
447
+ } catch {
448
+ return null;
449
+ }
450
+ }
451
+ function randomBytes(length) {
452
+ const out = new Uint8Array(length);
453
+ crypto.getRandomValues(out);
454
+ return out;
455
+ }
456
+ function hex(bytes) {
457
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
458
+ }
459
+ async function sha256Hex(text) {
460
+ return hex(new Uint8Array(await crypto.subtle.digest("SHA-256", encoder2.encode(text))));
461
+ }
462
+ var pepperKeys = /* @__PURE__ */ new Map();
463
+ function pepperKey(pepper) {
464
+ let key = pepperKeys.get(pepper);
465
+ if (!key) {
466
+ key = crypto.subtle.importKey(
467
+ "raw",
468
+ encoder2.encode(pepper),
469
+ { name: "HMAC", hash: "SHA-256" },
470
+ false,
471
+ ["sign"]
472
+ );
473
+ pepperKeys.set(pepper, key);
474
+ }
475
+ return key;
476
+ }
477
+ async function hmac(pepper, data) {
478
+ return new Uint8Array(await crypto.subtle.sign("HMAC", await pepperKey(pepper), data));
479
+ }
480
+ function constantTimeEqual(a, b) {
481
+ if (a.length !== b.length) return false;
482
+ let diff = 0;
483
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
484
+ return diff === 0;
485
+ }
486
+ function normalizeEmail(value) {
487
+ if (typeof value !== "string") return null;
488
+ const email = value.trim().toLowerCase();
489
+ if (email.length < 3 || encoder2.encode(email).length > MAX_EMAIL_LENGTH) return null;
490
+ if (!/^[^\s@]+@[^\s@]+$/.test(email)) return null;
491
+ return email;
492
+ }
493
+ function normalizeName(value) {
494
+ return typeof value === "string" ? value.trim().slice(0, MAX_NAME_LENGTH) : "";
495
+ }
496
+ function decodeFixed(value, bytes) {
497
+ if (typeof value !== "string") return null;
498
+ const decoded = fromBase64Url(value);
499
+ return decoded && decoded.length === bytes ? decoded : null;
500
+ }
501
+ var decodeClientKey = (value) => decodeFixed(value, CLIENT_KEY_BYTES);
502
+ var decodeSalt = (value) => decodeFixed(value, SALT_BYTES);
503
+ var ATTEMPT_WINDOW_MS = 15 * 6e4;
504
+ var EMAIL_FREE_FAILURES = 5;
505
+ var EMAIL_BASE_LOCK_MS = 3e4;
506
+ var MAX_LOCK_MS = 15 * 6e4;
507
+ var IP_MAX_FAILURES = 30;
508
+ function roleInEnvironment(user, environment) {
509
+ if (user.role === "owner") return "owner";
510
+ return user.environmentRoles?.[environment] ?? user.role;
511
+ }
512
+ function publicUser(user) {
513
+ return {
514
+ id: user.id,
515
+ email: user.email,
516
+ name: user.name,
517
+ role: user.role,
518
+ status: user.status,
519
+ createdAt: user.createdAt,
520
+ hasPassword: user.password !== void 0,
521
+ twoFactor: user.twoFactor !== void 0,
522
+ ...user.environmentRoles && Object.keys(user.environmentRoles).length > 0 ? { environmentRoles: user.environmentRoles } : {},
523
+ ...user.sso ? { sso: true } : {},
524
+ ...user.roleManagedBy ? { roleManagedBy: user.roleManagedBy } : {},
525
+ ...user.lastLoginAt ? { lastLoginAt: user.lastLoginAt } : {}
526
+ };
527
+ }
528
+ function isUserRecord(value) {
529
+ if (!value || typeof value !== "object") return false;
530
+ const v = value;
531
+ return typeof v.id === "string" && typeof v.email === "string" && isRole(v.role) && (v.status === "active" || v.status === "disabled");
532
+ }
533
+ function isSessionRecord(value) {
534
+ if (!value || typeof value !== "object") return false;
535
+ const v = value;
536
+ return typeof v.id === "string" && typeof v.userId === "string" && typeof v.lastSeenAt === "string" && typeof v.expiresAt === "string";
537
+ }
538
+ function isInviteRecord(value) {
539
+ if (!value || typeof value !== "object") return false;
540
+ const v = value;
541
+ return typeof v.id === "string" && (v.kind === "invite" || v.kind === "reset") && typeof v.email === "string" && isRole(v.role) && typeof v.expiresAt === "string";
542
+ }
543
+ function publicInvite(invite) {
544
+ const { userId: _, ...rest } = invite;
545
+ return rest;
546
+ }
547
+ function isTokenRecord(value) {
548
+ if (!value || typeof value !== "object") return false;
549
+ const v = value;
550
+ return typeof v.id === "string" && typeof v.userId === "string" && typeof v.name === "string" && isRole(v.role);
551
+ }
552
+ function isAttemptRecord(value) {
553
+ if (!value || typeof value !== "object") return false;
554
+ const v = value;
555
+ return typeof v.failures === "number" && typeof v.windowStart === "number";
556
+ }
557
+ function createAccountStore(storage, users) {
558
+ const records = storage;
559
+ const pepper = users.pepper;
560
+ const pepperId = users.pepperId ?? "p1";
561
+ const idleMs = (users.session?.idleMinutes ?? 30) * 6e4;
562
+ const maxMs = (users.session?.maxHours ?? 12) * 36e5;
563
+ const touchMs = Math.min(5 * 6e4, idleMs / 6);
564
+ const inviteMs = (users.invites?.expiresInDays ?? 7) * 864e5;
565
+ async function liveInvites() {
566
+ const now = Date.now();
567
+ const out = [];
568
+ for (const { id, value } of await records.listRecords(INVITES)) {
569
+ if (!isInviteRecord(value)) continue;
570
+ if (now >= Date.parse(value.expiresAt)) await records.deleteRecord(INVITES, id);
571
+ else out.push({ key: id, invite: value });
572
+ }
573
+ return out;
574
+ }
575
+ const ssoKey = (issuer, subject) => sha256Hex(`${issuer}
576
+ ${subject}`);
577
+ const sealSecret = (secret) => seal(pepper, "totp-secret", { exp: Number.MAX_SAFE_INTEGER, secret });
578
+ const openSecret = async (sealed) => (await unseal(pepper, "totp-secret", sealed))?.secret ?? null;
579
+ const hashRecoveryCode = (code) => sha256Hex(`recovery:${normalizeRecoveryCode(code)}`);
580
+ async function tokensOf(userId) {
581
+ return (await records.listRecords(TOKENS)).filter((r) => isTokenRecord(r.value)).filter((r) => r.value.userId === userId).map((r) => ({ key: r.id, token: r.value }));
582
+ }
583
+ async function allSessions() {
584
+ const now = Date.now();
585
+ const out = [];
586
+ for (const { id, value } of await records.listRecords(SESSIONS)) {
587
+ if (!isSessionRecord(value)) continue;
588
+ const expired = now >= Date.parse(value.expiresAt) || now - Date.parse(value.lastSeenAt) >= idleMs;
589
+ if (expired) await records.deleteRecord(SESSIONS, id);
590
+ else out.push({ key: id, session: value });
591
+ }
592
+ return out;
593
+ }
594
+ async function getUser(id) {
595
+ const value = await records.getRecord(USERS, id);
596
+ return isUserRecord(value) ? value : null;
597
+ }
598
+ async function userIdForEmail(email) {
599
+ const value = await records.getRecord(USERS_BY_EMAIL, email);
600
+ return typeof value?.userId === "string" ? value.userId : null;
601
+ }
602
+ async function findByEmail(email) {
603
+ const id = await userIdForEmail(email);
604
+ const user = await getUser(id ?? ABSENT_USER_ID);
605
+ return id && user && user.email === email ? user : null;
606
+ }
607
+ async function fakeSalt(email) {
608
+ return toBase64Url((await hmac(pepper, encoder2.encode(`salt:${email}`))).slice(0, SALT_BYTES));
609
+ }
610
+ async function makeVerifier(salt, clientKey) {
611
+ return {
612
+ kdf: PASSWORD_KDF,
613
+ iterations: PASSWORD_ITERATIONS,
614
+ salt: toBase64Url(salt),
615
+ verifier: toBase64Url(await hmac(pepper, clientKey)),
616
+ pepperId
617
+ };
618
+ }
619
+ async function checkPassword(user, clientKey) {
620
+ const [computed, dummy] = await Promise.all([
621
+ hmac(pepper, clientKey),
622
+ hmac(pepper, encoder2.encode("absent"))
623
+ ]);
624
+ const stored = user?.password ? fromBase64Url(user.password.verifier) : null;
625
+ const expected = stored ?? dummy;
626
+ const match = constantTimeEqual(computed, expected);
627
+ return match && stored !== null && user?.status === "active";
628
+ }
629
+ async function readAttempt(id) {
630
+ const value = await records.getRecord(LOGIN_ATTEMPTS, id);
631
+ return isAttemptRecord(value) ? value : null;
632
+ }
633
+ function attemptIds(email, ip) {
634
+ return [`email:${email}`, `ip:${ip.slice(0, 64)}`];
635
+ }
636
+ return {
637
+ async count() {
638
+ return (await records.listRecords(USERS)).length;
80
639
  },
81
- { name: "meta", description: "Health and discovery." }
82
- ],
83
- paths: {
84
- "/api/v1/flags": {
85
- get: {
86
- tags: ["admin"],
87
- summary: "List all flags",
88
- security: [{ bearerAuth: [] }],
89
- responses: {
90
- "200": {
91
- description: "All flags",
92
- content: {
93
- "application/json": {
94
- schema: {
95
- type: "object",
96
- required: ["flags"],
97
- properties: {
98
- flags: { type: "array", items: { $ref: "#/components/schemas/FeatureFlag" } }
99
- }
100
- }
101
- }
102
- }
103
- },
104
- "401": { $ref: "#/components/responses/Unauthorized" }
105
- }
640
+ getUser,
641
+ findByEmail,
642
+ async listUsers() {
643
+ return (await records.listRecords(USERS)).map((r) => r.value).filter(isUserRecord).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
644
+ },
645
+ async saveUser(user) {
646
+ const updated = { ...user, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
647
+ await records.putRecord(USERS, user.id, updated);
648
+ return updated;
649
+ },
650
+ /** Delete an account with its email index, sessions and any open reset link. */
651
+ async removeUser(user) {
652
+ await Promise.all([
653
+ ...(await allSessions()).filter((s) => s.session.userId === user.id).map((s) => records.deleteRecord(SESSIONS, s.key)),
654
+ ...(await liveInvites()).filter((i) => i.invite.userId === user.id).map((i) => records.deleteRecord(INVITES, i.key)),
655
+ ...(await tokensOf(user.id)).map((t) => records.deleteRecord(TOKENS, t.key)),
656
+ ...user.sso ? [
657
+ ssoKey(user.sso.issuer, user.sso.subject).then(
658
+ (k) => records.deleteRecord(USERS_BY_SSO, k)
659
+ )
660
+ ] : []
661
+ ]);
662
+ await records.deleteRecord(USERS_BY_EMAIL, user.email);
663
+ await records.deleteRecord(USERS, user.id);
664
+ },
665
+ /** When each user was last seen, from their live sessions. */
666
+ async lastActive() {
667
+ const out = /* @__PURE__ */ new Map();
668
+ for (const { session } of await allSessions()) {
669
+ const prev = out.get(session.userId);
670
+ if (!prev || session.lastSeenAt > prev) out.set(session.userId, session.lastSeenAt);
106
671
  }
672
+ return out;
107
673
  },
108
- "/api/v1/flags/{key}": {
109
- parameters: [{ $ref: "#/components/parameters/FlagKey" }],
110
- get: {
111
- tags: ["admin"],
112
- summary: "Get one flag",
113
- security: [{ bearerAuth: [] }],
114
- responses: {
115
- "200": {
116
- description: "The flag",
117
- content: {
118
- "application/json": { schema: { $ref: "#/components/schemas/FeatureFlag" } }
119
- }
120
- },
121
- "401": { $ref: "#/components/responses/Unauthorized" },
122
- "404": { $ref: "#/components/responses/NotFound" }
123
- }
124
- },
125
- put: {
126
- tags: ["admin"],
127
- summary: "Create or replace a flag",
128
- description: "A full replace. Creation metadata (createdBy/createdAt) is preserved; the updater and updatedAt are stamped server-side. Invalid targeting rules are rejected with 400.",
129
- security: [{ bearerAuth: [] }],
130
- requestBody: {
131
- required: true,
132
- content: { "application/json": { schema: { $ref: "#/components/schemas/FlagInput" } } }
674
+ /** Sign out every session of a user, except `keepId`. Returns how many ended. */
675
+ async revokeSessionsFor(userId, keepId) {
676
+ const ended = (await allSessions()).filter(
677
+ (s) => s.session.userId === userId && s.session.id !== keepId
678
+ );
679
+ await Promise.all(ended.map((s) => records.deleteRecord(SESSIONS, s.key)));
680
+ return ended.length;
681
+ },
682
+ // ---- two-factor codes ----
683
+ /** Whether the policy makes this account use two-factor codes with its password. */
684
+ twoFactorRequired(user) {
685
+ const policy = users.twoFactor ?? "optional";
686
+ if (policy === "everyone") return true;
687
+ return policy === "admins" && (user.role === "admin" || user.role === "owner");
688
+ },
689
+ /** Start setup: a fresh secret, kept sealed on the account until a code confirms it. */
690
+ async beginTwoFactor(user) {
691
+ const secret = newTotpSecret();
692
+ await records.putRecord(USERS, user.id, {
693
+ ...user,
694
+ pendingTwoFactor: {
695
+ secret: await sealSecret(secret),
696
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
133
697
  },
134
- responses: {
135
- "200": {
136
- description: "The stored flag",
137
- content: {
138
- "application/json": { schema: { $ref: "#/components/schemas/FeatureFlag" } }
139
- }
140
- },
141
- "400": { $ref: "#/components/responses/BadRequest" },
142
- "401": { $ref: "#/components/responses/Unauthorized" },
143
- "413": { description: "Payload too large" }
144
- }
145
- },
146
- delete: {
147
- tags: ["admin"],
148
- summary: "Delete a flag",
149
- security: [{ bearerAuth: [] }],
150
- responses: {
151
- "204": { description: "Deleted (idempotent)" },
152
- "401": { $ref: "#/components/responses/Unauthorized" }
153
- }
154
- }
698
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
699
+ });
700
+ return secret;
155
701
  },
156
- "/ofrep/v1/evaluate/flags": {
157
- post: {
158
- tags: ["evaluate"],
159
- summary: "Evaluate all flags for a context (OFREP bulk)",
160
- security: [{ apiKeyAuth: [] }],
161
- requestBody: {
162
- content: {
163
- "application/json": { schema: { $ref: "#/components/schemas/EvaluationRequest" } }
164
- }
702
+ /** Finish setup with a code from the app. Returns the recovery codes, or null for a wrong code. */
703
+ async confirmTwoFactor(user, code) {
704
+ if (!user.pendingTwoFactor) return null;
705
+ const secret = await openSecret(user.pendingTwoFactor.secret);
706
+ if (!secret) return null;
707
+ const step = await matchTotp(secret, code);
708
+ if (step === null) return null;
709
+ const codes = newRecoveryCodes();
710
+ const { pendingTwoFactor: _, ...rest } = user;
711
+ await records.putRecord(USERS, user.id, {
712
+ ...rest,
713
+ twoFactor: {
714
+ secret: user.pendingTwoFactor.secret,
715
+ enabledAt: (/* @__PURE__ */ new Date()).toISOString(),
716
+ lastStep: step,
717
+ recoveryCodes: await Promise.all(codes.map(hashRecoveryCode))
165
718
  },
166
- responses: {
167
- "200": {
168
- description: "Evaluated flags",
169
- content: {
170
- "application/json": {
171
- schema: {
172
- type: "object",
173
- properties: {
174
- flags: { type: "array", items: { $ref: "#/components/schemas/EvaluatedFlag" } }
175
- }
176
- }
177
- }
178
- }
179
- },
180
- "401": { $ref: "#/components/responses/Unauthorized" }
181
- }
719
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
720
+ });
721
+ return codes;
722
+ },
723
+ /**
724
+ * Check a code from the app, or a recovery code, for an account that has two-factor on. A code
725
+ * is accepted once: its time step is recorded, and a recovery code is used up.
726
+ */
727
+ async checkSecondFactor(user, code) {
728
+ const tf = user.twoFactor;
729
+ if (!tf) return null;
730
+ if (looksLikeRecoveryCode(code)) {
731
+ const hash = await hashRecoveryCode(code);
732
+ if (!tf.recoveryCodes.includes(hash)) return null;
733
+ await records.putRecord(USERS, user.id, {
734
+ ...user,
735
+ twoFactor: { ...tf, recoveryCodes: tf.recoveryCodes.filter((h) => h !== hash) },
736
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
737
+ });
738
+ return "recovery";
182
739
  }
740
+ const secret = await openSecret(tf.secret);
741
+ const step = secret ? await matchTotp(secret, code) : null;
742
+ if (step === null || tf.lastStep !== void 0 && step <= tf.lastStep) return null;
743
+ await records.putRecord(USERS, user.id, {
744
+ ...user,
745
+ twoFactor: { ...tf, lastStep: step },
746
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
747
+ });
748
+ return "app";
183
749
  },
184
- "/ofrep/v1/evaluate/flags/{key}": {
185
- parameters: [{ $ref: "#/components/parameters/FlagKey" }],
186
- post: {
187
- tags: ["evaluate"],
188
- summary: "Evaluate one flag (OFREP)",
189
- security: [{ apiKeyAuth: [] }],
190
- requestBody: {
191
- content: {
192
- "application/json": { schema: { $ref: "#/components/schemas/EvaluationRequest" } }
193
- }
750
+ async newRecoveryCodes(user) {
751
+ if (!user.twoFactor) return null;
752
+ const codes = newRecoveryCodes();
753
+ await records.putRecord(USERS, user.id, {
754
+ ...user,
755
+ twoFactor: {
756
+ ...user.twoFactor,
757
+ recoveryCodes: await Promise.all(codes.map(hashRecoveryCode))
194
758
  },
195
- responses: {
196
- "200": {
197
- description: "Evaluated flag",
198
- content: {
199
- "application/json": { schema: { $ref: "#/components/schemas/EvaluatedFlag" } }
200
- }
201
- },
202
- "404": { description: "Unknown flag (errorCode FLAG_NOT_FOUND)" }
203
- }
204
- }
759
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
760
+ });
761
+ return codes;
205
762
  },
206
- "/api/v1/openapi.json": {
207
- get: {
208
- tags: ["meta"],
209
- summary: "This OpenAPI document",
210
- responses: { "200": { description: "The OpenAPI document" } }
763
+ async removeTwoFactor(user) {
764
+ const { twoFactor: _, pendingTwoFactor: __, ...rest } = user;
765
+ await records.putRecord(USERS, user.id, {
766
+ ...rest,
767
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
768
+ });
769
+ },
770
+ // ---- personal access tokens ----
771
+ async createToken(user, input) {
772
+ const token = TOKEN_PREFIX + toBase64Url(randomBytes(32));
773
+ const now = Date.now();
774
+ const record = {
775
+ id: `tok_${hex(randomBytes(12))}`,
776
+ userId: user.id,
777
+ name: input.name,
778
+ role: input.role,
779
+ prefix: token.slice(0, TOKEN_PREFIX.length + 4),
780
+ createdAt: new Date(now).toISOString(),
781
+ ...input.expiresInDays !== null ? { expiresAt: new Date(now + input.expiresInDays * 864e5).toISOString() } : {}
782
+ };
783
+ await records.putRecord(TOKENS, await sha256Hex(token), record);
784
+ return { token, record };
785
+ },
786
+ /** A user's tokens, newest first. Expired ones are listed until revoked, marked by expiresAt. */
787
+ async listTokens(userId) {
788
+ return (await tokensOf(userId)).map((t) => t.token).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
789
+ },
790
+ /**
791
+ * The owner and effective role behind a token, or null. An expired token is deleted and
792
+ * reported through `onExpired` so the caller can audit it. lastUsedAt is written back at most
793
+ * every few minutes, like a session's lastSeenAt.
794
+ */
795
+ async resolveToken(token, onExpired) {
796
+ const key = await sha256Hex(token);
797
+ const value = await records.getRecord(TOKENS, key);
798
+ if (!isTokenRecord(value)) return null;
799
+ const now = Date.now();
800
+ if (value.expiresAt && now >= Date.parse(value.expiresAt)) {
801
+ await records.deleteRecord(TOKENS, key);
802
+ await onExpired?.(value);
803
+ return null;
211
804
  }
805
+ const user = await getUser(value.userId);
806
+ if (!user || user.status !== "active") return null;
807
+ let record = value;
808
+ if (!value.lastUsedAt || now - Date.parse(value.lastUsedAt) >= touchMs) {
809
+ record = { ...value, lastUsedAt: new Date(now).toISOString() };
810
+ await records.putRecord(TOKENS, key, record);
811
+ }
812
+ return { user, token: record, role: lowerRole(record.role, user.role) };
212
813
  },
213
- "/health": {
214
- get: {
215
- tags: ["meta"],
216
- summary: "Health check",
217
- responses: {
218
- "200": {
219
- description: "ok",
220
- content: {
221
- "application/json": {
222
- schema: { type: "object", properties: { status: { type: "string" } } }
223
- }
224
- }
225
- }
814
+ async revokeToken(userId, id) {
815
+ const match = (await tokensOf(userId)).find((t) => t.token.id === id);
816
+ if (!match) return null;
817
+ await records.deleteRecord(TOKENS, match.key);
818
+ return match.token;
819
+ },
820
+ /** Revoke the token presented, for signing out a CLI. */
821
+ async revokePresentedToken(token) {
822
+ const key = await sha256Hex(token);
823
+ const value = await records.getRecord(TOKENS, key);
824
+ await records.deleteRecord(TOKENS, key);
825
+ return isTokenRecord(value) ? value : null;
826
+ },
827
+ // ---- invites and reset links ----
828
+ /**
829
+ * Issue an invite, or a reset link for an existing account. Any earlier open link of the same
830
+ * kind for the same email stops working, so only the newest one can be used.
831
+ */
832
+ async createInvite(input) {
833
+ for (const { key, invite: invite2 } of await liveInvites()) {
834
+ if (invite2.kind === input.kind && invite2.email === input.email) {
835
+ await records.deleteRecord(INVITES, key);
226
836
  }
227
837
  }
228
- }
229
- },
230
- components: {
231
- securitySchemes: {
232
- bearerAuth: {
233
- type: "http",
234
- scheme: "bearer",
235
- description: "Admin token (or a validated OIDC JWT)."
236
- },
237
- apiKeyAuth: {
238
- type: "apiKey",
239
- in: "header",
240
- name: "x-api-key",
241
- description: "Read-only API key."
838
+ const prefix = input.kind === "invite" ? INVITE_PREFIX : RESET_PREFIX;
839
+ const token = prefix + toBase64Url(randomBytes(32));
840
+ const now = Date.now();
841
+ const invite = {
842
+ id: `inv_${hex(randomBytes(12))}`,
843
+ kind: input.kind,
844
+ email: input.email,
845
+ role: input.role,
846
+ ...input.userId ? { userId: input.userId } : {},
847
+ invitedBy: input.invitedBy,
848
+ createdAt: new Date(now).toISOString(),
849
+ expiresAt: new Date(
850
+ now + (input.kind === "invite" ? inviteMs : RESET_LINK_MS)
851
+ ).toISOString()
852
+ };
853
+ await records.putRecord(INVITES, await sha256Hex(token), invite);
854
+ return { token, invite };
855
+ },
856
+ async listInvites() {
857
+ return (await liveInvites()).map((i) => i.invite).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
858
+ },
859
+ /** The open invite or reset link behind a token, or null when unknown, used or expired. */
860
+ async findInvite(token) {
861
+ if (!token.startsWith(INVITE_PREFIX) && !token.startsWith(RESET_PREFIX)) return null;
862
+ const key = await sha256Hex(token);
863
+ const value = await records.getRecord(INVITES, key);
864
+ if (!isInviteRecord(value)) return null;
865
+ if (Date.now() >= Date.parse(value.expiresAt)) {
866
+ await records.deleteRecord(INVITES, key);
867
+ return null;
242
868
  }
869
+ return value;
243
870
  },
244
- parameters: {
245
- FlagKey: {
246
- name: "key",
247
- in: "path",
248
- required: true,
249
- description: "Flag key.",
250
- schema: { type: "string", pattern: "^[A-Za-z0-9][A-Za-z0-9._-]*$", maxLength: 256 }
871
+ async consumeInvite(token) {
872
+ await records.deleteRecord(INVITES, await sha256Hex(token));
873
+ },
874
+ /** Revoke an open invite or reset link by its public id. */
875
+ async revokeInvite(id) {
876
+ const match = (await liveInvites()).find((i) => i.invite.id === id);
877
+ if (!match) return null;
878
+ await records.deleteRecord(INVITES, match.key);
879
+ return match.invite;
880
+ },
881
+ async passwordParams(email) {
882
+ const user = await findByEmail(email);
883
+ const salt = user?.password?.salt ?? await fakeSalt(email);
884
+ return {
885
+ kdf: PASSWORD_KDF,
886
+ iterations: user?.password?.iterations ?? PASSWORD_ITERATIONS,
887
+ salt
888
+ };
889
+ },
890
+ /** Create an account. Returns null when the email is already taken. */
891
+ async createUser(input) {
892
+ if (await userIdForEmail(input.email)) return null;
893
+ const now = (/* @__PURE__ */ new Date()).toISOString();
894
+ const user = {
895
+ id: `usr_${hex(randomBytes(12))}`,
896
+ email: input.email,
897
+ name: input.name,
898
+ role: input.role,
899
+ status: "active",
900
+ password: await makeVerifier(input.salt, input.clientKey),
901
+ createdAt: now,
902
+ updatedAt: now
903
+ };
904
+ await records.putRecord(USERS, user.id, user);
905
+ await records.putRecord(USERS_BY_EMAIL, user.email, { userId: user.id });
906
+ return user;
907
+ },
908
+ checkPassword,
909
+ // ---- SSO ----
910
+ async findBySso(issuer, subject) {
911
+ const value = await records.getRecord(USERS_BY_SSO, await ssoKey(issuer, subject));
912
+ return typeof value?.userId === "string" ? getUser(value.userId) : null;
913
+ },
914
+ /** Tie an account to a provider identity, so later sign-ins find it even if the email changes. */
915
+ async linkSso(user, issuer, subject) {
916
+ const updated = {
917
+ ...user,
918
+ sso: { issuer, subject },
919
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
920
+ };
921
+ await records.putRecord(USERS, user.id, updated);
922
+ await records.putRecord(USERS_BY_SSO, await ssoKey(issuer, subject), { userId: user.id });
923
+ return updated;
924
+ },
925
+ /** Create an account that signs in with SSO only. Returns null when the email is taken. */
926
+ async createSsoUser(input) {
927
+ if (await userIdForEmail(input.email)) return null;
928
+ const now = (/* @__PURE__ */ new Date()).toISOString();
929
+ const user = {
930
+ id: `usr_${hex(randomBytes(12))}`,
931
+ email: input.email,
932
+ name: input.name.slice(0, MAX_NAME_LENGTH),
933
+ role: input.role,
934
+ status: "active",
935
+ sso: { issuer: input.issuer, subject: input.subject },
936
+ ...input.managed ? { roleManagedBy: "sso" } : {},
937
+ createdAt: now,
938
+ updatedAt: now
939
+ };
940
+ await records.putRecord(USERS, user.id, user);
941
+ await records.putRecord(USERS_BY_EMAIL, user.email, { userId: user.id });
942
+ await records.putRecord(USERS_BY_SSO, await ssoKey(input.issuer, input.subject), {
943
+ userId: user.id
944
+ });
945
+ return user;
946
+ },
947
+ async setPassword(user, salt, clientKey) {
948
+ const updated = {
949
+ ...user,
950
+ password: await makeVerifier(salt, clientKey),
951
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
952
+ };
953
+ await records.putRecord(USERS, user.id, updated);
954
+ },
955
+ async recordLogin(user) {
956
+ await records.putRecord(USERS, user.id, { ...user, lastLoginAt: (/* @__PURE__ */ new Date()).toISOString() });
957
+ },
958
+ // ---- throttling ----
959
+ async throttled(email, ip) {
960
+ const now = Date.now();
961
+ const attempts = await Promise.all(attemptIds(email, ip).map(readAttempt));
962
+ const lockedUntil = Math.max(0, ...attempts.map((a) => a?.lockedUntil ?? 0));
963
+ if (lockedUntil > now) {
964
+ return { ok: false, retryAfterSeconds: Math.ceil((lockedUntil - now) / 1e3) };
251
965
  }
966
+ return { ok: true };
252
967
  },
253
- responses: {
254
- Unauthorized: {
255
- description: "Missing or invalid credentials",
256
- content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }
257
- },
258
- BadRequest: {
259
- description: "Invalid request or flag definition",
260
- content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }
261
- },
262
- NotFound: {
263
- description: "Flag not found",
264
- content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }
968
+ async recordFailure(email, ip) {
969
+ const now = Date.now();
970
+ const [emailId, ipId] = attemptIds(email, ip);
971
+ const bump = async (id, lockFor) => {
972
+ const prev = await readAttempt(id);
973
+ const fresh = !prev || now - prev.windowStart > ATTEMPT_WINDOW_MS;
974
+ const failures = fresh ? 1 : prev.failures + 1;
975
+ const lockMs = lockFor(failures);
976
+ const next = {
977
+ failures,
978
+ windowStart: fresh ? now : prev.windowStart,
979
+ ...lockMs > 0 ? { lockedUntil: now + lockMs } : {}
980
+ };
981
+ await records.putRecord(LOGIN_ATTEMPTS, id, next);
982
+ };
983
+ await Promise.all([
984
+ bump(
985
+ emailId,
986
+ (n) => n <= EMAIL_FREE_FAILURES ? 0 : Math.min(EMAIL_BASE_LOCK_MS * 2 ** (n - EMAIL_FREE_FAILURES - 1), MAX_LOCK_MS)
987
+ ),
988
+ bump(ipId, (n) => n >= IP_MAX_FAILURES ? MAX_LOCK_MS : 0)
989
+ ]);
990
+ },
991
+ async clearFailures(email) {
992
+ await records.deleteRecord(LOGIN_ATTEMPTS, `email:${email}`);
993
+ },
994
+ // ---- sessions ----
995
+ async createSession(user, userAgent, via) {
996
+ const token = SESSION_PREFIX + toBase64Url(randomBytes(32));
997
+ const now = Date.now();
998
+ const session = {
999
+ id: `ses_${hex(randomBytes(12))}`,
1000
+ userId: user.id,
1001
+ createdAt: new Date(now).toISOString(),
1002
+ lastSeenAt: new Date(now).toISOString(),
1003
+ expiresAt: new Date(now + maxMs).toISOString(),
1004
+ ...userAgent ? { userAgent: userAgent.slice(0, MAX_USER_AGENT_LENGTH) } : {},
1005
+ ...via ? { via } : {}
1006
+ };
1007
+ await records.putRecord(SESSIONS, await sha256Hex(token), session);
1008
+ return { token, session };
1009
+ },
1010
+ /**
1011
+ * The live session and active user behind a session token, or null. Expired sessions are
1012
+ * deleted on sight. lastSeenAt is written back at most every few minutes.
1013
+ */
1014
+ async resolveSession(token) {
1015
+ const key = await sha256Hex(token);
1016
+ const value = await records.getRecord(SESSIONS, key);
1017
+ if (!isSessionRecord(value)) return null;
1018
+ const now = Date.now();
1019
+ const lastSeen = Date.parse(value.lastSeenAt);
1020
+ if (now >= Date.parse(value.expiresAt) || now - lastSeen >= idleMs) {
1021
+ await records.deleteRecord(SESSIONS, key);
1022
+ return null;
1023
+ }
1024
+ const user = await getUser(value.userId);
1025
+ if (!user || user.status !== "active") return null;
1026
+ let session = value;
1027
+ if (now - lastSeen >= touchMs) {
1028
+ session = { ...value, lastSeenAt: new Date(now).toISOString() };
1029
+ await records.putRecord(SESSIONS, key, session);
265
1030
  }
1031
+ return { user, session };
266
1032
  },
267
- schemas: {
268
- Error: { type: "object", required: ["error"], properties: { error: { type: "string" } } },
269
- Condition: {
270
- type: "object",
271
- required: ["attribute", "operator", "value"],
272
- properties: {
273
- attribute: { type: "string", maxLength: 256 },
274
- operator: {
275
- type: "string",
276
- enum: [
277
- "eq",
278
- "neq",
279
- "in",
280
- "notIn",
281
- "contains",
282
- "startsWith",
283
- "endsWith",
284
- "gt",
285
- "gte",
286
- "lt",
287
- "lte",
288
- "semverGte",
289
- "semverLt"
290
- ]
291
- },
292
- value: {
293
- description: "A scalar, or an array of scalars for `in` / `notIn`.",
294
- oneOf: [
295
- { type: "string" },
296
- { type: "number" },
297
- { type: "boolean" },
298
- { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } }
299
- ]
300
- }
301
- }
302
- },
303
- RuleResult: {
304
- type: "object",
305
- required: ["enabled"],
306
- properties: {
307
- enabled: { type: "boolean" },
308
- rollout: {
309
- type: "object",
310
- properties: { percentage: { type: "number", minimum: 0, maximum: 100 } }
311
- }
312
- }
313
- },
314
- TargetingRule: {
315
- type: "object",
316
- required: ["conditions", "result"],
317
- properties: {
318
- description: { type: "string" },
319
- conditions: { type: "array", items: { $ref: "#/components/schemas/Condition" } },
320
- result: { $ref: "#/components/schemas/RuleResult" }
321
- }
322
- },
323
- FlagMetadata: {
324
- type: "object",
325
- properties: {
326
- createdBy: { type: "string" },
327
- createdAt: { type: "string", format: "date-time" },
328
- updatedBy: { type: "string" },
329
- updatedAt: { type: "string", format: "date-time" }
330
- }
331
- },
332
- FlagInput: {
333
- type: "object",
334
- required: ["enabled", "rollout"],
335
- properties: {
336
- enabled: { type: "boolean" },
337
- rollout: {
338
- type: "object",
339
- required: ["percentage"],
340
- properties: { percentage: { type: "number", minimum: 0, maximum: 100 } }
341
- },
342
- rules: { type: "array", items: { $ref: "#/components/schemas/TargetingRule" } },
343
- description: { type: "string" }
344
- }
345
- },
346
- FeatureFlag: {
347
- allOf: [
348
- { $ref: "#/components/schemas/FlagInput" },
349
- {
350
- type: "object",
351
- required: ["key", "metadata"],
352
- properties: {
353
- key: { type: "string" },
354
- metadata: { $ref: "#/components/schemas/FlagMetadata" }
355
- }
356
- }
357
- ]
358
- },
359
- EvaluationRequest: {
360
- type: "object",
361
- properties: {
362
- context: {
363
- type: "object",
364
- description: "OpenFeature evaluation context.",
365
- properties: { targetingKey: { type: "string" } },
366
- additionalProperties: true
367
- }
368
- }
1033
+ async endSession(token) {
1034
+ const key = await sha256Hex(token);
1035
+ const value = await records.getRecord(SESSIONS, key);
1036
+ await records.deleteRecord(SESSIONS, key);
1037
+ return isSessionRecord(value) ? value : null;
1038
+ },
1039
+ /** A user's live sessions, keyed by storage id. Expired ones are cleaned up along the way. */
1040
+ async sessionsFor(userId) {
1041
+ return (await allSessions()).filter((s) => s.session.userId === userId);
1042
+ },
1043
+ async deleteSessionKey(key) {
1044
+ await records.deleteRecord(SESSIONS, key);
1045
+ }
1046
+ };
1047
+ }
1048
+ function publicSession(session, currentId) {
1049
+ return {
1050
+ id: session.id,
1051
+ createdAt: session.createdAt,
1052
+ lastSeenAt: session.lastSeenAt,
1053
+ expiresAt: session.expiresAt,
1054
+ ...session.userAgent ? { userAgent: session.userAgent } : {},
1055
+ current: session.id === currentId
1056
+ };
1057
+ }
1058
+
1059
+ // src/audit.ts
1060
+ import {
1061
+ auditCategory
1062
+ } from "@flaghoist/core";
1063
+ function generateId() {
1064
+ const ts = Date.now().toString(36);
1065
+ const rand = Math.random().toString(36).slice(2, 8);
1066
+ return `${ts}-${rand}`;
1067
+ }
1068
+ function createAuditLog(storage) {
1069
+ if (storage?.appendAudit && storage?.listAudit) {
1070
+ return {
1071
+ async record(entry) {
1072
+ await storage.appendAudit({
1073
+ ...entry,
1074
+ id: generateId(),
1075
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1076
+ });
369
1077
  },
370
- EvaluatedFlag: {
371
- type: "object",
372
- properties: {
373
- key: { type: "string" },
374
- value: { type: "boolean" },
375
- reason: {
376
- type: "string",
377
- enum: ["STATIC", "TARGETING_MATCH", "SPLIT", "DEFAULT"]
378
- },
379
- variant: { type: "string" }
380
- }
1078
+ async list(options) {
1079
+ return storage.listAudit(options);
381
1080
  }
382
- }
1081
+ };
383
1082
  }
384
- };
1083
+ const buffers = { flags: [], security: [] };
1084
+ const CAPACITY = 500;
1085
+ return {
1086
+ async record(entry) {
1087
+ const buf = buffers[auditCategory(entry.action)];
1088
+ buf.push({ ...entry, id: generateId(), timestamp: (/* @__PURE__ */ new Date()).toISOString() });
1089
+ if (buf.length > CAPACITY) buf.splice(0, buf.length - CAPACITY);
1090
+ },
1091
+ async list(options) {
1092
+ const source = options?.category ? buffers[options.category] : [...buffers.flags, ...buffers.security].sort(
1093
+ (a, b) => a.timestamp.localeCompare(b.timestamp)
1094
+ );
1095
+ let entries = source.slice().reverse();
1096
+ if (options?.flagKey) entries = entries.filter((e) => e.flagKey === options.flagKey);
1097
+ if (options?.action) entries = entries.filter((e) => e.action === options.action);
1098
+ if (options?.environment !== void 0) {
1099
+ entries = entries.filter((e) => e.environment === options.environment);
1100
+ }
1101
+ const total = entries.length;
1102
+ const offset = options?.offset ?? 0;
1103
+ const limit = options?.limit ?? 50;
1104
+ return { entries: entries.slice(offset, offset + limit), total };
1105
+ }
1106
+ };
1107
+ }
385
1108
 
386
- // src/ratelimit.ts
387
- function defaultRateLimitKey(headers) {
388
- const cf = headers.get("cf-connecting-ip");
389
- if (cf) return cf;
390
- const fwd = headers.get("x-forwarded-for");
391
- if (fwd) {
392
- const first = fwd.split(",")[0]?.trim();
393
- if (first) return first;
394
- }
395
- return "anonymous";
1109
+ // src/email.ts
1110
+ function escapeHtml(text) {
1111
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
396
1112
  }
397
- function memoryRateLimit(options = {}) {
398
- const max = options.max ?? 120;
399
- const windowMs = options.windowMs ?? 6e4;
400
- const maxKeys = options.maxKeys ?? 1e5;
401
- const hits = /* @__PURE__ */ new Map();
402
- const sweep = (now) => {
403
- for (const [key, entry] of hits) {
404
- if (entry.resetAt <= now) hits.delete(key);
405
- }
1113
+ function formatDate(iso) {
1114
+ return new Date(iso).toUTCString().replace(/:\d\d GMT$/, " UTC");
1115
+ }
1116
+ function htmlBody(paragraphs, link, button) {
1117
+ const safeLink = escapeHtml(link);
1118
+ return [
1119
+ '<!doctype html><html><body style="font-family:system-ui,sans-serif;line-height:1.5;color:#0b1e3a">',
1120
+ ...paragraphs.map((p) => `<p>${escapeHtml(p)}</p>`),
1121
+ `<p><a href="${safeLink}" style="display:inline-block;padding:10px 16px;background:#d9532b;color:#fff;border-radius:6px;text-decoration:none">${escapeHtml(button)}</a></p>`,
1122
+ `<p style="font-size:13px;color:#4a5a73">Or open this link: ${safeLink}</p>`,
1123
+ "</body></html>"
1124
+ ].join("");
1125
+ }
1126
+ function inviteEmail(input) {
1127
+ const paragraphs = [
1128
+ input.invitedBy ? `${input.invitedBy} invited you to Flaghoist as ${input.role}.` : `You are invited to Flaghoist as ${input.role}.`,
1129
+ input.ssoLabel ? `Open Flaghoist and choose Continue with ${input.ssoLabel}, signing in as ${input.to}. The invite lasts until ${formatDate(input.expiresAt)}.` : `Open the link to choose a password and sign in. It works once, until ${formatDate(input.expiresAt)}.`,
1130
+ "If you were not expecting this, you can ignore this email; nothing happens until the link is used."
1131
+ ];
1132
+ return {
1133
+ to: input.to,
1134
+ subject: "You are invited to Flaghoist",
1135
+ text: [...paragraphs, "", input.link].join("\n\n"),
1136
+ html: htmlBody(paragraphs, input.link, "Accept the invite")
406
1137
  };
1138
+ }
1139
+ function resetEmail(input) {
1140
+ const paragraphs = [
1141
+ "An admin created a link for you to set a new Flaghoist password.",
1142
+ `It works once, until ${formatDate(input.expiresAt)}. Setting a new password signs you out everywhere else.`,
1143
+ "If you did not ask for this, tell your Flaghoist admin; your current password keeps working until the link is used."
1144
+ ];
407
1145
  return {
408
- key: defaultRateLimitKey,
409
- check(key) {
410
- const now = Date.now();
411
- let entry = hits.get(key);
412
- if (!entry || entry.resetAt <= now) {
413
- if (!hits.has(key) && hits.size >= maxKeys) {
414
- sweep(now);
415
- if (hits.size >= maxKeys) return { ok: true };
416
- }
417
- entry = { count: 0, resetAt: now + windowMs };
418
- hits.set(key, entry);
419
- }
420
- entry.count += 1;
421
- if (entry.count > max) {
422
- return { ok: false, retryAfter: Math.max(1, Math.ceil((entry.resetAt - now) / 1e3)) };
423
- }
424
- return { ok: true };
425
- }
1146
+ to: input.to,
1147
+ subject: "Set a new Flaghoist password",
1148
+ text: [...paragraphs, "", input.link].join("\n\n"),
1149
+ html: htmlBody(paragraphs, input.link, "Set a new password")
426
1150
  };
427
1151
  }
428
1152
 
429
1153
  // src/auth.ts
430
- import { createRemoteJWKSet, jwtVerify } from "jose";
1154
+ import { createRemoteJWKSet, jwtVerify as jwtVerify2 } from "jose";
431
1155
  function extractBearer(headers) {
432
1156
  const header = headers.get("authorization");
433
1157
  if (!header) return null;
@@ -436,10 +1160,10 @@ function extractBearer(headers) {
436
1160
  return token.trim() || null;
437
1161
  }
438
1162
  async function safeEqual(a, b) {
439
- const encoder = new TextEncoder();
1163
+ const encoder3 = new TextEncoder();
440
1164
  const [da, db] = await Promise.all([
441
- crypto.subtle.digest("SHA-256", encoder.encode(a)),
442
- crypto.subtle.digest("SHA-256", encoder.encode(b))
1165
+ crypto.subtle.digest("SHA-256", encoder3.encode(a)),
1166
+ crypto.subtle.digest("SHA-256", encoder3.encode(b))
443
1167
  ]);
444
1168
  const va = new Uint8Array(da);
445
1169
  const vb = new Uint8Array(db);
@@ -473,6 +1197,22 @@ function apiKey(expected) {
473
1197
  return { ok: true, identity: "api-key" };
474
1198
  };
475
1199
  }
1200
+ function apiKeys(keys) {
1201
+ const entries = Object.entries(keys);
1202
+ for (const [env, secret] of entries) {
1203
+ warnIfWeakSecret(`read API key for "${env}"`, secret);
1204
+ }
1205
+ return async (headers) => {
1206
+ const provided = headers.get("x-api-key");
1207
+ if (!provided) return { ok: false, status: 401, message: "Invalid or missing API key" };
1208
+ const results = await Promise.all(
1209
+ entries.map(async ([env, secret]) => ({ env, match: await safeEqual(provided, secret) }))
1210
+ );
1211
+ const hit = results.find((r) => r.match);
1212
+ if (!hit) return { ok: false, status: 401, message: "Invalid or missing API key" };
1213
+ return { ok: true, identity: "api-key", environment: hit.env };
1214
+ };
1215
+ }
476
1216
  function bearerToken(expected) {
477
1217
  warnIfWeakSecret("admin token", expected);
478
1218
  return async (headers) => {
@@ -501,210 +1241,2439 @@ function oidc(options) {
501
1241
  if (!token) return { ok: false, status: 401, message: "Missing bearer token" };
502
1242
  let payload;
503
1243
  try {
504
- const verified = await jwtVerify(token, resolveKey, {
505
- issuer: options.issuer,
506
- audience: options.audience,
507
- algorithms
1244
+ const verified = await jwtVerify2(token, resolveKey, {
1245
+ issuer: options.issuer,
1246
+ audience: options.audience,
1247
+ algorithms
1248
+ });
1249
+ payload = verified.payload;
1250
+ } catch {
1251
+ return { ok: false, status: 401, message: "Invalid token" };
1252
+ }
1253
+ if (options.tokenUse && payload["token_use"] !== options.tokenUse) {
1254
+ return { ok: false, status: 401, message: "Invalid token" };
1255
+ }
1256
+ if (options.allowedGroups && options.allowedGroups.length > 0) {
1257
+ const groups = extractGroups(payload, options.groupsClaim ?? "groups");
1258
+ const allowed = options.allowedGroups;
1259
+ if (!groups.some((group) => allowed.includes(group))) {
1260
+ return { ok: false, status: 403, message: "Admin group required" };
1261
+ }
1262
+ }
1263
+ const email = typeof payload["email"] === "string" ? payload["email"] : void 0;
1264
+ const sub = typeof payload.sub === "string" ? payload.sub : void 0;
1265
+ return { ok: true, identity: email ?? sub ?? "unknown" };
1266
+ };
1267
+ }
1268
+
1269
+ // src/cache.ts
1270
+ function createDefinitionCache() {
1271
+ let entry = null;
1272
+ return {
1273
+ async load(storage, ttlMs2) {
1274
+ const now = Date.now();
1275
+ if (entry && entry.expiresAt > now) return entry.flags;
1276
+ const flags = await storage.list();
1277
+ entry = { flags, expiresAt: now + ttlMs2 };
1278
+ return flags;
1279
+ },
1280
+ invalidate() {
1281
+ entry = null;
1282
+ }
1283
+ };
1284
+ }
1285
+
1286
+ // src/environments.ts
1287
+ function scopedStorage(storage, environment, defaultEnvironment) {
1288
+ const isDefault = environment === defaultEnvironment;
1289
+ const storageKey = (key) => isDefault ? key : `${environment}:${key}`;
1290
+ return {
1291
+ async get(key) {
1292
+ return storage.get(storageKey(key));
1293
+ },
1294
+ async put(key, flag) {
1295
+ const { environment: _drop, ...rest } = flag;
1296
+ const stamped = isDefault ? rest : { ...rest, environment };
1297
+ await storage.put(storageKey(key), stamped);
1298
+ },
1299
+ async delete(key) {
1300
+ await storage.delete(storageKey(key));
1301
+ },
1302
+ async list() {
1303
+ const all = await storage.list();
1304
+ return isDefault ? all.filter((f) => !f.environment) : all.filter((f) => f.environment === environment);
1305
+ }
1306
+ };
1307
+ }
1308
+ function resolveAdminEnvironment(environments, defaultEnvironment, headers) {
1309
+ if (!environments || environments.length === 0) {
1310
+ return { ok: true, environment: defaultEnvironment };
1311
+ }
1312
+ const requested = headers.get("x-flaghoist-environment")?.trim();
1313
+ if (!requested) return { ok: true, environment: defaultEnvironment };
1314
+ if (!environments.includes(requested)) {
1315
+ return {
1316
+ ok: false,
1317
+ status: 400,
1318
+ message: `Unknown environment "${requested}". Configured environments: ${environments.join(", ")}.`
1319
+ };
1320
+ }
1321
+ return { ok: true, environment: requested };
1322
+ }
1323
+ function resolveReadEnvironment(environments, defaultEnvironment, authEnvironment) {
1324
+ if (!environments || environments.length === 0) return defaultEnvironment;
1325
+ if (authEnvironment && environments.includes(authEnvironment)) return authEnvironment;
1326
+ return defaultEnvironment;
1327
+ }
1328
+
1329
+ // src/flags.ts
1330
+ import { clampPercentage, LIMITS, parseFlag } from "@flaghoist/core";
1331
+ function buildFlag(key, body, identity, existing) {
1332
+ if (typeof body !== "object" || body === null) {
1333
+ return { ok: false, error: "Request body must be a JSON object" };
1334
+ }
1335
+ const b = body;
1336
+ const enabled = typeof b.enabled === "boolean" ? b.enabled : false;
1337
+ const rollout = typeof b.rollout === "object" && b.rollout !== null ? b.rollout : {};
1338
+ const percentage = typeof rollout.percentage === "number" ? clampPercentage(rollout.percentage) : 0;
1339
+ const description = typeof b.description === "string" ? b.description : "";
1340
+ if (description.length > LIMITS.maxDescriptionLength) {
1341
+ return { ok: false, error: `Description exceeds ${LIMITS.maxDescriptionLength} characters` };
1342
+ }
1343
+ const inputRules = Array.isArray(b.rules) ? b.rules : [];
1344
+ const nowMs = Date.now();
1345
+ const prevMs = existing ? Date.parse(existing.metadata.updatedAt) : 0;
1346
+ const updatedAt = new Date(nowMs > prevMs ? nowMs : prevMs + 1).toISOString();
1347
+ const createdAt = existing?.metadata.createdAt ?? new Date(nowMs).toISOString();
1348
+ const candidate = {
1349
+ key,
1350
+ enabled,
1351
+ rollout: { percentage },
1352
+ rules: inputRules,
1353
+ description,
1354
+ metadata: {
1355
+ createdBy: existing?.metadata.createdBy ?? identity,
1356
+ createdAt,
1357
+ updatedBy: identity,
1358
+ updatedAt
1359
+ }
1360
+ };
1361
+ const validated = parseFlag(candidate);
1362
+ if (!validated) return { ok: false, error: "Invalid flag definition" };
1363
+ if ((validated.rules?.length ?? 0) !== inputRules.length) {
1364
+ return { ok: false, error: "One or more targeting rules are invalid" };
1365
+ }
1366
+ return { ok: true, flag: validated };
1367
+ }
1368
+ function flagEtag(flag) {
1369
+ return `"${flag.metadata.updatedAt}"`;
1370
+ }
1371
+
1372
+ // src/openapi.ts
1373
+ var openApiDocument = {
1374
+ openapi: "3.1.0",
1375
+ info: {
1376
+ title: "Flaghoist API",
1377
+ version: "0.1.0",
1378
+ description: "Manage and evaluate feature flags. The admin API (`/api/v1/flags`) is Flaghoist's own; the read API (`/ofrep/v1`) follows the OpenFeature Remote Evaluation Protocol (OFREP). The unversioned `/flags` paths remain as a legacy alias of `/api/v1/flags`.",
1379
+ license: { name: "Apache-2.0", url: "https://www.apache.org/licenses/LICENSE-2.0" }
1380
+ },
1381
+ servers: [{ url: "/", description: "This Flaghoist server" }],
1382
+ tags: [
1383
+ { name: "admin", description: "Manage flag definitions (admin auth)." },
1384
+ {
1385
+ name: "evaluate",
1386
+ description: "Evaluate flags for a context (OFREP read path, API-key auth)."
1387
+ },
1388
+ { name: "meta", description: "Health and discovery." }
1389
+ ],
1390
+ paths: {
1391
+ "/api/v1/flags": {
1392
+ get: {
1393
+ tags: ["admin"],
1394
+ summary: "List all flags",
1395
+ security: [{ bearerAuth: [] }],
1396
+ responses: {
1397
+ "200": {
1398
+ description: "All flags",
1399
+ content: {
1400
+ "application/json": {
1401
+ schema: {
1402
+ type: "object",
1403
+ required: ["flags"],
1404
+ properties: {
1405
+ flags: { type: "array", items: { $ref: "#/components/schemas/FeatureFlag" } }
1406
+ }
1407
+ }
1408
+ }
1409
+ }
1410
+ },
1411
+ "401": { $ref: "#/components/responses/Unauthorized" }
1412
+ }
1413
+ }
1414
+ },
1415
+ "/api/v1/flags/{key}": {
1416
+ parameters: [{ $ref: "#/components/parameters/FlagKey" }],
1417
+ get: {
1418
+ tags: ["admin"],
1419
+ summary: "Get one flag",
1420
+ security: [{ bearerAuth: [] }],
1421
+ responses: {
1422
+ "200": {
1423
+ description: "The flag",
1424
+ content: {
1425
+ "application/json": { schema: { $ref: "#/components/schemas/FeatureFlag" } }
1426
+ }
1427
+ },
1428
+ "401": { $ref: "#/components/responses/Unauthorized" },
1429
+ "404": { $ref: "#/components/responses/NotFound" }
1430
+ }
1431
+ },
1432
+ put: {
1433
+ tags: ["admin"],
1434
+ summary: "Create or replace a flag",
1435
+ description: "A full replace. Creation metadata (createdBy/createdAt) is preserved; the updater and updatedAt are stamped server-side. Invalid targeting rules are rejected with 400.",
1436
+ security: [{ bearerAuth: [] }],
1437
+ requestBody: {
1438
+ required: true,
1439
+ content: { "application/json": { schema: { $ref: "#/components/schemas/FlagInput" } } }
1440
+ },
1441
+ responses: {
1442
+ "200": {
1443
+ description: "The stored flag",
1444
+ content: {
1445
+ "application/json": { schema: { $ref: "#/components/schemas/FeatureFlag" } }
1446
+ }
1447
+ },
1448
+ "400": { $ref: "#/components/responses/BadRequest" },
1449
+ "401": { $ref: "#/components/responses/Unauthorized" },
1450
+ "413": { description: "Payload too large" }
1451
+ }
1452
+ },
1453
+ delete: {
1454
+ tags: ["admin"],
1455
+ summary: "Delete a flag",
1456
+ security: [{ bearerAuth: [] }],
1457
+ responses: {
1458
+ "204": { description: "Deleted (idempotent)" },
1459
+ "401": { $ref: "#/components/responses/Unauthorized" }
1460
+ }
1461
+ }
1462
+ },
1463
+ "/ofrep/v1/evaluate/flags": {
1464
+ post: {
1465
+ tags: ["evaluate"],
1466
+ summary: "Evaluate all flags for a context (OFREP bulk)",
1467
+ security: [{ apiKeyAuth: [] }],
1468
+ requestBody: {
1469
+ content: {
1470
+ "application/json": { schema: { $ref: "#/components/schemas/EvaluationRequest" } }
1471
+ }
1472
+ },
1473
+ responses: {
1474
+ "200": {
1475
+ description: "Evaluated flags",
1476
+ content: {
1477
+ "application/json": {
1478
+ schema: {
1479
+ type: "object",
1480
+ properties: {
1481
+ flags: { type: "array", items: { $ref: "#/components/schemas/EvaluatedFlag" } }
1482
+ }
1483
+ }
1484
+ }
1485
+ }
1486
+ },
1487
+ "401": { $ref: "#/components/responses/Unauthorized" }
1488
+ }
1489
+ }
1490
+ },
1491
+ "/ofrep/v1/evaluate/flags/{key}": {
1492
+ parameters: [{ $ref: "#/components/parameters/FlagKey" }],
1493
+ post: {
1494
+ tags: ["evaluate"],
1495
+ summary: "Evaluate one flag (OFREP)",
1496
+ security: [{ apiKeyAuth: [] }],
1497
+ requestBody: {
1498
+ content: {
1499
+ "application/json": { schema: { $ref: "#/components/schemas/EvaluationRequest" } }
1500
+ }
1501
+ },
1502
+ responses: {
1503
+ "200": {
1504
+ description: "Evaluated flag",
1505
+ content: {
1506
+ "application/json": { schema: { $ref: "#/components/schemas/EvaluatedFlag" } }
1507
+ }
1508
+ },
1509
+ "404": { description: "Unknown flag (errorCode FLAG_NOT_FOUND)" }
1510
+ }
1511
+ }
1512
+ },
1513
+ "/api/v1/openapi.json": {
1514
+ get: {
1515
+ tags: ["meta"],
1516
+ summary: "This OpenAPI document",
1517
+ responses: { "200": { description: "The OpenAPI document" } }
1518
+ }
1519
+ },
1520
+ "/health": {
1521
+ get: {
1522
+ tags: ["meta"],
1523
+ summary: "Health check",
1524
+ responses: {
1525
+ "200": {
1526
+ description: "ok",
1527
+ content: {
1528
+ "application/json": {
1529
+ schema: { type: "object", properties: { status: { type: "string" } } }
1530
+ }
1531
+ }
1532
+ }
1533
+ }
1534
+ }
1535
+ }
1536
+ },
1537
+ components: {
1538
+ securitySchemes: {
1539
+ bearerAuth: {
1540
+ type: "http",
1541
+ scheme: "bearer",
1542
+ description: "Admin token (or a validated OIDC JWT)."
1543
+ },
1544
+ apiKeyAuth: {
1545
+ type: "apiKey",
1546
+ in: "header",
1547
+ name: "x-api-key",
1548
+ description: "Read-only API key."
1549
+ }
1550
+ },
1551
+ parameters: {
1552
+ FlagKey: {
1553
+ name: "key",
1554
+ in: "path",
1555
+ required: true,
1556
+ description: "Flag key.",
1557
+ schema: { type: "string", pattern: "^[A-Za-z0-9][A-Za-z0-9._-]*$", maxLength: 256 }
1558
+ }
1559
+ },
1560
+ responses: {
1561
+ Unauthorized: {
1562
+ description: "Missing or invalid credentials",
1563
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }
1564
+ },
1565
+ BadRequest: {
1566
+ description: "Invalid request or flag definition",
1567
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }
1568
+ },
1569
+ NotFound: {
1570
+ description: "Flag not found",
1571
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }
1572
+ }
1573
+ },
1574
+ schemas: {
1575
+ Error: { type: "object", required: ["error"], properties: { error: { type: "string" } } },
1576
+ Condition: {
1577
+ type: "object",
1578
+ required: ["attribute", "operator", "value"],
1579
+ properties: {
1580
+ attribute: { type: "string", maxLength: 256 },
1581
+ operator: {
1582
+ type: "string",
1583
+ enum: [
1584
+ "eq",
1585
+ "neq",
1586
+ "in",
1587
+ "notIn",
1588
+ "contains",
1589
+ "startsWith",
1590
+ "endsWith",
1591
+ "gt",
1592
+ "gte",
1593
+ "lt",
1594
+ "lte",
1595
+ "semverGte",
1596
+ "semverLt"
1597
+ ]
1598
+ },
1599
+ value: {
1600
+ description: "A scalar, or an array of scalars for `in` / `notIn`.",
1601
+ oneOf: [
1602
+ { type: "string" },
1603
+ { type: "number" },
1604
+ { type: "boolean" },
1605
+ { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } }
1606
+ ]
1607
+ }
1608
+ }
1609
+ },
1610
+ RuleResult: {
1611
+ type: "object",
1612
+ required: ["enabled"],
1613
+ properties: {
1614
+ enabled: { type: "boolean" },
1615
+ rollout: {
1616
+ type: "object",
1617
+ properties: { percentage: { type: "number", minimum: 0, maximum: 100 } }
1618
+ }
1619
+ }
1620
+ },
1621
+ TargetingRule: {
1622
+ type: "object",
1623
+ required: ["conditions", "result"],
1624
+ properties: {
1625
+ description: { type: "string" },
1626
+ conditions: { type: "array", items: { $ref: "#/components/schemas/Condition" } },
1627
+ result: { $ref: "#/components/schemas/RuleResult" }
1628
+ }
1629
+ },
1630
+ FlagMetadata: {
1631
+ type: "object",
1632
+ properties: {
1633
+ createdBy: { type: "string" },
1634
+ createdAt: { type: "string", format: "date-time" },
1635
+ updatedBy: { type: "string" },
1636
+ updatedAt: { type: "string", format: "date-time" }
1637
+ }
1638
+ },
1639
+ FlagInput: {
1640
+ type: "object",
1641
+ required: ["enabled", "rollout"],
1642
+ properties: {
1643
+ enabled: { type: "boolean" },
1644
+ rollout: {
1645
+ type: "object",
1646
+ required: ["percentage"],
1647
+ properties: { percentage: { type: "number", minimum: 0, maximum: 100 } }
1648
+ },
1649
+ rules: { type: "array", items: { $ref: "#/components/schemas/TargetingRule" } },
1650
+ description: { type: "string" }
1651
+ }
1652
+ },
1653
+ FeatureFlag: {
1654
+ allOf: [
1655
+ { $ref: "#/components/schemas/FlagInput" },
1656
+ {
1657
+ type: "object",
1658
+ required: ["key", "metadata"],
1659
+ properties: {
1660
+ key: { type: "string" },
1661
+ metadata: { $ref: "#/components/schemas/FlagMetadata" }
1662
+ }
1663
+ }
1664
+ ]
1665
+ },
1666
+ EvaluationRequest: {
1667
+ type: "object",
1668
+ properties: {
1669
+ context: {
1670
+ type: "object",
1671
+ description: "OpenFeature evaluation context.",
1672
+ properties: { targetingKey: { type: "string" } },
1673
+ additionalProperties: true
1674
+ }
1675
+ }
1676
+ },
1677
+ EvaluatedFlag: {
1678
+ type: "object",
1679
+ properties: {
1680
+ key: { type: "string" },
1681
+ value: { type: "boolean" },
1682
+ reason: {
1683
+ type: "string",
1684
+ enum: ["STATIC", "TARGETING_MATCH", "SPLIT", "DEFAULT"]
1685
+ },
1686
+ variant: { type: "string" }
1687
+ }
1688
+ }
1689
+ }
1690
+ }
1691
+ };
1692
+
1693
+ // src/ratelimit.ts
1694
+ function defaultRateLimitKey(headers) {
1695
+ const cf = headers.get("cf-connecting-ip");
1696
+ if (cf) return cf;
1697
+ const fwd = headers.get("x-forwarded-for");
1698
+ if (fwd) {
1699
+ const first = fwd.split(",")[0]?.trim();
1700
+ if (first) return first;
1701
+ }
1702
+ return "anonymous";
1703
+ }
1704
+ function memoryRateLimit(options = {}) {
1705
+ const max = options.max ?? 120;
1706
+ const windowMs = options.windowMs ?? 6e4;
1707
+ const maxKeys = options.maxKeys ?? 1e5;
1708
+ const hits = /* @__PURE__ */ new Map();
1709
+ const sweep = (now) => {
1710
+ for (const [key, entry] of hits) {
1711
+ if (entry.resetAt <= now) hits.delete(key);
1712
+ }
1713
+ };
1714
+ return {
1715
+ key: defaultRateLimitKey,
1716
+ check(key) {
1717
+ const now = Date.now();
1718
+ let entry = hits.get(key);
1719
+ if (!entry || entry.resetAt <= now) {
1720
+ if (!hits.has(key) && hits.size >= maxKeys) {
1721
+ sweep(now);
1722
+ if (hits.size >= maxKeys) return { ok: true };
1723
+ }
1724
+ entry = { count: 0, resetAt: now + windowMs };
1725
+ hits.set(key, entry);
1726
+ }
1727
+ entry.count += 1;
1728
+ if (entry.count > max) {
1729
+ return { ok: false, retryAfter: Math.max(1, Math.ceil((entry.resetAt - now) / 1e3)) };
1730
+ }
1731
+ return { ok: true };
1732
+ }
1733
+ };
1734
+ }
1735
+
1736
+ // src/webhooks.ts
1737
+ import { MEMBER_WEBHOOK_EVENTS, WEBHOOK_EVENTS } from "@flaghoist/core";
1738
+ var ALL_WEBHOOK_EVENTS = [...WEBHOOK_EVENTS, ...MEMBER_WEBHOOK_EVENTS];
1739
+ function createWebhookStore(storage) {
1740
+ if (storage?.putWebhook && storage?.getWebhook && storage?.deleteWebhook && storage?.listWebhooks) {
1741
+ return {
1742
+ list: () => storage.listWebhooks(),
1743
+ get: (id) => storage.getWebhook(id),
1744
+ put: (id, w) => storage.putWebhook(id, w),
1745
+ delete: (id) => storage.deleteWebhook(id)
1746
+ };
1747
+ }
1748
+ const mem = /* @__PURE__ */ new Map();
1749
+ return {
1750
+ async list() {
1751
+ return [...mem.values()];
1752
+ },
1753
+ async get(id) {
1754
+ return mem.get(id) ?? null;
1755
+ },
1756
+ async put(id, w) {
1757
+ mem.set(id, w);
1758
+ },
1759
+ async delete(id) {
1760
+ mem.delete(id);
1761
+ }
1762
+ };
1763
+ }
1764
+ var URL_MAX = 2048;
1765
+ function validateWebhookInput(input) {
1766
+ if (!input || typeof input !== "object") return { ok: false, error: "Expected an object" };
1767
+ const obj = input;
1768
+ if (typeof obj.url !== "string" || !obj.url.trim()) {
1769
+ return { ok: false, error: "url is required" };
1770
+ }
1771
+ const url = obj.url.trim();
1772
+ if (url.length > URL_MAX) return { ok: false, error: `url must be at most ${URL_MAX} characters` };
1773
+ let parsed;
1774
+ try {
1775
+ parsed = new URL(url);
1776
+ } catch {
1777
+ return { ok: false, error: "url is not a valid URL" };
1778
+ }
1779
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
1780
+ return { ok: false, error: "url must use http or https" };
1781
+ }
1782
+ let events;
1783
+ if (obj.events !== void 0) {
1784
+ if (!Array.isArray(obj.events)) return { ok: false, error: "events must be an array" };
1785
+ for (const e of obj.events) {
1786
+ if (!ALL_WEBHOOK_EVENTS.includes(e)) {
1787
+ return { ok: false, error: `Unknown event: ${String(e)}` };
1788
+ }
1789
+ }
1790
+ events = obj.events;
1791
+ }
1792
+ let enabled;
1793
+ if (obj.enabled !== void 0) {
1794
+ if (typeof obj.enabled !== "boolean") return { ok: false, error: "enabled must be a boolean" };
1795
+ enabled = obj.enabled;
1796
+ }
1797
+ return { ok: true, value: { url, events, enabled } };
1798
+ }
1799
+ async function sign(secret, body) {
1800
+ const enc = new TextEncoder();
1801
+ const key = await crypto.subtle.importKey(
1802
+ "raw",
1803
+ enc.encode(secret),
1804
+ { name: "HMAC", hash: "SHA-256" },
1805
+ false,
1806
+ ["sign"]
1807
+ );
1808
+ const sig = await crypto.subtle.sign("HMAC", key, enc.encode(body));
1809
+ const hex2 = [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
1810
+ return `sha256=${hex2}`;
1811
+ }
1812
+ async function dispatchWebhooks(store, event, payload) {
1813
+ const hooks = await store.list();
1814
+ const active = hooks.filter((h) => h.enabled && h.events.includes(event));
1815
+ if (active.length === 0) return;
1816
+ const body = JSON.stringify(payload);
1817
+ const deliveries = active.map(async (hook) => {
1818
+ try {
1819
+ const signature = await sign(hook.secret, body);
1820
+ await fetch(hook.url, {
1821
+ method: "POST",
1822
+ headers: {
1823
+ "Content-Type": "application/json",
1824
+ "X-Flaghoist-Event": event,
1825
+ "X-Flaghoist-Signature": signature,
1826
+ "X-Flaghoist-Webhook-Id": hook.id
1827
+ },
1828
+ body,
1829
+ signal: AbortSignal.timeout(1e4)
1830
+ });
1831
+ } catch {
1832
+ }
1833
+ });
1834
+ await Promise.allSettled(deliveries);
1835
+ }
1836
+ function generateId2() {
1837
+ return crypto.randomUUID().replace(/-/g, "").slice(0, 16);
1838
+ }
1839
+ function generateSecret() {
1840
+ const bytes = new Uint8Array(32);
1841
+ crypto.getRandomValues(bytes);
1842
+ return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
1843
+ }
1844
+
1845
+ // src/index.ts
1846
+ var ADMIN_TOKEN_IDENTITY = "admin token";
1847
+ var DEFAULT_CACHE_TTL_SECONDS = 30;
1848
+ var MAX_BODY_BYTES = 64 * 1024;
1849
+ async function readJsonBody(text) {
1850
+ if (new TextEncoder().encode(text).length > MAX_BODY_BYTES) {
1851
+ return { ok: false, status: 413, message: "Payload too large" };
1852
+ }
1853
+ if (!text) return { ok: true, value: {} };
1854
+ try {
1855
+ return { ok: true, value: JSON.parse(text) };
1856
+ } catch {
1857
+ return { ok: false, status: 400, message: "Invalid JSON body" };
1858
+ }
1859
+ }
1860
+ function ttlMs(cfg) {
1861
+ return (cfg.cacheTtlSeconds ?? DEFAULT_CACHE_TTL_SECONDS) * 1e3;
1862
+ }
1863
+ function resolveContext(body, cfg, headers) {
1864
+ const client = body && typeof body === "object" && "context" in body && typeof body.context === "object" ? body.context : {};
1865
+ const trusted = cfg.trustedContext?.(headers) ?? {};
1866
+ return { ...client, ...trusted };
1867
+ }
1868
+ function createFlagServer(config) {
1869
+ const caches = /* @__PURE__ */ new Map();
1870
+ function cacheFor(environment) {
1871
+ let c = caches.get(environment);
1872
+ if (!c) {
1873
+ c = createDefinitionCache();
1874
+ caches.set(environment, c);
1875
+ }
1876
+ return c;
1877
+ }
1878
+ const resolve = (env) => {
1879
+ const cfg = typeof config === "function" ? config(env) : config;
1880
+ if (cfg.users) assertUsersConfig(cfg.users, cfg.storage);
1881
+ return cfg;
1882
+ };
1883
+ const directConfig = typeof config === "function" ? null : config;
1884
+ if (directConfig?.users) assertUsersConfig(directConfig.users, directConfig.storage);
1885
+ const audit = createAuditLog(directConfig?.storage);
1886
+ const webhookStore = createWebhookStore(directConfig?.storage);
1887
+ function defaultEnvOf(cfg) {
1888
+ return cfg.defaultEnvironment ?? "production";
1889
+ }
1890
+ function snapshot(flag) {
1891
+ return { enabled: flag.enabled, rollout: flag.rollout, description: flag.description };
1892
+ }
1893
+ function fireWebhook(event, flagKey, actor, environment, current, previous) {
1894
+ const payload = {
1895
+ event,
1896
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1897
+ flag: {
1898
+ key: flagKey,
1899
+ enabled: current?.enabled ?? previous?.enabled ?? false,
1900
+ rollout: current?.rollout ?? previous?.rollout ?? { percentage: 0 },
1901
+ description: current?.description ?? previous?.description ?? ""
1902
+ },
1903
+ actor,
1904
+ previous,
1905
+ environment
1906
+ };
1907
+ dispatchWebhooks(webhookStore, event, payload).catch(() => {
1908
+ });
1909
+ }
1910
+ function fireMemberEvent(event, actor, member, previous) {
1911
+ const payload = {
1912
+ event,
1913
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1914
+ actor,
1915
+ member: {
1916
+ ...member.id ? { id: member.id } : {},
1917
+ email: member.email,
1918
+ role: member.role,
1919
+ ...member.status ? { status: member.status } : {},
1920
+ ...member.environmentRoles && Object.keys(member.environmentRoles).length > 0 ? { environmentRoles: member.environmentRoles } : {}
1921
+ },
1922
+ ...previous ? { previous } : {}
1923
+ };
1924
+ dispatchWebhooks(webhookStore, event, payload).catch(() => {
1925
+ });
1926
+ }
1927
+ function accountsOf(cfg) {
1928
+ return cfg.users ? createAccountStore(cfg.storage, cfg.users) : null;
1929
+ }
1930
+ async function authenticate(c) {
1931
+ const cfg = resolve(c.env);
1932
+ const token = extractBearer(c.req.raw.headers);
1933
+ const accounts = accountsOf(cfg);
1934
+ if (accounts && token?.startsWith(SESSION_PREFIX)) {
1935
+ const resolved = await accounts.resolveSession(token);
1936
+ if (!resolved) {
1937
+ return c.json(
1938
+ { error: "Your session has ended. Sign in again.", code: "session_expired" },
1939
+ 401
1940
+ );
1941
+ }
1942
+ const { user, session } = resolved;
1943
+ const twoFactorPending = session.via !== "sso" && accounts.twoFactorRequired(user) && !user.twoFactor;
1944
+ return {
1945
+ cfg,
1946
+ identity: user.email,
1947
+ role: user.role,
1948
+ user,
1949
+ session,
1950
+ pat: null,
1951
+ token,
1952
+ twoFactorPending
1953
+ };
1954
+ }
1955
+ if (accounts && token?.startsWith(TOKEN_PREFIX)) {
1956
+ const resolved = await accounts.resolveToken(token, async (expired) => {
1957
+ await audit.record({
1958
+ action: "token.expired",
1959
+ actor: expired.name,
1960
+ target: { type: "token", id: expired.id },
1961
+ changeDescription: `${expired.prefix}... expired`
1962
+ });
1963
+ });
1964
+ if (!resolved) {
1965
+ return c.json(
1966
+ {
1967
+ error: "This access token is not valid. It may have expired or been revoked.",
1968
+ code: "token_invalid"
1969
+ },
1970
+ 401
1971
+ );
1972
+ }
1973
+ const { user, token: pat, role: role2 } = resolved;
1974
+ return {
1975
+ cfg,
1976
+ identity: user.email,
1977
+ role: role2,
1978
+ user,
1979
+ session: null,
1980
+ pat,
1981
+ token,
1982
+ twoFactorPending: false
1983
+ };
1984
+ }
1985
+ const auth = await cfg.auth.admin(c.req.raw.headers);
1986
+ if (!auth.ok) return c.json({ error: auth.message ?? "Unauthorized" }, auth.status ?? 401);
1987
+ const role = auth.role === void 0 ? "owner" : isRole(auth.role) ? auth.role : null;
1988
+ const adminToken = accounts !== null && auth.role === void 0 && auth.identity === "admin";
1989
+ const identity = adminToken ? ADMIN_TOKEN_IDENTITY : auth.identity ?? "unknown";
1990
+ return {
1991
+ cfg,
1992
+ identity,
1993
+ role,
1994
+ user: null,
1995
+ session: null,
1996
+ pat: null,
1997
+ token,
1998
+ twoFactorPending: false
1999
+ };
2000
+ }
2001
+ async function authorize(c, permission) {
2002
+ const caller = await authenticate(c);
2003
+ if (caller instanceof Response) return caller;
2004
+ if (caller.twoFactorPending) return twoFactorSetupFirst(c);
2005
+ let role = caller.role;
2006
+ if (role !== null && caller.user && isEnvironmentPermission(permission)) {
2007
+ const env = resolveAdminEnvironment(
2008
+ caller.cfg.environments,
2009
+ defaultEnvOf(caller.cfg),
2010
+ c.req.raw.headers
2011
+ );
2012
+ if (env.ok) role = effectiveRole(caller, env.environment);
2013
+ }
2014
+ if (role === null || !can(role, permission)) {
2015
+ return c.json(
2016
+ {
2017
+ error: `This needs the ${minimumRole(permission)} role or higher.`,
2018
+ code: "insufficient_role"
2019
+ },
2020
+ 403
2021
+ );
2022
+ }
2023
+ return { ...caller, role };
2024
+ }
2025
+ function effectiveRole(caller, environment) {
2026
+ if (!caller.user) return caller.role;
2027
+ const inEnvironment = roleInEnvironment(caller.user, environment);
2028
+ return caller.pat ? lowerRole(caller.pat.role, inEnvironment) : inEnvironment;
2029
+ }
2030
+ const twoFactorSetupFirst = (c) => c.json(
2031
+ {
2032
+ error: "Your role needs two-factor sign-in. Set it up on your Account page to continue.",
2033
+ code: "two_factor_setup_required"
2034
+ },
2035
+ 403
2036
+ );
2037
+ const app = new Hono();
2038
+ const csp = [
2039
+ "default-src 'none'",
2040
+ "script-src 'unsafe-inline'",
2041
+ "style-src 'unsafe-inline'",
2042
+ "connect-src *",
2043
+ "img-src data:",
2044
+ "font-src data:",
2045
+ "frame-ancestors 'none'",
2046
+ "base-uri 'none'",
2047
+ "form-action 'none'"
2048
+ ].join("; ");
2049
+ app.use("*", async (c, next) => {
2050
+ await next();
2051
+ c.header("X-Content-Type-Options", "nosniff");
2052
+ c.header("X-Frame-Options", "DENY");
2053
+ c.header("Content-Security-Policy", csp);
2054
+ c.header("Referrer-Policy", "no-referrer");
2055
+ c.header("Permissions-Policy", "geolocation=(), microphone=(), camera=(), payment=(), usb=()");
2056
+ });
2057
+ app.use("*", async (c, next) => {
2058
+ const cfg = resolve(c.env);
2059
+ const origin = c.req.header("Origin");
2060
+ if (origin && cfg.allowedOrigins?.includes(origin)) {
2061
+ c.header("Access-Control-Allow-Origin", origin);
2062
+ c.header("Vary", "Origin");
2063
+ }
2064
+ if (c.req.method === "OPTIONS") {
2065
+ c.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
2066
+ c.header(
2067
+ "Access-Control-Allow-Headers",
2068
+ "Content-Type, Authorization, x-api-key, If-Match, X-Flaghoist-Environment"
2069
+ );
2070
+ return c.body(null, 204);
2071
+ }
2072
+ return next();
2073
+ });
2074
+ app.use("*", async (c, next) => {
2075
+ const cfg = resolve(c.env);
2076
+ if (!cfg.rateLimit || c.req.path === "/health") return next();
2077
+ const deriveKey = cfg.rateLimit.key ?? defaultRateLimitKey;
2078
+ const result = await cfg.rateLimit.check(deriveKey(c.req.raw.headers));
2079
+ if (!result.ok) {
2080
+ if (result.retryAfter) c.header("Retry-After", String(result.retryAfter));
2081
+ return c.json({ error: "Too many requests" }, 429);
2082
+ }
2083
+ return next();
2084
+ });
2085
+ app.get("/health", (c) => c.json({ status: "ok" }));
2086
+ app.get("/api/v1/openapi.json", (c) => {
2087
+ const cfg = resolve(c.env);
2088
+ if (cfg.exposeOpenApi === false) return c.text("Not found", 404);
2089
+ return c.json(openApiDocument);
2090
+ });
2091
+ const dashboardLimiter = memoryRateLimit({ max: 30, windowMs: 6e4 });
2092
+ app.get("/admin", async (c) => {
2093
+ const cfg = resolve(c.env);
2094
+ if (!cfg.dashboard) return c.text("Dashboard not configured", 404);
2095
+ const ip = defaultRateLimitKey(c.req.raw.headers);
2096
+ const result = dashboardLimiter.check(`admin:${ip}`);
2097
+ if (!result.ok) {
2098
+ if (result.retryAfter) c.header("Retry-After", String(result.retryAfter));
2099
+ return c.text("Too many requests", 429);
2100
+ }
2101
+ return c.html(cfg.dashboard);
2102
+ });
2103
+ app.get("/admin/*", async (c) => {
2104
+ const cfg = resolve(c.env);
2105
+ if (!cfg.dashboard) return c.text("Dashboard not configured", 404);
2106
+ const ip = defaultRateLimitKey(c.req.raw.headers);
2107
+ const result = dashboardLimiter.check(`admin:${ip}`);
2108
+ if (!result.ok) {
2109
+ if (result.retryAfter) c.header("Retry-After", String(result.retryAfter));
2110
+ return c.text("Too many requests", 429);
2111
+ }
2112
+ return c.html(cfg.dashboard);
2113
+ });
2114
+ const wireReason = (reason) => reason === "DISABLED" ? "STATIC" : reason;
2115
+ app.post("/ofrep/v1/evaluate/flags", async (c) => {
2116
+ const cfg = resolve(c.env);
2117
+ const auth = await cfg.auth.read(c.req.raw.headers);
2118
+ if (!auth.ok) {
2119
+ return c.json({ errorCode: "GENERAL", errorDetails: auth.message }, auth.status ?? 401);
2120
+ }
2121
+ const parsed = await readJsonBody(await c.req.text());
2122
+ if (!parsed.ok) {
2123
+ const errorCode = parsed.status === 413 ? "GENERAL" : "PARSE_ERROR";
2124
+ return c.json({ errorCode, errorDetails: parsed.message }, parsed.status);
2125
+ }
2126
+ const context = resolveContext(parsed.value, cfg, c.req.raw.headers);
2127
+ const defaultEnv = defaultEnvOf(cfg);
2128
+ const environment = resolveReadEnvironment(cfg.environments, defaultEnv, auth.environment);
2129
+ const scoped = scopedStorage(cfg.storage, environment, defaultEnv);
2130
+ const flags = await cacheFor(environment).load(scoped, ttlMs(cfg));
2131
+ const results = await Promise.all(
2132
+ flags.map(async (flag) => {
2133
+ const result = await evaluate(flag, context);
2134
+ return {
2135
+ key: flag.key,
2136
+ value: result.value,
2137
+ reason: wireReason(result.reason),
2138
+ variant: result.value ? "on" : "off"
2139
+ };
2140
+ })
2141
+ );
2142
+ return c.json({ flags: results });
2143
+ });
2144
+ app.post("/ofrep/v1/evaluate/flags/:key", async (c) => {
2145
+ const cfg = resolve(c.env);
2146
+ const key = c.req.param("key");
2147
+ const auth = await cfg.auth.read(c.req.raw.headers);
2148
+ if (!auth.ok) {
2149
+ return c.json({ key, errorCode: "GENERAL", errorDetails: auth.message }, auth.status ?? 401);
2150
+ }
2151
+ const parsed = await readJsonBody(await c.req.text());
2152
+ if (!parsed.ok) {
2153
+ const errorCode = parsed.status === 413 ? "GENERAL" : "PARSE_ERROR";
2154
+ return c.json({ key, errorCode, errorDetails: parsed.message }, parsed.status);
2155
+ }
2156
+ const context = resolveContext(parsed.value, cfg, c.req.raw.headers);
2157
+ const defaultEnv = defaultEnvOf(cfg);
2158
+ const environment = resolveReadEnvironment(cfg.environments, defaultEnv, auth.environment);
2159
+ const scoped = scopedStorage(cfg.storage, environment, defaultEnv);
2160
+ const flags = await cacheFor(environment).load(scoped, ttlMs(cfg));
2161
+ const flag = flags.find((f) => f.key === key);
2162
+ if (!flag) {
2163
+ return c.json(
2164
+ { key, errorCode: "FLAG_NOT_FOUND", errorDetails: `Flag ${key} not found` },
2165
+ 404
2166
+ );
2167
+ }
2168
+ const result = await evaluate(flag, context);
2169
+ return c.json({
2170
+ key,
2171
+ value: result.value,
2172
+ reason: wireReason(result.reason),
2173
+ variant: result.value ? "on" : "off"
2174
+ });
2175
+ });
2176
+ const registerAdmin = (prefix) => {
2177
+ app.get(`${prefix}/flags`, async (c) => {
2178
+ const authorized = await authorize(c, "flags:read");
2179
+ if (authorized instanceof Response) return authorized;
2180
+ const { cfg } = authorized;
2181
+ const defaultEnv = defaultEnvOf(cfg);
2182
+ const envResult = resolveAdminEnvironment(cfg.environments, defaultEnv, c.req.raw.headers);
2183
+ if (!envResult.ok) return c.json({ error: envResult.message }, envResult.status);
2184
+ const scoped = scopedStorage(cfg.storage, envResult.environment, defaultEnv);
2185
+ const all = await scoped.list();
2186
+ const includeArchived = c.req.query("includeArchived") === "true";
2187
+ return c.json({ flags: includeArchived ? all : all.filter((f) => !f.archived) });
2188
+ });
2189
+ app.get(`${prefix}/flags/:key`, async (c) => {
2190
+ const authorized = await authorize(c, "flags:read");
2191
+ if (authorized instanceof Response) return authorized;
2192
+ const { cfg } = authorized;
2193
+ const defaultEnv = defaultEnvOf(cfg);
2194
+ const envResult = resolveAdminEnvironment(cfg.environments, defaultEnv, c.req.raw.headers);
2195
+ if (!envResult.ok) return c.json({ error: envResult.message }, envResult.status);
2196
+ const scoped = scopedStorage(cfg.storage, envResult.environment, defaultEnv);
2197
+ const flag = await scoped.get(c.req.param("key"));
2198
+ if (!flag) return c.json({ error: "Flag not found" }, 404);
2199
+ c.header("ETag", flagEtag(flag));
2200
+ return c.json(flag);
2201
+ });
2202
+ app.put(`${prefix}/flags/:key`, async (c) => {
2203
+ const authorized = await authorize(c, "flags:write");
2204
+ if (authorized instanceof Response) return authorized;
2205
+ const { cfg, identity } = authorized;
2206
+ const defaultEnv = defaultEnvOf(cfg);
2207
+ const envResult = resolveAdminEnvironment(cfg.environments, defaultEnv, c.req.raw.headers);
2208
+ if (!envResult.ok) return c.json({ error: envResult.message }, envResult.status);
2209
+ const environment = envResult.environment;
2210
+ const scoped = scopedStorage(cfg.storage, environment, defaultEnv);
2211
+ const key = c.req.param("key");
2212
+ const existing = await scoped.get(key);
2213
+ const ifMatch = c.req.header("If-Match")?.trim();
2214
+ if (ifMatch !== void 0) {
2215
+ const precondition = ifMatch === "*" ? existing !== null : existing !== null && flagEtag(existing) === ifMatch;
2216
+ if (!precondition) {
2217
+ return c.json(
2218
+ { error: "This flag changed since you loaded it. Reload and reapply your change." },
2219
+ 412
2220
+ );
2221
+ }
2222
+ }
2223
+ const parsed = await readJsonBody(await c.req.text());
2224
+ if (!parsed.ok) return c.json({ error: parsed.message }, parsed.status);
2225
+ const changeDescription = parsed.value && typeof parsed.value === "object" && typeof parsed.value.changeDescription === "string" ? parsed.value.changeDescription.trim() || void 0 : void 0;
2226
+ const built = buildFlag(key, parsed.value, identity, existing);
2227
+ if (!built.ok) return c.json({ error: built.error }, 400);
2228
+ await scoped.put(key, built.flag);
2229
+ cacheFor(environment).invalidate();
2230
+ const flagAction = existing ? "update" : "create";
2231
+ const auditEnv = cfg.environments ? environment : void 0;
2232
+ await audit.record({
2233
+ action: flagAction,
2234
+ flagKey: key,
2235
+ actor: identity,
2236
+ previous: existing ? snapshot(existing) : void 0,
2237
+ current: snapshot(built.flag),
2238
+ changeDescription,
2239
+ environment: auditEnv
2240
+ });
2241
+ fireWebhook(
2242
+ `flag.${flagAction === "update" ? "updated" : "created"}`,
2243
+ key,
2244
+ identity,
2245
+ auditEnv,
2246
+ snapshot(built.flag),
2247
+ existing ? snapshot(existing) : void 0
2248
+ );
2249
+ c.header("ETag", flagEtag(built.flag));
2250
+ return c.json(built.flag);
2251
+ });
2252
+ app.post(`${prefix}/flags/:key/archive`, async (c) => {
2253
+ const authorized = await authorize(c, "flags:write");
2254
+ if (authorized instanceof Response) return authorized;
2255
+ const { cfg, identity } = authorized;
2256
+ const defaultEnv = defaultEnvOf(cfg);
2257
+ const envResult = resolveAdminEnvironment(cfg.environments, defaultEnv, c.req.raw.headers);
2258
+ if (!envResult.ok) return c.json({ error: envResult.message }, envResult.status);
2259
+ const environment = envResult.environment;
2260
+ const scoped = scopedStorage(cfg.storage, environment, defaultEnv);
2261
+ const key = c.req.param("key");
2262
+ const existing = await scoped.get(key);
2263
+ if (!existing) return c.json({ error: "Flag not found" }, 404);
2264
+ if (existing.archived) return c.json({ error: "Flag is already archived" }, 409);
2265
+ const archived = {
2266
+ ...existing,
2267
+ archived: true,
2268
+ archivedAt: (/* @__PURE__ */ new Date()).toISOString()
2269
+ };
2270
+ await scoped.put(key, archived);
2271
+ cacheFor(environment).invalidate();
2272
+ const auditEnv = cfg.environments ? environment : void 0;
2273
+ await audit.record({
2274
+ action: "archive",
2275
+ flagKey: key,
2276
+ actor: identity,
2277
+ previous: snapshot(existing),
2278
+ environment: auditEnv
2279
+ });
2280
+ fireWebhook("flag.archived", key, identity, auditEnv, void 0, snapshot(existing));
2281
+ return c.json(archived);
2282
+ });
2283
+ app.post(`${prefix}/flags/:key/restore`, async (c) => {
2284
+ const authorized = await authorize(c, "flags:write");
2285
+ if (authorized instanceof Response) return authorized;
2286
+ const { cfg, identity } = authorized;
2287
+ const defaultEnv = defaultEnvOf(cfg);
2288
+ const envResult = resolveAdminEnvironment(cfg.environments, defaultEnv, c.req.raw.headers);
2289
+ if (!envResult.ok) return c.json({ error: envResult.message }, envResult.status);
2290
+ const environment = envResult.environment;
2291
+ const scoped = scopedStorage(cfg.storage, environment, defaultEnv);
2292
+ const key = c.req.param("key");
2293
+ const existing = await scoped.get(key);
2294
+ if (!existing) return c.json({ error: "Flag not found" }, 404);
2295
+ if (!existing.archived) return c.json({ error: "Flag is not archived" }, 409);
2296
+ const { archived: _, archivedAt: __, ...rest } = existing;
2297
+ const restored = rest;
2298
+ await scoped.put(key, restored);
2299
+ cacheFor(environment).invalidate();
2300
+ const auditEnv = cfg.environments ? environment : void 0;
2301
+ await audit.record({
2302
+ action: "restore",
2303
+ flagKey: key,
2304
+ actor: identity,
2305
+ current: snapshot(restored),
2306
+ environment: auditEnv
2307
+ });
2308
+ fireWebhook("flag.restored", key, identity, auditEnv, snapshot(restored));
2309
+ return c.json(restored);
2310
+ });
2311
+ app.delete(`${prefix}/flags/:key`, async (c) => {
2312
+ const authorized = await authorize(c, "flags:delete");
2313
+ if (authorized instanceof Response) return authorized;
2314
+ const { cfg, identity } = authorized;
2315
+ const defaultEnv = defaultEnvOf(cfg);
2316
+ const envResult = resolveAdminEnvironment(cfg.environments, defaultEnv, c.req.raw.headers);
2317
+ if (!envResult.ok) return c.json({ error: envResult.message }, envResult.status);
2318
+ const environment = envResult.environment;
2319
+ const scoped = scopedStorage(cfg.storage, environment, defaultEnv);
2320
+ const key = c.req.param("key");
2321
+ const existing = await scoped.get(key);
2322
+ await scoped.delete(key);
2323
+ cacheFor(environment).invalidate();
2324
+ const auditEnv = cfg.environments ? environment : void 0;
2325
+ await audit.record({
2326
+ action: "delete",
2327
+ flagKey: key,
2328
+ actor: identity,
2329
+ previous: existing ? snapshot(existing) : void 0,
2330
+ environment: auditEnv
2331
+ });
2332
+ if (existing) {
2333
+ fireWebhook("flag.deleted", key, identity, auditEnv, void 0, snapshot(existing));
2334
+ }
2335
+ return c.body(null, 204);
2336
+ });
2337
+ app.get(`${prefix}/export`, async (c) => {
2338
+ const authorized = await authorize(c, "flags:read");
2339
+ if (authorized instanceof Response) return authorized;
2340
+ const { cfg } = authorized;
2341
+ const defaultEnv = defaultEnvOf(cfg);
2342
+ const envResult = resolveAdminEnvironment(cfg.environments, defaultEnv, c.req.raw.headers);
2343
+ if (!envResult.ok) return c.json({ error: envResult.message }, envResult.status);
2344
+ const scoped = scopedStorage(cfg.storage, envResult.environment, defaultEnv);
2345
+ const all = await scoped.list();
2346
+ const active = all.filter((f) => !f.archived);
2347
+ const payload = {
2348
+ version: 1,
2349
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
2350
+ flags: active.map((f) => ({
2351
+ key: f.key,
2352
+ enabled: f.enabled,
2353
+ rollout: f.rollout,
2354
+ description: f.description,
2355
+ ...f.rules && f.rules.length > 0 ? { rules: f.rules } : {}
2356
+ }))
2357
+ };
2358
+ c.header("Content-Disposition", 'attachment; filename="flaghoist-export.json"');
2359
+ return c.json(payload);
2360
+ });
2361
+ app.post(`${prefix}/import`, async (c) => {
2362
+ const authorized = await authorize(c, "flags:import");
2363
+ if (authorized instanceof Response) return authorized;
2364
+ const { cfg, identity } = authorized;
2365
+ const defaultEnv = defaultEnvOf(cfg);
2366
+ const envResult = resolveAdminEnvironment(cfg.environments, defaultEnv, c.req.raw.headers);
2367
+ if (!envResult.ok) return c.json({ error: envResult.message }, envResult.status);
2368
+ const environment = envResult.environment;
2369
+ const scoped = scopedStorage(cfg.storage, environment, defaultEnv);
2370
+ const parsed = await readJsonBody(await c.req.text());
2371
+ if (!parsed.ok) return c.json({ error: parsed.message }, parsed.status);
2372
+ const body = parsed.value;
2373
+ if (!body || typeof body !== "object" || !Array.isArray(body.flags)) {
2374
+ return c.json({ error: "Expected { flags: [...] }" }, 400);
2375
+ }
2376
+ const incoming = body.flags;
2377
+ if (incoming.length > 500) {
2378
+ return c.json({ error: "Import limited to 500 flags" }, 400);
2379
+ }
2380
+ const auditEnv = cfg.environments ? environment : void 0;
2381
+ let created = 0;
2382
+ let updated = 0;
2383
+ const errors = [];
2384
+ for (const raw of incoming) {
2385
+ const obj = raw;
2386
+ if (!obj || typeof obj !== "object" || typeof obj.key !== "string") {
2387
+ errors.push({ key: String(obj?.key ?? "(missing)"), error: "Missing or invalid key" });
2388
+ continue;
2389
+ }
2390
+ const existing = await scoped.get(obj.key);
2391
+ const built = buildFlag(obj.key, obj, identity, existing);
2392
+ if (!built.ok) {
2393
+ errors.push({ key: obj.key, error: built.error });
2394
+ continue;
2395
+ }
2396
+ await scoped.put(obj.key, built.flag);
2397
+ await audit.record({
2398
+ action: existing ? "update" : "create",
2399
+ flagKey: obj.key,
2400
+ actor: identity,
2401
+ previous: existing ? snapshot(existing) : void 0,
2402
+ current: snapshot(built.flag),
2403
+ changeDescription: "Bulk import",
2404
+ environment: auditEnv
2405
+ });
2406
+ if (existing) updated++;
2407
+ else created++;
2408
+ }
2409
+ cacheFor(environment).invalidate();
2410
+ return c.json({ created, updated, errors });
2411
+ });
2412
+ app.get(`${prefix}/audit`, async (c) => {
2413
+ const security = c.req.query("category") === "security";
2414
+ const authorized = await authorize(c, security ? "audit:security" : "audit:read");
2415
+ if (authorized instanceof Response) return authorized;
2416
+ const { cfg } = authorized;
2417
+ const defaultEnv = defaultEnvOf(cfg);
2418
+ const envResult = resolveAdminEnvironment(cfg.environments, defaultEnv, c.req.raw.headers);
2419
+ if (!envResult.ok) return c.json({ error: envResult.message }, envResult.status);
2420
+ const limit = Math.min(Math.max(parseInt(c.req.query("limit") ?? "50", 10) || 50, 1), 200);
2421
+ const offset = Math.max(parseInt(c.req.query("offset") ?? "0", 10) || 0, 0);
2422
+ const category = security ? "security" : "flags";
2423
+ const action = c.req.query("action");
2424
+ const validAction = action && auditCategory2(action) === category ? action : void 0;
2425
+ if (security) {
2426
+ const page2 = await audit.list({ limit, offset, category, action: validAction });
2427
+ return c.json({ ...page2, entries: page2.entries.filter((e) => !e.flagKey) });
2428
+ }
2429
+ const flagKey = c.req.query("flagKey") || void 0;
2430
+ const environment = cfg.environments ? envResult.environment : void 0;
2431
+ const page = await audit.list({
2432
+ limit,
2433
+ offset,
2434
+ category,
2435
+ flagKey,
2436
+ action: validAction,
2437
+ environment
2438
+ });
2439
+ return c.json({ ...page, entries: page.entries.filter((e) => e.flagKey !== void 0) });
2440
+ });
2441
+ app.get(`${prefix}/environments`, async (c) => {
2442
+ const authorized = await authorize(c, "flags:read");
2443
+ if (authorized instanceof Response) return authorized;
2444
+ const { cfg } = authorized;
2445
+ const defaultEnvironment = defaultEnvOf(cfg);
2446
+ const environments = cfg.environments && cfg.environments.length > 0 ? cfg.environments : [defaultEnvironment];
2447
+ return c.json({ environments, default: defaultEnvironment });
2448
+ });
2449
+ app.get(`${prefix}/webhooks`, async (c) => {
2450
+ const authorized = await authorize(c, "webhooks:manage");
2451
+ if (authorized instanceof Response) return authorized;
2452
+ return c.json({ webhooks: await webhookStore.list() });
2453
+ });
2454
+ app.get(`${prefix}/webhooks/:id`, async (c) => {
2455
+ const authorized = await authorize(c, "webhooks:manage");
2456
+ if (authorized instanceof Response) return authorized;
2457
+ const hook = await webhookStore.get(c.req.param("id"));
2458
+ if (!hook) return c.json({ error: "Webhook not found" }, 404);
2459
+ return c.json(hook);
2460
+ });
2461
+ app.post(`${prefix}/webhooks`, async (c) => {
2462
+ const authorized = await authorize(c, "webhooks:manage");
2463
+ if (authorized instanceof Response) return authorized;
2464
+ const parsed = await readJsonBody(await c.req.text());
2465
+ if (!parsed.ok) return c.json({ error: parsed.message }, parsed.status);
2466
+ const validated = validateWebhookInput(parsed.value);
2467
+ if (!validated.ok) return c.json({ error: validated.error }, 400);
2468
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2469
+ const hook = {
2470
+ id: generateId2(),
2471
+ url: validated.value.url,
2472
+ secret: generateSecret(),
2473
+ events: validated.value.events ?? [...WEBHOOK_EVENTS2],
2474
+ enabled: validated.value.enabled ?? true,
2475
+ createdAt: now,
2476
+ updatedAt: now
2477
+ };
2478
+ await webhookStore.put(hook.id, hook);
2479
+ await audit.record({
2480
+ action: "webhook.created",
2481
+ target: { type: "webhook", id: hook.id },
2482
+ actor: authorized.identity,
2483
+ changeDescription: hook.url
2484
+ });
2485
+ return c.json(hook, 201);
2486
+ });
2487
+ app.put(`${prefix}/webhooks/:id`, async (c) => {
2488
+ const authorized = await authorize(c, "webhooks:manage");
2489
+ if (authorized instanceof Response) return authorized;
2490
+ const id = c.req.param("id");
2491
+ const existing = await webhookStore.get(id);
2492
+ if (!existing) return c.json({ error: "Webhook not found" }, 404);
2493
+ const parsed = await readJsonBody(await c.req.text());
2494
+ if (!parsed.ok) return c.json({ error: parsed.message }, parsed.status);
2495
+ const obj = parsed.value;
2496
+ if (obj.url !== void 0) {
2497
+ const v = validateWebhookInput({ url: obj.url });
2498
+ if (!v.ok) return c.json({ error: v.error }, 400);
2499
+ existing.url = v.value.url;
2500
+ }
2501
+ if (obj.events !== void 0) {
2502
+ if (!Array.isArray(obj.events)) return c.json({ error: "events must be an array" }, 400);
2503
+ for (const e of obj.events) {
2504
+ if (!ALL_WEBHOOK_EVENTS.includes(e)) {
2505
+ return c.json({ error: `Unknown event: ${String(e)}` }, 400);
2506
+ }
2507
+ }
2508
+ existing.events = obj.events;
2509
+ }
2510
+ if (obj.enabled !== void 0) {
2511
+ if (typeof obj.enabled !== "boolean") {
2512
+ return c.json({ error: "enabled must be a boolean" }, 400);
2513
+ }
2514
+ existing.enabled = obj.enabled;
2515
+ }
2516
+ existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2517
+ await webhookStore.put(id, existing);
2518
+ await audit.record({
2519
+ action: "webhook.updated",
2520
+ target: { type: "webhook", id },
2521
+ actor: authorized.identity,
2522
+ changeDescription: existing.url
2523
+ });
2524
+ return c.json(existing);
2525
+ });
2526
+ app.delete(`${prefix}/webhooks/:id`, async (c) => {
2527
+ const authorized = await authorize(c, "webhooks:manage");
2528
+ if (authorized instanceof Response) return authorized;
2529
+ const id = c.req.param("id");
2530
+ const existing = await webhookStore.get(id);
2531
+ if (!existing) return c.json({ error: "Webhook not found" }, 404);
2532
+ await webhookStore.delete(id);
2533
+ await audit.record({
2534
+ action: "webhook.deleted",
2535
+ target: { type: "webhook", id },
2536
+ actor: authorized.identity,
2537
+ changeDescription: existing.url
2538
+ });
2539
+ return c.body(null, 204);
2540
+ });
2541
+ app.post(`${prefix}/webhooks/:id/test`, async (c) => {
2542
+ const authorized = await authorize(c, "webhooks:manage");
2543
+ if (authorized instanceof Response) return authorized;
2544
+ const { identity } = authorized;
2545
+ const hook = await webhookStore.get(c.req.param("id"));
2546
+ if (!hook) return c.json({ error: "Webhook not found" }, 404);
2547
+ const payload = {
2548
+ event: "flag.updated",
2549
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2550
+ flag: {
2551
+ key: "test-flag",
2552
+ enabled: true,
2553
+ rollout: { percentage: 100 },
2554
+ description: "Test webhook delivery"
2555
+ },
2556
+ actor: identity
2557
+ };
2558
+ try {
2559
+ const body = JSON.stringify(payload);
2560
+ const signature = await sign(hook.secret, body);
2561
+ const res = await fetch(hook.url, {
2562
+ method: "POST",
2563
+ headers: {
2564
+ "Content-Type": "application/json",
2565
+ "X-Flaghoist-Event": "flag.updated",
2566
+ "X-Flaghoist-Signature": signature,
2567
+ "X-Flaghoist-Webhook-Id": hook.id
2568
+ },
2569
+ body,
2570
+ signal: AbortSignal.timeout(1e4)
2571
+ });
2572
+ return c.json({ status: res.status, ok: res.ok });
2573
+ } catch (err) {
2574
+ const message = err instanceof Error ? err.message : "Request failed";
2575
+ return c.json({ status: 0, ok: false, error: message });
2576
+ }
2577
+ });
2578
+ };
2579
+ const accountsOff = (c) => c.json({ error: "Accounts are not enabled on this server.", code: "accounts_disabled" }, 404);
2580
+ async function readBody(c) {
2581
+ const parsed = await readJsonBody(await c.req.text());
2582
+ if (!parsed.ok) return c.json({ error: parsed.message }, parsed.status);
2583
+ const value = parsed.value;
2584
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2585
+ return c.json({ error: "Expected a JSON object" }, 400);
2586
+ }
2587
+ return value;
2588
+ }
2589
+ function clientIp(c) {
2590
+ return defaultRateLimitKey(c.req.raw.headers);
2591
+ }
2592
+ function fromIp(ip) {
2593
+ return ip === "anonymous" ? void 0 : `From ${ip}`;
2594
+ }
2595
+ function throttledResponse(c, retryAfterSeconds) {
2596
+ const minutes = Math.ceil(retryAfterSeconds / 60);
2597
+ c.header("Retry-After", String(retryAfterSeconds));
2598
+ return c.json(
2599
+ {
2600
+ error: `Too many sign-in attempts. Try again in ${minutes} minute${minutes === 1 ? "" : "s"}.`,
2601
+ code: "login_throttled"
2602
+ },
2603
+ 429
2604
+ );
2605
+ }
2606
+ const passwordOff = (cfg) => cfg.users?.sso?.passwordSignIn === false;
2607
+ const passwordDisabled = (c, cfg, extra = "") => c.json(
2608
+ {
2609
+ error: `Password sign-in is turned off. Continue with ${cfg.users?.sso?.label ?? "SSO"}${extra}.`,
2610
+ code: "password_disabled"
2611
+ },
2612
+ 409
2613
+ );
2614
+ app.get("/api/v1/auth/config", async (c) => {
2615
+ const cfg = resolve(c.env);
2616
+ const accounts = accountsOf(cfg);
2617
+ if (!accounts) return c.json({ accounts: false });
2618
+ return c.json({
2619
+ accounts: true,
2620
+ password: { kdf: PASSWORD_KDF, iterations: PASSWORD_ITERATIONS },
2621
+ passwordSignIn: !passwordOff(cfg),
2622
+ sso: cfg.users?.sso ? { label: cfg.users.sso.label ?? "SSO" } : null,
2623
+ setupRequired: await accounts.count() === 0
2624
+ });
2625
+ });
2626
+ app.post("/api/v1/auth/prelogin", async (c) => {
2627
+ const cfg = resolve(c.env);
2628
+ const accounts = accountsOf(cfg);
2629
+ if (!accounts) return accountsOff(c);
2630
+ if (passwordOff(cfg)) return passwordDisabled(c, cfg);
2631
+ const body = await readBody(c);
2632
+ if (body instanceof Response) return body;
2633
+ const email = normalizeEmail(body.email);
2634
+ if (!email) return c.json({ error: "Enter a valid email address." }, 400);
2635
+ return c.json(await accounts.passwordParams(email));
2636
+ });
2637
+ app.post("/api/v1/auth/login", async (c) => {
2638
+ const cfg = resolve(c.env);
2639
+ const accounts = accountsOf(cfg);
2640
+ if (!accounts) return accountsOff(c);
2641
+ if (passwordOff(cfg)) return passwordDisabled(c, cfg);
2642
+ const body = await readBody(c);
2643
+ if (body instanceof Response) return body;
2644
+ const email = normalizeEmail(body.email);
2645
+ const clientKey = decodeClientKey(body.clientKey);
2646
+ if (!email || !clientKey) return c.json({ error: "Enter your email and password." }, 400);
2647
+ const ip = clientIp(c);
2648
+ const throttle = await accounts.throttled(email, ip);
2649
+ if (!throttle.ok) return throttledResponse(c, throttle.retryAfterSeconds);
2650
+ const user = await accounts.findByEmail(email);
2651
+ if (!await accounts.checkPassword(user, clientKey)) {
2652
+ await accounts.recordFailure(email, ip);
2653
+ await audit.record({
2654
+ action: "login.failed",
2655
+ actor: email,
2656
+ ...user ? { target: { type: "user", id: user.id } } : {},
2657
+ changeDescription: user?.status === "disabled" ? "Account is disabled" : fromIp(ip)
2658
+ });
2659
+ return c.json({ error: "Invalid email or password.", code: "invalid_credentials" }, 401);
2660
+ }
2661
+ const signedIn = user;
2662
+ if (signedIn.twoFactor) {
2663
+ return c.json({
2664
+ twoFactorRequired: true,
2665
+ challenge: await twoFactorChallenge(cfg, signedIn)
2666
+ });
2667
+ }
2668
+ await accounts.clearFailures(email);
2669
+ await accounts.recordLogin(signedIn);
2670
+ const { token, session } = await accounts.createSession(signedIn, c.req.header("user-agent"));
2671
+ await audit.record({
2672
+ action: "login",
2673
+ actor: signedIn.email,
2674
+ target: { type: "user", id: signedIn.id },
2675
+ changeDescription: fromIp(ip)
2676
+ });
2677
+ return c.json({ token, expiresAt: session.expiresAt, user: publicUser(signedIn) });
2678
+ });
2679
+ function twoFactorChallenge(cfg, user) {
2680
+ return seal(cfg.users.pepper, "two-factor-challenge", {
2681
+ exp: Date.now() + 5 * 6e4,
2682
+ userId: user.id
2683
+ });
2684
+ }
2685
+ app.post("/api/v1/auth/login/two-factor", async (c) => {
2686
+ const cfg = resolve(c.env);
2687
+ const accounts = accountsOf(cfg);
2688
+ if (!cfg.users || !accounts) return accountsOff(c);
2689
+ const body = await readBody(c);
2690
+ if (body instanceof Response) return body;
2691
+ const challenge = typeof body.challenge === "string" ? body.challenge : "";
2692
+ const code = typeof body.code === "string" ? body.code : "";
2693
+ const opened = challenge ? await unseal(
2694
+ cfg.users.pepper,
2695
+ "two-factor-challenge",
2696
+ challenge
2697
+ ) : null;
2698
+ const user = opened ? await accounts.getUser(opened.userId) : null;
2699
+ if (!user || user.status !== "active" || !user.twoFactor) {
2700
+ return c.json(
2701
+ {
2702
+ error: "This sign-in has expired. Enter your password again.",
2703
+ code: "challenge_expired"
2704
+ },
2705
+ 401
2706
+ );
2707
+ }
2708
+ const ip = clientIp(c);
2709
+ const throttle = await accounts.throttled(user.email, ip);
2710
+ if (!throttle.ok) return throttledResponse(c, throttle.retryAfterSeconds);
2711
+ const used = await accounts.checkSecondFactor(user, code);
2712
+ if (!used) {
2713
+ await accounts.recordFailure(user.email, ip);
2714
+ await audit.record({
2715
+ action: "login.failed",
2716
+ actor: user.email,
2717
+ target: { type: "user", id: user.id },
2718
+ changeDescription: ["Wrong two-factor code", fromIp(ip)].filter(Boolean).join(". ")
2719
+ });
2720
+ return c.json(
2721
+ {
2722
+ error: "That code is not right. Enter the current one from your app.",
2723
+ code: "invalid_code"
2724
+ },
2725
+ 401
2726
+ );
2727
+ }
2728
+ const fresh = await accounts.getUser(user.id) ?? user;
2729
+ await accounts.clearFailures(user.email);
2730
+ await accounts.recordLogin(fresh);
2731
+ const { token, session } = await accounts.createSession(fresh, c.req.header("user-agent"));
2732
+ await audit.record({
2733
+ action: "login",
2734
+ actor: user.email,
2735
+ target: { type: "user", id: user.id },
2736
+ changeDescription: ["With two-factor", fromIp(ip)].filter(Boolean).join(". ")
2737
+ });
2738
+ if (used === "recovery") {
2739
+ const left = fresh.twoFactor?.recoveryCodes.length ?? 0;
2740
+ await audit.record({
2741
+ action: "two_factor.recovery_used",
2742
+ actor: user.email,
2743
+ target: { type: "user", id: user.id },
2744
+ changeDescription: `${left} recovery code${left === 1 ? "" : "s"} left`
2745
+ });
2746
+ }
2747
+ return c.json({ token, expiresAt: session.expiresAt, user: publicUser(fresh) });
2748
+ });
2749
+ app.post("/api/v1/auth/logout", async (c) => {
2750
+ const caller = await authenticate(c);
2751
+ if (caller instanceof Response) return caller;
2752
+ const accounts = accountsOf(caller.cfg);
2753
+ if (accounts && caller.session && caller.token) {
2754
+ await accounts.endSession(caller.token);
2755
+ await audit.record({
2756
+ action: "logout",
2757
+ actor: caller.identity,
2758
+ target: { type: "session", id: caller.session.id }
2759
+ });
2760
+ }
2761
+ if (accounts && caller.pat && caller.token) {
2762
+ await accounts.revokePresentedToken(caller.token);
2763
+ await audit.record({
2764
+ action: "token.revoked",
2765
+ actor: caller.identity,
2766
+ target: { type: "token", id: caller.pat.id },
2767
+ changeDescription: `${caller.pat.name} (signed out)`
2768
+ });
2769
+ }
2770
+ return c.body(null, 204);
2771
+ });
2772
+ app.get("/api/v1/auth/me", async (c) => {
2773
+ const caller = await authenticate(c);
2774
+ if (caller instanceof Response) return caller;
2775
+ return c.json({
2776
+ identity: caller.identity,
2777
+ role: caller.role,
2778
+ accounts: caller.cfg.users !== void 0,
2779
+ passwordSignIn: caller.cfg.users !== void 0 && !passwordOff(caller.cfg),
2780
+ user: caller.user ? publicUser(caller.user) : null,
2781
+ session: caller.session ? {
2782
+ id: caller.session.id,
2783
+ createdAt: caller.session.createdAt,
2784
+ expiresAt: caller.session.expiresAt
2785
+ } : null,
2786
+ token: caller.pat ? publicToken(caller.pat) : null,
2787
+ // The role this credential has in each environment, for a dashboard that follows it.
2788
+ environmentRoles: caller.user && caller.cfg.environments && caller.cfg.environments.length > 0 ? Object.fromEntries(
2789
+ caller.cfg.environments.map((env) => [env, effectiveRole(caller, env)])
2790
+ ) : null,
2791
+ twoFactor: caller.user ? {
2792
+ enabled: caller.user.twoFactor !== void 0,
2793
+ required: accountsOf(caller.cfg)?.twoFactorRequired(caller.user) ?? false,
2794
+ setupRequired: caller.twoFactorPending
2795
+ } : null
2796
+ });
2797
+ });
2798
+ app.post("/api/v1/auth/setup", async (c) => {
2799
+ const caller = await authenticate(c);
2800
+ if (caller instanceof Response) return caller;
2801
+ const accounts = accountsOf(caller.cfg);
2802
+ if (!accounts) return accountsOff(c);
2803
+ if (caller.role !== "owner" || caller.user) {
2804
+ return c.json(
2805
+ { error: "Only the admin token can create the first account.", code: "insufficient_role" },
2806
+ 403
2807
+ );
2808
+ }
2809
+ if (await accounts.count() > 0) {
2810
+ return c.json(
2811
+ { error: "An account already exists. Sign in with it instead.", code: "setup_complete" },
2812
+ 409
2813
+ );
2814
+ }
2815
+ const body = await readBody(c);
2816
+ if (body instanceof Response) return body;
2817
+ const email = normalizeEmail(body.email);
2818
+ const salt = decodeSalt(body.salt);
2819
+ const clientKey = decodeClientKey(body.clientKey);
2820
+ if (!email) return c.json({ error: "Enter a valid email address." }, 400);
2821
+ if (!salt || !clientKey) return c.json({ error: "Missing or malformed password key." }, 400);
2822
+ const user = await accounts.createUser({
2823
+ email,
2824
+ name: normalizeName(body.name),
2825
+ role: "owner",
2826
+ salt,
2827
+ clientKey
2828
+ });
2829
+ if (!user) return c.json({ error: "That email already has an account." }, 409);
2830
+ await audit.record({
2831
+ action: "user.created",
2832
+ actor: caller.identity,
2833
+ target: { type: "user", id: user.id },
2834
+ changeDescription: `${user.email} as owner`
2835
+ });
2836
+ fireMemberEvent("member.joined", caller.identity, user);
2837
+ return c.json({ user: publicUser(user) }, 201);
2838
+ });
2839
+ async function signedInUser(c) {
2840
+ const caller = await authenticate(c);
2841
+ if (caller instanceof Response) return caller;
2842
+ const accounts = accountsOf(caller.cfg);
2843
+ if (!accounts) return accountsOff(c);
2844
+ if (!caller.user || !caller.session) {
2845
+ return c.json(
2846
+ { error: "Sign in with an account to manage it.", code: "account_required" },
2847
+ 400
2848
+ );
2849
+ }
2850
+ return { ...caller, user: caller.user, session: caller.session, accounts };
2851
+ }
2852
+ app.put("/api/v1/me/password", async (c) => {
2853
+ const caller = await signedInUser(c);
2854
+ if (caller instanceof Response) return caller;
2855
+ const { accounts, user, session } = caller;
2856
+ if (passwordOff(caller.cfg)) return passwordDisabled(c, caller.cfg);
2857
+ if (!user.password) {
2858
+ return c.json(
2859
+ { error: "This account signs in with SSO and has no password.", code: "no_password" },
2860
+ 409
2861
+ );
2862
+ }
2863
+ const body = await readBody(c);
2864
+ if (body instanceof Response) return body;
2865
+ const currentKey = decodeClientKey(body.currentClientKey);
2866
+ const salt = decodeSalt(body.salt);
2867
+ const clientKey = decodeClientKey(body.clientKey);
2868
+ if (!currentKey || !salt || !clientKey) {
2869
+ return c.json({ error: "Missing or malformed password key." }, 400);
2870
+ }
2871
+ const ip = clientIp(c);
2872
+ const throttle = await accounts.throttled(user.email, ip);
2873
+ if (!throttle.ok) return throttledResponse(c, throttle.retryAfterSeconds);
2874
+ if (!await accounts.checkPassword(user, currentKey)) {
2875
+ await accounts.recordFailure(user.email, ip);
2876
+ return c.json({ error: "Your current password is incorrect.", code: "invalid_password" }, 400);
2877
+ }
2878
+ await accounts.setPassword(user, salt, clientKey);
2879
+ const others = (await accounts.sessionsFor(user.id)).filter((s) => s.session.id !== session.id);
2880
+ await Promise.all(others.map((s) => accounts.deleteSessionKey(s.key)));
2881
+ await audit.record({
2882
+ action: "password.changed",
2883
+ actor: user.email,
2884
+ target: { type: "user", id: user.id },
2885
+ changeDescription: others.length > 0 ? `Signed out ${others.length} other session${others.length === 1 ? "" : "s"}` : void 0
2886
+ });
2887
+ return c.json({ revokedSessions: others.length });
2888
+ });
2889
+ app.get("/api/v1/me/sessions", async (c) => {
2890
+ const caller = await signedInUser(c);
2891
+ if (caller instanceof Response) return caller;
2892
+ const sessions = (await caller.accounts.sessionsFor(caller.user.id)).map((s) => publicSession(s.session, caller.session.id)).sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt));
2893
+ return c.json({ sessions });
2894
+ });
2895
+ app.delete("/api/v1/me/sessions", async (c) => {
2896
+ const caller = await signedInUser(c);
2897
+ if (caller instanceof Response) return caller;
2898
+ const others = (await caller.accounts.sessionsFor(caller.user.id)).filter(
2899
+ (s) => s.session.id !== caller.session.id
2900
+ );
2901
+ await Promise.all(others.map((s) => caller.accounts.deleteSessionKey(s.key)));
2902
+ if (others.length > 0) {
2903
+ await audit.record({
2904
+ action: "session.revoked",
2905
+ actor: caller.user.email,
2906
+ target: { type: "user", id: caller.user.id },
2907
+ changeDescription: `Signed out ${others.length} other session${others.length === 1 ? "" : "s"}`
2908
+ });
2909
+ }
2910
+ return c.json({ revoked: others.length });
2911
+ });
2912
+ app.delete("/api/v1/me/sessions/:id", async (c) => {
2913
+ const caller = await signedInUser(c);
2914
+ if (caller instanceof Response) return caller;
2915
+ const id = c.req.param("id");
2916
+ const match = (await caller.accounts.sessionsFor(caller.user.id)).find(
2917
+ (s) => s.session.id === id
2918
+ );
2919
+ if (!match) return c.json({ error: "Session not found" }, 404);
2920
+ await caller.accounts.deleteSessionKey(match.key);
2921
+ await audit.record({
2922
+ action: "session.revoked",
2923
+ actor: caller.user.email,
2924
+ target: { type: "session", id }
2925
+ });
2926
+ return c.body(null, 204);
2927
+ });
2928
+ const ssoOff = (c) => c.json({ error: "SSO is not set up on this server.", code: "sso_disabled" }, 404);
2929
+ function allowedReturn(cfg, requestUrl, value) {
2930
+ if (!value) return null;
2931
+ try {
2932
+ const url = new URL(value);
2933
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
2934
+ const own = new URL(requestUrl).origin;
2935
+ if (url.origin !== own && !cfg.allowedOrigins?.includes(url.origin)) return null;
2936
+ url.hash = "";
2937
+ return url.toString();
2938
+ } catch {
2939
+ return null;
2940
+ }
2941
+ }
2942
+ app.get("/api/v1/auth/sso/start", async (c) => {
2943
+ const cfg = resolve(c.env);
2944
+ const sso = cfg.users?.sso;
2945
+ if (!cfg.users || !sso) return ssoOff(c);
2946
+ const returnTo = allowedReturn(cfg, c.req.url, c.req.query("return"));
2947
+ if (!returnTo) {
2948
+ return c.text("The return address is not allowed. Add its origin to allowedOrigins.", 400);
2949
+ }
2950
+ const browserHash = c.req.query("browser") ?? "";
2951
+ if (!/^[A-Za-z0-9_-]{43}$/.test(browserHash)) return c.text("Missing browser check.", 400);
2952
+ const redirectUri = sso.redirectUri ?? `${new URL(c.req.url).origin}${SSO_CALLBACK_PATH}`;
2953
+ try {
2954
+ const url = await buildAuthorizationUrl({
2955
+ sso,
2956
+ pepper: cfg.users.pepper,
2957
+ redirectUri,
2958
+ returnTo,
2959
+ browserHash
508
2960
  });
509
- payload = verified.payload;
510
- } catch {
511
- return { ok: false, status: 401, message: "Invalid token" };
2961
+ return c.redirect(url, 302);
2962
+ } catch (err) {
2963
+ const message = err instanceof SsoError ? err.message : "Could not reach the SSO provider.";
2964
+ if (!(err instanceof SsoError)) console.error("[flaghoist] SSO start failed", err);
2965
+ return c.redirect(`${returnTo}#sso_error=${encodeURIComponent(message)}`, 302);
512
2966
  }
513
- if (options.tokenUse && payload["token_use"] !== options.tokenUse) {
514
- return { ok: false, status: 401, message: "Invalid token" };
2967
+ });
2968
+ app.get(SSO_CALLBACK_PATH, async (c) => {
2969
+ const cfg = resolve(c.env);
2970
+ const sso = cfg.users?.sso;
2971
+ const accounts = accountsOf(cfg);
2972
+ if (!cfg.users || !sso || !accounts) return ssoOff(c);
2973
+ const state = await readState(cfg.users.pepper, c.req.query("state") ?? "");
2974
+ if (!state) {
2975
+ return c.text(
2976
+ "This sign-in has expired or did not start here. Go back to the dashboard and try again.",
2977
+ 400
2978
+ );
515
2979
  }
516
- if (options.allowedGroups && options.allowedGroups.length > 0) {
517
- const groups = extractGroups(payload, options.groupsClaim ?? "groups");
518
- const allowed = options.allowedGroups;
519
- if (!groups.some((group) => allowed.includes(group))) {
520
- return { ok: false, status: 403, message: "Admin group required" };
2980
+ const back = (message) => c.redirect(`${state.returnTo}#sso_error=${encodeURIComponent(message)}`, 302);
2981
+ const ip = clientIp(c);
2982
+ const refuse = async (email2, reason, message) => {
2983
+ await audit.record({
2984
+ action: "login.failed",
2985
+ actor: email2,
2986
+ changeDescription: [`SSO: ${reason}`, fromIp(ip)].filter(Boolean).join(". ")
2987
+ });
2988
+ return back(message);
2989
+ };
2990
+ const providerError = c.req.query("error");
2991
+ if (providerError) {
2992
+ return back(
2993
+ c.req.query("error_description") ?? `The provider refused the sign-in (${providerError}).`
2994
+ );
2995
+ }
2996
+ const code = c.req.query("code");
2997
+ if (!code) return back("The provider did not send a sign-in code.");
2998
+ let identity;
2999
+ try {
3000
+ identity = await completeSignIn({ sso, state, code });
3001
+ } catch (err) {
3002
+ if (!(err instanceof SsoError)) console.error("[flaghoist] SSO callback failed", err);
3003
+ return back(err instanceof SsoError ? err.message : "The sign-in could not be completed.");
3004
+ }
3005
+ const { email } = identity;
3006
+ if (!domainAllowed(sso, email)) {
3007
+ return refuse(email, "domain not allowed", `${email} is not allowed to sign in here.`);
3008
+ }
3009
+ const mapped = sso.roleMapping !== void 0 && Object.keys(sso.roleMapping).length > 0;
3010
+ const groupRole = roleFor(sso, identity.groups);
3011
+ const noAccess = "You are not in a group that has access to Flaghoist. Ask an admin.";
3012
+ let user = await accounts.findBySso(sso.issuer, identity.subject);
3013
+ if (!user) {
3014
+ if (!identity.emailVerified && sso.requireVerifiedEmail !== false) {
3015
+ return refuse(
3016
+ email,
3017
+ "email not verified",
3018
+ "Your provider has not verified your email address."
3019
+ );
3020
+ }
3021
+ const existing = await accounts.findByEmail(email);
3022
+ if (existing) {
3023
+ user = await accounts.linkSso(existing, sso.issuer, identity.subject);
3024
+ await audit.record({
3025
+ action: "user.updated",
3026
+ actor: email,
3027
+ target: { type: "user", id: user.id },
3028
+ changeDescription: `${email}: linked to SSO`
3029
+ });
3030
+ } else {
3031
+ const invite = (await accounts.listInvites()).find(
3032
+ (i) => i.kind === "invite" && i.email === email
3033
+ );
3034
+ const role = mapped ? groupRole : invite?.role ?? sso.defaultRole ?? null;
3035
+ if (!role) return refuse(email, "no mapped group", noAccess);
3036
+ const created = await accounts.createSsoUser({
3037
+ email,
3038
+ name: identity.name,
3039
+ role,
3040
+ issuer: sso.issuer,
3041
+ subject: identity.subject,
3042
+ managed: mapped
3043
+ });
3044
+ if (!created) return back("That email already has an account.");
3045
+ user = created;
3046
+ if (invite) {
3047
+ await accounts.revokeInvite(invite.id);
3048
+ await audit.record({
3049
+ action: "invite.accepted",
3050
+ actor: email,
3051
+ target: { type: "user", id: user.id },
3052
+ changeDescription: `Joined as ${user.role} with SSO`
3053
+ });
3054
+ fireMemberEvent("member.joined", email, user);
3055
+ } else {
3056
+ await audit.record({
3057
+ action: "user.created",
3058
+ actor: email,
3059
+ target: { type: "user", id: user.id },
3060
+ changeDescription: `${email} as ${user.role}, with SSO`
3061
+ });
3062
+ fireMemberEvent("member.joined", email, user);
3063
+ }
521
3064
  }
522
3065
  }
523
- const email = typeof payload["email"] === "string" ? payload["email"] : void 0;
524
- const sub = typeof payload.sub === "string" ? payload.sub : void 0;
525
- return { ok: true, identity: email ?? sub ?? "unknown" };
526
- };
527
- }
528
-
529
- // src/index.ts
530
- var DEFAULT_CACHE_TTL_SECONDS = 30;
531
- var MAX_BODY_BYTES = 64 * 1024;
532
- async function readJsonBody(text) {
533
- if (new TextEncoder().encode(text).length > MAX_BODY_BYTES) {
534
- return { ok: false, status: 413, message: "Payload too large" };
535
- }
536
- if (!text) return { ok: true, value: {} };
537
- try {
538
- return { ok: true, value: JSON.parse(text) };
539
- } catch {
540
- return { ok: false, status: 400, message: "Invalid JSON body" };
541
- }
542
- }
543
- function ttlMs(cfg) {
544
- return (cfg.cacheTtlSeconds ?? DEFAULT_CACHE_TTL_SECONDS) * 1e3;
545
- }
546
- function resolveContext(body, cfg, headers) {
547
- const client = body && typeof body === "object" && "context" in body && typeof body.context === "object" ? body.context : {};
548
- const trusted = cfg.trustedContext?.(headers) ?? {};
549
- return { ...client, ...trusted };
550
- }
551
- function createFlagServer(config) {
552
- const cache = createDefinitionCache();
553
- const resolve = (env) => typeof config === "function" ? config(env) : config;
554
- const app = new Hono();
555
- app.use("*", async (c, next) => {
556
- await next();
557
- c.header("X-Content-Type-Options", "nosniff");
558
- c.header("X-Frame-Options", "DENY");
559
- c.header("Content-Security-Policy", "frame-ancestors 'none'");
560
- c.header("Referrer-Policy", "no-referrer");
561
- c.header("Permissions-Policy", "geolocation=(), microphone=(), camera=(), payment=(), usb=()");
3066
+ if (user.status !== "active") {
3067
+ return refuse(email, "account disabled", "This account is disabled. Ask an admin.");
3068
+ }
3069
+ if (mapped) {
3070
+ if (!groupRole) return refuse(email, "no mapped group", noAccess);
3071
+ if (user.role !== groupRole || user.roleManagedBy !== "sso") {
3072
+ const before = user.role;
3073
+ user = await accounts.saveUser({ ...user, role: groupRole, roleManagedBy: "sso" });
3074
+ if (before !== groupRole) {
3075
+ await audit.record({
3076
+ action: "user.updated",
3077
+ actor: "SSO groups",
3078
+ target: { type: "user", id: user.id },
3079
+ changeDescription: `${email}: role ${before} to ${groupRole}`
3080
+ });
3081
+ fireMemberEvent("member.role_changed", "SSO groups", user, { role: before });
3082
+ }
3083
+ }
3084
+ }
3085
+ await accounts.recordLogin(user);
3086
+ await audit.record({
3087
+ action: "login",
3088
+ actor: email,
3089
+ target: { type: "user", id: user.id },
3090
+ changeDescription: ["With SSO", fromIp(ip)].filter(Boolean).join(". ")
3091
+ });
3092
+ const handBack = await exchangeCode(cfg.users.pepper, user.id, state.browser);
3093
+ return c.redirect(`${state.returnTo}#sso=${encodeURIComponent(handBack)}`, 302);
562
3094
  });
563
- app.use("*", async (c, next) => {
3095
+ app.post("/api/v1/auth/sso/exchange", async (c) => {
564
3096
  const cfg = resolve(c.env);
565
- const origin = c.req.header("Origin");
566
- if (origin && cfg.allowedOrigins?.includes(origin)) {
567
- c.header("Access-Control-Allow-Origin", origin);
568
- c.header("Vary", "Origin");
3097
+ const accounts = accountsOf(cfg);
3098
+ if (!cfg.users?.sso || !accounts) return ssoOff(c);
3099
+ const body = await readBody(c);
3100
+ if (body instanceof Response) return body;
3101
+ const code = typeof body.code === "string" ? body.code : "";
3102
+ const secret = typeof body.browserSecret === "string" ? body.browserSecret : "";
3103
+ const userId = code && secret ? await redeemExchangeCode(cfg.users.pepper, code, secret) : null;
3104
+ const user = userId ? await accounts.getUser(userId) : null;
3105
+ if (!user || user.status !== "active") {
3106
+ return c.json({ error: "This sign-in has expired. Try again.", code: "sso_expired" }, 410);
569
3107
  }
570
- if (c.req.method === "OPTIONS") {
571
- c.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
572
- c.header("Access-Control-Allow-Headers", "Content-Type, Authorization, x-api-key");
573
- return c.body(null, 204);
3108
+ const { token, session } = await accounts.createSession(user, c.req.header("user-agent"), "sso");
3109
+ return c.json({ token, expiresAt: session.expiresAt, user: publicUser(user) });
3110
+ });
3111
+ async function tokenOwner(c) {
3112
+ const caller = await authenticate(c);
3113
+ if (caller instanceof Response) return caller;
3114
+ const accounts = accountsOf(caller.cfg);
3115
+ if (!accounts) return accountsOff(c);
3116
+ if (!caller.user || caller.role === null) {
3117
+ return c.json(
3118
+ { error: "Sign in with an account to manage its access tokens.", code: "account_required" },
3119
+ 400
3120
+ );
574
3121
  }
575
- return next();
3122
+ if (caller.twoFactorPending) return twoFactorSetupFirst(c);
3123
+ return { ...caller, user: caller.user, accounts };
3124
+ }
3125
+ app.get("/api/v1/tokens", async (c) => {
3126
+ const caller = await tokenOwner(c);
3127
+ if (caller instanceof Response) return caller;
3128
+ const tokens = await caller.accounts.listTokens(caller.user.id);
3129
+ return c.json({ tokens: tokens.map(publicToken) });
576
3130
  });
577
- app.use("*", async (c, next) => {
578
- const cfg = resolve(c.env);
579
- if (!cfg.rateLimit || c.req.path === "/health") return next();
580
- const deriveKey = cfg.rateLimit.key ?? defaultRateLimitKey;
581
- const result = await cfg.rateLimit.check(deriveKey(c.req.raw.headers));
582
- if (!result.ok) {
583
- if (result.retryAfter) c.header("Retry-After", String(result.retryAfter));
584
- return c.json({ error: "Too many requests" }, 429);
3131
+ app.post("/api/v1/tokens", async (c) => {
3132
+ const caller = await tokenOwner(c);
3133
+ if (caller instanceof Response) return caller;
3134
+ const body = await readBody(c);
3135
+ if (body instanceof Response) return body;
3136
+ const name = normalizeName(body.name);
3137
+ if (!name) return c.json({ error: "Give the token a name." }, 400);
3138
+ const role = body.role ?? caller.role;
3139
+ if (!isRole(role)) return c.json({ error: `role must be one of ${ROLES.join(", ")}` }, 400);
3140
+ if (ROLES.indexOf(role) > ROLES.indexOf(caller.role)) {
3141
+ return c.json(
3142
+ {
3143
+ error: `A token cannot have a higher role than yours (${caller.role}).`,
3144
+ code: "insufficient_role"
3145
+ },
3146
+ 403
3147
+ );
585
3148
  }
586
- return next();
3149
+ const days = body.expiresInDays === void 0 ? DEFAULT_TOKEN_DAYS : body.expiresInDays;
3150
+ const validDays = days === null || typeof days === "number" && Number.isInteger(days) && days >= 1 && days <= MAX_TOKEN_DAYS;
3151
+ if (!validDays) {
3152
+ return c.json(
3153
+ { error: `expiresInDays must be a whole number from 1 to ${MAX_TOKEN_DAYS}, or null.` },
3154
+ 400
3155
+ );
3156
+ }
3157
+ const { token, record } = await caller.accounts.createToken(caller.user, {
3158
+ name,
3159
+ role,
3160
+ expiresInDays: days
3161
+ });
3162
+ await audit.record({
3163
+ action: "token.created",
3164
+ actor: caller.identity,
3165
+ target: { type: "token", id: record.id },
3166
+ changeDescription: `${record.name} as ${record.role}, ${record.expiresAt ? `expires ${record.expiresAt.slice(0, 10)}` : "never expires"}`
3167
+ });
3168
+ return c.json({ token, info: publicToken(record) }, 201);
587
3169
  });
588
- app.get("/health", (c) => c.json({ status: "ok" }));
589
- app.get("/api/v1/openapi.json", (c) => {
590
- const cfg = resolve(c.env);
591
- if (cfg.exposeOpenApi === false) return c.text("Not found", 404);
592
- return c.json(openApiDocument);
3170
+ app.delete("/api/v1/tokens/:id", async (c) => {
3171
+ const caller = await tokenOwner(c);
3172
+ if (caller instanceof Response) return caller;
3173
+ const revoked = await caller.accounts.revokeToken(caller.user.id, c.req.param("id"));
3174
+ if (!revoked) return c.json({ error: "Token not found" }, 404);
3175
+ await audit.record({
3176
+ action: "token.revoked",
3177
+ actor: caller.identity,
3178
+ target: { type: "token", id: revoked.id },
3179
+ changeDescription: revoked.name
3180
+ });
3181
+ return c.body(null, 204);
593
3182
  });
594
- app.get("/admin", (c) => {
595
- const cfg = resolve(c.env);
596
- return cfg.dashboard ? c.html(cfg.dashboard) : c.text("Dashboard not configured", 404);
3183
+ const invalidCode = (c) => c.json(
3184
+ {
3185
+ error: "That code is not right. Enter the current one from your app.",
3186
+ code: "invalid_code"
3187
+ },
3188
+ 400
3189
+ );
3190
+ app.post("/api/v1/me/two-factor/setup", async (c) => {
3191
+ const caller = await signedInUser(c);
3192
+ if (caller instanceof Response) return caller;
3193
+ const { accounts, user } = caller;
3194
+ if (user.twoFactor) {
3195
+ return c.json({ error: "Two-factor sign-in is already on.", code: "already_enabled" }, 409);
3196
+ }
3197
+ if (!user.password) {
3198
+ return c.json(
3199
+ {
3200
+ error: "This account signs in with SSO. Your identity provider's two-factor covers it.",
3201
+ code: "no_password"
3202
+ },
3203
+ 409
3204
+ );
3205
+ }
3206
+ const secret = await accounts.beginTwoFactor(user);
3207
+ return c.json({ secret, uri: otpauthUri(secret, user.email) });
597
3208
  });
598
- app.get("/admin/*", (c) => {
599
- const cfg = resolve(c.env);
600
- return cfg.dashboard ? c.html(cfg.dashboard) : c.text("Dashboard not configured", 404);
3209
+ app.post("/api/v1/me/two-factor/confirm", async (c) => {
3210
+ const caller = await signedInUser(c);
3211
+ if (caller instanceof Response) return caller;
3212
+ const { accounts, user } = caller;
3213
+ const body = await readBody(c);
3214
+ if (body instanceof Response) return body;
3215
+ if (!user.pendingTwoFactor) {
3216
+ return c.json({ error: "Start the setup first.", code: "not_started" }, 409);
3217
+ }
3218
+ const codes = await accounts.confirmTwoFactor(user, String(body.code ?? ""));
3219
+ if (!codes) return invalidCode(c);
3220
+ await audit.record({
3221
+ action: "two_factor.enabled",
3222
+ actor: user.email,
3223
+ target: { type: "user", id: user.id }
3224
+ });
3225
+ return c.json({ recoveryCodes: codes });
601
3226
  });
602
- const wireReason = (reason) => reason === "DISABLED" ? "STATIC" : reason;
603
- app.post("/ofrep/v1/evaluate/flags", async (c) => {
604
- const cfg = resolve(c.env);
605
- const auth = await cfg.auth.read(c.req.raw.headers);
606
- if (!auth.ok) {
607
- return c.json({ errorCode: "GENERAL", errorDetails: auth.message }, auth.status ?? 401);
3227
+ app.post("/api/v1/me/two-factor/recovery-codes", async (c) => {
3228
+ const caller = await signedInUser(c);
3229
+ if (caller instanceof Response) return caller;
3230
+ const { accounts, user } = caller;
3231
+ const body = await readBody(c);
3232
+ if (body instanceof Response) return body;
3233
+ if (!user.twoFactor)
3234
+ return c.json({ error: "Two-factor sign-in is off.", code: "not_enabled" }, 409);
3235
+ if (!await accounts.checkSecondFactor(user, String(body.code ?? ""))) return invalidCode(c);
3236
+ const codes = await accounts.newRecoveryCodes(await accounts.getUser(user.id) ?? user);
3237
+ return c.json({ recoveryCodes: codes });
3238
+ });
3239
+ app.delete("/api/v1/me/two-factor", async (c) => {
3240
+ const caller = await signedInUser(c);
3241
+ if (caller instanceof Response) return caller;
3242
+ const { accounts, user } = caller;
3243
+ const body = await readBody(c);
3244
+ if (body instanceof Response) return body;
3245
+ if (!user.twoFactor)
3246
+ return c.json({ error: "Two-factor sign-in is off.", code: "not_enabled" }, 409);
3247
+ if (accounts.twoFactorRequired(user)) {
3248
+ return c.json(
3249
+ {
3250
+ error: "Your role needs two-factor sign-in, so it cannot be turned off.",
3251
+ code: "required"
3252
+ },
3253
+ 409
3254
+ );
608
3255
  }
609
- const parsed = await readJsonBody(await c.req.text());
610
- if (!parsed.ok) {
611
- const errorCode = parsed.status === 413 ? "GENERAL" : "PARSE_ERROR";
612
- return c.json({ errorCode, errorDetails: parsed.message }, parsed.status);
3256
+ if (!await accounts.checkSecondFactor(user, String(body.code ?? ""))) return invalidCode(c);
3257
+ await accounts.removeTwoFactor(await accounts.getUser(user.id) ?? user);
3258
+ await audit.record({
3259
+ action: "two_factor.disabled",
3260
+ actor: user.email,
3261
+ target: { type: "user", id: user.id }
3262
+ });
3263
+ return c.body(null, 204);
3264
+ });
3265
+ async function memberAdmin(c) {
3266
+ const authorized = await authorize(c, "members:manage");
3267
+ if (authorized instanceof Response) return authorized;
3268
+ const accounts = accountsOf(authorized.cfg);
3269
+ if (!accounts) return accountsOff(c);
3270
+ return { ...authorized, accounts };
3271
+ }
3272
+ const ownersOnly = (c) => c.json({ error: "Only an owner can manage owners.", code: "insufficient_role" }, 403);
3273
+ async function isLastOwner(accounts, user) {
3274
+ if (user.role !== "owner" || user.status !== "active") return false;
3275
+ const owners = (await accounts.listUsers()).filter(
3276
+ (u) => u.role === "owner" && u.status === "active"
3277
+ );
3278
+ return owners.length <= 1;
3279
+ }
3280
+ const lastOwner = (c) => c.json(
3281
+ {
3282
+ error: "This is the only owner. Make someone else an owner first.",
3283
+ code: "last_owner"
3284
+ },
3285
+ 409
3286
+ );
3287
+ const notYourself = (c, what) => c.json({ error: `You cannot ${what} yourself.`, code: "self_change" }, 409);
3288
+ async function deliverLink(c, cfg, token, invite, requested) {
3289
+ const sender = cfg.users?.email;
3290
+ let emailed = false;
3291
+ if (sender) {
3292
+ const base = allowedReturn(cfg, c.req.url, typeof requested === "string" ? requested : void 0) ?? `${new URL(c.req.url).origin}/admin/`;
3293
+ const ssoOnly = passwordOff(cfg) ? cfg.users?.sso?.label ?? "SSO" : void 0;
3294
+ const plainBase = base.replace(/#.*$/, "");
3295
+ const link = invite.kind === "invite" && ssoOnly ? plainBase : `${plainBase}#accept=${encodeURIComponent(token)}`;
3296
+ const message = invite.kind === "invite" ? inviteEmail({
3297
+ to: invite.email,
3298
+ role: invite.role,
3299
+ invitedBy: invite.invitedBy === ADMIN_TOKEN_IDENTITY ? void 0 : invite.invitedBy,
3300
+ link,
3301
+ expiresAt: invite.expiresAt,
3302
+ ssoLabel: ssoOnly
3303
+ }) : resetEmail({ to: invite.email, link, expiresAt: invite.expiresAt });
3304
+ try {
3305
+ await sender.send(message);
3306
+ emailed = true;
3307
+ } catch (err) {
3308
+ console.error("[flaghoist] sending the email failed; the link was still returned", err);
3309
+ }
613
3310
  }
614
- const context = resolveContext(parsed.value, cfg, c.req.raw.headers);
615
- const flags = await cache.load(cfg.storage, ttlMs(cfg));
616
- const results = await Promise.all(
617
- flags.map(async (flag) => {
618
- const result = await evaluate(flag, context);
619
- return {
620
- key: flag.key,
621
- value: result.value,
622
- reason: wireReason(result.reason),
623
- variant: result.value ? "on" : "off"
624
- };
3311
+ return { token, invite: publicInvite(invite), emailed };
3312
+ }
3313
+ app.get("/api/v1/users", async (c) => {
3314
+ const caller = await memberAdmin(c);
3315
+ if (caller instanceof Response) return caller;
3316
+ const [users, lastActive] = await Promise.all([
3317
+ caller.accounts.listUsers(),
3318
+ caller.accounts.lastActive()
3319
+ ]);
3320
+ return c.json({
3321
+ users: users.map((u) => {
3322
+ const seen = lastActive.get(u.id);
3323
+ return { ...publicUser(u), ...seen ? { lastActiveAt: seen } : {} };
625
3324
  })
626
- );
627
- return c.json({ flags: results });
3325
+ });
628
3326
  });
629
- app.post("/ofrep/v1/evaluate/flags/:key", async (c) => {
630
- const cfg = resolve(c.env);
631
- const key = c.req.param("key");
632
- const auth = await cfg.auth.read(c.req.raw.headers);
633
- if (!auth.ok) {
634
- return c.json({ key, errorCode: "GENERAL", errorDetails: auth.message }, auth.status ?? 401);
3327
+ app.put("/api/v1/users/:id", async (c) => {
3328
+ const caller = await memberAdmin(c);
3329
+ if (caller instanceof Response) return caller;
3330
+ const { accounts } = caller;
3331
+ const user = await accounts.getUser(c.req.param("id"));
3332
+ if (!user) return c.json({ error: "Member not found" }, 404);
3333
+ const body = await readBody(c);
3334
+ if (body instanceof Response) return body;
3335
+ if (body.role !== void 0 && !isRole(body.role)) {
3336
+ return c.json({ error: `role must be one of ${ROLES.join(", ")}` }, 400);
635
3337
  }
636
- const parsed = await readJsonBody(await c.req.text());
637
- if (!parsed.ok) {
638
- const errorCode = parsed.status === 413 ? "GENERAL" : "PARSE_ERROR";
639
- return c.json({ key, errorCode, errorDetails: parsed.message }, parsed.status);
3338
+ if (body.status !== void 0 && body.status !== "active" && body.status !== "disabled") {
3339
+ return c.json({ error: "status must be active or disabled" }, 400);
640
3340
  }
641
- const context = resolveContext(parsed.value, cfg, c.req.raw.headers);
642
- const flags = await cache.load(cfg.storage, ttlMs(cfg));
643
- const flag = flags.find((f) => f.key === key);
644
- if (!flag) {
3341
+ const role = body.role ?? user.role;
3342
+ const status = body.status ?? user.status;
3343
+ const name = body.name !== void 0 ? normalizeName(body.name) : user.name;
3344
+ const roleChanged = role !== user.role;
3345
+ const statusChanged = status !== user.status;
3346
+ let environmentRoles = user.environmentRoles ?? {};
3347
+ if (body.environmentRoles !== void 0) {
3348
+ const envs = caller.cfg.environments ?? [];
3349
+ const value = body.environmentRoles;
3350
+ if (envs.length === 0) {
3351
+ return c.json({ error: "This server has no environments configured." }, 400);
3352
+ }
3353
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
3354
+ return c.json({ error: "environmentRoles must be an object of environment to role." }, 400);
3355
+ }
3356
+ const next = {};
3357
+ for (const [env, r] of Object.entries(value)) {
3358
+ if (!envs.includes(env)) {
3359
+ return c.json({ error: `Unknown environment "${env}".` }, 400);
3360
+ }
3361
+ if (r === null) continue;
3362
+ if (!isRole(r) || r === "owner") {
3363
+ return c.json({ error: "An environment role must be viewer, editor or admin." }, 400);
3364
+ }
3365
+ next[env] = r;
3366
+ }
3367
+ environmentRoles = next;
3368
+ }
3369
+ if (role === "owner") {
3370
+ if (body.environmentRoles !== void 0 && Object.keys(environmentRoles).length > 0) {
3371
+ return c.json({ error: "Owners have full access in every environment." }, 400);
3372
+ }
3373
+ environmentRoles = {};
3374
+ }
3375
+ const envChanges = [
3376
+ .../* @__PURE__ */ new Set([...Object.keys(user.environmentRoles ?? {}), ...Object.keys(environmentRoles)])
3377
+ ].sort().filter((env) => user.environmentRoles?.[env] !== environmentRoles[env]).map((env) => `${env}: ${environmentRoles[env] ?? "main role"}`);
3378
+ const envRolesChanged = envChanges.length > 0 && role !== "owner";
3379
+ if (roleChanged && user.roleManagedBy === "sso" && caller.cfg.users?.sso?.roleMapping) {
645
3380
  return c.json(
646
- { key, errorCode: "FLAG_NOT_FOUND", errorDetails: `Flag ${key} not found` },
647
- 404
3381
+ {
3382
+ error: `${user.email} gets their role from SSO groups. Change it in your identity provider.`,
3383
+ code: "managed_by_sso"
3384
+ },
3385
+ 409
648
3386
  );
649
3387
  }
650
- const result = await evaluate(flag, context);
651
- return c.json({
652
- key,
653
- value: result.value,
654
- reason: wireReason(result.reason),
655
- variant: result.value ? "on" : "off"
3388
+ if ((user.role === "owner" || role === "owner") && caller.role !== "owner") {
3389
+ if (roleChanged || statusChanged) return ownersOnly(c);
3390
+ }
3391
+ if (caller.user?.id === user.id && (roleChanged || statusChanged || envRolesChanged)) {
3392
+ return notYourself(c, roleChanged || envRolesChanged ? "change the role of" : "disable");
3393
+ }
3394
+ if ((roleChanged || status === "disabled") && await isLastOwner(accounts, user)) {
3395
+ return lastOwner(c);
3396
+ }
3397
+ const { environmentRoles: _previous, ...rest } = user;
3398
+ const saved = await accounts.saveUser({
3399
+ ...rest,
3400
+ role,
3401
+ status,
3402
+ name,
3403
+ ...Object.keys(environmentRoles).length > 0 ? { environmentRoles } : {}
3404
+ });
3405
+ const demoted = ROLES.indexOf(role) < ROLES.indexOf(user.role);
3406
+ if (status === "disabled" || demoted) await accounts.revokeSessionsFor(user.id);
3407
+ const changes = [];
3408
+ if (roleChanged) changes.push(`role ${user.role} to ${role}`);
3409
+ if (statusChanged) changes.push(status === "disabled" ? "disabled" : "enabled");
3410
+ if (name !== user.name) changes.push("name changed");
3411
+ if (envRolesChanged) changes.push(...envChanges.map((change) => `role in ${change}`));
3412
+ if (changes.length > 0) {
3413
+ await audit.record({
3414
+ action: "user.updated",
3415
+ actor: caller.identity,
3416
+ target: { type: "user", id: user.id },
3417
+ changeDescription: `${user.email}: ${changes.join(", ")}`
3418
+ });
3419
+ }
3420
+ if (roleChanged || envRolesChanged) {
3421
+ fireMemberEvent("member.role_changed", caller.identity, saved, {
3422
+ role: user.role,
3423
+ ...user.environmentRoles ? { environmentRoles: user.environmentRoles } : {}
3424
+ });
3425
+ }
3426
+ if (statusChanged) {
3427
+ fireMemberEvent(
3428
+ status === "disabled" ? "member.disabled" : "member.enabled",
3429
+ caller.identity,
3430
+ saved
3431
+ );
3432
+ }
3433
+ return c.json(publicUser(saved));
3434
+ });
3435
+ app.delete("/api/v1/users/:id", async (c) => {
3436
+ const caller = await memberAdmin(c);
3437
+ if (caller instanceof Response) return caller;
3438
+ const { accounts } = caller;
3439
+ const user = await accounts.getUser(c.req.param("id"));
3440
+ if (!user) return c.json({ error: "Member not found" }, 404);
3441
+ if (user.role === "owner" && caller.role !== "owner") return ownersOnly(c);
3442
+ if (caller.user?.id === user.id) return notYourself(c, "remove");
3443
+ if (await isLastOwner(accounts, user)) return lastOwner(c);
3444
+ await accounts.removeUser(user);
3445
+ await audit.record({
3446
+ action: "user.removed",
3447
+ actor: caller.identity,
3448
+ target: { type: "user", id: user.id },
3449
+ changeDescription: user.email
656
3450
  });
3451
+ fireMemberEvent("member.removed", caller.identity, { ...user, status: "removed" });
3452
+ return c.body(null, 204);
657
3453
  });
658
- const registerAdmin = (prefix) => {
659
- app.get(`${prefix}/flags`, async (c) => {
660
- const cfg = resolve(c.env);
661
- const auth = await cfg.auth.admin(c.req.raw.headers);
662
- if (!auth.ok) return c.json({ error: auth.message ?? "Unauthorized" }, auth.status ?? 401);
663
- return c.json({ flags: await cfg.storage.list() });
3454
+ app.delete("/api/v1/users/:id/two-factor", async (c) => {
3455
+ const caller = await memberAdmin(c);
3456
+ if (caller instanceof Response) return caller;
3457
+ const { accounts } = caller;
3458
+ const user = await accounts.getUser(c.req.param("id"));
3459
+ if (!user) return c.json({ error: "Member not found" }, 404);
3460
+ if (user.role === "owner" && caller.role !== "owner") return ownersOnly(c);
3461
+ if (caller.user?.id === user.id) return notYourself(c, "reset two-factor for");
3462
+ if (!user.twoFactor && !user.pendingTwoFactor) {
3463
+ return c.json({ error: `${user.email} does not use two-factor sign-in.` }, 409);
3464
+ }
3465
+ await accounts.removeTwoFactor(user);
3466
+ await accounts.revokeSessionsFor(user.id);
3467
+ await audit.record({
3468
+ action: "two_factor.reset",
3469
+ actor: caller.identity,
3470
+ target: { type: "user", id: user.id },
3471
+ changeDescription: user.email
664
3472
  });
665
- app.get(`${prefix}/flags/:key`, async (c) => {
666
- const cfg = resolve(c.env);
667
- const auth = await cfg.auth.admin(c.req.raw.headers);
668
- if (!auth.ok) return c.json({ error: auth.message ?? "Unauthorized" }, auth.status ?? 401);
669
- const flag = await cfg.storage.get(c.req.param("key"));
670
- if (!flag) return c.json({ error: "Flag not found" }, 404);
671
- c.header("ETag", flagEtag(flag));
672
- return c.json(flag);
3473
+ return c.body(null, 204);
3474
+ });
3475
+ app.post("/api/v1/users/:id/reset", async (c) => {
3476
+ const caller = await memberAdmin(c);
3477
+ if (caller instanceof Response) return caller;
3478
+ const { accounts } = caller;
3479
+ const user = await accounts.getUser(c.req.param("id"));
3480
+ if (!user) return c.json({ error: "Member not found" }, 404);
3481
+ if (user.role === "owner" && caller.role !== "owner") return ownersOnly(c);
3482
+ if (caller.user?.id === user.id) {
3483
+ return c.json(
3484
+ {
3485
+ error: "Change your own password from the Account page instead.",
3486
+ code: "self_change"
3487
+ },
3488
+ 409
3489
+ );
3490
+ }
3491
+ const body = await readBody(c);
3492
+ if (body instanceof Response) return body;
3493
+ const { token, invite } = await accounts.createInvite({
3494
+ kind: "reset",
3495
+ email: user.email,
3496
+ role: user.role,
3497
+ userId: user.id,
3498
+ invitedBy: caller.identity
673
3499
  });
674
- app.put(`${prefix}/flags/:key`, async (c) => {
675
- const cfg = resolve(c.env);
676
- const auth = await cfg.auth.admin(c.req.raw.headers);
677
- if (!auth.ok) return c.json({ error: auth.message ?? "Unauthorized" }, auth.status ?? 401);
678
- const key = c.req.param("key");
679
- const existing = await cfg.storage.get(key);
680
- const ifMatch = c.req.header("If-Match")?.trim();
681
- if (ifMatch !== void 0) {
682
- const precondition = ifMatch === "*" ? existing !== null : existing !== null && flagEtag(existing) === ifMatch;
683
- if (!precondition) {
684
- return c.json(
685
- { error: "This flag changed since you loaded it. Reload and reapply your change." },
686
- 412
687
- );
688
- }
689
- }
690
- const parsed = await readJsonBody(await c.req.text());
691
- if (!parsed.ok) return c.json({ error: parsed.message }, parsed.status);
692
- const built = buildFlag(key, parsed.value, auth.identity ?? "unknown", existing);
693
- if (!built.ok) return c.json({ error: built.error }, 400);
694
- await cfg.storage.put(key, built.flag);
695
- cache.invalidate();
696
- c.header("ETag", flagEtag(built.flag));
697
- return c.json(built.flag);
3500
+ const result = await deliverLink(c, caller.cfg, token, invite, body.dashboardUrl);
3501
+ await audit.record({
3502
+ action: "password.reset",
3503
+ actor: caller.identity,
3504
+ target: { type: "user", id: user.id },
3505
+ changeDescription: `Reset link for ${user.email}${result.emailed ? ", emailed" : ""}`
698
3506
  });
699
- app.delete(`${prefix}/flags/:key`, async (c) => {
700
- const cfg = resolve(c.env);
701
- const auth = await cfg.auth.admin(c.req.raw.headers);
702
- if (!auth.ok) return c.json({ error: auth.message ?? "Unauthorized" }, auth.status ?? 401);
703
- await cfg.storage.delete(c.req.param("key"));
704
- cache.invalidate();
705
- return c.body(null, 204);
3507
+ return c.json(result, 201);
3508
+ });
3509
+ app.get("/api/v1/invites", async (c) => {
3510
+ const caller = await memberAdmin(c);
3511
+ if (caller instanceof Response) return caller;
3512
+ const invites = (await caller.accounts.listInvites()).filter((i) => i.kind === "invite");
3513
+ return c.json({ invites: invites.map(publicInvite) });
3514
+ });
3515
+ app.post("/api/v1/invites", async (c) => {
3516
+ const caller = await memberAdmin(c);
3517
+ if (caller instanceof Response) return caller;
3518
+ const { accounts } = caller;
3519
+ const body = await readBody(c);
3520
+ if (body instanceof Response) return body;
3521
+ const email = normalizeEmail(body.email);
3522
+ if (!email) return c.json({ error: "Enter a valid email address." }, 400);
3523
+ const role = body.role ?? "viewer";
3524
+ if (!isRole(role)) return c.json({ error: `role must be one of ${ROLES.join(", ")}` }, 400);
3525
+ if (role === "owner" && caller.role !== "owner") return ownersOnly(c);
3526
+ if (await accounts.findByEmail(email)) {
3527
+ return c.json({ error: "That email already has an account.", code: "already_member" }, 409);
3528
+ }
3529
+ const { token, invite } = await accounts.createInvite({
3530
+ kind: "invite",
3531
+ email,
3532
+ role,
3533
+ invitedBy: caller.identity
706
3534
  });
707
- };
3535
+ const result = await deliverLink(c, caller.cfg, token, invite, body.dashboardUrl);
3536
+ await audit.record({
3537
+ action: "invite.created",
3538
+ actor: caller.identity,
3539
+ target: { type: "invite", id: invite.id },
3540
+ changeDescription: `${email} as ${role}${result.emailed ? ", emailed" : ""}`
3541
+ });
3542
+ fireMemberEvent("member.invited", caller.identity, { email, role });
3543
+ return c.json(result, 201);
3544
+ });
3545
+ app.post("/api/v1/invites/:id/resend", async (c) => {
3546
+ const caller = await memberAdmin(c);
3547
+ if (caller instanceof Response) return caller;
3548
+ const { accounts } = caller;
3549
+ const existing = (await accounts.listInvites()).find(
3550
+ (i) => i.id === c.req.param("id") && i.kind === "invite"
3551
+ );
3552
+ if (!existing) return c.json({ error: "Invite not found" }, 404);
3553
+ if (existing.role === "owner" && caller.role !== "owner") return ownersOnly(c);
3554
+ const { token, invite } = await accounts.createInvite({
3555
+ kind: "invite",
3556
+ email: existing.email,
3557
+ role: existing.role,
3558
+ invitedBy: caller.identity
3559
+ });
3560
+ const body = await readBody(c);
3561
+ if (body instanceof Response) return body;
3562
+ const result = await deliverLink(c, caller.cfg, token, invite, body.dashboardUrl);
3563
+ await audit.record({
3564
+ action: "invite.created",
3565
+ actor: caller.identity,
3566
+ target: { type: "invite", id: invite.id },
3567
+ changeDescription: `${existing.email} as ${existing.role}, resent${result.emailed ? ", emailed" : ""}`
3568
+ });
3569
+ return c.json(result, 201);
3570
+ });
3571
+ app.delete("/api/v1/invites/:id", async (c) => {
3572
+ const caller = await memberAdmin(c);
3573
+ if (caller instanceof Response) return caller;
3574
+ const { accounts } = caller;
3575
+ const existing = (await accounts.listInvites()).find((i) => i.id === c.req.param("id"));
3576
+ if (!existing) return c.json({ error: "Invite not found" }, 404);
3577
+ if (existing.role === "owner" && caller.role !== "owner") return ownersOnly(c);
3578
+ await accounts.revokeInvite(existing.id);
3579
+ await audit.record({
3580
+ action: "invite.revoked",
3581
+ actor: caller.identity,
3582
+ target: { type: "invite", id: existing.id },
3583
+ changeDescription: existing.email
3584
+ });
3585
+ return c.body(null, 204);
3586
+ });
3587
+ const linkGone = (c) => c.json(
3588
+ {
3589
+ error: "This link has expired or was already used. Ask an admin for a new one.",
3590
+ code: "link_invalid"
3591
+ },
3592
+ 410
3593
+ );
3594
+ app.post("/api/v1/invites/inspect", async (c) => {
3595
+ const accounts = accountsOf(resolve(c.env));
3596
+ if (!accounts) return accountsOff(c);
3597
+ const body = await readBody(c);
3598
+ if (body instanceof Response) return body;
3599
+ const invite = typeof body.token === "string" ? await accounts.findInvite(body.token) : null;
3600
+ if (!invite) return linkGone(c);
3601
+ return c.json({
3602
+ kind: invite.kind,
3603
+ email: invite.email,
3604
+ role: invite.role,
3605
+ expiresAt: invite.expiresAt,
3606
+ password: { kdf: PASSWORD_KDF, iterations: PASSWORD_ITERATIONS }
3607
+ });
3608
+ });
3609
+ app.post("/api/v1/invites/accept", async (c) => {
3610
+ const cfg = resolve(c.env);
3611
+ const accounts = accountsOf(cfg);
3612
+ if (!accounts) return accountsOff(c);
3613
+ const body = await readBody(c);
3614
+ if (body instanceof Response) return body;
3615
+ const token = typeof body.token === "string" ? body.token : "";
3616
+ const invite = token ? await accounts.findInvite(token) : null;
3617
+ if (!invite) return linkGone(c);
3618
+ if (passwordOff(cfg)) return passwordDisabled(c, cfg, ` as ${invite.email}`);
3619
+ const salt = decodeSalt(body.salt);
3620
+ const clientKey = decodeClientKey(body.clientKey);
3621
+ if (!salt || !clientKey) return c.json({ error: "Missing or malformed password key." }, 400);
3622
+ let user;
3623
+ if (invite.kind === "invite") {
3624
+ user = await accounts.createUser({
3625
+ email: invite.email,
3626
+ name: normalizeName(body.name),
3627
+ role: invite.role,
3628
+ salt,
3629
+ clientKey
3630
+ });
3631
+ if (!user) {
3632
+ await accounts.consumeInvite(token);
3633
+ return c.json(
3634
+ { error: "That email already has an account. Sign in instead.", code: "already_member" },
3635
+ 409
3636
+ );
3637
+ }
3638
+ await audit.record({
3639
+ action: "invite.accepted",
3640
+ actor: user.email,
3641
+ target: { type: "user", id: user.id },
3642
+ changeDescription: `Joined as ${user.role}`
3643
+ });
3644
+ fireMemberEvent("member.joined", user.email, user);
3645
+ } else {
3646
+ const existing = invite.userId ? await accounts.getUser(invite.userId) : null;
3647
+ if (!existing || existing.email !== invite.email) {
3648
+ await accounts.consumeInvite(token);
3649
+ return linkGone(c);
3650
+ }
3651
+ if (existing.status !== "active") {
3652
+ return c.json({ error: "This account is disabled.", code: "account_disabled" }, 403);
3653
+ }
3654
+ await accounts.setPassword(existing, salt, clientKey);
3655
+ await accounts.revokeSessionsFor(existing.id);
3656
+ await accounts.clearFailures(existing.email);
3657
+ user = await accounts.getUser(existing.id) ?? existing;
3658
+ await audit.record({
3659
+ action: "password.changed",
3660
+ actor: user.email,
3661
+ target: { type: "user", id: user.id },
3662
+ changeDescription: "With a reset link"
3663
+ });
3664
+ }
3665
+ await accounts.consumeInvite(token);
3666
+ if (user.twoFactor) {
3667
+ return c.json({ twoFactorRequired: true, challenge: await twoFactorChallenge(cfg, user) });
3668
+ }
3669
+ await accounts.recordLogin(user);
3670
+ const session = await accounts.createSession(user, c.req.header("user-agent"));
3671
+ return c.json({
3672
+ token: session.token,
3673
+ expiresAt: session.session.expiresAt,
3674
+ user: publicUser(user)
3675
+ });
3676
+ });
708
3677
  registerAdmin("/api/v1");
709
3678
  registerAdmin("");
710
3679
  app.onError((err, c) => {
@@ -714,11 +3683,19 @@ function createFlagServer(config) {
714
3683
  return app;
715
3684
  }
716
3685
  export {
3686
+ ADMIN_TOKEN_IDENTITY,
3687
+ ROLES,
717
3688
  apiKey,
3689
+ apiKeys,
718
3690
  bearerToken,
3691
+ can,
719
3692
  createFlagServer,
720
3693
  defaultRateLimitKey,
721
3694
  memoryRateLimit,
3695
+ minimumRole,
722
3696
  oidc,
723
- openApiDocument
3697
+ openApiDocument,
3698
+ resolveAdminEnvironment,
3699
+ resolveReadEnvironment,
3700
+ scopedStorage
724
3701
  };