okengine 0.6.1 → 0.8.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 (167) hide show
  1. package/AGENTS.md +2 -2
  2. package/README.md +73 -181
  3. package/package.json +3 -3
  4. package/site/content/docs/console/clock.mdx +25 -5
  5. package/site/content/docs/console/signals.mdx +13 -4
  6. package/site/content/docs/elements/ai.mdx +29 -2
  7. package/site/content/docs/elements/channel.mdx +42 -25
  8. package/site/content/docs/elements/clock.mdx +59 -25
  9. package/site/content/docs/elements/flow.mdx +8 -2
  10. package/site/content/docs/elements/gate.mdx +8 -1
  11. package/site/content/docs/elements/signal.mdx +173 -29
  12. package/site/content/docs/elements/store.mdx +595 -73
  13. package/site/content/docs/elements/vault.mdx +15 -5
  14. package/site/content/docs/get-started/basic-usage.mdx +13 -6
  15. package/site/content/docs/get-started/installation.mdx +52 -14
  16. package/site/content/docs/get-started/introduction.mdx +1 -1
  17. package/site/content/docs/plugins/magic-link.mdx +42 -0
  18. package/site/content/docs/plugins/phone-number.mdx +78 -17
  19. package/site/content/docs/plugins/two-factor.mdx +1 -0
  20. package/site/content/docs/reference/cli.md +32 -2
  21. package/site/content/docs/reference/configuration.mdx +15 -13
  22. package/site/content/docs/reference/environment-variables.mdx +32 -18
  23. package/site/content/docs/reference/errors.mdx +12 -11
  24. package/site/content/docs/reference/fx.mdx +15 -13
  25. package/site/content/docs/reference/security.md +3 -1
  26. package/src/cli/ai-setup/ai-setup.test.ts +144 -0
  27. package/src/cli/ai-setup/apply.ts +214 -0
  28. package/src/cli/ai-setup/catalog.ts +263 -0
  29. package/src/cli/ai-setup/detect-ollama.ts +166 -0
  30. package/src/cli/ai-setup/index.ts +228 -0
  31. package/src/cli/ai-setup/prompts.ts +649 -0
  32. package/src/cli/ai-setup/recommend.test.ts +100 -0
  33. package/src/cli/ai-setup/recommend.ts +203 -0
  34. package/src/cli/ai.ts +38 -0
  35. package/src/cli/db-auto-push.test.ts +6 -1
  36. package/src/cli/db-auto-push.ts +8 -3
  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-db-push.test.ts +59 -0
  41. package/src/cli/dev.test.ts +40 -0
  42. package/src/cli/dev.ts +55 -1
  43. package/src/cli/docker-clean.test.ts +179 -0
  44. package/src/cli/docker-clean.ts +406 -0
  45. package/src/cli/docker.ts +16 -4
  46. package/src/cli/ensure-drizzle-config.ts +1 -1
  47. package/src/cli/hero-meta.test.ts +6 -5
  48. package/src/cli/hero-meta.ts +7 -1
  49. package/src/cli/index.ts +5 -0
  50. package/src/cli/load-config.images.test.ts +22 -0
  51. package/src/cli/load-config.ts +7 -2
  52. package/src/cli/openbao-bootstrap.test.ts +1 -0
  53. package/src/cli/openbao-bootstrap.ts +9 -2
  54. package/src/cli/registry.ts +116 -2
  55. package/src/client/live-gap.test.ts +35 -0
  56. package/src/client/transport.test.ts +21 -0
  57. package/src/client/transport.ts +27 -5
  58. package/src/compiler/effects-infer.ts +1 -0
  59. package/src/compiler/fixtures/skyport/oke.config.ts +1 -1
  60. package/src/config/define-config.test.ts +6 -6
  61. package/src/config/index.ts +5 -1
  62. package/src/console/server/console.test.ts +45 -0
  63. package/src/console/server/flows.ts +39 -9
  64. package/src/console/server/i18n.ts +41 -0
  65. package/src/console/server/vault.ts +12 -38
  66. package/src/console/ui/dist/assets/index-ClX0ZCe5.js +10 -0
  67. package/src/console/ui/dist/assets/{panel-access-BGv45snf.js → panel-access-ri1X9Otj.js} +1 -1
  68. package/src/console/ui/dist/assets/{panel-ai-B2S7LEii.js → panel-ai-DLHjRoJk.js} +1 -1
  69. package/src/console/ui/dist/assets/{panel-architecture-D7UJh91v.js → panel-architecture-DI3bQF3J.js} +1 -1
  70. package/src/console/ui/dist/assets/{panel-channels-9T3ybqRu.js → panel-channels-BDYWX29p.js} +1 -1
  71. package/src/console/ui/dist/assets/{panel-clock-Cb1UXGRQ.js → panel-clock-i-JpTjVB.js} +1 -1
  72. package/src/console/ui/dist/assets/{panel-diff-DmYbKWmN.js → panel-diff-CFuWxiXy.js} +1 -1
  73. package/src/console/ui/dist/assets/{panel-flows-PiHwT55z.js → panel-flows-B8TRhcoe.js} +1 -1
  74. package/src/console/ui/dist/assets/{panel-gates-BQGYXvjT.js → panel-gates-CfkBgco7.js} +1 -1
  75. package/src/console/ui/dist/assets/{panel-overview-BBnRO18l.js → panel-overview-DONDzBd2.js} +1 -1
  76. package/src/console/ui/dist/assets/{panel-plugins-D0PsmVw2.js → panel-plugins-DDJwTehL.js} +1 -1
  77. package/src/console/ui/dist/assets/{panel-runs-CWuRDe0r.js → panel-runs-D5zf-D9c.js} +1 -1
  78. package/src/console/ui/dist/assets/{panel-signals-Bbg4ewpP.js → panel-signals-Dsxdu_AR.js} +1 -1
  79. package/src/console/ui/dist/assets/{panel-store-CPCbsDRa.js → panel-store-hy7O8HOs.js} +1 -1
  80. package/src/console/ui/dist/assets/{panel-traces-DVAzuA_S.js → panel-traces-CBiAe5go.js} +1 -1
  81. package/src/console/ui/dist/assets/{panel-vault-D1_MvOmo.js → panel-vault-Za1GyJfM.js} +1 -1
  82. package/src/console/ui/dist/index.html +1 -1
  83. package/src/console/ui/shell/components/ui.tsx +5 -1
  84. package/src/console/ui/shell/setup/Wizard.tsx +26 -5
  85. package/src/docker/cleanup.test.ts +193 -0
  86. package/src/docker/cleanup.ts +355 -0
  87. package/src/docker/docker.test.ts +4 -0
  88. package/src/docker/index.ts +19 -0
  89. package/src/docker/recipes/ollama.ts +10 -5
  90. package/src/drivers/ai-ollama.ts +45 -2
  91. package/src/drivers/ai-providers.test.ts +31 -0
  92. package/src/drivers/channel-sently.test.ts +8 -0
  93. package/src/drivers/channel-taqnyat-mail.ts +34 -0
  94. package/src/drivers/channel-types.ts +71 -0
  95. package/src/drivers/clock-postgres.test.ts +258 -0
  96. package/src/drivers/clock-postgres.ts +410 -0
  97. package/src/drivers/index.ts +21 -0
  98. package/src/drivers/journal-postgres.test.ts +175 -0
  99. package/src/drivers/journal-postgres.ts +492 -0
  100. package/src/drivers/signal-engine.ts +98 -25
  101. package/src/drivers/signal-nats.ts +4 -4
  102. package/src/drivers/signal-postgres.ts +158 -64
  103. package/src/drivers/signal-redis.ts +4 -4
  104. package/src/drivers/signal-types.ts +61 -2
  105. package/src/drivers/vault-driver-removal.test.ts +5 -1
  106. package/src/elements/channel/runtime.ts +51 -0
  107. package/src/elements/channel.test.ts +71 -0
  108. package/src/elements/clock/chaos-child.ts +389 -0
  109. package/src/elements/clock/chaos.test.ts +204 -0
  110. package/src/elements/clock/durable.ts +7 -0
  111. package/src/elements/clock/reconcile.ts +110 -0
  112. package/src/elements/clock/runtime.ts +13 -0
  113. package/src/elements/clock.test.ts +164 -2
  114. package/src/elements/clock.ts +2 -1
  115. package/src/elements/gate/runtime.ts +6 -0
  116. package/src/elements/signal/chaos-child.ts +41 -4
  117. package/src/elements/signal/declare.ts +4 -1
  118. package/src/elements/signal/delivery-modes.test.ts +179 -0
  119. package/src/elements/signal/key-ordering.test.ts +306 -0
  120. package/src/elements/signal/lease-reclaim.test.ts +123 -0
  121. package/src/elements/signal/optional-emit.test.ts +76 -0
  122. package/src/elements/signal/order-lifecycle.test.ts +130 -0
  123. package/src/elements/signal/orphan-messages.test.ts +152 -0
  124. package/src/elements/signal/runtime.ts +11 -4
  125. package/src/elements/signal/schema-emit.test.ts +107 -0
  126. package/src/elements/signal.test.ts +89 -5
  127. package/src/elements/store/files-fx.ts +104 -0
  128. package/src/elements/store/files-image.test.ts +251 -0
  129. package/src/elements/store/files-image.ts +494 -0
  130. package/src/elements/store/runtime.ts +60 -0
  131. package/src/elements/store/seed.test.ts +27 -0
  132. package/src/elements/store/seed.ts +68 -0
  133. package/src/elements/store/sql-session.test.ts +39 -0
  134. package/src/elements/store/sql-session.ts +55 -0
  135. package/src/elements/store/upsert-app.test.ts +103 -0
  136. package/src/elements/store.ts +29 -0
  137. package/src/elements/vault/boot-chain.ts +150 -0
  138. package/src/elements/vault/runtime.ts +8 -0
  139. package/src/index.ts +15 -0
  140. package/src/kernel/app.ts +168 -15
  141. package/src/kernel/boot-bind/channel.test.ts +16 -0
  142. package/src/kernel/boot-bind/channel.ts +13 -0
  143. package/src/kernel/boot-bind/clock.ts +63 -7
  144. package/src/kernel/boot-bind/gate.ts +71 -3
  145. package/src/kernel/boot-bind/honor-config.test.ts +348 -0
  146. package/src/kernel/boot-bind/journal.ts +89 -0
  147. package/src/kernel/boot-bind/signal.ts +64 -7
  148. package/src/kernel/boot-bind/vault.ts +27 -8
  149. package/src/kernel/boot.test.ts +6 -4
  150. package/src/kernel/boot.ts +63 -15
  151. package/src/kernel/concurrency.ts +1 -1
  152. package/src/kernel/errors.ts +8 -2
  153. package/src/kernel/fx.test.ts +19 -0
  154. package/src/kernel/fx.ts +153 -9
  155. package/src/kernel/index.ts +6 -0
  156. package/src/kernel/journal-boot.test.ts +397 -0
  157. package/src/kernel/journal-suspend.ts +35 -0
  158. package/src/kernel/journal.test.ts +142 -0
  159. package/src/kernel/journal.ts +202 -27
  160. package/src/plugins/auth-methods.security.test.ts +10 -7
  161. package/src/plugins/phone-number.ts +67 -10
  162. package/src/plugins/taqnyat.live.test.ts +174 -0
  163. package/src/runtime/dev-request-log.test.ts +20 -1
  164. package/src/runtime/dev-request-log.ts +38 -0
  165. package/src/term.test.ts +15 -0
  166. package/src/term.ts +8 -3
  167. package/src/console/ui/dist/assets/index-CjxwRGVv.js +0 -10
