okengine 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/package.json +3 -2
  2. package/site/content/docs/deployment/docker-swarm.mdx +228 -0
  3. package/site/content/docs/deployment/docker.mdx +212 -0
  4. package/site/content/docs/deployment/index.mdx +83 -0
  5. package/site/content/docs/deployment/kubernetes.mdx +176 -0
  6. package/site/content/docs/deployment/meta.json +5 -0
  7. package/site/content/docs/deployment/reverse-proxy.mdx +216 -0
  8. package/site/content/docs/elements/channel.mdx +25 -12
  9. package/site/content/docs/elements/clock.mdx +17 -15
  10. package/site/content/docs/elements/flow.mdx +6 -2
  11. package/site/content/docs/elements/signal.mdx +10 -8
  12. package/site/content/docs/elements/store.mdx +165 -0
  13. package/site/content/docs/get-started/index.mdx +5 -0
  14. package/site/content/docs/get-started/installation.mdx +18 -16
  15. package/site/content/docs/index.mdx +5 -0
  16. package/site/content/docs/meta.json +10 -1
  17. package/site/content/docs/plugins/index.mdx +1 -2
  18. package/site/content/docs/plugins/magic-link.mdx +44 -2
  19. package/site/content/docs/plugins/meta.json +1 -2
  20. package/site/content/docs/plugins/otp.mdx +202 -0
  21. package/site/content/docs/plugins/two-factor.mdx +2 -1
  22. package/site/content/docs/reference/cli.md +5 -2
  23. package/site/content/docs/reference/configuration.mdx +21 -3
  24. package/site/content/docs/reference/environment-variables.mdx +21 -8
  25. package/site/content/docs/reference/plugins.mdx +1 -1
  26. package/src/auth/auth.test.ts +36 -0
  27. package/src/auth/bindings.ts +3 -12
  28. package/src/auth/identity.ts +33 -0
  29. package/src/auth/index.ts +5 -0
  30. package/src/auth/otp-capability.ts +119 -0
  31. package/src/auth/otp-seal.test.ts +61 -0
  32. package/src/auth/otp-seal.ts +84 -0
  33. package/src/auth/schema.ts +3 -0
  34. package/src/auth/sessions.ts +26 -27
  35. package/src/auth/tables.ts +4 -0
  36. package/src/auth/verification.ts +61 -1
  37. package/src/cli/db-seed.ts +359 -0
  38. package/src/cli/db.test.ts +341 -3
  39. package/src/cli/db.ts +75 -8
  40. package/src/cli/dev-app-runner.ts +4 -0
  41. package/src/cli/docker.ts +4 -1
  42. package/src/cli/load-config.images.test.ts +26 -0
  43. package/src/cli/load-config.ts +10 -2
  44. package/src/cli/registry.ts +38 -2
  45. package/src/compiler/effects-infer.ts +1 -0
  46. package/src/config/index.ts +4 -0
  47. package/src/console/server/operator-db.ts +34 -9
  48. package/src/docker/compose.ts +162 -6
  49. package/src/docker/derive.ts +60 -3
  50. package/src/docker/docker.test.ts +374 -1
  51. package/src/docker/helpers.ts +2 -0
  52. package/src/docker/index.ts +11 -0
  53. package/src/docker/recipes/caddy.ts +51 -0
  54. package/src/docker/recipes/dragonfly.ts +31 -0
  55. package/src/docker/recipes/index.ts +25 -2
  56. package/src/docker/recipes/pgdog.ts +84 -0
  57. package/src/docker/recipes/redis.ts +6 -3
  58. package/src/docker/recipes/traefik.ts +83 -0
  59. package/src/docker/recipes/valkey.ts +30 -0
  60. package/src/docker/stack-id.ts +5 -0
  61. package/src/docker/types.ts +18 -0
  62. package/src/drivers/channel-sently.test.ts +8 -0
  63. package/src/drivers/channel-taqnyat-mail.ts +34 -0
  64. package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
  65. package/src/drivers/channel-types.ts +72 -0
  66. package/src/drivers/clock-postgres.test.ts +258 -0
  67. package/src/drivers/clock-postgres.ts +410 -0
  68. package/src/drivers/index.ts +18 -0
  69. package/src/drivers/journal-postgres.test.ts +175 -0
  70. package/src/drivers/journal-postgres.ts +492 -0
  71. package/src/elements/channel/otp-delivery.test.ts +76 -0
  72. package/src/elements/channel/otp-delivery.ts +291 -0
  73. package/src/elements/channel/runtime.ts +203 -114
  74. package/src/elements/channel.test.ts +71 -0
  75. package/src/elements/channel.ts +12 -2
  76. package/src/elements/clock/chaos-child.ts +280 -41
  77. package/src/elements/clock/durable.ts +7 -0
  78. package/src/elements/clock/reconcile.ts +2 -2
  79. package/src/elements/clock/runtime.ts +5 -3
  80. package/src/elements/clock.ts +1 -1
  81. package/src/elements/store/seed.test.ts +27 -0
  82. package/src/elements/store/seed.ts +68 -0
  83. package/src/elements/store/sql-session.test.ts +39 -0
  84. package/src/elements/store/sql-session.ts +55 -0
  85. package/src/elements/store/upsert-app.test.ts +103 -0
  86. package/src/elements/store.ts +5 -0
  87. package/src/index.ts +18 -0
  88. package/src/kernel/app.ts +221 -14
  89. package/src/kernel/boot-bind/channel.test.ts +16 -0
  90. package/src/kernel/boot-bind/channel.ts +64 -0
  91. package/src/kernel/boot-bind/clock.ts +17 -6
  92. package/src/kernel/boot-bind/gate.ts +14 -19
  93. package/src/kernel/boot-bind/honor-config.test.ts +123 -4
  94. package/src/kernel/boot-bind/journal.ts +89 -0
  95. package/src/kernel/boot-bind/signal.ts +20 -0
  96. package/src/kernel/boot-bind/store.test.ts +82 -0
  97. package/src/kernel/boot-bind/store.ts +22 -0
  98. package/src/kernel/boot.test.ts +6 -4
  99. package/src/kernel/boot.ts +53 -13
  100. package/src/kernel/concurrency.ts +1 -1
  101. package/src/kernel/fx.test.ts +9 -0
  102. package/src/kernel/fx.ts +175 -5
  103. package/src/kernel/graceful-shutdown.test.ts +76 -0
  104. package/src/kernel/graceful-shutdown.ts +106 -0
  105. package/src/kernel/horizontal-child.ts +257 -0
  106. package/src/kernel/horizontal.integration.test.ts +229 -0
  107. package/src/kernel/index.ts +14 -0
  108. package/src/kernel/journal-boot.test.ts +397 -0
  109. package/src/kernel/journal-suspend.ts +35 -0
  110. package/src/kernel/journal.test.ts +142 -0
  111. package/src/kernel/journal.ts +202 -27
  112. package/src/kernel/ready.test.ts +76 -0
  113. package/src/plugins/auth-delivery.mailpit.integration.test.ts +5 -5
  114. package/src/plugins/auth-methods.security.test.ts +20 -27
  115. package/src/plugins/auth-methods.test.ts +7 -6
  116. package/src/plugins/index.ts +12 -8
  117. package/src/plugins/magic-link.ts +1 -23
  118. package/src/plugins/otp.test.ts +236 -0
  119. package/src/plugins/otp.ts +570 -0
  120. package/src/plugins/taqnyat.live.test.ts +172 -0
  121. package/src/release/official-plugins.ts +1 -2
  122. package/site/content/docs/plugins/email-otp.mdx +0 -117
  123. package/site/content/docs/plugins/phone-number.mdx +0 -111
  124. package/src/plugins/email-otp.ts +0 -214
  125. package/src/plugins/phone-number.ts +0 -149
