okengine 0.5.0 → 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.
Files changed (94) hide show
  1. package/package.json +2 -1
  2. package/site/content/docs/elements/ai.mdx +82 -1
  3. package/site/content/docs/elements/channel.mdx +6 -1
  4. package/site/content/docs/elements/flow.mdx +20 -17
  5. package/site/content/docs/get-started/why.mdx +10 -10
  6. package/site/content/docs/plugins/email-otp.mdx +25 -19
  7. package/site/content/docs/plugins/headers.mdx +10 -10
  8. package/site/content/docs/plugins/magic-link.mdx +27 -21
  9. package/site/content/docs/plugins/passkey.mdx +36 -24
  10. package/site/content/docs/plugins/two-factor.mdx +2 -1
  11. package/site/content/docs/reference/configuration.mdx +7 -0
  12. package/site/content/docs/reference/environment-variables.mdx +10 -5
  13. package/site/content/docs/reference/errors.mdx +14 -0
  14. package/site/content/docs/reference/fx.mdx +68 -16
  15. package/site/content/docs/reference/i18n.mdx +313 -0
  16. package/site/content/docs/reference/index.mdx +6 -1
  17. package/site/content/docs/reference/meta.json +1 -0
  18. package/site/content/docs/reference/plugins.mdx +1 -0
  19. package/src/auth/auth.test.ts +3 -0
  20. package/src/auth/bindings.ts +1 -1
  21. package/src/auth/constant-time.ts +22 -0
  22. package/src/auth/index.ts +2 -0
  23. package/src/auth/method-context.ts +12 -2
  24. package/src/cli/competitor-mention-removal.test.ts +3 -3
  25. package/src/compiler/aot.test.ts +16 -13
  26. package/src/compiler/effects-infer.ts +46 -0
  27. package/src/console/server/ai.test.ts +34 -5
  28. package/src/docker/compose.ts +9 -0
  29. package/src/docker/docker.test.ts +39 -0
  30. package/src/docker/index.ts +11 -1
  31. package/src/docker/recipes/index.ts +3 -1
  32. package/src/docker/recipes/ollama.ts +43 -0
  33. package/src/docker/stack-id.ts +2 -0
  34. package/src/drivers/ai-mock.ts +60 -0
  35. package/src/drivers/ai-ollama-tools.integration.test.ts +107 -0
  36. package/src/drivers/ai-ollama.integration.test.ts +197 -0
  37. package/src/drivers/ai-ollama.ts +327 -0
  38. package/src/drivers/ai-openai-compatible.ts +211 -21
  39. package/src/drivers/ai-providers.test.ts +179 -2
  40. package/src/drivers/ai-stream.test.ts +195 -0
  41. package/src/drivers/ai-types.ts +42 -1
  42. package/src/drivers/channel-smtp.ts +8 -2
  43. package/src/drivers/index.ts +21 -1
  44. package/src/drivers/ollama.ts +14 -0
  45. package/src/elements/ai/rate.test.ts +53 -0
  46. package/src/elements/ai/rate.ts +66 -0
  47. package/src/elements/ai/redacted-prompt.test.ts +90 -0
  48. package/src/elements/ai/runtime.ts +330 -100
  49. package/src/elements/ai/tools.test.ts +99 -0
  50. package/src/elements/ai.test.ts +26 -2
  51. package/src/elements/ai.ts +10 -1
  52. package/src/i18n/catalogs/ar.ts +67 -0
  53. package/src/i18n/catalogs/en.ts +68 -0
  54. package/src/i18n/failure-message.test.ts +56 -0
  55. package/src/i18n/failure-message.ts +93 -0
  56. package/src/i18n/format.ts +67 -0
  57. package/src/i18n/index.ts +57 -0
  58. package/src/i18n/locale-context.ts +48 -0
  59. package/src/i18n/messages.test.ts +173 -0
  60. package/src/i18n/messages.ts +169 -0
  61. package/src/i18n/types.ts +90 -0
  62. package/src/index.ts +26 -0
  63. package/src/kernel/app.ts +92 -2
  64. package/src/kernel/boot-bind/ai.test.ts +60 -0
  65. package/src/kernel/boot-bind/ai.ts +125 -2
  66. package/src/kernel/boot.test.ts +4 -3
  67. package/src/kernel/boot.ts +1 -1
  68. package/src/kernel/errors.ts +56 -5
  69. package/src/kernel/fx.test.ts +27 -0
  70. package/src/kernel/fx.ts +74 -18
  71. package/src/kernel/pipeline.test.ts +4 -0
  72. package/src/kernel/pipeline.ts +1 -1
  73. package/src/kernel/plugin.ts +16 -0
  74. package/src/kernel/registry.ts +15 -0
  75. package/src/plugins/auth/shared.ts +5 -1
  76. package/src/plugins/auth-delivery.mailpit.integration.test.ts +330 -0
  77. package/src/plugins/auth-methods.security.test.ts +764 -0
  78. package/src/plugins/compression.ts +1 -1
  79. package/src/plugins/config-source.test.ts +11 -11
  80. package/src/plugins/config-source.ts +2 -2
  81. package/src/plugins/cors.ts +1 -1
  82. package/src/plugins/email-otp.ts +54 -1
  83. package/src/plugins/{security-headers.test.ts → headers.test.ts} +18 -18
  84. package/src/plugins/headers.ts +240 -41
  85. package/src/plugins/index.ts +27 -5
  86. package/src/plugins/magic-link.ts +63 -3
  87. package/src/plugins/passkey-webauthn.ts +217 -0
  88. package/src/plugins/passkey.ts +99 -33
  89. package/src/plugins/response-headers.ts +54 -0
  90. package/src/plugins/two-factor.ts +6 -2
  91. package/src/plugins/username-policy.test.ts +302 -0
  92. package/src/plugins/username.ts +290 -9
  93. package/src/release/measure.ts +8 -1
  94. package/src/plugins/security-headers.ts +0 -255
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okengine",
3
- "version": "0.5.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 (`ar` → default `en`) instead of failing when a translation is missing. Locales and the default come from the `i18n` block in `oke.config.ts`.
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 `{ data: null, error: { code, data } }` — so clients handle outcomes by `error.code`, not by parsing status text.
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 | Effect recorded | What it does |
182
- | --------------------------------------- | --------------- | -------------------------------------------- |
183
- | `fx.store(db).select/insert/…` | read / write | SQL, KV, files, index sessions |
184
- | `fx.emit(signal, payload)` | emit | Publish a signal (transactional with writes) |
185
- | `fx.send(template, opts)` | send | Reach a human (email · SMS · …) |
186
- | `fx.ask(prompt, input)` | ask | Call a versioned AI prompt |
187
- | `fx.run(agent, input)` | ask | Run a bounded agent |
188
- | `fx.call(flow, input)` | call | Invoke another flow |
189
- | `fx.vault(contract)` | read | Read a secret (`Redacted`; logs masked) |
190
- | `fx.clock.now()` / `.sleep(…)` | — | Injected time / durable sleep |
191
- | `fx.cache.get/set` | — | Shared cache with effect-aware invalidation |
192
- | `fx.step(name, fn)` | — | Named durable step — never re-runs on replay |
193
- | `fx.all` / `fx.race` / `fx.retry` | — | Structured concurrency + backoff retry |
194
- | `fx.signal` | — | Ambient `AbortSignal` for the current branch |
195
- | `fx.id()` · `fx.log` · `fx.t` | — | UUIDs, redacting logger, i18n |
196
- | `fx.auth` · `fx.operator` · `fx.tenant` | — | Who is calling (user / operator / 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
@@ -93,16 +93,16 @@ ninth element — the set of eight is closed.
93
93
 
94
94
  ## Traditional vs OKE
95
95
 
96
- | Seam | Maintained by hand | Derived by OKE |
97
- | ------------------ | --------------------------------------------------------- | ------------------------------------------------------------------------------- |
98
- | Behavior model | Endpoints, jobs, consumers, workflows as separate species | One species — Flow: `on(Trigger) → Effects` |
99
- | Cache invalidation | Hand-written keys; drift from writers | Derived from effects recorded through `fx` |
100
- | HTTP glue | Middleware copied per repo | Official plugins — `securityHeaders`, `cors`, `csrf`, compression, IP allowlist |
101
- | Secrets / config | Env sprawl; fails on first request | Vault contracts; `VaultBootError` at boot |
102
- | Observability | Bolted on; separate source of truth | Console from the Manifest, dev and prod (`:6533`) |
103
- | Permissions | Ad-hoc checks scattered in handlers | Least-privilege matrix from declared effects |
104
- | Local vs prod | Vendor clients; one-off compose | Protocol drivers; vendor in `images`; `oke dev --docker` |
105
- | Client / agents | Separate codegen or hand-kept schemas | Typed client and MCP (`:6535`) from the same Manifest |
96
+ | Seam | Maintained by hand | Derived by OKE |
97
+ | ------------------ | --------------------------------------------------------- | ----------------------------------------------------------------------- |
98
+ | Behavior model | Endpoints, jobs, consumers, workflows as separate species | One species — Flow: `on(Trigger) → Effects` |
99
+ | Cache invalidation | Hand-written keys; drift from writers | Derived from effects recorded through `fx` |
100
+ | HTTP glue | Middleware copied per repo | Official plugins — `headers`, `cors`, `csrf`, compression, IP allowlist |
101
+ | Secrets / config | Env sprawl; fails on first request | Vault contracts; `VaultBootError` at boot |
102
+ | Observability | Bolted on; separate source of truth | Console from the Manifest, dev and prod (`:6533`) |
103
+ | Permissions | Ad-hoc checks scattered in handlers | Least-privilege matrix from declared effects |
104
+ | Local vs prod | Vendor clients; one-off compose | Protocol drivers; vendor in `images`; `oke dev --docker` |
105
+ | Client / agents | Separate codegen or hand-kept schemas | Typed client and MCP (`:6535`) from the same Manifest |
106
106
 
107
107
  ## Ambition, stated plainly
108
108
 
@@ -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). Verify creates the
9
- user on first success and returns hybrid session tokens.
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. Channel email delivery is not
13
- wired yet — use `exposeDevOtp` locally.
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({ exposeDevOtp: true }));
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: data!.devOtp!,
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 | Meaning |
68
- | --------------- | ------------------- | -------- | --------------------------------------- |
69
- | `secret` | `string` | active\* | HMAC secret (\*from `gate.auth`) |
70
- | `sessions` | `SessionStore` | active\* | Session store |
71
- | `ttlMs` | `number` | 10m | Challenge lifetime |
72
- | `exposeDevOtp` | `boolean` | `false` | Include raw OTP in the request response |
73
- | `identities` | `IdentityStore` | new | Email user map |
74
- | `verifications` | `VerificationStore` | new | Challenge store |
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
- v1 does not send mail. Use `exposeDevOtp` in local/test, or send the code via your own Channel
89
- Flow.
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="Two-factor" description="TOTP step-up." href="/docs/plugins/two-factor" />
116
+ <Card title="Channel" description="Email delivery and Mailpit." href="/docs/elements/channel" />
111
117
  </Cards>
