okengine 0.7.0 → 0.9.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 (125) 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 +25 -12
  9. package/site/content/docs/elements/clock.mdx +17 -15
  10. package/site/content/docs/elements/flow.mdx +6 -2
  11. package/site/content/docs/elements/signal.mdx +10 -8
  12. package/site/content/docs/elements/store.mdx +165 -0
  13. package/site/content/docs/get-started/index.mdx +5 -0
  14. package/site/content/docs/get-started/installation.mdx +18 -16
  15. package/site/content/docs/index.mdx +5 -0
  16. package/site/content/docs/meta.json +10 -1
  17. package/site/content/docs/plugins/index.mdx +1 -2
  18. package/site/content/docs/plugins/magic-link.mdx +44 -2
  19. package/site/content/docs/plugins/meta.json +1 -2
  20. package/site/content/docs/plugins/otp.mdx +202 -0
  21. package/site/content/docs/plugins/two-factor.mdx +2 -1
  22. package/site/content/docs/reference/cli.md +5 -2
  23. package/site/content/docs/reference/configuration.mdx +21 -3
  24. package/site/content/docs/reference/environment-variables.mdx +21 -8
  25. package/site/content/docs/reference/plugins.mdx +1 -1
  26. package/src/auth/auth.test.ts +36 -0
  27. package/src/auth/bindings.ts +3 -12
  28. package/src/auth/identity.ts +33 -0
  29. package/src/auth/index.ts +5 -0
  30. package/src/auth/otp-capability.ts +119 -0
  31. package/src/auth/otp-seal.test.ts +61 -0
  32. package/src/auth/otp-seal.ts +84 -0
  33. package/src/auth/schema.ts +3 -0
  34. package/src/auth/sessions.ts +26 -27
  35. package/src/auth/tables.ts +4 -0
  36. package/src/auth/verification.ts +61 -1
  37. package/src/cli/db-seed.ts +359 -0
  38. package/src/cli/db.test.ts +341 -3
  39. package/src/cli/db.ts +75 -8
  40. package/src/cli/dev-app-runner.ts +4 -0
  41. package/src/cli/docker.ts +4 -1
  42. package/src/cli/load-config.images.test.ts +26 -0
  43. package/src/cli/load-config.ts +10 -2
  44. package/src/cli/registry.ts +38 -2
  45. package/src/compiler/effects-infer.ts +1 -0
  46. package/src/config/index.ts +4 -0
  47. package/src/console/server/operator-db.ts +34 -9
  48. package/src/docker/compose.ts +162 -6
  49. package/src/docker/derive.ts +60 -3
  50. package/src/docker/docker.test.ts +374 -1
  51. package/src/docker/helpers.ts +2 -0
  52. package/src/docker/index.ts +11 -0
  53. package/src/docker/recipes/caddy.ts +51 -0
  54. package/src/docker/recipes/dragonfly.ts +31 -0
  55. package/src/docker/recipes/index.ts +25 -2
  56. package/src/docker/recipes/pgdog.ts +84 -0
  57. package/src/docker/recipes/redis.ts +6 -3
  58. package/src/docker/recipes/traefik.ts +83 -0
  59. package/src/docker/recipes/valkey.ts +30 -0
  60. package/src/docker/stack-id.ts +5 -0
  61. package/src/docker/types.ts +18 -0
  62. package/src/drivers/channel-sently.test.ts +8 -0
  63. package/src/drivers/channel-taqnyat-mail.ts +34 -0
  64. package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
  65. package/src/drivers/channel-types.ts +72 -0
  66. package/src/drivers/clock-postgres.test.ts +258 -0
  67. package/src/drivers/clock-postgres.ts +410 -0
  68. package/src/drivers/index.ts +18 -0
  69. package/src/drivers/journal-postgres.test.ts +175 -0
  70. package/src/drivers/journal-postgres.ts +492 -0
  71. package/src/elements/channel/otp-delivery.test.ts +76 -0
  72. package/src/elements/channel/otp-delivery.ts +291 -0
  73. package/src/elements/channel/runtime.ts +203 -114
  74. package/src/elements/channel.test.ts +71 -0
  75. package/src/elements/channel.ts +12 -2
  76. package/src/elements/clock/chaos-child.ts +280 -41
  77. package/src/elements/clock/durable.ts +7 -0
  78. package/src/elements/clock/reconcile.ts +2 -2
  79. package/src/elements/clock/runtime.ts +5 -3
  80. package/src/elements/clock.ts +1 -1
  81. package/src/elements/store/seed.test.ts +27 -0
  82. package/src/elements/store/seed.ts +68 -0
  83. package/src/elements/store/sql-session.test.ts +39 -0
  84. package/src/elements/store/sql-session.ts +55 -0
  85. package/src/elements/store/upsert-app.test.ts +103 -0
  86. package/src/elements/store.ts +5 -0
  87. package/src/index.ts +18 -0
  88. package/src/kernel/app.ts +221 -14
  89. package/src/kernel/boot-bind/channel.test.ts +16 -0
  90. package/src/kernel/boot-bind/channel.ts +64 -0
  91. package/src/kernel/boot-bind/clock.ts +17 -6
  92. package/src/kernel/boot-bind/gate.ts +14 -19
  93. package/src/kernel/boot-bind/honor-config.test.ts +123 -4
  94. package/src/kernel/boot-bind/journal.ts +89 -0
  95. package/src/kernel/boot-bind/signal.ts +20 -0
  96. package/src/kernel/boot-bind/store.test.ts +82 -0
  97. package/src/kernel/boot-bind/store.ts +22 -0
  98. package/src/kernel/boot.test.ts +6 -4
  99. package/src/kernel/boot.ts +53 -13
  100. package/src/kernel/concurrency.ts +1 -1
  101. package/src/kernel/fx.test.ts +9 -0
  102. package/src/kernel/fx.ts +175 -5
  103. package/src/kernel/graceful-shutdown.test.ts +76 -0
  104. package/src/kernel/graceful-shutdown.ts +106 -0
  105. package/src/kernel/horizontal-child.ts +257 -0
  106. package/src/kernel/horizontal.integration.test.ts +229 -0
  107. package/src/kernel/index.ts +14 -0
  108. package/src/kernel/journal-boot.test.ts +397 -0
  109. package/src/kernel/journal-suspend.ts +35 -0
  110. package/src/kernel/journal.test.ts +142 -0
  111. package/src/kernel/journal.ts +202 -27
  112. package/src/kernel/ready.test.ts +76 -0
  113. package/src/plugins/auth-delivery.mailpit.integration.test.ts +5 -5
  114. package/src/plugins/auth-methods.security.test.ts +20 -27
  115. package/src/plugins/auth-methods.test.ts +7 -6
  116. package/src/plugins/index.ts +12 -8
  117. package/src/plugins/magic-link.ts +1 -23
  118. package/src/plugins/otp.test.ts +236 -0
  119. package/src/plugins/otp.ts +570 -0
  120. package/src/plugins/taqnyat.live.test.ts +172 -0
  121. package/src/release/official-plugins.ts +1 -2
  122. package/site/content/docs/plugins/email-otp.mdx +0 -117
  123. package/site/content/docs/plugins/phone-number.mdx +0 -111
  124. package/src/plugins/email-otp.ts +0 -214
  125. package/src/plugins/phone-number.ts +0 -149
