okengine 0.3.6 → 0.5.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 (205) hide show
  1. package/AGENTS.md +2 -0
  2. package/package.json +14 -12
  3. package/site/content/docs/ai/index.mdx +24 -0
  4. package/site/content/docs/ai/llms-txt.mdx +3 -0
  5. package/site/content/docs/ai/meta.json +1 -1
  6. package/site/content/docs/ai/skills.mdx +5 -3
  7. package/site/content/docs/console/gates.mdx +46 -8
  8. package/site/content/docs/console/index.mdx +54 -0
  9. package/site/content/docs/console/meta.json +1 -0
  10. package/site/content/docs/elements/ai.mdx +2 -0
  11. package/site/content/docs/elements/channel.mdx +2 -0
  12. package/site/content/docs/elements/clock.mdx +1 -4
  13. package/site/content/docs/elements/flow.mdx +4 -10
  14. package/site/content/docs/elements/gate.mdx +189 -48
  15. package/site/content/docs/elements/index.mdx +45 -0
  16. package/site/content/docs/elements/meta.json +1 -1
  17. package/site/content/docs/elements/signal.mdx +1 -5
  18. package/site/content/docs/elements/store.mdx +27 -6
  19. package/site/content/docs/elements/vault.mdx +10 -11
  20. package/site/content/docs/get-started/basic-usage.mdx +79 -43
  21. package/site/content/docs/get-started/index.mdx +33 -0
  22. package/site/content/docs/get-started/installation.mdx +95 -43
  23. package/site/content/docs/get-started/introduction.mdx +128 -75
  24. package/site/content/docs/get-started/meta.json +1 -1
  25. package/site/content/docs/get-started/why.mdx +141 -0
  26. package/site/content/docs/index.mdx +9 -31
  27. package/site/content/docs/plugins/anonymous.mdx +95 -0
  28. package/site/content/docs/plugins/compression.mdx +2 -2
  29. package/site/content/docs/plugins/cors.mdx +2 -2
  30. package/site/content/docs/plugins/csrf.mdx +2 -2
  31. package/site/content/docs/plugins/email-otp.mdx +111 -0
  32. package/site/content/docs/plugins/{security-headers.mdx → headers.mdx} +2 -2
  33. package/site/content/docs/plugins/index.mdx +69 -0
  34. package/site/content/docs/plugins/ip-allowlist.mdx +1 -2
  35. package/site/content/docs/plugins/magic-link.mdx +112 -0
  36. package/site/content/docs/plugins/maintenance-mode.mdx +8 -8
  37. package/site/content/docs/plugins/meta.json +10 -1
  38. package/site/content/docs/plugins/passkey.mdx +128 -0
  39. package/site/content/docs/plugins/phone-number.mdx +111 -0
  40. package/site/content/docs/plugins/two-factor.mdx +116 -0
  41. package/site/content/docs/plugins/username.mdx +117 -0
  42. package/site/content/docs/reference/client.mdx +331 -0
  43. package/site/content/docs/reference/configuration.mdx +1 -1
  44. package/site/content/docs/reference/environment-variables.mdx +5 -3
  45. package/site/content/docs/reference/fx.mdx +36 -8
  46. package/site/content/docs/reference/index.mdx +45 -0
  47. package/site/content/docs/reference/meta.json +11 -1
  48. package/site/content/docs/reference/plugins.mdx +25 -14
  49. package/src/auth/auth.test.ts +20 -2
  50. package/src/auth/bindings.ts +439 -0
  51. package/src/auth/breach-check.ts +112 -0
  52. package/src/auth/config.ts +288 -0
  53. package/src/auth/cookies.ts +123 -0
  54. package/src/auth/gate-auth.test.ts +379 -0
  55. package/src/auth/identity.ts +190 -0
  56. package/src/auth/index.ts +117 -1
  57. package/src/auth/method-context.ts +33 -0
  58. package/src/auth/operator.ts +27 -1
  59. package/src/auth/password-policy.test.ts +126 -0
  60. package/src/auth/password-policy.ts +77 -0
  61. package/src/auth/plugin.ts +62 -4
  62. package/src/auth/rate.ts +45 -0
  63. package/src/auth/schema.ts +260 -0
  64. package/src/auth/secondary-storage.ts +37 -0
  65. package/src/auth/sessions.ts +58 -1
  66. package/src/auth/tables.ts +4 -0
  67. package/src/auth/verification.ts +78 -0
  68. package/src/cli/competitor-mention-removal.test.ts +117 -0
  69. package/src/cli/dev.test.ts +3 -3
  70. package/src/cli/dev.ts +20 -0
  71. package/src/cli/meilisearch-local.test.ts +69 -0
  72. package/src/cli/meilisearch-local.ts +188 -0
  73. package/src/cli/schema.ts +95 -23
  74. package/src/client/auth.ts +120 -0
  75. package/src/client-react/index.ts +93 -0
  76. package/src/compiler/aot.test.ts +2 -1
  77. package/src/compiler/extract.ts +19 -0
  78. package/src/compiler/fixtures/skyport/src/flows/payments/index.ts +5 -1
  79. package/src/compiler/response.ts +16 -2
  80. package/src/console/server/app.ts +10 -6
  81. package/src/console/server/auth-rate.test.ts +3 -3
  82. package/src/console/server/bind.ts +12 -1
  83. package/src/console/server/channels.test.ts +1 -1
  84. package/src/console/server/console-gates.ts +14 -0
  85. package/src/console/server/console.test.ts +6 -6
  86. package/src/console/server/flows-invoke.test.ts +2 -2
  87. package/src/console/server/flows.ts +2 -0
  88. package/src/console/server/gates.ts +8 -1
  89. package/src/console/server/operator-db.test.ts +4 -4
  90. package/src/console/server/operator-db.ts +22 -4
  91. package/src/console/server/security.gate.test.ts +3 -3
  92. package/src/console/server/store.test.ts +1 -1
  93. package/src/console/server/store.ts +11 -1
  94. package/src/console/ui/dist/assets/index-CjxwRGVv.js +10 -0
  95. package/src/console/ui/dist/assets/panel-access-BGv45snf.js +64 -0
  96. package/src/console/ui/dist/assets/{panel-ai-D_m6WQI8.js → panel-ai-B2S7LEii.js} +1 -1
  97. package/src/console/ui/dist/assets/{panel-architecture-CKnXFyUx.js → panel-architecture-D7UJh91v.js} +1 -1
  98. package/src/console/ui/dist/assets/{panel-channels-DCDd4WAC.js → panel-channels-9T3ybqRu.js} +1 -1
  99. package/src/console/ui/dist/assets/panel-clock-Cb1UXGRQ.js +1 -0
  100. package/src/console/ui/dist/assets/{panel-diff-cdonmH8c.js → panel-diff-DmYbKWmN.js} +1 -1
  101. package/src/console/ui/dist/assets/panel-flows-PiHwT55z.js +48 -0
  102. package/src/console/ui/dist/assets/{panel-gates-B5eTE8XH.js → panel-gates-BQGYXvjT.js} +1 -1
  103. package/src/console/ui/dist/assets/panel-overview-BBnRO18l.js +1 -0
  104. package/src/console/ui/dist/assets/{panel-plugins-Cj7DK1er.js → panel-plugins-D0PsmVw2.js} +1 -1
  105. package/src/console/ui/dist/assets/panel-runs-CWuRDe0r.js +1 -0
  106. package/src/console/ui/dist/assets/{panel-signals-whmDXIg3.js → panel-signals-Bbg4ewpP.js} +1 -1
  107. package/src/console/ui/dist/assets/{panel-store-CEMHLvaw.js → panel-store-CPCbsDRa.js} +1 -1
  108. package/src/console/ui/dist/assets/panel-traces-DVAzuA_S.js +1 -0
  109. package/src/console/ui/dist/assets/{panel-vault-C9wjbki8.js → panel-vault-D1_MvOmo.js} +1 -1
  110. package/src/console/ui/dist/assets/{rolldown-runtime-CNC7AqOf.js → rolldown-runtime-B0Z9INg1.js} +1 -1
  111. package/src/console/ui/dist/index.html +2 -2
  112. package/src/console/ui/gates/fixture.ts +4 -0
  113. package/src/console/ui/gates/types.ts +2 -0
  114. package/src/console/ui/shell/client.ts +1 -0
  115. package/src/docker/compose.ts +5 -0
  116. package/src/docker/docker.test.ts +41 -0
  117. package/src/docker/recipes/index.ts +10 -2
  118. package/src/docker/recipes/meilisearch.ts +31 -0
  119. package/src/drivers/conformance.test.ts +16 -1
  120. package/src/drivers/conformance.ts +40 -3
  121. package/src/drivers/index.ts +14 -2
  122. package/src/drivers/libsql.ts +4 -4
  123. package/src/drivers/meilisearch.integration.test.ts +77 -0
  124. package/src/drivers/meilisearch.test.ts +181 -0
  125. package/src/drivers/meilisearch.ts +208 -0
  126. package/src/drivers/memory.ts +4 -4
  127. package/src/drivers/pgvector.ts +6 -6
  128. package/src/drivers/types.ts +93 -12
  129. package/src/drivers/vault-driver-removal.test.ts +6 -0
  130. package/src/drivers/vault-types.ts +4 -4
  131. package/src/elements/ai/runtime.ts +6 -0
  132. package/src/elements/ai.test.ts +22 -0
  133. package/src/elements/gate/boot.ts +136 -0
  134. package/src/elements/gate/config.ts +69 -0
  135. package/src/elements/gate/declare.ts +51 -1
  136. package/src/elements/gate/runtime.ts +3 -1
  137. package/src/elements/gate.test.ts +77 -0
  138. package/src/elements/gate.ts +20 -1
  139. package/src/elements/index.ts +8 -0
  140. package/src/elements/store/index-boot.test.ts +49 -7
  141. package/src/elements/store/runtime.ts +50 -15
  142. package/src/elements/store.ts +2 -0
  143. package/src/elements/vault.test.ts +27 -4
  144. package/src/elements/vault.ts +1 -1
  145. package/src/index.ts +15 -0
  146. package/src/kernel/app.ts +253 -32
  147. package/src/kernel/boot-bind/store.test.ts +9 -0
  148. package/src/kernel/boot-bind/store.ts +30 -2
  149. package/src/kernel/boot.test.ts +40 -3
  150. package/src/kernel/boot.ts +8 -0
  151. package/src/kernel/call.test.ts +46 -2
  152. package/src/kernel/concurrency.test.ts +58 -0
  153. package/src/kernel/concurrency.ts +48 -0
  154. package/src/kernel/edge.test.ts +3 -3
  155. package/src/kernel/flow.test.ts +2 -2
  156. package/src/kernel/fx.test.ts +12 -2
  157. package/src/kernel/fx.ts +97 -5
  158. package/src/kernel/hooks.test.ts +4 -4
  159. package/src/kernel/index.ts +22 -1
  160. package/src/kernel/pipeline.test.ts +12 -8
  161. package/src/kernel/pipeline.ts +23 -4
  162. package/src/kernel/plugin/decorate.test.ts +3 -3
  163. package/src/kernel/plugin/scoping.test.ts +3 -3
  164. package/src/kernel/plugin-elements.test.ts +51 -0
  165. package/src/kernel/plugin-needs.test.ts +83 -0
  166. package/src/kernel/plugin-needs.ts +129 -0
  167. package/src/kernel/plugin.ts +101 -0
  168. package/src/kernel/redacted.ts +74 -0
  169. package/src/kernel/registry-isolation.test.ts +5 -5
  170. package/src/kernel/registry.ts +102 -3
  171. package/src/kernel/router.ts +3 -3
  172. package/src/manifest/types.ts +2 -0
  173. package/src/plugins/anonymous.ts +58 -0
  174. package/src/plugins/auth/shared.ts +121 -0
  175. package/src/plugins/auth-methods.test.ts +176 -0
  176. package/src/plugins/compression.test.ts +5 -5
  177. package/src/plugins/config-source.test.ts +1 -1
  178. package/src/plugins/cors.test.ts +16 -10
  179. package/src/plugins/csrf.test.ts +1 -1
  180. package/src/plugins/email-otp.ts +161 -0
  181. package/src/plugins/index.ts +31 -0
  182. package/src/plugins/ip-allowlist.test.ts +19 -9
  183. package/src/plugins/magic-link.ts +163 -0
  184. package/src/plugins/maintenance-mode.test.ts +9 -5
  185. package/src/plugins/passkey.ts +216 -0
  186. package/src/plugins/phone-number.ts +149 -0
  187. package/src/plugins/security-headers.test.ts +14 -14
  188. package/src/plugins/two-factor.ts +249 -0
  189. package/src/plugins/username.ts +148 -0
  190. package/src/runs/runs.test.ts +6 -2
  191. package/src/runtime/primitives.ts +37 -4
  192. package/src/runtime/serve.test.ts +3 -2
  193. package/src/runtime/types.ts +25 -2
  194. package/src/test/create-test-app.test.ts +1 -1
  195. package/src/test/create-test-app.ts +4 -1
  196. package/src/test/provisions.integration.test.ts +2 -2
  197. package/site/content/docs/get-started/comparison.mdx +0 -65
  198. package/src/console/ui/dist/assets/index-CrKMmO__.js +0 -10
  199. package/src/console/ui/dist/assets/panel-access-C0J2D-a2.js +0 -64
  200. package/src/console/ui/dist/assets/panel-clock-DjGGFPzr.js +0 -1
  201. package/src/console/ui/dist/assets/panel-flows-DlCU5zjA.js +0 -45
  202. package/src/console/ui/dist/assets/panel-overview-BsFvDdts.js +0 -1
  203. package/src/console/ui/dist/assets/panel-runs-C0gmnoYL.js +0 -1
  204. package/src/console/ui/dist/assets/panel-traces-BDiAuVSK.js +0 -1
  205. package/src/drivers/vault-infisical.ts +0 -57
