okengine 0.7.0 → 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 (61) hide show
  1. package/package.json +2 -2
  2. package/site/content/docs/elements/channel.mdx +23 -12
  3. package/site/content/docs/elements/clock.mdx +17 -15
  4. package/site/content/docs/elements/flow.mdx +6 -2
  5. package/site/content/docs/elements/store.mdx +131 -0
  6. package/site/content/docs/get-started/installation.mdx +18 -16
  7. package/site/content/docs/plugins/magic-link.mdx +42 -0
  8. package/site/content/docs/plugins/phone-number.mdx +78 -17
  9. package/site/content/docs/plugins/two-factor.mdx +1 -0
  10. package/site/content/docs/reference/cli.md +2 -0
  11. package/site/content/docs/reference/configuration.mdx +5 -3
  12. package/site/content/docs/reference/environment-variables.mdx +20 -8
  13. package/src/cli/db-seed.ts +359 -0
  14. package/src/cli/db.test.ts +341 -3
  15. package/src/cli/db.ts +75 -8
  16. package/src/cli/load-config.images.test.ts +22 -0
  17. package/src/cli/load-config.ts +7 -2
  18. package/src/cli/registry.ts +37 -1
  19. package/src/compiler/effects-infer.ts +1 -0
  20. package/src/config/index.ts +4 -0
  21. package/src/drivers/channel-sently.test.ts +8 -0
  22. package/src/drivers/channel-taqnyat-mail.ts +34 -0
  23. package/src/drivers/channel-types.ts +71 -0
  24. package/src/drivers/clock-postgres.test.ts +258 -0
  25. package/src/drivers/clock-postgres.ts +410 -0
  26. package/src/drivers/index.ts +18 -0
  27. package/src/drivers/journal-postgres.test.ts +175 -0
  28. package/src/drivers/journal-postgres.ts +492 -0
  29. package/src/elements/channel/runtime.ts +51 -0
  30. package/src/elements/channel.test.ts +71 -0
  31. package/src/elements/clock/chaos-child.ts +280 -41
  32. package/src/elements/clock/durable.ts +7 -0
  33. package/src/elements/clock/reconcile.ts +2 -2
  34. package/src/elements/clock/runtime.ts +5 -3
  35. package/src/elements/clock.ts +1 -1
  36. package/src/elements/store/seed.test.ts +27 -0
  37. package/src/elements/store/seed.ts +68 -0
  38. package/src/elements/store/sql-session.test.ts +39 -0
  39. package/src/elements/store/sql-session.ts +55 -0
  40. package/src/elements/store/upsert-app.test.ts +103 -0
  41. package/src/elements/store.ts +5 -0
  42. package/src/index.ts +15 -0
  43. package/src/kernel/app.ts +165 -14
  44. package/src/kernel/boot-bind/channel.test.ts +16 -0
  45. package/src/kernel/boot-bind/channel.ts +13 -0
  46. package/src/kernel/boot-bind/clock.ts +17 -6
  47. package/src/kernel/boot-bind/honor-config.test.ts +105 -4
  48. package/src/kernel/boot-bind/journal.ts +89 -0
  49. package/src/kernel/boot.test.ts +6 -4
  50. package/src/kernel/boot.ts +53 -13
  51. package/src/kernel/concurrency.ts +1 -1
  52. package/src/kernel/fx.test.ts +6 -0
  53. package/src/kernel/fx.ts +126 -5
  54. package/src/kernel/index.ts +6 -0
  55. package/src/kernel/journal-boot.test.ts +397 -0
  56. package/src/kernel/journal-suspend.ts +35 -0
  57. package/src/kernel/journal.test.ts +142 -0
  58. package/src/kernel/journal.ts +202 -27
  59. package/src/plugins/auth-methods.security.test.ts +10 -7
  60. package/src/plugins/phone-number.ts +67 -10
  61. package/src/plugins/taqnyat.live.test.ts +174 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okengine",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "One law. Eight elements. Ten exports. One package. One manifest. Every backend need is derived, never added.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -73,7 +73,7 @@
73
73
  "ajv-formats": "^3.0.1",
74
74
  "intl-messageformat": "^11.2.13",
75
75
  "oxc-parser": "^0.142.0",
76
- "sently": "1.0.0"
76
+ "sently": "1.2.0"
77
77
  },
