okengine 0.4.3 → 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 (127) 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/index.mdx +9 -31
  15. package/site/content/docs/plugins/anonymous.mdx +95 -0
  16. package/site/content/docs/plugins/compression.mdx +2 -2
  17. package/site/content/docs/plugins/cors.mdx +2 -2
  18. package/site/content/docs/plugins/csrf.mdx +2 -2
  19. package/site/content/docs/plugins/email-otp.mdx +111 -0
  20. package/site/content/docs/plugins/{security-headers.mdx → headers.mdx} +1 -1
  21. package/site/content/docs/plugins/index.mdx +69 -0
  22. package/site/content/docs/plugins/magic-link.mdx +112 -0
  23. package/site/content/docs/plugins/maintenance-mode.mdx +8 -8
  24. package/site/content/docs/plugins/meta.json +10 -1
  25. package/site/content/docs/plugins/passkey.mdx +128 -0
  26. package/site/content/docs/plugins/phone-number.mdx +111 -0
  27. package/site/content/docs/plugins/two-factor.mdx +116 -0
  28. package/site/content/docs/plugins/username.mdx +117 -0
  29. package/site/content/docs/reference/client.mdx +331 -0
  30. package/site/content/docs/reference/fx.mdx +20 -5
  31. package/site/content/docs/reference/index.mdx +45 -0
  32. package/site/content/docs/reference/meta.json +11 -1
  33. package/site/content/docs/reference/plugins.mdx +25 -14
  34. package/src/auth/auth.test.ts +20 -2
  35. package/src/auth/bindings.ts +439 -0
  36. package/src/auth/breach-check.ts +112 -0
  37. package/src/auth/config.ts +288 -0
  38. package/src/auth/cookies.ts +123 -0
  39. package/src/auth/gate-auth.test.ts +379 -0
  40. package/src/auth/identity.ts +190 -0
  41. package/src/auth/index.ts +117 -1
  42. package/src/auth/method-context.ts +33 -0
  43. package/src/auth/operator.ts +27 -1
  44. package/src/auth/password-policy.test.ts +126 -0
  45. package/src/auth/password-policy.ts +77 -0
  46. package/src/auth/plugin.ts +62 -4
  47. package/src/auth/rate.ts +45 -0
  48. package/src/auth/schema.ts +260 -0
  49. package/src/auth/secondary-storage.ts +37 -0
  50. package/src/auth/sessions.ts +58 -1
  51. package/src/auth/tables.ts +4 -0
  52. package/src/auth/verification.ts +78 -0
  53. package/src/cli/dev.test.ts +3 -3
  54. package/src/cli/schema.ts +95 -23
  55. package/src/client/auth.ts +120 -0
  56. package/src/client-react/index.ts +93 -0
  57. package/src/compiler/aot.test.ts +2 -1
  58. package/src/compiler/extract.ts +19 -0
  59. package/src/compiler/response.ts +16 -2
  60. package/src/console/server/app.ts +10 -6
  61. package/src/console/server/auth-rate.test.ts +3 -3
  62. package/src/console/server/bind.ts +12 -1
  63. package/src/console/server/channels.test.ts +1 -1
  64. package/src/console/server/console-gates.ts +14 -0
  65. package/src/console/server/console.test.ts +6 -6
  66. package/src/console/server/flows-invoke.test.ts +2 -2
  67. package/src/console/server/flows.ts +2 -0
  68. package/src/console/server/gates.ts +8 -1
  69. package/src/console/server/operator-db.test.ts +4 -4
  70. package/src/console/server/operator-db.ts +22 -4
  71. package/src/console/server/security.gate.test.ts +3 -3
  72. package/src/console/ui/gates/fixture.ts +4 -0
  73. package/src/console/ui/gates/types.ts +2 -0
  74. package/src/console/ui/shell/client.ts +1 -0
  75. package/src/elements/gate/boot.ts +136 -0
  76. package/src/elements/gate/config.ts +69 -0
  77. package/src/elements/gate/declare.ts +51 -1
  78. package/src/elements/gate/runtime.ts +3 -1
  79. package/src/elements/gate.test.ts +77 -0
  80. package/src/elements/gate.ts +20 -1
  81. package/src/elements/index.ts +8 -0
  82. package/src/index.ts +11 -0
  83. package/src/kernel/app.ts +253 -32
  84. package/src/kernel/boot.test.ts +40 -3
  85. package/src/kernel/boot.ts +8 -0
  86. package/src/kernel/call.test.ts +46 -2
  87. package/src/kernel/edge.test.ts +3 -3
  88. package/src/kernel/flow.test.ts +2 -2
  89. package/src/kernel/fx.test.ts +1 -0
  90. package/src/kernel/fx.ts +60 -0
  91. package/src/kernel/hooks.test.ts +4 -4
  92. package/src/kernel/index.ts +12 -0
  93. package/src/kernel/pipeline.test.ts +12 -8
  94. package/src/kernel/pipeline.ts +23 -4
  95. package/src/kernel/plugin/decorate.test.ts +3 -3
  96. package/src/kernel/plugin/scoping.test.ts +3 -3
  97. package/src/kernel/plugin-elements.test.ts +51 -0
  98. package/src/kernel/plugin-needs.test.ts +83 -0
  99. package/src/kernel/plugin-needs.ts +129 -0
  100. package/src/kernel/plugin.ts +101 -0
  101. package/src/kernel/registry-isolation.test.ts +5 -5
  102. package/src/kernel/registry.ts +102 -3
  103. package/src/manifest/types.ts +2 -0
  104. package/src/plugins/anonymous.ts +58 -0
  105. package/src/plugins/auth/shared.ts +121 -0
  106. package/src/plugins/auth-methods.test.ts +176 -0
  107. package/src/plugins/compression.test.ts +5 -5
  108. package/src/plugins/config-source.test.ts +1 -1
  109. package/src/plugins/cors.test.ts +16 -10
  110. package/src/plugins/csrf.test.ts +1 -1
  111. package/src/plugins/email-otp.ts +161 -0
  112. package/src/plugins/index.ts +31 -0
  113. package/src/plugins/ip-allowlist.test.ts +19 -9
  114. package/src/plugins/magic-link.ts +163 -0
  115. package/src/plugins/maintenance-mode.test.ts +9 -5
  116. package/src/plugins/passkey.ts +216 -0
  117. package/src/plugins/phone-number.ts +149 -0
  118. package/src/plugins/security-headers.test.ts +14 -14
  119. package/src/plugins/two-factor.ts +249 -0
  120. package/src/plugins/username.ts +148 -0
  121. package/src/runs/runs.test.ts +6 -2
  122. package/src/runtime/primitives.ts +37 -4
  123. package/src/runtime/serve.test.ts +3 -2
  124. package/src/runtime/types.ts +25 -2
  125. package/src/test/create-test-app.test.ts +1 -1
  126. package/src/test/create-test-app.ts +4 -1
  127. package/src/test/provisions.integration.test.ts +1 -1
@@ -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" } }),
@@ -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
 
@@ -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