okengine 0.8.0 → 0.9.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 (89) hide show
  1. package/package.json +3 -2
  2. package/site/content/docs/deployment/docker-swarm.mdx +228 -0
  3. package/site/content/docs/deployment/docker.mdx +212 -0
  4. package/site/content/docs/deployment/index.mdx +83 -0
  5. package/site/content/docs/deployment/kubernetes.mdx +176 -0
  6. package/site/content/docs/deployment/meta.json +5 -0
  7. package/site/content/docs/deployment/reverse-proxy.mdx +216 -0
  8. package/site/content/docs/elements/channel.mdx +10 -5
  9. package/site/content/docs/elements/signal.mdx +10 -8
  10. package/site/content/docs/elements/store.mdx +34 -0
  11. package/site/content/docs/get-started/index.mdx +5 -0
  12. package/site/content/docs/index.mdx +5 -0
  13. package/site/content/docs/meta.json +10 -1
  14. package/site/content/docs/plugins/index.mdx +1 -2
  15. package/site/content/docs/plugins/magic-link.mdx +2 -2
  16. package/site/content/docs/plugins/meta.json +1 -2
  17. package/site/content/docs/plugins/otp.mdx +214 -0
  18. package/site/content/docs/plugins/two-factor.mdx +2 -2
  19. package/site/content/docs/reference/cli.md +3 -2
  20. package/site/content/docs/reference/configuration.mdx +16 -0
  21. package/site/content/docs/reference/environment-variables.mdx +9 -8
  22. package/site/content/docs/reference/plugins.mdx +1 -1
  23. package/src/auth/auth.test.ts +36 -0
  24. package/src/auth/bindings.ts +3 -12
  25. package/src/auth/identity.ts +33 -0
  26. package/src/auth/index.ts +5 -0
  27. package/src/auth/otp-capability.ts +119 -0
  28. package/src/auth/otp-seal.test.ts +61 -0
  29. package/src/auth/otp-seal.ts +84 -0
  30. package/src/auth/schema.ts +3 -0
  31. package/src/auth/sessions.ts +26 -27
  32. package/src/auth/tables.ts +4 -0
  33. package/src/auth/verification.ts +61 -1
  34. package/src/cli/dev-app-runner.ts +4 -0
  35. package/src/cli/docker.ts +4 -1
  36. package/src/cli/load-config.images.test.ts +4 -0
  37. package/src/cli/load-config.ts +3 -0
  38. package/src/cli/registry.ts +1 -1
  39. package/src/console/server/operator-db.ts +34 -9
  40. package/src/docker/compose.ts +162 -6
  41. package/src/docker/derive.ts +60 -3
  42. package/src/docker/docker.test.ts +374 -1
  43. package/src/docker/helpers.ts +2 -0
  44. package/src/docker/index.ts +11 -0
  45. package/src/docker/recipes/caddy.ts +51 -0
  46. package/src/docker/recipes/dragonfly.ts +31 -0
  47. package/src/docker/recipes/index.ts +25 -2
  48. package/src/docker/recipes/pgdog.ts +84 -0
  49. package/src/docker/recipes/redis.ts +6 -3
  50. package/src/docker/recipes/traefik.ts +83 -0
  51. package/src/docker/recipes/valkey.ts +30 -0
  52. package/src/docker/stack-id.ts +5 -0
  53. package/src/docker/types.ts +18 -0
  54. package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
  55. package/src/drivers/channel-types.ts +1 -0
  56. package/src/elements/channel/otp-delivery.test.ts +76 -0
  57. package/src/elements/channel/otp-delivery.ts +291 -0
  58. package/src/elements/channel/runtime.ts +152 -114
  59. package/src/elements/channel.ts +12 -2
  60. package/src/index.ts +3 -0
  61. package/src/kernel/app.ts +60 -4
  62. package/src/kernel/boot-bind/channel.ts +51 -0
  63. package/src/kernel/boot-bind/gate.ts +14 -19
  64. package/src/kernel/boot-bind/honor-config.test.ts +18 -0
  65. package/src/kernel/boot-bind/signal.ts +20 -0
  66. package/src/kernel/boot-bind/store.test.ts +82 -0
  67. package/src/kernel/boot-bind/store.ts +22 -0
  68. package/src/kernel/boot.test.ts +5 -5
  69. package/src/kernel/fx.test.ts +3 -0
  70. package/src/kernel/fx.ts +49 -0
  71. package/src/kernel/graceful-shutdown.test.ts +76 -0
  72. package/src/kernel/graceful-shutdown.ts +106 -0
  73. package/src/kernel/horizontal-child.ts +257 -0
  74. package/src/kernel/horizontal.integration.test.ts +229 -0
  75. package/src/kernel/index.ts +8 -0
  76. package/src/kernel/ready.test.ts +76 -0
  77. package/src/plugins/auth-delivery.mailpit.integration.test.ts +5 -5
  78. package/src/plugins/auth-methods.security.test.ts +19 -29
  79. package/src/plugins/auth-methods.test.ts +7 -6
  80. package/src/plugins/index.ts +12 -8
  81. package/src/plugins/magic-link.ts +1 -23
  82. package/src/plugins/otp.test.ts +238 -0
  83. package/src/plugins/otp.ts +572 -0
  84. package/src/plugins/taqnyat.live.test.ts +5 -7
  85. package/src/release/official-plugins.ts +1 -2
  86. package/site/content/docs/plugins/email-otp.mdx +0 -117
  87. package/site/content/docs/plugins/phone-number.mdx +0 -172
  88. package/src/plugins/email-otp.ts +0 -214
  89. package/src/plugins/phone-number.ts +0 -206