@@ -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
  }
@@ -421,7 +421,7 @@ describe("auth methods — anonymous non-escalation", () => {
421
421
  });
422
422
 
423
423
  describe("auth methods — channel delivery", () => {
424
- test("magic / email-otp send via fx.send; phone remains unwired; exposeDev* stays off by default", async () => {
424
+ test("magic / email-otp send via fx.send; phone uses fx.sendOtp (Taqnyat Verify); exposeDev* stays off by default", async () => {
425
425
  resetBindings();
426
426
  resetFlowSeq();
427
427
  const app = oke({
@@ -445,6 +445,7 @@ describe("auth methods — channel delivery", () => {
445
445
  expect(otpBody.data.ok).toBe(true);
446
446
  expect(otpBody.data.devOtp).toBeUndefined();
447
447
 
448
+ // No SMS driver in test env → local hashed path; never exposeDevOtp by default.
448
449
  const phone = await app.fetch(jsonPost("/auth/phone/request", { phone: "+15551112222" }));
449
450
  const phoneBody = (await phone.json()) as { data: Record<string, unknown> };
450
451
  expect(phoneBody.data.ok).toBe(true);
@@ -453,14 +454,16 @@ describe("auth methods — channel delivery", () => {
453
454
  const magicSrc = await Bun.file(new URL("./magic-link.ts", import.meta.url)).text();
454
455
  const emailSrc = await Bun.file(new URL("./email-otp.ts", import.meta.url)).text();
455
456
  const phoneSrc = await Bun.file(new URL("./phone-number.ts", import.meta.url)).text();
456
- expect(magicSrc).toMatch(/fx\.send/);
457
+ expect(magicSrc).toMatch(/fx\.send\(/);
457
458
  expect(magicSrc).toMatch(/channel\./);
458
- expect(emailSrc).toMatch(/fx\.send/);
459
+ expect(emailSrc).toMatch(/fx\.send\(/);
459
460
  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/);
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/);
464
467
 
465
468
  await app.stop();
466
469
  });
@@ -64,22 +64,57 @@ export function phoneNumber(opts: PhoneNumberOptions = {}): PluginDef {
64
64
  name: "auth.requestPhoneOtp",
65
65
  unit: "auth",
66
66
  plane: "user",
67
- in: z.object({ phone: z.string().min(8) }),
67
+ in: z.object({
68
+ phone: z.string().min(8),
69
+ /** OTP message language for the Taqnyat Verify path. */
70
+ lang: z.enum(["en", "ar"]).optional(),
71
+ }),
68
72
  out: z.object({
69
73
  ok: z.literal(true),
70
74
  devOtp: z.string().optional(),
71
75
  }),
72
76
  errors: { AuthFailed, AuthRateLimited },
73
- do: async (input) => {
77
+ effects: { sends: ["auth-phone-otp", "sms-otp"] },
78
+ do: async (input, fx) => {
74
79
  const phone = input.phone.trim();
75
80
  if (!E164.test(phone)) return fail("AuthFailed", { reason: "invalid_phone" });
76
- const otp = generateOtp(6);
77
81
  const now = runtime.now();
78
82
  for (const row of verifications.rows.values()) {
79
83
  if (row.identifier === `phone-otp:${phone}` && row.consumedAt === null) {
80
84
  row.consumedAt = now;
81
85
  }
82
86
  }
87
+
88
+ // Provider-managed OTP (Taqnyat Verify) when a Taqnyat SMS driver is
89
+ // bound via Channel. Throws loudly for unsupported SMS drivers; returns
90
+ // undefined only when no SMS driver is bound (local/dev path).
91
+ const requestId = crypto.randomUUID();
92
+ const provider = await fx
93
+ .sendOtp({
94
+ to: phone,
95
+ requestId,
96
+ ...(input.lang ? { lang: input.lang } : {}),
97
+ })
98
+ .catch((err: unknown) => {
99
+ const msg = err instanceof Error ? err.message : String(err);
100
+ if (msg.includes("no SMS driver bound")) return undefined;
101
+ throw err;
102
+ });
103
+
104
+ if (provider) {
105
+ putVerification(verifications, {
106
+ id: crypto.randomUUID(),
107
+ identifier: `phone-otp:${phone}`,
108
+ value: `taqnyat:${requestId}`,
109
+ expiresAt: now + ttlMs,
110
+ createdAt: now,
111
+ consumedAt: null,
112
+ attempts: 0,
113
+ });
114
+ return { ok: true as const };
115
+ }
116
+
117
+ const otp = generateOtp(6);
83
118
  putVerification(verifications, {
84
119
  id: crypto.randomUUID(),
85
120
  identifier: `phone-otp:${phone}`,
@@ -103,10 +138,13 @@ export function phoneNumber(opts: PhoneNumberOptions = {}): PluginDef {
103
138
  in: z.object({
104
139
  phone: z.string().min(8),
105
140
  otp: z.string().min(4).max(8),
141
+ /** OTP message language for the Taqnyat Verify path. */
142
+ lang: z.enum(["en", "ar"]).optional(),
106
143
  }),
107
144
  out: SessionTokensOut,
108
145
  errors: { AuthFailed, AuthRateLimited },
109
- do: async (input) => {
146
+ effects: { sends: ["sms-otp"] },
147
+ do: async (input, fx) => {
110
148
  const phone = input.phone.trim();
111
149
  if (!E164.test(phone)) return fail("AuthFailed", { reason: "invalid_phone" });
112
150
  const now = runtime.now();
@@ -116,13 +154,32 @@ export function phoneNumber(opts: PhoneNumberOptions = {}): PluginDef {
116
154
  row.consumedAt = now;
117
155
  return fail("AuthFailed", { reason: "invalid_credentials" });
118
156
  }
119
- const hash = await hashChallenge(input.otp.trim());
120
- if (hash !== row.value) {
121
- row.attempts += 1;
122
- if (row.attempts >= MAX_ATTEMPTS) row.consumedAt = now;
123
- return fail("AuthFailed", { reason: "invalid_credentials" });
157
+
158
+ if (row.value.startsWith("taqnyat:")) {
159
+ const requestId = row.value.slice("taqnyat:".length);
160
+ try {
161
+ await fx.verifyOtp({
162
+ to: phone,
163
+ requestId,
164
+ code: input.otp.trim(),
165
+ ...(input.lang ? { lang: input.lang } : {}),
166
+ });
167
+ } catch {
168
+ row.attempts += 1;
169
+ if (row.attempts >= MAX_ATTEMPTS) row.consumedAt = now;
170
+ return fail("AuthFailed", { reason: "invalid_credentials" });
171
+ }
172
+ row.consumedAt = now;
173
+ } else {
174
+ const hash = await hashChallenge(input.otp.trim());
175
+ if (hash !== row.value) {
176
+ row.attempts += 1;
177
+ if (row.attempts >= MAX_ATTEMPTS) row.consumedAt = now;
178
+ return fail("AuthFailed", { reason: "invalid_credentials" });
179
+ }
180
+ row.consumedAt = now;
124
181
  }
125
- row.consumedAt = now;
182
+
126
183
  let userId = phones.byPhone.get(phone);
127
184
  if (!userId) {
128
185
  userId = crypto.randomUUID();
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Real Taqnyat end-to-end: phoneNumber OTP via Verify API + magic-link via Taqnyat Mail.
3
+ *
4
+ * Double-gated — runs ONLY when BOTH:
5
+ * 1. The global per-medium opt-in flag is set explicitly
6
+ * (`OKE_SMS_LIVE=1` / `OKE_EMAIL_LIVE=1`), and
7
+ * 2. the matching real credentials are present in the environment.
8
+ *
9
+ * Credential presence alone is NEVER enough — this mirrors sently's own
10
+ * opt-in live suite and prevents burning provider quota on a routine
11
+ * `bun test` run with stray credentials in the shell. The flags are
12
+ * provider-agnostic: any future SMS or email provider live suite gates on
13
+ * the same medium flag plus its own credentials.
14
+ *
15
+ * Skip is always visible (`console.log("skip: …")` + `test.skip`), never a
16
+ * silent pass.
17
+ *
18
+ * Setup:
19
+ * # SMS OTP — sends exactly ONE real SMS (the plugin's own send; its
20
+ * # provider response is captured for the code-5 assertion)
21
+ * export OKE_SMS_LIVE=1
22
+ * export TAQNYAT_TOKEN=… (or TAQNYAT_BEARER_TOKEN)
23
+ * export TAQNYAT_SENDER=YourBrand
24
+ * export OKE_TEST_TAQNYAT_PHONE=+9665xxxxxxxx (or TAQNYAT_TO)
25
+ * # Mail
26
+ * export OKE_EMAIL_LIVE=1
27
+ * export TAQNYAT_MAIL_TOKEN=…
28
+ * export TAQNYAT_CAMPAIGN=auth
29
+ * export OKE_TEST_TAQNYAT_MAIL=you@example.com (or TAQNYAT_MAIL_TO)
30
+ */
31
+
32
+ import { afterEach, describe, expect, test } from "bun:test";
33
+ import { oke } from "../kernel/app.ts";
34
+ import { resetFlowSeq } from "../kernel/flow.ts";
35
+ import { resetBindings } from "../kernel/on.ts";
36
+ import { magicLink } from "./magic-link.ts";
37
+ import { phoneNumber } from "./phone-number.ts";
38
+
39
+ const SECRET = "test-secret-at-least-16";
40
+
41
+ afterEach(() => {
42
+ resetBindings();
43
+ resetFlowSeq();
44
+ });
45
+
46
+ function jsonPost(path: string, body: unknown): Request {
47
+ return new Request(`http://localhost${path}`, {
48
+ method: "POST",
49
+ headers: { "content-type": "application/json" },
50
+ body: JSON.stringify(body),
51
+ });
52
+ }
53
+
54
+ const WANT_SMS = process.env.OKE_SMS_LIVE === "1";
55
+ const WANT_MAIL = process.env.OKE_EMAIL_LIVE === "1";
56
+
57
+ const SMS_TOKEN = process.env.TAQNYAT_TOKEN ?? process.env.TAQNYAT_BEARER_TOKEN;
58
+ const SMS_CREDS = Boolean(SMS_TOKEN) && Boolean(process.env.TAQNYAT_SENDER);
59
+ // OKE_TEST_TAQNYAT_PHONE wins; TAQNYAT_TO (sently's convention) is the alias.
60
+ const SMS_PHONE = process.env.OKE_TEST_TAQNYAT_PHONE ?? process.env.TAQNYAT_TO;
61
+ const LIVE_SMS = WANT_SMS && SMS_CREDS && Boolean(SMS_PHONE);
62
+
63
+ const MAIL_CREDS = Boolean(process.env.TAQNYAT_MAIL_TOKEN) && Boolean(process.env.TAQNYAT_CAMPAIGN);
64
+ const MAIL_TO = process.env.OKE_TEST_TAQNYAT_MAIL ?? process.env.TAQNYAT_MAIL_TO;
65
+ const LIVE_MAIL = WANT_MAIL && MAIL_CREDS && Boolean(MAIL_TO);
66
+
67
+ if (!LIVE_SMS) {
68
+ console.log(
69
+ WANT_SMS
70
+ ? "skip: taqnyat SMS OTP live (missing TAQNYAT_TOKEN/TAQNYAT_SENDER or OKE_TEST_TAQNYAT_PHONE)"
71
+ : "skip: taqnyat SMS OTP live (OKE_SMS_LIVE≠1)",
72
+ );
73
+ }
74
+ if (!LIVE_MAIL) {
75
+ console.log(
76
+ WANT_MAIL
77
+ ? "skip: taqnyat mail live (missing TAQNYAT_MAIL_TOKEN/TAQNYAT_CAMPAIGN or OKE_TEST_TAQNYAT_MAIL)"
78
+ : "skip: taqnyat mail live (OKE_EMAIL_LIVE≠1)",
79
+ );
80
+ }
81
+
82
+ const liveSms = LIVE_SMS ? test : test.skip;
83
+ const liveMail = LIVE_MAIL ? test : test.skip;
84
+
85
+ describe("taqnyat live — provider-managed OTP (Taqnyat Verify)", () => {
86
+ liveSms(
87
+ "phone OTP request → real sendOtp → Taqnyat success code 5",
88
+ async () => {
89
+ const app = oke({
90
+ name: `taqnyat-live-${crypto.randomUUID()}`,
91
+ env: "test",
92
+ registry: "ignore",
93
+ gate: { auth: { secret: SECRET } },
94
+ config: {
95
+ drivers: { channel: { sms: { test: "taqnyat" } } },
96
+ },
97
+ }).plug(phoneNumber());
98
+ await app.boot({ env: "test" });
99
+
100
+ // Wrap the live transport so the plugin's single real send also proves
101
+ // the provider accepted it (success code 5) — exactly one SMS, never two.
102
+ const sms = app.bootResult?.channel?.drivers.find((d) => d.id === "taqnyat")?.smsTransport as
103
+ | {
104
+ sendOtp(o: {
105
+ to: string;
106
+ requestId: string;
107
+ lang?: "en" | "ar";
108
+ }): Promise<{ code: number }>;
109
+ }
110
+ | undefined;
111
+ expect(sms).toBeDefined();
112
+ const realSendOtp = sms!.sendOtp.bind(sms);
113
+ let providerCode: number | undefined;
114
+ sms!.sendOtp = async (opts) => {
115
+ const result = await realSendOtp(opts);
116
+ providerCode = result.code;
117
+ return result;
118
+ };
119
+
120
+ const res = await app.fetch(
121
+ jsonPost("/auth/phone/request", { phone: SMS_PHONE, lang: "en" }),
122
+ );
123
+ const body = (await res.json()) as { data?: { ok: true }; error?: { message?: string } };
124
+ if (res.status !== 200) {
125
+ console.log("taqnyat sendOtp response", res.status, body);
126
+ }
127
+ expect(res.status).toBe(200);
128
+ expect(body.data?.ok).toBe(true);
129
+ // Local dev leak must stay off in the provider path.
130
+ expect((body.data as { devOtp?: string }).devOtp).toBeUndefined();
131
+ // Taqnyat Verify documented success code — from the plugin's own send.
132
+ expect(providerCode).toBe(5);
133
+
134
+ await app.stop();
135
+ },
136
+ 30_000,
137
+ );
138
+ });
139
+
140
+ describe("taqnyat live — magic-link via Taqnyat Mail", () => {
141
+ liveMail(
142
+ "magic-link request → real TaqnyatMailTransport send",
143
+ async () => {
144
+ const app = oke({
145
+ name: `taqnyat-mail-live-${crypto.randomUUID()}`,
146
+ env: "test",
147
+ registry: "ignore",
148
+ gate: { auth: { secret: SECRET } },
149
+ config: {
150
+ drivers: { channel: { email: { test: "taqnyat-mail" } } },
151
+ },
152
+ }).plug(magicLink({ baseUrl: "http://app.test:6530" }));
153
+ await app.boot({ env: "test" });
154
+
155
+ const res = await app.fetch(jsonPost("/auth/magic-link/request", { email: MAIL_TO }));
156
+ const body = (await res.json()) as { data?: { ok: true }; error?: { message?: string } };
157
+ if (res.status !== 200) {
158
+ console.log("taqnyat mail send response", res.status, body);
159
+ }
160
+ expect(res.status).toBe(200);
161
+ expect(body.data?.ok).toBe(true);
162
+ expect((body.data as { devToken?: string }).devToken).toBeUndefined();
163
+
164
+ // A successful send is recorded in the Channel receipt ledger.
165
+ const receipts = app.bootResult?.channel?.receipts.all() ?? [];
166
+ const sent = receipts.find((r) => r.to === MAIL_TO);
167
+ expect(sent?.status === "sent" || sent?.status === "fallback").toBe(true);
168
+ expect(sent?.driverId).toContain("taqnyat");
169
+
170
+ await app.stop();
171
+ },
172
+ 30_000,
173
+ );
174
+ });