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.
Files changed (180) hide show
  1. package/AGENTS.md +17 -15
  2. package/README.md +59 -31
  3. package/package.json +11 -16
  4. package/site/content/docs/ai/llms-txt.mdx +54 -0
  5. package/site/content/docs/ai/mcp.mdx +123 -0
  6. package/site/content/docs/ai/meta.json +5 -0
  7. package/site/content/docs/ai/skills.mdx +53 -0
  8. package/site/content/docs/console/access.mdx +29 -0
  9. package/site/content/docs/console/ai.mdx +35 -0
  10. package/site/content/docs/console/architecture.mdx +35 -0
  11. package/site/content/docs/console/channels.mdx +37 -0
  12. package/site/content/docs/console/clock.mdx +31 -0
  13. package/site/content/docs/console/flows.mdx +31 -0
  14. package/site/content/docs/console/gates.mdx +35 -0
  15. package/site/content/docs/console/manifest-diff.mdx +34 -0
  16. package/site/content/docs/console/meta.json +23 -0
  17. package/site/content/docs/console/overview.mdx +40 -0
  18. package/site/content/docs/console/plugins.mdx +41 -0
  19. package/site/content/docs/console/privacy.mdx +32 -0
  20. package/site/content/docs/console/runs.mdx +40 -0
  21. package/site/content/docs/console/signals.mdx +31 -0
  22. package/site/content/docs/console/store.mdx +32 -0
  23. package/site/content/docs/console/tenancy.mdx +32 -0
  24. package/site/content/docs/console/traces.mdx +34 -0
  25. package/site/content/docs/console/vault.mdx +37 -0
  26. package/site/content/docs/elements/ai.mdx +180 -0
  27. package/site/content/docs/elements/channel.mdx +167 -0
  28. package/site/content/docs/elements/clock.mdx +182 -0
  29. package/site/content/docs/elements/flow.mdx +288 -0
  30. package/site/content/docs/elements/gate.mdx +171 -0
  31. package/site/content/docs/elements/meta.json +5 -0
  32. package/site/content/docs/elements/signal.mdx +171 -0
  33. package/site/content/docs/elements/store.mdx +320 -0
  34. package/site/content/docs/elements/vault.mdx +263 -0
  35. package/site/content/docs/get-started/basic-usage.mdx +124 -0
  36. package/site/content/docs/get-started/comparison.mdx +65 -0
  37. package/site/content/docs/get-started/installation.mdx +113 -0
  38. package/site/content/docs/get-started/introduction.mdx +123 -0
  39. package/site/content/docs/get-started/meta.json +5 -0
  40. package/site/content/docs/index.mdx +63 -0
  41. package/site/content/docs/meta.json +5 -0
  42. package/site/content/docs/plugins/compression.mdx +60 -0
  43. package/site/content/docs/plugins/cors.mdx +98 -0
  44. package/site/content/docs/plugins/csrf.mdx +96 -0
  45. package/site/content/docs/plugins/ip-allowlist.mdx +93 -0
  46. package/site/content/docs/plugins/maintenance-mode.mdx +101 -0
  47. package/site/content/docs/plugins/meta.json +15 -0
  48. package/site/content/docs/plugins/security-headers.mdx +136 -0
  49. package/site/content/docs/reference/cli.md +101 -0
  50. package/site/content/docs/reference/configuration.mdx +159 -0
  51. package/site/content/docs/reference/environment-variables.mdx +87 -0
  52. package/site/content/docs/reference/errors.mdx +80 -0
  53. package/site/content/docs/reference/fx.mdx +117 -0
  54. package/site/content/docs/reference/meta.json +5 -0
  55. package/site/content/docs/reference/plugins.mdx +249 -0
  56. package/site/content/docs/reference/security.md +63 -0
  57. package/src/auth/auth.test.ts +3 -0
  58. package/src/cli/ask-dev-mode.ts +1 -1
  59. package/src/cli/db.ts +87 -17
  60. package/src/cli/dev-db-push.test.ts +32 -2
  61. package/src/cli/dev-schema-sync.test.ts +66 -0
  62. package/src/cli/dev-schema-sync.ts +139 -0
  63. package/src/cli/dev.test.ts +123 -1
  64. package/src/cli/dev.ts +150 -8
  65. package/src/cli/doc-staleness.test.ts +4 -4
  66. package/src/cli/docker-cli.test.ts +20 -0
  67. package/src/cli/docker.ts +10 -0
  68. package/src/cli/drizzle-env.test.ts +67 -0
  69. package/src/cli/drizzle-env.ts +78 -0
  70. package/src/cli/ensure-drizzle-config.ts +50 -0
  71. package/src/cli/hero-meta.test.ts +1 -1
  72. package/src/cli/load-config.ts +6 -0
  73. package/src/cli/mode.ts +24 -4
  74. package/src/cli/openbao-bootstrap.test.ts +149 -0
  75. package/src/cli/openbao-bootstrap.ts +314 -0
  76. package/src/cli/openbao-restart.integration.test.ts +136 -0
  77. package/src/cli/ports.test.ts +7 -5
  78. package/src/cli/ports.ts +6 -2
  79. package/src/cli/resolve-dev-sql-env.test.ts +48 -0
  80. package/src/cli/resolve-dev-sql-env.ts +42 -0
  81. package/src/cli/stack.ts +7 -4
  82. package/src/cli/vault-cmd.ts +63 -0
  83. package/src/client/types.ts +7 -1
  84. package/src/compiler/extract.test.ts +40 -0
  85. package/src/compiler/extract.ts +123 -1
  86. package/src/compiler/fixtures/skyport/oke.config.ts +2 -2
  87. package/src/compiler/fixtures/skyport.expected.json +1 -1
  88. package/src/compiler/response.ts +12 -0
  89. package/src/config/define-config.test.ts +4 -6
  90. package/src/config/index.ts +2 -15
  91. package/src/console/server/app.ts +2 -0
  92. package/src/console/server/vault.ts +21 -6
  93. package/src/docker/compose.ts +112 -16
  94. package/src/docker/derive.ts +7 -1
  95. package/src/docker/docker.test.ts +103 -0
  96. package/src/docker/index.ts +11 -1
  97. package/src/docker/recipes/index.ts +3 -2
  98. package/src/docker/recipes/openbao.ts +47 -0
  99. package/src/docker/recipes/redis.ts +5 -1
  100. package/src/docker/recipes/rustfs.ts +2 -3
  101. package/src/docker/stack-id.test.ts +43 -8
  102. package/src/docker/stack-id.ts +99 -20
  103. package/src/docker/stack.ts +36 -4
  104. package/src/docker/types.ts +3 -0
  105. package/src/docs-origin.ts +4 -4
  106. package/src/drivers/drizzle-dialect.test.ts +20 -0
  107. package/src/drivers/drizzle-dialect.ts +37 -0
  108. package/src/drivers/index.ts +1 -2
  109. package/src/drivers/memory.ts +278 -39
  110. package/src/drivers/s3.ts +10 -1
  111. package/src/drivers/vault-driver-removal.test.ts +55 -0
  112. package/src/drivers/vault-openbao.test.ts +97 -0
  113. package/src/drivers/vault-openbao.ts +102 -35
  114. package/src/drivers/vault-types.ts +3 -10
  115. package/src/elements/store/declare.ts +4 -1
  116. package/src/elements/store/resource-list-docs.fixture.ts +56 -0
  117. package/src/elements/store/resource-list-docs.test.ts +79 -0
  118. package/src/elements/store/resource.test.ts +253 -0
  119. package/src/elements/store/resource.ts +786 -0
  120. package/src/elements/store/sql-condition.test.ts +132 -0
  121. package/src/elements/store/sql-condition.ts +284 -46
  122. package/src/elements/store/sql-session.test.ts +86 -1
  123. package/src/elements/store/sql-session.ts +187 -27
  124. package/src/elements/store/table.ts +34 -4
  125. package/src/elements/store.ts +16 -0
  126. package/src/elements/vault/runtime.ts +1 -1
  127. package/src/elements/vault.test.ts +1 -28
  128. package/src/elements/vault.ts +1 -1
  129. package/src/kernel/app.ts +59 -25
  130. package/src/kernel/boot-bind/channel.test.ts +60 -0
  131. package/src/kernel/boot-bind/channel.ts +64 -2
  132. package/src/kernel/boot-bind/store.test.ts +10 -1
  133. package/src/kernel/boot-bind/store.ts +49 -2
  134. package/src/kernel/boot.test.ts +0 -1
  135. package/src/kernel/boot.ts +1 -1
  136. package/src/kernel/edge.test.ts +68 -0
  137. package/src/kernel/errors.registry.test.ts +1 -1
  138. package/src/kernel/flow.ts +8 -0
  139. package/src/kernel/fx.test.ts +23 -3
  140. package/src/kernel/fx.ts +115 -18
  141. package/src/kernel/hooks.test.ts +33 -0
  142. package/src/kernel/hooks.ts +22 -0
  143. package/src/kernel/index.ts +7 -0
  144. package/src/kernel/on.ts +44 -3
  145. package/src/kernel/plugin.ts +33 -3
  146. package/src/kernel/registry-isolation.test.ts +74 -0
  147. package/src/kernel/registry.ts +22 -1
  148. package/src/kernel/triggers.ts +59 -0
  149. package/src/manifest/fixtures/skyport.excerpt.json +1 -1
  150. package/src/manifest/fixtures/skyport.manifest.json +1 -1
  151. package/src/manifest/index.ts +1 -1
  152. package/src/manifest/types.ts +1 -1
  153. package/src/manifest/validate.ts +2 -2
  154. package/src/plugins/compression.test.ts +127 -0
  155. package/src/plugins/compression.ts +94 -0
  156. package/src/plugins/config-source.test.ts +204 -0
  157. package/src/plugins/config-source.ts +209 -0
  158. package/src/plugins/cors.test.ts +172 -0
  159. package/src/plugins/cors.ts +153 -0
  160. package/src/plugins/csrf.test.ts +102 -0
  161. package/src/plugins/csrf.ts +86 -0
  162. package/src/plugins/headers.ts +54 -0
  163. package/src/plugins/index.ts +26 -0
  164. package/src/plugins/ip-allowlist.test.ts +139 -0
  165. package/src/plugins/ip-allowlist.ts +129 -0
  166. package/src/plugins/maintenance-mode.test.ts +91 -0
  167. package/src/plugins/maintenance-mode.ts +85 -0
  168. package/src/plugins/security-headers.test.ts +243 -0
  169. package/src/plugins/security-headers.ts +255 -0
  170. package/src/release/measure.ts +1 -2
  171. package/src/test/create-test-app.ts +14 -2
  172. package/docs/spec/console.md +0 -762
  173. package/docs/spec/example.md +0 -1374
  174. package/docs/spec/four-applications.md +0 -1376
  175. package/docs/spec/unified-theory.md +0 -498
  176. package/src/cli/doc-drift.test.ts +0 -147
  177. package/src/cli/doc-drift.ts +0 -401
  178. package/src/cli/doctor-diff-examples.ts +0 -90
  179. package/src/drivers/vault-sops.ts +0 -246
  180. /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
+ });