78
78
  "devDependencies": {
79
79
  "@base-ui/react": "^1.6.0",
@@ -132,23 +132,34 @@ images: {
132
132
  },
133
133
  ```
134
134
 
135
- | Driver | Medium | Behavior |
136
- | ---------- | -------- | ------------------------------------------------------------------- |
137
- | `console` | any | Captures into a readable inbox — local + tests |
138
- | `smtp` | email | Real SMTP — Mailpit in docker, your provider in prod |
139
- | `resend` | email | Resend HTTP API (`RESEND_API_KEY`) |
140
- | `sndr` | email | SNDR HTTP API (`SNDR_API_KEY`) |
141
- | `taqnyat` | sms | Taqnyat SMS (`TAQNYAT_BEARER_TOKEN` + `TAQNYAT_SENDER`) |
142
- | `msegat` | sms | Msegat SMS (`MSEGAT_USERNAME` + `MSEGAT_API_KEY` + `MSEGAT_SENDER`) |
143
- | `unifonic` | sms | Unifonic el.cloud SMS (`UNIFONIC_APPSID`) |
144
- | `wa-cloud` | whatsapp | Meta WhatsApp Cloud API |
145
- | `webpush` | push | Web Push + VAPID (`createPushSender` → `WebPushTransport`) |
146
- | `fcm` | push | Firebase Cloud Messaging HTTP v1 |
135
+ | Driver | Medium | Behavior |
136
+ | -------------- | -------- | ------------------------------------------------------------------- |
137
+ | `console` | any | Captures into a readable inbox — local + tests |
138
+ | `smtp` | email | Real SMTP — Mailpit in docker, your provider in prod |
139
+ | `resend` | email | Resend HTTP API (`RESEND_API_KEY`) |
140
+ | `sndr` | email | SNDR HTTP API (`SNDR_API_KEY`) |
141
+ | `taqnyat-mail` | email | Taqnyat Mail API (`TAQNYAT_MAIL_TOKEN` + `TAQNYAT_CAMPAIGN`) |
142
+ | `taqnyat` | sms | Taqnyat SMS (`TAQNYAT_BEARER_TOKEN` + `TAQNYAT_SENDER`) |
143
+ | `msegat` | sms | Msegat SMS (`MSEGAT_USERNAME` + `MSEGAT_API_KEY` + `MSEGAT_SENDER`) |
144
+ | `unifonic` | sms | Unifonic el.cloud SMS (`UNIFONIC_APPSID`) |
145
+ | `wa-cloud` | whatsapp | Meta WhatsApp Cloud API |
146
+ | `webpush` | push | Web Push + VAPID (`createPushSender` → `WebPushTransport`) |
147
+ | `fcm` | push | Firebase Cloud Messaging HTTP v1 |
147
148
 
148
149
  Email fallback uses ordered email transports; SMS with multiple SMS drivers uses
149
150
  the same failover path. Credentials for boot-opened drivers are listed under
150
151
  [Environment Variables](/docs/reference/environment-variables).
151
152
 
153
+ ### Provider-managed OTP
154
+
155
+ Flows can also send a provider-generated SMS code with `fx.sendOtp({ to, requestId, lang? })`
156
+ and check it with `fx.verifyOtp({ to, requestId, code })` — Taqnyat's Verify API. Both are
157
+ capability-gated `send` effects and dry-run safe.
158
+
159
+ They dispatch only when the bound SMS driver supports Verify (`taqnyat`); any other SMS
160
+ driver fails loudly instead of silently falling back to a self-generated code. The
161
+ [Phone number](/docs/plugins/phone-number) plugin uses this path automatically.
162
+
152
163
  `webpush` needs VAPID keys — open it yourself and include it in
153
164
  `BootOptions.channel.drivers` (boot does not open push from env):
154
165
 
@@ -73,7 +73,7 @@ export const sendDaily = on(
73
73
  ```typescript
74
74
  do: async (input, fx) => {
75
75
  const now = fx.clock.now(); // epoch-ms, injectable
76
- await fx.clock.sleep("wait-for-payment", "7d"); // durable — survives restarts
76
+ await fx.clock.sleep("wait-for-payment", "7d"); // durable — survives restarts on a shared journal
77
77
  };
78
78
  ```
79
79
 
@@ -103,13 +103,13 @@ difference.
103
103
  <ClockSleep />
104
104
 
105
105
  `fx.clock.sleep(label, duration)` is a **durable** sleep: in a `durable: true` flow the wake time is
106
- journaled, so the flow resumes after restarts and deploys instead of losing its place. The `label`
107
- names the step in the journal it is what the Console shows when you inspect a sleeping run.
106
+ journaled with a shared journal (`drivers.journal: "postgres"`) the flow resumes after restarts and
107
+ deploys; the `memory` default resumes within the same process only. The `label` names the step — the Console shows it on sleeping runs.
108
108
 
109
109
  In a non-durable flow the same call resolves immediately, so code reads identically in tests.
110
110
 
111
- Completed journal steps never re-run on resume — a process killed mid-flow continues at the next
112
- unfinished step.
111
+ Completed journal steps never re-run on resume — with a shared journal, a process killed mid-flow is
112
+ reclaimed after its lease expires and continues at the next unfinished step.
113
113
 
114
114
  ## What the runtime guarantees
115
115
 
@@ -123,8 +123,8 @@ unfinished step.
123
123
  | DST detection | Ambiguous local times get a Console warning — schedules are not rewritten or blocked |
124
124
 
125
125
  **Leader election needs a shared store.** The default in-memory CronStore is per process. Multi-replica
126
- exclusivity only holds when every instance opens the same durable CronStore (file-backed store is
127
- available for that; a Postgres cron driver is not wired yet).
126
+ exclusivity holds when every instance opens the same durable CronStore — `postgres` (SKIP LOCKED +
127
+ lease reclaim, docker/prod default) or `file` (shared volume, single-host / local).
128
128
 
129
129
  **Catch-up example:** an hourly clock down for five hours reports `missedRuns: 5` and
130
130
  `catchUp: "one"`, then a single tick runs the handler once.
@@ -141,15 +141,16 @@ Detection attaches `dstAmbiguity` on the cron row and the Console shows it. `oke
141
141
 
142
142
  ```typescript title="oke.config.ts"
143
143
  drivers: {
144
- clock: { local: "memory", docker: "file", test: "frozen", prod: "file" },
144
+ clock: { local: "memory", docker: "postgres", test: "frozen", prod: "postgres" },
145
145
  },
146
146
  ```
147
147
 
148
- | Driver | Behavior |
149
- | -------- | ---------------------------------------------------------------------------------------- |
150
- | `memory` | In-process CronStore + timers — single process; lost on exit |
151
- | `file` | On-disk CronStore (`.oke/crons.json`) — multi-process leader election on a shared volume |
152
- | `frozen` | Deterministic test clocktime advances only when the test says so |
148
+ | Driver | Behavior |
149
+ | ---------- | ---------------------------------------------------------------------------------------------- |
150
+ | `memory` | In-process CronStore + timers — single process; lost on exit |
151
+ | `postgres` | Postgres CronStore (`oke_crons`) — multi-host leader election via SKIP LOCKED + lease reclaim |
152
+ | `file` | On-disk CronStore (`.oke/crons.json`)multi-process on a shared volume (local / single-host) |
153
+ | `frozen` | Deterministic test clock — time advances only when the test says so |
153
154
 
154
155
  `frozen` is why the no-`Date.now()` rule pays off: tests inject time travel through `fx.clock` and every flow obeys it automatically.
155
156
 
@@ -179,12 +180,13 @@ The clock was declared without `overridable: true`. Add it and redeploy — the
179
180
  </Accordion>
180
181
  <Accordion title="How do I run something once, later — not recurring?">
181
182
 
182
- Emit it from inside a flow with `fx.clock.sleep(label, duration)` before the work, in a `durable: true` flow. The sleep survives restarts, so "remind me in 7 days" is one line, not a cron row.
183
+ Emit it from inside a flow with `fx.clock.sleep(label, duration)` before the work, in a `durable: true` flow. The sleep survives restarts on a shared journal, so "remind me in 7 days" is one line, not a cron row.
183
184
 
184
185
  </Accordion>
185
186
  <Accordion title="Two replicas both ran my cron">
186
187
 
187
- They are not sharing one CronStore. In-memory leases are per process. Point every replica at the same durable cron store, or run a single scheduler instance until the Postgres clock driver ships.
188
+ They are not sharing one CronStore. In-memory leases are per process. Point every replica at the same
189
+ `postgres` CronStore (`DATABASE_URL`), or use `file` on a shared volume for single-host multi-process.
188
190
 
189
191
  </Accordion>
190
192
  </Accordions>
@@ -219,14 +219,18 @@ export const chargeOrder = flow({
219
219
  stripe(fx.vault(stripeKey).reveal()).create(orderId),
220
220
  );
221
221
 
222
- await fx.clock.sleep("verify-window", "2m"); // survives restart and deploy
222
+ await fx.clock.sleep("verify-window", "2m"); // journals wakeAt in a durable flow
223
223
 
224
224
  return fx.step("confirm", () => stripe(fx.vault(stripeKey).reveal()).confirm(intent));
225
225
  },
226
226
  });