@@ -0,0 +1,45 @@
1
+ ---
2
+ title: "Reference"
3
+ description: "Lookup pages — config, fx, client, env vars, errors, plugins API, CLI, and Console security."
4
+ icon: "BookMarked"
5
+ source: "docs/spec/unified-theory.md"
6
+ ---
7
+
8
+ Dense tables and command lists. Reach for these when you already know what you are looking for.
9
+
10
+ ## Pages
11
+
12
+ <Cards>
13
+ <Card
14
+ title="Configuration"
15
+ description="Every option in oke.config.ts."
16
+ href="/docs/reference/configuration"
17
+ />
18
+ <Card title="fx" description="The complete fx surface and effects." href="/docs/reference/fx" />
19
+ <Card
20
+ title="Client"
21
+ description="Typed createClient — REST, RPC, envelope helpers."
22
+ href="/docs/reference/client"
23
+ />
24
+ <Card
25
+ title="Environment Variables"
26
+ description="Every variable OKE reads."
27
+ href="/docs/reference/environment-variables"
28
+ />
29
+ <Card title="Errors" description="OKE codes, denials, fixes." href="/docs/reference/errors" />
30
+ <Card
31
+ title="Plugins"
32
+ description="Plugin API — hooks, schemas, identity."
33
+ href="/docs/reference/plugins"
34
+ />
35
+ <Card
36
+ title="CLI Reference"
37
+ description="oke and create-oke commands."
38
+ href="/docs/reference/cli"
39
+ />
40
+ <Card
41
+ title="Security"
42
+ description="Console Host, Origin, MCP posture."
43
+ href="/docs/reference/security"
44
+ />
45
+ </Cards>
@@ -1,5 +1,15 @@
1
1
  {
2
2
  "title": "Reference",
3
3
  "icon": "BookMarked",
4
- "pages": ["configuration", "fx", "environment-variables", "errors", "plugins", "cli", "security"]
4
+ "pages": [
5
+ "index",
6
+ "configuration",
7
+ "fx",
8
+ "client",
9
+ "environment-variables",
10
+ "errors",
11
+ "plugins",
12
+ "cli",
13
+ "security"
14
+ ]
5
15
  }