@@ -239,6 +239,151 @@ 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
+ <Callout title="Connection pooling is infrastructure, not app code" type="info">
255
+ Bun.SQL defaults to **10** connections per process — fine per instance. Scale out and `N × pool`
256
+ can exceed Postgres `max_connections`. docker/prod puts **PgDog** in front; `DATABASE_URL` → port
257
+ `6432`. No app code changes.
258
+ </Callout>
259
+
260
+ **Why PgDog (not PgBouncer as the default).** Transaction pooling fixes the
261
+ math; naive poolers can leak session state (`SET`, RLS vars, `LISTEN`/`NOTIFY`)
262
+ across clients. PgDog re-applies those under transaction mode (PgBouncer is fine — not shipped).
263
+
264
+ **Read replicas later, zero app changes.** Add `role = "replica"` in
265
+ `pgdog.toml` later: `BEGIN READ ONLY` → replica, failover on promotion.
266
+ Not wired this round — readiness only.
267
+
268
+ #### Seeding
269
+
270
+ `oke db seed` loads `defineSeed` from `src/seed/index.ts` — **never at boot**.
271
+ Seed proves existence (bootstrap rows); it does not correct schema-adjacent data over time.
272
+
273
+ <StoreSeeding />
274
+
275
+ | env | `essential` | `dev` | `prod` |
276
+ | -------- | ----------- | ----- | ------ |
277
+ | `local` | yes | yes | no |
278
+ | `docker` | yes | yes | no |
279
+ | `test` | yes | no | no |
280
+ | `prod` | yes | no | yes |
281
+
282
+ `docker` is a laptop profile with prod-shaped drivers — still development, so `dev` runs.
283
+ Outside `test`, exactly one of `dev` / `prod` runs with `essential`.
284
+
285
+ **Simple form** — everything inline:
286
+
287
+ ```ts
288
+ import { defineSeed, type Fx } from "okengine";
289
+ import { db } from "../core";
290
+ import { notes } from "../schema.decl";
291
+
292
+ export default defineSeed({
293
+ essential: async (fx: Fx) => {
294
+ await fx
295
+ .store(db)
296
+ .upsert(
297
+ notes,
298
+ { id: "welcome" },
299
+ { id: "welcome", title: "Welcome", body: "…", createdAt: 1 },
300
+ );
301
+ },
302
+ dev: async (fx: Fx) => {
303
+ await fx
304
+ .store(db)
305
+ .upsert(
306
+ notes,
307
+ { id: "sample-ideas" },
308
+ { id: "sample-ideas", title: "Ideas", body: "…", createdAt: 2 },
309
+ );
310
+ },
311
+ // prod: async (fx) => { /* e.g. register a real external webhook URL */ },
312
+ });
313
+ ```
314
+
315
+ **Complex form** — one function per file, compose with arrays (order = run order):
316
+
317
+ ```ts
318
+ import { defineSeed } from "okengine";
319
+ import { seedWelcome } from "./essential/welcome";
320
+ import { seedSamples } from "./dev/samples";
321
+ import { seedWebhook } from "./prod/webhook";
322
+
323
+ export default defineSeed({
324
+ essential: [seedWelcome],
325
+ dev: [seedSamples],
326
+ prod: [seedWebhook],
327
+ });
328
+ ```
329
+
330
+ | `upsert` outcome | When |
331
+ | ----------------- | ------------------------------------------ |
332
+ | `upserted` | no row matched `matchOn` — insert |
333
+ | `already-existed` | match found; default leaves it alone |
334
+ | `changed` | match found and `{ onExisting: "update" }` |
335
+
336
+ Default upsert is **insert-if-missing**. Pass `{ onExisting: "update" }` only when this
337
+ call should also rewrite other columns on a match — opt-in per call, never global.
338
+
339
+ ```ts
340
+ // Safe bootstrap — second seed run leaves the row alone
341
+ await fx.store(db).upsert(
342
+ notes,
343
+ { id: "welcome" },
344
+ {
345
+ id: "welcome",
346
+ title: "Welcome",
347
+ body: "Your Notes API is ready.",
348
+ createdAt: 1,
349
+ },
350
+ );
351
+
352
+ // Refresh copy on every seed — match stays; title/body are rewritten
353
+ await fx.store(db).upsert(
354
+ notes,
355
+ { id: "welcome" },
356
+ {
357
+ id: "welcome",
358
+ title: "Welcome (updated)",
359
+ body: "New bootstrap copy.",
360
+ createdAt: 1,
361
+ },
362
+ { onExisting: "update" },
363
+ );
364
+ ```
365
+
366
+ **Consequence:** default keeps operator edits safe across re-seeds; `onExisting: "update"`
367
+ is for seed-owned rows you intentionally overwrite. Schema-wide data fixes still belong in
368
+ migrations, not seed.
369
+
370
+ | Env | Confirm |
371
+ | ----------------- | ----------------------------------------------------- |
372
+ | `local` / `test` | none |
373
+ | `docker` / `prod` | print DB target; type the env name (`--force` for CI) |
374
+
375
+ ```bash
376
+ oke db seed # resolve env like other oke db commands (.oke/mode)
377
+ oke db seed --env docker # explicit override
378
+ oke db seed --env prod --force
379
+ ```
380
+
381
+ <Callout title="Seed vs migrate">
382
+ Permanent reference-data corrections go through `oke db generate` → `migrate`, not repeated seed.
383
+ Optional `drizzle-seed` may be used inside a `dev` function body only — never as the seed system
384
+ backbone.
385
+ </Callout>
386
+
242
387
  <Callout title="Escape hatch">