227
227
  ```
228
228
 
229
- **Consequence:** kill the process between the two steps and the run **resumes at `confirm`** completed steps replay from the journal, so the card is not charged twice. This is verified by the engine's own test suite: after resume, `create-intent` has run exactly once.
229
+ **Consequence:** completed steps replay from the journal on resume, `create-intent` does not re-run, so the card is not charged twice.
230
+
231
+ **Crash recovery needs a shared journal.** Set `drivers.journal: "postgres"` (the docker/prod template default) and every run persists in `oke_journal_runs` under a per-run lease: boot scans for orphaned `running` / due `sleeping` runs and resumes them, and the lease lets exactly one instance execute a run at a time. The default `memory` journal is per process — replay holds for that process only.
232
+
233
+ Replay is **at-least-once** for a step whose body never persisted (crash mid-step, lease overrun): that step re-runs on reclaim, so keep step bodies short or internally idempotent. A client transport retry starts a _new_ run with a fresh `runId` — request-level idempotency is an application concern, not the journal's.
230
234
 
231
235
  For flaky sub-steps, wrap the work in `fx.retry` **inside** `fx.step` so a completed charge is never retried on resume. Coarse whole-body retry is also available as `flow({ retry: { retries, delay, backoff, jitter } })` and reuses the same journal session.
232
236
 
@@ -239,6 +239,137 @@ not when emit rewrites `schema.generated.ts`.
239
239
  Opt out with `--no-db-push` or `db: { autoPush: false }`. Docker/prod **never**
240
240
  auto-apply DDL; a missing table fails as **OKE1101** (`oke db migrate`).
241
241
 
242
+ #### Multiple environments
243
+
244
+ Local can `oke db push` freely — safe to wipe and regenerate.
245
+
246
+ Staging/prod accumulate versioned SQL under `drizzle/` (`oke db generate`).
247
+ `oke db migrate` applies only unrecorded files in order — behind DBs catch up.
248
+
249
+ | Environment | Sync |
250
+ | -------------- | -------------------------------------------------------------- |
251
+ | Local | `oke db push` (or auto-push from `oke dev`) |
252
+ | Staging / prod | `oke db generate` → review files → `oke db migrate` on that DB |
253
+
254
+ #### Seeding
255
+
256
+ `oke db seed` loads `defineSeed` from `src/seed/index.ts` — **never at boot**.
257
+ Seed proves existence (bootstrap rows); it does not correct schema-adjacent data over time.
258
+
259
+ <StoreSeeding />
260
+
261
+ | env | `essential` | `dev` | `prod` |
262
+ | -------- | ----------- | ----- | ------ |
263
+ | `local` | yes | yes | no |
264
+ | `docker` | yes | yes | no |
265
+ | `test` | yes | no | no |
266
+ | `prod` | yes | no | yes |
267
+
268
+ `docker` is a laptop profile with prod-shaped drivers — still development, so `dev` runs.
269
+ Outside `test`, exactly one of `dev` / `prod` runs with `essential`.
270
+
271
+ **Simple form** — everything inline:
272
+
273
+ ```ts
274
+ import { defineSeed, type Fx } from "okengine";
275
+ import { db } from "../core";
276
+ import { notes } from "../schema.decl";
277
+
278
+ export default defineSeed({
279
+ essential: async (fx: Fx) => {
280
+ await fx
281
+ .store(db)
282
+ .upsert(
283
+ notes,
284
+ { id: "welcome" },
285
+ { id: "welcome", title: "Welcome", body: "…", createdAt: 1 },
286
+ );
287
+ },
288
+ dev: async (fx: Fx) => {
289
+ await fx
290
+ .store(db)
291
+ .upsert(
292
+ notes,
293
+ { id: "sample-ideas" },
294
+ { id: "sample-ideas", title: "Ideas", body: "…", createdAt: 2 },
295
+ );
296
+ },
297
+ // prod: async (fx) => { /* e.g. register a real external webhook URL */ },
298
+ });
299
+ ```
300
+
301
+ **Complex form** — one function per file, compose with arrays (order = run order):
302
+
303
+ ```ts
304
+ import { defineSeed } from "okengine";
305
+ import { seedWelcome } from "./essential/welcome";
306
+ import { seedSamples } from "./dev/samples";
307
+ import { seedWebhook } from "./prod/webhook";
308
+
309
+ export default defineSeed({
310
+ essential: [seedWelcome],
311
+ dev: [seedSamples],
312
+ prod: [seedWebhook],
313
+ });
314
+ ```
315
+
316
+ | `upsert` outcome | When |
317
+ | ----------------- | ------------------------------------------ |
318
+ | `upserted` | no row matched `matchOn` — insert |
319
+ | `already-existed` | match found; default leaves it alone |
320
+ | `changed` | match found and `{ onExisting: "update" }` |
321
+
322
+ Default upsert is **insert-if-missing**. Pass `{ onExisting: "update" }` only when this
323
+ call should also rewrite other columns on a match — opt-in per call, never global.
324
+
325
+ ```ts
326
+ // Safe bootstrap — second seed run leaves the row alone
327
+ await fx.store(db).upsert(
328
+ notes,
329
+ { id: "welcome" },
330
+ {
331
+ id: "welcome",
332
+ title: "Welcome",
333
+ body: "Your Notes API is ready.",
334
+ createdAt: 1,
335
+ },
336
+ );
337
+
338
+ // Refresh copy on every seed — match stays; title/body are rewritten
339
+ await fx.store(db).upsert(
340
+ notes,
341
+ { id: "welcome" },
342
+ {
343
+ id: "welcome",
344
+ title: "Welcome (updated)",
345
+ body: "New bootstrap copy.",
346
+ createdAt: 1,
347
+ },
348
+ { onExisting: "update" },
349
+ );
350
+ ```
351
+
352
+ **Consequence:** default keeps operator edits safe across re-seeds; `onExisting: "update"`
353
+ is for seed-owned rows you intentionally overwrite. Schema-wide data fixes still belong in
354
+ migrations, not seed.
355
+
356
+ | Env | Confirm |
357
+ | ----------------- | ----------------------------------------------------- |
358
+ | `local` / `test` | none |
359
+ | `docker` / `prod` | print DB target; type the env name (`--force` for CI) |
360
+
361
+ ```bash
362
+ oke db seed # resolve env like other oke db commands (.oke/mode)
363
+ oke db seed --env docker # explicit override
364
+ oke db seed --env prod --force
365
+ ```
366
+
367
+ <Callout title="Seed vs migrate">
368
+ Permanent reference-data corrections go through `oke db generate` → `migrate`, not repeated seed.
369
+ Optional `drizzle-seed` may be used inside a `dev` function body only — never as the seed system
370
+ backbone.
371
+ </Callout>
372
+
242
373
  <Callout title="Escape hatch">
243
374
  Hand-written Drizzle in `src/schema.ts` stays supported — if there is nothing to emit, the emit
244
375
  step is skipped and your file is used as-is. Plugins may contribute **whole new tables**;
@@ -60,17 +60,18 @@ bunx create-oke@latest my-app --yes
60
60
  ```
