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.
- package/package.json +3 -2
- package/site/content/docs/deployment/docker-swarm.mdx +228 -0
- package/site/content/docs/deployment/docker.mdx +212 -0
- package/site/content/docs/deployment/index.mdx +83 -0
- package/site/content/docs/deployment/kubernetes.mdx +176 -0
- package/site/content/docs/deployment/meta.json +5 -0
- package/site/content/docs/deployment/reverse-proxy.mdx +216 -0
- package/site/content/docs/elements/channel.mdx +25 -12
- package/site/content/docs/elements/clock.mdx +17 -15
- package/site/content/docs/elements/flow.mdx +6 -2
- package/site/content/docs/elements/signal.mdx +10 -8
- package/site/content/docs/elements/store.mdx +165 -0
- package/site/content/docs/get-started/index.mdx +5 -0
- package/site/content/docs/get-started/installation.mdx +18 -16
- package/site/content/docs/index.mdx +5 -0
- package/site/content/docs/meta.json +10 -1
- package/site/content/docs/plugins/index.mdx +1 -2
- package/site/content/docs/plugins/magic-link.mdx +44 -2
- package/site/content/docs/plugins/meta.json +1 -2
- package/site/content/docs/plugins/otp.mdx +202 -0
- package/site/content/docs/plugins/two-factor.mdx +2 -1
- package/site/content/docs/reference/cli.md +5 -2
- package/site/content/docs/reference/configuration.mdx +21 -3
- package/site/content/docs/reference/environment-variables.mdx +21 -8
- package/site/content/docs/reference/plugins.mdx +1 -1
- package/src/auth/auth.test.ts +36 -0
- package/src/auth/bindings.ts +3 -12
- package/src/auth/identity.ts +33 -0
- package/src/auth/index.ts +5 -0
- package/src/auth/otp-capability.ts +119 -0
- package/src/auth/otp-seal.test.ts +61 -0
- package/src/auth/otp-seal.ts +84 -0
- package/src/auth/schema.ts +3 -0
- package/src/auth/sessions.ts +26 -27
- package/src/auth/tables.ts +4 -0
- package/src/auth/verification.ts +61 -1
- package/src/cli/db-seed.ts +359 -0
- package/src/cli/db.test.ts +341 -3
- package/src/cli/db.ts +75 -8
- package/src/cli/dev-app-runner.ts +4 -0
- package/src/cli/docker.ts +4 -1
- package/src/cli/load-config.images.test.ts +26 -0
- package/src/cli/load-config.ts +10 -2
- package/src/cli/registry.ts +38 -2
- package/src/compiler/effects-infer.ts +1 -0
- package/src/config/index.ts +4 -0
- package/src/console/server/operator-db.ts +34 -9
- package/src/docker/compose.ts +162 -6
- package/src/docker/derive.ts +60 -3
- package/src/docker/docker.test.ts +374 -1
- package/src/docker/helpers.ts +2 -0
- package/src/docker/index.ts +11 -0
- package/src/docker/recipes/caddy.ts +51 -0
- package/src/docker/recipes/dragonfly.ts +31 -0
- package/src/docker/recipes/index.ts +25 -2
- package/src/docker/recipes/pgdog.ts +84 -0
- package/src/docker/recipes/redis.ts +6 -3
- package/src/docker/recipes/traefik.ts +83 -0
- package/src/docker/recipes/valkey.ts +30 -0
- package/src/docker/stack-id.ts +5 -0
- package/src/docker/types.ts +18 -0
- package/src/drivers/channel-sently.test.ts +8 -0
- package/src/drivers/channel-taqnyat-mail.ts +34 -0
- package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
- package/src/drivers/channel-types.ts +72 -0
- package/src/drivers/clock-postgres.test.ts +258 -0
- package/src/drivers/clock-postgres.ts +410 -0
- package/src/drivers/index.ts +18 -0
- package/src/drivers/journal-postgres.test.ts +175 -0
- package/src/drivers/journal-postgres.ts +492 -0
- package/src/elements/channel/otp-delivery.test.ts +76 -0
- package/src/elements/channel/otp-delivery.ts +291 -0
- package/src/elements/channel/runtime.ts +203 -114
- package/src/elements/channel.test.ts +71 -0
- package/src/elements/channel.ts +12 -2
- package/src/elements/clock/chaos-child.ts +280 -41
- package/src/elements/clock/durable.ts +7 -0
- package/src/elements/clock/reconcile.ts +2 -2
- package/src/elements/clock/runtime.ts +5 -3
- package/src/elements/clock.ts +1 -1
- package/src/elements/store/seed.test.ts +27 -0
- package/src/elements/store/seed.ts +68 -0
- package/src/elements/store/sql-session.test.ts +39 -0
- package/src/elements/store/sql-session.ts +55 -0
- package/src/elements/store/upsert-app.test.ts +103 -0
- package/src/elements/store.ts +5 -0
- package/src/index.ts +18 -0
- package/src/kernel/app.ts +221 -14
- package/src/kernel/boot-bind/channel.test.ts +16 -0
- package/src/kernel/boot-bind/channel.ts +64 -0
- package/src/kernel/boot-bind/clock.ts +17 -6
- package/src/kernel/boot-bind/gate.ts +14 -19
- package/src/kernel/boot-bind/honor-config.test.ts +123 -4
- package/src/kernel/boot-bind/journal.ts +89 -0
- package/src/kernel/boot-bind/signal.ts +20 -0
- package/src/kernel/boot-bind/store.test.ts +82 -0
- package/src/kernel/boot-bind/store.ts +22 -0
- package/src/kernel/boot.test.ts +6 -4
- package/src/kernel/boot.ts +53 -13
- package/src/kernel/concurrency.ts +1 -1
- package/src/kernel/fx.test.ts +9 -0
- package/src/kernel/fx.ts +175 -5
- package/src/kernel/graceful-shutdown.test.ts +76 -0
- package/src/kernel/graceful-shutdown.ts +106 -0
- package/src/kernel/horizontal-child.ts +257 -0
- package/src/kernel/horizontal.integration.test.ts +229 -0
- package/src/kernel/index.ts +14 -0
- package/src/kernel/journal-boot.test.ts +397 -0
- package/src/kernel/journal-suspend.ts +35 -0
- package/src/kernel/journal.test.ts +142 -0
- package/src/kernel/journal.ts +202 -27
- package/src/kernel/ready.test.ts +76 -0
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +5 -5
- package/src/plugins/auth-methods.security.test.ts +20 -27
- package/src/plugins/auth-methods.test.ts +7 -6
- package/src/plugins/index.ts +12 -8
- package/src/plugins/magic-link.ts +1 -23
- package/src/plugins/otp.test.ts +236 -0
- package/src/plugins/otp.ts +570 -0
- package/src/plugins/taqnyat.live.test.ts +172 -0
- package/src/release/official-plugins.ts +1 -2
- package/site/content/docs/plugins/email-otp.mdx +0 -117
- package/site/content/docs/plugins/phone-number.mdx +0 -111
- package/src/plugins/email-otp.ts +0 -214
- 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,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
|
|
136
|
-
|
|
|
137
|
-
| `console`
|
|
138
|
-
| `smtp`
|
|
139
|
-
| `resend`
|
|
140
|
-
| `sndr`
|
|
141
|
-
| `taqnyat`
|
|
142
|
-
| `
|
|
143
|
-
| `
|
|
144
|
-
| `
|
|
145
|
-
| `
|
|
146
|
-
| `
|
|
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
|
|
107
|
-
|
|
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
|
|
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
|
|
127
|
-
|
|
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: "
|
|
144
|
+
clock: { local: "memory", docker: "postgres", test: "frozen", prod: "postgres" },
|
|
145
145
|
},
|
|
146
146
|
```
|
|
147
147
|
|
|
148
|
-
| Driver
|
|
149
|
-
|
|
|
150
|
-
| `memory`
|
|
151
|
-
| `
|
|
152
|
-
| `
|
|
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
|
|
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"); //
|
|
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:**
|
|
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
|
|
249
|
-
| ---------- |
|
|
250
|
-
| `memory` | in-process
|
|
251
|
-
| `redis` | Redis / Valkey
|
|
252
|
-
| `postgres` | Postgres
|
|
253
|
-
| `nats` | NATS
|
|
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`
|
|
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).
|
|
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">
|