okengine 0.8.0 → 0.9.1

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 (89) hide show
  1. package/package.json +3 -2
  2. package/site/content/docs/deployment/docker-swarm.mdx +228 -0
  3. package/site/content/docs/deployment/docker.mdx +212 -0
  4. package/site/content/docs/deployment/index.mdx +83 -0
  5. package/site/content/docs/deployment/kubernetes.mdx +176 -0
  6. package/site/content/docs/deployment/meta.json +5 -0
  7. package/site/content/docs/deployment/reverse-proxy.mdx +216 -0
  8. package/site/content/docs/elements/channel.mdx +10 -5
  9. package/site/content/docs/elements/signal.mdx +10 -8
  10. package/site/content/docs/elements/store.mdx +34 -0
  11. package/site/content/docs/get-started/index.mdx +5 -0
  12. package/site/content/docs/index.mdx +5 -0
  13. package/site/content/docs/meta.json +10 -1
  14. package/site/content/docs/plugins/index.mdx +1 -2
  15. package/site/content/docs/plugins/magic-link.mdx +2 -2
  16. package/site/content/docs/plugins/meta.json +1 -2
  17. package/site/content/docs/plugins/otp.mdx +214 -0
  18. package/site/content/docs/plugins/two-factor.mdx +2 -2
  19. package/site/content/docs/reference/cli.md +3 -2
  20. package/site/content/docs/reference/configuration.mdx +16 -0
  21. package/site/content/docs/reference/environment-variables.mdx +9 -8
  22. package/site/content/docs/reference/plugins.mdx +1 -1
  23. package/src/auth/auth.test.ts +36 -0
  24. package/src/auth/bindings.ts +3 -12
  25. package/src/auth/identity.ts +33 -0
  26. package/src/auth/index.ts +5 -0
  27. package/src/auth/otp-capability.ts +119 -0
  28. package/src/auth/otp-seal.test.ts +61 -0
  29. package/src/auth/otp-seal.ts +84 -0
  30. package/src/auth/schema.ts +3 -0
  31. package/src/auth/sessions.ts +26 -27
  32. package/src/auth/tables.ts +4 -0
  33. package/src/auth/verification.ts +61 -1
  34. package/src/cli/dev-app-runner.ts +4 -0
  35. package/src/cli/docker.ts +4 -1
  36. package/src/cli/load-config.images.test.ts +4 -0
  37. package/src/cli/load-config.ts +3 -0
  38. package/src/cli/registry.ts +1 -1
  39. package/src/console/server/operator-db.ts +34 -9
  40. package/src/docker/compose.ts +162 -6
  41. package/src/docker/derive.ts +60 -3
  42. package/src/docker/docker.test.ts +374 -1
  43. package/src/docker/helpers.ts +2 -0
  44. package/src/docker/index.ts +11 -0
  45. package/src/docker/recipes/caddy.ts +51 -0
  46. package/src/docker/recipes/dragonfly.ts +31 -0
  47. package/src/docker/recipes/index.ts +25 -2
  48. package/src/docker/recipes/pgdog.ts +84 -0
  49. package/src/docker/recipes/redis.ts +6 -3
  50. package/src/docker/recipes/traefik.ts +83 -0
  51. package/src/docker/recipes/valkey.ts +30 -0
  52. package/src/docker/stack-id.ts +5 -0
  53. package/src/docker/types.ts +18 -0
  54. package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
  55. package/src/drivers/channel-types.ts +1 -0
  56. package/src/elements/channel/otp-delivery.test.ts +76 -0
  57. package/src/elements/channel/otp-delivery.ts +291 -0
  58. package/src/elements/channel/runtime.ts +152 -114
  59. package/src/elements/channel.ts +12 -2
  60. package/src/index.ts +3 -0
  61. package/src/kernel/app.ts +60 -4
  62. package/src/kernel/boot-bind/channel.ts +51 -0
  63. package/src/kernel/boot-bind/gate.ts +14 -19
  64. package/src/kernel/boot-bind/honor-config.test.ts +18 -0
  65. package/src/kernel/boot-bind/signal.ts +20 -0
  66. package/src/kernel/boot-bind/store.test.ts +82 -0
  67. package/src/kernel/boot-bind/store.ts +22 -0
  68. package/src/kernel/boot.test.ts +5 -5
  69. package/src/kernel/fx.test.ts +3 -0
  70. package/src/kernel/fx.ts +49 -0
  71. package/src/kernel/graceful-shutdown.test.ts +76 -0
  72. package/src/kernel/graceful-shutdown.ts +106 -0
  73. package/src/kernel/horizontal-child.ts +257 -0
  74. package/src/kernel/horizontal.integration.test.ts +229 -0
  75. package/src/kernel/index.ts +8 -0
  76. package/src/kernel/ready.test.ts +76 -0
  77. package/src/plugins/auth-delivery.mailpit.integration.test.ts +5 -5
  78. package/src/plugins/auth-methods.security.test.ts +19 -29
  79. package/src/plugins/auth-methods.test.ts +7 -6
  80. package/src/plugins/index.ts +12 -8
  81. package/src/plugins/magic-link.ts +1 -23
  82. package/src/plugins/otp.test.ts +238 -0
  83. package/src/plugins/otp.ts +572 -0
  84. package/src/plugins/taqnyat.live.test.ts +5 -7
  85. package/src/release/official-plugins.ts +1 -2
  86. package/site/content/docs/plugins/email-otp.mdx +0 -117
  87. package/site/content/docs/plugins/phone-number.mdx +0 -172
  88. package/src/plugins/email-otp.ts +0 -214
  89. package/src/plugins/phone-number.ts +0 -206
