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
@@ -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
@@ -132,23 +134,34 @@ images: {
132
134
  },
133
135
  ```
134
136
 
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 |
137
+ | Driver | Medium | Behavior |
138
+ | -------------- | -------- | ------------------------------------------------------------------- |
139
+ | `console` | any | Captures into a readable inbox — local + tests |
140
+ | `smtp` | email | Real SMTP — Mailpit in docker, your provider in prod |
141
+ | `resend` | email | Resend HTTP API (`RESEND_API_KEY`) |
142
+ | `sndr` | email | SNDR HTTP API (`SNDR_API_KEY`) |
143
+ | `taqnyat-mail` | email | Taqnyat Mail API (`TAQNYAT_MAIL_TOKEN` + `TAQNYAT_CAMPAIGN`) |
144
+ | `taqnyat` | sms | Taqnyat SMS (`TAQNYAT_BEARER_TOKEN` + `TAQNYAT_SENDER`) |
145
+ | `msegat` | sms | Msegat SMS (`MSEGAT_USERNAME` + `MSEGAT_API_KEY` + `MSEGAT_SENDER`) |
146
+ | `unifonic` | sms | Unifonic el.cloud SMS (`UNIFONIC_APPSID`) |
147
+ | `wa-cloud` | whatsapp | Meta WhatsApp Cloud API |
148
+ | `webpush` | push | Web Push + VAPID (`createPushSender` → `WebPushTransport`) |
149
+ | `fcm` | push | Firebase Cloud Messaging HTTP v1 |
147
150
 
148
151
  Email fallback uses ordered email transports; SMS with multiple SMS drivers uses
149
152
  the same failover path. Credentials for boot-opened drivers are listed under
150
153
  [Environment Variables](/docs/reference/environment-variables).
151
154
 
155
+ ### Provider-managed OTP
156
+
157
+ Flows can also send a provider-generated SMS code with `fx.sendOtp({ to, requestId, lang? })`
158
+ and check it with `fx.verifyOtp({ to, requestId, code })` — Taqnyat's Verify API. Both are
159
+ capability-gated `send` effects and dry-run safe.
160
+
161
+ They dispatch only when the bound SMS driver supports Verify (`taqnyat`); any other SMS
162
+ driver fails loudly instead of silently falling back to a self-generated code. The
163
+ [OTP](/docs/plugins/otp) Tier 1 uses this path automatically.
164
+
152
165
  `webpush` needs VAPID keys — open it yourself and include it in
153
166
  `BootOptions.channel.drivers` (boot does not open push from env):
154
167
 
@@ -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
 
@@ -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">