@@ -14,7 +14,7 @@ import { deriveInfrastructure, writeDerivedFiles } from "../docker/index.ts";
14
14
  import { oke } from "../kernel/app.ts";
15
15
  import { resetFlowSeq } from "../kernel/flow.ts";
16
16
  import { resetBindings } from "../kernel/on.ts";
17
- import { emailOtp } from "./email-otp.ts";
17
+ import { otp } from "./otp.ts";
18
18
  import { magicLink } from "./magic-link.ts";
19
19
 
20
20
  const SECRET = "test-secret-at-least-16";
@@ -290,21 +290,21 @@ describe("auth delivery — Mailpit integration", () => {
290
290
  config: {
291
291
  drivers: { channel: { email: { test: "smtp" } } },
292
292
  },
293
- }).plug(emailOtp({ exposeDevOtp: true }));
293
+ }).plug(otp({ mode: "app", channels: ["email"], exposeDevOtp: true }));
294
294
  await app.boot({ env: "test" });
295
295
 
296
- const res = await app.fetch(jsonPost("/auth/email-otp/request", { email }));
296
+ const res = await app.fetch(jsonPost("/auth/otp/request", { email }));
297
297
  expect(res.status).toBe(200);
298
298
  const body = (await res.json()) as { data: { ok: true; devOtp?: string } };
299
299
  expect(body.data.ok).toBe(true);
300
300
  expect(body.data.devOtp).toMatch(/^\d{6}$/);
301
- const otp = body.data.devOtp!;
301
+ const code = body.data.devOtp!;
302
302
 
303
303
  const msg = await waitForMessage(uiUrl, email);
304
304
  expect(msg.Subject).toBe("Your sign-in code");
305
305
  const text = msg.Text ?? "";
306
306
  const html = msg.HTML ?? "";
307
- expect(text.includes(otp) || html.includes(otp)).toBe(true);
307
+ expect(text.includes(code) || html.includes(code)).toBe(true);
308
308
 
309
309
  await app.stop();
