okengine 0.2.8 → 0.3.4
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/AGENTS.md +17 -15
- package/README.md +59 -31
- package/package.json +11 -16
- package/site/content/docs/ai/llms-txt.mdx +54 -0
- package/site/content/docs/ai/mcp.mdx +123 -0
- package/site/content/docs/ai/meta.json +5 -0
- package/site/content/docs/ai/skills.mdx +53 -0
- package/site/content/docs/console/access.mdx +29 -0
- package/site/content/docs/console/ai.mdx +35 -0
- package/site/content/docs/console/architecture.mdx +35 -0
- package/site/content/docs/console/channels.mdx +37 -0
- package/site/content/docs/console/clock.mdx +31 -0
- package/site/content/docs/console/flows.mdx +31 -0
- package/site/content/docs/console/gates.mdx +35 -0
- package/site/content/docs/console/manifest-diff.mdx +34 -0
- package/site/content/docs/console/meta.json +23 -0
- package/site/content/docs/console/overview.mdx +40 -0
- package/site/content/docs/console/plugins.mdx +41 -0
- package/site/content/docs/console/privacy.mdx +32 -0
- package/site/content/docs/console/runs.mdx +40 -0
- package/site/content/docs/console/signals.mdx +31 -0
- package/site/content/docs/console/store.mdx +32 -0
- package/site/content/docs/console/tenancy.mdx +32 -0
- package/site/content/docs/console/traces.mdx +34 -0
- package/site/content/docs/console/vault.mdx +37 -0
- package/site/content/docs/elements/ai.mdx +180 -0
- package/site/content/docs/elements/channel.mdx +167 -0
- package/site/content/docs/elements/clock.mdx +182 -0
- package/site/content/docs/elements/flow.mdx +288 -0
- package/site/content/docs/elements/gate.mdx +171 -0
- package/site/content/docs/elements/meta.json +5 -0
- package/site/content/docs/elements/signal.mdx +171 -0
- package/site/content/docs/elements/store.mdx +320 -0
- package/site/content/docs/elements/vault.mdx +263 -0
- package/site/content/docs/get-started/basic-usage.mdx +124 -0
- package/site/content/docs/get-started/comparison.mdx +65 -0
- package/site/content/docs/get-started/installation.mdx +113 -0
- package/site/content/docs/get-started/introduction.mdx +123 -0
- package/site/content/docs/get-started/meta.json +5 -0
- package/site/content/docs/index.mdx +63 -0
- package/site/content/docs/meta.json +5 -0
- package/site/content/docs/plugins/compression.mdx +60 -0
- package/site/content/docs/plugins/cors.mdx +98 -0
- package/site/content/docs/plugins/csrf.mdx +96 -0
- package/site/content/docs/plugins/ip-allowlist.mdx +93 -0
- package/site/content/docs/plugins/maintenance-mode.mdx +101 -0
- package/site/content/docs/plugins/meta.json +15 -0
- package/site/content/docs/plugins/security-headers.mdx +136 -0
- package/site/content/docs/reference/cli.md +101 -0
- package/site/content/docs/reference/configuration.mdx +159 -0
- package/site/content/docs/reference/environment-variables.mdx +87 -0
- package/site/content/docs/reference/errors.mdx +80 -0
- package/site/content/docs/reference/fx.mdx +117 -0
- package/site/content/docs/reference/meta.json +5 -0
- package/site/content/docs/reference/plugins.mdx +249 -0
- package/site/content/docs/reference/security.md +63 -0
- package/src/auth/auth.test.ts +3 -0
- package/src/cli/ask-dev-mode.ts +1 -1
- package/src/cli/db.ts +87 -17
- package/src/cli/dev-db-push.test.ts +32 -2
- package/src/cli/dev-schema-sync.test.ts +66 -0
- package/src/cli/dev-schema-sync.ts +139 -0
- package/src/cli/dev.test.ts +123 -1
- package/src/cli/dev.ts +150 -8
- package/src/cli/doc-staleness.test.ts +4 -4
- package/src/cli/docker-cli.test.ts +20 -0
- package/src/cli/docker.ts +10 -0
- package/src/cli/drizzle-env.test.ts +67 -0
- package/src/cli/drizzle-env.ts +78 -0
- package/src/cli/ensure-drizzle-config.ts +50 -0
- package/src/cli/hero-meta.test.ts +1 -1
- package/src/cli/load-config.ts +6 -0
- package/src/cli/mode.ts +24 -4
- package/src/cli/openbao-bootstrap.test.ts +149 -0
- package/src/cli/openbao-bootstrap.ts +314 -0
- package/src/cli/openbao-restart.integration.test.ts +136 -0
- package/src/cli/ports.test.ts +7 -5
- package/src/cli/ports.ts +6 -2
- package/src/cli/resolve-dev-sql-env.test.ts +48 -0
- package/src/cli/resolve-dev-sql-env.ts +42 -0
- package/src/cli/stack.ts +7 -4
- package/src/cli/vault-cmd.ts +63 -0
- package/src/client/types.ts +7 -1
- package/src/compiler/extract.test.ts +40 -0
- package/src/compiler/extract.ts +123 -1
- package/src/compiler/fixtures/skyport/oke.config.ts +2 -2
- package/src/compiler/fixtures/skyport.expected.json +1 -1
- package/src/compiler/response.ts +12 -0
- package/src/config/define-config.test.ts +4 -6
- package/src/config/index.ts +2 -15
- package/src/console/server/app.ts +2 -0
- package/src/console/server/vault.ts +21 -6
- package/src/docker/compose.ts +112 -16
- package/src/docker/derive.ts +7 -1
- package/src/docker/docker.test.ts +103 -0
- package/src/docker/index.ts +11 -1
- package/src/docker/recipes/index.ts +3 -2
- package/src/docker/recipes/openbao.ts +47 -0
- package/src/docker/recipes/redis.ts +5 -1
- package/src/docker/recipes/rustfs.ts +2 -3
- package/src/docker/stack-id.test.ts +43 -8
- package/src/docker/stack-id.ts +99 -20
- package/src/docker/stack.ts +36 -4
- package/src/docker/types.ts +3 -0
- package/src/docs-origin.ts +4 -4
- package/src/drivers/drizzle-dialect.test.ts +20 -0
- package/src/drivers/drizzle-dialect.ts +37 -0
- package/src/drivers/index.ts +1 -2
- package/src/drivers/memory.ts +278 -39
- package/src/drivers/s3.ts +10 -1
- package/src/drivers/vault-driver-removal.test.ts +55 -0
- package/src/drivers/vault-openbao.test.ts +97 -0
- package/src/drivers/vault-openbao.ts +102 -35
- package/src/drivers/vault-types.ts +3 -10
- package/src/elements/store/declare.ts +4 -1
- package/src/elements/store/resource-list-docs.fixture.ts +56 -0
- package/src/elements/store/resource-list-docs.test.ts +79 -0
- package/src/elements/store/resource.test.ts +253 -0
- package/src/elements/store/resource.ts +786 -0
- package/src/elements/store/sql-condition.test.ts +132 -0
- package/src/elements/store/sql-condition.ts +284 -46
- package/src/elements/store/sql-session.test.ts +86 -1
- package/src/elements/store/sql-session.ts +187 -27
- package/src/elements/store/table.ts +34 -4
- package/src/elements/store.ts +16 -0
- package/src/elements/vault/runtime.ts +1 -1
- package/src/elements/vault.test.ts +1 -28
- package/src/elements/vault.ts +1 -1
- package/src/kernel/app.ts +59 -25
- package/src/kernel/boot-bind/channel.test.ts +60 -0
- package/src/kernel/boot-bind/channel.ts +64 -2
- package/src/kernel/boot-bind/store.test.ts +10 -1
- package/src/kernel/boot-bind/store.ts +49 -2
- package/src/kernel/boot.test.ts +0 -1
- package/src/kernel/boot.ts +1 -1
- package/src/kernel/edge.test.ts +68 -0
- package/src/kernel/errors.registry.test.ts +1 -1
- package/src/kernel/flow.ts +8 -0
- package/src/kernel/fx.test.ts +23 -3
- package/src/kernel/fx.ts +115 -18
- package/src/kernel/hooks.test.ts +33 -0
- package/src/kernel/hooks.ts +22 -0
- package/src/kernel/index.ts +7 -0
- package/src/kernel/on.ts +44 -3
- package/src/kernel/plugin.ts +33 -3
- package/src/kernel/registry-isolation.test.ts +74 -0
- package/src/kernel/registry.ts +22 -1
- package/src/kernel/triggers.ts +59 -0
- package/src/manifest/fixtures/skyport.excerpt.json +1 -1
- package/src/manifest/fixtures/skyport.manifest.json +1 -1
- package/src/manifest/index.ts +1 -1
- package/src/manifest/types.ts +1 -1
- package/src/manifest/validate.ts +2 -2
- package/src/plugins/compression.test.ts +127 -0
- package/src/plugins/compression.ts +94 -0
- package/src/plugins/config-source.test.ts +204 -0
- package/src/plugins/config-source.ts +209 -0
- package/src/plugins/cors.test.ts +172 -0
- package/src/plugins/cors.ts +153 -0
- package/src/plugins/csrf.test.ts +102 -0
- package/src/plugins/csrf.ts +86 -0
- package/src/plugins/headers.ts +54 -0
- package/src/plugins/index.ts +26 -0
- package/src/plugins/ip-allowlist.test.ts +139 -0
- package/src/plugins/ip-allowlist.ts +129 -0
- package/src/plugins/maintenance-mode.test.ts +91 -0
- package/src/plugins/maintenance-mode.ts +85 -0
- package/src/plugins/security-headers.test.ts +243 -0
- package/src/plugins/security-headers.ts +255 -0
- package/src/release/measure.ts +1 -2
- package/src/test/create-test-app.ts +14 -2
- package/docs/spec/console.md +0 -762
- package/docs/spec/example.md +0 -1374
- package/docs/spec/four-applications.md +0 -1376
- package/docs/spec/unified-theory.md +0 -498
- package/src/cli/doc-drift.test.ts +0 -147
- package/src/cli/doc-drift.ts +0 -401
- package/src/cli/doctor-diff-examples.ts +0 -90
- package/src/drivers/vault-sops.ts +0 -246
- /package/{spec/manifest.v1.schema.json → manifest.v1.schema.json} +0 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Official `ip-allowlist` plugin — client-IP allow/deny rules at the edge
|
|
3
|
+
* of the pipeline. Uses only the public plugin API (unified-theory §14).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { fail } from "../kernel/errors.ts";
|
|
7
|
+
import { plugin, type PluginDef } from "../kernel/plugin.ts";
|
|
8
|
+
import {
|
|
9
|
+
isConfigSource,
|
|
10
|
+
pluginConfigSnapshot,
|
|
11
|
+
resolvePluginOptions,
|
|
12
|
+
withConfigTable,
|
|
13
|
+
type ConfigSource,
|
|
14
|
+
} from "./config-source.ts";
|
|
15
|
+
|
|
16
|
+
/** Options for {@link ipAllowlist}. */
|
|
17
|
+
export interface IpAllowlistOptions {
|
|
18
|
+
/**
|
|
19
|
+
* Client IPs permitted to call any flow. When set, every other client
|
|
20
|
+
* gets `Forbidden`. Exact IPv4/IPv6 strings.
|
|
21
|
+
*/
|
|
22
|
+
readonly allow?: readonly string[];
|
|
23
|
+
/**
|
|
24
|
+
* Client IPs blocked from calling any flow. Checked before `allow`, so
|
|
25
|
+
* a deny always wins on overlap.
|
|
26
|
+
*/
|
|
27
|
+
readonly deny?: readonly string[];
|
|
28
|
+
/**
|
|
29
|
+
* Header carrying the client IP. Default `"x-forwarded-for"`.
|
|
30
|
+
*
|
|
31
|
+
* For XFF, reverse proxies (nginx `$proxy_add_x_forwarded_for`, etc.)
|
|
32
|
+
* **append** the connecting peer — they do not overwrite. The trusted
|
|
33
|
+
* client IP is therefore taken from the **right** of the chain (see
|
|
34
|
+
* {@link IpAllowlistOptions.trustedProxyDepth}), not the left. A client
|
|
35
|
+
* connecting directly can still forge the whole header; this plugin is
|
|
36
|
+
* only trustworthy behind a proxy that appends (or sets) it.
|
|
37
|
+
*/
|
|
38
|
+
readonly header?: string;
|
|
39
|
+
/**
|
|
40
|
+
* How many trusted proxies sit in front of the app and append to XFF.
|
|
41
|
+
* Default `1` (single reverse proxy — the usual docker / edge shape).
|
|
42
|
+
*
|
|
43
|
+
* The client IP is the hop `trustedProxyDepth` entries from the right:
|
|
44
|
+
* depth `1` = last hop (what the nearest proxy observed); depth `2` =
|
|
45
|
+
* second-from-last (CDN + internal LB both appending), and so on.
|
|
46
|
+
*
|
|
47
|
+
* **This must match the real number of trusted proxies in your
|
|
48
|
+
* deployment.** Too low trusts a spoofable left-side hop; too high may
|
|
49
|
+
* pick a proxy address instead of the client. Wrong depth bypasses the
|
|
50
|
+
* allowlist — this is a topology-dependent security control, not a
|
|
51
|
+
* drop-in default you can ignore.
|
|
52
|
+
*/
|
|
53
|
+
readonly trustedProxyDepth?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Reject a non-positive or non-integer `trustedProxyDepth`.
|
|
58
|
+
* Fail loud at construction (and again if runtime config introduces it).
|
|
59
|
+
*/
|
|
60
|
+
function assertSafeIpAllowlistOptions(options: IpAllowlistOptions): void {
|
|
61
|
+
const depth = options.trustedProxyDepth;
|
|
62
|
+
if (depth === undefined) return;
|
|
63
|
+
if (!Number.isInteger(depth) || depth < 1) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`ip-allowlist: trustedProxyDepth must be an integer >= 1 (got ${String(depth)}) — ` +
|
|
66
|
+
"set it to the real number of trusted proxies that append X-Forwarded-For",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Extract the client IP from the configured header.
|
|
73
|
+
*
|
|
74
|
+
* For comma-separated XFF chains, trust the hop `trustedProxyDepth` from
|
|
75
|
+
* the right (nearest trusted proxy's observation at depth 1). Left-side
|
|
76
|
+
* entries are attacker-controlled when clients can set the header before
|
|
77
|
+
* a proxy that appends. Fewer hops than `trustedProxyDepth` → undefined
|
|
78
|
+
* (fail closed).
|
|
79
|
+
*/
|
|
80
|
+
function clientIp(request: Request, header: string, trustedProxyDepth: number): string | undefined {
|
|
81
|
+
const raw = request.headers.get(header);
|
|
82
|
+
if (raw === null) return undefined;
|
|
83
|
+
const hops = raw
|
|
84
|
+
.split(",")
|
|
85
|
+
.map((h) => h.trim())
|
|
86
|
+
.filter((h) => h.length > 0);
|
|
87
|
+
if (hops.length === 0) return undefined;
|
|
88
|
+
const index = hops.length - trustedProxyDepth;
|
|
89
|
+
if (index < 0) return undefined;
|
|
90
|
+
return hops[index];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Enforce IP allow/deny rules at `onAuth`, before any gate policy or flow
|
|
95
|
+
* body runs. Denied clients get the same typed `Forbidden` denial the gate
|
|
96
|
+
* element produces. A missing/empty IP header is denied when `allow` is
|
|
97
|
+
* set, permitted otherwise. Non-HTTP triggers no-op.
|
|
98
|
+
*
|
|
99
|
+
* Accepts static options or a {@link ConfigSource} — block an abusive IP
|
|
100
|
+
* from the database and have every instance pick it up on the next sync.
|
|
101
|
+
*
|
|
102
|
+
* @param options - Allow/deny lists and header name, or a config source
|
|
103
|
+
*/
|
|
104
|
+
export function ipAllowlist(
|
|
105
|
+
options: IpAllowlistOptions | ConfigSource<IpAllowlistOptions>,
|
|
106
|
+
): PluginDef {
|
|
107
|
+
assertSafeIpAllowlistOptions(pluginConfigSnapshot(options));
|
|
108
|
+
|
|
109
|
+
const def = plugin("ip-allowlist", {
|
|
110
|
+
version: "0.0.3",
|
|
111
|
+
config: pluginConfigSnapshot(options),
|
|
112
|
+
}).hook("onAuth", (ctx) => {
|
|
113
|
+
if (!ctx.request) return;
|
|
114
|
+
const resolved = resolvePluginOptions(options);
|
|
115
|
+
assertSafeIpAllowlistOptions(resolved);
|
|
116
|
+
const header = (resolved.header ?? "x-forwarded-for").toLowerCase();
|
|
117
|
+
const depth = resolved.trustedProxyDepth ?? 1;
|
|
118
|
+
const ip = clientIp(ctx.request, header, depth);
|
|
119
|
+
|
|
120
|
+
if (ip !== undefined && (resolved.deny ?? []).includes(ip)) {
|
|
121
|
+
return fail("Forbidden", { reason: "ip_denied", ip });
|
|
122
|
+
}
|
|
123
|
+
if (resolved.allow !== undefined && (ip === undefined || !resolved.allow.includes(ip))) {
|
|
124
|
+
return fail("Forbidden", { reason: "ip_not_allowed", ip });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return isConfigSource(options) ? withConfigTable(def, options) : def;
|
|
129
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `maintenance-mode` plugin — 503 drain behavior, allow-paths, bypass
|
|
3
|
+
* header, and non-HTTP no-op through the real pipeline.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import { oke } from "../kernel/app.ts";
|
|
8
|
+
import { flow, resetFlowSeq } from "../kernel/flow.ts";
|
|
9
|
+
import { on, resetBindings } from "../kernel/on.ts";
|
|
10
|
+
import { http } from "../kernel/triggers.ts";
|
|
11
|
+
import { maintenanceMode } from "./maintenance-mode.ts";
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
resetBindings();
|
|
15
|
+
resetFlowSeq();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("maintenanceMode plugin", () => {
|
|
19
|
+
test("returns 503 with ServiceUnavailable envelope and Retry-After", async () => {
|
|
20
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
21
|
+
const app = oke({ name: "maint" }).plug(maintenanceMode({ retryAfter: 120 }));
|
|
22
|
+
|
|
23
|
+
const res = await app.fetch(new Request("http://localhost/x"));
|
|
24
|
+
|
|
25
|
+
expect(res.status).toBe(503);
|
|
26
|
+
expect(res.headers.get("retry-after")).toBe("120");
|
|
27
|
+
const body = (await res.json()) as {
|
|
28
|
+
data: null;
|
|
29
|
+
error: { code: string; data: { retryAfter?: number }; message?: string };
|
|
30
|
+
};
|
|
31
|
+
expect(body.data).toBeNull();
|
|
32
|
+
expect(body.error.code).toBe("ServiceUnavailable");
|
|
33
|
+
expect(body.error.data.retryAfter).toBe(120);
|
|
34
|
+
expect(body.error.message).toBe("Service is under maintenance.");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("enabled: false passes traffic through", async () => {
|
|
38
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
39
|
+
const app = oke({ name: "maint-off" }).plug(maintenanceMode({ enabled: false }));
|
|
40
|
+
|
|
41
|
+
const res = await app.fetch(new Request("http://localhost/x"));
|
|
42
|
+
|
|
43
|
+
expect(res.status).toBe(200);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("allowPaths prefixes keep serving", async () => {
|
|
47
|
+
on(http.get("/health"), flow({ name: "health.get", do: () => ({ up: true }) }));
|
|
48
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
49
|
+
const app = oke({ name: "maint-allow" }).plug(maintenanceMode({ allowPaths: ["/health"] }));
|
|
50
|
+
|
|
51
|
+
const health = await app.fetch(new Request("http://localhost/health"));
|
|
52
|
+
expect(health.status).toBe(200);
|
|
53
|
+
|
|
54
|
+
const x = await app.fetch(new Request("http://localhost/x"));
|
|
55
|
+
expect(x.status).toBe(503);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("bypass header lets operators through (any non-empty value)", async () => {
|
|
59
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
60
|
+
const app = oke({ name: "maint-bypass" }).plug(
|
|
61
|
+
maintenanceMode({ bypassHeader: "x-ops-token" }),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const blocked = await app.fetch(new Request("http://localhost/x"));
|
|
65
|
+
expect(blocked.status).toBe(503);
|
|
66
|
+
|
|
67
|
+
const allowed = await app.fetch(
|
|
68
|
+
new Request("http://localhost/x", { headers: { "x-ops-token": "let-me-in" } }),
|
|
69
|
+
);
|
|
70
|
+
expect(allowed.status).toBe(200);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("the 503 still flows through onResponse (other plugins stamp it)", async () => {
|
|
74
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
75
|
+
const app = oke({ name: "maint-stamp" }).plug(maintenanceMode());
|
|
76
|
+
app.hook("onResponse", (ctx) => {
|
|
77
|
+
if (!ctx.response) return;
|
|
78
|
+
const headers = new Headers(ctx.response.headers);
|
|
79
|
+
headers.set("x-stamped", "yes");
|
|
80
|
+
ctx.response = new Response(ctx.response.body, {
|
|
81
|
+
status: ctx.response.status,
|
|
82
|
+
headers,
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const res = await app.fetch(new Request("http://localhost/x"));
|
|
87
|
+
|
|
88
|
+
expect(res.status).toBe(503);
|
|
89
|
+
expect(res.headers.get("x-stamped")).toBe("yes");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Official `maintenance-mode` plugin — drain HTTP traffic with one flag.
|
|
3
|
+
* Uses only the public plugin API (unified-theory §14).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { plugin, type PluginDef } from "../kernel/plugin.ts";
|
|
7
|
+
import {
|
|
8
|
+
isConfigSource,
|
|
9
|
+
pluginConfigSnapshot,
|
|
10
|
+
resolvePluginOptions,
|
|
11
|
+
withConfigTable,
|
|
12
|
+
type ConfigSource,
|
|
13
|
+
} from "./config-source.ts";
|
|
14
|
+
|
|
15
|
+
/** Options for {@link maintenanceMode}. */
|
|
16
|
+
export interface MaintenanceModeOptions {
|
|
17
|
+
/**
|
|
18
|
+
* Whether the mode is active. Default `true` — plugging the plugin turns
|
|
19
|
+
* it on. Drive it from the environment to flip without a code change:
|
|
20
|
+
* `enabled: process.env.MAINTENANCE_MODE === "1"`.
|
|
21
|
+
*/
|
|
22
|
+
readonly enabled?: boolean;
|
|
23
|
+
/** `Retry-After` value in seconds on the 503. Omitted unless provided. */
|
|
24
|
+
readonly retryAfter?: number;
|
|
25
|
+
/**
|
|
26
|
+
* Path prefixes that keep serving while the mode is active (e.g.
|
|
27
|
+
* `["/health"]` so load-balancer checks stay green).
|
|
28
|
+
*/
|
|
29
|
+
readonly allowPaths?: readonly string[];
|
|
30
|
+
/**
|
|
31
|
+
* Operator bypass: requests carrying this header name (any non-empty
|
|
32
|
+
* value) pass through. Presence-based — an ops convenience, not auth.
|
|
33
|
+
*/
|
|
34
|
+
readonly bypassHeader?: string;
|
|
35
|
+
/** Failure message in the envelope. Default `"Service is under maintenance."` */
|
|
36
|
+
readonly message?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Short-circuit every HTTP invocation at `onRequest` with a 503 failure
|
|
41
|
+
* envelope (`ServiceUnavailable`) plus an optional `Retry-After`. Excluded
|
|
42
|
+
* prefixes and the bypass header keep ops paths alive. The 503 still flows
|
|
43
|
+
* through `onResponse`, so response-shaping plugins (security headers,
|
|
44
|
+
* compression) apply to it too. Non-HTTP triggers no-op — clock flows and
|
|
45
|
+
* signal subscribers keep running.
|
|
46
|
+
*
|
|
47
|
+
* Accepts static options or a {@link ConfigSource} — the flagship use
|
|
48
|
+
* case: flip `enabled` from the database and drain traffic without a
|
|
49
|
+
* redeploy.
|
|
50
|
+
*
|
|
51
|
+
* @param options - Flag, retry hint, allow-list, bypass — or a config source
|
|
52
|
+
*/
|
|
53
|
+
export function maintenanceMode(
|
|
54
|
+
options: MaintenanceModeOptions | ConfigSource<MaintenanceModeOptions> = {},
|
|
55
|
+
): PluginDef {
|
|
56
|
+
const def = plugin("maintenance-mode", {
|
|
57
|
+
version: "0.0.2",
|
|
58
|
+
config: pluginConfigSnapshot(options),
|
|
59
|
+
}).hook("onRequest", (ctx) => {
|
|
60
|
+
const resolved = resolvePluginOptions(options);
|
|
61
|
+
if ((resolved.enabled ?? true) === false || !ctx.request) return;
|
|
62
|
+
const message = resolved.message ?? "Service is under maintenance.";
|
|
63
|
+
|
|
64
|
+
const { pathname } = new URL(ctx.request.url);
|
|
65
|
+
if (resolved.allowPaths?.some((prefix) => pathname.startsWith(prefix))) return;
|
|
66
|
+
|
|
67
|
+
const bypass = resolved.bypassHeader;
|
|
68
|
+
if (bypass !== undefined) {
|
|
69
|
+
const value = ctx.request.headers.get(bypass);
|
|
70
|
+
if (value !== null && value.length > 0) return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const headers: Record<string, string> = {};
|
|
74
|
+
if (resolved.retryAfter !== undefined) headers["retry-after"] = String(resolved.retryAfter);
|
|
75
|
+
return Response.json(
|
|
76
|
+
{
|
|
77
|
+
data: null,
|
|
78
|
+
error: { code: "ServiceUnavailable", data: { retryAfter: resolved.retryAfter }, message },
|
|
79
|
+
},
|
|
80
|
+
{ status: 503, headers },
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return isConfigSource(options) ? withConfigTable(def, options) : def;
|
|
85
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `security-headers` plugin — defaults on success, presence on failure,
|
|
3
|
+
* and app-wins override semantics through the real pipeline.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import { oke } from "../kernel/app.ts";
|
|
8
|
+
import { flow, resetFlowSeq } from "../kernel/flow.ts";
|
|
9
|
+
import { fail } from "../kernel/index.ts";
|
|
10
|
+
import { on, resetBindings } from "../kernel/on.ts";
|
|
11
|
+
import { http } from "../kernel/triggers.ts";
|
|
12
|
+
import { defaultCspDirectives, securityHeaders } from "./security-headers.ts";
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
resetBindings();
|
|
16
|
+
resetFlowSeq();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("securityHeaders plugin", () => {
|
|
20
|
+
test("sets the default trio on a successful response", async () => {
|
|
21
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
22
|
+
const app = oke({ name: "sec" }).plug(securityHeaders());
|
|
23
|
+
|
|
24
|
+
const res = await app.fetch(new Request("http://localhost/x"));
|
|
25
|
+
|
|
26
|
+
expect(res.status).toBe(200);
|
|
27
|
+
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
|
|
28
|
+
expect(res.headers.get("x-frame-options")).toBe("DENY");
|
|
29
|
+
expect(res.headers.get("referrer-policy")).toBe("no-referrer");
|
|
30
|
+
expect(res.headers.get("content-security-policy")).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("helmet-parity defaults: agent cluster, dns prefetch, download, cross-domain, xss filter", async () => {
|
|
34
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
35
|
+
const app = oke({ name: "sec-parity" }).plug(securityHeaders());
|
|
36
|
+
|
|
37
|
+
const res = await app.fetch(new Request("http://localhost/x"));
|
|
38
|
+
|
|
39
|
+
expect(res.headers.get("origin-agent-cluster")).toBe("?1");
|
|
40
|
+
expect(res.headers.get("x-dns-prefetch-control")).toBe("off");
|
|
41
|
+
expect(res.headers.get("x-download-options")).toBe("noopen");
|
|
42
|
+
expect(res.headers.get("x-permitted-cross-domain-policies")).toBe("none");
|
|
43
|
+
expect(res.headers.get("x-xss-protection")).toBe("0");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("each parity default can be switched off", async () => {
|
|
47
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
48
|
+
const app = oke({ name: "sec-off" }).plug(
|
|
49
|
+
securityHeaders({
|
|
50
|
+
originAgentCluster: false,
|
|
51
|
+
dnsPrefetchControl: false,
|
|
52
|
+
downloadOptions: false,
|
|
53
|
+
xssProtection: false,
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const res = await app.fetch(new Request("http://localhost/x"));
|
|
58
|
+
|
|
59
|
+
expect(res.headers.get("origin-agent-cluster")).toBeNull();
|
|
60
|
+
expect(res.headers.get("x-dns-prefetch-control")).toBeNull();
|
|
61
|
+
expect(res.headers.get("x-download-options")).toBeNull();
|
|
62
|
+
expect(res.headers.get("x-xss-protection")).toBeNull();
|
|
63
|
+
expect(res.headers.get("x-permitted-cross-domain-policies")).toBe("none");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("dnsPrefetchControl allow, custom cross-domain policy, COEP stamps when configured", async () => {
|
|
67
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
68
|
+
const app = oke({ name: "sec-tuned" }).plug(
|
|
69
|
+
securityHeaders({
|
|
70
|
+
dnsPrefetchControl: { allow: true },
|
|
71
|
+
permittedCrossDomainPolicies: "by-content-type",
|
|
72
|
+
crossOriginEmbedderPolicy: "require-corp",
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const res = await app.fetch(new Request("http://localhost/x"));
|
|
77
|
+
|
|
78
|
+
expect(res.headers.get("x-dns-prefetch-control")).toBe("on");
|
|
79
|
+
expect(res.headers.get("x-permitted-cross-domain-policies")).toBe("by-content-type");
|
|
80
|
+
expect(res.headers.get("cross-origin-embedder-policy")).toBe("require-corp");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("x-powered-by is removed by default; a string sets a decoy; false keeps the app's", async () => {
|
|
84
|
+
const poweredByApp = (options: Parameters<typeof securityHeaders>[0], name: string) => {
|
|
85
|
+
on(
|
|
86
|
+
http.get("/x"),
|
|
87
|
+
flow({
|
|
88
|
+
name: "x.get",
|
|
89
|
+
do: () =>
|
|
90
|
+
new Response("{}", {
|
|
91
|
+
headers: { "content-type": "application/json", "x-powered-by": "Express" },
|
|
92
|
+
}),
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
return oke({ name }).plug(securityHeaders(options));
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const removed = await poweredByApp({}, "sec-pb-off").fetch(new Request("http://localhost/x"));
|
|
99
|
+
expect(removed.headers.get("x-powered-by")).toBeNull();
|
|
100
|
+
|
|
101
|
+
const decoy = await poweredByApp(
|
|
102
|
+
{ poweredBy: "PHP 4.2.0", override: true },
|
|
103
|
+
"sec-pb-decoy",
|
|
104
|
+
).fetch(new Request("http://localhost/x"));
|
|
105
|
+
expect(decoy.headers.get("x-powered-by")).toBe("PHP 4.2.0");
|
|
106
|
+
|
|
107
|
+
const kept = await poweredByApp({ poweredBy: false }, "sec-pb-keep").fetch(
|
|
108
|
+
new Request("http://localhost/x"),
|
|
109
|
+
);
|
|
110
|
+
expect(kept.headers.get("x-powered-by")).toBe("Express");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("structured CSP merges over helmet's defaults, camelCase keys, report-only mode", async () => {
|
|
114
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
115
|
+
const app = oke({ name: "sec-csp-builder" }).plug(
|
|
116
|
+
securityHeaders({
|
|
117
|
+
contentSecurityPolicy: {
|
|
118
|
+
directives: { scriptSrc: ["'self'", "https://cdn.example.com"] },
|
|
119
|
+
reportOnly: true,
|
|
120
|
+
},
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const res = await app.fetch(new Request("http://localhost/x"));
|
|
125
|
+
|
|
126
|
+
expect(res.headers.get("content-security-policy")).toBeNull();
|
|
127
|
+
const reportOnly = res.headers.get("content-security-policy-report-only");
|
|
128
|
+
expect(reportOnly).toContain("script-src 'self' https://cdn.example.com");
|
|
129
|
+
expect(reportOnly).toContain("default-src 'self'");
|
|
130
|
+
expect(reportOnly).toContain("upgrade-insecure-requests");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("structured CSP with useDefaults: false emits exactly the given directives", async () => {
|
|
134
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
135
|
+
const app = oke({ name: "sec-csp-bare" }).plug(
|
|
136
|
+
securityHeaders({
|
|
137
|
+
contentSecurityPolicy: { useDefaults: false, directives: { "default-src": ["'none'"] } },
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const res = await app.fetch(new Request("http://localhost/x"));
|
|
142
|
+
expect(res.headers.get("content-security-policy")).toBe("default-src 'none'");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("defaultCspDirectives matches helmet's documented default policy", () => {
|
|
146
|
+
expect(defaultCspDirectives["upgrade-insecure-requests"]).toEqual([]);
|
|
147
|
+
expect(defaultCspDirectives["script-src-attr"]).toEqual(["'none'"]);
|
|
148
|
+
expect(Object.keys(defaultCspDirectives)).toHaveLength(11);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("headers land on failures too (onResponse runs after onError)", async () => {
|
|
152
|
+
on(
|
|
153
|
+
http.get("/deny"),
|
|
154
|
+
flow({ name: "x.deny", do: () => ({ ok: true }) }).hook("beforeHandle", () =>
|
|
155
|
+
fail("Forbidden", {}),
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
const app = oke({ name: "sec-fail" }).plug(securityHeaders());
|
|
159
|
+
|
|
160
|
+
const res = await app.fetch(new Request("http://localhost/deny"));
|
|
161
|
+
|
|
162
|
+
expect(res.status).not.toBe(200);
|
|
163
|
+
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("adds CSP only when configured", async () => {
|
|
167
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
168
|
+
const app = oke({ name: "sec-csp" }).plug(
|
|
169
|
+
securityHeaders({ contentSecurityPolicy: "default-src 'self'" }),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const res = await app.fetch(new Request("http://localhost/x"));
|
|
173
|
+
|
|
174
|
+
expect(res.headers.get("content-security-policy")).toBe("default-src 'self'");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("HSTS is off by default", async () => {
|
|
178
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
179
|
+
const app = oke({ name: "sec-hsts-off" }).plug(securityHeaders());
|
|
180
|
+
|
|
181
|
+
const res = await app.fetch(new Request("http://localhost/x"));
|
|
182
|
+
|
|
183
|
+
expect(res.headers.get("strict-transport-security")).toBeNull();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("hsts: true stamps a one-year max-age", async () => {
|
|
187
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
188
|
+
const app = oke({ name: "sec-hsts-on" }).plug(securityHeaders({ hsts: true }));
|
|
189
|
+
|
|
190
|
+
const res = await app.fetch(new Request("http://localhost/x"));
|
|
191
|
+
|
|
192
|
+
expect(res.headers.get("strict-transport-security")).toBe("max-age=31536000");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("hsts object tunes max-age, subdomains, preload", async () => {
|
|
196
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
197
|
+
const app = oke({ name: "sec-hsts-tuned" }).plug(
|
|
198
|
+
securityHeaders({ hsts: { maxAge: 63072000, includeSubDomains: true, preload: true } }),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const res = await app.fetch(new Request("http://localhost/x"));
|
|
202
|
+
|
|
203
|
+
expect(res.headers.get("strict-transport-security")).toBe(
|
|
204
|
+
"max-age=63072000; includeSubDomains; preload",
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("permissions-policy and cross-origin policies stamp when configured", async () => {
|
|
209
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
210
|
+
const app = oke({ name: "sec-extra" }).plug(
|
|
211
|
+
securityHeaders({
|
|
212
|
+
permissionsPolicy: "camera=(), microphone=()",
|
|
213
|
+
crossOriginOpenerPolicy: "same-origin",
|
|
214
|
+
crossOriginResourcePolicy: "same-site",
|
|
215
|
+
}),
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const res = await app.fetch(new Request("http://localhost/x"));
|
|
219
|
+
|
|
220
|
+
expect(res.headers.get("permissions-policy")).toBe("camera=(), microphone=()");
|
|
221
|
+
expect(res.headers.get("cross-origin-opener-policy")).toBe("same-origin");
|
|
222
|
+
expect(res.headers.get("cross-origin-resource-policy")).toBe("same-site");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("an explicit app-set value wins by default", async () => {
|
|
226
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
227
|
+
const app = oke({ name: "sec-keep" });
|
|
228
|
+
app.hook("onResponse", (ctx) => {
|
|
229
|
+
if (!ctx.response) return;
|
|
230
|
+
const headers = new Headers(ctx.response.headers);
|
|
231
|
+
headers.set("x-frame-options", "SAMEORIGIN");
|
|
232
|
+
ctx.response = new Response(ctx.response.body, {
|
|
233
|
+
status: ctx.response.status,
|
|
234
|
+
headers,
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
app.plug(securityHeaders());
|
|
238
|
+
|
|
239
|
+
const res = await app.fetch(new Request("http://localhost/x"));
|
|
240
|
+
|
|
241
|
+
expect(res.headers.get("x-frame-options")).toBe("SAMEORIGIN");
|
|
242
|
+
});
|
|
243
|
+
});
|