@@ -2,18 +2,18 @@
2
2
  title: "Headers"
3
3
  description: "Official plugin — the complete secure-headers set on every HTTP response, failures included. Full helmet.js parity with API-first defaults, a CSP builder with report-only mode, and live DB-driven config."
4
4
  icon: "ShieldCheck"
5
- source: "src/plugins/security-headers.ts"
5
+ source: "src/plugins/headers.ts"
6
6
  ---
7
7
 
8
- `securityHeaders()` stamps security headers on **every** HTTP flow response — successes, failures, and short-circuits alike, because it runs at `onResponse`, the last pipeline stage. An explicit value your app already set is never overridden unless you ask for it.
8
+ `headers()` stamps security headers on **every** HTTP flow response — successes, failures, and short-circuits alike, because it runs at `onResponse`, the last pipeline stage. An explicit value your app already set is never overridden unless you ask for it.
9
9
 
10
10
  ## Quick start
11
11
 
12
12
  ```typescript title="src/app.ts"
13
13
  import { oke } from "okengine";
14
- import { securityHeaders } from "okengine/plugins";
14
+ import { headers } from "okengine/plugins";
15
15
 
16
- export const app = oke({ name: "shop", env: "dev" }).plug(securityHeaders());
16
+ export const app = oke({ name: "shop", env: "dev" }).plug(headers());
17
17
  ```
