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