@@ -66,20 +66,26 @@ Every method below exists on both the fluent definition and the boot-time builde
66
66
 
67
67
  | Method | Contributes |
68
68
  | -------------------------------- | --------------------------------------------------------------------------------------------- |
69
- | `.flow(def)` | An ordinary flow — Manifest, Console, client types |
69
+ | `.flow(def)` | An ordinary flow — Manifest / Console metadata (does **not** join the HTTP router alone) |
70
+ | `.binding({ trigger, flow })` | A real Binding — joins `adopted` + the router on `app.plug()` (auth method plugins) |
70
71
  | `.hook(stage, fn)` | A per-request intercept at one pipeline stage |
71
72
  | `.edge(fn)` | A handler for HTTP requests that match **no** flow |
72
73
  | `.decorate(key, value)` | A typed context decoration, visible to flows |
73
- | `.element({ kind, name })` | An element contribution (e.g. `store.sql` facet) |
74
+ | `.element({ kind, name })` | An opaque element contribution (e.g. `store.sql` facet) |
75
+ | `.vault(secret)` | A vault secret/config contract — merged into boot secrets |
76
+ | `.clock(decl)` | A named clock schedule — merged into boot clocks |
77
+ | `.signal(decl)` | A signal declaration — merged into boot signals |
78
+ | `.gate(decl)` | A gate declaration — merged into boot gates |
79
+ | `.channelTemplate(decl)` | A channel template — merged into boot channel templates |
74
80
  | `.driver(id, impl)` | A protocol-named driver for an existing element |