243
388
  Hand-written Drizzle in `src/schema.ts` stays supported — if there is nothing to emit, the emit
244
389
  step is skipped and your file is used as-is. Plugins may contribute **whole new tables**;
@@ -359,6 +504,8 @@ drivers: {
359
504
  },
360
505
  images: {
361
506
  "store.kv": "redis:8-alpine",
507
+ // or: "valkey/valkey:8-alpine"
508
+ // or: "docker.dragonflydb.io/dragonflydb/dragonfly"
362
509
  },
363
510
  ```
364
511
 
@@ -374,6 +521,22 @@ images: {
374
521
 
375
522
  Missing Redis URL fails boot loudly: `oke boot: redis driver needs REDIS_URL`.
376
523
 
524
+ Driver id stays `redis` for every image below — same `REDIS_URL`, zero Flow changes.
525
+ Redis is the default because it is the most mature and battle-tested; Valkey and
526
+ Dragonfly are equally legitimate opt-in pins.
527
+
528
+ | Image | Pin | Why pick it | License |
529
+ | ------------- | --------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
530
+ | **Redis** | `redis:8-alpine` (default) | Most mature / battle-tested | RSALv2 / SSPLv1 / AGPLv3 (Redis ≥8) — service limits under RSAL/SSPL apply if you offer Redis as a managed service to third parties |
531
+ | **Valkey** | `valkey/valkey:8-alpine` | BSD fork (Linux Foundation) | BSD-3-Clause — permissive; no managed-service restriction |
532
+ | **Dragonfly** | `docker.dragonflydb.io/dragonflydb/dragonfly` | Multi-threaded Redis-wire runtime | BSL 1.1 (converts to Apache 2.0 on a published change date) — free for self-hosting; restricts offering Dragonfly as a commercial managed service |
533
+
534
+ <Callout title="Licenses bite managed-service resellers — read this once" type="warn">
535
+ RSAL, SSPL, and BSL service restrictions apply when you offer that specific datastore **as a
536
+ service** to third parties — not when you run it for your own app. Pick for your situation; OKE
537
+ does not pick a “safer” default for you.
538
+ </Callout>
539
+
377
540
  <Callout title="Dry-run refuses KV writes">
378
541
  `set` and `delete` throw `DryRunWriteIsolationError` during dry-run — the runtime will not risk a
379
542
  double-write against a shared Redis. Reads (`get` / `list`) still run.
@@ -472,6 +635,8 @@ images: {
472
635
 
473
636
  `memory` is the test default. Local `fs` writes under a temp root when no binding `root` is set.
474
637
 
638
+ **Known limit:** `fs` is single-host. Under horizontal scale each replica sees its own filesystem (silently inconsistent object views). Boot prints a one-shot warning when `drivers.store.files` is `fs`. Use `s3` for docker/prod (create-oke templates already do).
639
+
475
640
  <Callout title="Console can browse, not edit bytes">
476
641
  The [Console · Store](/docs/console/store) lists keys and can delete them. Direct edit is **KV +
477
642
  SQL only** — blob bodies are not patched from the Console.
@@ -30,4 +30,9 @@ Learn the shape once, install on Bun, then write your first Flow from the standa
30
30
  description="Health Flow, typed client, Console proof."
31
31
  href="/docs/get-started/basic-usage"
32
32
  />
33
+ <Card
34
+ title="Deployment"
35
+ description="Compose, Swarm, Kubernetes, and reverse proxy."
36
+ href="/docs/deployment"
37
+ />
33
38
  </Cards>
@@ -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
 
@@ -32,6 +32,11 @@ on(orderPlaced, sendReceipt);
32
32
  href="/docs/plugins"
33
33
  />
34
34
  <Card title="Console" description="Manifest-derived operator panels." href="/docs/console" />
35
+ <Card
36
+ title="Deployment"
37
+ description="Compose, Swarm, Kubernetes, reverse proxy."
38
+ href="/docs/deployment"
39
+ />
35
40
  <Card
36
41
  title="Reference"
37
42
  description="Config, fx, env, errors, CLI, security."
@@ -1,5 +1,14 @@
1
1
  {
2
2
  "title": "Documentation",
3
3
  "icon": "BookOpen",
4
- "pages": ["index", "get-started", "elements", "plugins", "console", "reference", "ai"]
4
+ "pages": [
5
+ "index",
6
+ "get-started",
7
+ "elements",
8
+ "plugins",
9
+ "console",
10
+ "deployment",
11
+ "reference",
12
+ "ai"
13
+ ]
5
14
  }
@@ -17,8 +17,7 @@ First-party plugins you `.plug()` onto an app. Each page is one export from `oke
17
17
  href="/docs/plugins/anonymous"
