@flaghoist/server 0.1.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/LICENSE +201 -0
- package/dist/dashboard.cjs +30 -0
- package/dist/dashboard.d.cts +10 -0
- package/dist/dashboard.d.ts +10 -0
- package/dist/dashboard.js +5 -0
- package/dist/index.cjs +650 -0
- package/dist/index.d.cts +101 -0
- package/dist/index.d.ts +101 -0
- package/dist/index.js +621 -0
- package/package.json +64 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { evaluate } from "@flaghoist/core";
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
|
|
5
|
+
// src/cache.ts
|
|
6
|
+
function createDefinitionCache() {
|
|
7
|
+
let entry = null;
|
|
8
|
+
return {
|
|
9
|
+
async load(storage, ttlMs2) {
|
|
10
|
+
const now = Date.now();
|
|
11
|
+
if (entry && entry.expiresAt > now) return entry.flags;
|
|
12
|
+
const flags = await storage.list();
|
|
13
|
+
entry = { flags, expiresAt: now + ttlMs2 };
|
|
14
|
+
return flags;
|
|
15
|
+
},
|
|
16
|
+
invalidate() {
|
|
17
|
+
entry = null;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// src/flags.ts
|
|
23
|
+
import { clampPercentage, parseFlag } from "@flaghoist/core";
|
|
24
|
+
function buildFlag(key, body, identity, existing) {
|
|
25
|
+
if (typeof body !== "object" || body === null) {
|
|
26
|
+
return { ok: false, error: "Request body must be a JSON object" };
|
|
27
|
+
}
|
|
28
|
+
const b = body;
|
|
29
|
+
const enabled = typeof b.enabled === "boolean" ? b.enabled : false;
|
|
30
|
+
const rollout = typeof b.rollout === "object" && b.rollout !== null ? b.rollout : {};
|
|
31
|
+
const percentage = typeof rollout.percentage === "number" ? clampPercentage(rollout.percentage) : 0;
|
|
32
|
+
const description = typeof b.description === "string" ? b.description : "";
|
|
33
|
+
const inputRules = Array.isArray(b.rules) ? b.rules : [];
|
|
34
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
35
|
+
const candidate = {
|
|
36
|
+
key,
|
|
37
|
+
enabled,
|
|
38
|
+
rollout: { percentage },
|
|
39
|
+
rules: inputRules,
|
|
40
|
+
description,
|
|
41
|
+
metadata: {
|
|
42
|
+
createdBy: existing?.metadata.createdBy ?? identity,
|
|
43
|
+
createdAt: existing?.metadata.createdAt ?? now,
|
|
44
|
+
updatedBy: identity,
|
|
45
|
+
updatedAt: now
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
const validated = parseFlag(candidate);
|
|
49
|
+
if (!validated) return { ok: false, error: "Invalid flag definition" };
|
|
50
|
+
if ((validated.rules?.length ?? 0) !== inputRules.length) {
|
|
51
|
+
return { ok: false, error: "One or more targeting rules are invalid" };
|
|
52
|
+
}
|
|
53
|
+
return { ok: true, flag: validated };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// src/openapi.ts
|
|
57
|
+
var openApiDocument = {
|
|
58
|
+
openapi: "3.1.0",
|
|
59
|
+
info: {
|
|
60
|
+
title: "Flaghoist API",
|
|
61
|
+
version: "0.1.0",
|
|
62
|
+
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`.",
|
|
63
|
+
license: { name: "Apache-2.0", url: "https://www.apache.org/licenses/LICENSE-2.0" }
|
|
64
|
+
},
|
|
65
|
+
servers: [{ url: "/", description: "This Flaghoist server" }],
|
|
66
|
+
tags: [
|
|
67
|
+
{ name: "admin", description: "Manage flag definitions (admin auth)." },
|
|
68
|
+
{
|
|
69
|
+
name: "evaluate",
|
|
70
|
+
description: "Evaluate flags for a context (OFREP read path, API-key auth)."
|
|
71
|
+
},
|
|
72
|
+
{ name: "meta", description: "Health and discovery." }
|
|
73
|
+
],
|
|
74
|
+
paths: {
|
|
75
|
+
"/api/v1/flags": {
|
|
76
|
+
get: {
|
|
77
|
+
tags: ["admin"],
|
|
78
|
+
summary: "List all flags",
|
|
79
|
+
security: [{ bearerAuth: [] }],
|
|
80
|
+
responses: {
|
|
81
|
+
"200": {
|
|
82
|
+
description: "All flags",
|
|
83
|
+
content: {
|
|
84
|
+
"application/json": {
|
|
85
|
+
schema: {
|
|
86
|
+
type: "object",
|
|
87
|
+
required: ["flags"],
|
|
88
|
+
properties: {
|
|
89
|
+
flags: { type: "array", items: { $ref: "#/components/schemas/FeatureFlag" } }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
"/api/v1/flags/{key}": {
|
|
100
|
+
parameters: [{ $ref: "#/components/parameters/FlagKey" }],
|
|
101
|
+
get: {
|
|
102
|
+
tags: ["admin"],
|
|
103
|
+
summary: "Get one flag",
|
|
104
|
+
security: [{ bearerAuth: [] }],
|
|
105
|
+
responses: {
|
|
106
|
+
"200": {
|
|
107
|
+
description: "The flag",
|
|
108
|
+
content: {
|
|
109
|
+
"application/json": { schema: { $ref: "#/components/schemas/FeatureFlag" } }
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
113
|
+
"404": { $ref: "#/components/responses/NotFound" }
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
put: {
|
|
117
|
+
tags: ["admin"],
|
|
118
|
+
summary: "Create or replace a flag",
|
|
119
|
+
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.",
|
|
120
|
+
security: [{ bearerAuth: [] }],
|
|
121
|
+
requestBody: {
|
|
122
|
+
required: true,
|
|
123
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/FlagInput" } } }
|
|
124
|
+
},
|
|
125
|
+
responses: {
|
|
126
|
+
"200": {
|
|
127
|
+
description: "The stored flag",
|
|
128
|
+
content: {
|
|
129
|
+
"application/json": { schema: { $ref: "#/components/schemas/FeatureFlag" } }
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
"400": { $ref: "#/components/responses/BadRequest" },
|
|
133
|
+
"401": { $ref: "#/components/responses/Unauthorized" },
|
|
134
|
+
"413": { description: "Payload too large" }
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
delete: {
|
|
138
|
+
tags: ["admin"],
|
|
139
|
+
summary: "Delete a flag",
|
|
140
|
+
security: [{ bearerAuth: [] }],
|
|
141
|
+
responses: {
|
|
142
|
+
"204": { description: "Deleted (idempotent)" },
|
|
143
|
+
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
"/ofrep/v1/evaluate/flags": {
|
|
148
|
+
post: {
|
|
149
|
+
tags: ["evaluate"],
|
|
150
|
+
summary: "Evaluate all flags for a context (OFREP bulk)",
|
|
151
|
+
security: [{ apiKeyAuth: [] }],
|
|
152
|
+
requestBody: {
|
|
153
|
+
content: {
|
|
154
|
+
"application/json": { schema: { $ref: "#/components/schemas/EvaluationRequest" } }
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
responses: {
|
|
158
|
+
"200": {
|
|
159
|
+
description: "Evaluated flags",
|
|
160
|
+
content: {
|
|
161
|
+
"application/json": {
|
|
162
|
+
schema: {
|
|
163
|
+
type: "object",
|
|
164
|
+
properties: {
|
|
165
|
+
flags: { type: "array", items: { $ref: "#/components/schemas/EvaluatedFlag" } }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
"401": { $ref: "#/components/responses/Unauthorized" }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
"/ofrep/v1/evaluate/flags/{key}": {
|
|
176
|
+
parameters: [{ $ref: "#/components/parameters/FlagKey" }],
|
|
177
|
+
post: {
|
|
178
|
+
tags: ["evaluate"],
|
|
179
|
+
summary: "Evaluate one flag (OFREP)",
|
|
180
|
+
security: [{ apiKeyAuth: [] }],
|
|
181
|
+
requestBody: {
|
|
182
|
+
content: {
|
|
183
|
+
"application/json": { schema: { $ref: "#/components/schemas/EvaluationRequest" } }
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
responses: {
|
|
187
|
+
"200": {
|
|
188
|
+
description: "Evaluated flag",
|
|
189
|
+
content: {
|
|
190
|
+
"application/json": { schema: { $ref: "#/components/schemas/EvaluatedFlag" } }
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
"404": { description: "Unknown flag (errorCode FLAG_NOT_FOUND)" }
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
"/api/v1/openapi.json": {
|
|
198
|
+
get: {
|
|
199
|
+
tags: ["meta"],
|
|
200
|
+
summary: "This OpenAPI document",
|
|
201
|
+
responses: { "200": { description: "The OpenAPI document" } }
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
"/health": {
|
|
205
|
+
get: {
|
|
206
|
+
tags: ["meta"],
|
|
207
|
+
summary: "Health check",
|
|
208
|
+
responses: {
|
|
209
|
+
"200": {
|
|
210
|
+
description: "ok",
|
|
211
|
+
content: {
|
|
212
|
+
"application/json": {
|
|
213
|
+
schema: { type: "object", properties: { status: { type: "string" } } }
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
components: {
|
|
222
|
+
securitySchemes: {
|
|
223
|
+
bearerAuth: {
|
|
224
|
+
type: "http",
|
|
225
|
+
scheme: "bearer",
|
|
226
|
+
description: "Admin token (or a validated OIDC JWT)."
|
|
227
|
+
},
|
|
228
|
+
apiKeyAuth: {
|
|
229
|
+
type: "apiKey",
|
|
230
|
+
in: "header",
|
|
231
|
+
name: "x-api-key",
|
|
232
|
+
description: "Read-only API key."
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
parameters: {
|
|
236
|
+
FlagKey: {
|
|
237
|
+
name: "key",
|
|
238
|
+
in: "path",
|
|
239
|
+
required: true,
|
|
240
|
+
description: "Flag key.",
|
|
241
|
+
schema: { type: "string", pattern: "^[A-Za-z0-9][A-Za-z0-9._-]*$", maxLength: 256 }
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
responses: {
|
|
245
|
+
Unauthorized: {
|
|
246
|
+
description: "Missing or invalid credentials",
|
|
247
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }
|
|
248
|
+
},
|
|
249
|
+
BadRequest: {
|
|
250
|
+
description: "Invalid request or flag definition",
|
|
251
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }
|
|
252
|
+
},
|
|
253
|
+
NotFound: {
|
|
254
|
+
description: "Flag not found",
|
|
255
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
schemas: {
|
|
259
|
+
Error: { type: "object", required: ["error"], properties: { error: { type: "string" } } },
|
|
260
|
+
Condition: {
|
|
261
|
+
type: "object",
|
|
262
|
+
required: ["attribute", "operator", "value"],
|
|
263
|
+
properties: {
|
|
264
|
+
attribute: { type: "string", maxLength: 256 },
|
|
265
|
+
operator: {
|
|
266
|
+
type: "string",
|
|
267
|
+
enum: [
|
|
268
|
+
"eq",
|
|
269
|
+
"neq",
|
|
270
|
+
"in",
|
|
271
|
+
"notIn",
|
|
272
|
+
"contains",
|
|
273
|
+
"startsWith",
|
|
274
|
+
"endsWith",
|
|
275
|
+
"gt",
|
|
276
|
+
"gte",
|
|
277
|
+
"lt",
|
|
278
|
+
"lte",
|
|
279
|
+
"semverGte",
|
|
280
|
+
"semverLt"
|
|
281
|
+
]
|
|
282
|
+
},
|
|
283
|
+
value: {
|
|
284
|
+
description: "A scalar, or an array of scalars for `in` / `notIn`.",
|
|
285
|
+
oneOf: [
|
|
286
|
+
{ type: "string" },
|
|
287
|
+
{ type: "number" },
|
|
288
|
+
{ type: "boolean" },
|
|
289
|
+
{ type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } }
|
|
290
|
+
]
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
RuleResult: {
|
|
295
|
+
type: "object",
|
|
296
|
+
required: ["enabled"],
|
|
297
|
+
properties: {
|
|
298
|
+
enabled: { type: "boolean" },
|
|
299
|
+
rollout: {
|
|
300
|
+
type: "object",
|
|
301
|
+
properties: { percentage: { type: "number", minimum: 0, maximum: 100 } }
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
TargetingRule: {
|
|
306
|
+
type: "object",
|
|
307
|
+
required: ["conditions", "result"],
|
|
308
|
+
properties: {
|
|
309
|
+
description: { type: "string" },
|
|
310
|
+
conditions: { type: "array", items: { $ref: "#/components/schemas/Condition" } },
|
|
311
|
+
result: { $ref: "#/components/schemas/RuleResult" }
|
|
312
|
+
}
|
|
313
|
+
},
|
|
314
|
+
FlagMetadata: {
|
|
315
|
+
type: "object",
|
|
316
|
+
properties: {
|
|
317
|
+
createdBy: { type: "string" },
|
|
318
|
+
createdAt: { type: "string", format: "date-time" },
|
|
319
|
+
updatedBy: { type: "string" },
|
|
320
|
+
updatedAt: { type: "string", format: "date-time" }
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
FlagInput: {
|
|
324
|
+
type: "object",
|
|
325
|
+
required: ["enabled", "rollout"],
|
|
326
|
+
properties: {
|
|
327
|
+
enabled: { type: "boolean" },
|
|
328
|
+
rollout: {
|
|
329
|
+
type: "object",
|
|
330
|
+
required: ["percentage"],
|
|
331
|
+
properties: { percentage: { type: "number", minimum: 0, maximum: 100 } }
|
|
332
|
+
},
|
|
333
|
+
rules: { type: "array", items: { $ref: "#/components/schemas/TargetingRule" } },
|
|
334
|
+
description: { type: "string" }
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
FeatureFlag: {
|
|
338
|
+
allOf: [
|
|
339
|
+
{ $ref: "#/components/schemas/FlagInput" },
|
|
340
|
+
{
|
|
341
|
+
type: "object",
|
|
342
|
+
required: ["key", "metadata"],
|
|
343
|
+
properties: {
|
|
344
|
+
key: { type: "string" },
|
|
345
|
+
metadata: { $ref: "#/components/schemas/FlagMetadata" }
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
]
|
|
349
|
+
},
|
|
350
|
+
EvaluationRequest: {
|
|
351
|
+
type: "object",
|
|
352
|
+
properties: {
|
|
353
|
+
context: {
|
|
354
|
+
type: "object",
|
|
355
|
+
description: "OpenFeature evaluation context.",
|
|
356
|
+
properties: { targetingKey: { type: "string" } },
|
|
357
|
+
additionalProperties: true
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
EvaluatedFlag: {
|
|
362
|
+
type: "object",
|
|
363
|
+
properties: {
|
|
364
|
+
key: { type: "string" },
|
|
365
|
+
value: { type: "boolean" },
|
|
366
|
+
reason: {
|
|
367
|
+
type: "string",
|
|
368
|
+
enum: ["DISABLED", "TARGETING_MATCH", "SPLIT", "DEFAULT"]
|
|
369
|
+
},
|
|
370
|
+
variant: { type: "string" }
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
// src/auth.ts
|
|
378
|
+
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
379
|
+
function extractBearer(headers) {
|
|
380
|
+
const header = headers.get("authorization");
|
|
381
|
+
if (!header) return null;
|
|
382
|
+
const [scheme, token] = header.split(" ");
|
|
383
|
+
if (!token || scheme?.toLowerCase() !== "bearer") return null;
|
|
384
|
+
return token.trim() || null;
|
|
385
|
+
}
|
|
386
|
+
async function safeEqual(a, b) {
|
|
387
|
+
const encoder = new TextEncoder();
|
|
388
|
+
const [da, db] = await Promise.all([
|
|
389
|
+
crypto.subtle.digest("SHA-256", encoder.encode(a)),
|
|
390
|
+
crypto.subtle.digest("SHA-256", encoder.encode(b))
|
|
391
|
+
]);
|
|
392
|
+
const va = new Uint8Array(da);
|
|
393
|
+
const vb = new Uint8Array(db);
|
|
394
|
+
let diff = 0;
|
|
395
|
+
for (let i = 0; i < va.length; i++) diff |= va[i] ^ vb[i];
|
|
396
|
+
return diff === 0;
|
|
397
|
+
}
|
|
398
|
+
function apiKey(expected) {
|
|
399
|
+
return async (headers) => {
|
|
400
|
+
const provided = headers.get("x-api-key");
|
|
401
|
+
if (!provided || !await safeEqual(provided, expected)) {
|
|
402
|
+
return { ok: false, status: 401, message: "Invalid or missing API key" };
|
|
403
|
+
}
|
|
404
|
+
return { ok: true, identity: "api-key" };
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
function bearerToken(expected) {
|
|
408
|
+
return async (headers) => {
|
|
409
|
+
const provided = extractBearer(headers);
|
|
410
|
+
if (!provided || !await safeEqual(provided, expected)) {
|
|
411
|
+
return { ok: false, status: 401, message: "Invalid or missing bearer token" };
|
|
412
|
+
}
|
|
413
|
+
return { ok: true, identity: "admin" };
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
function defaultJwksUri(issuer) {
|
|
417
|
+
const base = issuer.endsWith("/") ? issuer : `${issuer}/`;
|
|
418
|
+
return new URL(".well-known/jwks.json", base).toString();
|
|
419
|
+
}
|
|
420
|
+
function extractGroups(payload, claim) {
|
|
421
|
+
const value = payload[claim];
|
|
422
|
+
if (Array.isArray(value)) return value.filter((x) => typeof x === "string");
|
|
423
|
+
if (typeof value === "string") return value.split(/[,\s]+/).filter(Boolean);
|
|
424
|
+
return [];
|
|
425
|
+
}
|
|
426
|
+
function oidc(options) {
|
|
427
|
+
const resolveKey = options.keyResolver ?? createRemoteJWKSet(new URL(defaultJwksUri(options.issuer)));
|
|
428
|
+
const algorithms = options.algorithms ?? ["RS256"];
|
|
429
|
+
return async (headers) => {
|
|
430
|
+
const token = extractBearer(headers);
|
|
431
|
+
if (!token) return { ok: false, status: 401, message: "Missing bearer token" };
|
|
432
|
+
let payload;
|
|
433
|
+
try {
|
|
434
|
+
const verified = await jwtVerify(token, resolveKey, {
|
|
435
|
+
issuer: options.issuer,
|
|
436
|
+
audience: options.audience,
|
|
437
|
+
algorithms
|
|
438
|
+
});
|
|
439
|
+
payload = verified.payload;
|
|
440
|
+
} catch {
|
|
441
|
+
return { ok: false, status: 401, message: "Invalid token" };
|
|
442
|
+
}
|
|
443
|
+
if (options.tokenUse && payload["token_use"] !== options.tokenUse) {
|
|
444
|
+
return { ok: false, status: 401, message: "Invalid token" };
|
|
445
|
+
}
|
|
446
|
+
if (options.allowedGroups && options.allowedGroups.length > 0) {
|
|
447
|
+
const groups = extractGroups(payload, options.groupsClaim ?? "groups");
|
|
448
|
+
const allowed = options.allowedGroups;
|
|
449
|
+
if (!groups.some((group) => allowed.includes(group))) {
|
|
450
|
+
return { ok: false, status: 403, message: "Admin group required" };
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
const email = typeof payload["email"] === "string" ? payload["email"] : void 0;
|
|
454
|
+
const sub = typeof payload.sub === "string" ? payload.sub : void 0;
|
|
455
|
+
return { ok: true, identity: email ?? sub ?? "unknown" };
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// src/index.ts
|
|
460
|
+
var DEFAULT_CACHE_TTL_SECONDS = 30;
|
|
461
|
+
var MAX_BODY_BYTES = 64 * 1024;
|
|
462
|
+
async function readJsonBody(text) {
|
|
463
|
+
if (new TextEncoder().encode(text).length > MAX_BODY_BYTES) {
|
|
464
|
+
return { ok: false, status: 413, message: "Payload too large" };
|
|
465
|
+
}
|
|
466
|
+
if (!text) return { ok: true, value: {} };
|
|
467
|
+
try {
|
|
468
|
+
return { ok: true, value: JSON.parse(text) };
|
|
469
|
+
} catch {
|
|
470
|
+
return { ok: false, status: 400, message: "Invalid JSON body" };
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
function ttlMs(cfg) {
|
|
474
|
+
return (cfg.cacheTtlSeconds ?? DEFAULT_CACHE_TTL_SECONDS) * 1e3;
|
|
475
|
+
}
|
|
476
|
+
function resolveContext(body, cfg, headers) {
|
|
477
|
+
const client = body && typeof body === "object" && "context" in body && typeof body.context === "object" ? body.context : {};
|
|
478
|
+
const trusted = cfg.trustedContext?.(headers) ?? {};
|
|
479
|
+
return { ...client, ...trusted };
|
|
480
|
+
}
|
|
481
|
+
function createFlagServer(config) {
|
|
482
|
+
const cache = createDefinitionCache();
|
|
483
|
+
const resolve = (env) => typeof config === "function" ? config(env) : config;
|
|
484
|
+
const app = new Hono();
|
|
485
|
+
app.use("*", async (c, next) => {
|
|
486
|
+
const cfg = resolve(c.env);
|
|
487
|
+
const origin = c.req.header("Origin");
|
|
488
|
+
if (origin && cfg.allowedOrigins?.includes(origin)) {
|
|
489
|
+
c.header("Access-Control-Allow-Origin", origin);
|
|
490
|
+
c.header("Access-Control-Allow-Credentials", "true");
|
|
491
|
+
c.header("Vary", "Origin");
|
|
492
|
+
}
|
|
493
|
+
if (c.req.method === "OPTIONS") {
|
|
494
|
+
c.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
495
|
+
c.header("Access-Control-Allow-Headers", "Content-Type, Authorization, x-api-key");
|
|
496
|
+
return c.body(null, 204);
|
|
497
|
+
}
|
|
498
|
+
return next();
|
|
499
|
+
});
|
|
500
|
+
app.get("/health", (c) => c.json({ status: "ok" }));
|
|
501
|
+
app.get("/api/v1/openapi.json", (c) => c.json(openApiDocument));
|
|
502
|
+
app.get("/admin", (c) => {
|
|
503
|
+
const cfg = resolve(c.env);
|
|
504
|
+
return cfg.dashboard ? c.html(cfg.dashboard) : c.text("Dashboard not configured", 404);
|
|
505
|
+
});
|
|
506
|
+
app.get("/admin/*", (c) => {
|
|
507
|
+
const cfg = resolve(c.env);
|
|
508
|
+
return cfg.dashboard ? c.html(cfg.dashboard) : c.text("Dashboard not configured", 404);
|
|
509
|
+
});
|
|
510
|
+
app.post("/ofrep/v1/evaluate/flags", async (c) => {
|
|
511
|
+
const cfg = resolve(c.env);
|
|
512
|
+
const auth = await cfg.auth.read(c.req.raw.headers);
|
|
513
|
+
if (!auth.ok) {
|
|
514
|
+
return c.json({ errorCode: "GENERAL", errorDetails: auth.message }, auth.status ?? 401);
|
|
515
|
+
}
|
|
516
|
+
const parsed = await readJsonBody(await c.req.text());
|
|
517
|
+
if (!parsed.ok) {
|
|
518
|
+
const errorCode = parsed.status === 413 ? "GENERAL" : "PARSE_ERROR";
|
|
519
|
+
return c.json({ errorCode, errorDetails: parsed.message }, parsed.status);
|
|
520
|
+
}
|
|
521
|
+
const context = resolveContext(parsed.value, cfg, c.req.raw.headers);
|
|
522
|
+
const flags = await cache.load(cfg.storage, ttlMs(cfg));
|
|
523
|
+
const results = await Promise.all(
|
|
524
|
+
flags.map(async (flag) => {
|
|
525
|
+
const result = await evaluate(flag, context);
|
|
526
|
+
return {
|
|
527
|
+
key: flag.key,
|
|
528
|
+
value: result.value,
|
|
529
|
+
reason: result.reason,
|
|
530
|
+
variant: result.value ? "on" : "off"
|
|
531
|
+
};
|
|
532
|
+
})
|
|
533
|
+
);
|
|
534
|
+
return c.json({ flags: results });
|
|
535
|
+
});
|
|
536
|
+
app.post("/ofrep/v1/evaluate/flags/:key", async (c) => {
|
|
537
|
+
const cfg = resolve(c.env);
|
|
538
|
+
const key = c.req.param("key");
|
|
539
|
+
const auth = await cfg.auth.read(c.req.raw.headers);
|
|
540
|
+
if (!auth.ok) {
|
|
541
|
+
return c.json({ key, errorCode: "GENERAL", errorDetails: auth.message }, auth.status ?? 401);
|
|
542
|
+
}
|
|
543
|
+
const parsed = await readJsonBody(await c.req.text());
|
|
544
|
+
if (!parsed.ok) {
|
|
545
|
+
const errorCode = parsed.status === 413 ? "GENERAL" : "PARSE_ERROR";
|
|
546
|
+
return c.json({ key, errorCode, errorDetails: parsed.message }, parsed.status);
|
|
547
|
+
}
|
|
548
|
+
const context = resolveContext(parsed.value, cfg, c.req.raw.headers);
|
|
549
|
+
const flags = await cache.load(cfg.storage, ttlMs(cfg));
|
|
550
|
+
const flag = flags.find((f) => f.key === key);
|
|
551
|
+
if (!flag) {
|
|
552
|
+
return c.json(
|
|
553
|
+
{ key, errorCode: "FLAG_NOT_FOUND", errorDetails: `Flag ${key} not found` },
|
|
554
|
+
404
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
const result = await evaluate(flag, context);
|
|
558
|
+
return c.json({
|
|
559
|
+
key,
|
|
560
|
+
value: result.value,
|
|
561
|
+
reason: result.reason,
|
|
562
|
+
variant: result.value ? "on" : "off"
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
const registerAdmin = (prefix) => {
|
|
566
|
+
app.get(`${prefix}/flags`, async (c) => {
|
|
567
|
+
const cfg = resolve(c.env);
|
|
568
|
+
const auth = await cfg.auth.admin(c.req.raw.headers);
|
|
569
|
+
if (!auth.ok) return c.json({ error: auth.message ?? "Unauthorized" }, auth.status ?? 401);
|
|
570
|
+
return c.json({ flags: await cfg.storage.list() });
|
|
571
|
+
});
|
|
572
|
+
app.get(`${prefix}/flags/:key`, async (c) => {
|
|
573
|
+
const cfg = resolve(c.env);
|
|
574
|
+
const auth = await cfg.auth.admin(c.req.raw.headers);
|
|
575
|
+
if (!auth.ok) return c.json({ error: auth.message ?? "Unauthorized" }, auth.status ?? 401);
|
|
576
|
+
const flag = await cfg.storage.get(c.req.param("key"));
|
|
577
|
+
if (!flag) return c.json({ error: "Flag not found" }, 404);
|
|
578
|
+
return c.json(flag);
|
|
579
|
+
});
|
|
580
|
+
app.put(`${prefix}/flags/:key`, async (c) => {
|
|
581
|
+
const cfg = resolve(c.env);
|
|
582
|
+
const auth = await cfg.auth.admin(c.req.raw.headers);
|
|
583
|
+
if (!auth.ok) return c.json({ error: auth.message ?? "Unauthorized" }, auth.status ?? 401);
|
|
584
|
+
const key = c.req.param("key");
|
|
585
|
+
const parsed = await readJsonBody(await c.req.text());
|
|
586
|
+
if (!parsed.ok) return c.json({ error: parsed.message }, parsed.status);
|
|
587
|
+
const built = buildFlag(
|
|
588
|
+
key,
|
|
589
|
+
parsed.value,
|
|
590
|
+
auth.identity ?? "unknown",
|
|
591
|
+
await cfg.storage.get(key)
|
|
592
|
+
);
|
|
593
|
+
if (!built.ok) return c.json({ error: built.error }, 400);
|
|
594
|
+
await cfg.storage.put(key, built.flag);
|
|
595
|
+
cache.invalidate();
|
|
596
|
+
return c.json(built.flag);
|
|
597
|
+
});
|
|
598
|
+
app.delete(`${prefix}/flags/:key`, async (c) => {
|
|
599
|
+
const cfg = resolve(c.env);
|
|
600
|
+
const auth = await cfg.auth.admin(c.req.raw.headers);
|
|
601
|
+
if (!auth.ok) return c.json({ error: auth.message ?? "Unauthorized" }, auth.status ?? 401);
|
|
602
|
+
await cfg.storage.delete(c.req.param("key"));
|
|
603
|
+
cache.invalidate();
|
|
604
|
+
return c.body(null, 204);
|
|
605
|
+
});
|
|
606
|
+
};
|
|
607
|
+
registerAdmin("/api/v1");
|
|
608
|
+
registerAdmin("");
|
|
609
|
+
app.onError((err, c) => {
|
|
610
|
+
console.error("[flaghoist] unhandled error", err);
|
|
611
|
+
return c.json({ error: "Internal server error" }, 500);
|
|
612
|
+
});
|
|
613
|
+
return app;
|
|
614
|
+
}
|
|
615
|
+
export {
|
|
616
|
+
apiKey,
|
|
617
|
+
bearerToken,
|
|
618
|
+
createFlagServer,
|
|
619
|
+
oidc,
|
|
620
|
+
openApiDocument
|
|
621
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flaghoist/server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Hono app factory for Flaghoist: OFREP evaluate, admin CRUD, and pluggable auth.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.cjs",
|
|
8
|
+
"module": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"import": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"require": {
|
|
17
|
+
"types": "./dist/index.d.cts",
|
|
18
|
+
"default": "./dist/index.cjs"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"./dashboard": {
|
|
22
|
+
"import": {
|
|
23
|
+
"types": "./dist/dashboard.d.ts",
|
|
24
|
+
"default": "./dist/dashboard.js"
|
|
25
|
+
},
|
|
26
|
+
"require": {
|
|
27
|
+
"types": "./dist/dashboard.d.cts",
|
|
28
|
+
"default": "./dist/dashboard.cjs"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"typesVersions": {
|
|
33
|
+
"*": {
|
|
34
|
+
"dashboard": [
|
|
35
|
+
"./dist/dashboard.d.ts"
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist"
|
|
41
|
+
],
|
|
42
|
+
"sideEffects": false,
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=20"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"hono": "^4.13.0",
|
|
48
|
+
"jose": "^6.2.8",
|
|
49
|
+
"@flaghoist/core": "0.1.0"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@flaghoist/adapter-memory": "0.1.0",
|
|
53
|
+
"@flaghoist/dashboard": "0.0.0"
|
|
54
|
+
},
|
|
55
|
+
"publishConfig": {
|
|
56
|
+
"access": "public"
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"build": "node scripts/embed-dashboard.mjs && tsup src/index.ts src/dashboard.ts --format esm,cjs --dts --clean",
|
|
60
|
+
"test": "node scripts/embed-dashboard.mjs && vitest run --passWithNoTests",
|
|
61
|
+
"typecheck": "node scripts/embed-dashboard.mjs && tsc --noEmit",
|
|
62
|
+
"check:package": "publint --strict && attw --pack ."
|
|
63
|
+
}
|
|
64
|
+
}
|