75
81
  | `.image(role, recipe)` | An image recipe for a docker role |
76
82
  | `.table(name, columns, options)` | A whole DB table, merged into the generated schema (`options.description` / `plane` optional) |
77
83
  | `.errors(map)` | Typed errors flows can fail with |
78
- | `.client(name, ext)` | A typed client extension |
84
+ | `.client(name, ext)` | A typed client extension (reserved seam — not yet merged into `createClient`) |
79
85
  | `.consolePanel(panel)` | A Console panel (ESM entry loaded at runtime) |
80
86
  | `.cli(name, handler)` | An `oke <name>` CLI command |
81
87
  | `.config(schema)` | A config schema; values live on the plugin identity |
82
- | `.needs(dep)` | A declared dependency (e.g. `"store.kv"`) |
88
+ | `.needs(dep)` | Runtime dependency plugin name or element/driver id; unmet → `PluginNeedsError` at boot |
83
89
 
84
90
  New infrastructure is a **driver** for an existing element, never a ninth element — plugins follow the same law.
85
91
 
@@ -202,13 +208,13 @@ Extending an existing **app-owned** table with plugin columns is not supported i
202
208
 
203
209
  ## Identity, config, and dependencies
204
210
 
205
- | Concept | Rule |
206
- | ---------- | ------------------------------------------------------------------------------ |
207
- | Name | Stable plugin id — the Manifest key and conflict namespace |
208
- | `version` | Semver string recorded in the Manifest |
209
- | `config` | Snapshot for identity dedup: same name + same config → no-op re-plug |
210
- | Conflict | Same name + **different** config → loud boot error, never a silent merge |
211
- | `.needs()` | Declares runtime dependencies so boot fails early when a capability is missing |
211
+ | Concept | Rule |
212
+ | ---------- | -------------------------------------------------------------------------------------------- |
213
+ | Name | Stable plugin id — the Manifest key and conflict namespace |
214
+ | `version` | Semver string recorded in the Manifest |
215
+ | `config` | Snapshot for identity dedup: same name + same config → no-op re-plug |
216
+ | Conflict | Same name + **different** config → loud boot error, never a silent merge |
217
+ | `.needs()` | Declares runtime dependencies (plugin name or element/driver id); unmet `PluginNeedsError` |
212
218
 
213
219
  ## Troubleshooting
214
220
 
@@ -227,11 +233,16 @@ Extending an existing **app-owned** table with plugin columns is not supported i
227
233
  table above, and confirm the flow you expect actually reaches that stage (a gate denial never
228
234
  reaches `beforeHandle`).
229
235
  </Accordion>
