okengine 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/package.json +3 -2
  2. package/site/content/docs/deployment/docker-swarm.mdx +228 -0
  3. package/site/content/docs/deployment/docker.mdx +212 -0
  4. package/site/content/docs/deployment/index.mdx +83 -0
  5. package/site/content/docs/deployment/kubernetes.mdx +176 -0
  6. package/site/content/docs/deployment/meta.json +5 -0
  7. package/site/content/docs/deployment/reverse-proxy.mdx +216 -0
  8. package/site/content/docs/elements/channel.mdx +3 -1
  9. package/site/content/docs/elements/signal.mdx +10 -8
  10. package/site/content/docs/elements/store.mdx +34 -0
  11. package/site/content/docs/get-started/index.mdx +5 -0
  12. package/site/content/docs/index.mdx +5 -0
  13. package/site/content/docs/meta.json +10 -1
  14. package/site/content/docs/plugins/index.mdx +1 -2
  15. package/site/content/docs/plugins/magic-link.mdx +2 -2
  16. package/site/content/docs/plugins/meta.json +1 -2
  17. package/site/content/docs/plugins/otp.mdx +202 -0
  18. package/site/content/docs/plugins/two-factor.mdx +2 -2
  19. package/site/content/docs/reference/cli.md +3 -2
  20. package/site/content/docs/reference/configuration.mdx +16 -0
  21. package/site/content/docs/reference/environment-variables.mdx +9 -8
  22. package/site/content/docs/reference/plugins.mdx +1 -1
  23. package/src/auth/auth.test.ts +36 -0
  24. package/src/auth/bindings.ts +3 -12
  25. package/src/auth/identity.ts +33 -0
  26. package/src/auth/index.ts +5 -0
  27. package/src/auth/otp-capability.ts +119 -0
  28. package/src/auth/otp-seal.test.ts +61 -0
  29. package/src/auth/otp-seal.ts +84 -0
  30. package/src/auth/schema.ts +3 -0
  31. package/src/auth/sessions.ts +26 -27
  32. package/src/auth/tables.ts +4 -0
  33. package/src/auth/verification.ts +61 -1
  34. package/src/cli/dev-app-runner.ts +4 -0
  35. package/src/cli/docker.ts +4 -1
  36. package/src/cli/load-config.images.test.ts +4 -0
  37. package/src/cli/load-config.ts +3 -0
  38. package/src/cli/registry.ts +1 -1
  39. package/src/console/server/operator-db.ts +34 -9
  40. package/src/docker/compose.ts +162 -6
  41. package/src/docker/derive.ts +60 -3
  42. package/src/docker/docker.test.ts +374 -1
  43. package/src/docker/helpers.ts +2 -0
  44. package/src/docker/index.ts +11 -0
  45. package/src/docker/recipes/caddy.ts +51 -0
  46. package/src/docker/recipes/dragonfly.ts +31 -0
  47. package/src/docker/recipes/index.ts +25 -2
  48. package/src/docker/recipes/pgdog.ts +84 -0
  49. package/src/docker/recipes/redis.ts +6 -3
  50. package/src/docker/recipes/traefik.ts +83 -0
  51. package/src/docker/recipes/valkey.ts +30 -0
  52. package/src/docker/stack-id.ts +5 -0
  53. package/src/docker/types.ts +18 -0
  54. package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
  55. package/src/drivers/channel-types.ts +1 -0
  56. package/src/elements/channel/otp-delivery.test.ts +76 -0
  57. package/src/elements/channel/otp-delivery.ts +291 -0
  58. package/src/elements/channel/runtime.ts +152 -114
  59. package/src/elements/channel.ts +12 -2
  60. package/src/index.ts +3 -0
  61. package/src/kernel/app.ts +60 -4
  62. package/src/kernel/boot-bind/channel.ts +51 -0
  63. package/src/kernel/boot-bind/gate.ts +14 -19
  64. package/src/kernel/boot-bind/honor-config.test.ts +18 -0
  65. package/src/kernel/boot-bind/signal.ts +20 -0
  66. package/src/kernel/boot-bind/store.test.ts +82 -0
  67. package/src/kernel/boot-bind/store.ts +22 -0
  68. package/src/kernel/boot.test.ts +5 -5
  69. package/src/kernel/fx.test.ts +3 -0
  70. package/src/kernel/fx.ts +49 -0
  71. package/src/kernel/graceful-shutdown.test.ts +76 -0
  72. package/src/kernel/graceful-shutdown.ts +106 -0
  73. package/src/kernel/horizontal-child.ts +257 -0
  74. package/src/kernel/horizontal.integration.test.ts +229 -0
  75. package/src/kernel/index.ts +8 -0
  76. package/src/kernel/ready.test.ts +76 -0
  77. package/src/plugins/auth-delivery.mailpit.integration.test.ts +5 -5
  78. package/src/plugins/auth-methods.security.test.ts +19 -29
  79. package/src/plugins/auth-methods.test.ts +7 -6
  80. package/src/plugins/index.ts +12 -8
  81. package/src/plugins/magic-link.ts +1 -23
  82. package/src/plugins/otp.test.ts +236 -0
  83. package/src/plugins/otp.ts +570 -0
  84. package/src/plugins/taqnyat.live.test.ts +4 -6
  85. package/src/release/official-plugins.ts +1 -2
  86. package/site/content/docs/plugins/email-otp.mdx +0 -117
  87. package/site/content/docs/plugins/phone-number.mdx +0 -172
  88. package/src/plugins/email-otp.ts +0 -214
  89. package/src/plugins/phone-number.ts +0 -206
