okengine 0.5.1 → 0.6.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 -1
- package/site/content/docs/elements/ai.mdx +82 -1
- package/site/content/docs/elements/channel.mdx +6 -1
- package/site/content/docs/elements/flow.mdx +20 -17
- package/site/content/docs/plugins/email-otp.mdx +25 -19
- package/site/content/docs/plugins/magic-link.mdx +27 -21
- package/site/content/docs/reference/configuration.mdx +7 -0
- package/site/content/docs/reference/environment-variables.mdx +10 -5
- package/site/content/docs/reference/errors.mdx +14 -0
- package/site/content/docs/reference/fx.mdx +68 -16
- package/site/content/docs/reference/i18n.mdx +313 -0
- package/site/content/docs/reference/index.mdx +6 -1
- package/site/content/docs/reference/meta.json +1 -0
- package/site/content/docs/reference/plugins.mdx +1 -0
- package/src/auth/auth.test.ts +3 -0
- package/src/auth/bindings.ts +1 -1
- package/src/auth/method-context.ts +12 -2
- package/src/compiler/aot.test.ts +16 -13
- package/src/compiler/effects-infer.ts +46 -0
- package/src/console/server/ai.test.ts +34 -5
- package/src/docker/compose.ts +9 -0
- package/src/docker/docker.test.ts +39 -0
- package/src/docker/index.ts +11 -1
- package/src/docker/recipes/index.ts +3 -1
- package/src/docker/recipes/ollama.ts +43 -0
- package/src/docker/stack-id.ts +2 -0
- package/src/drivers/ai-mock.ts +60 -0
- package/src/drivers/ai-ollama-tools.integration.test.ts +107 -0
- package/src/drivers/ai-ollama.integration.test.ts +197 -0
- package/src/drivers/ai-ollama.ts +327 -0
- package/src/drivers/ai-openai-compatible.ts +211 -21
- package/src/drivers/ai-providers.test.ts +179 -2
- package/src/drivers/ai-stream.test.ts +195 -0
- package/src/drivers/ai-types.ts +42 -1
- package/src/drivers/channel-smtp.ts +8 -2
- package/src/drivers/index.ts +21 -1
- package/src/drivers/ollama.ts +14 -0
- package/src/elements/ai/rate.test.ts +53 -0
- package/src/elements/ai/rate.ts +66 -0
- package/src/elements/ai/redacted-prompt.test.ts +90 -0
- package/src/elements/ai/runtime.ts +330 -100
- package/src/elements/ai/tools.test.ts +99 -0
- package/src/elements/ai.test.ts +26 -2
- package/src/elements/ai.ts +10 -1
- package/src/i18n/catalogs/ar.ts +67 -0
- package/src/i18n/catalogs/en.ts +68 -0
- package/src/i18n/failure-message.test.ts +56 -0
- package/src/i18n/failure-message.ts +93 -0
- package/src/i18n/format.ts +67 -0
- package/src/i18n/index.ts +57 -0
- package/src/i18n/locale-context.ts +48 -0
- package/src/i18n/messages.test.ts +173 -0
- package/src/i18n/messages.ts +169 -0
- package/src/i18n/types.ts +90 -0
- package/src/index.ts +26 -0
- package/src/kernel/app.ts +92 -2
- package/src/kernel/boot-bind/ai.test.ts +60 -0
- package/src/kernel/boot-bind/ai.ts +125 -2
- package/src/kernel/boot.test.ts +4 -3
- package/src/kernel/boot.ts +1 -1
- package/src/kernel/errors.ts +56 -5
- package/src/kernel/fx.test.ts +27 -0
- package/src/kernel/fx.ts +74 -18
- package/src/kernel/pipeline.test.ts +4 -0
- package/src/kernel/pipeline.ts +1 -1
- package/src/kernel/plugin.ts +16 -0
- package/src/kernel/registry.ts +15 -0
- package/src/plugins/auth/shared.ts +5 -1
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +330 -0
- package/src/plugins/auth-methods.security.test.ts +12 -10
- package/src/plugins/email-otp.ts +54 -1
- package/src/plugins/index.ts +16 -2
- package/src/plugins/magic-link.ts +63 -3
- package/src/plugins/username-policy.test.ts +302 -0
- package/src/plugins/username.ts +290 -9
- package/src/release/measure.ts +8 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "okengine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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": {
|
|
@@ -71,6 +71,7 @@
|
|
|
71
71
|
"@duckdb/node-api": "^1.5.5-r.2",
|
|
72
72
|
"ajv": "^8.20.0",
|
|
73
73
|
"ajv-formats": "^3.0.1",
|
|
74
|
+
"intl-messageformat": "^11.2.13",
|
|
74
75
|
"oxc-parser": "^0.142.0",
|
|
75
76
|
"sently": "^0.8.0"
|
|
76
77
|
},
|
|
@@ -88,6 +88,17 @@ export const triage = smart.prompt("ticket-triage", {
|
|
|
88
88
|
|
|
89
89
|
<AiGuardrails />
|
|
90
90
|
|
|
91
|
+
## Tools are flows (same `fx.call` path)
|
|
92
|
+
|
|
93
|
+
Offer flows as tools on a one-shot ask — each model-initiated call goes through the host flow’s `fx.call`, so capability, Manifest `calls`, and Runs stay honest. No silent extra authority for the model:
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
const result = await fx.ask(triage, input, {
|
|
97
|
+
tools: [getBooking, refundBooking],
|
|
98
|
+
maxSteps: 6,
|
|
99
|
+
});
|
|
100
|
+
```
|
|
101
|
+
|
|
91
102
|
## Agents with real guardrails
|
|
92
103
|
|
|
93
104
|
An agent's tools are your flows — each carrying its own gates, effects, and typed errors, so the agent can never do anything a flow couldn't:
|
|
@@ -101,7 +112,25 @@ export const support = ai.agent("support", {
|
|
|
101
112
|
});
|
|
102
113
|
```
|
|
103
114
|
|
|
104
|
-
`maxSteps` bounds the loop; `budget.maxCostPerRun` bounds the spend. Both are declared, so "the agent ran away" is a violated contract, not a surprise.
|
|
115
|
+
`maxSteps` bounds the loop; `budget.maxCostPerRun` bounds the spend. Both are declared, so "the agent ran away" is a violated contract, not a surprise. `fx.run(support, { message })` uses the same tool loop and the same `fx.call` dispatch as `fx.ask(…, { tools })`.
|
|
116
|
+
|
|
117
|
+
## Rate limits via Gate
|
|
118
|
+
|
|
119
|
+
AI calls are expensive — reuse `gate.rate`, don’t invent a parallel budget. Presets: `aiRateGate("ask" | "agent" | "embed")` (defaults 20 / 10 / 60 per minute, `keyBy: "user"`). Attach on the HTTP trigger:
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
on(http.post("/triage").gate(member, aiRateGate("ask")), triageFlow);
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Use `keyBy: "ip"` on public unauthenticated AI edges. Cost caps stay on prompt/agent `budget` decls.
|
|
126
|
+
|
|
127
|
+
## Secrets stay out of prompts
|
|
128
|
+
|
|
129
|
+
`Redacted` values (e.g. from `fx.vault`) stringify to `[redacted]` in provider-facing messages — the same placeholder as logs. Never cleartext to a model.
|
|
130
|
+
|
|
131
|
+
## Streaming
|
|
132
|
+
|
|
133
|
+
`fx.stream(model, { prompt })` yields real provider tokens (Ollama NDJSON, OpenAI-compatible SSE). Cancel by aborting the ambient signal — the same one `fx.all` / `fx.race` already use. Drivers without `stream` fail loud (no stub echo).
|
|
105
134
|
|
|
106
135
|
## PII cannot leak by accident
|
|
107
136
|
|
|
@@ -130,6 +159,53 @@ Because `fx.ask` is nondeterministic, the runtime adjusts around it: journaling
|
|
|
130
159
|
|
|
131
160
|
There is deliberately **no production default**: `prod` must name a driver, which keeps the model choice visible in `oke.config.ts` where review can see it.
|
|
132
161
|
|
|
162
|
+
### Local Ollama
|
|
163
|
+
|
|
164
|
+
`ollama` is a local-model driver — any model you have pulled works. Pin it in `drivers.ai`; optionally pin `images.ai` for compose.
|
|
165
|
+
|
|
166
|
+
```typescript title="oke.config.ts"
|
|
167
|
+
import { defineConfig } from "okengine/config";
|
|
168
|
+
|
|
169
|
+
export default defineConfig({
|
|
170
|
+
drivers: {
|
|
171
|
+
ai: { local: "ollama", docker: "ollama", prod: "anthropic" },
|
|
172
|
+
},
|
|
173
|
+
images: {
|
|
174
|
+
ai: "ollama/ollama:latest",
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
```typescript title="src/ai.ts"
|
|
180
|
+
export const smart = ai.model("smart", {
|
|
181
|
+
provider: "ollama",
|
|
182
|
+
model: "qwen3.5:9b", // balanced local-dev starting point — override freely
|
|
183
|
+
});
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
- **Model:** set on `ai.model(…, { model })` or `OKE_AI_MODEL`. `qwen3.5:9b` ([Ollama library](https://ollama.com/library/qwen3.5); same as `qwen3.5:latest`) is a reasonable balanced starting point for local dev — not the only option. On Apple Silicon, prefer `qwen3.5:9b-mlx` for the MLX build.
|
|
187
|
+
- **URL:** default `http://127.0.0.1:11434`. Override with `OKE_AI_URL` or `OLLAMA_HOST` (bare `host:port` is fine).
|
|
188
|
+
- **Docker:** the image recipe pulls the configured model and writes `OKE_AI_URL` into `docker/.env.docker`.
|
|
189
|
+
- **Fail-loud:** unreachable or unhealthy Ollama throws `OllamaUnavailableError` — never a silent mock fallback.
|
|
190
|
+
- **PII:** `ollama` counts as on-premise for the build-time egress check (same as `mock`).
|
|
191
|
+
|
|
192
|
+
### OpenAI-compatible endpoints (one driver)
|
|
193
|
+
|
|
194
|
+
`openai-compatible` is the shared chat/completions client — OpenAI, Groq, Together, OpenRouter, vLLM, LM Studio, and Ollama’s `/v1` shim. You do **not** get a separate driver per vendor; only `baseUrl`, `apiKey`, `model`, and optional `headers` change.
|
|
195
|
+
|
|
196
|
+
| Target | Typical `baseUrl` | Key |
|
|
197
|
+
| ------------------ | ------------------------------------- | ----------------------------------------------------------- |
|
|
198
|
+
| OpenAI | `https://api.openai.com/v1` (default) | required (`OPENAI_API_KEY`) |
|
|
199
|
+
| Groq | `https://api.groq.com/openai/v1` | required |
|
|
200
|
+
| Together | `https://api.together.xyz/v1` | required |
|
|
201
|
+
| OpenRouter | `https://openrouter.ai/api/v1` | required; optional `HTTP-Referer` / `X-Title` via `headers` |
|
|
202
|
+
| LM Studio / local | e.g. `http://127.0.0.1:1234/v1` | optional |
|
|
203
|
+
| Ollama OpenAI shim | `http://127.0.0.1:11434/v1` | optional |
|
|
204
|
+
|
|
205
|
+
Prefer the native `ollama` driver for local Ollama (`/api/chat`). Point `openai-compatible` at Ollama `/v1` only when you need the OpenAI wire shape.
|
|
206
|
+
|
|
207
|
+
**Consequence:** a missing key against the default OpenAI cloud base fails at open; a custom `baseUrl` may omit the key. HTTP errors always throw — never a silent mock fallback.
|
|
208
|
+
|
|
133
209
|
## Troubleshooting
|
|
134
210
|
|
|
135
211
|
<Accordions>
|
|
@@ -142,6 +218,11 @@ A flow sends a classified PII field to a third-party model. Either stop sending
|
|
|
142
218
|
|
|
143
219
|
The one you declared. There is no fallback or guess — if `prod` has no AI driver configured, that's a configuration gap to fix, not a silent default.
|
|
144
220
|
|
|
221
|
+
</Accordion>
|
|
222
|
+
<Accordion title="oke boot: ollama driver needs OKE_AI_URL">
|
|
223
|
+
|
|
224
|
+
Docker mode expects `OKE_AI_URL` from `docker/.env.docker` (written when `images.ai` is pinned). Locally, the default is `http://127.0.0.1:11434`. If asks fail with `OllamaUnavailableError`, confirm the server answers and that you have pulled the model you named (`ollama pull qwen3.5:9b`, or `qwen3.5:9b-mlx` on Apple Silicon, or your `OKE_AI_MODEL`).
|
|
225
|
+
|
|
145
226
|
</Accordion>
|
|
146
227
|
<Accordion title="A prompt edit made answers worse — how do I catch that pre-merge?">
|
|
147
228
|
|
|
@@ -87,7 +87,12 @@ Opt-out is first-class: a subject who opted out of a medium is **suppressed**
|
|
|
87
87
|
|
|
88
88
|
### Locale resolves through a chain
|
|
89
89
|
|
|
90
|
-
Templates render per recipient locale, falling back through your configured chain
|
|
90
|
+
Templates render per recipient locale, falling back through your configured chain
|
|
91
|
+
(`ar` → default `en`) instead of failing when a translation is missing.
|
|
92
|
+
|
|
93
|
+
Locales come from `oke.config.ts` `i18n`. Catalog bodies use `{{field}}` — not
|
|
94
|
+
ICU ([i18n](/docs/reference/i18n) / `fx.t` owns flow copy). Pass `locale` /
|
|
95
|
+
`profileLocale` / `acceptLanguage` on `fx.send`, or omit them for `fx.locale`.
|
|
91
96
|
|
|
92
97
|
### Fallback chains are explicit
|
|
93
98
|
|
|
@@ -92,7 +92,9 @@ do: async (input, fx) => {
|
|
|
92
92
|
};
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
-
Every response follows one envelope — success `{ data, error: null }`, failure
|
|
95
|
+
Every response follows one envelope — success `{ data, error: null }`, failure
|
|
96
|
+
`{ data: null, error: { code, data, message? } }` — clients switch on `error.code`.
|
|
97
|
+
Built-in codes attach a localized `message` ([Errors](/docs/reference/errors)); custom codes stay message-less unless registered or `{ message }` is passed.
|
|
96
98
|
|
|
97
99
|
## The five triggers
|
|
98
100
|
|
|
@@ -178,22 +180,22 @@ const order = await fx.call(getOrder, { id: orderId }); // from any other flow
|
|
|
178
180
|
|
|
179
181
|
Everything a flow may touch, on one object:
|
|
180
182
|
|
|
181
|
-
| Surface
|
|
182
|
-
|
|
|
183
|
-
| `fx.store(db).select/insert/…`
|
|
184
|
-
| `fx.emit(signal, payload)`
|
|
185
|
-
| `fx.send(template, opts)`
|
|
186
|
-
| `fx.ask(prompt, input)`
|
|
187
|
-
| `fx.run(agent, input)`
|
|
188
|
-
| `fx.call(flow, input)`
|
|
189
|
-
| `fx.vault(contract)`
|
|
190
|
-
| `fx.clock.now()` / `.sleep(…)`
|
|
191
|
-
| `fx.cache.get/set`
|
|
192
|
-
| `fx.step(name, fn)`
|
|
193
|
-
| `fx.all` / `fx.race` / `fx.retry`
|
|
194
|
-
| `fx.signal`
|
|
195
|
-
| `fx.id()` · `fx.log` · `fx.t`
|
|
196
|
-
| `fx.auth` · `fx.operator` · `fx.tenant`
|
|
183
|
+
| Surface | Effect recorded | What it does |
|
|
184
|
+
| ------------------------------------------- | --------------- | -------------------------------------------- |
|
|
185
|
+
| `fx.store(db).select/insert/…` | read / write | SQL, KV, files, index sessions |
|
|
186
|
+
| `fx.emit(signal, payload)` | emit | Publish a signal (transactional with writes) |
|
|
187
|
+
| `fx.send(template, opts)` | send | Reach a human (email · SMS · …) |
|
|
188
|
+
| `fx.ask(prompt, input)` | ask | Call a versioned AI prompt |
|
|
189
|
+
| `fx.run(agent, input)` | ask | Run a bounded agent |
|
|
190
|
+
| `fx.call(flow, input)` | call | Invoke another flow |
|
|
191
|
+
| `fx.vault(contract)` | read | Read a secret (`Redacted`; logs masked) |
|
|
192
|
+
| `fx.clock.now()` / `.sleep(…)` | — | Injected time / durable sleep |
|
|
193
|
+
| `fx.cache.get/set` | — | Shared cache with effect-aware invalidation |
|
|
194
|
+
| `fx.step(name, fn)` | — | Named durable step — never re-runs on replay |
|
|
195
|
+
| `fx.all` / `fx.race` / `fx.retry` | — | Structured concurrency + backoff retry |
|
|
196
|
+
| `fx.signal` | — | Ambient `AbortSignal` for the current branch |
|
|
197
|
+
| `fx.id()` · `fx.log` · `fx.t` · `fx.locale` | — | UUIDs, redacting logger, ICU i18n |
|
|
198
|
+
| `fx.auth` · `fx.operator` · `fx.tenant` | — | Who is calling (user / operator / tenant) |
|
|
197
199
|
|
|
198
200
|
<Callout title="Why this strictness pays off">
|
|
199
201
|
Effects are inferred from `fx` usage, so the Manifest knows exactly which flows read `orders` or
|
|
@@ -275,6 +277,7 @@ Only that run fails — the schedule keeps firing and the process does not exit.
|
|
|
275
277
|
|
|
276
278
|
## Learn more
|
|
277
279
|
|
|
280
|
+
- [fx](/docs/reference/fx) — full `fx` surface, including ICU `fx.t` / `fx.locale`
|
|
278
281
|
- [Signal](/docs/elements/signal) — delivery physics (`once` · `broadcast` · `live`)
|
|
279
282
|
- [Clock](/docs/elements/clock) — schedules and durable sleep
|
|
280
283
|
- [Console · Flows](/docs/console/flows) — the Manifest-derived panel
|
|
@@ -5,12 +5,13 @@ icon: "MailCheck"
|
|
|
5
5
|
source: "docs/spec/unified-theory.md"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
`emailOtp()` stores a hashed 6-digit code (default 10 minutes, 5 attempts).
|
|
9
|
-
user on first success and returns hybrid
|
|
8
|
+
`emailOtp()` stores a hashed 6-digit code (default 10 minutes, 5 attempts). Request sends the
|
|
9
|
+
code via Channel (`auth-email-otp`); verify creates the user on first success and returns hybrid
|
|
10
|
+
session tokens.
|
|
10
11
|
|
|
11
12
|
<Callout title="The one rule">
|
|
12
|
-
Enable `gate.auth`, then `.plug(emailOtp())`. Never log raw OTPs.
|
|
13
|
-
|
|
13
|
+
Enable `gate.auth`, then `.plug(emailOtp())`. Never log raw OTPs. Delivery goes through `fx.send`;
|
|
14
|
+
use `exposeDevOtp` only for local DX without SMTP.
|
|
14
15
|
</Callout>
|
|
15
16
|
|
|
16
17
|
## Quick start
|
|
@@ -28,7 +29,7 @@ export const app = oke({
|
|
|
28
29
|
name: "shop",
|
|
29
30
|
env: "local",
|
|
30
31
|
gate: { auth: {} },
|
|
31
|
-
}).plug(emailOtp(
|
|
32
|
+
}).plug(emailOtp());
|
|
32
33
|
```
|
|
33
34
|
|
|
34
35
|
</Step>
|
|
@@ -38,7 +39,7 @@ export const app = oke({
|
|
|
38
39
|
|
|
39
40
|
```typescript
|
|
40
41
|
const { data } = await api.auth.requestEmailOtp({ email: "ali@example.com" });
|
|
41
|
-
// data.devOtp when exposeDevOtp
|
|
42
|
+
// Channel delivers auth-email-otp; data.devOtp only when exposeDevOtp
|
|
42
43
|
```
|
|
43
44
|
|
|
44
45
|
`POST /auth/email-otp/request` — prior active codes for that email are invalidated.
|
|
@@ -51,7 +52,7 @@ const { data } = await api.auth.requestEmailOtp({ email: "ali@example.com" });
|
|
|
51
52
|
```typescript
|
|
52
53
|
const { data } = await api.auth.verifyEmailOtp({
|
|
53
54
|
email: "ali@example.com",
|
|
54
|
-
otp
|
|
55
|
+
otp,
|
|
55
56
|
});
|
|
56
57
|
```
|
|
57
58
|
|
|
@@ -64,14 +65,15 @@ const { data } = await api.auth.verifyEmailOtp({
|
|
|
64
65
|
|
|
65
66
|
## Options
|
|
66
67
|
|
|
67
|
-
| Option | Type | Default
|
|
68
|
-
| --------------- | ------------------- |
|
|
69
|
-
| `secret` | `string` | active\*
|
|
70
|
-
| `sessions` | `SessionStore` | active\*
|
|
71
|
-
| `ttlMs` | `number` | 10m
|
|
72
|
-
| `
|
|
73
|
-
| `
|
|
74
|
-
| `
|
|
68
|
+
| Option | Type | Default | Meaning |
|
|
69
|
+
| --------------- | ------------------- | -------------------------- | --------------------------------------- |
|
|
70
|
+
| `secret` | `string` | active\* | HMAC secret (\*from `gate.auth`) |
|
|
71
|
+
| `sessions` | `SessionStore` | active\* | Session store |
|
|
72
|
+
| `ttlMs` | `number` | 10m | Challenge lifetime |
|
|
73
|
+
| `from` | `string` | `OKE <no-reply@oke.local>` | Template From address |
|
|
74
|
+
| `exposeDevOtp` | `boolean` | `false` | Include raw OTP in the request response |
|
|
75
|
+
| `identities` | `IdentityStore` | new | Email → user map |
|
|
76
|
+
| `verifications` | `VerificationStore` | new | Challenge store |
|
|
75
77
|
|
|
76
78
|
## Surfaces
|
|
77
79
|
|
|
@@ -80,13 +82,16 @@ const { data } = await api.auth.verifyEmailOtp({
|
|
|
80
82
|
| `auth.requestEmailOtp` | `POST /auth/email-otp/request` | `gate.public` + otp rate |
|
|
81
83
|
| `auth.verifyEmailOtp` | `POST /auth/email-otp/verify` | `gate.public` + otp rate |
|
|
82
84
|
|
|
85
|
+
**Consequence:** the plugin contributes the `auth-email-otp` Channel template and EN/AR
|
|
86
|
+
catalog bodies (`{{otp}}`). Override copy by merging your own catalog at boot.
|
|
87
|
+
|
|
83
88
|
## Troubleshooting
|
|
84
89
|
|
|
85
90
|
<Accordions>
|
|
86
91
|
<Accordion title="No email with the code">
|
|
87
92
|
|
|
88
|
-
|
|
89
|
-
|
|
93
|
+
In `local` / `test` the `console` driver captures mail — open the inbox, or run
|
|
94
|
+
`oke dev --docker` and check Mailpit. For unit tests without SMTP, set `exposeDevOtp: true`.
|
|
90
95
|
|
|
91
96
|
</Accordion>
|
|
92
97
|
<Accordion title="AuthFailed after a few tries">
|
|
@@ -99,7 +104,8 @@ Five failed attempts consume the challenge. Request a new OTP.
|
|
|
99
104
|
## Learn more
|
|
100
105
|
|
|
101
106
|
- [Magic link](/docs/plugins/magic-link) — link instead of a code
|
|
102
|
-
- [Phone number](/docs/plugins/phone-number) — SMS OTP (E.164)
|
|
107
|
+
- [Phone number](/docs/plugins/phone-number) — SMS OTP (E.164; delivery deferred)
|
|
108
|
+
- [Channel](/docs/elements/channel) — `fx.send`, Mailpit, consents
|
|
103
109
|
- [Gate](/docs/elements/gate) — `gate.auth`
|
|
104
110
|
|
|
105
111
|
## Next
|
|
@@ -107,5 +113,5 @@ Five failed attempts consume the challenge. Request a new OTP.
|
|
|
107
113
|
<Cards>
|
|
108
114
|
<Card title="Phone number" description="E.164 SMS OTP." href="/docs/plugins/phone-number" />
|
|
109
115
|
<Card title="Magic link" description="Email link sign-in." href="/docs/plugins/magic-link" />
|
|
110
|
-
<Card title="
|
|
116
|
+
<Card title="Channel" description="Email delivery and Mailpit." href="/docs/elements/channel" />
|
|
111
117
|
</Cards>
|
|
@@ -5,12 +5,13 @@ icon: "Link2"
|
|
|
5
5
|
source: "docs/spec/unified-theory.md"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
`magicLink()` issues a hashed, single-use token (default 10 minutes).
|
|
9
|
-
hybrid session and creates the user
|
|
8
|
+
`magicLink()` issues a hashed, single-use token (default 10 minutes). Request sends the link
|
|
9
|
+
via Channel (`auth-magic-link`); verify exchanges it for a hybrid session and creates the user
|
|
10
|
+
on first success.
|
|
10
11
|
|
|
11
12
|
<Callout title="The one rule">
|
|
12
13
|
Enable `gate.auth`, then `.plug(magicLink())`. Tokens are hashed at rest — never log the raw link.
|
|
13
|
-
|
|
14
|
+
Delivery goes through `fx.send`; use `exposeDevToken` only for local DX without SMTP.
|
|
14
15
|
</Callout>
|
|
15
16
|
|
|
16
17
|
## Quick start
|
|
@@ -28,7 +29,7 @@ export const app = oke({
|
|
|
28
29
|
name: "shop",
|
|
29
30
|
env: "local",
|
|
30
31
|
gate: { auth: {} },
|
|
31
|
-
}).plug(magicLink({
|
|
32
|
+
}).plug(magicLink({ baseUrl: "http://127.0.0.1:6530" }));
|
|
32
33
|
```
|
|
33
34
|
|
|
34
35
|
</Step>
|
|
@@ -38,10 +39,12 @@ export const app = oke({
|
|
|
38
39
|
|
|
39
40
|
```typescript
|
|
40
41
|
const { data } = await api.auth.requestMagicLink({ email: "ali@example.com" });
|
|
41
|
-
// data.ok === true;
|
|
42
|
+
// data.ok === true; Channel delivers auth-magic-link
|
|
43
|
+
// data.devToken only when exposeDevToken
|
|
42
44
|
```
|
|
43
45
|
|
|
44
|
-
`POST /auth/magic-link/request`.
|
|
46
|
+
`POST /auth/magic-link/request`. Locally the `console` driver captures mail; with
|
|
47
|
+
`oke dev --docker`, Mailpit receives the real SMTP message.
|
|
45
48
|
|
|
46
49
|
</Step>
|
|
47
50
|
|
|
@@ -49,7 +52,7 @@ const { data } = await api.auth.requestMagicLink({ email: "ali@example.com" });
|
|
|
49
52
|
### Verify
|
|
50
53
|
|
|
51
54
|
```typescript
|
|
52
|
-
const { data } = await api.auth.verifyMagicLink({ token
|
|
55
|
+
const { data } = await api.auth.verifyMagicLink({ token });
|
|
53
56
|
// session tokens + userId
|
|
54
57
|
```
|
|
55
58
|
|
|
@@ -62,14 +65,16 @@ const { data } = await api.auth.verifyMagicLink({ token: data!.devToken! });
|
|
|
62
65
|
|
|
63
66
|
## Options
|
|
64
67
|
|
|
65
|
-
| Option | Type | Default
|
|
66
|
-
| ---------------- | ------------------- |
|
|
67
|
-
| `secret` | `string` | active\*
|
|
68
|
-
| `sessions` | `SessionStore` | active\*
|
|
69
|
-
| `ttlMs` | `number` | 10m
|
|
70
|
-
| `
|
|
71
|
-
| `
|
|
72
|
-
| `
|
|
68
|
+
| Option | Type | Default | Meaning |
|
|
69
|
+
| ---------------- | ------------------- | -------------------------- | ----------------------------------------- |
|
|
70
|
+
| `secret` | `string` | active\* | HMAC secret (\*from `gate.auth`) |
|
|
71
|
+
| `sessions` | `SessionStore` | active\* | Session store |
|
|
72
|
+
| `ttlMs` | `number` | 10m | Challenge lifetime |
|
|
73
|
+
| `baseUrl` | `string` | `OKE_APP_URL` or `:6530` | Origin used to build the magic link |
|
|
74
|
+
| `from` | `string` | `OKE <no-reply@oke.local>` | Template From address |
|
|
75
|
+
| `exposeDevToken` | `boolean` | `false` | Include raw token in the request response |
|
|
76
|
+
| `identities` | `IdentityStore` | new | Email → user map |
|
|
77
|
+
| `verifications` | `VerificationStore` | new | Challenge store |
|
|
73
78
|
|
|
74
79
|
## Surfaces
|
|
75
80
|
|
|
@@ -78,8 +83,8 @@ const { data } = await api.auth.verifyMagicLink({ token: data!.devToken! });
|
|
|
78
83
|
| `auth.requestMagicLink` | `POST /auth/magic-link/request` | `gate.public` + otp rate |
|
|
79
84
|
| `auth.verifyMagicLink` | `POST /auth/magic-link/verify` | `gate.public` + otp rate |
|
|
80
85
|
|
|
81
|
-
**Consequence:**
|
|
82
|
-
|
|
86
|
+
**Consequence:** the plugin contributes the `auth-magic-link` Channel template and EN/AR
|
|
87
|
+
catalog bodies (`{{link}}`, `{{token}}`). Override copy by merging your own catalog at boot.
|
|
83
88
|
|
|
84
89
|
## Troubleshooting
|
|
85
90
|
|
|
@@ -91,8 +96,9 @@ Token expired (default 10m), already used, or mistyped. Request a new link.
|
|
|
91
96
|
</Accordion>
|
|
92
97
|
<Accordion title="No email arrived">
|
|
93
98
|
|
|
94
|
-
|
|
95
|
-
|
|
99
|
+
In `local` / `test` the `console` driver captures mail into the inbox — nothing hits a mailbox.
|
|
100
|
+
Run `oke dev --docker` and open Mailpit (`MAILPIT_UI_URL`) to see the rendered message. For
|
|
101
|
+
unit tests without SMTP, set `exposeDevToken: true`.
|
|
96
102
|
|
|
97
103
|
</Accordion>
|
|
98
104
|
</Accordions>
|
|
@@ -101,12 +107,12 @@ link via a Channel Flow you own.
|
|
|
101
107
|
|
|
102
108
|
- [Email OTP](/docs/plugins/email-otp) — numeric code instead of a link
|
|
103
109
|
- [Gate](/docs/elements/gate) — `gate.auth`
|
|
104
|
-
- [Channel](/docs/elements/channel) —
|
|
110
|
+
- [Channel](/docs/elements/channel) — `fx.send`, Mailpit, consents
|
|
105
111
|
|
|
106
112
|
## Next
|
|
107
113
|
|
|
108
114
|
<Cards>
|
|
109
115
|
<Card title="Email OTP" description="Six-digit email codes." href="/docs/plugins/email-otp" />
|
|
110
116
|
<Card title="Gate" description="Builtin auth and policies." href="/docs/elements/gate" />
|
|
111
|
-
<Card title="
|
|
117
|
+
<Card title="Channel" description="Email delivery and Mailpit." href="/docs/elements/channel" />
|
|
112
118
|
</Cards>
|
|
@@ -76,9 +76,12 @@ images: {
|
|
|
76
76
|
"store.files": "rustfs/rustfs:1.0.0-beta.11",
|
|
77
77
|
"channel.email": "axllent/mailpit:v1.22.3",
|
|
78
78
|
vault: "openbao/openbao:2.6.1",
|
|
79
|
+
ai: "ollama/ollama:latest",
|
|
79
80
|
},
|
|
80
81
|
```
|
|
81
82
|
|
|
83
|
+
For `ai`, the Ollama recipe pulls `OKE_AI_MODEL` (default `qwen3.5:9b` — a balanced local-dev starting point; override freely; on Apple Silicon prefer `qwen3.5:9b-mlx`) and writes `OKE_AI_URL`.
|
|
84
|
+
|
|
82
85
|
## i18n
|
|
83
86
|
|
|
84
87
|
| Option | Type | Default | Meaning |
|
|
@@ -91,6 +94,10 @@ images: {
|
|
|
91
94
|
i18n: { locales: ["en", "ar"], default: "en", dir: { ar: "rtl" } },
|
|
92
95
|
```
|
|
93
96
|
|
|
97
|
+
`fx.t` catalogs are ICU strings from `defineLocale` / `defineMessages` — see
|
|
98
|
+
[i18n](/docs/reference/i18n). Built-ins also cover failure `error.message` and
|
|
99
|
+
OKE cause/fix; Channel templates keep separate `locales` + `{{field}}` bodies.
|
|
100
|
+
|
|
94
101
|
## tenancy
|
|
95
102
|
|
|
96
103
|
Multi-tenant isolation policy.
|
|
@@ -72,11 +72,16 @@ Written by the first-boot bootstrap; you normally only set these when pointing a
|
|
|
72
72
|
|
|
73
73
|
## AI providers
|
|
74
74
|
|
|
75
|
-
| Variable | Used for
|
|
76
|
-
| ------------------- |
|
|
77
|
-
| `ANTHROPIC_API_KEY` | `anthropic` driver credential
|
|
78
|
-
| `ANTHROPIC_MODEL` | Model override for the anthropic driver |
|
|
79
|
-
| `OPENAI_API_KEY` | `openai-compatible` driver credential
|
|
75
|
+
| Variable | Used for | Default when unset |
|
|
76
|
+
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- |
|
|
77
|
+
| `ANTHROPIC_API_KEY` | `anthropic` driver credential | — |
|
|
78
|
+
| `ANTHROPIC_MODEL` | Model override for the anthropic driver | — |
|
|
79
|
+
| `OPENAI_API_KEY` | `openai-compatible` driver credential | — |
|
|
80
|
+
| `OPENAI_BASE_URL` | `openai-compatible` base URL | OpenAI cloud |
|
|
81
|
+
| `OKE_AI_DRIVER` | Force the AI driver id (honoured in docker mode) | config map |
|
|
82
|
+
| `OKE_AI_URL` | Ollama (or openai-compatible) base URL | `http://127.0.0.1:11434` (ollama) |
|
|
83
|
+
| `OKE_AI_MODEL` | Default model for `ollama` (any pulled model; `qwen3.5:9b` is a balanced local-dev starting point; on Apple Silicon try `qwen3.5:9b-mlx`) | `qwen3.5:9b` |
|
|
84
|
+
| `OLLAMA_HOST` | Alternate Ollama host (`host:port` or URL) when `OKE_AI_URL` is unset | — |
|
|
80
85
|
|
|
81
86
|
## Framework behavior
|
|
82
87
|
|
|
@@ -14,6 +14,19 @@ OKE1101 domain table not found — migrations have not been applied.
|
|
|
14
14
|
|
|
15
15
|
Codes are permanent once published — you can match on them safely across upgrades.
|
|
16
16
|
|
|
17
|
+
## Localized messages
|
|
18
|
+
|
|
19
|
+
Typed failures and OKE codes ship English and Arabic ICU catalogs. Locale comes
|
|
20
|
+
from `Accept-Language` (matched to `i18n.locales`); fallback is `i18n.default`.
|
|
21
|
+
|
|
22
|
+
| Surface | Keys | How it appears |
|
|
23
|
+
| ------------------ | ------------------------------------------ | ---------------------------------------- |
|
|
24
|
+
| `fx.fail` / `fail` | `errors.{code}` · `errors.{code}.{reason}` | Optional `error.message` on the envelope |
|
|
25
|
+
| Thrown `OkeError` | `oke.{code}.cause` · `oke.{code}.fix` | Cause + fix lines in the thrown message |
|
|
26
|
+
|
|
27
|
+
Override via `defineLocale`. Pass `fail(code, data, { message })` for a custom
|
|
28
|
+
string. Custom app codes stay message-less until registered.
|
|
29
|
+
|
|
17
30
|
## OKE numeric codes
|
|
18
31
|
|
|
19
32
|
| Code | Name | Cause | Fix |
|
|
@@ -75,6 +88,7 @@ Thrown by specific subsystems — each names its own cause:
|
|
|
75
88
|
|
|
76
89
|
## Learn more
|
|
77
90
|
|
|
91
|
+
- [i18n](/docs/reference/i18n) — catalogs, `fx.t`, locale matching
|
|
78
92
|
- [Flow](/docs/elements/flow) — `fx.fail` and the response envelope
|
|
79
93
|
- [Gate](/docs/elements/gate) — where the three denials come from
|
|
80
94
|
- [CLI Reference](/docs/reference/cli) — `oke db migrate` and friends
|
|
@@ -102,20 +102,23 @@ const rows = await fx.using(
|
|
|
102
102
|
|
|
103
103
|
## Channel
|
|
104
104
|
|
|
105
|
-
| Signature
|
|
106
|
-
|
|
|
107
|
-
| `fx.send(template, { to?, data?, via
|
|
105
|
+
| Signature | Records | Notes |
|
|
106
|
+
| -------------------------------------------- | ------- | ------------------------------------------------------------------------------------------ |
|
|
107
|
+
| `fx.send(template, { to?, data?, via?, … })` | `send` | `via` orders fallback; `locale` / `profileLocale` / `acceptLanguage` feed the locale chain |
|
|
108
|
+
|
|
109
|
+
Omit locale opts and the send uses `fx.locale`. Dry runs record _would have fired_ and never
|
|
110
|
+
contact a provider. Channel bodies use `{{field}}` catalogs — not ICU (see [Channel](/docs/elements/channel)).
|
|
108
111
|
|
|
109
112
|
## AI
|
|
110
113
|
|
|
111
|
-
| Signature
|
|
112
|
-
|
|
|
113
|
-
| `fx.ask(prompt, input?, { via? })`
|
|
114
|
-
| `fx.run(agent, input?)`
|
|
115
|
-
| `fx.stream(model, { prompt?, data? })`
|
|
116
|
-
| `fx.search(embed, query, { topK? })`
|
|
114
|
+
| Signature | Records | Returns |
|
|
115
|
+
| ----------------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------- |
|
|
116
|
+
| `fx.ask(prompt, input?, { via?, tools?, maxSteps? })` | `ask` (+ `call` per tool) | Object validated against the prompt's `out` |
|
|
117
|
+
| `fx.run(agent, input?)` | `ask` | Agent result |
|
|
118
|
+
| `fx.stream(model, { prompt?, data? })` | `ask` | `AsyncIterable<string>` — real driver stream; cancels via ambient `fx.signal` |
|
|
119
|
+
| `fx.search(embed, query, { topK? })` | `read` | Matches from the index/embed |
|
|
117
120
|
|
|
118
|
-
AI calls are nondeterministic: journaling is forced on and auto-cache disabled around them.
|
|
121
|
+
AI calls are nondeterministic: journaling is forced on and auto-cache disabled around them. `tools` are Flow refs — each model tool call goes through `fx.call` (same capability and Runs path).
|
|
119
122
|
|
|
120
123
|
## Vault
|
|
121
124
|
|
|
@@ -153,11 +156,59 @@ Returning a plain value instead answers 200 with `{ data: value, error: null }`
|
|
|
153
156
|
|
|
154
157
|
## Logging, i18n, ids
|
|
155
158
|
|
|
156
|
-
| Signature | Notes
|
|
157
|
-
| ------------------------------------------ |
|
|
158
|
-
| `fx.log.debug/info/warn/error(msg, data?)` | Redacting — secrets print as `***`
|
|
159
|
-
| `fx.t(key,
|
|
160
|
-
| `fx.
|
|
159
|
+
| Signature | Notes |
|
|
160
|
+
| ------------------------------------------ | ----------------------------------------------------------- |
|
|
161
|
+
| `fx.log.debug/info/warn/error(msg, data?)` | Redacting — secrets print as `***` |
|
|
162
|
+
| `fx.t(key, values?)` | ICU MessageFormat — active locale → `i18n.default` → key |
|
|
163
|
+
| `fx.locale` | Active locale (`Accept-Language` matched to `i18n.locales`) |
|
|
164
|
+
| `fx.id()` | UUID — the only legal id generator |
|
|
165
|
+
|
|
166
|
+
`fx.t` uses [ICU MessageFormat](https://unicode-org.github.io/icu/userguide/format_parse/messages/)
|
|
167
|
+
(FormatJS): `{name}`, plurals, `select` / `selectordinal`, rich-text tags.
|
|
168
|
+
Full syntax and escaping: [i18n · ICU](/docs/reference/i18n#icu-messageformat).
|
|
169
|
+
|
|
170
|
+
Register catalogs with `defineLocale` before boot (nested trees → dot keys). Augment
|
|
171
|
+
`Register` so keys autocomplete and typos fail at compile time:
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
import { defineLocale, defineMessages, type MessagesFor } from "okengine";
|
|
175
|
+
|
|
176
|
+
export const en = defineMessages({
|
|
177
|
+
greeting: "Hello, {name}",
|
|
178
|
+
items: "{count, plural, one {# item} other {# items}}",
|
|
179
|
+
place: "You finished {place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}!",
|
|
180
|
+
status: "{status, select, online {Online} offline {Offline} other {Unknown}}",
|
|
181
|
+
cta: "Read <docs>the docs</docs>",
|
|
182
|
+
});
|
|
183
|
+
defineLocale("en", en);
|
|
184
|
+
|
|
185
|
+
defineLocale("ar", {
|
|
186
|
+
greeting: "مرحباً، {name}",
|
|
187
|
+
items: "{count, plural, zero {لا عناصر} one {عنصر واحد} other {# عناصر}}",
|
|
188
|
+
place: "أنهيت في المرتبة {place, selectordinal, other {#}}!",
|
|
189
|
+
status: "{status, select, online {متصل} offline {غير متصل} other {غير معروف}}",
|
|
190
|
+
cta: "اقرأ <docs>التوثيق</docs>",
|
|
191
|
+
} satisfies MessagesFor<typeof en>);
|
|
192
|
+
|
|
193
|
+
declare module "okengine" {
|
|
194
|
+
interface Register {
|
|
195
|
+
messages: typeof en;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
fx.t("greeting", { name: input.name });
|
|
202
|
+
fx.t("items", { count: 3 });
|
|
203
|
+
fx.t("cta", { docs: (chunks) => `<a href="/docs">${chunks.join("")}</a>` });
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Supported locales and the fallback come from `oke.config.ts` `i18n`.
|
|
207
|
+
`create-oke` scaffolds English and Arabic locale modules already wired this way.
|
|
208
|
+
|
|
209
|
+
Built-in `errors.*` / `oke.*` catalogs localize `fx.fail` messages and thrown
|
|
210
|
+
`OkeError` cause/fix the same way — full i18n guide:
|
|
211
|
+
[i18n](/docs/reference/i18n), [Errors](/docs/reference/errors).
|
|
161
212
|
|
|
162
213
|
## Principals
|
|
163
214
|
|
|
@@ -181,5 +232,6 @@ must log who started the call chain.
|
|
|
181
232
|
## Learn more
|
|
182
233
|
|
|
183
234
|
- [Flow](/docs/elements/flow) — why `fx` is the only door
|
|
235
|
+
- [Channel](/docs/elements/channel) — `fx.send`, consent, locale chain, `{{field}}` catalogs
|
|
184
236
|
- [Errors](/docs/reference/errors) — what `fx.fail` produces
|
|
185
|
-
- [Configuration](/docs/reference/configuration) —
|
|
237
|
+
- [Configuration](/docs/reference/configuration) — drivers and the `i18n` block
|