okengine 0.8.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 (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 +3 -1
  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 +202 -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 +236 -0
  83. package/src/plugins/otp.ts +570 -0
  84. package/src/plugins/taqnyat.live.test.ts +4 -6
  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
package/src/kernel/app.ts CHANGED
@@ -78,6 +78,7 @@ import {
78
78
  type JournalSession,
79
79
  type JournalStore,
80
80
  } from "./journal.ts";
81
+ import { releaseInstanceLeases } from "./graceful-shutdown.ts";
81
82
  import type { JournalRuntime } from "./boot-bind/journal.ts";
82
83
  import { listBindings, resetBindings, type Binding } from "./on.ts";
83
84
  import {
@@ -199,6 +200,9 @@ export interface OkeOptions {
199
200
  readonly journalLeaseMs?: number;
200
201
  }
201
202
 
203
+ /** Readiness probe state — see `GET /_/ready`. */
204
+ export type ReadyState = "booting" | "orphan_scan" | "ready";
205
+
202
206
  /** Payload for a CDC invocation. */
203
207
  export interface CdcPayload {
204
208
  readonly before: Record<string, unknown> | null;
@@ -257,6 +261,11 @@ export interface OkeApp<D extends Record<string, unknown> = {}, R extends AppRou
257
261
  readonly plugins: PluginRegistry;
258
262
  /** Whether {@link OkeApp.boot} has completed. */
259
263
  readonly booted: boolean;
264
+ /**
265
+ * Readiness for probes — `booting` until boot returns, `orphan_scan` while
266
+ * the durable orphan resume runs, then `ready`. See `GET /_/ready`.
267
+ */
268
+ readonly readyState: ReadyState;
260
269
  /** Result of {@link OkeApp.boot} (element runtimes, capabilities). */
261
270
  readonly bootResult: BootResult | undefined;
262
271
  /**
@@ -565,6 +574,7 @@ export function oke(options: OkeOptions): OkeApp {
565
574
  // --- boot (vault → store → signal → clock → channel → AI → runs → caps) ---
566
575
  let bootResult: BootResult | undefined;
567
576
  let bootPromise: Promise<BootResult> | undefined;
577
+ let readyState: ReadyState = "booting";
568
578
  let bootEnv: BootOptions["env"] = options.env ?? "local";
569
579
  let authBinding: AppAuthBinding | undefined =
570
580
  gateConfig.auth !== undefined
@@ -834,13 +844,29 @@ export function oke(options: OkeOptions): OkeApp {
834
844
  };
835
845
  const result = await bootApplication(merged);
836
846
  bootResult = result;
847
+ // otp() Tier 1 / Tier 2 capability — fail loud at boot, never silent downgrade.
848
+ if (result.channel) {
849
+ const { assertOtpPluginCapability } = await import("../auth/otp-capability.ts");
850
+ for (const entry of pluginRegistry.installed) {
851
+ if (entry.plugin.name === "otp") {
852
+ assertOtpPluginCapability(entry.plugin.configSnapshot, result.channel.drivers);
853
+ }
854
+ }
855
+ }
837
856
  // Boot-time orphan discovery: resume/schedule any `running` / `sleeping`
838
857
  // run left without a live lease by a crashed (or previous) instance.
839
- // Fire-and-forget boot must not block serving on a long resume.
858
+ // Does not block boot return readiness stays `orphan_scan` until done.
840
859
  if (result.journal && hasJournalLease(result.journal.store)) {
841
- void resumeOrphanedDurableRuns().catch((err) => {
842
- console.error("oke: durable orphan scan failed", err);
843
- });
860
+ readyState = "orphan_scan";
861
+ void resumeOrphanedDurableRuns()
862
+ .catch((err) => {
863
+ console.error("oke: durable orphan scan failed", err);
864
+ })
865
+ .finally(() => {
866
+ if (bootResult === result) readyState = "ready";
867
+ });
868
+ } else {
869
+ readyState = "ready";
844
870
  }
845
871
  // Prefer the booted clock for access-token expiry checks.
846
872
  if (authBinding) {
@@ -1196,6 +1222,9 @@ export function oke(options: OkeOptions): OkeApp {
1196
1222
  get booted() {
1197
1223
  return bootResult !== undefined;
1198
1224
  },
1225
+ get readyState() {
1226
+ return readyState;
1227
+ },
1199
1228
  get bootResult() {
1200
1229
  return bootResult;
1201
1230
  },
@@ -1227,9 +1256,18 @@ export function oke(options: OkeOptions): OkeApp {
1227
1256
  async stop() {
1228
1257
  if (!bootResult) return;
1229
1258
  bootResult.stopScheduler();
1259
+ // Proactive lease release so survivors need not wait for TTL reclaim.
1260
+ await releaseInstanceLeases({
1261
+ bootResult: {
1262
+ clock: bootResult.clock,
1263
+ journal: bootResult.journal,
1264
+ },
1265
+ stop: async () => {},
1266
+ });
1230
1267
  await bootResult.close();
1231
1268
  bootResult = undefined;
1232
1269
  bootPromise = undefined;
1270
+ readyState = "booting";
1233
1271
  },
1234
1272
  get authBinding() {
1235
1273
  return authBinding;
@@ -1347,6 +1385,24 @@ export function oke(options: OkeOptions): OkeApp {
1347
1385
  return response;
1348
1386
  };
1349
1387
 
1388
+ // Kernel readiness — distinct from app-authored GET /health (liveness).
1389
+ if (method === "GET" && url.pathname === "/_/ready") {
1390
+ if (readyState === "ready") {
1391
+ return respond(
1392
+ new Response(JSON.stringify({ ready: true }), {
1393
+ status: 200,
1394
+ headers: { "content-type": "application/json" },
1395
+ }),
1396
+ );
1397
+ }
1398
+ return respond(
1399
+ new Response(JSON.stringify({ ready: false, reason: readyState }), {
1400
+ status: 503,
1401
+ headers: { "content-type": "application/json" },
1402
+ }),
1403
+ );
1404
+ }
1405
+
1350
1406
  if (method === "GET" && url.pathname === "/_oke/client.json") {
1351
1407
  return respond(
1352
1408
  new Response(JSON.stringify(routes), {
@@ -10,7 +10,9 @@ import { openSmtpChannel } from "../../drivers/channel-smtp.ts";
10
10
  import { openSndrChannel } from "../../drivers/channel-sndr.ts";
11
11
  import { openTaqnyatChannel } from "../../drivers/channel-taqnyat.ts";
12
12
  import { openTaqnyatMailChannel } from "../../drivers/channel-taqnyat-mail.ts";
13
+ import { openTaqnyatWhatsAppChannel } from "../../drivers/channel-taqnyat-whatsapp.ts";
13
14
  import { openUnifonicChannel } from "../../drivers/channel-unifonic.ts";
15
+ import { openWaCloudChannel } from "../../drivers/channel-wa-cloud.ts";
14
16
  import type { ChannelDriver, ChannelOpenOptions } from "../../drivers/channel-types.ts";
15
17
  import { createChannelRuntime, type ChannelRuntime } from "../../elements/channel.ts";
16
18
  import type { BootOptions } from "../boot.ts";
@@ -43,6 +45,8 @@ function defaultDrivers(options: BootOptions, env: ConfigEnv, docker: boolean):
43
45
  const drivers: ChannelDriver[] = [emailDriverFor(options, env, docker)];
44
46
  const sms = smsDriverFor(options, env);
45
47
  if (sms) drivers.push(sms);
48
+ const whatsapp = whatsappDriverFor(options, env);
49
+ if (whatsapp) drivers.push(whatsapp);
46
50
  return drivers;
47
51
  }
48
52
 
@@ -92,6 +96,53 @@ export function resolveSmsDriverId(options: BootOptions, env: ConfigEnv): string
92
96
  return resolveDriverId(options.config?.drivers?.channel?.sms, env);
93
97
  }
94
98
 
99
+ /**
100
+ * Resolve the configured WhatsApp driver for one environment.
101
+ *
102
+ * @param options - Boot options
103
+ * @param env - Active environment
104
+ */
105
+ export function resolveWhatsappDriverId(options: BootOptions, env: ConfigEnv): string | undefined {
106
+ return resolveDriverId(options.config?.drivers?.channel?.whatsapp, env);
107
+ }
108
+
109
+ function whatsappDriverFor(options: BootOptions, env: ConfigEnv): ChannelDriver | undefined {
110
+ const id = resolveWhatsappDriverId(options, env);
111
+ if (!id || id === "console") return undefined;
112
+ if (id === "taqnyat-whatsapp") return openTaqnyatWhatsAppChannel(taqnyatWhatsAppOptionsFromEnv());
113
+ if (id === "wa-cloud") return openWaCloudChannel(waCloudOptionsFromEnv());
114
+ throw new Error(`oke boot: unknown whatsapp channel driver "${id}"`);
115
+ }
116
+
117
+ /**
118
+ * Resolve Taqnyat WhatsApp options from env.
119
+ */
120
+ export function taqnyatWhatsAppOptionsFromEnv(): ChannelOpenOptions {
121
+ const bearerToken =
122
+ process.env.TAQNYAT_WHATSAPP_TOKEN?.trim() ??
123
+ process.env.TAQNYAT_BEARER_TOKEN?.trim() ??
124
+ process.env.TAQNYAT_TOKEN?.trim();
125
+ if (!bearerToken) {
126
+ throw new Error(
127
+ "oke boot: taqnyat-whatsapp channel needs TAQNYAT_WHATSAPP_TOKEN (or TAQNYAT_BEARER_TOKEN)",
128
+ );
129
+ }
130
+ return { bearerToken };
131
+ }
132
+
133
+ /**
134
+ * Resolve WhatsApp Cloud API options from env.
135
+ */
136
+ export function waCloudOptionsFromEnv(): ChannelOpenOptions {
137
+ const token = process.env.WHATSAPP_TOKEN?.trim() ?? process.env.WA_CLOUD_TOKEN?.trim();
138
+ const from =
139
+ process.env.WHATSAPP_PHONE_NUMBER_ID?.trim() ?? process.env.WA_CLOUD_PHONE_NUMBER_ID?.trim();
140
+ if (!token || !from) {
141
+ throw new Error("oke boot: wa-cloud channel needs WHATSAPP_TOKEN and WHATSAPP_PHONE_NUMBER_ID");
142
+ }
143
+ return { token, from };
144
+ }
145
+
95
146
  /**
96
147
  * Resolve SMTP connection options from `SMTP_URL` plus optional auth overrides.
97
148
  *
@@ -33,15 +33,14 @@ export function resolveGateKvDriverId(
33
33
  return docker ? "redis" : "memory";
34
34
  }
35
35
 
36
- function kvUrlFor(docker: boolean): string | undefined {
36
+ function kvUrlFor(docker: boolean): string {
37
37
  const url = process.env.REDIS_URL ?? process.env.OKE_STORE_KV_URL ?? undefined;
38
38
  if (!url) {
39
- if (docker) {
40
- throw new Error(
41
- "oke boot: gate redis kv needs REDIS_URL (did `oke dev -d` write docker/.env.docker?)",
42
- );
43
- }
44
- return undefined;
39
+ throw new Error(
40
+ docker
41
+ ? "oke boot: gate redis kv needs REDIS_URL (did `oke dev -d` write docker/.env.docker?)"
42
+ : "oke boot: gate redis kv needs REDIS_URL — declare drivers.store.kv memory for local, or set REDIS_URL",
43
+ );
45
44
  }
46
45
  return url;
47
46
  }
@@ -65,19 +64,15 @@ export async function bindGate(
65
64
 
66
65
  let kvNs;
67
66
  if (kvId === "redis") {
67
+ // Fail loud when redis is configured without URL/inject — never soft-fallback
68
+ // to memory (that silently doubles rate budgets under horizontal scale).
68
69
  const url = injected ? undefined : kvUrlFor(docker);
69
- if (!injected && url === undefined) {
70
- // Local without REDIS_URL — soft-fallback to memory (same as missing redis for store would fail;
71
- // gate rates should not brick local apps that only declared policy gates).
72
- kvNs = await memoryDrivers.kv.open({ name: GATE_KV_NAMESPACE, nowMs: now });
73
- } else {
74
- kvNs = await redisDriver.open({
75
- name: GATE_KV_NAMESPACE,
76
- ...(url !== undefined ? { url } : {}),
77
- ...(injected !== undefined ? { client: injected } : {}),
78
- nowMs: now,
79
- });
80
- }
70
+ kvNs = await redisDriver.open({
71
+ name: GATE_KV_NAMESPACE,
72
+ ...(url !== undefined ? { url } : {}),
73
+ ...(injected !== undefined ? { client: injected } : {}),
74
+ nowMs: now,
75
+ });
81
76
  } else {
82
77
  kvNs = await memoryDrivers.kv.open({ name: GATE_KV_NAMESPACE, nowMs: now });
83
78
  }
@@ -263,6 +263,24 @@ describe("boot binders honour drivers.* config", () => {
263
263
  }
264
264
  });
265
265
 
266
+ test("gate: drivers.store.kv redis without REDIS_URL fails loud (never soft memory)", async () => {
267
+ delete process.env.REDIS_URL;
268
+ delete process.env.OKE_STORE_KV_URL;
269
+ await expect(
270
+ bootApplication({
271
+ env: "local",
272
+ gates: [gate.rate({ max: 10, per: "1m" })],
273
+ config: {
274
+ drivers: {
275
+ store: {
276
+ kv: { local: "redis", docker: "redis", prod: "redis" },
277
+ },
278
+ },
279
+ },
280
+ }),
281
+ ).rejects.toThrow(/gate redis kv needs REDIS_URL/);
282
+ });
283
+
266
284
  test("gate: drivers.store.kv memory keeps memory oke:gates", async () => {
267
285
  const result = await bootApplication({
268
286
  env: "local",
@@ -29,6 +29,25 @@ function redisUrlFor(docker: boolean): string | undefined {
29
29
  return url;
30
30
  }
31
31
 
32
+ let signalRedisWarned = false;
33
+
34
+ /** One-shot: redis Signal is emit-relay + process-local outbox consume today. */
35
+ function warnSignalRedisProcessLocal(): void {
36
+ if (signalRedisWarned) return;
37
+ signalRedisWarned = true;
38
+ console.warn(
39
+ "oke boot: drivers.signal redis — emit relays to Redis, but consume/live/drain use a " +
40
+ "process-local outbox (not multi-instance competing consumers). Prefer a shared durable " +
41
+ "outbox path for multi-process tests, or a single consumer instance, until Redis Streams " +
42
+ "consume ships.",
43
+ );
44
+ }
45
+
46
+ /** Test helper — reset the one-shot redis Signal warn. */
47
+ export function resetSignalRedisWarnForTests(): void {
48
+ signalRedisWarned = false;
49
+ }
50
+
32
51
  /**
33
52
  * Construct a Signal runtime, register decls / binding names, start the bus.
34
53
  *
@@ -59,6 +78,7 @@ export async function bindSignal(
59
78
  break;
60
79
  case "redis": {
61
80
  const redis = injectedRedis ?? createBunSignalRedisClient(redisUrlFor(docker));
81
+ warnSignalRedisProcessLocal();
62
82
  signal = createSignalRuntime({
63
83
  driver: redisSignalDriver,
64
84
  now,
@@ -3,8 +3,11 @@
3
3
  */
4
4
 
5
5
  import { afterEach, describe, expect, test } from "bun:test";
6
+ import { files } from "../../elements/store.ts";
6
7
  import {
8
+ bindStore,
7
9
  indexDriverFor,
10
+ resetFilesFsWarnForTests,
8
11
  resolveFilesDriverId,
9
12
  resolveIndexDriverId,
10
13
  resolveKvDriverId,
@@ -77,6 +80,85 @@ describe("bindStore driver resolution", () => {
77
80
  });
78
81
  });
79
82
 
83
+ describe("bindStore files fs multi-instance warn", () => {
84
+ afterEach(() => {
85
+ resetFilesFsWarnForTests();
86
+ });
87
+
88
+ test("warns once when files driver is fs", () => {
89
+ const warnings: string[] = [];
90
+ const prev = console.warn;
91
+ console.warn = (...args: unknown[]) => {
92
+ warnings.push(args.map(String).join(" "));
93
+ };
94
+ try {
95
+ const uploads = files("uploads");
96
+ bindStore(
97
+ {
98
+ stores: [uploads],
99
+ config: {
100
+ drivers: {
101
+ store: {
102
+ files: { local: "fs", docker: "s3", prod: "s3" },
103
+ },
104
+ },
105
+ },
106
+ },
107
+ "local",
108
+ () => Date.now(),
109
+ false,
110
+ );
111
+ bindStore(
112
+ {
113
+ stores: [uploads],
114
+ config: {
115
+ drivers: {
116
+ store: {
117
+ files: { local: "fs", docker: "s3", prod: "s3" },
118
+ },
119
+ },
120
+ },
121
+ },
122
+ "local",
123
+ () => Date.now(),
124
+ false,
125
+ );
126
+ expect(warnings.some((w) => w.includes('drivers.store.files "fs"'))).toBe(true);
127
+ expect(warnings.filter((w) => w.includes('drivers.store.files "fs"'))).toHaveLength(1);
128
+ } finally {
129
+ console.warn = prev;
130
+ }
131
+ });
132
+
133
+ test("does not warn for memory or s3 files", () => {
134
+ const warnings: string[] = [];
135
+ const prev = console.warn;
136
+ console.warn = (...args: unknown[]) => {
137
+ warnings.push(args.map(String).join(" "));
138
+ };
139
+ try {
140
+ bindStore(
141
+ {
142
+ stores: [files("uploads")],
143
+ config: {
144
+ drivers: {
145
+ store: {
146
+ files: { local: "memory", docker: "s3", prod: "s3" },
147
+ },
148
+ },
149
+ },
150
+ },
151
+ "local",
152
+ () => Date.now(),
153
+ false,
154
+ );
155
+ expect(warnings.some((w) => w.includes('drivers.store.files "fs"'))).toBe(false);
156
+ } finally {
157
+ console.warn = prev;
158
+ }
159
+ });
160
+ });
161
+
80
162
  describe("bindStore index driver resolution", () => {
81
163
  const prevIndex = process.env.OKE_INDEX_DRIVER;
82
164
 
@@ -29,6 +29,27 @@ import type { BootOptions } from "../boot.ts"; // type-only — no cycle at runt
29
29
  * @param now - Clock
30
30
  * @param docker - Prefer compose URLs when opening postgres/redis
31
31
  */
32
+ let filesFsWarned = false;
33
+
34
+ /** Test helper — reset the one-shot `fs` multi-instance warn. */
35
+ export function resetFilesFsWarnForTests(): void {
36
+ filesFsWarned = false;
37
+ }
38
+
39
+ /**
40
+ * Warn once when `drivers.store.files` is `fs` — per-process (or per-pod)
41
+ * filesystem visibility under horizontal scale.
42
+ */
43
+ function warnFilesFsMultiInstance(): void {
44
+ if (filesFsWarned) return;
45
+ filesFsWarned = true;
46
+ console.warn(
47
+ 'oke boot: drivers.store.files "fs" is single-host — each instance sees its own ' +
48
+ "filesystem (temp root when unbound). For horizontal scale use drivers.store.files s3 " +
49
+ "(or an explicitly shared volume root, still single-host semantics).",
50
+ );
51
+ }
52
+
32
53
  export function bindStore(
33
54
  options: BootOptions,
34
55
  env: ConfigEnv,
@@ -39,6 +60,7 @@ export function bindStore(
39
60
  const kvId = resolveKvDriverId(options, env, docker);
40
61
  const filesId = resolveFilesDriverId(options, env, docker);
41
62
  const indexId = resolveIndexDriverId(options, env, docker);
63
+ if (filesId === "fs") warnFilesFsMultiInstance();
42
64
  const sqlUrl = sqlUrlFor(sqlId, docker);
43
65
  const kvUrl = kvUrlFor(kvId, docker);
44
66
  const filesRoot = filesRootFor(filesId);
@@ -79,7 +79,7 @@ describe("boot — lazy element needs", () => {
79
79
  expect(needs.signal).toBe(false);
80
80
  });
81
81
 
82
- test("oke() Store-only graph stays under the prior 51 kB baseline", async () => {
82
+ test("oke() Store-only graph stays under the prior 53 kB baseline", async () => {
83
83
  const dir = await mkdtemp(join(tmpdir(), "oke-store-only-"));
84
84
  const entry = join(dir, "entry.ts");
85
85
  const appPath = join(import.meta.dir, "app.ts");
@@ -117,10 +117,10 @@ describe("boot — lazy element needs", () => {
117
117
  total += Bun.gzipSync(new Uint8Array(raw)).byteLength;
118
118
  }
119
119
  // Rebased after fx.sendOtp/verifyOtp on the shared fx surface (~50.1 kB
120
- // gzip), then again after the durable-journal lease surface (SKIP LOCKED
121
- // claim/release/orphan-scan on journal.ts + app.ts wiring, ~51.2 kB).
122
- // Clock/channel/journal drivers stay lazy-bound; far below eager bind.
123
- expect(total).toBeLessThan(51_500);
120
+ // gzip), durable-journal lease surface (~51.2 kB), then fx.deliverOtp +
121
+ // unified otp() plugin wiring on the shared surface (~50.9 kB → 53 kB
122
+ // cap). Clock/channel/journal drivers stay lazy-bound; far below eager bind.
123
+ expect(total).toBeLessThan(53_000);
124
124
  } finally {
125
125
  await rm(dir, { recursive: true, force: true });
126
126
  }
@@ -147,6 +147,9 @@ describe("fx — wholesale swap", () => {
147
147
  async verifyOtp() {
148
148
  return { ok: true };
149
149
  },
150
+ async deliverOtp(opts) {
151
+ return { ok: true as const, channel: opts.only ?? opts.channels[0] ?? "email" };
152
+ },
150
153
  async ask() {
151
154
  return {};
152
155
  },
package/src/kernel/fx.ts CHANGED
@@ -265,6 +265,20 @@ export interface FxVerifyOtpOptions {
265
265
  readonly note?: string;
266
266
  }
267
267
 
268
+ /** Options for {@link Fx.deliverOtp} (Tier-2 multi-channel delivery). */
269
+ export interface FxDeliverOtpOptions {
270
+ /** Preferred channel order. */
271
+ readonly channels: readonly ("sms" | "whatsapp" | "email")[];
272
+ /** Template name per medium. */
273
+ readonly templates: Readonly<Partial<Record<"sms" | "whatsapp" | "email", string>>>;
274
+ readonly email?: string;
275
+ readonly phone?: string;
276
+ readonly data: Readonly<Record<string, unknown>>;
277
+ readonly locale?: string;
278
+ /** Explicit single-channel resend — no cross-medium failover. */
279
+ readonly only?: "sms" | "whatsapp" | "email";
280
+ }
281
+
268
282
  /** Options for {@link Fx.ask}. */
269
283
  export interface FxAskOptions {
270
284
  readonly via?: readonly NamedRef[];
@@ -385,6 +399,18 @@ export interface Fx {
385
399
  * @param opts - Recipient + requestId + code (+ lang / note / from)
386
400
  */
387
401
  verifyOtp(opts: FxVerifyOtpOptions): Promise<{ ok: true }>;
402
+ /**
403
+ * Deliver an app-owned OTP across declared channels (records `send` on `auth-otp`).
404
+ *
405
+ * Tier-2 only — uses Channel `deliverOtp` (sently FallbackTransport). Pass
406
+ * `only` for explicit user resend (single channel, no cross-medium failover).
407
+ *
408
+ * @param opts - Channels, templates, addresses, OTP data
409
+ */
410
+ deliverOtp(opts: FxDeliverOtpOptions): Promise<{
411
+ ok: true;
412
+ channel: "sms" | "whatsapp" | "email";
413
+ }>;
388
414
  /**
389
415
  * Ask an AI prompt (records `ask`). Stub returns `{}`.
390
416
  *
@@ -1213,6 +1239,29 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1213
1239
  return { ok: true as const };
1214
1240
  });
1215
1241
  },
1242
+ deliverOtp(opts) {
1243
+ return gated("send", "auth-otp", async () => {
1244
+ if (isDryRun()) {
1245
+ recordWouldHaveFired("send", "auth-otp");
1246
+ return { ok: true as const, channel: opts.only ?? opts.channels[0] ?? "email" };
1247
+ }
1248
+ if (!options.channelRuntime) {
1249
+ throw new Error(
1250
+ "fx.deliverOtp needs a bound Channel — declare channel templates and drivers for the configured media",
1251
+ );
1252
+ }
1253
+ const result = await options.channelRuntime.deliverOtp({
1254
+ channels: opts.channels,
1255
+ templates: opts.templates,
1256
+ ...(opts.email ? { email: opts.email } : {}),
1257
+ ...(opts.phone ? { phone: opts.phone } : {}),
1258
+ data: opts.data,
1259
+ ...(opts.locale ? { locale: opts.locale } : {}),
1260
+ ...(opts.only ? { only: opts.only } : {}),
1261
+ });
1262
+ return { ok: true as const, channel: result.channel };
1263
+ });
1264
+ },
1216
1265
  ask(prompt, input, opts) {
1217
1266
  const name = resolveName(prompt);
1218
1267
  return gated("ask", name, async () => {
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Graceful shutdown — Clock / Journal lease release without waiting for TTL.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+
7
+ import { createClockRuntime, createMemoryCronStore } from "../elements/clock.ts";
8
+ import { tryAcquireLease } from "../elements/clock/leader.ts";
9
+ import { createMemoryJournalStore, hasJournalLease, JOURNAL_DEFAULT_LEASE_MS } from "./journal.ts";
10
+ import { releaseInstanceLeases } from "./graceful-shutdown.ts";
11
+
12
+ describe("releaseInstanceLeases", () => {
13
+ test("releases Clock cron leases held by this instance", async () => {
14
+ const store = createMemoryCronStore();
15
+ await store.put({
16
+ name: "hourly",
17
+ status: "active",
18
+ timezone: "UTC",
19
+ overridable: false,
20
+ effectiveEvery: "1h",
21
+ });
22
+ const clock = createClockRuntime({ store, instanceId: "inst-a", leaseMs: 30_000 });
23
+ const t = 1_000_000;
24
+ expect(
25
+ await tryAcquireLease({
26
+ name: "hourly",
27
+ instanceId: "inst-a",
28
+ now: t,
29
+ leaseMs: 30_000,
30
+ store,
31
+ }),
32
+ ).toBe(true);
33
+ expect((await store.get("hourly"))!.leaderInstanceId).toBe("inst-a");
34
+
35
+ await releaseInstanceLeases({
36
+ bootResult: {
37
+ clock: {
38
+ instanceId: clock.instanceId,
39
+ store,
40
+ now: () => t + 1,
41
+ },
42
+ },
43
+ stop: async () => {},
44
+ });
45
+
46
+ const row = await store.get("hourly");
47
+ expect(row!.leaderInstanceId).toBeUndefined();
48
+ expect(row!.leaderLeaseUntil).toBeUndefined();
49
+ });
50
+
51
+ test("releases Journal run leases held by this instance", async () => {
52
+ const store = createMemoryJournalStore();
53
+ expect(hasJournalLease(store)).toBe(true);
54
+ const runId = "run-1";
55
+ await store.put({
56
+ id: runId,
57
+ flow: "charge",
58
+ status: "running",
59
+ input: {},
60
+ entries: [],
61
+ createdAt: 1,
62
+ updatedAt: 1,
63
+ });
64
+ expect(await store.acquireLease!(runId, "inst-a", 1_000, JOURNAL_DEFAULT_LEASE_MS)).toBe(true);
65
+ expect((await store.get(runId))!.lockedBy).toBe("inst-a");
66
+
67
+ await releaseInstanceLeases({
68
+ bootResult: {
69
+ journal: { instanceId: "inst-a", store },
70
+ },
71
+ stop: async () => {},
72
+ });
73
+
74
+ expect((await store.get(runId))!.lockedBy).toBeUndefined();
75
+ });
76
+ });