310
310
  } finally {
@@ -12,11 +12,10 @@ import { oke } from "../kernel/app.ts";
12
12
  import { resetFlowSeq } from "../kernel/flow.ts";
13
13
  import { resetBindings } from "../kernel/on.ts";
14
14
  import { anonymous } from "./anonymous.ts";
15
- import { emailOtp } from "./email-otp.ts";
16
15
  import { magicLink } from "./magic-link.ts";
16
+ import { otp } from "./otp.ts";
17
17
  import { passkey } from "./passkey.ts";
18
18
  import { b64urlEncode, buildAuthenticatorData, signWebAuthnAssertion } from "./passkey-webauthn.ts";
19
- import { phoneNumber } from "./phone-number.ts";
20
19
  import { twoFactor, verifyTotp, createTwoFactorStore } from "./two-factor.ts";
21
20
  import { username } from "./username.ts";
22
21
 
@@ -51,8 +50,7 @@ function fullAuthApp() {
51
50
  .plug(username())
52
51
  .plug(anonymous())
53
52
  .plug(magicLink({ exposeDevToken: true }))
54
- .plug(emailOtp({ exposeDevOtp: true }))
55
- .plug(phoneNumber({ exposeDevOtp: true }))
53
+ .plug(otp({ mode: "app", channels: ["email", "sms"], exposeDevOtp: true }))
56
54
  .plug(twoFactor())
57
55
  .plug(passkey({ origins: ["http://localhost"] }));
58
56
  }
@@ -141,8 +139,7 @@ describe("auth methods — rate limiting / enumeration", () => {
141
139
 
142
140
  const paths: Array<{ path: string; body: unknown }> = [
143
141
  { path: "/auth/magic-link/request", body: { email: "r@example.com" } },
144
- { path: "/auth/email-otp/request", body: { email: "r@example.com" } },
145
- { path: "/auth/phone/request", body: { phone: "+15551234567" } },
142
+ { path: "/auth/otp/request", body: { email: "r@example.com" } },
146
143
  { path: "/auth/passkey/authenticate/options", body: {} },
147
144
  ];
148
145
 
@@ -225,14 +222,14 @@ describe("auth methods — single-use OTP / token / backup codes", () => {
225
222
  const app = fullAuthApp();
226
223
  await app.boot({ env: "test" });
227
224
 
228
- const req = await app.fetch(jsonPost("/auth/email-otp/request", { email: "otp@example.com" }));
225
+ const req = await app.fetch(jsonPost("/auth/otp/request", { email: "otp@example.com" }));
229
226
  const { data } = (await req.json()) as { data: { devOtp: string } };
230
227
  const first = await app.fetch(
231
- jsonPost("/auth/email-otp/verify", { email: "otp@example.com", otp: data.devOtp }),
228
+ jsonPost("/auth/otp/verify", { email: "otp@example.com", otp: data.devOtp }),
232
229
  );
233
230
  expect(first.status).toBe(200);
234
231
  const reuse = await app.fetch(
235
- jsonPost("/auth/email-otp/verify", { email: "otp@example.com", otp: data.devOtp }),
232
+ jsonPost("/auth/otp/verify", { email: "otp@example.com", otp: data.devOtp }),
236
233
  );
237
234
  expect((await readError(reuse)).reason).toBe("invalid_credentials");
238
235
 
@@ -243,14 +240,14 @@ describe("auth methods — single-use OTP / token / backup codes", () => {
243
240
  const app = fullAuthApp();
244
241
  await app.boot({ env: "test" });
245
242
 
246
- const req = await app.fetch(jsonPost("/auth/phone/request", { phone: "+15559876543" }));
243
+ const req = await app.fetch(jsonPost("/auth/otp/request", { phone: "+15559876543" }));
247
244
  const { data } = (await req.json()) as { data: { devOtp: string } };
248
245
  const first = await app.fetch(
249
- jsonPost("/auth/phone/verify", { phone: "+15559876543", otp: data.devOtp }),
246
+ jsonPost("/auth/otp/verify", { phone: "+15559876543", otp: data.devOtp }),
250
247
  );
251
248
  expect(first.status).toBe(200);
252
249
  const reuse = await app.fetch(
253
- jsonPost("/auth/phone/verify", { phone: "+15559876543", otp: data.devOtp }),
250
+ jsonPost("/auth/otp/verify", { phone: "+15559876543", otp: data.devOtp }),
254
251
  );
255
252
  expect((await readError(reuse)).reason).toBe("invalid_credentials");
256
253
 
@@ -421,7 +418,7 @@ describe("auth methods — anonymous non-escalation", () => {
421
418
  });
422
419
 
423
420
  describe("auth methods — channel delivery", () => {
424
- test("magic / email-otp send via fx.send; phone uses fx.sendOtp (Taqnyat Verify); exposeDev* stays off by default", async () => {
421
+ test("magic uses fx.send; otp app mode uses fx.deliverOtp; exposeDev* stays off by default", async () => {
425
422
  resetBindings();
426
423
  resetFlowSeq();
427
424
  const app = oke({
@@ -431,8 +428,7 @@ describe("auth methods — channel delivery", () => {
431
428
  gate: { auth: { secret: SECRET, emailAndPassword: { enabled: true } } },
432
429
  })
433
430
  .plug(magicLink())
434
- .plug(emailOtp())
435
- .plug(phoneNumber());
431
+ .plug(otp({ mode: "app", channels: ["email", "sms"] }));
436
432
  await app.boot({ env: "test" });
437
433
 
438
434
  const ml = await app.fetch(jsonPost("/auth/magic-link/request", { email: "x@example.com" }));
@@ -440,30 +436,24 @@ describe("auth methods — channel delivery", () => {
440
436
  expect(mlBody.data.ok).toBe(true);
441
437
  expect(mlBody.data.devToken).toBeUndefined();
442
438
 
443
- const otp = await app.fetch(jsonPost("/auth/email-otp/request", { email: "x@example.com" }));
444
- const otpBody = (await otp.json()) as { data: Record<string, unknown> };
439
+ const otpRes = await app.fetch(jsonPost("/auth/otp/request", { email: "x@example.com" }));
440
+ const otpBody = (await otpRes.json()) as { data: Record<string, unknown> };
445
441
  expect(otpBody.data.ok).toBe(true);
446
442
  expect(otpBody.data.devOtp).toBeUndefined();
447
443
 
448
- // No SMS driver in test env local hashed path; never exposeDevOtp by default.
449
- const phone = await app.fetch(jsonPost("/auth/phone/request", { phone: "+15551112222" }));
444
+ const phone = await app.fetch(jsonPost("/auth/otp/request", { phone: "+15551112222" }));
450
445
  const phoneBody = (await phone.json()) as { data: Record<string, unknown> };
451
446
  expect(phoneBody.data.ok).toBe(true);
452
447
  expect(phoneBody.data.devOtp).toBeUndefined();
453
448
 
454
449
  const magicSrc = await Bun.file(new URL("./magic-link.ts", import.meta.url)).text();
455
- const emailSrc = await Bun.file(new URL("./email-otp.ts", import.meta.url)).text();
456
- const phoneSrc = await Bun.file(new URL("./phone-number.ts", import.meta.url)).text();
450
+ const otpSrc = await Bun.file(new URL("./otp.ts", import.meta.url)).text();
457
451
  expect(magicSrc).toMatch(/fx\.send\(/);
458
452
  expect(magicSrc).toMatch(/channel\./);
459
- expect(emailSrc).toMatch(/fx\.send\(/);
460
- expect(emailSrc).toMatch(/channel\./);
461
- // Phone OTP is provider-managed via Taqnyat Verify through fx — never a
462
- // raw transport import or a generic fx.send template. Regexes tolerate
463
- // formatter line wraps between `fx` and the method name.
464
- expect(phoneSrc).toMatch(/fx\s*\.\s*sendOtp/);
465
- expect(phoneSrc).toMatch(/fx\s*\.\s*verifyOtp/);
466
- expect(phoneSrc).not.toMatch(/taqnyat-sms|sently\/transports/);
453
+ expect(otpSrc).toMatch(/fx\s*\.\s*deliverOtp/);
454
+ expect(otpSrc).toMatch(/fx\s*\.\s*sendOtp/);
455
+ expect(otpSrc).toMatch(/fx\s*\.\s*verifyOtp/);
456
+ expect(otpSrc).not.toMatch(/taqnyat-sms|sently\/transports/);
467
457
 
468
458
  await app.stop();
469
459
  });
@@ -7,8 +7,8 @@ import { oke } from "../kernel/app.ts";
7
7
  import { resetFlowSeq } from "../kernel/flow.ts";
8
8
  import { resetBindings } from "../kernel/on.ts";
9
9
  import { anonymous } from "./anonymous.ts";
10
- import { emailOtp } from "./email-otp.ts";
11
10
  import { magicLink } from "./magic-link.ts";
11
+ import { otp } from "./otp.ts";
12
12
  import { username } from "./username.ts";
13
13
 
14
14
  afterEach(() => {
@@ -31,7 +31,7 @@ function authApp() {
31
31
  .plug(username())
32
32
  .plug(anonymous())
33
33
  .plug(magicLink({ exposeDevToken: true }))
34
- .plug(emailOtp({ exposeDevOtp: true }));
34
+ .plug(otp({ mode: "app", channels: ["email"], exposeDevOtp: true }));
35
35
  }
36
36
 
37
37
  describe("auth method plugins", () => {
@@ -42,8 +42,9 @@ describe("auth method plugins", () => {
42
42
  expect(app.router.match("POST", "/auth/sign-in/anonymous")).toBeTruthy();
43
43
  expect(app.router.match("POST", "/auth/magic-link/request")).toBeTruthy();
44
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();
45
+ expect(app.router.match("POST", "/auth/otp/request")).toBeTruthy();
46
+ expect(app.router.match("POST", "/auth/otp/verify")).toBeTruthy();
47
+ expect(app.router.match("POST", "/auth/otp/resend")).toBeTruthy();
47
48
  await app.boot({ env: "test" });
48
49
  await app.stop();
49
50
  });
@@ -147,7 +148,7 @@ describe("auth method plugins", () => {
147
148
  await app.boot({ env: "test" });
148
149
 
149
150
  const req = await app.fetch(
150
- new Request("http://localhost/auth/email-otp/request", {
151
+ new Request("http://localhost/auth/otp/request", {
151
152
  method: "POST",
152
153
  headers: { "content-type": "application/json" },
153
154
  body: JSON.stringify({ email: "otp@example.com" }),
@@ -158,7 +159,7 @@ describe("auth method plugins", () => {
158
159
  expect(requested.data.devOtp).toMatch(/^\d{6}$/);
159
160
 
160
161
  const verify = await app.fetch(
161
- new Request("http://localhost/auth/email-otp/verify", {
162
+ new Request("http://localhost/auth/otp/verify", {
162
163
  method: "POST",
163
164
  headers: { "content-type": "application/json" },
164
165
  body: JSON.stringify({
@@ -15,8 +15,6 @@ export {
15
15
  type ConfigSourceOptions,
16
16
  } from "./config-source.ts";
17
17
  export { cors, type CorsOptions } from "./cors.ts";
18
- export { csrf, type CsrfOptions } from "./csrf.ts";
19
- export { emailOtp, emailOtpCatalog, emailOtpTemplate, type EmailOtpOptions } from "./email-otp.ts";
20
18
  export { ipAllowlist, type IpAllowlistOptions } from "./ip-allowlist.ts";
21
19
  export {
22
20
  magicLink,
@@ -25,6 +23,18 @@ export {
25
23
  type MagicLinkOptions,
26
24
  } from "./magic-link.ts";
27
25
  export { maintenanceMode, type MaintenanceModeOptions } from "./maintenance-mode.ts";
26
+ export {
27
+ createPhoneStore,
28
+ otp,
29
+ otpCatalog,
30
+ otpEmailTemplate,
31
+ otpSmsTemplate,
32
+ otpWhatsappTemplate,
33
+ type OtpAppModeOptions,
34
+ type OtpOptions,
35
+ type OtpProviderModeOptions,
36
+ type PhoneStore,
37
+ } from "./otp.ts";
28
38
  export {
29
39
  createPasskeyStore,
30
40
  passkey,
@@ -40,12 +50,6 @@ export {
40
50
  verifyWebAuthnCeremony,
41
51
  type WebAuthnVerifyOptions,
42
52
  } from "./passkey-webauthn.ts";
43
- export {
44
- createPhoneStore,
45
- phoneNumber,
46
- type PhoneNumberOptions,
47
- type PhoneStore,
48
- } from "./phone-number.ts";
49
53
  export {
50
54
  defaultCspDirectives,
51
55
  headers,
@@ -8,9 +8,9 @@
8
8
 
9
9
  import {
10
10
  createIdentityStore,
11
+ ensureUserByEmail,
11
12
  normalizeEmail,
12
13
  type IdentityStore,
13
- type UserIdentityRow,
14
14
  } from "../auth/identity.ts";
15
15
  import { issueSessionWithScopes } from "../auth/sessions.ts";
16
16
  import {
@@ -199,25 +199,3 @@ export function magicLink(opts: MagicLinkOptions = {}): PluginDef {
199
199
  .binding(bindPublicAuth("/magic-link/request", request, "otp"))
200
200
  .binding(bindPublicAuth("/magic-link/verify", verify, "otp"));
201
201
  }
202
-
203
- function ensureUserByEmail(store: IdentityStore, email: string, now: number): UserIdentityRow {
204
- const existingId = store.byEmail.get(email);
205
- if (existingId) {
206
- const existing = store.users.get(existingId);
207
- if (existing) return existing;
208
- }
209
- const id = crypto.randomUUID();
210
- const user: UserIdentityRow = {
211
- id,
212
- email,
213
- name: email.split("@")[0] || "user",
214
- emailVerified: true,
215
- status: "active",
216
- createdAt: now,
217
- updatedAt: now,
218
- extra: {},
219
- };
220
- store.users.set(id, user);
221
- store.byEmail.set(email, id);
222
- return user;
223
- }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * otp() plugin — plug-time fail-loud, app-mode resend / sealed lifetime, provider-mode boot.
3
+ */
4
+
5
+ import { afterEach, describe, expect, test } from "bun:test";
6
+ import {
7
+ createVerificationStore,
8
+ findActiveVerification,
9
+ type VerificationStore,
10
+ } from "../auth/verification.ts";
11
+ import { openConsoleChannel } from "../drivers/channel-console.ts";
12
+ import type { ChannelDriver, SmsOtpTransport, SmsTransport } from "../drivers/channel-types.ts";
13
+ import { oke } from "../kernel/app.ts";
14
+ import { resetFlowSeq } from "../kernel/flow.ts";
15
+ import { resetBindings } from "../kernel/on.ts";
16
+ import { otp } from "./otp.ts";
17
+
18
+ afterEach(() => {
19
+ resetBindings();
20
+ resetFlowSeq();
21
+ });
22
+
23
+ const SECRET = "test-secret-at-least-16";
24
+
25
+ function jsonPost(path: string, body: unknown): Request {
26
+ return new Request(`http://localhost${path}`, {
27
+ method: "POST",
28
+ headers: { "content-type": "application/json" },
29
+ body: JSON.stringify(body),
30
+ });
31
+ }
32
+
33
+ function mockOtpSmsDriver(): ChannelDriver {
34
+ const codes = new Map<string, string>();
35
+ const otpTransport: SmsTransport & SmsOtpTransport = {
36
+ provider: "mock-otp",
37
+ async send() {
38
+ return { messageId: "m", to: "+", status: "ok", response: "ok" };
39
+ },
40
+ async sendOtp(opts) {
41
+ codes.set(opts.requestId, "pending");
42
+ return {
43
+ requestId: opts.requestId,
44
+ to: opts.to,
45
+ code: 5,
46
+ response: "ok",
47
+ provider: "mock-otp",
48
+ };
49
+ },
50
+ async verifyOtp(opts) {
51
+ if (!codes.has(opts.requestId)) throw new Error("unknown request");
52
+ return { ok: true as const, code: 10, message: "ok", response: "ok", provider: "mock-otp" };
53
+ },
54
+ };
55
+ return {
56
+ id: "taqnyat",
57
+ smsTransport: otpTransport,
58
+ channel: {
59
+ provider: "mock-otp",
60
+ mediums: ["sms"],
61
+ async send() {
62
+ return {
63
+ ok: true,
64
+ messageId: "m",
65
+ driverId: "mock-otp",
66
+ attempts: [{ driverId: "mock-otp", ok: true, at: Date.now() }],
67
+ };
68
+ },
69
+ },
70
+ };
71
+ }
72
+
73
+ describe("otp() plug-time fail-loud", () => {
74
+ test("missing mode throws", () => {
75
+ expect(() => otp({} as never)).toThrow(/mode is required/);
76
+ });
77
+
78
+ test('mode "provider" with channels throws', () => {
79
+ expect(() => otp({ mode: "provider", channels: ["sms"] } as never)).toThrow(
80
+ /channels are forbidden/,
81
+ );
82
+ });
83
+
84
+ test('mode "provider" with exposeDevOtp throws', () => {
85
+ expect(() => otp({ mode: "provider", exposeDevOtp: true } as never)).toThrow(
86
+ /exposeDevOtp is forbidden/,
87
+ );
88
+ });
89
+
90
+ test('mode "app" without channels throws', () => {
91
+ expect(() => otp({ mode: "app" } as never)).toThrow(/channels is required/);
92
+ });
93
+ });
94
+
95
+ describe("otp() provider mode boot", () => {
96
+ test("fails loud without Verify-capable SMS driver", async () => {
97
+ const app = oke({
98
+ name: `otp-provider-${crypto.randomUUID()}`,
99
+ env: "test",
100
+ registry: "ignore",
101
+ gate: { auth: { secret: SECRET } },
102
+ }).plug(otp({ mode: "provider" }));
103
+ await expect(app.boot({ env: "test" })).rejects.toThrow(/mode: "provider"/);
104
+ });
105
+
106
+ test("boots when Verify-capable SMS driver is bound", async () => {
107
+ const app = oke({
108
+ name: `otp-provider-ok-${crypto.randomUUID()}`,
109
+ env: "test",
110
+ registry: "ignore",
111
+ gate: { auth: { secret: SECRET } },
112
+ channel: { drivers: [mockOtpSmsDriver()] },
113
+ }).plug(otp({ mode: "provider" }));
114
+ await app.boot({ env: "test" });
115
+ const res = await app.fetch(jsonPost("/auth/otp/request", { phone: "+15551234567" }));
116
+ expect(res.status).toBe(200);
117
+ const body = (await res.json()) as { data: { ok: true; devOtp?: string } };
118
+ expect(body.data.ok).toBe(true);
119
+ expect(body.data.devOtp).toBeUndefined();
120
+ expect(app.router.match("POST", "/auth/otp/resend")).toBeFalsy();
121
+ await app.stop();
122
+ });
123
+ });
124
+
125
+ describe("otp() app mode resend + sealed lifetime", () => {
126
+ test("cross-channel resend keeps same code; cooldown then verify wipes seal", async () => {
127
+ let now = 1_000_000;
128
+ const verifications: VerificationStore = createVerificationStore();
129
+ const app = oke({
130
+ name: `otp-app-${crypto.randomUUID()}`,
131
+ env: "test",
132
+ registry: "ignore",
133
+ gate: { auth: { secret: SECRET } },
134
+ channel: { drivers: [openConsoleChannel()] },
135
+ fx: { now: () => now },
136
+ }).plug(
137
+ otp({
138
+ mode: "app",
139
+ channels: ["sms", "email"],
140
+ exposeDevOtp: true,
141
+ resendCooldownMs: 60_000,
142
+ verifications,
143
+ now: () => now,
144
+ secret: SECRET,
145
+ }),
146
+ );
147
+ await app.boot({ env: "test" });
148
+
149
+ const req = await app.fetch(
150
+ jsonPost("/auth/otp/request", {
151
+ phone: "+15551234567",
152
+ email: "a@example.com",
153
+ }),
154
+ );
155
+ expect(req.status).toBe(200);
156
+ const requested = (await req.json()) as {
157
+ data: { ok: true; devOtp: string; channel: string };
158
+ };
159
+ expect(requested.data.devOtp).toMatch(/^\d{6}$/);
160
+ const code = requested.data.devOtp;
161
+ const expiresAt = findActiveVerification(verifications, "otp:+15551234567", now)?.expiresAt;
162
+ expect(expiresAt).toBeDefined();
163
+ expect(findActiveVerification(verifications, "otp:+15551234567", now)?.sealedOtp).toBeTruthy();
164
+
165
+ const cool = await app.fetch(
166
+ jsonPost("/auth/otp/resend", {
167
+ phone: "+15551234567",
168
+ email: "a@example.com",
169
+ channel: "email",
170
+ }),
171
+ );
172
+ const coolBody = (await cool.json()) as { error?: { data?: { reason?: string } } };
173
+ expect(coolBody.error?.data?.reason).toBe("resend_cooldown");
174
+
175
+ now += 60_001;
176
+ const resent = await app.fetch(
177
+ jsonPost("/auth/otp/resend", {
178
+ phone: "+15551234567",
179
+ email: "a@example.com",
180
+ channel: "email",
181
+ }),
182
+ );
183
+ expect(resent.status).toBe(200);
184
+ const resentBody = (await resent.json()) as { data: { channel: string; devOtp?: string } };
185
+ expect(resentBody.data.channel).toBe("email");
186
+ expect(resentBody.data.devOtp).toBe(code);
187
+ expect(findActiveVerification(verifications, "otp:+15551234567", now)?.expiresAt).toBe(
188
+ expiresAt,
189
+ );
190
+
191
+ const verify = await app.fetch(
192
+ jsonPost("/auth/otp/verify", { phone: "+15551234567", otp: code }),
193
+ );
194
+ expect(verify.status).toBe(200);
195
+
196
+ const row = [...verifications.rows.values()].find((r) => r.identifier === "otp:+15551234567");
197
+ expect(row?.sealedOtp).toBeNull();
198
+ expect(row?.consumedAt).toBeTruthy();
199
+
200
+ await app.stop();
201
+ });
202
+
203
+ test("expired challenge wipes sealed OTP", async () => {
204
+ let now = 1_000_000;
205
+ const verifications = createVerificationStore();
206
+ const app = oke({
207
+ name: `otp-ttl-${crypto.randomUUID()}`,
208
+ env: "test",
209
+ registry: "ignore",
210
+ gate: { auth: { secret: SECRET } },
211
+ fx: { now: () => now },
212
+ }).plug(
213
+ otp({
214
+ mode: "app",
215
+ channels: ["email"],
216
+ exposeDevOtp: true,
217
+ ttlMs: 1_000,
218
+ verifications,
219
+ now: () => now,
220
+ secret: SECRET,
221
+ }),
222
+ );
223
+ await app.boot({ env: "test" });
224
+
225
+ await app.fetch(jsonPost("/auth/otp/request", { email: "ttl@example.com" }));
226
+ const active = findActiveVerification(verifications, "otp:ttl@example.com", now);
227
+ expect(active?.sealedOtp).toBeTruthy();
228
+
229
+ now += 2_000;
230
+ expect(findActiveVerification(verifications, "otp:ttl@example.com", now)).toBeUndefined();
231
+ const row = [...verifications.rows.values()].find(
232
+ (r) => r.identifier === "otp:ttl@example.com",
233
+ );
234
+ expect(row?.sealedOtp).toBeNull();
235
+
236
+ await app.stop();
237
+ });
238
+ });