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
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Phase 2 Gate auth method plugins — username, anonymous, magic-link, email OTP.
3
+ */
4
+
5
+ import { afterEach, describe, expect, test } from "bun:test";
6
+ import { oke } from "../kernel/app.ts";
7
+ import { resetFlowSeq } from "../kernel/flow.ts";
8
+ import { resetBindings } from "../kernel/on.ts";
9
+ import { anonymous } from "./anonymous.ts";
10
+ import { emailOtp } from "./email-otp.ts";
11
+ import { magicLink } from "./magic-link.ts";
12
+ import { username } from "./username.ts";
13
+
14
+ afterEach(() => {
15
+ resetBindings();
16
+ resetFlowSeq();
17
+ });
18
+
19
+ function authApp() {
20
+ return oke({
21
+ name: "auth-plugins",
22
+ env: "test",
23
+ registry: "ignore",
24
+ gate: {
25
+ auth: {
26
+ secret: "test-secret-at-least-16",
27
+ emailAndPassword: { enabled: true },
28
+ },
29
+ },
30
+ })
31
+ .plug(username())
32
+ .plug(anonymous())
33
+ .plug(magicLink({ exposeDevToken: true }))
34
+ .plug(emailOtp({ exposeDevOtp: true }));
35
+ }
36
+
37
+ describe("auth method plugins", () => {
38
+ test("bindings appear on router after .plug()", async () => {
39
+ const app = authApp();
40
+ expect(app.router.match("POST", "/auth/sign-up/username")).toBeTruthy();
41
+ expect(app.router.match("POST", "/auth/sign-in/username")).toBeTruthy();
42
+ expect(app.router.match("POST", "/auth/sign-in/anonymous")).toBeTruthy();
43
+ expect(app.router.match("POST", "/auth/magic-link/request")).toBeTruthy();
44
+ expect(app.router.match("POST", "/auth/magic-link/verify")).toBeTruthy();
45
+ expect(app.router.match("POST", "/auth/email-otp/request")).toBeTruthy();
46
+ expect(app.router.match("POST", "/auth/email-otp/verify")).toBeTruthy();
47
+ await app.boot({ env: "test" });
48
+ await app.stop();
49
+ });
50
+
51
+ test("username sign-up + sign-in", async () => {
52
+ const app = authApp();
53
+ await app.boot({ env: "test" });
54
+
55
+ const signUp = await app.fetch(
56
+ new Request("http://localhost/auth/sign-up/username", {
57
+ method: "POST",
58
+ headers: { "content-type": "application/json" },
59
+ body: JSON.stringify({ username: "alice", password: "CorrectHorse1" }),
60
+ }),
61
+ );
62
+ expect(signUp.status).toBe(200);
63
+ const created = (await signUp.json()) as {
64
+ data: { accessToken: string; userId: string };
65
+ };
66
+ expect(created.data.accessToken).toBeTruthy();
67
+ expect(created.data.userId).toBeTruthy();
68
+
69
+ const bad = await app.fetch(
70
+ new Request("http://localhost/auth/sign-in/username", {
71
+ method: "POST",
72
+ headers: { "content-type": "application/json" },
73
+ body: JSON.stringify({ username: "alice", password: "wrong-password" }),
74
+ }),
75
+ );
76
+ const badBody = (await bad.json()) as { error: { data?: { reason?: string } } };
77
+ expect(badBody.error.data?.reason).toBe("invalid_credentials");
78
+
79
+ const signIn = await app.fetch(
80
+ new Request("http://localhost/auth/sign-in/username", {
81
+ method: "POST",
82
+ headers: { "content-type": "application/json" },
83
+ body: JSON.stringify({ username: "alice", password: "CorrectHorse1" }),
84
+ }),
85
+ );
86
+ expect(signIn.status).toBe(200);
87
+ const session = (await signIn.json()) as { data: { accessToken: string } };
88
+ expect(session.data.accessToken).toBeTruthy();
89
+
90
+ await app.stop();
91
+ });
92
+
93
+ test("anonymous sign-in issues session", async () => {
94
+ const app = authApp();
95
+ await app.boot({ env: "test" });
96
+
97
+ const res = await app.fetch(
98
+ new Request("http://localhost/auth/sign-in/anonymous", {
99
+ method: "POST",
100
+ headers: { "content-type": "application/json" },
101
+ body: "{}",
102
+ }),
103
+ );
104
+ expect(res.status).toBe(200);
105
+ const body = (await res.json()) as {
106
+ data: { accessToken: string; userId: string };
107
+ };
108
+ expect(body.data.accessToken).toBeTruthy();
109
+ expect(body.data.userId).toBeTruthy();
110
+
111
+ await app.stop();
112
+ });
113
+
114
+ test("magic-link request + verify with devToken", async () => {
115
+ const app = authApp();
116
+ await app.boot({ env: "test" });
117
+
118
+ const req = await app.fetch(
119
+ new Request("http://localhost/auth/magic-link/request", {
120
+ method: "POST",
121
+ headers: { "content-type": "application/json" },
122
+ body: JSON.stringify({ email: "ml@example.com" }),
123
+ }),
124
+ );
125
+ expect(req.status).toBe(200);
126
+ const requested = (await req.json()) as { data: { ok: true; devToken?: string } };
127
+ expect(requested.data.ok).toBe(true);
128
+ expect(requested.data.devToken).toBeTruthy();
129
+
130
+ const verify = await app.fetch(
131
+ new Request("http://localhost/auth/magic-link/verify", {
132
+ method: "POST",
133
+ headers: { "content-type": "application/json" },
134
+ body: JSON.stringify({ token: requested.data.devToken }),
135
+ }),
136
+ );
137
+ expect(verify.status).toBe(200);
138
+ const session = (await verify.json()) as { data: { accessToken: string; userId: string } };
139
+ expect(session.data.accessToken).toBeTruthy();
140
+ expect(session.data.userId).toBeTruthy();
141
+
142
+ await app.stop();
143
+ });
144
+
145
+ test("email OTP request + verify with devOtp", async () => {
146
+ const app = authApp();
147
+ await app.boot({ env: "test" });
148
+
149
+ const req = await app.fetch(
150
+ new Request("http://localhost/auth/email-otp/request", {
151
+ method: "POST",
152
+ headers: { "content-type": "application/json" },
153
+ body: JSON.stringify({ email: "otp@example.com" }),
154
+ }),
155
+ );
156
+ expect(req.status).toBe(200);
157
+ const requested = (await req.json()) as { data: { ok: true; devOtp?: string } };
158
+ expect(requested.data.devOtp).toMatch(/^\d{6}$/);
159
+
160
+ const verify = await app.fetch(
161
+ new Request("http://localhost/auth/email-otp/verify", {
162
+ method: "POST",
163
+ headers: { "content-type": "application/json" },
164
+ body: JSON.stringify({
165
+ email: "otp@example.com",
166
+ otp: requested.data.devOtp,
167
+ }),
168
+ }),
169
+ );
170
+ expect(verify.status).toBe(200);
171
+ const session = (await verify.json()) as { data: { accessToken: string } };
172
+ expect(session.data.accessToken).toBeTruthy();
173
+
174
+ await app.stop();
175
+ });
176
+ });
@@ -38,7 +38,7 @@ describe("acceptsGzip", () => {
38
38
  describe("compression plugin", () => {
39
39
  test("gzips a large JSON body when the client accepts gzip", async () => {
40
40
  on(http.get("/big"), flow({ name: "big.get", do: bigPayload }));
41
- const app = oke({ name: "zip" }).plug(compression());
41
+ const app = oke({ autoBoot: false, name: "zip" }).plug(compression());
42
42
 
43
43
  const res = await app.fetch(
44
44
  new Request("http://localhost/big", { headers: { "accept-encoding": "gzip" } }),
@@ -59,7 +59,7 @@ describe("compression plugin", () => {
59
59
 
60
60
  test("passes through untouched without Accept-Encoding: gzip", async () => {
61
61
  on(http.get("/big"), flow({ name: "big.get", do: bigPayload }));
62
- const app = oke({ name: "zip-plain" }).plug(compression());
62
+ const app = oke({ autoBoot: false, name: "zip-plain" }).plug(compression());
63
63
 
64
64
  const res = await app.fetch(new Request("http://localhost/big"));
65
65
 
@@ -70,7 +70,7 @@ describe("compression plugin", () => {
70
70
 
71
71
  test("skips bodies under minSize", async () => {
72
72
  on(http.get("/small"), flow({ name: "small.get", do: () => ({ ok: true }) }));
73
- const app = oke({ name: "zip-small" }).plug(compression());
73
+ const app = oke({ autoBoot: false, name: "zip-small" }).plug(compression());
74
74
 
75
75
  const res = await app.fetch(
76
76
  new Request("http://localhost/small", { headers: { "accept-encoding": "gzip" } }),
@@ -81,7 +81,7 @@ describe("compression plugin", () => {
81
81
 
82
82
  test("minSize: 0 compresses even tiny bodies", async () => {
83
83
  on(http.get("/small"), flow({ name: "small.get", do: () => ({ ok: true }) }));
84
- const app = oke({ name: "zip-zero" }).plug(compression({ minSize: 0 }));
84
+ const app = oke({ autoBoot: false, name: "zip-zero" }).plug(compression({ minSize: 0 }));
85
85
 
86
86
  const res = await app.fetch(
87
87
  new Request("http://localhost/small", { headers: { "accept-encoding": "gzip" } }),
@@ -112,7 +112,7 @@ describe("compression plugin", () => {
112
112
  ),
113
113
  }),
114
114
  );
115
- const app = oke({ name: "zip-skip" }).plug(compression());
115
+ const app = oke({ autoBoot: false, name: "zip-skip" }).plug(compression());
116
116
 
117
117
  const img = await app.fetch(
118
118
  new Request("http://localhost/img", { headers: { "accept-encoding": "gzip" } }),
@@ -11,7 +11,7 @@ import {
11
11
  withConfigTable,
12
12
  type ConfigSource,
13
13
  } from "./config-source.ts";
14
- import { appendVary } from "./headers.ts";
14
+ import { appendVary } from "./response-headers.ts";
15
15
 
16
16
  /** Options for {@link compression}. */
17
17
  export interface CompressionOptions {
@@ -19,7 +19,7 @@ import { createPluginRegistry } from "../kernel/registry.ts";
19
19
  import { http } from "../kernel/triggers.ts";
20
20
  import { configSource, resolvePluginOptions } from "./config-source.ts";
21
21
  import { maintenanceMode } from "./maintenance-mode.ts";
22
- import { securityHeaders } from "./security-headers.ts";
22
+ import { headers } from "./headers.ts";
23
23
 
24
24
  beforeEach(() => {
25
25
  resetBindings();
@@ -74,7 +74,7 @@ describe("configSource — box semantics", () => {
74
74
  };
75
75
  const fx = createFxContext({ flow: "x.config-sync", effects, storeRuntime: runtime }).fx;
76
76
 
77
- const table = defineTable("security_headers_config", { key: true, value: true });
77
+ const table = defineTable("headers_config", { key: true, value: true });
78
78
  const sql = fx.store(db) as SqlStoreHandle;
79
79
  await sql.ensureTable(table);
80
80
  await sql
@@ -83,7 +83,7 @@ describe("configSource — box semantics", () => {
83
83
  .execute();
84
84
 
85
85
  const source = configSource({
86
- plugin: "security-headers",
86
+ plugin: "headers",
87
87
  code: { hsts: false },
88
88
  db: { store: db },
89
89
  kv: cfg,
@@ -110,11 +110,11 @@ describe("configSource — box semantics", () => {
110
110
 
111
111
  const effects = { reads: ["sql:cfgdb" as const], writes: ["sql:cfgdb" as const] };
112
112
  const fx = createFxContext({ flow: "x.config-sync", effects, storeRuntime: runtime }).fx;
113
- const table = defineTable("security_headers_config", { key: true, value: true });
113
+ const table = defineTable("headers_config", { key: true, value: true });
114
114
  await (fx.store(db) as SqlStoreHandle).ensureTable(table);
115
115
 
116
116
  const source = configSource({
117
- plugin: "security-headers",
117
+ plugin: "headers",
118
118
  code: { frameOptions: "DENY" as const },
119
119
  db: { store: db },
120
120
  });
@@ -132,13 +132,13 @@ describe("configSource — box semantics", () => {
132
132
 
133
133
  const effects = { reads: ["sql:cfgdb" as const], writes: ["sql:cfgdb" as const] };
134
134
  const fx = createFxContext({ flow: "x.config-sync", effects, storeRuntime: runtime }).fx;
135
- const table = defineTable("security_headers_config", { key: true, value: true });
135
+ const table = defineTable("headers_config", { key: true, value: true });
136
136
  const sql = fx.store(db) as SqlStoreHandle;
137
137
  await sql.ensureTable(table);
138
138
  await sql.insert(table).values({ key: "config", value: "{not json" }).execute();
139
139
 
140
140
  const source = configSource({
141
- plugin: "security-headers",
141
+ plugin: "headers",
142
142
  code: {},
143
143
  db: { store: db },
144
144
  });
@@ -156,7 +156,7 @@ describe("configSource — plugin wiring", () => {
156
156
  });
157
157
 
158
158
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
159
- const app = oke({ name: "cfg-live" }).plug(maintenanceMode(source));
159
+ const app = oke({ autoBoot: false, name: "cfg-live" }).plug(maintenanceMode(source));
160
160
 
161
161
  expect((await app.fetch(new Request("http://localhost/x"))).status).toBe(200);
162
162
 
@@ -171,17 +171,17 @@ describe("configSource — plugin wiring", () => {
171
171
  test("DB-backed sources contribute their config table; static options do not", () => {
172
172
  const db = store.sql("cfgdb");
173
173
  const source = configSource({
174
- plugin: "security-headers",
174
+ plugin: "headers",
175
175
  code: {},
176
176
  db: { store: db },
177
177
  });
178
178
 
179
179
  const registry = createPluginRegistry();
180
- const registration = registry.plug(securityHeaders(source), { kind: "app" });
181
- expect(registration?.tables.map((t) => t.name)).toEqual(["security_headers_config"]);
180
+ const registration = registry.plug(headers(source), { kind: "app" });
181
+ expect(registration?.tables.map((t) => t.name)).toEqual(["headers_config"]);
182
182
 
183
183
  const staticRegistry = createPluginRegistry();
184
- const staticRegistration = staticRegistry.plug(securityHeaders({}), { kind: "app" });
184
+ const staticRegistration = staticRegistry.plug(headers({}), { kind: "app" });
185
185
  expect(staticRegistration?.tables).toEqual([]);
186
186
  });
187
187
 
@@ -35,8 +35,8 @@ export interface ConfigSourceDb {
35
35
  /** Options for {@link configSource}. */
36
36
  export interface ConfigSourceOptions<T extends object> {
37
37
  /**
38
- * Plugin id this config belongs to (e.g. `"security-headers"`) — derives
39
- * the DB row key, the KV key, and the default table name.
38
+ * Plugin id this config belongs to (e.g. `"headers"`) — derives the DB
39
+ * row key, the KV key, and the default table name.
40
40
  */
41
41
  readonly plugin: string;
42
42
  /**
@@ -29,7 +29,9 @@ function preflight(path = "/x", headers: Record<string, string> = {}): Request {
29
29
  describe("cors plugin — preflight", () => {
30
30
  test("answers preflight for a path bound to another method (edge)", async () => {
31
31
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
32
- const app = oke({ name: "cors-pre" }).plug(cors({ origin: "https://app.example.com" }));
32
+ const app = oke({ autoBoot: false, name: "cors-pre" }).plug(
33
+ cors({ origin: "https://app.example.com" }),
34
+ );
33
35
 
34
36
  const res = await app.fetch(preflight());
35
37
 
@@ -41,7 +43,9 @@ describe("cors plugin — preflight", () => {
41
43
 
42
44
  test("denied origins get a 204 without CORS headers — the browser blocks quietly", async () => {
43
45
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
44
- const app = oke({ name: "cors-deny" }).plug(cors({ origin: "https://other.example.com" }));
46
+ const app = oke({ autoBoot: false, name: "cors-deny" }).plug(
47
+ cors({ origin: "https://other.example.com" }),
48
+ );
45
49
 
46
50
  const res = await app.fetch(preflight());
47
51
 
@@ -51,7 +55,7 @@ describe("cors plugin — preflight", () => {
51
55
 
52
56
  test("no origin configured answers nothing — cross-origin closed by default", async () => {
53
57
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
54
- const app = oke({ name: "cors-closed" }).plug(cors());
58
+ const app = oke({ autoBoot: false, name: "cors-closed" }).plug(cors());
55
59
 
56
60
  const res = await app.fetch(preflight());
57
61
  expect(res.headers.get("access-control-allow-origin")).toBeNull();
@@ -59,7 +63,7 @@ describe("cors plugin — preflight", () => {
59
63
 
60
64
  test("a plain unmatched OPTIONS without preflight headers still 404s", async () => {
61
65
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
62
- const app = oke({ name: "cors-404" }).plug(cors({ origin: "*" }));
66
+ const app = oke({ autoBoot: false, name: "cors-404" }).plug(cors({ origin: "*" }));
63
67
 
64
68
  const res = await app.fetch(new Request("http://localhost/x", { method: "OPTIONS" }));
65
69
  expect(res.status).toBe(404);
@@ -67,7 +71,7 @@ describe("cors plugin — preflight", () => {
67
71
 
68
72
  test("reflects request headers by default, honors configured lists and maxAge", async () => {
69
73
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
70
- const app = oke({ name: "cors-hdr" }).plug(
74
+ const app = oke({ autoBoot: false, name: "cors-hdr" }).plug(
71
75
  cors({ origin: "*", maxAge: 600, allowedHeaders: ["x-custom"] }),
72
76
  );
73
77
 
@@ -88,7 +92,7 @@ describe("cors plugin — preflight", () => {
88
92
 
89
93
  test("allowlist + credentials reflects only listed origins", async () => {
90
94
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
91
- const app = oke({ name: "cors-cred" }).plug(
95
+ const app = oke({ autoBoot: false, name: "cors-cred" }).plug(
92
96
  cors({ origin: ["https://app.example.com", "https://admin.example.com"], credentials: true }),
93
97
  );
94
98
 
@@ -106,7 +110,7 @@ describe("cors plugin — preflight", () => {
106
110
  describe("cors plugin — actual requests", () => {
107
111
  test("allowed origins get allow-origin (+ exposed headers) on matched responses", async () => {
108
112
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
109
- const app = oke({ name: "cors-actual" }).plug(
113
+ const app = oke({ autoBoot: false, name: "cors-actual" }).plug(
110
114
  cors({ origin: ["https://app.example.com"], exposedHeaders: ["x-total"] }),
111
115
  );
112
116
 
@@ -121,7 +125,7 @@ describe("cors plugin — actual requests", () => {
121
125
 
122
126
  test("allowlist + credentials on matched responses reflects only listed origins", async () => {
123
127
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
124
- const app = oke({ name: "cors-actual-cred" }).plug(
128
+ const app = oke({ autoBoot: false, name: "cors-actual-cred" }).plug(
125
129
  cors({ origin: ["https://app.example.com"], credentials: true }),
126
130
  );
127
131
 
@@ -142,7 +146,9 @@ describe("cors plugin — actual requests", () => {
142
146
 
143
147
  test("denied origins get no CORS headers on matched responses", async () => {
144
148
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
145
- const app = oke({ name: "cors-actual-deny" }).plug(cors({ origin: "https://ok.example.com" }));
149
+ const app = oke({ autoBoot: false, name: "cors-actual-deny" }).plug(
150
+ cors({ origin: "https://ok.example.com" }),
151
+ );
146
152
 
147
153
  const res = await app.fetch(
148
154
  new Request("http://localhost/x", { headers: { origin: "https://evil.example.com" } }),
@@ -154,7 +160,7 @@ describe("cors plugin — actual requests", () => {
154
160
 
155
161
  test("requests without an Origin header are untouched", async () => {
156
162
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
157
- const app = oke({ name: "cors-no-origin" }).plug(cors({ origin: "*" }));
163
+ const app = oke({ autoBoot: false, name: "cors-no-origin" }).plug(cors({ origin: "*" }));
158
164
 
159
165
  const res = await app.fetch(new Request("http://localhost/x"));
160
166
  expect(res.headers.get("access-control-allow-origin")).toBeNull();
@@ -13,7 +13,7 @@ import {
13
13
  isConfigSource,
14
14
  type ConfigSource,
15
15
  } from "./config-source.ts";
16
- import { appendVary, withHeaders } from "./headers.ts";
16
+ import { appendVary, withHeaders } from "./response-headers.ts";
17
17
 
18
18
  /** Options for {@link cors}. */
19
19
  export interface CorsOptions {
@@ -22,7 +22,7 @@ function mutating(headers: Record<string, string> = {}): Request {
22
22
  function appWith(options: Parameters<typeof csrf>[0]) {
23
23
  on(http.post("/transfer"), flow({ name: "transfer.create", do: () => ({ moved: true }) }));
24
24
  on(http.get("/balance"), flow({ name: "balance.get", do: () => ({ balance: 5 }) }));
25
- return oke({ name: `csrf-${crypto.randomUUID()}` }).plug(csrf(options));
25
+ return oke({ autoBoot: false, name: `csrf-${crypto.randomUUID()}` }).plug(csrf(options));
26
26
  }
27
27
 
28
28
  describe("csrf plugin", () => {
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Email OTP Gate auth method plugin.
3
+ */
4
+
5
+ import {
6
+ createIdentityStore,
7
+ normalizeEmail,
8
+ type IdentityStore,
9
+ type UserIdentityRow,
10
+ } from "../auth/identity.ts";
11
+ import { issueSessionWithScopes } from "../auth/sessions.ts";
12
+ import {
13
+ createVerificationStore,
14
+ findActiveVerification,
15
+ generateOtp,
16
+ hashChallenge,
17
+ putVerification,
18
+ type VerificationStore,
19
+ } from "../auth/verification.ts";
20
+ import { plugin, type PluginDef } from "../kernel/plugin.ts";
21
+ import {
22
+ AuthFailed,
23
+ AuthRateLimited,
24
+ SessionTokensOut,
25
+ bindPublicAuth,
26
+ createMethodRuntime,
27
+ fail,
28
+ flow,
29
+ z,
30
+ type AuthMethodOptions,
31
+ } from "./auth/shared.ts";
32
+
33
+ const DEFAULT_TTL_MS = 10 * 60 * 1000;
34
+ const MAX_ATTEMPTS = 5;
35
+
36
+ /** Options for {@link emailOtp}. */
37
+ export interface EmailOtpOptions extends AuthMethodOptions {
38
+ /** Challenge TTL (default 10m). */
39
+ readonly ttlMs?: number;
40
+ readonly identities?: IdentityStore;
41
+ readonly verifications?: VerificationStore;
42
+ /** Return raw OTP in the request response (test / local). */
43
+ readonly exposeDevOtp?: boolean;
44
+ }
45
+
46
+ /**
47
+ * Email OTP request + verify (6-digit, hashed, 5 attempts).
48
+ *
49
+ * @param opts - TTL / stores / dev OTP
50
+ */
51
+ export function emailOtp(opts: EmailOtpOptions = {}): PluginDef {
52
+ const runtime = createMethodRuntime(opts);
53
+ const identities = opts.identities ?? createIdentityStore();
54
+ const verifications = opts.verifications ?? createVerificationStore();
55
+ const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
56
+
57
+ const request = flow({
58
+ name: "auth.requestEmailOtp",
59
+ unit: "auth",
60
+ plane: "user",
61
+ in: z.object({ email: z.string().min(3) }),
62
+ out: z.object({
63
+ ok: z.literal(true),
64
+ devOtp: z.string().optional(),
65
+ }),
66
+ errors: { AuthFailed, AuthRateLimited },
67
+ do: async (input) => {
68
+ const email = normalizeEmail(input.email);
69
+ if (!email.includes("@")) return fail("AuthFailed", { reason: "invalid_email" });
70
+ const otp = generateOtp(6);
71
+ const now = runtime.now();
72
+ // Invalidate prior active challenges for this email.
73
+ for (const row of verifications.rows.values()) {
74
+ if (row.identifier === `email-otp:${email}` && row.consumedAt === null) {
75
+ row.consumedAt = now;
76
+ }
77
+ }
78
+ putVerification(verifications, {
79
+ id: crypto.randomUUID(),
80
+ identifier: `email-otp:${email}`,
81
+ value: await hashChallenge(otp),
82
+ expiresAt: now + ttlMs,
83
+ createdAt: now,
84
+ consumedAt: null,
85
+ attempts: 0,
86
+ });
87
+ return {
88
+ ok: true as const,
89
+ ...(opts.exposeDevOtp ? { devOtp: otp } : {}),
90
+ };
91
+ },
92
+ });
93
+
94
+ const verify = flow({
95
+ name: "auth.verifyEmailOtp",
96
+ unit: "auth",
97
+ plane: "user",
98
+ in: z.object({
99
+ email: z.string().min(3),
100
+ otp: z.string().min(4).max(8),
101
+ }),
102
+ out: SessionTokensOut,
103
+ errors: { AuthFailed, AuthRateLimited },
104
+ do: async (input) => {
105
+ const email = normalizeEmail(input.email);
106
+ const now = runtime.now();
107
+ const row = findActiveVerification(verifications, `email-otp:${email}`, now);
108
+ if (!row) return fail("AuthFailed", { reason: "invalid_credentials" });
109
+ if (row.attempts >= MAX_ATTEMPTS) {
110
+ row.consumedAt = now;
111
+ return fail("AuthFailed", { reason: "invalid_credentials" });
112
+ }
113
+ const hash = await hashChallenge(input.otp.trim());
114
+ if (hash !== row.value) {
115
+ row.attempts += 1;
116
+ if (row.attempts >= MAX_ATTEMPTS) row.consumedAt = now;
117
+ return fail("AuthFailed", { reason: "invalid_credentials" });
118
+ }
119
+ row.consumedAt = now;
120
+ const user = ensureUserByEmail(identities, email, now);
121
+ const issued = await issueSessionWithScopes(runtime.sessions, runtime.crypto, {
122
+ id: user.id,
123
+ plane: "user",
124
+ scopes: [],
125
+ });
126
+ return {
127
+ accessToken: issued.accessToken,
128
+ refreshToken: issued.refreshToken,
129
+ accessExpiresAt: issued.accessExpiresAt,
130
+ userId: user.id,
131
+ };
132
+ },
133
+ });
134
+
135
+ return plugin("emailOtp", { version: "0.0.1", config: { method: "email-otp" } })
136
+ .needs("auth")
137
+ .binding(bindPublicAuth("/email-otp/request", request, "otp"))
138
+ .binding(bindPublicAuth("/email-otp/verify", verify, "otp"));
139
+ }
140
+
141
+ function ensureUserByEmail(store: IdentityStore, email: string, now: number): UserIdentityRow {
142
+ const existingId = store.byEmail.get(email);
143
+ if (existingId) {
144
+ const existing = store.users.get(existingId);
145
+ if (existing) return existing;
146
+ }
147
+ const id = crypto.randomUUID();
148
+ const user: UserIdentityRow = {
149
+ id,
150
+ email,
151
+ name: email.split("@")[0] || "user",
152
+ emailVerified: true,
153
+ status: "active",
154
+ createdAt: now,
155
+ updatedAt: now,
156
+ extra: {},
157
+ };
158
+ store.users.set(id, user);
159
+ store.byEmail.set(email, id);
160
+ return user;
161
+ }