61
61
 
62
62
  Two Notes starters: **standard** (local-first) and **advanced** (docker-ready +
63
- files/digest/AI). On a TTY: pick a template, then **recommended defaults**,
64
- **customize** (choose **local** or **docker** first, walk that side’s facets,
65
- optionally customize the other; saved to `~/.oke/create-defaults.json`), or
66
- **reuse** when saved settings match that template. The project-name step rejects
67
- a path that already exists and is not empty.
68
-
69
- Non-TTY / `--yes` stay scriptable. Default pins: local SQLite · Docker/prod
70
- Postgres. `--sql postgres` pins SQL envs; `--ai` runs the AI model wizard
71
- **before** install. Scaffold writes `.oke/mode` from the primary side
72
- (recommended: standard → `local`, advanced → `docker`) so the first `oke dev`
73
- skips the mode prompt unless you delete that file or pass `--local` / `--docker`.
63
+ files/digest/AI).
64
+
65
+ On a TTY: pick a template, then **recommended defaults**, **customize** (local
66
+ or docker first; saved to `~/.oke/create-defaults.json`), or **reuse** matching
67
+ saved settings. Project name rejects a non-empty existing path.
68
+
69
+ Non-TTY / `--yes` stay scriptable. Defaults: local SQLite · Docker/prod Postgres.
70
+ `--sql postgres` pins SQL; `--ai` runs the model wizard before install.
71
+
72
+ Scaffold writes `.oke/mode` (standard → `local`, advanced → `docker`) so the
73
+ first `oke dev` skips the mode prompt unless you delete it or pass `--local` /
74
+ `--docker`.
74
75
 