236
+ <Accordion title="Boot: plugin boot failed — unmet .needs() dependencies">
237
+ A plugged plugin declared `.needs("auth")` or `.needs("store.sql")` (or another token) and
238
+ nothing satisfied it. For `"auth"`, enable `oke({ gate: { auth } })`. Otherwise plug the peer
239
+ plugin, or ensure the element/driver is available (tables imply `store.sql`, and so on).
240
+ </Accordion>
230
241
  </Accordions>
231
242
 
232
243
  ## Learn more
233
244
 
234
- - [Security Headers](/docs/plugins/security-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) — first-party plugins built on exactly this API
245
+ - [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)
235
246
  - [Flow](/docs/elements/flow) — what plugin flows and hooks plug into
236
247
  - [Store](/docs/elements/store) — `field.*` builders and schema sync
237
248
  - [Configuration](/docs/reference/configuration) — where plugin config is declared
@@ -240,9 +251,9 @@ Extending an existing **app-owned** table with plugin columns is not supported i
240
251
 
241
252
  <Cards>
242
253
  <Card
243
- title="Security Headers"
254
+ title="Headers"
244
255
  description="The full secure-headers set on every response."
245
- href="/docs/plugins/security-headers"
256
+ href="/docs/plugins/headers"
246
257
  />
247
258
  <Card title="CORS" description="Cross-origin rules at the edge." href="/docs/plugins/cors" />
248
259
  <Card title="Flow" description="Triggers, effects, and durability." href="/docs/elements/flow" />
