okengine 0.4.3 → 0.5.1

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 (138) hide show
  1. package/package.json +3 -1
  2. package/site/content/docs/ai/index.mdx +24 -0
  3. package/site/content/docs/ai/llms-txt.mdx +3 -0
  4. package/site/content/docs/ai/meta.json +1 -1
  5. package/site/content/docs/console/gates.mdx +46 -8
  6. package/site/content/docs/console/index.mdx +54 -0
  7. package/site/content/docs/console/meta.json +1 -0
  8. package/site/content/docs/elements/gate.mdx +187 -48
  9. package/site/content/docs/elements/index.mdx +45 -0
  10. package/site/content/docs/elements/meta.json +1 -1
  11. package/site/content/docs/get-started/basic-usage.mdx +4 -3
  12. package/site/content/docs/get-started/index.mdx +33 -0
  13. package/site/content/docs/get-started/meta.json +1 -1
  14. package/site/content/docs/get-started/why.mdx +10 -10
  15. package/site/content/docs/index.mdx +9 -31
  16. package/site/content/docs/plugins/anonymous.mdx +95 -0
  17. package/site/content/docs/plugins/compression.mdx +2 -2
  18. package/site/content/docs/plugins/cors.mdx +2 -2
  19. package/site/content/docs/plugins/csrf.mdx +2 -2
  20. package/site/content/docs/plugins/email-otp.mdx +111 -0
  21. package/site/content/docs/plugins/{security-headers.mdx → headers.mdx} +11 -11
  22. package/site/content/docs/plugins/index.mdx +69 -0
  23. package/site/content/docs/plugins/magic-link.mdx +112 -0
  24. package/site/content/docs/plugins/maintenance-mode.mdx +8 -8
  25. package/site/content/docs/plugins/meta.json +10 -1
  26. package/site/content/docs/plugins/passkey.mdx +140 -0
  27. package/site/content/docs/plugins/phone-number.mdx +111 -0
  28. package/site/content/docs/plugins/two-factor.mdx +117 -0
  29. package/site/content/docs/plugins/username.mdx +117 -0
  30. package/site/content/docs/reference/client.mdx +331 -0
  31. package/site/content/docs/reference/fx.mdx +20 -5
  32. package/site/content/docs/reference/index.mdx +45 -0
  33. package/site/content/docs/reference/meta.json +11 -1
  34. package/site/content/docs/reference/plugins.mdx +25 -14
  35. package/src/auth/auth.test.ts +20 -2
  36. package/src/auth/bindings.ts +439 -0
  37. package/src/auth/breach-check.ts +112 -0
  38. package/src/auth/config.ts +288 -0
  39. package/src/auth/constant-time.ts +22 -0
  40. package/src/auth/cookies.ts +123 -0
  41. package/src/auth/gate-auth.test.ts +379 -0
  42. package/src/auth/identity.ts +190 -0
  43. package/src/auth/index.ts +119 -1
  44. package/src/auth/method-context.ts +33 -0
  45. package/src/auth/operator.ts +27 -1
  46. package/src/auth/password-policy.test.ts +126 -0
  47. package/src/auth/password-policy.ts +77 -0
  48. package/src/auth/plugin.ts +62 -4
  49. package/src/auth/rate.ts +45 -0
  50. package/src/auth/schema.ts +260 -0
  51. package/src/auth/secondary-storage.ts +37 -0
  52. package/src/auth/sessions.ts +58 -1
  53. package/src/auth/tables.ts +4 -0
  54. package/src/auth/verification.ts +78 -0
  55. package/src/cli/competitor-mention-removal.test.ts +3 -3
  56. package/src/cli/dev.test.ts +3 -3
  57. package/src/cli/schema.ts +95 -23
  58. package/src/client/auth.ts +120 -0
  59. package/src/client-react/index.ts +93 -0
  60. package/src/compiler/aot.test.ts +2 -1
  61. package/src/compiler/extract.ts +19 -0
  62. package/src/compiler/response.ts +16 -2
  63. package/src/console/server/app.ts +10 -6
  64. package/src/console/server/auth-rate.test.ts +3 -3
  65. package/src/console/server/bind.ts +12 -1
  66. package/src/console/server/channels.test.ts +1 -1
  67. package/src/console/server/console-gates.ts +14 -0
  68. package/src/console/server/console.test.ts +6 -6
  69. package/src/console/server/flows-invoke.test.ts +2 -2
  70. package/src/console/server/flows.ts +2 -0
  71. package/src/console/server/gates.ts +8 -1
  72. package/src/console/server/operator-db.test.ts +4 -4
  73. package/src/console/server/operator-db.ts +22 -4
  74. package/src/console/server/security.gate.test.ts +3 -3
  75. package/src/console/ui/gates/fixture.ts +4 -0
  76. package/src/console/ui/gates/types.ts +2 -0
  77. package/src/console/ui/shell/client.ts +1 -0
  78. package/src/elements/gate/boot.ts +136 -0
  79. package/src/elements/gate/config.ts +69 -0
  80. package/src/elements/gate/declare.ts +51 -1
  81. package/src/elements/gate/runtime.ts +3 -1
  82. package/src/elements/gate.test.ts +77 -0
  83. package/src/elements/gate.ts +20 -1
  84. package/src/elements/index.ts +8 -0
  85. package/src/index.ts +11 -0
  86. package/src/kernel/app.ts +253 -32
  87. package/src/kernel/boot.test.ts +40 -3
  88. package/src/kernel/boot.ts +8 -0
  89. package/src/kernel/call.test.ts +46 -2
  90. package/src/kernel/edge.test.ts +3 -3
  91. package/src/kernel/flow.test.ts +2 -2
  92. package/src/kernel/fx.test.ts +1 -0
  93. package/src/kernel/fx.ts +60 -0
  94. package/src/kernel/hooks.test.ts +4 -4
  95. package/src/kernel/index.ts +12 -0
  96. package/src/kernel/pipeline.test.ts +12 -8
  97. package/src/kernel/pipeline.ts +23 -4
  98. package/src/kernel/plugin/decorate.test.ts +3 -3
  99. package/src/kernel/plugin/scoping.test.ts +3 -3
  100. package/src/kernel/plugin-elements.test.ts +51 -0
  101. package/src/kernel/plugin-needs.test.ts +83 -0
  102. package/src/kernel/plugin-needs.ts +129 -0
  103. package/src/kernel/plugin.ts +101 -0
  104. package/src/kernel/registry-isolation.test.ts +5 -5
  105. package/src/kernel/registry.ts +102 -3
  106. package/src/manifest/types.ts +2 -0
  107. package/src/plugins/anonymous.ts +58 -0
  108. package/src/plugins/auth/shared.ts +121 -0
  109. package/src/plugins/auth-methods.security.test.ts +762 -0
  110. package/src/plugins/auth-methods.test.ts +176 -0
  111. package/src/plugins/compression.test.ts +5 -5
  112. package/src/plugins/compression.ts +1 -1
  113. package/src/plugins/config-source.test.ts +12 -12
  114. package/src/plugins/config-source.ts +2 -2
  115. package/src/plugins/cors.test.ts +16 -10
  116. package/src/plugins/cors.ts +1 -1
  117. package/src/plugins/csrf.test.ts +1 -1
  118. package/src/plugins/email-otp.ts +161 -0
  119. package/src/plugins/{security-headers.test.ts → headers.test.ts} +26 -26
  120. package/src/plugins/headers.ts +240 -41
  121. package/src/plugins/index.ts +42 -3
  122. package/src/plugins/ip-allowlist.test.ts +19 -9
  123. package/src/plugins/magic-link.ts +163 -0
  124. package/src/plugins/maintenance-mode.test.ts +9 -5
  125. package/src/plugins/passkey-webauthn.ts +217 -0
  126. package/src/plugins/passkey.ts +282 -0
  127. package/src/plugins/phone-number.ts +149 -0
  128. package/src/plugins/response-headers.ts +54 -0
  129. package/src/plugins/two-factor.ts +253 -0
  130. package/src/plugins/username.ts +148 -0
  131. package/src/runs/runs.test.ts +6 -2
  132. package/src/runtime/primitives.ts +37 -4
  133. package/src/runtime/serve.test.ts +3 -2
  134. package/src/runtime/types.ts +25 -2
  135. package/src/test/create-test-app.test.ts +1 -1
  136. package/src/test/create-test-app.ts +4 -1
  137. package/src/test/provisions.integration.test.ts +1 -1
  138. package/src/plugins/security-headers.ts +0 -255
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `security-headers` plugin — defaults on success, presence on failure,
2
+ * `headers` plugin — defaults on success, presence on failure,
3
3
  * and app-wins override semantics through the real pipeline.