75
76
  </Step>
76
77
 
@@ -86,11 +87,12 @@ Four ports come up together (mnemonic: **O·K·E = 6·5·3**):
86
87
 
87
88
  <Surfaces />
88
89
 
89
- Open `http://localhost:6533`. On first boot, claim the Console with the
90
- **claim code** printed once in the `oke dev` log, then create the first
91
- operator. Password policy matches Gate auth: **≥ 12 characters, with a letter
92
- and a number.** After that, if the Console lists your flows, the install
93
- worked — **derived, not configured.**
90
+ Open `http://localhost:6533`. Claim the Console with the **claim code** printed
91
+ once in the `oke dev` log, then create the first operator.
92
+
93
+ Password policy matches Gate auth: **≥ 12 characters, with a letter and a
94
+ number.** If the Console lists your flows, the install worked — **derived, not
95
+ configured.**
94
96
 
95
97
  </Step>
96
98
 
@@ -86,6 +86,48 @@ const { data } = await api.auth.verifyMagicLink({ token });
86
86
  **Consequence:** the plugin contributes the `auth-magic-link` Channel template and EN/AR
87
87
  catalog bodies (`{{link}}`, `{{token}}`). Override copy by merging your own catalog at boot.
88
88
 
89
+ ## Delivery drivers
90
+
91
+ | `drivers.channel.email` | Delivery |
92
+ | ----------------------- | ----------------------------------------------------------------- |
93
+ | `console` | Dev inbox (local/test default) |
94
+ | `smtp` | Any SMTP host — Mailpit under `oke dev --docker` (docker default) |
95
+ | `resend` / `sndr` | Hosted email APIs |
96
+ | `taqnyat-mail` | Taqnyat Mail API (additive option) |
97
+
98
+ ### Taqnyat Mail
99
+
100
+ ```typescript title="oke.config.ts"
101
+ export default {
102
+ drivers: {
103
+ channel: {
104
+ email: { local: "console", docker: "smtp", test: "console", prod: "taqnyat-mail" },
105
+ },
106
+ },
107
+ };
108
+ ```
109
+
110
+ | Env | Meaning |
111
+ | -------------------- | ------------------------------------------------ |
112
+ | `TAQNYAT_MAIL_TOKEN` | Taqnyat bearer token enabled for Email |
113
+ | `TAQNYAT_CAMPAIGN` | Campaign name required by Taqnyat `mailSend.php` |
114
+
115
+ The plugin needs no change — delivery stays Channel-mediated via `fx.send`. SMTP/Mailpit
116
+ remains the default docker path; `taqnyat-mail` is strictly additive.
117
+
118
+ ## Live tests (opt-in)
119
+
120
+ The Taqnyat live suite sends real email and burns real quota, so it is double-gated: it runs
121
+ only when `OKE_EMAIL_LIVE=1` **and** the real credentials (`TAQNYAT_MAIL_TOKEN`,
122
+ `TAQNYAT_CAMPAIGN`, plus `OKE_TEST_TAQNYAT_MAIL`) are all present.
123
+
124
+ Credentials alone never send; without the flag the suite skips visibly — never a silent pass.
125
+
126
+ ```bash
127
+ OKE_EMAIL_LIVE=1 TAQNYAT_MAIL_TOKEN=… TAQNYAT_CAMPAIGN=auth \
128
+ OKE_TEST_TAQNYAT_MAIL=you@example.com bun test src/plugins
129
+ ```
130
+
89
131
  ## Troubleshooting