@@ -0,0 +1,176 @@
1
+ ---
2
+ title: Kubernetes
3
+ description: Run an OKE app as a plain Deployment — PgDog, readiness vs liveness probes, graceful SIGTERM, shared drivers, and honest multi-instance limits.
4
+ icon: Container
5
+ source: README.md
6
+ ---
7
+
8
+ OKE apps are safe to run as more than one replica when shared drivers back
9
+ Clock, Journal, Gate, and Store. Kubernetes is full orchestration with the
10
+ largest ecosystem — use a **Deployment**, never a StatefulSet.
11
+
12
+ <Callout title="The one rule">
13
+ Treat every replica as replaceable. Put durable state in Postgres / Redis / S3, probe `GET
14
+ /_/ready` for readiness and app `GET /health` for liveness, and set
15
+ `terminationGracePeriodSeconds` high enough for lease release on SIGTERM.
16
+ </Callout>
17
+
18
+ ## Quick start
19
+
20
+ <Steps>
21
+
22
+ <Step>
23
+ ### Deploy as a Deployment
24
+
25
+ Use a plain Kubernetes **Deployment** (or compatible replica set). Do **not**
26
+ use a StatefulSet — there is no per-pod volume identity, ordered startup, or
27
+ sticky network id to preserve. Scale with `replicas: N`.
28
+
29
+ </Step>
30
+
31
+ <Step>
32
+ ### Share backing services
33
+
34
+ Pin docker/prod drivers to shared stores:
35
+
36
+ | Concern | Driver | Shared backend |
37
+ | ----------------------------- | ---------------------------- | ------------------------------------------------- |
38
+ | SQL / durable journal / clock | `postgres` | Postgres (optionally via PgDog) |
39
+ | Gate rate counters | `redis` (`drivers.store.kv`) | Redis / Valkey / Dragonfly (`images["store.kv"]`) |
40
+ | Files | `s3` | S3-compatible object store |
41
+ | Signal | see known limits below | — |
42
+
43
+ </Step>
44
+
45
+ <Step>
46
+ ### Probe readiness and liveness
47
+
48
+ - **Readiness:** `GET /_/ready` — returns `200 { ready: true }` only after boot and the durable orphan scan finish. While booting or scanning orphans it returns `503 { ready: false, reason: "booting" | "orphan_scan" }`.
49
+ - **Liveness:** app-authored `GET /health` (create-oke ships one) — cheap “process is alive,” not orphan-scan aware.
50
+
51
+ Wire `readinessProbe` to `/_/ready` and `livenessProbe` to `/health`.
52
+
53
+ </Step>
54
+
55
+ </Steps>
56
+
57
+ ## PgDog pooling
58
+
59
+ Bun.SQL defaults to **10** connections per process — fine for one pod.
60
+ Scale the Deployment and `N × pool` can exceed Postgres `max_connections`.
61
+
62
+ When both `store.sql` and `pgdog` are pinned (docker/prod default in templates),
63
+ `DATABASE_URL` points at PgDog on port `6432` — transaction pooling, wire-protocol
64
+ transparent to Bun.SQL / Drizzle. Point the cluster Service at that pooler.
65
+
66
+ **Why PgDog.** Transaction pooling fixes the connection math. Naive poolers can
67
+ leak session state (`SET`, RLS vars, `LISTEN`/`NOTIFY`) across clients; PgDog
68
+ re-applies those under transaction mode.
69
+
70
+ Read-replica routing (`BEGIN READ ONLY` → replica) is documented readiness —
71
+ not configured as a generated Kubernetes manifest this round.
72
+
73
+ ## Redis-protocol images (Redis · Valkey · Dragonfly)
74
+
75
+ `images["store.kv"]` defaults to Redis (most mature). Pin Valkey or Dragonfly
76
+ the same way — driver id stays `redis`, same `REDIS_URL`. Image table + one-line
77
+ licenses: [Store · KV](/docs/elements/store#kv).
78
+
79
+ ## Readiness vs liveness
80
+
81
+ | Endpoint | Role | Kubernetes |
82
+ | -------------- | ------------------------------------------------------ | ------------------------------------------------ |
83
+ | `GET /_/ready` | Kernel readiness (`booting` → `orphan_scan` → `ready`) | `readinessProbe` — no traffic until Ready |
84
+ | `GET /health` | App liveness (create-oke) — cheap “process alive” | `livenessProbe` — restart if the process is dead |
85
+
86
+ **Consequence:** orphan scan delays Ready (and traffic) on purpose so a new
87
+ pod is not hit mid-takeover. Do not point liveness at `/_/ready` — a long orphan
88
+ resume would kill the pod instead of waiting.
89
+
90
+ ## Graceful shutdown
91
+
92
+ On `SIGTERM` / `SIGINT`, `installGracefulShutdown` (wired by `oke dev`’s app
93
+ runner; call it next to `createBunRuntime().serve` in custom entries):
94
+
95
+ 1. Stops accepting new connections
96
+ 2. Releases **Clock** cron leases and **Journal** run leases held by this instance
97
+ 3. Closes element runtimes and exits
98
+
99
+ Signal message leases have no proactive release today — survivors reclaim after
100
+ the visibility TTL (default 30s). Set `terminationGracePeriodSeconds` ≥ your
101
+ longest lease TTL (typically ≥ 30s) so release finishes before kubelet SIGKILL.
102
+
103
+ ## Ingress (TLS / routing)
104
+
105
+ Use cluster-native **Ingress** or Gateway API for HTTPS.
106
+ Compose `images.proxy` recipes (Caddy / Traefik) target plain Docker and Swarm —
107
+ see [Reverse proxy](/docs/deployment/reverse-proxy) when you are not on Kubernetes.
108
+
109
+ Still set serve `allowedHosts` to the public hostname
110
+ ([Security](/docs/reference/security)).
111
+
112
+ ## Known multi-instance limits
113
+
114
+ Be honest about what is still process-local:
115
+
116
+ | Surface | Multi-instance today |
117
+ | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
118
+ | Clock (`postgres` / shared `file`) | Shared leader election |
119
+ | Journal (`postgres` / shared `file`) | Shared durable runs + orphan resume |
120
+ | Gate rates (`drivers.store.kv: redis`) | Shared counters |
121
+ | Store SQL / KV redis / files `s3` | Shared |
122
+ | Store files `fs` | **Per-instance filesystem** — boot warns; use `s3` when scaled |
123
+ | Signal `redis` | Emit relays to Redis; **consume / `live` / `drain` are process-local outbox** until Streams consume ships |
124
+ | Channel suppression / consent / receipts | **Default stores are process-local** — opt-out on replica A is invisible to B until a durable driver ships |
125
+
126
+ ## Troubleshooting
127
+
128
+ <Accordions>
129
+ <Accordion title="Pods pass liveness but get no traffic after deploy">
130
+
131
+ Check readiness: `/_/ready` stays `503` with `reason: "orphan_scan"` until the
132
+ durable orphan resume finishes. A long resume delays Ready; that is intentional
133
+ so traffic does not hit a pod mid-takeover.
134
+
135
+ </Accordion>
136
+ <Accordion title="Rate limits look twice as high after scaling to 2">
137
+
138
+ `drivers.store.kv` must be `redis` with `REDIS_URL` set. A missing URL now
139
+ **fails boot** (no silent memory fallback). Declaring `memory` for local is fine;
140
+ docker/prod should stay on redis.
141
+
142
+ </Accordion>
143
+ <Accordion title="Files uploaded on pod A are missing on pod B">
144
+
145
+ `drivers.store.files: "fs"` is single-host. Switch docker/prod to `s3`
146
+ (create-oke templates already do).
147
+
148
+ </Accordion>
149
+ <Accordion title="Pod killed during orphan scan">
150
+
151
+ Liveness is pointed at `/_/ready`. Move liveness to `/health`; keep readiness
152
+ on `/_/ready` so the pod stays Alive while Waiting for Ready.
153
+
154
+ </Accordion>
155
+ </Accordions>
156
+
157
+ ## Learn more
158
+
159
+ - [Deployment](/docs/deployment) — choose compose, Swarm, or Kubernetes
160
+ - [Docker](/docs/deployment/docker) — plain compose
161
+ - [Docker Swarm](/docs/deployment/docker-swarm) — `docker stack deploy`
162
+ - [Reverse proxy](/docs/deployment/reverse-proxy) — Caddy / Traefik (non-K8s)
163
+ - [Store](/docs/elements/store) — PgDog and facets
164
+ - [Clock](/docs/elements/clock) — shared CronStore / leader election
165
+ - [Flow](/docs/elements/flow) — durable journal
166
+ - [Signal](/docs/elements/signal) — delivery modes and redis honesty
167
+ - [Channel](/docs/elements/channel) — suppression process-local limit
168
+ - [CLI](/docs/reference/cli) — `oke docker --prod`, `oke start`
169
+
170
+ ## Next
171
+
172
+ <Cards>
173
+ <Card title="Deployment" href="/docs/deployment" />
174
+ <Card title="Docker" href="/docs/deployment/docker" />
175
+ <Card title="Docker Swarm" href="/docs/deployment/docker-swarm" />
176
+ </Cards>
@@ -0,0 +1,5 @@
1
+ {
2
+ "title": "Deployment",
3
+ "icon": "Server",
4
+ "pages": ["index", "docker", "docker-swarm", "kubernetes", "reverse-proxy"]
5
+ }
@@ -0,0 +1,216 @@
1
+ ---
2
+ title: Reverse proxy
3
+ description: Opt-in Caddy or Traefik for TLS termination and routing on plain Docker and Swarm — custom domains, Let's Encrypt, and the socket-proxy security model.
4
+ icon: Globe
5
+ source: README.md
6
+ ---
7
+
8
+ Pin `images.proxy` when you need HTTPS at the edge on plain `docker compose` or
9
+ Docker Swarm. Kubernetes uses cluster Ingress / Gateway API instead — this page
10
+ is for the compose recipes.
11
+
12
+ <Callout title="The one rule">
13
+ Leave the no-proxy default until you need TLS or horizontal scale on compose. Then pick **Caddy**
14
+ (simplest automatic HTTPS) or **Traefik** (label auto-discovery for `--scale app=N`). Never mount
15
+ the raw Docker socket into Traefik.
16
+ </Callout>
17
+
18
+ ## Quick start
19
+
20
+ <Steps>
21
+
22
+ <Step>
23
+ ### Opt in (pick one)
24
+
25
+ ```typescript title="oke.config.ts"
26
+ images: {
27
+ "store.sql": "postgres:18-alpine",
28
+ // …
29
+ proxy: "caddy:2-alpine", // or "traefik:v3.3"
30
+ },
31
+ ```
32
+
33
+ Pin **either** Caddy or Traefik — never both. Re-run `oke docker --prod`.
34
+
35
+ </Step>
36
+
37
+ <Step>
38
+ ### Set the public host and ACME email
39
+
40
+ ```bash title="docker/.env.docker"
41
+ OKE_PROXY_HOST=app.example.com
42
+ OKE_PROXY_ACME_EMAIL=admin@example.com
43
+ ```
44
+
45
+ `.env.docker` is preserved across regeneration. Point DNS `A`/`AAAA` at the host.
46
+
47
+ </Step>
48
+
49
+ <Step>
50
+ ### Bring the stack up
51
+
52
+ ```bash
53
+ cd docker
54
+ docker compose -f compose.yml -f compose.store.sql.yml \
55
+ -f compose.proxy.yml -f compose.prod.yml up -d
56
+ ```
57
+
58
+ Pass every `-f` file `oke docker` listed. The edge publishes `80`/`443`; `app`
59
+ stays on the internal `oke` network (no host bind on `6530`).
60
+
61
+ Add your public hostname to serve `allowedHosts`
62
+ ([Security](/docs/reference/security)).
63
+
64
+ </Step>
65
+
66
+ </Steps>
67
+
68
+ ## Which proxy?
69
+
70
+ | Choice | Best for | How routing works |
71
+ | ---------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------ |
72
+ | **Caddy** (`caddy:2-alpine`) | hello / minimal / standard — single instance, simplest TLS | Generated `Caddyfile` → `reverse_proxy app:6530`; automatic HTTPS |
73
+ | **Traefik** (`traefik:v3.3`) | Horizontally scaled plain compose | Docker labels on `app`; replicas auto-discovered and load-balanced |
74
+ | _(omit `images.proxy`)_ | Local / private networks | App publishes `6530` — **default, unchanged** |
75
+
76
+ **Consequence:** Caddy is the recommended default when you just need a certificate.
77
+ Choose Traefik when `docker compose up --scale app=N` is the deployment shape.
78
+
79
+ ## Caddy
80
+
81
+ Emits `docker/Caddyfile`:
82
+
83
+ ```text
84
+ {$OKE_PROXY_HOST:localhost} {
85
+ reverse_proxy app:6530
86
+ }
87
+ ```
88
+
89
+ | Setting | What it does |
90
+ | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
91
+ | `OKE_PROXY_HOST` | Site address. `localhost` → local TLS; a public DNS name → Let's Encrypt HTTP-01 |
92
+ | `OKE_PROXY_ACME_EMAIL` | Written into `.env.docker` for the proxy role. Traefik consumes it; for Caddy, add a global `email` block if you want a specific ACME contact |
93
+
94
+ Custom domain:
95
+
96
+ 1. DNS for `app.example.com` → this host
97
+ 2. `OKE_PROXY_HOST=app.example.com` in `docker/.env.docker`
98
+ 3. Ports `80`/`443` reachable from the public internet
99
+ 4. `allowedHosts` includes `app.example.com`
100
+
101
+ Override the file via `compose.override.yml` or replace `docker/Caddyfile` —
102
+ `oke docker` regenerates the default on the next run.
103
+
104
+ ## Traefik
105
+
106
+ Generated `compose.proxy.yml` labels `app`:
107
+
108
+ | Label | Meaning |
109
+ | --------------------------------------------------------- | -------------------------------------------- |
110
+ | `traefik.enable=true` | Advertise this service to Traefik |
111
+ | `traefik.http.routers.app.rule=Host(…)` | Match `OKE_PROXY_HOST` (default `localhost`) |
112
+ | `traefik.http.routers.app.entrypoints=websecure` | HTTPS entrypoint |
113
+ | `traefik.http.routers.app.tls.certresolver=letsencrypt` | ACME via Let's Encrypt |
114
+ | `traefik.http.services.app.loadbalancer.server.port=6530` | Upstream port on the compose network |
115
+
116
+ HTTP (`:80`) redirects to HTTPS. ACME email comes from
117
+ `OKE_PROXY_ACME_EMAIL` (default `admin@example.com`) on the Traefik command line.
118
+
119
+ Scaled replicas need no label edits — `docker compose up --scale app=N` registers
120
+ every replica through the Docker provider.
121
+
122
+ ## Why docker-socket-proxy (Traefik)
123
+
124
+ Traefik's Docker provider watches the Engine API for containers and labels.
125
+ The naive setup mounts `/var/run/docker.sock` into Traefik itself.
126
+
127
+ That socket is root-equivalent for the host Docker daemon. If the edge process
128
+ is compromised (public `80`/`443`), an attacker with the raw socket can start
129
+ privileged containers, mount the host filesystem, or escape the container.
130
+
131
+ Traefik [documents this risk](https://doc.traefik.io/traefik/reference/install-configuration/providers/docker/)
132
+ and recommends a filtered proxy. OKE **never** mounts `docker.sock` into Traefik —
133
+ a companion `tecnativa/docker-socket-proxy` mounts it read-only and exposes only:
134
+
135
+ | Flag | Why Traefik needs it |
136
+ | ------------ | -------------------------- |
137
+ | `CONTAINERS` | List / inspect for labels |
138
+ | `EVENTS` | Watch start/stop for scale |
139
+ | `PING` | Health of the Engine API |
140
+ | `VERSION` | API negotiation |
141
+ | `NETWORKS` | Network membership |
142
+
143
+ Destructive Engine calls (`images`, `volumes`, exec, swarm admin, …) stay denied.
144
+ Traefik uses `--providers.docker.endpoint=tcp://socket-proxy:2375` on the
145
+ internal `oke` network only — do **not** publish `2375` on the host.
146
+
147
+ **Consequence:** a compromised Traefik still talks to a narrow API surface, not
148
+ the full Docker control plane.
149
+
150
+ ## Environment
151
+
152
+ | Variable | Default | Used by |
153
+ | ---------------------- | ------------------- | ----------------------------------------- |
154
+ | `OKE_PROXY_HOST` | `localhost` | Caddy site address; Traefik `Host()` rule |
155
+ | `OKE_PROXY_ACME_EMAIL` | `admin@example.com` | Traefik ACME account email |
156
+
157
+ ## Where this applies
158
+
159
+ | Surface | Edge / ingress |
160
+ | --------------------------------------------- | -------------------------------------------------------- |
161
+ | [Docker](/docs/deployment/docker) | Opt-in Caddy or Traefik (`images.proxy`) |
162
+ | [Docker Swarm](/docs/deployment/docker-swarm) | Same proxy recipes + Swarm routing mesh |
163
+ | [Kubernetes](/docs/deployment/kubernetes) | Ingress / Gateway API (cluster-native — not this recipe) |
164
+
165
+ ## Troubleshooting
166
+
167
+ <Accordions>
168
+ <Accordion title="compose up --scale app=N fails with port already allocated">
169
+
170
+ You still have `6530:6530` on `app`. Confirm `images.proxy` is set and
171
+ `compose.proxy.yml` is in the `-f` list so derivation omitted the host bind.
172
+
173
+ </Accordion>
174
+ <Accordion title="Let's Encrypt challenge fails">
175
+
176
+ `OKE_PROXY_HOST` must be a public DNS name pointing at this host; ports `80`/`443`
177
+ reachable. Set `OKE_PROXY_ACME_EMAIL` for Traefik. For localhost-only smoke tests, prefer Caddy.
178
+
179
+ </Accordion>
180
+ <Accordion title="Traefik starts but never routes to app">
181
+
182
+ Confirm app labels are present (`traefik.enable=true`) in `compose.proxy.yml`,
183
+ containers share the `oke` network, and `socket-proxy` is healthy. Traefik must
184
+ not mount `docker.sock` itself.
185
+
186
+ </Accordion>
187
+ <Accordion title="Host header rejected (403)">
188
+
189
+ Serve validates `Host`. Add your public hostname to `allowedHosts`
190
+ ([Security](/docs/reference/security)).
191
+
192
+ </Accordion>
193
+ <Accordion title="Caddy serves localhost certs on my public host">
194
+
195
+ `OKE_PROXY_HOST` is still `localhost` (or unset). Set the public DNS name in
196
+ `docker/.env.docker` and recreate the proxy container so it reloads the Caddyfile.
197
+
198
+ </Accordion>
199
+ </Accordions>
200
+
201
+ ## Learn more
202
+
203
+ - [Deployment](/docs/deployment) — pick compose, Swarm, or Kubernetes
204
+ - [Docker](/docs/deployment/docker) — plain compose path (includes proxy opt-in)
205
+ - [Docker Swarm](/docs/deployment/docker-swarm) — `docker stack deploy`
206
+ - [CLI](/docs/reference/cli) — `oke docker`, `oke docker --prod`
207
+ - [Configuration](/docs/reference/configuration) — `images` pins
208
+ - [Security](/docs/reference/security) — `allowedHosts` behind a reverse proxy
209
+
210
+ ## Next
211
+
212
+ <Cards>
213
+ <Card title="Deployment" href="/docs/deployment" />
214
+ <Card title="Docker" href="/docs/deployment/docker" />
215
+ <Card title="Docker Swarm" href="/docs/deployment/docker-swarm" />
216
+ </Cards>
@@ -85,6 +85,8 @@ Locally the `console` driver captures mail into an inbox instead of sending; in
85
85
 
86
86
  Opt-out is first-class: a subject who opted out of a medium is **suppressed** — the send resolves without contacting the provider, and the receipt status is `suppressed/opted-out`. You never hand-roll "did they unsubscribe?" checks.
87
87
 
88
+ **Known limit:** default suppression, consent, and receipt stores are **process-local memory**. Opt-out / hard-bounce / receipt on replica A is invisible to replica B. Boot prints a one-shot warning when those defaults are used. Inject shared stores for multi-instance, or run a single Channel consumer, until a durable driver ships.
89
+
88
90
  ### Locale resolves, then the catalog
89
91
 
90
92
  `fx.send` picks a locale: explicit `locale` → `profileLocale` → first
@@ -158,7 +160,7 @@ capability-gated `send` effects and dry-run safe.
158
160
 
159
161
  They dispatch only when the bound SMS driver supports Verify (`taqnyat`); any other SMS
160
162
  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.
163
+ [OTP](/docs/plugins/otp) Tier 1 uses this path automatically.
162
164
 
163
165
  `webpush` needs VAPID keys — open it yourself and include it in
164
166
  `BootOptions.channel.drivers` (boot does not open push from env):
@@ -245,14 +245,16 @@ drivers: {
245
245
  },
246
246
  ```
247
247
 
248
- | Driver | Runs as | Boot today |
249
- | ---------- | -------------- | ------------------------------------------------------------------------- |
250
- | `memory` | in-process | Local loop + tests — zero infrastructure |
251
- | `redis` | Redis / Valkey | Default for docker/prod — Streams + pub/sub via `Bun.redis` |
252
- | `postgres` | Postgres | Driver exists; boot **fails loud** until a LISTEN/NOTIFY SQL client binds |
253
- | `nats` | NATS | Driver exists; boot **fails loud** until a production NATS client binds |
248
+ | Driver | Runs as | Boot today |
249
+ | ---------- | ------------------------------------ | --------------------------------------------------------------------------- |
250
+ | `memory` | in-process | Local loop + tests — zero infrastructure |
251
+ | `redis` | Redis / Valkey / Dragonfly image pin | Boot-bound **emit relay** via Streams / pub/sub; consume is process-local |
252
+ | `postgres` | Postgres | Driver exists; boot **fails loud** until a LISTEN/NOTIFY SQL client binds |
253
+ | `nats` | NATS | Driver exists; boot **fails loud** until a production NATS client binds |
254
254
 
255
- `redis` is the honestly boot-bound durable path today. Pairing an arbitrary Store insert with emit inside **one** shared SQL transaction is not what `fx.emit` does keep consumers idempotent (at-least-once).
255
+ **Known limit:** `redis` relays emits to Redis, but `once` / `broadcast` / `live` **consume, `live` replay, and `drain` still use a process-local outbox** today. Competing consumers across replicas do not share that outbox until Redis Streams consume ships. Boot prints a one-shot warning when `drivers.signal` is `redis`. Prefer a single consumer instance (or a shared durable outbox path in tests) for multi-process `once` until then.
256
+
257
+ Pairing an arbitrary Store insert with emit inside **one** shared SQL transaction is not what `fx.emit` does — keep consumers idempotent (at-least-once).
256
258
 
257
259
  ## Troubleshooting
258
260
 
@@ -279,7 +281,7 @@ Ask: _how many consumers should process each message?_ One → `once`. All of th
279
281
  </Accordion>
280
282
  <Accordion title="My emit happened but the row didn't (or vice versa)">
281
283
 
282
- `fx.emit` commits the signal outbox when it resolves; it does not wrap your Store insert. Treat delivery as at-least-once and make the consumer idempotent (or check the row before acting). Prefer `redis` (or a future LISTEN-capable postgres bind) when you want durable delivery rather than in-process memory.
284
+ `fx.emit` commits the signal outbox when it resolves; it does not wrap your Store insert. Treat delivery as at-least-once and make the consumer idempotent (or check the row before acting). Remember: `redis` today is an emit relay with a process-local consume outbox not multi-instance competing consumers yet.
283
285
 
284
286
  </Accordion>
285
287
  <Accordion title="The same once message ran twice">
@@ -251,6 +251,20 @@ Staging/prod accumulate versioned SQL under `drizzle/` (`oke db generate`).
251
251
  | Local | `oke db push` (or auto-push from `oke dev`) |
252
252
  | Staging / prod | `oke db generate` → review files → `oke db migrate` on that DB |
253
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
+
254
268
  #### Seeding
255
269
 
256
270
  `oke db seed` loads `defineSeed` from `src/seed/index.ts` — **never at boot**.
@@ -490,6 +504,8 @@ drivers: {
490
504
  },
491
505
  images: {
492
506
  "store.kv": "redis:8-alpine",
507
+ // or: "valkey/valkey:8-alpine"
508
+ // or: "docker.dragonflydb.io/dragonflydb/dragonfly"
493
509
  },
494
510
  ```
495
511
 
@@ -505,6 +521,22 @@ images: {
505
521
 
506
522
  Missing Redis URL fails boot loudly: `oke boot: redis driver needs REDIS_URL`.
507
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
+
508
540
  <Callout title="Dry-run refuses KV writes">
509
541
  `set` and `delete` throw `DryRunWriteIsolationError` during dry-run — the runtime will not risk a
510
542
  double-write against a shared Redis. Reads (`get` / `list`) still run.
@@ -603,6 +635,8 @@ images: {
603
635
 
604
636
  `memory` is the test default. Local `fs` writes under a temp root when no binding `root` is set.
605
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
+
606
640
  <Callout title="Console can browse, not edit bytes">
607
641
  The [Console · Store](/docs/console/store) lists keys and can delete them. Direct edit is **KV +
608
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>
@@ -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>
@@ -147,14 +147,14 @@ unit tests without SMTP, set `exposeDevToken: true`.
147
147
 
148
148
  ## Learn more
149
149
 
150
- - [Email OTP](/docs/plugins/email-otp) — numeric code instead of a link
150
+ - [OTP](/docs/plugins/otp) — numeric code instead of a link
151
151
  - [Gate](/docs/elements/gate) — `gate.auth`
152
152
  - [Channel](/docs/elements/channel) — `fx.send`, Mailpit, consents
153
153
 
154
154
  ## Next
155
155
 
156
156
  <Cards>
157
- <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" />
158
158
  <Card title="Gate" description="Builtin auth and policies." href="/docs/elements/gate" />
159
159
  <Card title="Channel" description="Email delivery and Mailpit." href="/docs/elements/channel" />
160
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---",