okengine 0.3.6 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/AGENTS.md +2 -0
  2. package/package.json +14 -12
  3. package/site/content/docs/ai/index.mdx +24 -0
  4. package/site/content/docs/ai/llms-txt.mdx +3 -0
  5. package/site/content/docs/ai/meta.json +1 -1
  6. package/site/content/docs/ai/skills.mdx +5 -3
  7. package/site/content/docs/console/gates.mdx +46 -8
  8. package/site/content/docs/console/index.mdx +54 -0
  9. package/site/content/docs/console/meta.json +1 -0
  10. package/site/content/docs/elements/ai.mdx +2 -0
  11. package/site/content/docs/elements/channel.mdx +2 -0
  12. package/site/content/docs/elements/clock.mdx +1 -4
  13. package/site/content/docs/elements/flow.mdx +4 -10
  14. package/site/content/docs/elements/gate.mdx +189 -48
  15. package/site/content/docs/elements/index.mdx +45 -0
  16. package/site/content/docs/elements/meta.json +1 -1
  17. package/site/content/docs/elements/signal.mdx +1 -5
  18. package/site/content/docs/elements/store.mdx +27 -6
  19. package/site/content/docs/elements/vault.mdx +10 -11
  20. package/site/content/docs/get-started/basic-usage.mdx +79 -43
  21. package/site/content/docs/get-started/index.mdx +33 -0
  22. package/site/content/docs/get-started/installation.mdx +95 -43
  23. package/site/content/docs/get-started/introduction.mdx +128 -75
  24. package/site/content/docs/get-started/meta.json +1 -1
  25. package/site/content/docs/get-started/why.mdx +141 -0
  26. package/site/content/docs/index.mdx +9 -31
  27. package/site/content/docs/plugins/anonymous.mdx +95 -0
  28. package/site/content/docs/plugins/compression.mdx +2 -2
  29. package/site/content/docs/plugins/cors.mdx +2 -2
  30. package/site/content/docs/plugins/csrf.mdx +2 -2
  31. package/site/content/docs/plugins/email-otp.mdx +111 -0
  32. package/site/content/docs/plugins/{security-headers.mdx → headers.mdx} +2 -2
  33. package/site/content/docs/plugins/index.mdx +69 -0
  34. package/site/content/docs/plugins/ip-allowlist.mdx +1 -2
  35. package/site/content/docs/plugins/magic-link.mdx +112 -0
  36. package/site/content/docs/plugins/maintenance-mode.mdx +8 -8
  37. package/site/content/docs/plugins/meta.json +10 -1
  38. package/site/content/docs/plugins/passkey.mdx +128 -0
  39. package/site/content/docs/plugins/phone-number.mdx +111 -0
  40. package/site/content/docs/plugins/two-factor.mdx +116 -0
  41. package/site/content/docs/plugins/username.mdx +117 -0
  42. package/site/content/docs/reference/client.mdx +331 -0
  43. package/site/content/docs/reference/configuration.mdx +1 -1
  44. package/site/content/docs/reference/environment-variables.mdx +5 -3
  45. package/site/content/docs/reference/fx.mdx +36 -8
  46. package/site/content/docs/reference/index.mdx +45 -0
  47. package/site/content/docs/reference/meta.json +11 -1
  48. package/site/content/docs/reference/plugins.mdx +25 -14
  49. package/src/auth/auth.test.ts +20 -2
  50. package/src/auth/bindings.ts +439 -0
  51. package/src/auth/breach-check.ts +112 -0
  52. package/src/auth/config.ts +288 -0
  53. package/src/auth/cookies.ts +123 -0
  54. package/src/auth/gate-auth.test.ts +379 -0
  55. package/src/auth/identity.ts +190 -0
  56. package/src/auth/index.ts +117 -1
  57. package/src/auth/method-context.ts +33 -0
  58. package/src/auth/operator.ts +27 -1
  59. package/src/auth/password-policy.test.ts +126 -0
  60. package/src/auth/password-policy.ts +77 -0
  61. package/src/auth/plugin.ts +62 -4
  62. package/src/auth/rate.ts +45 -0
  63. package/src/auth/schema.ts +260 -0
  64. package/src/auth/secondary-storage.ts +37 -0
  65. package/src/auth/sessions.ts +58 -1
  66. package/src/auth/tables.ts +4 -0
  67. package/src/auth/verification.ts +78 -0
  68. package/src/cli/competitor-mention-removal.test.ts +117 -0
  69. package/src/cli/dev.test.ts +3 -3
  70. package/src/cli/dev.ts +20 -0
  71. package/src/cli/meilisearch-local.test.ts +69 -0
  72. package/src/cli/meilisearch-local.ts +188 -0
  73. package/src/cli/schema.ts +95 -23
  74. package/src/client/auth.ts +120 -0
  75. package/src/client-react/index.ts +93 -0
  76. package/src/compiler/aot.test.ts +2 -1
  77. package/src/compiler/extract.ts +19 -0
  78. package/src/compiler/fixtures/skyport/src/flows/payments/index.ts +5 -1
  79. package/src/compiler/response.ts +16 -2
  80. package/src/console/server/app.ts +10 -6
  81. package/src/console/server/auth-rate.test.ts +3 -3
  82. package/src/console/server/bind.ts +12 -1
  83. package/src/console/server/channels.test.ts +1 -1
  84. package/src/console/server/console-gates.ts +14 -0
  85. package/src/console/server/console.test.ts +6 -6
  86. package/src/console/server/flows-invoke.test.ts +2 -2
  87. package/src/console/server/flows.ts +2 -0
  88. package/src/console/server/gates.ts +8 -1
  89. package/src/console/server/operator-db.test.ts +4 -4
  90. package/src/console/server/operator-db.ts +22 -4
  91. package/src/console/server/security.gate.test.ts +3 -3
  92. package/src/console/server/store.test.ts +1 -1
  93. package/src/console/server/store.ts +11 -1
  94. package/src/console/ui/dist/assets/index-CjxwRGVv.js +10 -0
  95. package/src/console/ui/dist/assets/panel-access-BGv45snf.js +64 -0
  96. package/src/console/ui/dist/assets/{panel-ai-D_m6WQI8.js → panel-ai-B2S7LEii.js} +1 -1
  97. package/src/console/ui/dist/assets/{panel-architecture-CKnXFyUx.js → panel-architecture-D7UJh91v.js} +1 -1
  98. package/src/console/ui/dist/assets/{panel-channels-DCDd4WAC.js → panel-channels-9T3ybqRu.js} +1 -1
  99. package/src/console/ui/dist/assets/panel-clock-Cb1UXGRQ.js +1 -0
  100. package/src/console/ui/dist/assets/{panel-diff-cdonmH8c.js → panel-diff-DmYbKWmN.js} +1 -1
  101. package/src/console/ui/dist/assets/panel-flows-PiHwT55z.js +48 -0
  102. package/src/console/ui/dist/assets/{panel-gates-B5eTE8XH.js → panel-gates-BQGYXvjT.js} +1 -1
  103. package/src/console/ui/dist/assets/panel-overview-BBnRO18l.js +1 -0
  104. package/src/console/ui/dist/assets/{panel-plugins-Cj7DK1er.js → panel-plugins-D0PsmVw2.js} +1 -1
  105. package/src/console/ui/dist/assets/panel-runs-CWuRDe0r.js +1 -0
  106. package/src/console/ui/dist/assets/{panel-signals-whmDXIg3.js → panel-signals-Bbg4ewpP.js} +1 -1
  107. package/src/console/ui/dist/assets/{panel-store-CEMHLvaw.js → panel-store-CPCbsDRa.js} +1 -1
  108. package/src/console/ui/dist/assets/panel-traces-DVAzuA_S.js +1 -0
  109. package/src/console/ui/dist/assets/{panel-vault-C9wjbki8.js → panel-vault-D1_MvOmo.js} +1 -1
  110. package/src/console/ui/dist/assets/{rolldown-runtime-CNC7AqOf.js → rolldown-runtime-B0Z9INg1.js} +1 -1
  111. package/src/console/ui/dist/index.html +2 -2
  112. package/src/console/ui/gates/fixture.ts +4 -0
  113. package/src/console/ui/gates/types.ts +2 -0
  114. package/src/console/ui/shell/client.ts +1 -0
  115. package/src/docker/compose.ts +5 -0
  116. package/src/docker/docker.test.ts +41 -0
  117. package/src/docker/recipes/index.ts +10 -2
  118. package/src/docker/recipes/meilisearch.ts +31 -0
  119. package/src/drivers/conformance.test.ts +16 -1
  120. package/src/drivers/conformance.ts +40 -3
  121. package/src/drivers/index.ts +14 -2
  122. package/src/drivers/libsql.ts +4 -4
  123. package/src/drivers/meilisearch.integration.test.ts +77 -0
  124. package/src/drivers/meilisearch.test.ts +181 -0
  125. package/src/drivers/meilisearch.ts +208 -0
  126. package/src/drivers/memory.ts +4 -4
  127. package/src/drivers/pgvector.ts +6 -6
  128. package/src/drivers/types.ts +93 -12
  129. package/src/drivers/vault-driver-removal.test.ts +6 -0
  130. package/src/drivers/vault-types.ts +4 -4
  131. package/src/elements/ai/runtime.ts +6 -0
  132. package/src/elements/ai.test.ts +22 -0
  133. package/src/elements/gate/boot.ts +136 -0
  134. package/src/elements/gate/config.ts +69 -0
  135. package/src/elements/gate/declare.ts +51 -1
  136. package/src/elements/gate/runtime.ts +3 -1
  137. package/src/elements/gate.test.ts +77 -0
  138. package/src/elements/gate.ts +20 -1
  139. package/src/elements/index.ts +8 -0
  140. package/src/elements/store/index-boot.test.ts +49 -7
  141. package/src/elements/store/runtime.ts +50 -15
  142. package/src/elements/store.ts +2 -0
  143. package/src/elements/vault.test.ts +27 -4
  144. package/src/elements/vault.ts +1 -1
  145. package/src/index.ts +15 -0
  146. package/src/kernel/app.ts +253 -32
  147. package/src/kernel/boot-bind/store.test.ts +9 -0
  148. package/src/kernel/boot-bind/store.ts +30 -2
  149. package/src/kernel/boot.test.ts +40 -3
  150. package/src/kernel/boot.ts +8 -0
  151. package/src/kernel/call.test.ts +46 -2
  152. package/src/kernel/concurrency.test.ts +58 -0
  153. package/src/kernel/concurrency.ts +48 -0
  154. package/src/kernel/edge.test.ts +3 -3
  155. package/src/kernel/flow.test.ts +2 -2
  156. package/src/kernel/fx.test.ts +12 -2
  157. package/src/kernel/fx.ts +97 -5
  158. package/src/kernel/hooks.test.ts +4 -4
  159. package/src/kernel/index.ts +22 -1
  160. package/src/kernel/pipeline.test.ts +12 -8
  161. package/src/kernel/pipeline.ts +23 -4
  162. package/src/kernel/plugin/decorate.test.ts +3 -3
  163. package/src/kernel/plugin/scoping.test.ts +3 -3
  164. package/src/kernel/plugin-elements.test.ts +51 -0
  165. package/src/kernel/plugin-needs.test.ts +83 -0
  166. package/src/kernel/plugin-needs.ts +129 -0
  167. package/src/kernel/plugin.ts +101 -0
  168. package/src/kernel/redacted.ts +74 -0
  169. package/src/kernel/registry-isolation.test.ts +5 -5
  170. package/src/kernel/registry.ts +102 -3
  171. package/src/kernel/router.ts +3 -3
  172. package/src/manifest/types.ts +2 -0
  173. package/src/plugins/anonymous.ts +58 -0
  174. package/src/plugins/auth/shared.ts +121 -0
  175. package/src/plugins/auth-methods.test.ts +176 -0
  176. package/src/plugins/compression.test.ts +5 -5
  177. package/src/plugins/config-source.test.ts +1 -1
  178. package/src/plugins/cors.test.ts +16 -10
  179. package/src/plugins/csrf.test.ts +1 -1
  180. package/src/plugins/email-otp.ts +161 -0
  181. package/src/plugins/index.ts +31 -0
  182. package/src/plugins/ip-allowlist.test.ts +19 -9
  183. package/src/plugins/magic-link.ts +163 -0
  184. package/src/plugins/maintenance-mode.test.ts +9 -5
  185. package/src/plugins/passkey.ts +216 -0
  186. package/src/plugins/phone-number.ts +149 -0
  187. package/src/plugins/security-headers.test.ts +14 -14
  188. package/src/plugins/two-factor.ts +249 -0
  189. package/src/plugins/username.ts +148 -0
  190. package/src/runs/runs.test.ts +6 -2
  191. package/src/runtime/primitives.ts +37 -4
  192. package/src/runtime/serve.test.ts +3 -2
  193. package/src/runtime/types.ts +25 -2
  194. package/src/test/create-test-app.test.ts +1 -1
  195. package/src/test/create-test-app.ts +4 -1
  196. package/src/test/provisions.integration.test.ts +2 -2
  197. package/site/content/docs/get-started/comparison.mdx +0 -65
  198. package/src/console/ui/dist/assets/index-CrKMmO__.js +0 -10
  199. package/src/console/ui/dist/assets/panel-access-C0J2D-a2.js +0 -64
  200. package/src/console/ui/dist/assets/panel-clock-DjGGFPzr.js +0 -1
  201. package/src/console/ui/dist/assets/panel-flows-DlCU5zjA.js +0 -45
  202. package/src/console/ui/dist/assets/panel-overview-BsFvDdts.js +0 -1
  203. package/src/console/ui/dist/assets/panel-runs-C0gmnoYL.js +0 -1
  204. package/src/console/ui/dist/assets/panel-traces-BDiAuVSK.js +0 -1
  205. package/src/drivers/vault-infisical.ts +0 -57