90
132
 
91
133
  <Accordions>
@@ -1,16 +1,18 @@
1
1
  ---
2
2
  title: "Phone number"
3
- description: "Official plugin — E.164 phone OTP request and verify under /auth."
3
+ description: "Official plugin — E.164 phone OTP request and verify under /auth, with provider-managed delivery via Taqnyat Verify."
4
4
  icon: "Smartphone"
5
5
  source: "docs/spec/unified-theory.md"
6
6
  ---
7
7
 
8
- `phoneNumber()` signs people in with a phone number in E.164 form (`+15551234567`). Codes are
9
- 6 digits, hashed, 10 minutes, 5 attempts same security shape as email OTP.
8
+ `phoneNumber()` signs people in with a phone number in E.164 form (`+15551234567`). Delivery
9
+ has two shapes: provider-managed OTP through Taqnyat Verify when an SMS driver is bound, or a
10
+ local hashed code (10 minutes, 5 attempts) you deliver yourself.
10
11
 
11
12
  <Callout title="The one rule">
12
- Enable `gate.auth`, then `.plug(phoneNumber())`. Reject non-E.164 numbers. SMS Channel delivery is
13
- not wired yet use `exposeDevOtp` locally. Prefer stronger methods when phone is the only factor.
13
+ Enable `gate.auth`, then `.plug(phoneNumber())`. For real SMS set `drivers.channel.sms` to
14
+ `taqnyat`the plugin uses Taqnyat's Verify API, never a self-generated code. Use `exposeDevOtp`
15
+ only when no SMS driver is bound.
14
16
  </Callout>
15
17
 
16
18
  ## Quick start