@@ -0,0 +1,214 @@
1
+ ---
2
+ title: "OTP"
3
+ description: "Official plugin — one-time codes over SMS, WhatsApp, or email under /auth, with explicit provider or app mode."
4
+ icon: "KeyRound"
5
+ source: "docs/spec/unified-theory.md"
6
+ ---
7
+
8
+ `otp()` signs people in with a one-time code. You must set `mode` — there is no
9
+ auto-detect. Provider mode is SMS Verify via the bound driver; app mode is
10
+ app-owned delivery across the channels you declare.
11
+
12
+ <Callout title="The one rule">
13
+ Enable `gate.auth`, then `.plug(otp({ mode: "provider" }))` or
14
+ `.plug(otp({ mode: "app", channels: [...] }))`. Never omit `mode`. Never log
15
+ raw OTPs.
16
+ </Callout>
17
+
18
+ <Callout type="info" title="One otp() per app">
19
+ Provider and app mode cannot both be active — they claim the same fixed `/auth/otp/*` routes. Need
20
+ two OTP-like mechanisms? Combine `otp()` with a different plugin (e.g. `magicLink()`), not a
21
+ second `otp()`.
22
+ </Callout>
23
+
24
+ <Callout type="info" title="fx.sendOtp vs otp()">
25
+ `fx.sendOtp` / `fx.verifyOtp` are raw Channel capabilities — no `.plug()`. Direct use means you
26
+ build routes, sessions, rates, and storage yourself. `otp()` provider mode wraps that path; skip
27
+ it and call the raw methods in your own flow without losing the provider connection.
28
+ </Callout>
29
+
30
+ ## Quick start
31
+
32
+ <Steps>
33
+
34
+ <Step>
35
+ ### Plug app mode (multi-channel)
36
+
37
+ ```typescript title="src/app.ts"
38
+ import { oke } from "okengine";
39
+ import { otp } from "okengine/plugins";
40
+
41
+ export const app = oke({
42
+ name: "shop",
43
+ env: "local",
44
+ gate: { auth: {} },
45
+ }).plug(
46
+ otp({
47
+ mode: "app",
48
+ channels: ["sms", "whatsapp", "email"],
49
+ exposeDevOtp: true, // local DX only
50
+ }),
51
+ );
52
+ ```
53
+
54
+ </Step>
55
+
56
+ <Step>
57
+ ### Request a code
58
+
59
+ ```typescript
60
+ const { data } = await api.auth.requestOtp({
61
+ phone: "+15551234567",
62
+ email: "ali@example.com",
63
+ });
64
+ ```
65
+
66
+ `POST /auth/otp/request`. Prior active challenges for that principal are
67
+ invalidated. Delivery follows `channels` order for addresses you pass.
68
+
69
+ </Step>
70
+
71
+ <Step>
72
+ ### Resend on another channel (app mode only)
73
+
74
+ ```typescript
75
+ const { data } = await api.auth.resendOtp({
76
+ phone: "+15551234567",
77
+ email: "ali@example.com",
78
+ channel: "email",
79
+ });
80
+ ```
81
+
82
+ Same code, same TTL. Default cooldown is 60 seconds. Provider mode has no
83
+ resend surface — the provider owns the code.
84
+
85
+ </Step>
86
+
87
+ <Step>
88
+ ### Verify
89
+
90
+ ```typescript
91
+ const { data } = await api.auth.verifyOtp({
92
+ phone: "+15551234567",
93
+ otp,
94
+ });
95
+ ```
96
+
97
+ `POST /auth/otp/verify` — five failed attempts consume the challenge.
98
+
99
+ </Step>
100
+
101
+ </Steps>
102
+
103
+ ## Modes
104
+
105
+ | | Provider mode | App mode |
106
+ | -------------------- | ----------------------------- | --------------------------------------------- |
107
+ | Config | `otp({ mode: "provider" })` | `otp({ mode: "app", channels: [...] })` |
108
+ | Who owns the code | Provider (Verify API) | Your app |
109
+ | Delivery | `fx.sendOtp` / `fx.verifyOtp` | `fx.deliverOtp` (Channel templates) |
110
+ | Channels | SMS only | `sms` · `whatsapp` · `email` (declared order) |
111
+ | Resend other channel | Impossible | `POST /auth/otp/resend` |
112
+ | `exposeDevOtp` | Forbidden | Optional (default off) |
113
+
114
+ <Callout type="warn" title="Provider mode limitation">
115
+ Resend-via-different-channel is impossible in provider mode — the code value is never visible to
116
+ OKE. Use app mode when you need SMS → email fallback for the same code.
117
+ </Callout>
118
+
119
+ ### Provider mode setup
120
+
121
+ ```typescript title="oke.config.ts"
122
+ export default {
123
+ drivers: {
124
+ channel: {
125
+ sms: { local: "console", docker: "taqnyat", test: "console", prod: "taqnyat" },
126
+ },
127
+ },
128
+ };
129
+ ```
130
+
131
+ Boot fails loudly if no SMS driver exposes `sendOtp` / `verifyOtp`. Switch to
132
+ app mode, or bind a Verify-capable driver (for example `taqnyat`).
133
+
134
+ ### App mode delivery
135
+
136
+ | Concern | Behavior |
137
+ | --------------- | --------------------------------------------------------------------------------------------------------------------------- |
138
+ | Storage | SHA-256 hash for verify + sealed AES-GCM copy (HKDF `oke-otp-seal-v1`) for redelivery |
139
+ | Seal lifetime | Wiped on verify, lockout, or TTL expiry — never left after the challenge dies |
140
+ | Challenge TTL | Default 10 minutes (`ttlMs`) |
141
+ | Resend cooldown | Default 60 seconds (`resendCooldownMs`) — separate from TTL |
142
+ | Auto failover | On real provider send errors, sently `FallbackTransport` walks remaining media; Taqnyat WhatsApp may use `sendWithFailover` |
143
+ | User resend | Explicit `resend` with `channel` — not automatic |
144
+
145
+ Templates: `auth-otp-email`, `auth-otp-sms`, `auth-otp-whatsapp` (EN/AR,
146
+ `{{otp}}`). SMS here is a plain message — not Taqnyat Verify.
147
+
148
+ ## Options
149
+
150
+ | Option | Type | Default | Meaning |
151
+ | ------------------ | -------------------------------- | -------------------------- | ----------------------------------- |
152
+ | `mode` | `"provider" \| "app"` | required | Delivery mechanism — no auto |
153
+ | `channels` | `("sms"\|"whatsapp"\|"email")[]` | required in app mode | Build-time preferred order |
154
+ | `ttlMs` | `number` | 10m | Challenge lifetime |
155
+ | `resendCooldownMs` | `number` | 60s | App-mode resend spacing |
156
+ | `exposeDevOtp` | `boolean` | `false` | App mode only — raw OTP in response |
157
+ | `from` | `string` | `OKE <no-reply@oke.local>` | Email template From |
158
+ | `secret` | `string` | active\* | Auth secret (\*from `gate.auth`) |
159
+ | `sessions` | `SessionStore` | active\* | Session store |
160
+ | `identities` | `IdentityStore` | new | Email → user |
161
+ | `phones` | `PhoneStore` | new | Phone → user |
162
+ | `verifications` | `VerificationStore` | new | Challenge store |
163
+
164
+ ## Surfaces
165
+
166
+ | Flow | Path | Gate | Mode |
167
+ | ----------------- | ------------------------ | ------------------------ | ------------- |
168
+ | `auth.requestOtp` | `POST /auth/otp/request` | `gate.public` + otp rate | both |
169
+ | `auth.verifyOtp` | `POST /auth/otp/verify` | `gate.public` + otp rate | both |
170
+ | `auth.resendOtp` | `POST /auth/otp/resend` | `gate.public` + otp rate | app mode only |
171
+
172
+ ## Troubleshooting
173
+
174
+ <Accordions>
175
+ <Accordion title='otp(): mode is required'>
176
+
177
+ You omitted `mode`. Set `mode: "provider"` or `mode: "app"` explicitly — OKE
178
+ never infers which mechanism you meant.
179
+
180
+ </Accordion>
181
+ <Accordion title="Boot fails in provider mode">
182
+
183
+ No Verify-capable SMS driver is bound. Set `drivers.channel.sms` to `taqnyat`
184
+ (or another driver with `sendOtp`/`verifyOtp`), or switch to
185
+ `otp({ mode: "app", channels: [...] })`.
186
+
187
+ </Accordion>
188
+ <Accordion title="resend_cooldown">
189
+
190
+ Wait for `resendCooldownMs` (default 60s). The challenge TTL is unchanged —
191
+ only delivery is rate-limited.
192
+
193
+ </Accordion>
194
+ <Accordion title="No email / SMS with the code (app mode)">
195
+
196
+ In `local` / `test` the `console` driver captures messages. Use
197
+ `exposeDevOtp: true` for unit tests without a real provider.
198
+
199
+ </Accordion>
200
+ </Accordions>
201
+
202
+ ## Learn more
203
+
204
+ - [Magic link](/docs/plugins/magic-link) — link instead of a code
205
+ - [Channel](/docs/elements/channel) — `fx.send`, `fx.sendOtp`, drivers, Mailpit
206
+ - [Gate](/docs/elements/gate) — `gate.auth`
207
+
208
+ ## Next
209
+
210
+ <Cards>
211
+ <Card title="Magic link" description="Email link sign-in." href="/docs/plugins/magic-link" />
212
+ <Card title="Two-factor" description="TOTP second factor." href="/docs/plugins/two-factor" />
213
+ <Card title="Channel" description="Delivery drivers and Mailpit." href="/docs/elements/channel" />
214
+ </Cards>
@@ -107,12 +107,12 @@ 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
+ - OTP over SMS or email instead of TOTP? See [OTP](/docs/plugins/otp) / [Magic link](/docs/plugins/magic-link)
111
111
 