@@ -75,6 +75,9 @@ describe("auth plugin", () => {
75
75
  tables.push(name);
76
76
  return this;
77
77
  },
78
+ binding() {
79
+ return this;
80
+ },
78
81
  flow() {
79
82
  return this;
80
83
  },
@@ -84,6 +87,21 @@ describe("auth plugin", () => {
84
87
  config() {
85
88
  return this;
86
89
  },
90
+ vault() {
91
+ return this;
92
+ },
93
+ clock() {
94
+ return this;
95
+ },
96
+ signal() {
97
+ return this;
98
+ },
99
+ gate() {
100
+ return this;
101
+ },
102
+ channelTemplate() {
103
+ return this;
104
+ },
87
105
  });
88
106
  expect(tables).toContain("oke_operators");
89
107
  expect(tables).toContain("oke_operator_credentials");
@@ -218,7 +236,7 @@ describe("operator plane", () => {
218
236
  const op = await createOperator(store, {
219
237
  email: "ops@example.com",
220
238
  name: "Ops",
221
- password: "correct horse battery staple",
239
+ password: "correct horse battery staple 1",
222
240
  });
223
241
 
224
242
  expect(store.credentials.has(op.id)).toBe(true);
@@ -231,7 +249,7 @@ describe("operator plane", () => {
231
249
  const authed = await authenticateOperator(
232
250
  store,
233
251
  "ops@example.com",
234
- "correct horse battery staple",
252
+ "correct horse battery staple 1",
235
253
  );
236
254
  expect(authed?.id).toBe(op.id);
237
255
  });
@@ -0,0 +1,439 @@
1
+ /**
2
+ * Materialize Gate auth HTTP surfaces as real {@link Binding}s.
3
+ *
4
+ * Must be pushed into `adopted` + the smart router **before**
5
+ * `assertHttpGatePosture` — never registry `flows[]` alone.
6
+ */
7
+
8
+ import { z } from "zod";
9
+ import { gate, type PolicyGateDecl } from "../elements/gate.ts";
10
+ import type { GateDecl } from "../elements/gate/declare.ts";
11
+ import { fail } from "../kernel/errors.ts";
12
+ import { flow, type AnyFlowDef } from "../kernel/flow.ts";
13
+ import type { Binding } from "../kernel/on.ts";
14
+ import { http, normalizeTrigger, type HttpTrigger, type Trigger } from "../kernel/triggers.ts";
15
+ import type { ResolvedGateAuth } from "./config.ts";
16
+ import { BreachCheckError } from "./breach-check.ts";
17
+ import {
18
+ authenticateUser,
19
+ createIdentityStore,
20
+ createUserWithPassword,
21
+ getUserById,
22
+ IdentityError,
23
+ type IdentityStore,
24
+ } from "./identity.ts";
25
+ import { PasswordPolicyError } from "./password-policy.ts";
26
+ import {
27
+ createSessionStore,
28
+ issueSessionWithScopes,
29
+ revokeFamily,
30
+ rotateRefresh,
31
+ SessionError,
32
+ type SessionCrypto,
33
+ type SessionStore,
34
+ } from "./sessions.ts";
35
+ import { touchRateLimit, type LoginAttemptBag, createLoginAttemptBag } from "./rate.ts";
36
+
37
+ /** Built-in policy: verified user session (for `/auth/me` and step-up surfaces). */
38
+ export const AUTH_SESSION_GATE: PolicyGateDecl = gate.policy(
39
+ "auth.session",
40
+ ({ auth }) => !!auth.userId && auth.verified === true,
41
+ );
42
+
43
+ /** Built-in policy: session within `freshAge` (step-up). Requires `meta.sessionCreatedAt`. */
44
+ export function createFreshSessionGate(freshAgeMs: number): GateDecl {
45
+ return gate.policy("auth.fresh", (ctx) => {
46
+ if (!ctx.auth.userId || ctx.auth.verified !== true) return false;
47
+ const createdAt = ctx.meta?.sessionCreatedAt;
48
+ if (typeof createdAt !== "number") return false;
49
+ return Date.now() - createdAt <= freshAgeMs;
50
+ });
51
+ }
52
+
53
+ /** Auth rate presets (stricter on credential paths). */
54
+ export const AUTH_RATE_PRESETS = {
55
+ signIn: { max: 5, per: "1m", keyBy: "ip" },
56
+ signUp: { max: 10, per: "1m", keyBy: "ip" },
57
+ refresh: { max: 30, per: "1m", keyBy: "ip" },
58
+ otp: { max: 5, per: "1m", keyBy: "ip" },
59
+ } as const;
60
+
61
+ /** Runtime context shared by auth Flows. */
62
+ export interface AuthRuntimeContext {
63
+ readonly config: ResolvedGateAuth;
64
+ readonly sessions: SessionStore;
65
+ readonly identities: IdentityStore;
66
+ readonly loginAttempts: LoginAttemptBag;
67
+ readonly authGates: readonly GateDecl[];
68
+ now(): number;
69
+ sessionCrypto(): SessionCrypto;
70
+ }
71
+
72
+ /** Result of {@link createAuthHttpBindings}. */
73
+ export interface AuthHttpMaterialization {
74
+ readonly bindings: readonly Binding[];
75
+ readonly flows: {
76
+ readonly refresh: AnyFlowDef;
77
+ readonly revoke: AnyFlowDef;
78
+ readonly me: AnyFlowDef;
79
+ readonly signInEmail?: AnyFlowDef;
80
+ readonly signUpEmail?: AnyFlowDef;
81
+ };
82
+ readonly ctx: AuthRuntimeContext;
83
+ /** Rate + policy gates to merge into boot `gates`. */
84
+ readonly authGates: readonly GateDecl[];
85
+ }
86
+
87
+ const SessionTokensOut = z.object({
88
+ accessToken: z.string(),
89
+ refreshToken: z.string(),
90
+ accessExpiresAt: z.number(),
91
+ userId: z.string().optional(),
92
+ });
93
+
94
+ const RefreshIn = z.object({
95
+ refreshToken: z.string().min(1),
96
+ });
97
+
98
+ const RevokeIn = z.object({
99
+ refreshToken: z.string().min(1).optional(),
100
+ });
101
+
102
+ const EmailPasswordIn = z.object({
103
+ email: z.string().min(3),
104
+ password: z.string().min(1),
105
+ name: z.string().optional(),
106
+ });
107
+
108
+ const AuthFailed = z.object({
109
+ reason: z.string().optional(),
110
+ /** Password-policy failure details (`reason: "password_policy"`). */
111
+ reasons: z.array(z.string()).optional(),
112
+ });
113
+
114
+ const AuthRateLimited = z.object({
115
+ reason: z.string(),
116
+ });
117
+
118
+ const MeOut = z.object({
119
+ userId: z.string(),
120
+ email: z.string(),
121
+ name: z.string(),
122
+ emailVerified: z.boolean(),
123
+ });
124
+
125
+ /**
126
+ * Build rate gate decls for auth paths (when rate limiting enabled).
127
+ *
128
+ * @param enabled - Whether presets are active
129
+ */
130
+ export function createAuthRateGates(enabled: boolean): GateDecl[] {
131
+ if (!enabled) return [];
132
+ return [
133
+ gate.rate({
134
+ max: AUTH_RATE_PRESETS.signIn.max,
135
+ per: AUTH_RATE_PRESETS.signIn.per,
136
+ keyBy: AUTH_RATE_PRESETS.signIn.keyBy,
137
+ description: "Auth sign-in rate limit",
138
+ }),
139
+ gate.rate({
140
+ max: AUTH_RATE_PRESETS.signUp.max,
141
+ per: AUTH_RATE_PRESETS.signUp.per,
142
+ keyBy: AUTH_RATE_PRESETS.signUp.keyBy,
143
+ description: "Auth sign-up rate limit",
144
+ }),
145
+ gate.rate({
146
+ max: AUTH_RATE_PRESETS.refresh.max,
147
+ per: AUTH_RATE_PRESETS.refresh.per,
148
+ keyBy: AUTH_RATE_PRESETS.refresh.keyBy,
149
+ description: "Auth refresh rate limit",
150
+ }),
151
+ ];
152
+ }
153
+
154
+ /**
155
+ * Policy gates contributed by Gate auth (session + optional fresh).
156
+ *
157
+ * @param config - Resolved auth
158
+ */
159
+ export function createAuthPolicyGates(config: ResolvedGateAuth): GateDecl[] {
160
+ return [AUTH_SESSION_GATE, createFreshSessionGate(config.session.freshAgeMs)];
161
+ }
162
+
163
+ /**
164
+ * Materialize auth HTTP Bindings for the same `adopted` / router / posture path.
165
+ *
166
+ * @param config - Resolved gate.auth
167
+ * @param options - Rate-limit enable + optional shared stores
168
+ */
169
+ export function createAuthHttpBindings(
170
+ config: ResolvedGateAuth,
171
+ options: {
172
+ readonly rateLimitEnabled?: boolean;
173
+ readonly sessions?: SessionStore;
174
+ readonly identities?: IdentityStore;
175
+ } = {},
176
+ ): AuthHttpMaterialization {
177
+ const sessions = options.sessions ?? config.sessions ?? createSessionStore();
178
+ const identities = options.identities ?? createIdentityStore();
179
+ const loginAttempts = createLoginAttemptBag();
180
+ const rateGates = createAuthRateGates(options.rateLimitEnabled !== false);
181
+ const policyGates = createAuthPolicyGates(config);
182
+ const authGates = [...policyGates, ...rateGates];
183
+ const signInRate = rateGates[0];
184
+ const signUpRate = rateGates[1];
185
+ const refreshRate = rateGates[2];
186
+
187
+ const ctx: AuthRuntimeContext = {
188
+ config,
189
+ sessions,
190
+ identities,
191
+ loginAttempts,
192
+ authGates,
193
+ now: () => (config.now ?? (() => Date.now()))(),
194
+ sessionCrypto: () => ({
195
+ secret: config.secret,
196
+ now: () => ctx.now(),
197
+ accessTtlMs: config.session.accessTtlMs,
198
+ refreshTtlMs: config.session.refreshTtlMs,
199
+ idleTtlMs: config.session.idleTtlMs,
200
+ absoluteTtlMs: config.session.absoluteTtlMs,
201
+ singleSessionPerUser: config.session.singleSessionPerUser,
202
+ audience: config.audience,
203
+ }),
204
+ };
205
+
206
+ const base = config.basePath;
207
+
208
+ const refresh = flow({
209
+ name: "auth.refresh",
210
+ unit: "auth",
211
+ plane: "user",
212
+ in: RefreshIn,
213
+ out: SessionTokensOut,
214
+ errors: { AuthFailed, AuthRateLimited },
215
+ do: async (input) => {
216
+ try {
217
+ const issued = await rotateRefresh(sessions, ctx.sessionCrypto(), input.refreshToken);
218
+ return {
219
+ accessToken: issued.accessToken,
220
+ refreshToken: issued.refreshToken,
221
+ accessExpiresAt: issued.accessExpiresAt,
222
+ userId: issued.session.principalId,
223
+ };
224
+ } catch (err) {
225
+ if (err instanceof SessionError) {
226
+ // Enumeration-safe: same error shape for unknown / reused / expired.
227
+ return fail("AuthFailed", { reason: "invalid_refresh" });
228
+ }
229
+ throw err;
230
+ }
231
+ },
232
+ });
233
+
234
+ const revoke = flow({
235
+ name: "auth.revoke",
236
+ unit: "auth",
237
+ plane: "user",
238
+ in: RevokeIn,
239
+ out: z.object({ ok: z.literal(true) }),
240
+ errors: { AuthFailed },
241
+ do: async (input) => {
242
+ if (input.refreshToken) {
243
+ try {
244
+ const hash = await hashRefresh(input.refreshToken);
245
+ const row = [...sessions.refresh.values()].find((r) => r.hash === hash);
246
+ if (row) revokeFamily(sessions, row.familyId, ctx.now());
247
+ } catch {
248
+ /* ignore — revoke is idempotent */
249
+ }
250
+ }
251
+ return { ok: true as const };
252
+ },
253
+ });
254
+
255
+ const me = flow({
256
+ name: "auth.me",
257
+ unit: "auth",
258
+ plane: "user",
259
+ out: MeOut,
260
+ errors: { AuthFailed },
261
+ do: (_input, fx) => {
262
+ const userId = fx.auth.userId;
263
+ if (!userId) return fail("AuthFailed", { reason: "unauthenticated" });
264
+ const user = getUserById(identities, userId);
265
+ if (!user) return fail("AuthFailed", { reason: "unauthenticated" });
266
+ return {
267
+ userId: user.id,
268
+ email: user.email,
269
+ name: user.name,
270
+ emailVerified: user.emailVerified,
271
+ };
272
+ },
273
+ });
274
+
275
+ const flows: {
276
+ refresh: AnyFlowDef;
277
+ revoke: AnyFlowDef;
278
+ me: AnyFlowDef;
279
+ signInEmail?: AnyFlowDef;
280
+ signUpEmail?: AnyFlowDef;
281
+ } = { refresh, revoke, me };
282
+ const bindings: Binding[] = [
283
+ bindAuthHttp(
284
+ http.post(`${base}/refresh`).gate(gate.public, ...(refreshRate ? [refreshRate] : [])),
285
+ refresh,
286
+ ),
287
+ bindAuthHttp(http.post(`${base}/revoke`).gate(gate.public), revoke),
288
+ bindAuthHttp(http.get(`${base}/me`).gate(AUTH_SESSION_GATE), me),
289
+ ];
290
+
291
+ if (config.emailAndPassword.enabled) {
292
+ const signInEmail = flow({
293
+ name: "auth.signInEmail",
294
+ unit: "auth",
295
+ plane: "user",
296
+ in: EmailPasswordIn,
297
+ out: SessionTokensOut,
298
+ errors: { AuthFailed, AuthRateLimited },
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: "too many sign-in attempts; retry later",
309
+ });
310
+ }
311
+ const user = await authenticateUser(identities, input.email, input.password);
312
+ // Enumeration hygiene: identical failure for unknown email / bad password.
313
+ if (!user) return fail("AuthFailed", { reason: "invalid_credentials" });
314
+ if (config.emailAndPassword.requireEmailVerification && !user.emailVerified) {
315
+ return fail("AuthFailed", { reason: "invalid_credentials" });
316
+ }
317
+ const issued = await issueSessionWithScopes(sessions, ctx.sessionCrypto(), {
318
+ id: user.id,
319
+ plane: "user",
320
+ scopes: [],
321
+ });
322
+ return {
323
+ accessToken: issued.accessToken,
324
+ refreshToken: issued.refreshToken,
325
+ accessExpiresAt: issued.accessExpiresAt,
326
+ userId: user.id,
327
+ };
328
+ },
329
+ });
330
+
331
+ const signUpEmail = flow({
332
+ name: "auth.signUpEmail",
333
+ unit: "auth",
334
+ plane: "user",
335
+ in: EmailPasswordIn,
336
+ out: SessionTokensOut,
337
+ errors: { AuthFailed, AuthRateLimited },
338
+ do: async (input) => {
339
+ try {
340
+ const user = await createUserWithPassword(identities, {
341
+ email: input.email,
342
+ password: input.password,
343
+ name: input.name,
344
+ passwordPolicy: config.passwordPolicy,
345
+ passwordHash: config.password,
346
+ breachCheck: config.breachCheck,
347
+ now: () => ctx.now(),
348
+ });
349
+ if (config.hooks?.user?.create?.after) {
350
+ await config.hooks.user.create.after({
351
+ id: user.id,
352
+ email: user.email,
353
+ name: user.name,
354
+ });
355
+ }
356
+ const issued = await issueSessionWithScopes(sessions, ctx.sessionCrypto(), {
357
+ id: user.id,
358
+ plane: "user",
359
+ scopes: [],
360
+ });
361
+ return {
362
+ accessToken: issued.accessToken,
363
+ refreshToken: issued.refreshToken,
364
+ accessExpiresAt: issued.accessExpiresAt,
365
+ userId: user.id,
366
+ };
367
+ } catch (err) {
368
+ if (err instanceof PasswordPolicyError) {
369
+ return fail("AuthFailed", {
370
+ reason: "password_policy",
371
+ reasons: [...err.reasons],
372
+ });
373
+ }
374
+ if (err instanceof BreachCheckError) {
375
+ return fail("AuthFailed", { reason: "password_breached" });
376
+ }
377
+ if (err instanceof IdentityError) {
378
+ // Enumeration-safe on email_taken — same shape as generic failure.
379
+ if (err.code === "email_taken") {
380
+ return fail("AuthFailed", { reason: "invalid_credentials" });
381
+ }
382
+ return fail("AuthFailed", { reason: err.code });
383
+ }
384
+ throw err;
385
+ }
386
+ },
387
+ });
388
+
389
+ flows.signInEmail = signInEmail;
390
+ flows.signUpEmail = signUpEmail;
391
+ bindings.push(
392
+ bindAuthHttp(
393
+ http.post(`${base}/sign-in/email`).gate(gate.public, ...(signInRate ? [signInRate] : [])),
394
+ signInEmail,
395
+ ),
396
+ bindAuthHttp(
397
+ http.post(`${base}/sign-up/email`).gate(gate.public, ...(signUpRate ? [signUpRate] : [])),
398
+ signUpEmail,
399
+ ),
400
+ );
401
+ }
402
+
403
+ return { bindings, flows, ctx, authGates };
404
+ }
405
+
406
+ /**
407
+ * Whether a session is still within the fresh-age window.
408
+ *
409
+ * @param createdAt - Session creation epoch-ms
410
+ * @param freshAgeMs - Fresh window
411
+ * @param now - Clock
412
+ */
413
+ export function isSessionFresh(
414
+ createdAt: number,
415
+ freshAgeMs: number,
416
+ now: number = Date.now(),
417
+ ): boolean {
418
+ return now - createdAt <= freshAgeMs;
419
+ }
420
+
421
+ /**
422
+ * Bind without touching the global `on` registry.
423
+ *
424
+ * @param trigger - HTTP trigger (must already carry posture)
425
+ * @param flowDef - Flow
426
+ */
427
+ export function bindAuthHttp(trigger: HttpTrigger, flowDef: AnyFlowDef): Binding {
428
+ const withPosture = trigger.gates.length > 0 ? trigger : trigger.gate(gate.public);
429
+ const normalized = normalizeTrigger(withPosture);
430
+ const list = flowDef.triggers as Trigger[];
431
+ list.push(normalized);
432
+ (flowDef as { $trigger: Trigger }).$trigger = normalized;
433
+ return { trigger: normalized, flow: flowDef };
434
+ }
435
+
436
+ async function hashRefresh(raw: string): Promise<string> {
437
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(raw));
438
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
439
+ }