@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/README.md +23 -3
- package/dist/dashboard.cjs +1 -1
- package/dist/dashboard.js +1 -1
- package/dist/index.cjs +3545 -566
- package/dist/index.d.cts +309 -2
- package/dist/index.d.ts +309 -2
- package/dist/index.js +3540 -563
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,433 +1,1157 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import {
|
|
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/
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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/
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
|
62
|
-
|
|
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/
|
|
66
|
-
var
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
371
|
-
|
|
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/
|
|
387
|
-
function
|
|
388
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
396
1112
|
}
|
|
397
|
-
function
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
|
1163
|
+
const encoder3 = new TextEncoder();
|
|
440
1164
|
const [da, db] = await Promise.all([
|
|
441
|
-
crypto.subtle.digest("SHA-256",
|
|
442
|
-
crypto.subtle.digest("SHA-256",
|
|
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
|
|
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
|
-
|
|
510
|
-
} catch {
|
|
511
|
-
|
|
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
|
-
|
|
514
|
-
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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.
|
|
3095
|
+
app.post("/api/v1/auth/sso/exchange", async (c) => {
|
|
564
3096
|
const cfg = resolve(c.env);
|
|
565
|
-
const
|
|
566
|
-
if (
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
|
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.
|
|
578
|
-
const
|
|
579
|
-
if (
|
|
580
|
-
const
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
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.
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
return c.json(
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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.
|
|
599
|
-
const
|
|
600
|
-
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
const
|
|
606
|
-
|
|
607
|
-
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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.
|
|
630
|
-
const
|
|
631
|
-
|
|
632
|
-
const
|
|
633
|
-
|
|
634
|
-
|
|
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
|
-
|
|
637
|
-
|
|
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
|
|
642
|
-
const
|
|
643
|
-
const
|
|
644
|
-
|
|
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
|
-
{
|
|
647
|
-
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
};
|