18
18
  />
19
19
  <Card title="Magic link" description="One-time email link." href="/docs/plugins/magic-link" />
20
- <Card title="Email OTP" description="Six-digit email codes." href="/docs/plugins/email-otp" />
21
- <Card title="Phone number" description="E.164 SMS OTP." href="/docs/plugins/phone-number" />
20
+ <Card title="OTP" description="SMS, WhatsApp, or email codes." href="/docs/plugins/otp" />
22
21
  <Card title="Two-factor" description="TOTP enable / verify." href="/docs/plugins/two-factor" />
23
22
  <Card title="Passkey" description="WebAuthn-shaped passkeys." href="/docs/plugins/passkey" />
24
23
  </Cards>
@@ -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>
@@ -105,14 +147,14 @@ unit tests without SMTP, set `exposeDevToken: true`.
105
147
 
106
148
  ## Learn more
107
149
 
108
- - [Email OTP](/docs/plugins/email-otp) — numeric code instead of a link
150
+ - [OTP](/docs/plugins/otp) — numeric code instead of a link
109
151
  - [Gate](/docs/elements/gate) — `gate.auth`
110
152
  - [Channel](/docs/elements/channel) — `fx.send`, Mailpit, consents
111
153
 
112
154
  ## Next
113
155
 
114
156
  <Cards>