@@ -40,7 +42,8 @@ export const app = oke({
40
42
  const { data, error } = await api.auth.requestPhoneOtp({ phone: "+15551234567" });
41
43
  ```
42
44
 
43
- `POST /auth/phone/request`. Invalid E.164 → `AuthFailed` / `invalid_phone`.
45
+ `POST /auth/phone/request`. Invalid E.164 → `AuthFailed` / `invalid_phone`. Optional `lang:
46
+ "en" | "ar"` picks the OTP message language on the Taqnyat path.
44
47
 
45
48
  </Step>
46
49
 
@@ -60,16 +63,51 @@ const { data } = await api.auth.verifyPhoneOtp({
60
63
 
61
64
  </Steps>
62
65
 
66
+ ## Delivery
67
+
68
+ | Bound SMS driver | Request behavior | Verify behavior |
69
+ | -------------------- | ---------------------------------------------------------- | ------------------------------ |
70
+ | `taqnyat` | Taqnyat Verify `sendOtp` (provider generates the code) | Taqnyat Verify `verifyOtp` |
71
+ | none | Local 6-digit code, hashed at rest (`exposeDevOtp` for DX) | Local hash compare, 5 attempts |
72
+ | any other SMS driver | Fails loudly — provider-managed OTP unsupported | — |
73
+
74
+ **Consequence:** the request response never contains `devOtp` on the Taqnyat path — the code
75
+ exists only inside the SMS Taqnyat sends.
76
+
77
+ ### Taqnyat Verify
78
+
79
+ ```typescript title="oke.config.ts"
80
+ export default {
81
+ drivers: {
82
+ channel: {
83
+ sms: { local: "console", docker: "taqnyat", test: "console", prod: "taqnyat" },
84
+ },
85
+ },
86
+ };
87
+ ```
88
+
89
+ | Env | Meaning |
90
+ | ---------------------- | -------------------------------------------- |
91
+ | `TAQNYAT_BEARER_TOKEN` | Taqnyat bearer token (`TAQNYAT_TOKEN` alias) |
92
+ | `TAQNYAT_SENDER` | Pre-approved sender id |
93
+
94
+ Request stores a provider challenge (`taqnyat:<requestId>`, 10m TTL, 5 attempts) and calls
95
+ `fx.sendOtp`; verify replays the same `requestId` with the code through `fx.verifyOtp`. Both
96
+ are capability-gated `send` effects — dry-run records them without contacting Taqnyat.
97
+
98
+ An SMS driver without Verify support (for example a generic SMS sender) makes the request fail
99
+ with a clear error: use `exposeDevOtp` locally or switch `drivers.channel.sms` to `taqnyat`.
100
+
63
101
  ## Options
64
102
 
65
- | Option | Type | Default | Meaning |
66
- | --------------- | ------------------- | -------- | --------------------------------------- |
67
- | `secret` | `string` | active\* | HMAC secret (\*from `gate.auth`) |
68
- | `sessions` | `SessionStore` | active\* | Session store |
69
- | `ttlMs` | `number` | 10m | Challenge lifetime |
70
- | `exposeDevOtp` | `boolean` | `false` | Include raw OTP in the request response |
71
- | `phones` | `PhoneStore` | new | Phone → userId map |
72
- | `verifications` | `VerificationStore` | new | Challenge store |
103
+ | Option | Type | Default | Meaning |
104
+ | --------------- | ------------------- | -------- | --------------------------------------------------------- |
105
+ | `secret` | `string` | active\* | HMAC secret (\*from `gate.auth`) |
106
+ | `sessions` | `SessionStore` | active\* | Session store |
107
+ | `ttlMs` | `number` | 10m | Challenge lifetime |
108
+ | `exposeDevOtp` | `boolean` | `false` | Include raw OTP in the request response (local path only) |
109
+ | `phones` | `PhoneStore` | new | Phone → userId map |
110
+ | `verifications` | `VerificationStore` | new | Challenge store |
73
111
 
74
112
  ## Surfaces
75
113
 
@@ -80,6 +118,21 @@ const { data } = await api.auth.verifyPhoneOtp({
80
118
 
81
119
  E.164 pattern: `^\+[1-9]\d{7,14}$`.
82
120
 
121
+ ## Live tests (opt-in)
122
+
123
+ The Taqnyat live suite sends real SMS and burns real quota, so it is double-gated: it runs only when
124
+ `OKE_SMS_LIVE=1` **and** the real credentials (`TAQNYAT_TOKEN`, `TAQNYAT_SENDER`, plus
125
+ `OKE_TEST_TAQNYAT_PHONE` — `TAQNYAT_TO` also accepted) are all present. Credentials alone never send.
126
+
127
+ The suite sends exactly one SMS and never asks for the code.
128
+
129
+ ```bash
130
+ OKE_SMS_LIVE=1 TAQNYAT_TOKEN=… TAQNYAT_SENDER=YourBrand \
131
+ OKE_TEST_TAQNYAT_PHONE=+9665xxxxxxxx bun test src/plugins
132
+ ```
133
+
134
+ Without the flag the suite skips visibly — never a silent pass.
135
+
83
136
  ## Troubleshooting
84
137
 
85
138
  <Accordions>
@@ -88,10 +141,18 @@ E.164 pattern: `^\+[1-9]\d{7,14}$`.
88
141
  Number must start with `+` and a non-zero country digit, then 7–14 more digits. No spaces or
89
142
  dashes.
90
143
 
144
+ </Accordion>
145
+ <Accordion title="SMS driver does not support provider-managed OTP">
146
+
147
+ The configured `drivers.channel.sms` driver has no Verify API. Either switch to `taqnyat` or
148
+ remove the SMS driver so the local hashed path (`exposeDevOtp`) is used.
149
+
91
150
  </Accordion>
92
151
  <Accordion title="No SMS arrived">
93
152
 
94
- v1 does not send SMS. Use `exposeDevOtp` in local/test, or deliver via your own Channel Flow.
153
+ On the local path no SMS is sent by design use `exposeDevOtp`, or bind `taqnyat` with valid
154
+ `TAQNYAT_*` credentials. On the Taqnyat path check the sender id is pre-approved and the
155
+ number is E.164.
95
156
 
96
157
  </Accordion>
97
158
  </Accordions>
@@ -99,8 +160,8 @@ v1 does not send SMS. Use `exposeDevOtp` in local/test, or deliver via your own
99
160
  ## Learn more
100
161
 
101
162
  - [Email OTP](/docs/plugins/email-otp) — email variant of the same OTP rules
102
- - [Gate](/docs/elements/gate) — `gate.auth`
103
- - [Channel](/docs/elements/channel) — when you wire SMS yourself
163
+ - [Magic link](/docs/plugins/magic-link) — one-time email link sign-in
164
+ - [Channel](/docs/elements/channel) — SMS / email drivers and delivery
104
165
 
105
166
  ## Next
106
167
 
@@ -107,6 +107,7 @@ enable. A recovery code works once, then is consumed.
107
107
  - [Passkey](/docs/plugins/passkey) — WebAuthn register / authenticate
108
108
  - [Gate](/docs/elements/gate) — session + policies
109
109
  - [Username](/docs/plugins/username) — first factor to enroll against
110
+ - OTP over SMS or email instead of TOTP? See [Phone number](/docs/plugins/phone-number) / [Magic link](/docs/plugins/magic-link)
110
111
 
111
112
  ## Next
112
113
 
@@ -28,6 +28,8 @@ oke schema generate # core + plugin stubs → schema/oke.ts (--ch
28
28
  oke db push # domain schema.ts → live local DB (dev; drizzle-kit)
29
29
  oke db generate # versioned SQL under drizzle/ (review)
30
30
  oke db migrate # apply migrations (explicit; never auto in prod)
31
+ oke db seed # defineSeed (essential + env category); never at boot
32
+ oke db seed --env prod --force # CI: skip docker/prod confirmation prompt
31
33
  oke vault set STRIPE_KEY # also: list · import .env · key rotate
32
34
  oke client add <url> # types for a separate frontend repo
33
35
 
@@ -26,7 +26,8 @@ drivers: {
26
26
  files: { local: "fs", docker: "s3", test: "memory", prod: "s3" },
27
27
  },
28
28
  signal: { local: "memory", docker: "redis", test: "memory", prod: "redis" },
29
- clock: { local: "memory", docker: "file", test: "frozen", prod: "file" },
29
+ clock: { local: "memory", docker: "postgres", test: "frozen", prod: "postgres" },
30
+ journal: { local: "memory", docker: "postgres", test: "memory", prod: "postgres" },
30
31
  vault: { local: "env", docker: "openbao", test: "memory", prod: "openbao" },
31
32
  channel: {
32
33
  email: { local: "console", docker: "smtp", test: "console", prod: "smtp" },
@@ -42,9 +43,10 @@ drivers: {
42
43
  | `store.files` | env driver map | `memory` · `fs` · `s3` |
43
44
  | `store.index` | env driver map | `memory` · `pgvector` · `libsql` · `meilisearch` |
44
45
  | `signal` | env driver map | `memory` · `redis` (boot); `postgres` · `nats` fail loud until clients bind |
45
- | `clock` | env driver map | `memory` · `file` · `frozen` |
46
+ | `clock` | env driver map | `memory` · `postgres` · `file` · `frozen` |
47
+ | `journal` | env driver map | `memory` · `file` · `postgres` |
46
48
  | `vault` | env driver map | `env` · `openbao` · `memory` · `managed` |
47
- | `channel.email` | env driver map | `console` · `smtp` · `resend` · `sndr` |
49
+ | `channel.email` | env driver map | `console` · `smtp` · `resend` · `sndr` · `taqnyat-mail` |
48
50
  | `channel.sms` | env driver map | `console` · `taqnyat` · `msegat` · `unifonic` |
49
51
  | `channel.whatsapp` | env driver map | `console` · `wa-cloud` |
50
52
  | `channel.push` | env driver map | `console` · `webpush` · `fcm` |
@@ -13,14 +13,14 @@ OKE reads environment variables at boot for connection detail and secrets — ne
13
13
 
14
14
  ## SQL store
15
15
 
16
- | Variable | Used for | Default when unset |
17
- | ------------------- | ---------------------------------------------------- | ------------------------------- |
18
- | `DATABASE_URL` | Postgres connection (`postgres` driver, drizzle-kit) | `postgres://localhost:5432/oke` |
19
- | `OKE_STORE_SQL_URL` | Explicit SQL URL override | — |
20
- | `OKE_SQLITE_URL` | SQLite file path (`sqlite` driver) | `.oke/app.sqlite` |
21
- | `OKE_LIBSQL_URL` | libSQL URL or file path (`libsql` driver) | `.oke/app.libsql` |
22
- | `OKE_PGLITE_URL` | PGlite data dir or `memory://` (`pglite` driver) | `.oke/pgdata` |
23
- | `OKE_SQL_DRIVER` | Force the sql driver id at boot | config map |
16
+ | Variable | Used for | Default when unset |
17
+ | ------------------- | ----------------------------------------------------------------------------- | ------------------------------- |
18
+ | `DATABASE_URL` | Postgres connection (`postgres` store / clock / journal drivers, drizzle-kit) | `postgres://localhost:5432/oke` |
19
+ | `OKE_STORE_SQL_URL` | Explicit SQL URL override | — |
20
+ | `OKE_SQLITE_URL` | SQLite file path (`sqlite` driver) | `.oke/app.sqlite` |
21
+ | `OKE_LIBSQL_URL` | libSQL URL or file path (`libsql` driver) | `.oke/app.libsql` |
22
+ | `OKE_PGLITE_URL` | PGlite data dir or `memory://` (`pglite` driver) | `.oke/pgdata` |
23
+ | `OKE_SQL_DRIVER` | Force the sql driver id at boot | config map |
24
24
 
25
25
  ## Index store
26
26
 
@@ -74,6 +74,8 @@ Read when `drivers.channel.email` resolves to that driver id.
74
74
  | `RESEND_API_KEY` | `resend` API key |
75
75
  | `SNDR_API_KEY` | `sndr` API key |
76
76
  | `SNDR_BASE_URL` | Optional SNDR API origin (default `api.sndr.sh`) |
77
+ | `TAQNYAT_MAIL_TOKEN` | `taqnyat-mail` bearer token (Email-enabled) |
78
+ | `TAQNYAT_CAMPAIGN` | `taqnyat-mail` campaign name |
77
79
 
78
80
  ## Channel (SMS) — boot binder
79
81
 
@@ -94,6 +96,16 @@ Read when `drivers.channel.sms` resolves to that driver id (`console` opens noth
94
96
  WhatsApp (`wa-cloud`) and push (`webpush` / `fcm`) are not opened from env at
95
97
  boot — pass them on `BootOptions.channel.drivers` with their open options.
96
98
 
99
+ ## Live test gates (opt-in)
100
+
101
+ Provider-quota-burning live suites are double-gated: the medium flag **plus** that
102
+ provider's real credentials — credentials alone never send.
103
+
104
+ | Variable | Used for |
105
+ | ---------------- | --------------------------------------------------------- |
106
+ | `OKE_SMS_LIVE` | `=1` allows live SMS provider tests (e.g. Taqnyat OTP) |
107
+ | `OKE_EMAIL_LIVE` | `=1` allows live email provider tests (e.g. Taqnyat Mail) |
108
+
97
109
  ## AI providers
98
110
 
99
111
  | Variable | Used for | Default when unset |