18
18
 
19
19
  Every HTTP response now carries:
@@ -75,7 +75,7 @@ Beyond parity: headers land on **failures too** (middleware that only wraps happ
75
75
 
76
76
  ```typescript
77
77
  .plug(
78
- securityHeaders({
78
+ headers({
79
79
  contentSecurityPolicy: {
80
80
  directives: { scriptSrc: ["'self'", "https://cdn.example.com"] }, // merged over the defaults
81
81
  reportOnly: true, // Content-Security-Policy-Report-Only while you tune
@@ -97,18 +97,18 @@ Beyond parity: headers land on **failures too** (middleware that only wraps happ
97
97
  Header policy is exactly the config you want to flip without a redeploy — enable HSTS the day HTTPS lands, tighten the CSP after an audit. Pass a `configSource()` and options follow the database within one sync interval:
98
98
 
99
99
  ```typescript
100
- const headers = configSource({
101
- plugin: "security-headers",
100
+ const headerConfig = configSource({
101
+ plugin: "headers",
102
102
  code: { hsts: false }, // safe floor for local dev
103
103
  db: { store: db },
104
104
  kv: cache,
105
105
  });
106
- on(every("30s"), headers.sync());
107
- export const app = oke({ name: "shop", env: "dev" }).plug(securityHeaders(headers));
106
+ on(every("30s"), headerConfig.sync());
107
+ export const app = oke({ name: "shop", env: "dev" }).plug(headers(headerConfig));
108
108
  ```
109
109
 
110
110
  ```sql
111
- INSERT INTO security_headers_config ("key", "value")
111
+ INSERT INTO headers_config ("key", "value")
112
112
  VALUES ('config', '{"hsts": true}');
113
113
  ```
114
114
 
@@ -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). Verify exchanges it for a
9
- hybrid session and creates the user on first success.
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
- Channel email delivery is not wired yet; use `exposeDevToken` locally.
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({ exposeDevToken: true }));
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; data.devToken only when exposeDevToken
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: data!.devToken! });
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 | Meaning |
66
- | ---------------- | ------------------- | -------- | ----------------------------------------- |
67
- | `secret` | `string` | active\* | HMAC secret (\*from `gate.auth`) |
68
- | `sessions` | `SessionStore` | active\* | Session store |
69
- | `ttlMs` | `number` | 10m | Challenge lifetime |
70
- | `exposeDevToken` | `boolean` | `false` | Include raw token in the request response |
71
- | `identities` | `IdentityStore` | new | Email user map |
72
- | `verifications` | `VerificationStore` | new | Challenge store |
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:** until Channel delivery ships, production apps must send the link themselves
82
- (or keep `exposeDevToken` off and never log tokens).
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
- v1 does not send mail. Use `exposeDevToken` in local/test, or deliver `devToken` / your own
95
- link via a Channel Flow you own.
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) — when you wire delivery yourself
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="Username" description="Password sign-in." href="/docs/plugins/username" />
117
+ <Card title="Channel" description="Email delivery and Mailpit." href="/docs/elements/channel" />
112
118
  </Cards>
@@ -1,18 +1,18 @@
1
1
  ---
2
2
  title: "Passkey"
3
- description: "Official plugin — simplified WebAuthn register and authenticate under /auth."
3
+ description: "Official plugin — WebAuthn register and authenticate under /auth with signature and origin checks."
4
4
  icon: "FingerprintPattern"
5
5
  source: "docs/spec/unified-theory.md"
6
6
  ---
7
7
 
8
- `passkey()` adds register and authenticate Flows for passkey-shaped credentials (`oke_passkeys`).
9
- v1 is a simplified ceremony options return a challenge; register/authenticate accept the
10
- payload your client posts (not a full browser WebAuthn SDK).
8
+ `passkey()` adds register and authenticate Flows for WebAuthn credentials (`oke_passkeys`).
9
+ Options return a challenge; register and authenticate verify `clientDataJSON` origin + challenge,
10
+ `authenticatorData` rpId hash, and an ECDSA P-256 signature against the stored SPKI public key.
11
11
 
12
12
  <Callout title="The one rule">
13
13
  Enable `gate.auth`, then `.plug(passkey())`. Registration needs a Bearer session; authenticate is
14
- public. Wire `navigator.credentials` (or tests) yourselfthe plugin stores and verifies the
15
- posted fields.
14
+ public. Post the full ceremony fieldspresence of a stored credential alone never issues a
15
+ session.
16
16
  </Callout>
17
17
 
18
18
  ## Quick start
@@ -30,7 +30,7 @@ export const app = oke({
30
30
  name: "shop",
31
31
  env: "local",
32
32
  gate: { auth: {} },
33
- }).plug(passkey());
33
+ }).plug(passkey({ origins: ["http://localhost", "https://localhost"] }));
34
34
  ```
35
35
 
36
36
  </Step>
@@ -45,10 +45,13 @@ const opts = await api.auth.passkeyRegisterOptions({});
45
45
  // opts.data: { challenge, rpId, userId }
46
46
 
47
47
  await api.auth.passkeyRegister({
48
- credentialId: "...",
49
- publicKey: "...",
48
+ credentialId: "...", // base64url
49
+ publicKey: "...", // base64url SPKI (ECDSA P-256)
50
50
  userId: opts.data!.userId,
51
51
  challenge: opts.data!.challenge,
52
+ clientDataJSON: "...", // base64url JSON { type, challenge, origin }
53
+ authenticatorData: "...", // base64url
54
+ signature: "...", // base64url ECDSA over authData || SHA-256(clientDataJSON)
52
55
  });
53
56
  ```
54
57
 
@@ -60,15 +63,18 @@ Paths: `POST /auth/passkey/register/options`, `POST /auth/passkey/register`.
60
63
  ### Authenticate
61
64
 
62
65
  ```typescript
63
- await api.auth.passkeyAuthenticateOptions({});
66
+ const opts = await api.auth.passkeyAuthenticateOptions({});
64
67
  const { data } = await api.auth.passkeyAuthenticate({
65
68
  credentialId: "...",
66
- userId: "...",
69
+ challenge: opts.data!.challenge,
70
+ clientDataJSON: "...",
71
+ authenticatorData: "...",
72
+ signature: "...",
67
73
  });
68
74
  ```
69
75
 
70
76
  Paths: `POST /auth/passkey/authenticate/options`, `POST /auth/passkey/authenticate`.
71
- v1 issues a session when the stored credential matches `userId`.
77
+ Challenges are single-use; wrong origin `invalid_origin`; bad signature `invalid_credentials`.
72
78
 
73
79
  </Step>
74
80
 
@@ -76,14 +82,15 @@ v1 issues a session when the stored credential matches `userId`.
76
82
 
77
83
  ## Options
78
84
 
79
- | Option | Type | Default | Meaning |
80
- | ------------ | ------------------- | ------------- | -------------------------------- |
81
- | `secret` | `string` | active\* | HMAC secret (\*from `gate.auth`) |
82
- | `sessions` | `SessionStore` | active\* | Session store |
83
- | `now` | `() => number` | `Date.now` | Injectable clock |
84
- | `passkeys` | `PasskeyStore` | new | Credential → user mapping |
85
- | `challenges` | `VerificationStore` | new | Registration / auth challenges |
86
- | `rpId` | `string` | `"localhost"` | Relying party id in options |
85
+ | Option | Type | Default | Meaning |
86
+ | ------------ | ------------------- | ------------------------------------------ | -------------------------------- |
87
+ | `secret` | `string` | active\* | HMAC secret (\*from `gate.auth`) |
88
+ | `sessions` | `SessionStore` | active\* | Session store |
89
+ | `now` | `() => number` | `Date.now` | Injectable clock |
90
+ | `passkeys` | `PasskeyStore` | new | Credential → user mapping |
91
+ | `challenges` | `VerificationStore` | new | Registration / auth challenges |
92
+ | `rpId` | `string` | `"localhost"` | Relying party id |
93
+ | `origins` | `string[]` | `["http://localhost","https://localhost"]` | Allowed `clientDataJSON.origin` |
87
94
 
88
95
  ## Surfaces
89
96
 
@@ -94,8 +101,8 @@ v1 issues a session when the stored credential matches `userId`.
94
101
  | `auth.passkeyAuthenticateOptions` | `POST /auth/passkey/authenticate/options` | `gate.public` + otp rate |
95
102
  | `auth.passkeyAuthenticate` | `POST /auth/passkey/authenticate` | `gate.public` + otp rate |
96
103
 
97
- **Consequence:** production apps should replace the simplified verify with a standards-compliant
98
- WebAuthn verifier when you leave local/test.
104
+ **Consequence:** a stolen `credentialId` without the private key cannot mint a session. Set
105
+ `origins` to your real app origins before production.
99
106
 
100
107
  ## Troubleshooting
101
108
 
@@ -104,11 +111,16 @@ WebAuthn verifier when you leave local/test.
104
111
 
105
112
  Sign in with another method first. `userId` in the body must match the Bearer session.
106
113
 
114
+ </Accordion>
115
+ <Accordion title="authenticate returns invalid_origin">
116
+
117
+ `clientDataJSON.origin` must be in `passkey({ origins })`. Default allows only localhost HTTP/S.
118
+
107
119
  </Accordion>
108
120
  <Accordion title="authenticate returns invalid_credentials">
109
121
 
110
- Unknown `credentialId`, or `userId` does not match the stored credential. Re-run registration
111
- after a successful session.
122
+ Unknown `credentialId`, consumed/expired challenge, bad signature, or rpId hash mismatch.
123
+ Re-run authenticate options for a fresh challenge, then sign with the enrolled private key.
112
124
 
113
125
  </Accordion>
114
126
  </Accordions>