112
112
  ## Next
113
113
 
114
114
  <Cards>
115
115
  <Card title="Passkey" description="WebAuthn register and assert." href="/docs/plugins/passkey" />
116
116
  <Card title="Gate" description="Builtin auth and policies." href="/docs/elements/gate" />
117
- <Card title="Email OTP" description="Passwordless email codes." href="/docs/plugins/email-otp" />
117
+ <Card title="OTP" description="SMS, WhatsApp, or email codes." href="/docs/plugins/otp" />
118
118
  </Cards>
@@ -33,8 +33,9 @@ oke db seed --env prod --force # CI: skip docker/prod confirmation prompt
33
33
  oke vault set STRIPE_KEY # also: list · import .env · key rotate
34
34
  oke client add <url> # types for a separate frontend repo
35
35
 
36
- oke docker # docker/Dockerfile + docker/compose.<role>.yml · …
37
- oke docker --prod # healthchecks, volumes, limits, secret refs, deploy.replicas
36
+ oke docker # docker/Dockerfile + compose.yml + compose.<role>.yml + compose.all.yml · …
37
+ # opt-in images.proxy Caddyfile (caddy) or Traefik labels + socket-proxy
38
+ oke docker --prod # + compose.prod.yml (HEALTHCHECK /_/ready, deploy, limits); folded into compose.all.yml
38
39
  oke docker clean # TTY: pick oke-dev-* stacks → down -v (containers, networks, volumes)