115
- <Card title="Email OTP" description="Six-digit email codes." href="/docs/plugins/email-otp" />
157
+ <Card title="OTP" description="SMS, WhatsApp, or email codes." href="/docs/plugins/otp" />
116
158
  <Card title="Gate" description="Builtin auth and policies." href="/docs/elements/gate" />
117
159
  <Card title="Channel" description="Email delivery and Mailpit." href="/docs/elements/channel" />
118
160
  </Cards>
@@ -7,8 +7,7 @@
7
7
  "username",
8
8
  "anonymous",
9
9
  "magic-link",
10
- "email-otp",
11
- "phone-number",
10
+ "otp",
12
11
  "two-factor",
13
12
  "passkey",
14
13
  "---Security---",
@@ -0,0 +1,202 @@
1
+ ---
2
+ title: "OTP"
3
+ description: "Official plugin — one-time codes over SMS, WhatsApp, or email under /auth, with explicit Tier 1 or Tier 2 delivery."
4
+ icon: "KeyRound"
5
+ source: "docs/spec/unified-theory.md"
6
+ ---
7
+
8
+ `otp()` signs people in with a one-time code. You must set `tier` — there is no
9
+ auto-detect. Tier 1 is provider-owned SMS Verify; Tier 2 is app-owned delivery
10
+ across the channels you declare.
11
+
12
+ <Callout title="The one rule">
13
+ Enable `gate.auth`, then `.plug(otp({ tier: 1 }))` or
14
+ `.plug(otp({ tier: 2, channels: [...] }))`. Never omit `tier`. Never log raw
15
+ OTPs.
16
+ </Callout>
17
+
18
+ ## Quick start
19
+
20
+ <Steps>
21
+
22
+ <Step>
23
+ ### Plug Tier 2 (multi-channel)
24
+
25
+ ```typescript title="src/app.ts"
26
+ import { oke } from "okengine";
27
+ import { otp } from "okengine/plugins";
28
+
29
+ export const app = oke({
30
+ name: "shop",
31
+ env: "local",
32
+ gate: { auth: {} },
33
+ }).plug(
34
+ otp({
35
+ tier: 2,
36
+ channels: ["sms", "whatsapp", "email"],
37
+ exposeDevOtp: true, // local DX only
38
+ }),
39
+ );
40
+ ```
41
+
42
+ </Step>
43
+
44
+ <Step>
45
+ ### Request a code
46
+
47
+ ```typescript
48
+ const { data } = await api.auth.requestOtp({
49
+ phone: "+15551234567",
50
+ email: "ali@example.com",
51
+ });
52
+ ```
53
+
54
+ `POST /auth/otp/request`. Prior active challenges for that principal are
55
+ invalidated. Delivery follows `channels` order for addresses you pass.
56
+
57
+ </Step>
58
+
59
+ <Step>
60
+ ### Resend on another channel (Tier 2 only)
61
+
62
+ ```typescript
63
+ const { data } = await api.auth.resendOtp({
64
+ phone: "+15551234567",
65
+ email: "ali@example.com",
66
+ channel: "email",
67
+ });
68
+ ```
69
+
70
+ Same code, same TTL. Default cooldown is 60 seconds. Tier 1 has no resend
71
+ surface — the provider owns the code.
72
+
73
+ </Step>
74
+
75
+ <Step>
76
+ ### Verify
77
+
78
+ ```typescript
79
+ const { data } = await api.auth.verifyOtp({
80
+ phone: "+15551234567",
81
+ otp,
82
+ });
83
+ ```
84
+
85
+ `POST /auth/otp/verify` — five failed attempts consume the challenge.
86
+
87
+ </Step>
88
+
89
+ </Steps>
90
+
91
+ ## Tiers
92
+
93
+ | | Tier 1 | Tier 2 |
94
+ | -------------------- | ----------------------------- | --------------------------------------------- |
95
+ | Config | `otp({ tier: 1 })` | `otp({ tier: 2, channels: [...] })` |
96
+ | Who owns the code | Provider (Verify API) | Your app |
97
+ | Delivery | `fx.sendOtp` / `fx.verifyOtp` | `fx.deliverOtp` (Channel templates) |
98
+ | Channels | SMS only | `sms` · `whatsapp` · `email` (declared order) |
99
+ | Resend other channel | Impossible | `POST /auth/otp/resend` |
100
+ | `exposeDevOtp` | Forbidden | Optional (default off) |
101
+
102
+ <Callout type="warn" title="Tier 1 limitation">
103
+ Resend-via-different-channel is impossible on Tier 1 — the code value is never visible to OKE. Use
104
+ Tier 2 when you need SMS → email fallback for the same code.
105
+ </Callout>
106
+
107
+ ### Tier 1 setup
108
+
109
+ ```typescript title="oke.config.ts"
110
+ export default {
111
+ drivers: {
112
+ channel: {
113
+ sms: { local: "console", docker: "taqnyat", test: "console", prod: "taqnyat" },
114
+ },
115
+ },
116
+ };
117
+ ```
118
+
119
+ Boot fails loudly if no SMS driver exposes `sendOtp` / `verifyOtp`. Switch to
120
+ Tier 2, or bind a Verify-capable driver (for example `taqnyat`).
121
+
122
+ ### Tier 2 delivery
123
+
124
+ | Concern | Behavior |
125
+ | --------------- | --------------------------------------------------------------------------------------------------------------------------- |
126
+ | Storage | SHA-256 hash for verify + sealed AES-GCM copy (HKDF `oke-otp-seal-v1`) for redelivery |
127
+ | Seal lifetime | Wiped on verify, lockout, or TTL expiry — never left after the challenge dies |
128
+ | Challenge TTL | Default 10 minutes (`ttlMs`) |
129
+ | Resend cooldown | Default 60 seconds (`resendCooldownMs`) — separate from TTL |
130
+ | Auto failover | On real provider send errors, sently `FallbackTransport` walks remaining media; Taqnyat WhatsApp may use `sendWithFailover` |
131
+ | User resend | Explicit `resend` with `channel` — not automatic |
132
+
133
+ Templates: `auth-otp-email`, `auth-otp-sms`, `auth-otp-whatsapp` (EN/AR,
134
+ `{{otp}}`). SMS here is a plain message — not Taqnyat Verify.
135
+
136
+ ## Options
137
+
138
+ | Option | Type | Default | Meaning |
139
+ | ------------------ | -------------------------------- | -------------------------- | --------------------------------- |
140
+ | `tier` | `1 \| 2` | required | Delivery mechanism — no auto |
141
+ | `channels` | `("sms"\|"whatsapp"\|"email")[]` | required on Tier 2 | Build-time preferred order |
142
+ | `ttlMs` | `number` | 10m | Challenge lifetime |
143
+ | `resendCooldownMs` | `number` | 60s | Tier 2 resend spacing |
144
+ | `exposeDevOtp` | `boolean` | `false` | Tier 2 only — raw OTP in response |
145
+ | `from` | `string` | `OKE <no-reply@oke.local>` | Email template From |
146
+ | `secret` | `string` | active\* | Auth secret (\*from `gate.auth`) |
147
+ | `sessions` | `SessionStore` | active\* | Session store |
148
+ | `identities` | `IdentityStore` | new | Email → user |
149
+ | `phones` | `PhoneStore` | new | Phone → user |
150
+ | `verifications` | `VerificationStore` | new | Challenge store |
151
+
152
+ ## Surfaces
153
+
154
+ | Flow | Path | Gate | Tier |
155
+ | ----------------- | ------------------------ | ------------------------ | ------ |
156
+ | `auth.requestOtp` | `POST /auth/otp/request` | `gate.public` + otp rate | 1 + 2 |
157
+ | `auth.verifyOtp` | `POST /auth/otp/verify` | `gate.public` + otp rate | 1 + 2 |
158
+ | `auth.resendOtp` | `POST /auth/otp/resend` | `gate.public` + otp rate | 2 only |
159
+
160
+ ## Troubleshooting
161
+
162
+ <Accordions>
163
+ <Accordion title='otp(): tier is required'>
164
+
165
+ You omitted `tier`. Set `tier: 1` or `tier: 2` explicitly — OKE never infers
166
+ which mechanism you meant.
167
+
168
+ </Accordion>
169
+ <Accordion title="Boot fails on Tier 1">
170
+
171
+ No Verify-capable SMS driver is bound. Set `drivers.channel.sms` to `taqnyat`
172
+ (or another driver with `sendOtp`/`verifyOtp`), or switch to
173
+ `otp({ tier: 2, channels: [...] })`.
174
+
175
+ </Accordion>
176
+ <Accordion title="resend_cooldown">
177
+
178
+ Wait for `resendCooldownMs` (default 60s). The challenge TTL is unchanged —
179
+ only delivery is rate-limited.
180
+
181
+ </Accordion>
182
+ <Accordion title="No email / SMS with the code (Tier 2)">
183
+
184
+ In `local` / `test` the `console` driver captures messages. Use
185
+ `exposeDevOtp: true` for unit tests without a real provider.
186
+
187
+ </Accordion>
188
+ </Accordions>
189
+
190
+ ## Learn more
191
+
192
+ - [Magic link](/docs/plugins/magic-link) — link instead of a code
193
+ - [Channel](/docs/elements/channel) — `fx.send`, drivers, Mailpit
194
+ - [Gate](/docs/elements/gate) — `gate.auth`
195
+
196
+ ## Next
197
+
198
+ <Cards>
199
+ <Card title="Magic link" description="Email link sign-in." href="/docs/plugins/magic-link" />
200
+ <Card title="Two-factor" description="TOTP second factor." href="/docs/plugins/two-factor" />
201
+ <Card title="Channel" description="Delivery drivers and Mailpit." href="/docs/elements/channel" />
202
+ </Cards>
@@ -107,11 +107,12 @@ 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 [OTP](/docs/plugins/otp) / [Magic link](/docs/plugins/magic-link)
110
111
 