4
4
  */
5
5
 
@@ -9,17 +9,17 @@ import { flow, resetFlowSeq } from "../kernel/flow.ts";
9
9
  import { fail } from "../kernel/index.ts";
10
10
  import { on, resetBindings } from "../kernel/on.ts";
11
11
  import { http } from "../kernel/triggers.ts";
12
- import { defaultCspDirectives, securityHeaders } from "./security-headers.ts";
12
+ import { defaultCspDirectives, headers } from "./headers.ts";
13
13
 
14
14
  beforeEach(() => {
15
15
  resetBindings();
16
16
  resetFlowSeq();
17
17
  });
18
18
 
19
- describe("securityHeaders plugin", () => {
19
+ describe("headers plugin", () => {
20
20
  test("sets the default trio on a successful response", async () => {
21
21
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
22
- const app = oke({ name: "sec" }).plug(securityHeaders());
22
+ const app = oke({ autoBoot: false, name: "sec" }).plug(headers());
23
23
 
24
24
  const res = await app.fetch(new Request("http://localhost/x"));
25
25
 
@@ -32,7 +32,7 @@ describe("securityHeaders plugin", () => {
32
32
 
33
33
  test("helmet-parity defaults: agent cluster, dns prefetch, download, cross-domain, xss filter", async () => {
34
34
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
35
- const app = oke({ name: "sec-parity" }).plug(securityHeaders());
35
+ const app = oke({ autoBoot: false, name: "sec-parity" }).plug(headers());
36
36
 
37
37
  const res = await app.fetch(new Request("http://localhost/x"));
38
38
 
@@ -45,8 +45,8 @@ describe("securityHeaders plugin", () => {
45
45
 
46
46
  test("each parity default can be switched off", async () => {
47
47
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
48
- const app = oke({ name: "sec-off" }).plug(
49
- securityHeaders({
48
+ const app = oke({ autoBoot: false, name: "sec-off" }).plug(
49
+ headers({
50
50
  originAgentCluster: false,
51
51
  dnsPrefetchControl: false,
52
52
  downloadOptions: false,
@@ -65,8 +65,8 @@ describe("securityHeaders plugin", () => {
65
65
 
66
66
  test("dnsPrefetchControl allow, custom cross-domain policy, COEP stamps when configured", async () => {
67
67
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
68
- const app = oke({ name: "sec-tuned" }).plug(
69
- securityHeaders({
68
+ const app = oke({ autoBoot: false, name: "sec-tuned" }).plug(
69
+ headers({
70
70
  dnsPrefetchControl: { allow: true },
71
71
  permittedCrossDomainPolicies: "by-content-type",
72
72
  crossOriginEmbedderPolicy: "require-corp",
@@ -81,7 +81,7 @@ describe("securityHeaders plugin", () => {
81
81
  });
82
82
 
83
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) => {
84
+ const poweredByApp = (options: Parameters<typeof headers>[0], name: string) => {
85
85
  on(
86
86
  http.get("/x"),
87
87
  flow({
@@ -92,7 +92,7 @@ describe("securityHeaders plugin", () => {
92
92
  }),
93
93
  }),
94
94
  );
95
- return oke({ name }).plug(securityHeaders(options));
95
+ return oke({ autoBoot: false, name }).plug(headers(options));
96
96
  };
97
97
 
98
98
  const removed = await poweredByApp({}, "sec-pb-off").fetch(new Request("http://localhost/x"));
@@ -112,8 +112,8 @@ describe("securityHeaders plugin", () => {
112
112
 
113
113
  test("structured CSP merges over helmet's defaults, camelCase keys, report-only mode", async () => {
114
114
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
115
- const app = oke({ name: "sec-csp-builder" }).plug(
116
- securityHeaders({
115
+ const app = oke({ autoBoot: false, name: "sec-csp-builder" }).plug(
116
+ headers({
117
117
  contentSecurityPolicy: {
118
118
  directives: { scriptSrc: ["'self'", "https://cdn.example.com"] },
119
119
  reportOnly: true,
@@ -132,8 +132,8 @@ describe("securityHeaders plugin", () => {
132
132
 
133
133
  test("structured CSP with useDefaults: false emits exactly the given directives", async () => {
134
134
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
135
- const app = oke({ name: "sec-csp-bare" }).plug(
136
- securityHeaders({
135
+ const app = oke({ autoBoot: false, name: "sec-csp-bare" }).plug(
136
+ headers({
137
137
  contentSecurityPolicy: { useDefaults: false, directives: { "default-src": ["'none'"] } },
138
138
  }),
139
139
  );
@@ -155,7 +155,7 @@ describe("securityHeaders plugin", () => {
155
155
  fail("Forbidden", {}),
156
156
  ),
157
157
  );
158
- const app = oke({ name: "sec-fail" }).plug(securityHeaders());
158
+ const app = oke({ autoBoot: false, name: "sec-fail" }).plug(headers());
159
159
 
160
160
  const res = await app.fetch(new Request("http://localhost/deny"));
161
161
 
@@ -165,8 +165,8 @@ describe("securityHeaders plugin", () => {
165
165
 
166
166
  test("adds CSP only when configured", async () => {
167
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'" }),
168
+ const app = oke({ autoBoot: false, name: "sec-csp" }).plug(
169
+ headers({ contentSecurityPolicy: "default-src 'self'" }),
170
170
  );
171
171
 
172
172
  const res = await app.fetch(new Request("http://localhost/x"));
@@ -176,7 +176,7 @@ describe("securityHeaders plugin", () => {
176
176
 
177
177
  test("HSTS is off by default", async () => {
178
178
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
179
- const app = oke({ name: "sec-hsts-off" }).plug(securityHeaders());
179
+ const app = oke({ autoBoot: false, name: "sec-hsts-off" }).plug(headers());
180
180
 
181
181
  const res = await app.fetch(new Request("http://localhost/x"));
182
182
 
@@ -185,7 +185,7 @@ describe("securityHeaders plugin", () => {
185
185
 
186
186
  test("hsts: true stamps a one-year max-age", async () => {
187
187
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
188
- const app = oke({ name: "sec-hsts-on" }).plug(securityHeaders({ hsts: true }));
188
+ const app = oke({ autoBoot: false, name: "sec-hsts-on" }).plug(headers({ hsts: true }));
189
189
 
190
190
  const res = await app.fetch(new Request("http://localhost/x"));
191
191
 
@@ -194,8 +194,8 @@ describe("securityHeaders plugin", () => {
194
194
 
195
195
  test("hsts object tunes max-age, subdomains, preload", async () => {
196
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 } }),
197
+ const app = oke({ autoBoot: false, name: "sec-hsts-tuned" }).plug(
198
+ headers({ hsts: { maxAge: 63072000, includeSubDomains: true, preload: true } }),
199
199
  );
200
200
 
201
201
  const res = await app.fetch(new Request("http://localhost/x"));
@@ -207,8 +207,8 @@ describe("securityHeaders plugin", () => {
207
207
 
208
208
  test("permissions-policy and cross-origin policies stamp when configured", async () => {
209
209
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
210
- const app = oke({ name: "sec-extra" }).plug(
211
- securityHeaders({
210
+ const app = oke({ autoBoot: false, name: "sec-extra" }).plug(
211
+ headers({
212
212
  permissionsPolicy: "camera=(), microphone=()",
213
213
  crossOriginOpenerPolicy: "same-origin",
214
214
  crossOriginResourcePolicy: "same-site",
@@ -224,7 +224,7 @@ describe("securityHeaders plugin", () => {
224
224
 
225
225
  test("an explicit app-set value wins by default", async () => {
226
226
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
227
- const app = oke({ name: "sec-keep" });
227
+ const app = oke({ autoBoot: false, name: "sec-keep" });
228
228
  app.hook("onResponse", (ctx) => {
229
229
  if (!ctx.response) return;
230
230
  const headers = new Headers(ctx.response.headers);
@@ -234,7 +234,7 @@ describe("securityHeaders plugin", () => {
234
234
  headers,
235
235
  });
236
236
  });
237
- app.plug(securityHeaders());
237
+ app.plug(headers());
238
238
 
239
239
  const res = await app.fetch(new Request("http://localhost/x"));
240
240
 
@@ -1,54 +1,253 @@
1
1
  /**
2
- * Shared response-header mutation for HTTP middleware plugins.
3
- * `Headers` are immutable-in-place on a built `Response` rebuild instead.
2
+ * Official `headers` plugin — the complete secure-headers set on every HTTP
3
+ * response (helmet parity, API-first defaults). Uses only the public plugin
4
+ * API (unified-theory §14).
4
5
  */
5
6
 
6
- /**
7
- * Rebuild a response with headers mutated by `fn`.
8
- *
9
- * @param response - Original response
10
- * @param fn - Header mutation
11
- */
12
- export function withHeaders(response: Response, fn: (headers: Headers) => void): Response {
13
- const headers = new Headers(response.headers);
14
- fn(headers);
15
- return new Response(response.body, {
16
- status: response.status,
17
- statusText: response.statusText,
18
- headers,
19
- });
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
+ import { setUnlessPresent, withHeaders } from "./response-headers.ts";
16
+
17
+ /** HSTS value options (see {@link HeadersOptions.hsts}). */
18
+ export interface HstsOptions {
19
+ /** `max-age` in seconds. Default `31536000` (one year). */
20
+ readonly maxAge?: number;
21
+ /** Append `includeSubDomains`. Default `false`. */
22
+ readonly includeSubDomains?: boolean;
23
+ /** Append `preload`. Only meaningful with subdomains + a ≥1-year max-age. */
24
+ readonly preload?: boolean;
25
+ }
26
+
27
+ /** Structured CSP (see {@link HeadersOptions.contentSecurityPolicy}). */
28
+ export interface CspOptions {
29
+ /**
30
+ * Directive map — kebab-case (`script-src`) or camelCase (`scriptSrc`)
31
+ * keys, values as string arrays. An empty array emits a bare directive
32
+ * (e.g. `upgrade-insecure-requests`).
33
+ */
34
+ readonly directives: Readonly<Record<string, readonly string[]>>;
35
+ /** Merge over {@link defaultCspDirectives}. Default `true`. */
36
+ readonly useDefaults?: boolean;
37
+ /** Emit as `Content-Security-Policy-Report-Only`. Default `false`. */
38
+ readonly reportOnly?: boolean;
20
39
  }
21
40
 
22
41
  /**
23
- * Set a header only when absent (or when `override` is on).
24
- *
25
- * @param headers - Mutable headers
26
- * @param name - Header name
27
- * @param value - Header value
28
- * @param override - Replace an app-set value
42
+ * Helmet's default CSP the baseline `directives` merge over unless
43
+ * `useDefaults: false`.
29
44
  */
30
- export function setUnlessPresent(
31
- headers: Headers,
32
- name: string,
33
- value: string,
34
- override: boolean,
35
- ): void {
36
- if (!override && headers.has(name)) return;
37
- headers.set(name, value);
45
+ export const defaultCspDirectives: Readonly<Record<string, readonly string[]>> = {
46
+ "default-src": ["'self'"],
47
+ "base-uri": ["'self'"],
48
+ "font-src": ["'self'", "https:", "data:"],
49
+ "form-action": ["'self'"],
50
+ "frame-ancestors": ["'self'"],
51
+ "img-src": ["'self'", "data:"],
52
+ "object-src": ["'none'"],
53
+ "script-src": ["'self'"],
54
+ "script-src-attr": ["'none'"],
55
+ "style-src": ["'self'", "https:", "'unsafe-inline'"],
56
+ "upgrade-insecure-requests": [],
57
+ };
58
+
59
+ /** Options for {@link headers}. */
60
+ export interface HeadersOptions {
61
+ /**
62
+ * Content-Security-Policy — a raw header string, or a structured
63
+ * {@link CspOptions} (directive builder over helmet's defaults, optional
64
+ * report-only mode). Omitted unless provided.
65
+ */
66
+ readonly contentSecurityPolicy?: string | CspOptions;
67
+ /** X-Frame-Options value. Default `"DENY"`. */
68
+ readonly frameOptions?: "DENY" | "SAMEORIGIN";
69
+ /** Referrer-Policy value. Default `"no-referrer"`. */
70
+ readonly referrerPolicy?: string;
71
+ /**
72
+ * Strict-Transport-Security. `true` → one-year `max-age`; pass an object
73
+ * to tune. Default off — HSTS is sticky, and local dev is plain HTTP, so
74
+ * enable it deliberately for HTTPS deployments.
75
+ */
76
+ readonly hsts?: boolean | HstsOptions;
77
+ /** Permissions-Policy value (e.g. `"camera=(), microphone=()"`). Omitted unless provided. */
78
+ readonly permissionsPolicy?: string;
79
+ /**
80
+ * Cross-Origin-Opener-Policy value. Omitted unless provided — opt-in
81
+ * because OKE serves APIs, where cross-origin clients are legitimate
82
+ * (helmet targets web pages and defaults it on).
83
+ */
84
+ readonly crossOriginOpenerPolicy?: "same-origin" | "same-origin-allow-popups" | "unsafe-none";
85
+ /** Cross-Origin-Resource-Policy value. Omitted unless provided (same API rationale). */
86
+ readonly crossOriginResourcePolicy?: "same-origin" | "same-site" | "cross-origin";
87
+ /** Cross-Origin-Embedder-Policy value. Omitted unless provided (helmet also defaults it off). */
88
+ readonly crossOriginEmbedderPolicy?: "require-corp" | "credentialless";
89
+ /** Origin-Agent-Cluster header. Default `true` → `?1`. */
90
+ readonly originAgentCluster?: boolean;
91
+ /**
92
+ * X-DNS-Prefetch-Control. Default `true` → `off` (privacy-preserving);
93
+ * `{ allow: true }` → `on`; `false` omits the header.
94
+ */
95
+ readonly dnsPrefetchControl?: boolean | { readonly allow: boolean };
96
+ /** X-Download-Options: noopen (legacy IE8 mitigation, helmet parity). Default `true`. */
97
+ readonly downloadOptions?: boolean;
98
+ /** X-Permitted-Cross-Domain-Policies value. Default `"none"`. */
99
+ readonly permittedCrossDomainPolicies?: "none" | "master-only" | "by-content-type" | "all";
100
+ /**
101
+ * X-Powered-By handling. Default `true` → remove the header (it leaks
102
+ * framework fingerprints). A string sets a custom value; `false` keeps it.
103
+ */
104
+ readonly poweredBy?: boolean | string;
105
+ /**
106
+ * X-XSS-Protection: 0 — disables the legacy, buggy browser XSS auditor
107
+ * (helmet parity; modern defense is CSP). Default `true`.
108
+ */
109
+ readonly xssProtection?: boolean;
110
+ /**
111
+ * Replace headers the app already set. Default `false` — an explicit
112
+ * app-level value always wins.
113
+ */
114
+ readonly override?: boolean;
115
+ }
116
+
117
+ /** Render the Strict-Transport-Security header value. */
118
+ function hstsValue(hsts: boolean | HstsOptions): string {
119
+ const opts: HstsOptions = typeof hsts === "object" ? hsts : {};
120
+ let value = `max-age=${opts.maxAge ?? 31536000}`;
121
+ if (opts.includeSubDomains) value += "; includeSubDomains";
122
+ if (opts.preload) value += "; preload";
123
+ return value;
124
+ }
125
+
126
+ /** camelCase directive keys → kebab-case (`scriptSrc` → `script-src`). */
127
+ function directiveName(name: string): string {
128
+ return name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
129
+ }
130
+
131
+ /** Build a CSP header value from a directive map. */
132
+ function buildCsp(directives: Readonly<Record<string, readonly string[]>>): string {
133
+ return Object.entries(directives)
134
+ .map(([name, values]) => {
135
+ const kebab = directiveName(name);
136
+ return values.length === 0 ? kebab : `${kebab} ${values.join(" ")}`;
137
+ })
138
+ .join("; ");
139
+ }
140
+
141
+ /** Resolve the CSP option into header name + value. */
142
+ function cspEntry(option: string | CspOptions): { readonly name: string; readonly value: string } {
143
+ if (typeof option === "string") return { name: "content-security-policy", value: option };
144
+ const useDefaults = option.useDefaults ?? true;
145
+ const merged: Record<string, readonly string[]> = useDefaults
146
+ ? { ...defaultCspDirectives, ...option.directives }
147
+ : { ...option.directives };
148
+ return {
149
+ name:
150
+ (option.reportOnly ?? false)
151
+ ? "content-security-policy-report-only"
152
+ : "content-security-policy",
153
+ value: buildCsp(merged),
154
+ };
38
155
  }
39
156
 
40
157
  /**
41
- * Append a token to `Vary` without duplicating it.
158
+ * Apply the complete secure-headers set to every HTTP response, including
159
+ * failures (runs at `onResponse`, which fires after `onError` too). Covers
160
+ * every helmet.js middleware; API-first deviations (opt-in HSTS, COOP,
161
+ * CORP) are documented per option.
162
+ *
163
+ * Accepts static options or a {@link ConfigSource} for DB-driven config
164
+ * (e.g. flip `hsts` from the database without a redeploy).
42
165
  *
43
- * @param headers - Mutable headers
44
- * @param token - Vary token (e.g. `"origin"`)
166
+ * @param options - Header values, or a config source
45
167
  */
46
- export function appendVary(headers: Headers, token: string): void {
47
- const vary = headers.get("vary");
48
- if (vary === null) {
49
- headers.set("vary", token);
50
- return;
51
- }
52
- const pattern = new RegExp(`\\b${token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i");
53
- if (!pattern.test(vary)) headers.set("vary", `${vary}, ${token}`);
168
+ export function headers(options: HeadersOptions | ConfigSource<HeadersOptions> = {}): PluginDef {
169
+ const def = plugin("headers", {
170
+ version: "0.1.0",
171
+ config: pluginConfigSnapshot(options),
172
+ }).hook("onResponse", (ctx) => {
173
+ if (!ctx.response) return;
174
+ const resolved = resolvePluginOptions(options);
175
+ const override = resolved.override ?? false;
176
+
177
+ ctx.response = withHeaders(ctx.response, (headers) => {
178
+ setUnlessPresent(headers, "x-content-type-options", "nosniff", override);
179
+ setUnlessPresent(headers, "x-frame-options", resolved.frameOptions ?? "DENY", override);
180
+ setUnlessPresent(
181
+ headers,
182
+ "referrer-policy",
183
+ resolved.referrerPolicy ?? "no-referrer",
184
+ override,
185
+ );
186
+
187
+ if (resolved.originAgentCluster ?? true) {
188
+ setUnlessPresent(headers, "origin-agent-cluster", "?1", override);
189
+ }
190
+ const dns = resolved.dnsPrefetchControl ?? true;
191
+ if (dns !== false) {
192
+ const allow = typeof dns === "object" ? dns.allow : false;
193
+ setUnlessPresent(headers, "x-dns-prefetch-control", allow ? "on" : "off", override);
194
+ }
195
+ if (resolved.downloadOptions ?? true) {
196
+ setUnlessPresent(headers, "x-download-options", "noopen", override);
197
+ }
198
+ setUnlessPresent(
199
+ headers,
200
+ "x-permitted-cross-domain-policies",
201
+ resolved.permittedCrossDomainPolicies ?? "none",
202
+ override,
203
+ );
204
+ if (resolved.xssProtection ?? true) {
205
+ setUnlessPresent(headers, "x-xss-protection", "0", override);
206
+ }
207
+
208
+ const poweredBy = resolved.poweredBy ?? true;
209
+ if (poweredBy === true) {
210
+ headers.delete("x-powered-by");
211
+ } else if (typeof poweredBy === "string") {
212
+ setUnlessPresent(headers, "x-powered-by", poweredBy, override);
213
+ }
214
+
215
+ if (resolved.contentSecurityPolicy !== undefined) {
216
+ const csp = cspEntry(resolved.contentSecurityPolicy);
217
+ setUnlessPresent(headers, csp.name, csp.value, override);
218
+ }
219
+ if (resolved.hsts !== undefined && resolved.hsts !== false) {
220
+ setUnlessPresent(headers, "strict-transport-security", hstsValue(resolved.hsts), override);
221
+ }
222
+ if (resolved.permissionsPolicy !== undefined) {
223
+ setUnlessPresent(headers, "permissions-policy", resolved.permissionsPolicy, override);
224
+ }
225
+ if (resolved.crossOriginOpenerPolicy !== undefined) {
226
+ setUnlessPresent(
227
+ headers,
228
+ "cross-origin-opener-policy",
229
+ resolved.crossOriginOpenerPolicy,
230
+ override,
231
+ );
232
+ }
233
+ if (resolved.crossOriginResourcePolicy !== undefined) {
234
+ setUnlessPresent(
235
+ headers,
236
+ "cross-origin-resource-policy",
237
+ resolved.crossOriginResourcePolicy,
238
+ override,
239
+ );
240
+ }
241
+ if (resolved.crossOriginEmbedderPolicy !== undefined) {
242
+ setUnlessPresent(
243
+ headers,
244
+ "cross-origin-embedder-policy",
245
+ resolved.crossOriginEmbedderPolicy,
246
+ override,
247
+ );
248
+ }
249
+ });
250
+ });
251
+
252
+ return isConfigSource(options) ? withConfigTable(def, options) : def;
54
253
  }
@@ -4,6 +4,7 @@
4
4
  * `.plug()`.
5
5
  */
6
6
 
7
+ export { anonymous, type AnonymousPluginOptions } from "./anonymous.ts";
7
8
  export { compression, type CompressionOptions } from "./compression.ts";
8
9
  export {
9
10
  configSource,
@@ -15,12 +16,50 @@ export {
15
16
  } from "./config-source.ts";
16
17
  export { cors, type CorsOptions } from "./cors.ts";
17
18
  export { csrf, type CsrfOptions } from "./csrf.ts";
19
+ export { emailOtp, type EmailOtpOptions } from "./email-otp.ts";
18
20
  export { ipAllowlist, type IpAllowlistOptions } from "./ip-allowlist.ts";
21
+ export { magicLink, type MagicLinkOptions } from "./magic-link.ts";
19
22
  export { maintenanceMode, type MaintenanceModeOptions } from "./maintenance-mode.ts";
23
+ export {
24
+ createPasskeyStore,
25
+ passkey,
26
+ type PasskeyCredential,
27
+ type PasskeyOptions,
28
+ type PasskeyStore,
29
+ } from "./passkey.ts";
30
+ export {
31
+ b64urlDecode,
32
+ b64urlEncode,
33
+ buildAuthenticatorData,
34
+ signWebAuthnAssertion,
35
+ verifyWebAuthnCeremony,
36
+ type WebAuthnVerifyOptions,
37
+ } from "./passkey-webauthn.ts";
38
+ export {
39
+ createPhoneStore,
40
+ phoneNumber,
41
+ type PhoneNumberOptions,
42
+ type PhoneStore,
43
+ } from "./phone-number.ts";
20
44
  export {
21
45
  defaultCspDirectives,
22
- securityHeaders,
46
+ headers,
23
47
  type CspOptions,
48
+ type HeadersOptions,
24
49
  type HstsOptions,
25
- type SecurityHeadersOptions,
26
- } from "./security-headers.ts";
50
+ } from "./headers.ts";
51
+ export {
52
+ createTwoFactorStore,
53
+ twoFactor,
54
+ verifyTotp,
55
+ type TwoFactorOptions,
56
+ type TwoFactorRow,
57
+ type TwoFactorStore,
58
+ } from "./two-factor.ts";
59
+ export {
60
+ createUsernameStore,
61
+ username,
62
+ type UsernamePluginOptions,
63
+ type UsernameRow,
64
+ type UsernameStore,
65
+ } from "./username.ts";
@@ -26,7 +26,7 @@ function get(ip?: string): Request {
26
26
  describe("ipAllowlist plugin", () => {
27
27
  test("allow list: listed IPs pass, others get 403 Forbidden", async () => {
28
28
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
29
- const app = oke({ name: "ips" }).plug(ipAllowlist({ allow: ["203.0.113.7"] }));
29
+ const app = oke({ autoBoot: false, name: "ips" }).plug(ipAllowlist({ allow: ["203.0.113.7"] }));
30
30
 
31
31
  const ok = await app.fetch(get("203.0.113.7"));
32
32
  expect(ok.status).toBe(200);
@@ -40,7 +40,9 @@ describe("ipAllowlist plugin", () => {
40
40
 
41
41
  test("deny list: blocked IPs get 403 ip_denied, others pass", async () => {
42
42
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
43
- const app = oke({ name: "ips-deny" }).plug(ipAllowlist({ deny: ["198.51.100.9"] }));
43
+ const app = oke({ autoBoot: false, name: "ips-deny" }).plug(
44
+ ipAllowlist({ deny: ["198.51.100.9"] }),
45
+ );
44
46
 
45
47
  const blocked = await app.fetch(get("198.51.100.9"));
46
48
  expect(blocked.status).toBe(403);
@@ -53,7 +55,7 @@ describe("ipAllowlist plugin", () => {
53
55
 
54
56
  test("deny wins over allow on overlap", async () => {
55
57
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
56
- const app = oke({ name: "ips-both" }).plug(
58
+ const app = oke({ autoBoot: false, name: "ips-both" }).plug(
57
59
  ipAllowlist({ allow: ["203.0.113.7"], deny: ["203.0.113.7"] }),
58
60
  );
59
61
 
@@ -63,7 +65,9 @@ describe("ipAllowlist plugin", () => {
63
65
 
64
66
  test("XFF last hop is the client; a spoofed first hop cannot bypass allow", async () => {
65
67
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
66
- const app = oke({ name: "ips-xff" }).plug(ipAllowlist({ allow: ["203.0.113.7"] }));
68
+ const app = oke({ autoBoot: false, name: "ips-xff" }).plug(
69
+ ipAllowlist({ allow: ["203.0.113.7"] }),
70
+ );
67
71
 
68
72
  // Attacker-controlled first hop + trusted proxy appended the real client (last).
69
73
  const spoofedFirst = await app.fetch(get("203.0.113.7, 198.51.100.9"));
@@ -75,7 +79,9 @@ describe("ipAllowlist plugin", () => {
75
79
 
76
80
  test("spoofed first hop cannot bypass a deny rule", async () => {
77
81
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
78
- const app = oke({ name: "ips-xff-deny" }).plug(ipAllowlist({ deny: ["198.51.100.9"] }));
82
+ const app = oke({ autoBoot: false, name: "ips-xff-deny" }).plug(
83
+ ipAllowlist({ deny: ["198.51.100.9"] }),
84
+ );
79
85
 
80
86
  // Spoof a non-denied IP first; proxy appended the real (denied) client last.
81
87
  const spoofed = await app.fetch(get("203.0.113.7, 198.51.100.9"));
@@ -88,7 +94,7 @@ describe("ipAllowlist plugin", () => {
88
94
  test("trustedProxyDepth selects the hop behind N trusted proxies", async () => {
89
95
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
90
96
  // Chain: spoofed, real-client, cdn-egress — depth 2 skips the nearest proxy hop.
91
- const app = oke({ name: "ips-depth" }).plug(
97
+ const app = oke({ autoBoot: false, name: "ips-depth" }).plug(
92
98
  ipAllowlist({ allow: ["203.0.113.7"], trustedProxyDepth: 2 }),
93
99
  );
94
100
 
@@ -108,7 +114,9 @@ describe("ipAllowlist plugin", () => {
108
114
 
109
115
  test("missing header: denied when allow is set, permitted for deny-only", async () => {
110
116
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
111
- const strict = oke({ name: "ips-missing-allow" }).plug(ipAllowlist({ allow: ["203.0.113.7"] }));
117
+ const strict = oke({ autoBoot: false, name: "ips-missing-allow" }).plug(
118
+ ipAllowlist({ allow: ["203.0.113.7"] }),
119
+ );
112
120
 
113
121
  const denied = await strict.fetch(get());
114
122
  expect(denied.status).toBe(403);
@@ -117,14 +125,16 @@ describe("ipAllowlist plugin", () => {
117
125
  resetFlowSeq();
118
126
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
119
127
 
120
- const lax = oke({ name: "ips-missing-deny" }).plug(ipAllowlist({ deny: ["198.51.100.9"] }));
128
+ const lax = oke({ autoBoot: false, name: "ips-missing-deny" }).plug(
129
+ ipAllowlist({ deny: ["198.51.100.9"] }),
130
+ );
121
131
  const ok = await lax.fetch(get());
122
132
  expect(ok.status).toBe(200);
123
133
  });
124
134
 
125
135
  test("custom header name is honored", async () => {
126
136
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
127
- const app = oke({ name: "ips-custom" }).plug(
137
+ const app = oke({ autoBoot: false, name: "ips-custom" }).plug(
128
138
  ipAllowlist({ allow: ["203.0.113.7"], header: "x-real-ip" }),
129
139
  );
130
140