39
40
  oke docker clean --yes # non-TTY: current project stack only
40
41
  oke docker clean --all --yes # non-TTY: every oke-dev-* stack on this machine
@@ -75,16 +75,32 @@ Container image pins keyed by element role — vendor choice lives here, never i
75
75
  ```typescript
76
76
  images: {
77
77
  "store.sql": "postgres:18",
78
+ pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.51",
78
79
  "store.kv": "redis:8",
80
+ // or: "valkey/valkey:8-alpine"
81
+ // or: "docker.dragonflydb.io/dragonflydb/dragonfly"
79
82
  "store.files": "rustfs/rustfs:1.0.0-beta.11",
80
83
  "channel.email": "axllent/mailpit:v1.22.3",
81
84
  vault: "openbao/openbao:2.6.1",
82
85
  ai: "ollama/ollama:latest",
86
+ // opt-in TLS edge — omit for the default (app publishes 6530):
87
+ // proxy: "caddy:2-alpine", // simplest automatic HTTPS
88
+ // proxy: "traefik:v3.3", // label auto-discovery for --scale app=N
83
89
  },
84
90
  ```
85
91
 
92
+ When both `store.sql` and `pgdog` are pinned, `DATABASE_URL` points at PgDog
93
+ (`6432`) — wire-protocol transparent to Bun.SQL / Drizzle. See
94
+ [Store · Multiple environments](/docs/elements/store#multiple-environments).
95
+
96
+ For `store.kv`, pin Redis (default), Valkey, or Dragonfly — driver id stays
97
+ `redis`, same `REDIS_URL`. Image table + licenses: [Store · KV](/docs/elements/store#kv).
98
+
86
99
  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`.
87
100
 
101
+ For `proxy`, pin **either** Caddy or Traefik — never both. Opt-in only; see
102
+ [Reverse proxy](/docs/deployment/reverse-proxy).
103
+
88
104
  ## i18n
89
105
 
90
106
  | Option | Type | Default | Meaning |
@@ -13,14 +13,15 @@ OKE reads environment variables at boot for connection detail and secrets — ne
13
13
 
14
14
  ## SQL store
15
15
 
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 |
16
+ | Variable | Used for | Default when unset |
17
+ | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
18
+ | `DATABASE_URL` | Postgres connection (`postgres` store / clock / journal drivers, drizzle-kit). When PgDog is in the stack, this points at the pooler (`:6432`). | `postgres://localhost:5432/oke` |
19
+ | `OKE_STORE_SQL_URL` | Direct SQL URL (Postgres host, even when PgDog fronts the app) | — |
20
+ | `OKE_PGDOG_URL` | PgDog pooler URL (same value as `DATABASE_URL` when both are present) | |
21
+ | `OKE_SQLITE_URL` | SQLite file path (`sqlite` driver) | `.oke/app.sqlite` |
22
+ | `OKE_LIBSQL_URL` | libSQL URL or file path (`libsql` driver) | `.oke/app.libsql` |
23
+ | `OKE_PGLITE_URL` | PGlite data dir or `memory://` (`pglite` driver) | `.oke/pgdata` |
24
+ | `OKE_SQL_DRIVER` | Force the sql driver id at boot | config map |
24
25
 
25
26
  ## Index store
26
27
 
@@ -243,7 +243,7 @@ Extending an existing **app-owned** table with plugin columns is not supported i
243
243
 
244
244
  ## Learn more
245
245
 
246
- - [Plugins](/docs/plugins) — [username](/docs/plugins/username) · [anonymous](/docs/plugins/anonymous) · [magic link](/docs/plugins/magic-link) · [email OTP](/docs/plugins/email-otp) · [phone](/docs/plugins/phone-number) · [two-factor](/docs/plugins/two-factor) · [passkey](/docs/plugins/passkey) · [Headers](/docs/plugins/headers) · [CORS](/docs/plugins/cors) · [CSRF](/docs/plugins/csrf) · [Compression](/docs/plugins/compression) · [Maintenance Mode](/docs/plugins/maintenance-mode) · [IP Allowlist](/docs/plugins/ip-allowlist)
246
+ - [Plugins](/docs/plugins) — [username](/docs/plugins/username) · [anonymous](/docs/plugins/anonymous) · [magic link](/docs/plugins/magic-link) · [OTP](/docs/plugins/otp) · [two-factor](/docs/plugins/two-factor) · [passkey](/docs/plugins/passkey) · [Headers](/docs/plugins/headers) · [CORS](/docs/plugins/cors) · [CSRF](/docs/plugins/csrf) · [Compression](/docs/plugins/compression) · [Maintenance Mode](/docs/plugins/maintenance-mode) · [IP Allowlist](/docs/plugins/ip-allowlist)
247
247
  - [Flow](/docs/elements/flow) — what plugin flows and hooks plug into
248
248
  - [Store](/docs/elements/store) — `field.*` builders and schema sync
249
249
  - [Configuration](/docs/reference/configuration) — where plugin config is declared
@@ -231,6 +231,42 @@ describe("hybrid sessions", () => {
231
231
  SessionError,
232
232
  );
233
233
  });
234
+
235
+ test("scopes and audience survive on the session row across store instances", async () => {
236
+ const store = createSessionStore();
237
+ let now = 2_000_000;
238
+ const crypto = {
239
+ secret: "test-secret",
240
+ now: () => now,
241
+ audience: "oke-app",
242
+ };
243
+ const issued = await issueSessionWithScopes(store, crypto, {
244
+ id: "user-2",
245
+ plane: "user",
246
+ scopes: ["notes:write", "notes:read"],
247
+ });
248
+ expect(issued.session.scopes).toEqual(["notes:write", "notes:read"]);
249
+ expect(issued.session.audience).toBe("oke-app");
250
+
251
+ // Simulate process restart: copy rows into a fresh SessionStore (no module Maps).
252
+ const restored = createSessionStore();
253
+ for (const [id, row] of store.sessions) {
254
+ restored.sessions.set(id, { ...row, scopes: [...row.scopes] });
255
+ }
256
+ for (const [id, row] of store.refresh) {
257
+ restored.refresh.set(id, { ...row });
258
+ }
259
+
260
+ now += 1_000;
261
+ const rotated = await rotateRefresh(restored, crypto, issued.refreshToken);
262
+ const { verifyAccess } = await import("./sessions.ts");
263
+ const claims = await verifyAccess(restored, crypto.secret, rotated.accessToken, {
264
+ now: () => now,
265
+ audience: "oke-app",
266
+ });
267
+ expect(claims.scopes).toEqual(["notes:write", "notes:read"]);
268
+ expect(claims.aud).toBe("oke-app");
269
+ });
234
270
  });