111
112
  ## Next
112
113
 
113
114
  <Cards>
114
115
  <Card title="Passkey" description="WebAuthn register and assert." href="/docs/plugins/passkey" />
115
116
  <Card title="Gate" description="Builtin auth and policies." href="/docs/elements/gate" />
116
- <Card title="Email OTP" description="Passwordless email codes." href="/docs/plugins/email-otp" />
117
+ <Card title="OTP" description="SMS, WhatsApp, or email codes." href="/docs/plugins/otp" />
117
118
  </Cards>
@@ -28,11 +28,14 @@ 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
 
34
- oke docker # docker/Dockerfile + docker/compose.<role>.yml · …
35
- oke docker --prod # healthchecks, volumes, limits, secret refs, deploy.replicas
36
+ oke docker # docker/Dockerfile + compose.yml + compose.<role>.yml + compose.all.yml · …
37
+ # opt-in images.proxy Caddyfile (caddy) or Traefik labels + socket-proxy
38
+ oke docker --prod # + compose.prod.yml (HEALTHCHECK /_/ready, deploy, limits); folded into compose.all.yml
36
39
  oke docker clean # TTY: pick oke-dev-* stacks → down -v (containers, networks, volumes)
37
40
  oke docker clean --yes # non-TTY: current project stack only