@@ -9,6 +9,9 @@
9
9
 
10
10
  import { mkdir } from "node:fs/promises";
11
11
  import { dirname } from "node:path";
12
+ import { JournalSuspend } from "./journal-suspend.ts";
13
+
14
+ export { JournalSuspend, isJournalSuspend } from "./journal-suspend.ts";
12
15
 
13
16
  /** Status of a durable run. */
14
17
  export type JournalRunStatus = "running" | "sleeping" | "completed" | "failed";
@@ -53,32 +56,58 @@ export interface JournalRun {
53
56
  wakeAt?: number;
54
57
  error?: string;
55
58
  output?: unknown;
59
+ /** Lease holder instance id (run-level coordination — Signal/Clock physics). */
60
+ lockedBy?: string;
61
+ /** Lease expiry epoch-ms; a crashed holder's run is reclaimable after this. */
62
+ leaseExpiresAt?: number;
56
63
  readonly createdAt: number;
57
64
  updatedAt: number;
58
65
  }
59
66
 
60
67
  /**
61
- * Thrown inside a durable body when a sleep has not yet elapsed.
62
- * The runner catches this and parks the run as `sleeping`.
68
+ * Run-level lease coordination same SKIP LOCKED + lazy-reclaim physics as
69
+ * Signal's message claims and Clock's tick claims. No sweeper, no fencing
70
+ * token: at-least-once after lease expiry, journal replay keeps completed
71
+ * steps from re-running.
63
72
  */
