@flaghoist/server 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dashboard.cjs +1 -1
- package/dist/dashboard.js +1 -1
- package/dist/index.cjs +82 -1
- package/dist/index.d.cts +70 -2
- package/dist/index.d.ts +70 -2
- package/dist/index.js +81 -2
- package/package.json +3 -3
package/dist/index.cjs
CHANGED
|
@@ -23,6 +23,8 @@ __export(index_exports, {
|
|
|
23
23
|
apiKey: () => apiKey,
|
|
24
24
|
bearerToken: () => bearerToken,
|
|
25
25
|
createFlagServer: () => createFlagServer,
|
|
26
|
+
defaultRateLimitKey: () => defaultRateLimitKey,
|
|
27
|
+
memoryRateLimit: () => memoryRateLimit,
|
|
26
28
|
oidc: () => oidc,
|
|
27
29
|
openApiDocument: () => openApiDocument
|
|
28
30
|
});
|
|
@@ -58,6 +60,9 @@ function buildFlag(key, body, identity, existing) {
|
|
|
58
60
|
const rollout = typeof b.rollout === "object" && b.rollout !== null ? b.rollout : {};
|
|
59
61
|
const percentage = typeof rollout.percentage === "number" ? (0, import_core.clampPercentage)(rollout.percentage) : 0;
|
|
60
62
|
const description = typeof b.description === "string" ? b.description : "";
|
|
63
|
+
if (description.length > import_core.LIMITS.maxDescriptionLength) {
|
|
64
|
+
return { ok: false, error: `Description exceeds ${import_core.LIMITS.maxDescriptionLength} characters` };
|
|
65
|
+
}
|
|
61
66
|
const inputRules = Array.isArray(b.rules) ? b.rules : [];
|
|
62
67
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
63
68
|
const candidate = {
|
|
@@ -402,6 +407,49 @@ var openApiDocument = {
|
|
|
402
407
|
}
|
|
403
408
|
};
|
|
404
409
|
|
|
410
|
+
// src/ratelimit.ts
|
|
411
|
+
function defaultRateLimitKey(headers) {
|
|
412
|
+
const cf = headers.get("cf-connecting-ip");
|
|
413
|
+
if (cf) return cf;
|
|
414
|
+
const fwd = headers.get("x-forwarded-for");
|
|
415
|
+
if (fwd) {
|
|
416
|
+
const first = fwd.split(",")[0]?.trim();
|
|
417
|
+
if (first) return first;
|
|
418
|
+
}
|
|
419
|
+
return "anonymous";
|
|
420
|
+
}
|
|
421
|
+
function memoryRateLimit(options = {}) {
|
|
422
|
+
const max = options.max ?? 120;
|
|
423
|
+
const windowMs = options.windowMs ?? 6e4;
|
|
424
|
+
const maxKeys = options.maxKeys ?? 1e5;
|
|
425
|
+
const hits = /* @__PURE__ */ new Map();
|
|
426
|
+
const sweep = (now) => {
|
|
427
|
+
for (const [key, entry] of hits) {
|
|
428
|
+
if (entry.resetAt <= now) hits.delete(key);
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
return {
|
|
432
|
+
key: defaultRateLimitKey,
|
|
433
|
+
check(key) {
|
|
434
|
+
const now = Date.now();
|
|
435
|
+
let entry = hits.get(key);
|
|
436
|
+
if (!entry || entry.resetAt <= now) {
|
|
437
|
+
if (!hits.has(key) && hits.size >= maxKeys) {
|
|
438
|
+
sweep(now);
|
|
439
|
+
if (hits.size >= maxKeys) return { ok: true };
|
|
440
|
+
}
|
|
441
|
+
entry = { count: 0, resetAt: now + windowMs };
|
|
442
|
+
hits.set(key, entry);
|
|
443
|
+
}
|
|
444
|
+
entry.count += 1;
|
|
445
|
+
if (entry.count > max) {
|
|
446
|
+
return { ok: false, retryAfter: Math.max(1, Math.ceil((entry.resetAt - now) / 1e3)) };
|
|
447
|
+
}
|
|
448
|
+
return { ok: true };
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
405
453
|
// src/auth.ts
|
|
406
454
|
var import_jose = require("jose");
|
|
407
455
|
function extractBearer(headers) {
|
|
@@ -423,7 +471,22 @@ async function safeEqual(a, b) {
|
|
|
423
471
|
for (let i = 0; i < va.length; i++) diff |= va[i] ^ vb[i];
|
|
424
472
|
return diff === 0;
|
|
425
473
|
}
|
|
474
|
+
var MIN_SECRET_LENGTH = 16;
|
|
475
|
+
var warnedWeakSecrets = /* @__PURE__ */ new Set();
|
|
476
|
+
function warnIfWeakSecret(kind, secret) {
|
|
477
|
+
if (secret.length >= MIN_SECRET_LENGTH) return;
|
|
478
|
+
void (async () => {
|
|
479
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
|
|
480
|
+
const id = Array.from(new Uint8Array(digest).slice(0, 6)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
481
|
+
if (warnedWeakSecrets.has(id)) return;
|
|
482
|
+
warnedWeakSecrets.add(id);
|
|
483
|
+
console.warn(
|
|
484
|
+
`[flaghoist] the ${kind} is ${secret.length} characters. Use a long random value (for example \`openssl rand -hex 32\`); a short secret is guessable, especially since Flaghoist does not rate limit authentication.`
|
|
485
|
+
);
|
|
486
|
+
})();
|
|
487
|
+
}
|
|
426
488
|
function apiKey(expected) {
|
|
489
|
+
warnIfWeakSecret("read API key", expected);
|
|
427
490
|
return async (headers) => {
|
|
428
491
|
const provided = headers.get("x-api-key");
|
|
429
492
|
if (!provided || !await safeEqual(provided, expected)) {
|
|
@@ -433,6 +496,7 @@ function apiKey(expected) {
|
|
|
433
496
|
};
|
|
434
497
|
}
|
|
435
498
|
function bearerToken(expected) {
|
|
499
|
+
warnIfWeakSecret("admin token", expected);
|
|
436
500
|
return async (headers) => {
|
|
437
501
|
const provided = extractBearer(headers);
|
|
438
502
|
if (!provided || !await safeEqual(provided, expected)) {
|
|
@@ -525,8 +589,23 @@ function createFlagServer(config) {
|
|
|
525
589
|
}
|
|
526
590
|
return next();
|
|
527
591
|
});
|
|
592
|
+
app.use("*", async (c, next) => {
|
|
593
|
+
const cfg = resolve(c.env);
|
|
594
|
+
if (!cfg.rateLimit || c.req.path === "/health") return next();
|
|
595
|
+
const deriveKey = cfg.rateLimit.key ?? defaultRateLimitKey;
|
|
596
|
+
const result = await cfg.rateLimit.check(deriveKey(c.req.raw.headers));
|
|
597
|
+
if (!result.ok) {
|
|
598
|
+
if (result.retryAfter) c.header("Retry-After", String(result.retryAfter));
|
|
599
|
+
return c.json({ error: "Too many requests" }, 429);
|
|
600
|
+
}
|
|
601
|
+
return next();
|
|
602
|
+
});
|
|
528
603
|
app.get("/health", (c) => c.json({ status: "ok" }));
|
|
529
|
-
app.get("/api/v1/openapi.json", (c) =>
|
|
604
|
+
app.get("/api/v1/openapi.json", (c) => {
|
|
605
|
+
const cfg = resolve(c.env);
|
|
606
|
+
if (cfg.exposeOpenApi === false) return c.text("Not found", 404);
|
|
607
|
+
return c.json(openApiDocument);
|
|
608
|
+
});
|
|
530
609
|
app.get("/admin", (c) => {
|
|
531
610
|
const cfg = resolve(c.env);
|
|
532
611
|
return cfg.dashboard ? c.html(cfg.dashboard) : c.text("Dashboard not configured", 404);
|
|
@@ -646,6 +725,8 @@ function createFlagServer(config) {
|
|
|
646
725
|
apiKey,
|
|
647
726
|
bearerToken,
|
|
648
727
|
createFlagServer,
|
|
728
|
+
defaultRateLimitKey,
|
|
729
|
+
memoryRateLimit,
|
|
649
730
|
oidc,
|
|
650
731
|
openApiDocument
|
|
651
732
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -3,6 +3,61 @@ import { Hono } from 'hono';
|
|
|
3
3
|
import { StorageAdapter, AttributeValue } from '@flaghoist/core';
|
|
4
4
|
import { JWTVerifyGetKey } from 'jose';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Rate limiting for the server. Opt-in: with no `rateLimit` in the config the server behaves
|
|
8
|
+
* exactly as before, because a limiter with the wrong bucket key is worse than none, and only the
|
|
9
|
+
* operator knows how their deployment is fronted.
|
|
10
|
+
*
|
|
11
|
+
* On a single Node or container process the in-memory limiter here is genuinely effective. On
|
|
12
|
+
* Cloudflare Workers it is per-isolate, so it caps a burst against one isolate but not the fleet;
|
|
13
|
+
* the real answer there is Cloudflare's own Rate Limiting rules in front of the Worker. The read
|
|
14
|
+
* path already fails closed under a 429 (an OFREP client returns the caller's default), so limiting
|
|
15
|
+
* it degrades safely.
|
|
16
|
+
*/
|
|
17
|
+
/** Outcome of a rate-limit check. `retryAfter` is whole seconds until the caller may try again. */
|
|
18
|
+
interface RateLimitResult {
|
|
19
|
+
ok: boolean;
|
|
20
|
+
retryAfter?: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A rate limiter. `check` decides whether a bucket may proceed; `key` chooses the bucket for a
|
|
24
|
+
* request. Bring your own (a Cloudflare binding, a Redis counter) or use `memoryRateLimit`.
|
|
25
|
+
*/
|
|
26
|
+
interface RateLimit {
|
|
27
|
+
check(key: string): RateLimitResult | Promise<RateLimitResult>;
|
|
28
|
+
/**
|
|
29
|
+
* Derive the bucket key from request headers. The default trusts `CF-Connecting-IP` (set by
|
|
30
|
+
* Cloudflare and not spoofable there), then the first hop of `X-Forwarded-For`, then a single
|
|
31
|
+
* shared `anonymous` bucket. On a Node process exposed directly, with no proxy setting a
|
|
32
|
+
* forwarded header, every caller lands in that shared bucket, which caps total throughput rather
|
|
33
|
+
* than per-client; supply your own `key` if you have a trustworthy client identifier.
|
|
34
|
+
*/
|
|
35
|
+
key?(headers: Headers): string;
|
|
36
|
+
}
|
|
37
|
+
interface MemoryRateLimitOptions {
|
|
38
|
+
/** Maximum requests allowed per key within the window. Default 120. */
|
|
39
|
+
max?: number;
|
|
40
|
+
/** Window length in milliseconds. Default 60000 (one minute). */
|
|
41
|
+
windowMs?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Cap on how many distinct keys are tracked, to bound memory against a many-IP flood. When the
|
|
44
|
+
* table is full of live entries the limiter fails open (allows) rather than growing without
|
|
45
|
+
* limit or blocking new clients. Default 100000.
|
|
46
|
+
*/
|
|
47
|
+
maxKeys?: number;
|
|
48
|
+
}
|
|
49
|
+
/** The default bucket key: Cloudflare's client IP, then a forwarded header, then a shared bucket. */
|
|
50
|
+
declare function defaultRateLimitKey(headers: Headers): string;
|
|
51
|
+
/**
|
|
52
|
+
* A fixed-window, in-memory rate limiter. Counts requests per key within a window and denies once
|
|
53
|
+
* the count exceeds `max`, reporting the seconds until the window resets. Memory is bounded: expired
|
|
54
|
+
* entries are swept lazily, and if the table stays full the limiter fails open rather than growing.
|
|
55
|
+
*/
|
|
56
|
+
declare function memoryRateLimit(options?: MemoryRateLimitOptions): {
|
|
57
|
+
check(key: string): RateLimitResult;
|
|
58
|
+
key(headers: Headers): string;
|
|
59
|
+
};
|
|
60
|
+
|
|
6
61
|
/** Result of an authentication attempt. `ok: false` carries the status/message to return. */
|
|
7
62
|
interface AuthResult {
|
|
8
63
|
ok: boolean;
|
|
@@ -31,6 +86,12 @@ interface ServerConfig {
|
|
|
31
86
|
allowedOrigins?: string[];
|
|
32
87
|
/** TTL in seconds for the in-isolate flag-definition cache on the read path. Default: 30. */
|
|
33
88
|
cacheTtlSeconds?: number;
|
|
89
|
+
/**
|
|
90
|
+
* Optional rate limiter, applied to every route except `/health`. Off by default. On a single
|
|
91
|
+
* process the built-in `memoryRateLimit` is effective; on Cloudflare it is per-isolate, so prefer
|
|
92
|
+
* Cloudflare's own Rate Limiting there. See `packages/server/src/ratelimit.ts`.
|
|
93
|
+
*/
|
|
94
|
+
rateLimit?: RateLimit;
|
|
34
95
|
/**
|
|
35
96
|
* Inject trusted context attributes derived from headers you control (e.g. a validated
|
|
36
97
|
* session). These override client-supplied context, so security-relevant targeting can be
|
|
@@ -39,9 +100,16 @@ interface ServerConfig {
|
|
|
39
100
|
trustedContext?: (headers: Headers) => Record<string, AttributeValue>;
|
|
40
101
|
/**
|
|
41
102
|
* Prebuilt admin dashboard HTML (a single-file SPA build) to serve at `/admin`. When set, a
|
|
42
|
-
* single deploy gives you the read API, the admin API, and the management UI together.
|
|
103
|
+
* single deploy gives you the read API, the admin API, and the management UI together. Omit it
|
|
104
|
+
* (or set `dashboard = false` in flaghoist.toml) to run the APIs without the UI.
|
|
43
105
|
*/
|
|
44
106
|
dashboard?: string;
|
|
107
|
+
/**
|
|
108
|
+
* Serve the OpenAPI 3.1 document at `/api/v1/openapi.json`. On by default. It is unauthenticated
|
|
109
|
+
* and describes every route and auth scheme, so a locked-down deployment that does not need it can
|
|
110
|
+
* set this to `false` and stop advertising its own API surface.
|
|
111
|
+
*/
|
|
112
|
+
exposeOpenApi?: boolean;
|
|
45
113
|
}
|
|
46
114
|
/** Config, or a function that derives it from the runtime environment (e.g. Workers bindings). */
|
|
47
115
|
type ConfigResolver<Env> = ServerConfig | ((env: Env) => ServerConfig);
|
|
@@ -98,4 +166,4 @@ declare function createFlagServer<Env extends object = Record<string, unknown>>(
|
|
|
98
166
|
Bindings: Env;
|
|
99
167
|
}, hono_types.BlankSchema, "/">;
|
|
100
168
|
|
|
101
|
-
export { type AuthResult, type Authenticator, type ConfigResolver, type OidcOptions, type ServerConfig, apiKey, bearerToken, createFlagServer, oidc, openApiDocument };
|
|
169
|
+
export { type AuthResult, type Authenticator, type ConfigResolver, type MemoryRateLimitOptions, type OidcOptions, type RateLimit, type RateLimitResult, type ServerConfig, apiKey, bearerToken, createFlagServer, defaultRateLimitKey, memoryRateLimit, oidc, openApiDocument };
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,61 @@ import { Hono } from 'hono';
|
|
|
3
3
|
import { StorageAdapter, AttributeValue } from '@flaghoist/core';
|
|
4
4
|
import { JWTVerifyGetKey } from 'jose';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Rate limiting for the server. Opt-in: with no `rateLimit` in the config the server behaves
|
|
8
|
+
* exactly as before, because a limiter with the wrong bucket key is worse than none, and only the
|
|
9
|
+
* operator knows how their deployment is fronted.
|
|
10
|
+
*
|
|
11
|
+
* On a single Node or container process the in-memory limiter here is genuinely effective. On
|
|
12
|
+
* Cloudflare Workers it is per-isolate, so it caps a burst against one isolate but not the fleet;
|
|
13
|
+
* the real answer there is Cloudflare's own Rate Limiting rules in front of the Worker. The read
|
|
14
|
+
* path already fails closed under a 429 (an OFREP client returns the caller's default), so limiting
|
|
15
|
+
* it degrades safely.
|
|
16
|
+
*/
|
|
17
|
+
/** Outcome of a rate-limit check. `retryAfter` is whole seconds until the caller may try again. */
|
|
18
|
+
interface RateLimitResult {
|
|
19
|
+
ok: boolean;
|
|
20
|
+
retryAfter?: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A rate limiter. `check` decides whether a bucket may proceed; `key` chooses the bucket for a
|
|
24
|
+
* request. Bring your own (a Cloudflare binding, a Redis counter) or use `memoryRateLimit`.
|
|
25
|
+
*/
|
|
26
|
+
interface RateLimit {
|
|
27
|
+
check(key: string): RateLimitResult | Promise<RateLimitResult>;
|
|
28
|
+
/**
|
|
29
|
+
* Derive the bucket key from request headers. The default trusts `CF-Connecting-IP` (set by
|
|
30
|
+
* Cloudflare and not spoofable there), then the first hop of `X-Forwarded-For`, then a single
|
|
31
|
+
* shared `anonymous` bucket. On a Node process exposed directly, with no proxy setting a
|
|
32
|
+
* forwarded header, every caller lands in that shared bucket, which caps total throughput rather
|
|
33
|
+
* than per-client; supply your own `key` if you have a trustworthy client identifier.
|
|
34
|
+
*/
|
|
35
|
+
key?(headers: Headers): string;
|
|
36
|
+
}
|
|
37
|
+
interface MemoryRateLimitOptions {
|
|
38
|
+
/** Maximum requests allowed per key within the window. Default 120. */
|
|
39
|
+
max?: number;
|
|
40
|
+
/** Window length in milliseconds. Default 60000 (one minute). */
|
|
41
|
+
windowMs?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Cap on how many distinct keys are tracked, to bound memory against a many-IP flood. When the
|
|
44
|
+
* table is full of live entries the limiter fails open (allows) rather than growing without
|
|
45
|
+
* limit or blocking new clients. Default 100000.
|
|
46
|
+
*/
|
|
47
|
+
maxKeys?: number;
|
|
48
|
+
}
|
|
49
|
+
/** The default bucket key: Cloudflare's client IP, then a forwarded header, then a shared bucket. */
|
|
50
|
+
declare function defaultRateLimitKey(headers: Headers): string;
|
|
51
|
+
/**
|
|
52
|
+
* A fixed-window, in-memory rate limiter. Counts requests per key within a window and denies once
|
|
53
|
+
* the count exceeds `max`, reporting the seconds until the window resets. Memory is bounded: expired
|
|
54
|
+
* entries are swept lazily, and if the table stays full the limiter fails open rather than growing.
|
|
55
|
+
*/
|
|
56
|
+
declare function memoryRateLimit(options?: MemoryRateLimitOptions): {
|
|
57
|
+
check(key: string): RateLimitResult;
|
|
58
|
+
key(headers: Headers): string;
|
|
59
|
+
};
|
|
60
|
+
|
|
6
61
|
/** Result of an authentication attempt. `ok: false` carries the status/message to return. */
|
|
7
62
|
interface AuthResult {
|
|
8
63
|
ok: boolean;
|
|
@@ -31,6 +86,12 @@ interface ServerConfig {
|
|
|
31
86
|
allowedOrigins?: string[];
|
|
32
87
|
/** TTL in seconds for the in-isolate flag-definition cache on the read path. Default: 30. */
|
|
33
88
|
cacheTtlSeconds?: number;
|
|
89
|
+
/**
|
|
90
|
+
* Optional rate limiter, applied to every route except `/health`. Off by default. On a single
|
|
91
|
+
* process the built-in `memoryRateLimit` is effective; on Cloudflare it is per-isolate, so prefer
|
|
92
|
+
* Cloudflare's own Rate Limiting there. See `packages/server/src/ratelimit.ts`.
|
|
93
|
+
*/
|
|
94
|
+
rateLimit?: RateLimit;
|
|
34
95
|
/**
|
|
35
96
|
* Inject trusted context attributes derived from headers you control (e.g. a validated
|
|
36
97
|
* session). These override client-supplied context, so security-relevant targeting can be
|
|
@@ -39,9 +100,16 @@ interface ServerConfig {
|
|
|
39
100
|
trustedContext?: (headers: Headers) => Record<string, AttributeValue>;
|
|
40
101
|
/**
|
|
41
102
|
* Prebuilt admin dashboard HTML (a single-file SPA build) to serve at `/admin`. When set, a
|
|
42
|
-
* single deploy gives you the read API, the admin API, and the management UI together.
|
|
103
|
+
* single deploy gives you the read API, the admin API, and the management UI together. Omit it
|
|
104
|
+
* (or set `dashboard = false` in flaghoist.toml) to run the APIs without the UI.
|
|
43
105
|
*/
|
|
44
106
|
dashboard?: string;
|
|
107
|
+
/**
|
|
108
|
+
* Serve the OpenAPI 3.1 document at `/api/v1/openapi.json`. On by default. It is unauthenticated
|
|
109
|
+
* and describes every route and auth scheme, so a locked-down deployment that does not need it can
|
|
110
|
+
* set this to `false` and stop advertising its own API surface.
|
|
111
|
+
*/
|
|
112
|
+
exposeOpenApi?: boolean;
|
|
45
113
|
}
|
|
46
114
|
/** Config, or a function that derives it from the runtime environment (e.g. Workers bindings). */
|
|
47
115
|
type ConfigResolver<Env> = ServerConfig | ((env: Env) => ServerConfig);
|
|
@@ -98,4 +166,4 @@ declare function createFlagServer<Env extends object = Record<string, unknown>>(
|
|
|
98
166
|
Bindings: Env;
|
|
99
167
|
}, hono_types.BlankSchema, "/">;
|
|
100
168
|
|
|
101
|
-
export { type AuthResult, type Authenticator, type ConfigResolver, type OidcOptions, type ServerConfig, apiKey, bearerToken, createFlagServer, oidc, openApiDocument };
|
|
169
|
+
export { type AuthResult, type Authenticator, type ConfigResolver, type MemoryRateLimitOptions, type OidcOptions, type RateLimit, type RateLimitResult, type ServerConfig, apiKey, bearerToken, createFlagServer, defaultRateLimitKey, memoryRateLimit, oidc, openApiDocument };
|
package/dist/index.js
CHANGED
|
@@ -20,7 +20,7 @@ function createDefinitionCache() {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
// src/flags.ts
|
|
23
|
-
import { clampPercentage, parseFlag } from "@flaghoist/core";
|
|
23
|
+
import { clampPercentage, LIMITS, parseFlag } from "@flaghoist/core";
|
|
24
24
|
function buildFlag(key, body, identity, existing) {
|
|
25
25
|
if (typeof body !== "object" || body === null) {
|
|
26
26
|
return { ok: false, error: "Request body must be a JSON object" };
|
|
@@ -30,6 +30,9 @@ function buildFlag(key, body, identity, existing) {
|
|
|
30
30
|
const rollout = typeof b.rollout === "object" && b.rollout !== null ? b.rollout : {};
|
|
31
31
|
const percentage = typeof rollout.percentage === "number" ? clampPercentage(rollout.percentage) : 0;
|
|
32
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` };
|
|
35
|
+
}
|
|
33
36
|
const inputRules = Array.isArray(b.rules) ? b.rules : [];
|
|
34
37
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
35
38
|
const candidate = {
|
|
@@ -374,6 +377,49 @@ var openApiDocument = {
|
|
|
374
377
|
}
|
|
375
378
|
};
|
|
376
379
|
|
|
380
|
+
// src/ratelimit.ts
|
|
381
|
+
function defaultRateLimitKey(headers) {
|
|
382
|
+
const cf = headers.get("cf-connecting-ip");
|
|
383
|
+
if (cf) return cf;
|
|
384
|
+
const fwd = headers.get("x-forwarded-for");
|
|
385
|
+
if (fwd) {
|
|
386
|
+
const first = fwd.split(",")[0]?.trim();
|
|
387
|
+
if (first) return first;
|
|
388
|
+
}
|
|
389
|
+
return "anonymous";
|
|
390
|
+
}
|
|
391
|
+
function memoryRateLimit(options = {}) {
|
|
392
|
+
const max = options.max ?? 120;
|
|
393
|
+
const windowMs = options.windowMs ?? 6e4;
|
|
394
|
+
const maxKeys = options.maxKeys ?? 1e5;
|
|
395
|
+
const hits = /* @__PURE__ */ new Map();
|
|
396
|
+
const sweep = (now) => {
|
|
397
|
+
for (const [key, entry] of hits) {
|
|
398
|
+
if (entry.resetAt <= now) hits.delete(key);
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
return {
|
|
402
|
+
key: defaultRateLimitKey,
|
|
403
|
+
check(key) {
|
|
404
|
+
const now = Date.now();
|
|
405
|
+
let entry = hits.get(key);
|
|
406
|
+
if (!entry || entry.resetAt <= now) {
|
|
407
|
+
if (!hits.has(key) && hits.size >= maxKeys) {
|
|
408
|
+
sweep(now);
|
|
409
|
+
if (hits.size >= maxKeys) return { ok: true };
|
|
410
|
+
}
|
|
411
|
+
entry = { count: 0, resetAt: now + windowMs };
|
|
412
|
+
hits.set(key, entry);
|
|
413
|
+
}
|
|
414
|
+
entry.count += 1;
|
|
415
|
+
if (entry.count > max) {
|
|
416
|
+
return { ok: false, retryAfter: Math.max(1, Math.ceil((entry.resetAt - now) / 1e3)) };
|
|
417
|
+
}
|
|
418
|
+
return { ok: true };
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
377
423
|
// src/auth.ts
|
|
378
424
|
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
379
425
|
function extractBearer(headers) {
|
|
@@ -395,7 +441,22 @@ async function safeEqual(a, b) {
|
|
|
395
441
|
for (let i = 0; i < va.length; i++) diff |= va[i] ^ vb[i];
|
|
396
442
|
return diff === 0;
|
|
397
443
|
}
|
|
444
|
+
var MIN_SECRET_LENGTH = 16;
|
|
445
|
+
var warnedWeakSecrets = /* @__PURE__ */ new Set();
|
|
446
|
+
function warnIfWeakSecret(kind, secret) {
|
|
447
|
+
if (secret.length >= MIN_SECRET_LENGTH) return;
|
|
448
|
+
void (async () => {
|
|
449
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
|
|
450
|
+
const id = Array.from(new Uint8Array(digest).slice(0, 6)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
451
|
+
if (warnedWeakSecrets.has(id)) return;
|
|
452
|
+
warnedWeakSecrets.add(id);
|
|
453
|
+
console.warn(
|
|
454
|
+
`[flaghoist] the ${kind} is ${secret.length} characters. Use a long random value (for example \`openssl rand -hex 32\`); a short secret is guessable, especially since Flaghoist does not rate limit authentication.`
|
|
455
|
+
);
|
|
456
|
+
})();
|
|
457
|
+
}
|
|
398
458
|
function apiKey(expected) {
|
|
459
|
+
warnIfWeakSecret("read API key", expected);
|
|
399
460
|
return async (headers) => {
|
|
400
461
|
const provided = headers.get("x-api-key");
|
|
401
462
|
if (!provided || !await safeEqual(provided, expected)) {
|
|
@@ -405,6 +466,7 @@ function apiKey(expected) {
|
|
|
405
466
|
};
|
|
406
467
|
}
|
|
407
468
|
function bearerToken(expected) {
|
|
469
|
+
warnIfWeakSecret("admin token", expected);
|
|
408
470
|
return async (headers) => {
|
|
409
471
|
const provided = extractBearer(headers);
|
|
410
472
|
if (!provided || !await safeEqual(provided, expected)) {
|
|
@@ -497,8 +559,23 @@ function createFlagServer(config) {
|
|
|
497
559
|
}
|
|
498
560
|
return next();
|
|
499
561
|
});
|
|
562
|
+
app.use("*", async (c, next) => {
|
|
563
|
+
const cfg = resolve(c.env);
|
|
564
|
+
if (!cfg.rateLimit || c.req.path === "/health") return next();
|
|
565
|
+
const deriveKey = cfg.rateLimit.key ?? defaultRateLimitKey;
|
|
566
|
+
const result = await cfg.rateLimit.check(deriveKey(c.req.raw.headers));
|
|
567
|
+
if (!result.ok) {
|
|
568
|
+
if (result.retryAfter) c.header("Retry-After", String(result.retryAfter));
|
|
569
|
+
return c.json({ error: "Too many requests" }, 429);
|
|
570
|
+
}
|
|
571
|
+
return next();
|
|
572
|
+
});
|
|
500
573
|
app.get("/health", (c) => c.json({ status: "ok" }));
|
|
501
|
-
app.get("/api/v1/openapi.json", (c) =>
|
|
574
|
+
app.get("/api/v1/openapi.json", (c) => {
|
|
575
|
+
const cfg = resolve(c.env);
|
|
576
|
+
if (cfg.exposeOpenApi === false) return c.text("Not found", 404);
|
|
577
|
+
return c.json(openApiDocument);
|
|
578
|
+
});
|
|
502
579
|
app.get("/admin", (c) => {
|
|
503
580
|
const cfg = resolve(c.env);
|
|
504
581
|
return cfg.dashboard ? c.html(cfg.dashboard) : c.text("Dashboard not configured", 404);
|
|
@@ -617,6 +694,8 @@ export {
|
|
|
617
694
|
apiKey,
|
|
618
695
|
bearerToken,
|
|
619
696
|
createFlagServer,
|
|
697
|
+
defaultRateLimitKey,
|
|
698
|
+
memoryRateLimit,
|
|
620
699
|
oidc,
|
|
621
700
|
openApiDocument
|
|
622
701
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flaghoist/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Hono app factory for Flaghoist: OFREP evaluate, admin CRUD, and pluggable auth.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -46,10 +46,10 @@
|
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"hono": "^4.13.3",
|
|
48
48
|
"jose": "^6.2.9",
|
|
49
|
-
"@flaghoist/core": "0.1.
|
|
49
|
+
"@flaghoist/core": "0.1.2"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"@flaghoist/adapter-memory": "0.1.
|
|
52
|
+
"@flaghoist/adapter-memory": "0.1.2",
|
|
53
53
|
"@flaghoist/dashboard": "0.0.0"
|
|
54
54
|
},
|
|
55
55
|
"publishConfig": {
|