38
41
  oke docker clean --all --yes # non-TTY: every oke-dev-* stack on this machine
@@ -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` |
@@ -73,16 +75,32 @@ Container image pins keyed by element role — vendor choice lives here, never i
73
75
  ```typescript
74
76
  images: {
75
77
  "store.sql": "postgres:18",
78
+ pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.51",
76
79
  "store.kv": "redis:8",
80
+ // or: "valkey/valkey:8-alpine"
81
+ // or: "docker.dragonflydb.io/dragonflydb/dragonfly"
77
82
  "store.files": "rustfs/rustfs:1.0.0-beta.11",
78
83
  "channel.email": "axllent/mailpit:v1.22.3",
79
84
  vault: "openbao/openbao:2.6.1",
80
85
  ai: "ollama/ollama:latest",
86
+ // opt-in TLS edge — omit for the default (app publishes 6530):
87
+ // proxy: "caddy:2-alpine", // simplest automatic HTTPS
88
+ // proxy: "traefik:v3.3", // label auto-discovery for --scale app=N
81
89
  },
82
90
  ```
83
91
 
92
+ When both `store.sql` and `pgdog` are pinned, `DATABASE_URL` points at PgDog
93
+ (`6432`) — wire-protocol transparent to Bun.SQL / Drizzle. See
94
+ [Store · Multiple environments](/docs/elements/store#multiple-environments).
95
+
96
+ For `store.kv`, pin Redis (default), Valkey, or Dragonfly — driver id stays
97
+ `redis`, same `REDIS_URL`. Image table + licenses: [Store · KV](/docs/elements/store#kv).
98
+
84
99
  For `ai`, the Ollama recipe pulls `OKE_AI_MODEL` (default `qwen3.5:9b` — a balanced local-dev starting point; override freely; on Apple Silicon prefer `qwen3.5:9b-mlx`) and writes `OKE_AI_URL`.
85
100
 
101
+ For `proxy`, pin **either** Caddy or Traefik — never both. Opt-in only; see
102
+ [Reverse proxy](/docs/deployment/reverse-proxy).
103
+
86
104
  ## i18n
87
105
 
88
106
  | Option | Type | Default | Meaning |