64
- export class JournalSuspend extends Error {
65
- readonly wakeAt: number;
66
- readonly label: string;
67
-
73
+ export interface JournalLeaseStore {
68
74
  /**
69
- * @param label - Sleep label
70
- * @param wakeAt - Absolute wake epoch-ms
75
+ * Acquire / renew / reclaim a run lease. Claimable when unlocked, held by
76
+ * the same instance, or expired.
77
+ *
78
+ * @param runId - Run id
79
+ * @param instanceId - Claimant instance
80
+ * @param now - Epoch-ms
81
+ * @param leaseMs - Lease duration
71
82
  */
72
- constructor(label: string, wakeAt: number) {
73
- super(`journal suspend: sleep "${label}" until ${wakeAt}`);
74
- this.name = "JournalSuspend";
75
- this.label = label;
76
- this.wakeAt = wakeAt;
77
- }
83
+ acquireLease(runId: string, instanceId: string, now: number, leaseMs: number): Promise<boolean>;
84
+ /**
85
+ * Release a lease held by `instanceId` (no-op for other holders).
86
+ *
87
+ * @param runId - Run id
88
+ * @param instanceId - Holder instance
89
+ */
90
+ releaseLease(runId: string, instanceId: string): Promise<void>;
91
+ /**
92
+ * Atomically claim the next due sleep (`status=sleeping`, `wakeAt<=now`, no
93
+ * live lease) and return it — `undefined` when none is claimable.
94
+ *
95
+ * @param instanceId - Claimant instance
96
+ * @param now - Epoch-ms
97
+ * @param leaseMs - Lease duration
98
+ */
99
+ claimDueSleep(instanceId: string, now: number, leaseMs: number): Promise<JournalRun | undefined>;
100
+ /**
101
+ * Boot-time orphan discovery: `running` / `sleeping` runs with no live lease
102
+ * (crashed holder or never claimed). Rows are never deleted.
103
+ *
104
+ * @param now - Epoch-ms
105
+ */
106
+ listOrphans(now: number): Promise<readonly JournalRun[]>;
78
107
  }
79
108
 
80
109
  /** Persistence backend for journal runs. */
81
- export interface JournalStore {
110
+ export interface JournalStore extends Partial<JournalLeaseStore> {
82
111
  /**
83
112
  * Load a run by id.
84
113
  *
@@ -95,12 +124,107 @@ export interface JournalStore {
95
124
  list(): Promise<readonly JournalRun[]>;
96
125
  }
97
126
 
127
+ /** Default run lease — matches Signal's claim lease. */
128
+ export const JOURNAL_DEFAULT_LEASE_MS = 30_000;
129
+
130
+ /**
131
+ * Narrow a store to its lease-coordination surface (present on the built-in
132
+ * memory / file / postgres stores; absent on custom minimal stores).
133
+ *
134
+ * @param store - Journal store
135
+ */
136
+ export function hasJournalLease(store: JournalStore): store is JournalStore & JournalLeaseStore {
137
+ return (
138
+ typeof store.acquireLease === "function" &&
139
+ typeof store.releaseLease === "function" &&
140
+ typeof store.claimDueSleep === "function" &&
141
+ typeof store.listOrphans === "function"
142
+ );
143
+ }
144
+
145
+ /** Thrown when a run resume loses the lease race to another live instance. */
146
+ export class JournalLeaseBusy extends Error {
147
+ readonly runId: string;
148
+ constructor(runId: string) {
149
+ super(`journal: run "${runId}" is leased by another instance`);
150
+ this.name = "JournalLeaseBusy";
151
+ this.runId = runId;
152
+ }
153
+ }
154
+
155
+ /** Type guard for {@link JournalLeaseBusy}. */
156
+ export function isJournalLeaseBusy(err: unknown): err is JournalLeaseBusy {
157
+ return err instanceof JournalLeaseBusy;
158
+ }
159
+
160
+ /** Live lease = a holder with an unexpired expiry. */
161
+ function hasLiveLease(run: JournalRun, now: number): boolean {
162
+ return run.lockedBy !== undefined && run.leaseExpiresAt !== undefined && run.leaseExpiresAt > now;
163
+ }
164
+
165
+ /** Claimable when unlocked, same-holder, or without a live lease. */
166
+ function claimable(run: JournalRun, instanceId: string, now: number): boolean {
167
+ if (run.lockedBy === undefined) return true;
168
+ if (run.lockedBy === instanceId) return true;
169
+ return !hasLiveLease(run, now);
170
+ }
171
+
172
+ /** Lease methods shared by the memory + file stores (single-writer maps). */
173
+ function leaseMethods(
174
+ load: () => Promise<Map<string, JournalRun>>,
175
+ flush?: (map: Map<string, JournalRun>) => Promise<void>,
176
+ ): JournalLeaseStore {
177
+ return {
178
+ async acquireLease(runId, instanceId, now, leaseMs) {
179
+ const map = await load();
180
+ const run = map.get(runId);
181
+ if (!run || !claimable(run, instanceId, now)) return false;
182
+ run.lockedBy = instanceId;
183
+ run.leaseExpiresAt = now + leaseMs;
184
+ await flush?.(map);
185
+ return true;
186
+ },
187
+ async releaseLease(runId, instanceId) {
188
+ const map = await load();
189
+ const run = map.get(runId);
190
+ if (!run || run.lockedBy !== instanceId) return;
191
+ delete run.lockedBy;
192
+ delete run.leaseExpiresAt;
193
+ await flush?.(map);
194
+ },
195
+ async claimDueSleep(instanceId, now, leaseMs) {
196
+ const map = await load();
197
+ const due = [...map.values()]
198
+ .filter(
199
+ (r) =>
200
+ r.status === "sleeping" &&
201
+ r.wakeAt !== undefined &&
202
+ r.wakeAt <= now &&
203
+ claimable(r, instanceId, now),
204
+ )
205
+ .sort((a, b) => (a.wakeAt ?? 0) - (b.wakeAt ?? 0))[0];
206
+ if (!due) return undefined;
207
+ due.lockedBy = instanceId;
208
+ due.leaseExpiresAt = now + leaseMs;
209
+ await flush?.(map);
210
+ return cloneRun(due);
211
+ },
212
+ async listOrphans(now) {
213
+ const map = await load();
214
+ return [...map.values()]
215
+ .filter((r) => (r.status === "running" || r.status === "sleeping") && !hasLiveLease(r, now))
216
+ .map(cloneRun);
217
+ },
218
+ };
219
+ }
220
+
98
221
  /** In-memory journal store. */
99
222
  export function createMemoryJournalStore(seed?: readonly JournalRun[]): JournalStore {
100
223
  const runs = new Map<string, JournalRun>();
101
224
  for (const r of seed ?? []) {
102
225
  runs.set(r.id, cloneRun(r));
103
226
  }
227
+ const load = async (): Promise<Map<string, JournalRun>> => runs;
104
228
  return {
105
229
  async get(runId) {
106
230
  const r = runs.get(runId);
@@ -112,6 +236,7 @@ export function createMemoryJournalStore(seed?: readonly JournalRun[]): JournalS
112
236
  async list() {
113
237
  return [...runs.values()].map(cloneRun);
114
238
  },
239
+ ...leaseMethods(load),
115
240
  };
116
241
  }
117
242
 
@@ -156,9 +281,19 @@ export function createFileJournalStore(path: string): JournalStore {
156
281
  const map = await load();
157
282
  return [...map.values()].map(cloneRun);
158
283
  },
284
+ // Single-host file: leases coordinate same-machine processes only.
285
+ ...leaseMethods(load, flush),
159
286
  };
160
287
  }
161
288
 
289
+ /** Run-level lease holder for {@link CreateJournalOptions.lease}. */
290
+ export interface JournalLeaseOptions {
291
+ /** This instance's id (lease holder). */
292
+ readonly instanceId: string;
293
+ /** Lease duration ms (default {@link JOURNAL_DEFAULT_LEASE_MS}). */
294
+ readonly leaseMs?: number;
295
+ }
296
+
162
297
  /** Options for {@link createJournal}. */
163
298
  export interface CreateJournalOptions {
164
299
  /** Persistence backend. */
@@ -167,6 +302,13 @@ export interface CreateJournalOptions {
167
302
  readonly now?: () => number;
168
303
  /** Id factory (defaults to UUID). */
169
304
  readonly id?: () => string;
305
+ /**
306
+ * Run-level lease (when the store supports it). `start` inserts with the
307
+ * lease held; `resume` claims the run or throws {@link JournalLeaseBusy};
308
+ * every persist renews; parking a sleep and terminal commits release so a
309
+ * sleeping/finished run never holds a 30s lock.
310
+ */
311
+ readonly lease?: JournalLeaseOptions;
170
312
  }
171
313
 
172
314
  /**
@@ -240,16 +382,31 @@ export interface Journal {
240
382
  export function createJournal(options: CreateJournalOptions): Journal {
241
383
  const now = options.now ?? (() => Date.now());
242
384
  const newId = options.id ?? (() => crypto.randomUUID());
385
+ const lease = options.lease;
386
+ const coordinated = lease !== undefined && hasJournalLease(options.store);
243
387
 
244
- function openSession(run: JournalRun): JournalSession {
388
+ function openSession(run: JournalRun, leased: boolean): JournalSession {
245
389
  /** Next entry index to consume on replay. */
246
390
  let cursor = 0;
391
+ let leaseHeld = leased;
247
392
 
248
393
  async function persist(): Promise<void> {
249
394
  run.updatedAt = now();
395
+ // Natural heartbeat — a live holder renews on every journal write.
396
+ if (leaseHeld && lease) {
397
+ run.lockedBy = lease.instanceId;
398
+ run.leaseExpiresAt = now() + (lease.leaseMs ?? JOURNAL_DEFAULT_LEASE_MS);
399
+ }
250
400
  await options.store.put(cloneRun(run));
251
401
  }
252
402
 
403
+ /** Parking / terminal states must not hold a short lease across days. */
404
+ function releaseLeaseLocally(): void {
405
+ leaseHeld = false;
406
+ delete run.lockedBy;
407
+ delete run.leaseExpiresAt;
408
+ }
409
+
253
410
  return {
254
411
  runId: run.id,
255
412
  run,
@@ -282,6 +439,7 @@ export function createJournal(options: CreateJournalOptions): Journal {
282
439
  if (now() < e.wakeAt) {
283
440
  run.status = "sleeping";
284
441
  run.wakeAt = e.wakeAt;
442
+ releaseLeaseLocally();
285
443
  await persist();
286
444
  throw new JournalSuspend(label, e.wakeAt);
287
445
  }
@@ -301,6 +459,7 @@ export function createJournal(options: CreateJournalOptions): Journal {
301
459
  if (now() < wakeAt) {
302
460
  run.status = "sleeping";
303
461
  run.wakeAt = wakeAt;
462
+ releaseLeaseLocally();
304
463
  await persist();
305
464
  throw new JournalSuspend(label, wakeAt);
306
465
  }
@@ -341,6 +500,7 @@ export function createJournal(options: CreateJournalOptions): Journal {
341
500
  if (patch?.error !== undefined) run.error = patch.error;
342
501
  if (status === "completed" || status === "failed") {
343
502
  delete run.wakeAt;
503
+ releaseLeaseLocally();
344
504
  }
345
505
  await persist();
346
506
  },
@@ -360,31 +520,46 @@ export function createJournal(options: CreateJournalOptions): Journal {
360
520
  createdAt: t,
361
521
  updatedAt: t,
362
522
  };
523
+ if (coordinated && lease) {
524
+ // Fresh id — insert already holding the lease (no claim race).
525
+ run.lockedBy = lease.instanceId;
526
+ run.leaseExpiresAt = t + (lease.leaseMs ?? JOURNAL_DEFAULT_LEASE_MS);
527
+ }
363
528
  await options.store.put(cloneRun(run));
364
- return openSession(run);
529
+ return openSession(run, coordinated);
365
530
  },
366
531
  async resume(runId) {
532
+ if (coordinated && lease) {
533
+ const t = now();
534
+ const claimed = await options.store.acquireLease!(
535
+ runId,
536
+ lease.instanceId,
537
+ t,
538
+ lease.leaseMs ?? JOURNAL_DEFAULT_LEASE_MS,
539
+ );
540
+ if (!claimed) {
541
+ throw new JournalLeaseBusy(runId);
542
+ }
543
+ }
367
544
  const run = await options.store.get(runId);
368
545
  if (!run) {
546
+ if (coordinated && lease) {
547
+ await options.store.releaseLease!(runId, lease.instanceId);
548
+ }
369
549
  throw new Error(`journal: run "${runId}" not found`);
370
550
  }
371
551
  // Leave status intact — the durable runner parks or continues.
372
552
  run.updatedAt = now();
553
+ if (coordinated && lease) {
554
+ run.lockedBy = lease.instanceId;
555
+ run.leaseExpiresAt = now() + (lease.leaseMs ?? JOURNAL_DEFAULT_LEASE_MS);
556
+ }
373
557
  await options.store.put(cloneRun(run));
374
- return openSession(run);
558
+ return openSession(run, coordinated);
375
559
  },
376
560
  };
377
561
  }
378
562
 
379
- /**
380
- * True when `err` is a {@link JournalSuspend}.
381
- *
382
- * @param err - Unknown
383
- */
384
- export function isJournalSuspend(err: unknown): err is JournalSuspend {
385
- return err instanceof JournalSuspend;
386
- }
387
-
388
563
  function cloneRun(run: JournalRun): JournalRun {
389
564
  return structuredClone(run);
390
565
  }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Kernel readiness endpoint — boot vs orphan_scan vs ready.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+
7
+ import { oke } from "./app.ts";
8
+ import { flow, type AnyFlowDef } from "./flow.ts";
9
+ import { createMemoryJournalStore, hasJournalLease } from "./journal.ts";
10
+ import type { Binding } from "./on.ts";
11
+ import { http } from "./triggers.ts";
12
+ import { gate } from "../elements/gate.ts";
13
+
14
+ describe("GET /_/ready", () => {
15
+ test("returns 503 booting before boot, 200 ready after boot without journal", async () => {
16
+ const app = oke({
17
+ name: "ready-plain",
18
+ autoBoot: false,
19
+ startScheduler: false,
20
+ });
21
+ const before = await app.fetch(new Request("http://127.0.0.1/_/ready"));
22
+ expect(before.status).toBe(503);
23
+ expect(await before.json()).toEqual({ ready: false, reason: "booting" });
24
+
25
+ await app.boot();
26
+ expect(app.readyState).toBe("ready");
27
+ const after = await app.fetch(new Request("http://127.0.0.1/_/ready"));
28
+ expect(after.status).toBe(200);
29
+ expect(await after.json()).toEqual({ ready: true });
30
+ await app.stop();
31
+ });
32
+
33
+ test("orphan_scan then ready when durable journal is bound", async () => {
34
+ const journalStore = createMemoryJournalStore();
35
+ expect(hasJournalLease(journalStore)).toBe(true);
36
+ const charge = flow({
37
+ name: "charge",
38
+ durable: true,
39
+ do: () => ({ ok: true }),
40
+ });
41
+ const bindings: Binding[] = [
42
+ {
43
+ trigger: http.post("/charge").gate(gate.public),
44
+ flow: charge as AnyFlowDef,
45
+ },
46
+ ];
47
+ const app = oke({
48
+ name: "ready-journal",
49
+ autoBoot: false,
50
+ startScheduler: false,
51
+ env: "test",
52
+ gate: { unguardedHttp: "allow" },
53
+ bindings,
54
+ elements: {
55
+ journal: {
56
+ store: journalStore,
57
+ instanceId: "ready-j",
58
+ leaseMs: 30_000,
59
+ driverId: "memory",
60
+ },
61
+ },
62
+ });
63
+ await app.boot();
64
+ // Memory journal orphan scan is fast — may already be ready; accept either
65
+ // intermediate orphan_scan or ready, but never stay booting after boot().
66
+ expect(["orphan_scan", "ready"]).toContain(app.readyState);
67
+ const deadline = Date.now() + 2_000;
68
+ while (app.readyState !== "ready" && Date.now() < deadline) {
69
+ await Bun.sleep(5);
70
+ }
71
+ expect(app.readyState).toBe("ready");
72
+ const res = await app.fetch(new Request("http://127.0.0.1/_/ready"));
73
+ expect(res.status).toBe(200);
74
+ await app.stop();
75
+ });
76
+ });
@@ -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({ tier: 2, 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({ tier: 2, 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 remains unwired; exposeDev* stays off by default", async () => {
421
+ test("magic uses fx.send; otp Tier 2 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({ tier: 2, 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,27 +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
- const phone = await app.fetch(jsonPost("/auth/phone/request", { phone: "+15551112222" }));
444
+ const phone = await app.fetch(jsonPost("/auth/otp/request", { phone: "+15551112222" }));
449
445
  const phoneBody = (await phone.json()) as { data: Record<string, unknown> };
450
446
  expect(phoneBody.data.ok).toBe(true);
451
447
  expect(phoneBody.data.devOtp).toBeUndefined();
452
448
 
453
449
  const magicSrc = await Bun.file(new URL("./magic-link.ts", import.meta.url)).text();
454
- const emailSrc = await Bun.file(new URL("./email-otp.ts", import.meta.url)).text();
455
- const phoneSrc = await Bun.file(new URL("./phone-number.ts", import.meta.url)).text();
456
- expect(magicSrc).toMatch(/fx\.send/);
450
+ const otpSrc = await Bun.file(new URL("./otp.ts", import.meta.url)).text();
451
+ expect(magicSrc).toMatch(/fx\.send\(/);
457
452
  expect(magicSrc).toMatch(/channel\./);
458
- expect(emailSrc).toMatch(/fx\.send/);
459
- expect(emailSrc).toMatch(/channel\./);
460
- // SMS delivery stays deferred — phone must not import Channel or fx.send.
461
- expect(phoneSrc).not.toMatch(/fx\.send/);
462
- expect(phoneSrc).not.toMatch(/channel\./);
463
- expect(phoneSrc).not.toMatch(/from ["'].*channel/);
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/);
464
457
 
465
458
  await app.stop();
466
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({ tier: 2, 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 OtpOptions,
34
+ type OtpTier1Options,
35
+ type OtpTier2Options,
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,