@@ -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();
@@ -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
+ }
@@ -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,8 +16,23 @@ 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
+ createPhoneStore,
32
+ phoneNumber,
33
+ type PhoneNumberOptions,
34
+ type PhoneStore,
35
+ } from "./phone-number.ts";
20
36
  export {
21
37
  defaultCspDirectives,
22
38
  securityHeaders,
@@ -24,3 +40,18 @@ export {
24
40
  type HstsOptions,
25
41
  type SecurityHeadersOptions,
26
42
  } from "./security-headers.ts";
43
+ export {
44
+ createTwoFactorStore,
45
+ twoFactor,
46
+ verifyTotp,
47
+ type TwoFactorOptions,
48
+ type TwoFactorRow,
49
+ type TwoFactorStore,
50
+ } from "./two-factor.ts";
51
+ export {
52
+ createUsernameStore,
53
+ username,
54
+ type UsernamePluginOptions,
55
+ type UsernameRow,
56
+ type UsernameStore,
57
+ } 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
 
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Magic-link Gate auth method plugin.
3
+ *
4
+ * Delivery via Channel is optional in v1 — when {@link MagicLinkOptions.exposeDevToken}
5
+ * is set, the request response includes `devToken` for tests / local DX.
6
+ */
7
+
8
+ import {
9
+ createIdentityStore,
10
+ normalizeEmail,
11
+ type IdentityStore,
12
+ type UserIdentityRow,
13
+ } from "../auth/identity.ts";
14
+ import { issueSessionWithScopes } from "../auth/sessions.ts";
15
+ import {
16
+ createVerificationStore,
17
+ hashChallenge,
18
+ putVerification,
19
+ type VerificationStore,
20
+ } from "../auth/verification.ts";
21
+ import { plugin, type PluginDef } from "../kernel/plugin.ts";
22
+ import {
23
+ AuthFailed,
24
+ AuthRateLimited,
25
+ SessionTokensOut,
26
+ bindPublicAuth,
27
+ createMethodRuntime,
28
+ fail,
29
+ flow,
30
+ z,
31
+ type AuthMethodOptions,
32
+ } from "./auth/shared.ts";
33
+
34
+ const DEFAULT_TTL_MS = 10 * 60 * 1000;
35
+
36
+ /** Options for {@link magicLink}. */
37
+ export interface MagicLinkOptions extends AuthMethodOptions {
38
+ /** Challenge TTL (default 10m). */
39
+ readonly ttlMs?: number;
40
+ /** Identity store for email → user. */
41
+ readonly identities?: IdentityStore;
42
+ /** Verification / challenge store. */
43
+ readonly verifications?: VerificationStore;
44
+ /** Return raw token in the request response (test / local). */
45
+ readonly exposeDevToken?: boolean;
46
+ }
47
+
48
+ /**
49
+ * Magic-link request + verify (`oke_verifications` challenge rows).
50
+ *
51
+ * @param opts - TTL / stores / dev token
52
+ */
53
+ export function magicLink(opts: MagicLinkOptions = {}): PluginDef {
54
+ const runtime = createMethodRuntime(opts);
55
+ const identities = opts.identities ?? createIdentityStore();
56
+ const verifications = opts.verifications ?? createVerificationStore();
57
+ const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
58
+
59
+ const request = flow({
60
+ name: "auth.requestMagicLink",
61
+ unit: "auth",
62
+ plane: "user",
63
+ in: z.object({ email: z.string().min(3) }),
64
+ out: z.object({
65
+ ok: z.literal(true),
66
+ devToken: z.string().optional(),
67
+ }),
68
+ errors: { AuthFailed, AuthRateLimited },
69
+ do: async (input) => {
70
+ const email = normalizeEmail(input.email);
71
+ if (!email.includes("@")) return fail("AuthFailed", { reason: "invalid_email" });
72
+ const token = `ml_${crypto.randomUUID().replace(/-/g, "")}`;
73
+ const now = runtime.now();
74
+ putVerification(verifications, {
75
+ id: crypto.randomUUID(),
76
+ identifier: `magic:${email}`,
77
+ value: await hashChallenge(token),
78
+ expiresAt: now + ttlMs,
79
+ createdAt: now,
80
+ consumedAt: null,
81
+ attempts: 0,
82
+ });
83
+ return {
84
+ ok: true as const,
85
+ ...(opts.exposeDevToken ? { devToken: token } : {}),
86
+ };
87
+ },
88
+ });
89
+
90
+ const verify = flow({
91
+ name: "auth.verifyMagicLink",
92
+ unit: "auth",
93
+ plane: "user",
94
+ in: z.object({ token: z.string().min(1) }),
95
+ out: SessionTokensOut,
96
+ errors: { AuthFailed, AuthRateLimited },
97
+ do: async (input) => {
98
+ const hash = await hashChallenge(input.token);
99
+ const now = runtime.now();
100
+ let row:
101
+ | {
102
+ id: string;
103
+ identifier: string;
104
+ value: string;
105
+ expiresAt: number;
106
+ consumedAt: number | null;
107
+ attempts: number;
108
+ }
109
+ | undefined;
110
+ for (const candidate of verifications.rows.values()) {
111
+ if (!candidate.identifier.startsWith("magic:")) continue;
112
+ if (candidate.consumedAt !== null) continue;
113
+ if (candidate.expiresAt <= now) continue;
114
+ if (candidate.value === hash) {
115
+ row = candidate;
116
+ break;
117
+ }
118
+ }
119
+ if (!row) return fail("AuthFailed", { reason: "invalid_credentials" });
120
+ row.consumedAt = now;
121
+ const email = row.identifier.slice("magic:".length);
122
+ const user = ensureUserByEmail(identities, email, now);
123
+ const issued = await issueSessionWithScopes(runtime.sessions, runtime.crypto, {
124
+ id: user.id,
125
+ plane: "user",
126
+ scopes: [],
127
+ });
128
+ return {
129
+ accessToken: issued.accessToken,
130
+ refreshToken: issued.refreshToken,
131
+ accessExpiresAt: issued.accessExpiresAt,
132
+ userId: user.id,
133
+ };
134
+ },
135
+ });
136
+
137
+ return plugin("magicLink", { version: "0.0.1", config: { method: "magic-link" } })
138
+ .needs("auth")
139
+ .binding(bindPublicAuth("/magic-link/request", request, "otp"))
140
+ .binding(bindPublicAuth("/magic-link/verify", verify, "otp"));
141
+ }
142
+
143
+ function ensureUserByEmail(store: IdentityStore, email: string, now: number): UserIdentityRow {
144
+ const existingId = store.byEmail.get(email);
145
+ if (existingId) {
146
+ const existing = store.users.get(existingId);
147
+ if (existing) return existing;
148
+ }
149
+ const id = crypto.randomUUID();
150
+ const user: UserIdentityRow = {
151
+ id,
152
+ email,
153
+ name: email.split("@")[0] || "user",
154
+ emailVerified: true,
155
+ status: "active",
156
+ createdAt: now,
157
+ updatedAt: now,
158
+ extra: {},
159
+ };
160
+ store.users.set(id, user);
161
+ store.byEmail.set(email, id);
162
+ return user;
163
+ }
@@ -18,7 +18,7 @@ beforeEach(() => {
18
18
  describe("maintenanceMode plugin", () => {
19
19
  test("returns 503 with ServiceUnavailable envelope and Retry-After", async () => {
20
20
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
21
- const app = oke({ name: "maint" }).plug(maintenanceMode({ retryAfter: 120 }));
21
+ const app = oke({ autoBoot: false, name: "maint" }).plug(maintenanceMode({ retryAfter: 120 }));
22
22
 
23
23
  const res = await app.fetch(new Request("http://localhost/x"));
24
24
 
@@ -36,7 +36,9 @@ describe("maintenanceMode plugin", () => {
36
36
 
37
37
  test("enabled: false passes traffic through", async () => {
38
38
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
39
- const app = oke({ name: "maint-off" }).plug(maintenanceMode({ enabled: false }));
39
+ const app = oke({ autoBoot: false, name: "maint-off" }).plug(
40
+ maintenanceMode({ enabled: false }),
41
+ );
40
42
 
41
43
  const res = await app.fetch(new Request("http://localhost/x"));
42
44
 
@@ -46,7 +48,9 @@ describe("maintenanceMode plugin", () => {
46
48
  test("allowPaths prefixes keep serving", async () => {
47
49
  on(http.get("/health"), flow({ name: "health.get", do: () => ({ up: true }) }));
48
50
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
49
- const app = oke({ name: "maint-allow" }).plug(maintenanceMode({ allowPaths: ["/health"] }));
51
+ const app = oke({ autoBoot: false, name: "maint-allow" }).plug(
52
+ maintenanceMode({ allowPaths: ["/health"] }),
53
+ );
50
54
 
51
55
  const health = await app.fetch(new Request("http://localhost/health"));
52
56
  expect(health.status).toBe(200);
@@ -57,7 +61,7 @@ describe("maintenanceMode plugin", () => {
57
61
 
58
62
  test("bypass header lets operators through (any non-empty value)", async () => {
59
63
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
60
- const app = oke({ name: "maint-bypass" }).plug(
64
+ const app = oke({ autoBoot: false, name: "maint-bypass" }).plug(
61
65
  maintenanceMode({ bypassHeader: "x-ops-token" }),
62
66
  );
63
67
 
@@ -72,7 +76,7 @@ describe("maintenanceMode plugin", () => {
72
76
 
73
77
  test("the 503 still flows through onResponse (other plugins stamp it)", async () => {
74
78
  on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
75
- const app = oke({ name: "maint-stamp" }).plug(maintenanceMode());
79
+ const app = oke({ autoBoot: false, name: "maint-stamp" }).plug(maintenanceMode());
76
80
  app.hook("onResponse", (ctx) => {
77
81
  if (!ctx.response) return;
78
82
  const headers = new Headers(ctx.response.headers);