235
271
 
236
272
  describe("operator plane", () => {
@@ -32,7 +32,7 @@ import {
32
32
  type SessionCrypto,
33
33
  type SessionStore,
34
34
  } from "./sessions.ts";
35
- import { touchRateLimit, type LoginAttemptBag, createLoginAttemptBag } from "./rate.ts";
35
+ import { type LoginAttemptBag, createLoginAttemptBag } from "./rate.ts";
36
36
 
37
37
  /** Built-in policy: verified user session (for `/auth/me` and step-up surfaces). */
38
38
  export const AUTH_SESSION_GATE: PolicyGateDecl = gate.policy(
@@ -297,17 +297,8 @@ export function createAuthHttpBindings(
297
297
  out: SessionTokensOut,
298
298
  errors: { AuthFailed, AuthRateLimited },
299
299
  do: async (input) => {
300
- const key = input.email.trim().toLowerCase();
301
- let bag = loginAttempts.get(key);
302
- if (!bag) {
303
- bag = [];
304
- loginAttempts.set(key, bag);
305
- }
306
- if (touchRateLimit(bag, ctx.now()) === "rate_limited") {
307
- return fail("AuthRateLimited", {
308
- reason: "rate_limited",
309
- });
310
- }
300
+ // Rate limit is Gate KV (`signInRate` on the binding) — shared across
301
+ // instances when `drivers.store.kv` is redis. No process-local email bag.
311
302
  const user = await authenticateUser(identities, input.email, input.password);
312
303
  // Enumeration hygiene: identical failure for unknown email / bad password.
313
304
  if (!user) return fail("AuthFailed", { reason: "invalid_credentials" });
@@ -77,6 +77,39 @@ export function normalizeEmail(email: string): string {
77
77
  return email.trim().toLowerCase();
78
78
  }
79
79
 
80
+ /**
81
+ * Find or create a user row keyed by email (magic-link / OTP sign-in).
82
+ *
83
+ * @param store - Identity store
84
+ * @param email - Normalized email
85
+ * @param now - Clock
86
+ */
87
+ export function ensureUserByEmail(
88
+ store: IdentityStore,
89
+ email: string,
90
+ now: number,
91
+ ): UserIdentityRow {
92
+ const existingId = store.byEmail.get(email);
93
+ if (existingId) {
94
+ const existing = store.users.get(existingId);
95
+ if (existing) return existing;
96
+ }
97
+ const id = crypto.randomUUID();
98
+ const user: UserIdentityRow = {
99
+ id,
100
+ email,
101
+ name: email.split("@")[0] || "user",
102
+ emailVerified: true,
103
+ status: "active",
104
+ createdAt: now,
105
+ updatedAt: now,
106
+ extra: {},
107
+ };
108
+ store.users.set(id, user);
109
+ store.byEmail.set(email, id);
110
+ return user;
111
+ }
112
+
80
113
  /**
81
114
  * Create a user with a credential account (provider `credential`).
82
115
  *
package/src/auth/index.ts CHANGED
@@ -69,10 +69,14 @@ export {
69
69
  createVerificationStore,
70
70
  putVerification,
71
71
  findActiveVerification,
72
+ consumeVerification,
73
+ invalidateVerifications,
74
+ wipeSealedOtp,
72
75
  hashChallenge,
73
76
  generateOtp,
74
77
  type VerificationStore,
75
78
  type VerificationRow,
79
+ type OtpChannel,
76
80
  } from "./verification.ts";
77
81
 
78
82
  export { constantTimeEqual } from "./constant-time.ts";
@@ -83,6 +87,7 @@ export {
83
87
  authenticateUser,
84
88
  getUserById,
85
89
  normalizeEmail,
90
+ ensureUserByEmail,
86
91
  IdentityError,
87
92
  type IdentityStore,
88
93
  type UserIdentityRow,
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Fail-loud capability checks for the otp() plugin (provider / app mode).
3
+ */
4
+
5
+ import type { ChannelDriver, SmsOtpTransport } from "../drivers/channel-types.ts";
6
+ import type { OtpChannel } from "./verification.ts";
7
+
8
+ /** Config snapshot stored on the otp plugin for boot-time assertion. */
9
+ export interface OtpPluginConfig {
10
+ readonly method: "otp";
11
+ readonly mode: "provider" | "app";
12
+ readonly channels?: readonly OtpChannel[];
13
+ }
14
+
15
+ /**
16
+ * Whether an SMS transport exposes provider OTP via Verify (structural).
17
+ *
18
+ * @param t - Candidate transport
19
+ */
20
+ export function isSmsOtpTransport(t: unknown): t is SmsOtpTransport {
21
+ if (!t || typeof t !== "object") return false;
22
+ const o = t as Partial<SmsOtpTransport>;
23
+ return typeof o.sendOtp === "function" && typeof o.verifyOtp === "function";
24
+ }
25
+
26
+ /**
27
+ * Find a bound SMS driver with real sendOtp/verifyOtp, or undefined.
28
+ *
29
+ * @param drivers - Bound channel drivers
30
+ */
31
+ export function findOtpSmsDriver(
32
+ drivers: readonly ChannelDriver[],
33
+ ): { readonly driver: ChannelDriver; readonly otp: SmsOtpTransport } | undefined {
34
+ for (const d of drivers) {
35
+ if (isSmsOtpTransport(d.smsTransport)) {
36
+ return { driver: d, otp: d.smsTransport };
37
+ }
38
+ }
39
+ return undefined;
40
+ }
41
+
42
+ /**
43
+ * Whether any bound driver can deliver on `medium`.
44
+ *
45
+ * @param drivers - Bound channel drivers
46
+ * @param medium - OTP channel
47
+ */
48
+ export function driverCoversMedium(drivers: readonly ChannelDriver[], medium: OtpChannel): boolean {
49
+ for (const d of drivers) {
50
+ if (medium === "email" && d.transport) return true;
51
+ if (medium === "sms" && (d.smsTransport || d.channel?.mediums.includes("sms"))) return true;
52
+ if (medium === "whatsapp" && (d.whatsappTransport || d.channel?.mediums.includes("whatsapp"))) {
53
+ return true;
54
+ }
55
+ if (d.channel?.mediums.includes(medium) || d.channel?.mediums.includes("any")) return true;
56
+ }
57
+ return false;
58
+ }
59
+
60
+ /**
61
+ * Assert provider mode: a Verify-capable SMS driver must be bound.
62
+ *
63
+ * @param drivers - Bound channel drivers
64
+ */
65
+ export function assertOtpProviderModeCapability(drivers: readonly ChannelDriver[]): void {
66
+ if (findOtpSmsDriver(drivers)) return;
67
+ const sms = drivers.filter((d) => d.smsTransport);
68
+ if (sms.length === 0) {
69
+ throw new Error(
70
+ 'otp({ mode: "provider" }): no SMS driver with sendOtp/verifyOtp bound — set drivers.channel.sms to a Verify-capable driver (e.g. "taqnyat"), or switch to otp({ mode: "app", channels: [...] })',
71
+ );
72
+ }
73
+ const id = sms[0]?.id ?? "unknown";
74
+ throw new Error(
75
+ `otp({ mode: "provider" }): SMS driver "${id}" does not support provider OTP — bind a Verify-capable driver (e.g. taqnyat), or switch to otp({ mode: "app", channels: [...] })`,
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Assert app mode: every declared channel has a deliverable driver.
81
+ *
82
+ * @param drivers - Bound channel drivers
83
+ * @param channels - Declared channel order
84
+ */
85
+ export function assertOtpAppModeChannels(
86
+ drivers: readonly ChannelDriver[],
87
+ channels: readonly OtpChannel[],
88
+ ): void {
89
+ for (const ch of channels) {
90
+ if (!driverCoversMedium(drivers, ch)) {
91
+ throw new Error(
92
+ `otp({ mode: "app" }): no channel driver covers "${ch}" — configure drivers.channel.${ch === "email" ? "email" : ch === "sms" ? "sms" : "whatsapp"}`,
93
+ );
94
+ }
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Run the matching boot assertion for an otp plugin config snapshot.
100
+ *
101
+ * @param config - Plugin configSnapshot
102
+ * @param drivers - Bound channel drivers
103
+ */
104
+ export function assertOtpPluginCapability(
105
+ config: unknown,
106
+ drivers: readonly ChannelDriver[],
107
+ ): void {
108
+ if (!config || typeof config !== "object") return;
109
+ const c = config as Partial<OtpPluginConfig>;
110
+ if (c.method !== "otp") return;
111
+ if (c.mode === "provider") {
112
+ assertOtpProviderModeCapability(drivers);
113
+ return;
114
+ }
115
+ if (c.mode === "app") {
116
+ const channels = c.channels ?? [];
117
+ assertOtpAppModeChannels(drivers, channels);
118
+ }
119
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * OTP seal — HKDF domain separation + AES-GCM round-trip.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { deriveOtpSealKey, OTP_SEAL_HKDF_INFO, sealOtp, unsealOtp } from "./otp-seal.ts";
7
+
8
+ describe("otp-seal", () => {
9
+ test("info constant is the literal oke-otp-seal-v1", () => {
10
+ expect(OTP_SEAL_HKDF_INFO).toBe("oke-otp-seal-v1");
11
+ });
12
+
13
+ test("round-trip seal / unseal", async () => {
14
+ const secret = "test-secret-at-least-16";
15
+ const otp = "482913";
16
+ const blob = await sealOtp(secret, otp);
17
+ expect(blob).not.toContain(otp);
18
+ expect(await unsealOtp(secret, blob)).toBe(otp);
19
+ });
20
+
21
+ test("secret A cannot unseal secret B", async () => {
22
+ const blob = await sealOtp("secret-aaaaaaaaaaaa", "123456");
23
+ await expect(unsealOtp("secret-bbbbbbbbbbbb", blob)).rejects.toThrow();
24
+ });
25
+
26
+ test("deriveOtpSealKey produces an AES-GCM key (not raw secret)", async () => {
27
+ const key = await deriveOtpSealKey("test-secret-at-least-16");
28
+ expect(key.type).toBe("secret");
29
+ expect(key.algorithm).toMatchObject({ name: "AES-GCM" });
30
+ // Non-extractable — raw secret bytes never leave HKDF as exportable AES material.
31
+ expect(key.extractable).toBe(false);
32
+ });
33
+
34
+ test("HKDF info bytes pin domain separation (known key length)", async () => {
35
+ const ikm = new TextEncoder().encode("fixed-secret-for-vector");
36
+ const baseKey = await crypto.subtle.importKey("raw", ikm, "HKDF", false, ["deriveBits"]);
37
+ const bits = await crypto.subtle.deriveBits(
38
+ {
39
+ name: "HKDF",
40
+ hash: "SHA-256",
41
+ salt: new Uint8Array(0),
42
+ info: new TextEncoder().encode(OTP_SEAL_HKDF_INFO),
43
+ },
44
+ baseKey,
45
+ 256,
46
+ );
47
+ expect(new Uint8Array(bits).byteLength).toBe(32);
48
+ // Different info → different bits
49
+ const other = await crypto.subtle.deriveBits(
50
+ {
51
+ name: "HKDF",
52
+ hash: "SHA-256",
53
+ salt: new Uint8Array(0),
54
+ info: new TextEncoder().encode("session-hmac-v1"),
55
+ },
56
+ baseKey,
57
+ 256,
58
+ );
59
+ expect(Buffer.from(bits).equals(Buffer.from(other))).toBe(false);
60
+ });
61
+ });