okengine 0.14.1 → 0.16.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 (285) hide show
  1. package/package.json +3 -4
  2. package/site/content/docs/elements/ai.mdx +2 -1
  3. package/site/content/docs/elements/channel.mdx +1 -0
  4. package/site/content/docs/elements/clock.mdx +3 -0
  5. package/site/content/docs/elements/flow.mdx +9 -0
  6. package/site/content/docs/elements/gate.mdx +80 -22
  7. package/site/content/docs/elements/signal.mdx +4 -4
  8. package/site/content/docs/elements/store.mdx +84 -27
  9. package/site/content/docs/elements/vault.mdx +4 -0
  10. package/site/content/docs/get-started/basic-usage.mdx +22 -13
  11. package/site/content/docs/get-started/index.mdx +5 -0
  12. package/site/content/docs/get-started/introduction.mdx +11 -3
  13. package/site/content/docs/get-started/meta.json +1 -1
  14. package/site/content/docs/get-started/project-structure.mdx +932 -0
  15. package/site/content/docs/reference/cli.md +13 -10
  16. package/site/content/docs/reference/client.mdx +28 -21
  17. package/site/content/docs/reference/fx.mdx +29 -18
  18. package/site/content/docs/reference/security.md +2 -2
  19. package/src/auth/api-key-sql.test.ts +47 -0
  20. package/src/auth/api-key-sql.ts +254 -0
  21. package/src/auth/api-keys.ts +273 -14
  22. package/src/auth/auth.test.ts +56 -0
  23. package/src/auth/bindings.ts +14 -5
  24. package/src/auth/config.ts +5 -5
  25. package/src/auth/cross-plane.ts +10 -3
  26. package/src/auth/index.ts +25 -1
  27. package/src/auth/schema.ts +8 -0
  28. package/src/cli/ai-setup/ai-setup.test.ts +62 -0
  29. package/src/cli/ai-setup/apply.ts +32 -1
  30. package/src/cli/attach-host-console.test.ts +40 -2
  31. package/src/cli/attach-host-console.ts +9 -0
  32. package/src/cli/build.test.ts +4 -2
  33. package/src/cli/db-seed.ts +48 -15
  34. package/src/cli/db.test.ts +104 -0
  35. package/src/cli/db.ts +122 -10
  36. package/src/cli/dev-controls.test.ts +20 -0
  37. package/src/cli/dev-controls.ts +13 -0
  38. package/src/cli/dev.test.ts +15 -4
  39. package/src/cli/dev.ts +102 -29
  40. package/src/cli/ensure-drizzle-config.ts +2 -1
  41. package/src/cli/tui/DevLive.tsx +12 -2
  42. package/src/cli/tui/keys.test.ts +2 -1
  43. package/src/cli/tui/keys.ts +3 -2
  44. package/src/compiler/aot.test.ts +1 -1
  45. package/src/compiler/effects-infer.ts +17 -2
  46. package/src/compiler/extract.test.ts +409 -33
  47. package/src/compiler/extract.ts +318 -67
  48. package/src/compiler/flow-path.test.ts +96 -0
  49. package/src/compiler/flow-path.ts +173 -0
  50. package/src/compiler/generate-adopt.test.ts +112 -3
  51. package/src/compiler/generate-adopt.ts +280 -26
  52. package/src/compiler/schema-from-ast.ts +609 -0
  53. package/src/console/server/access.test.ts +116 -8
  54. package/src/console/server/access.ts +70 -23
  55. package/src/console/server/app.ts +1 -0
  56. package/src/console/server/bind.ts +2 -2
  57. package/src/console/server/console.test.ts +3 -0
  58. package/src/console/server/dev-identities.test.ts +127 -0
  59. package/src/console/server/dev-identities.ts +229 -0
  60. package/src/console/server/flows-invoke.test.ts +21 -4
  61. package/src/console/server/flows.ts +233 -98
  62. package/src/console/server/gates.test.ts +24 -0
  63. package/src/console/server/gates.ts +8 -21
  64. package/src/console/server/index.ts +1 -0
  65. package/src/console/server/invoke-user-flow.test.ts +3 -1
  66. package/src/console/server/invoke-user-flow.ts +17 -2
  67. package/src/console/server/iso-at.test.ts +9 -0
  68. package/src/console/server/iso-at.ts +11 -0
  69. package/src/console/server/live.test.ts +26 -1
  70. package/src/console/server/live.ts +4 -2
  71. package/src/console/server/operator-db.ts +2 -0
  72. package/src/console/server/security-headers.ts +6 -3
  73. package/src/console/server/spa-proxy.test.ts +1 -0
  74. package/src/console/server/sql-catalog.ts +147 -7
  75. package/src/console/server/sql-rls.test.ts +16 -0
  76. package/src/console/server/state.ts +37 -55
  77. package/src/console/server/store.test.ts +7 -4
  78. package/src/console/server/store.ts +142 -58
  79. package/src/console/ui-next/dist/assets/access-page-CAGHrA9H.js +4 -0
  80. package/src/console/ui-next/dist/assets/agent-disclosure-BP0Y0Sux.js +1 -0
  81. package/src/console/ui-next/dist/assets/cache-glyph-B5X-NM0-.js +1 -0
  82. package/src/console/ui-next/dist/assets/call-pii-button-ChAUiLo9.js +1 -0
  83. package/src/console/ui-next/dist/assets/collapsible-DGnOM2ph.js +1 -0
  84. package/src/console/ui-next/dist/assets/copy-inline-button-DBHhgHKP.js +1 -0
  85. package/src/console/ui-next/dist/assets/detail-header-DhHM1iaZ.js +1 -0
  86. package/src/console/ui-next/dist/assets/dropdown-menu-_NjTEo5_.js +1 -0
  87. package/src/console/ui-next/dist/assets/{duration-tone-oiRxPVsZ.js → duration-tone-sC3lGABz.js} +1 -1
  88. package/src/console/ui-next/dist/assets/element-icons-BVXtRyd3.js +1 -0
  89. package/src/console/ui-next/dist/assets/explorer-empty-2uIhBu0_.js +1 -0
  90. package/src/console/ui-next/dist/assets/flows-page-B-OUtiAu.js +1 -0
  91. package/src/console/ui-next/dist/assets/{highlighted-json-CvDPvveV.js → highlighted-json-Awq7gYdu.js} +1 -1
  92. package/src/console/ui-next/dist/assets/http-method-_UHM2ODJ.js +1 -0
  93. package/src/console/ui-next/dist/assets/index-CGoZkILK.js +66 -0
  94. package/src/console/ui-next/dist/assets/index-Ck88Jmv8.css +2 -0
  95. package/src/console/ui-next/dist/assets/link-BX6Vqztd.js +1 -0
  96. package/src/console/ui-next/dist/assets/observability-page-BDyXalNR.js +4 -0
  97. package/src/console/ui-next/dist/assets/react-D8E3mtu1.js +1 -0
  98. package/src/console/ui-next/dist/assets/replica-lag-DKRrbvdo.js +18 -0
  99. package/src/console/ui-next/dist/assets/request-meta-DV0ywz7t.js +1 -0
  100. package/src/console/ui-next/dist/assets/shortcut-JQIZlWfm.js +1 -0
  101. package/src/console/ui-next/dist/assets/shortcut-keys-DKxNTe_m.js +1 -0
  102. package/src/console/ui-next/dist/assets/{skeleton-CL_X0GCj.js → skeleton-D-czQJT6.js} +1 -1
  103. package/src/console/ui-next/dist/assets/store-page-Xh8Kn3rx.js +41 -0
  104. package/src/console/ui-next/dist/assets/trace-detail-sheet-B09O8rA6.js +2 -0
  105. package/src/console/ui-next/dist/assets/tree-expand-toggle-DkOXA12R.js +54 -0
  106. package/src/console/ui-next/dist/assets/units-page-Dpk40kOQ.js +1 -0
  107. package/src/console/ui-next/dist/assets/{use-vault-list-Cl79j_ku.js → use-vault-list-uk4WVboC.js} +1 -1
  108. package/src/console/ui-next/dist/assets/useMutation-B8EO02Ej.js +1 -0
  109. package/src/console/ui-next/dist/assets/useRender-BE2A9BWC.js +1 -0
  110. package/src/console/ui-next/dist/assets/vault-page-B1dB9Ft0.js +2 -0
  111. package/src/console/ui-next/dist/index.html +12 -10
  112. package/src/console/ui-next/index.html +3 -3
  113. package/src/console/ui-next/src/client.ts +202 -7
  114. package/src/console/ui-next/src/components/motion/table/index.tsx +16 -0
  115. package/src/console/ui-next/src/components/motion/table/types.ts +4 -0
  116. package/src/console/ui-next/src/components/shell/app-sidebar.tsx +2 -0
  117. package/src/console/ui-next/src/components/shell/command-items.test.ts +1 -0
  118. package/src/console/ui-next/src/components/shell/command-items.ts +8 -0
  119. package/src/console/ui-next/src/components/shell/shell-layout.tsx +2 -1
  120. package/src/console/ui-next/src/components/ui/kbd.tsx +2 -2
  121. package/src/console/ui-next/src/components/ui/sheet-form.tsx +8 -3
  122. package/src/console/ui-next/src/features/access/access-page.tsx +347 -0
  123. package/src/console/ui-next/src/features/access/data/use-access.ts +23 -0
  124. package/src/console/ui-next/src/features/access/detail/access-detail.tsx +552 -0
  125. package/src/console/ui-next/src/features/access/explorer/access-list.tsx +125 -0
  126. package/src/console/ui-next/src/features/access/lib/format-when.test.ts +139 -0
  127. package/src/console/ui-next/src/features/access/lib/format-when.ts +296 -0
  128. package/src/console/ui-next/src/features/access/lib/scope-groups.test.ts +34 -0
  129. package/src/console/ui-next/src/features/access/lib/scope-groups.ts +40 -0
  130. package/src/console/ui-next/src/features/access/sheets/access-allow-fields.tsx +236 -0
  131. package/src/console/ui-next/src/features/access/sheets/access-create-sheet.tsx +316 -0
  132. package/src/console/ui-next/src/features/access/sheets/access-edit-sheet.tsx +240 -0
  133. package/src/console/ui-next/src/features/access/sheets/access-expiry-fields.tsx +66 -0
  134. package/src/console/ui-next/src/features/access/sheets/access-refresh-sheet.tsx +104 -0
  135. package/src/console/ui-next/src/features/access/sheets/access-scope-field.tsx +115 -0
  136. package/src/console/ui-next/src/features/access/sheets/access-secret-sheet.tsx +89 -0
  137. package/src/console/ui-next/src/features/access/sheets/access-sheet-search.tsx +49 -0
  138. package/src/console/ui-next/src/features/access/state/access-selection.ts +84 -0
  139. package/src/console/ui-next/src/features/auth/auth-redirect.test.ts +5 -0
  140. package/src/console/ui-next/src/features/auth/auth-redirect.ts +18 -2
  141. package/src/console/ui-next/src/features/flows/traces/http-method.ts +19 -0
  142. package/src/console/ui-next/src/features/flows/traces/trace-detail.test.ts +19 -1
  143. package/src/console/ui-next/src/features/flows/traces/traces-pane.tsx +10 -3
  144. package/src/console/ui-next/src/features/not-found/not-found-page.tsx +1 -1
  145. package/src/console/ui-next/src/features/store/detail/browse-section.tsx +45 -1
  146. package/src/console/ui-next/src/features/store/detail/resource-panel.tsx +78 -16
  147. package/src/console/ui-next/src/features/store/detail/store-row-detail-sheet.tsx +155 -14
  148. package/src/console/ui-next/src/features/store/explorer/store-tree.tsx +33 -133
  149. package/src/console/ui-next/src/features/store/files/file-preview.tsx +4 -1
  150. package/src/console/ui-next/src/features/store/grid/rls-policy-sheet.tsx +326 -165
  151. package/src/console/ui-next/src/features/store/grid/sql-insert-sheet.tsx +6 -0
  152. package/src/console/ui-next/src/features/store/grid/store-data-grid.tsx +50 -3
  153. package/src/console/ui-next/src/features/store/lib/files-meta.test.ts +3 -0
  154. package/src/console/ui-next/src/features/store/lib/files-meta.ts +12 -0
  155. package/src/console/ui-next/src/features/store/lib/query-defaults.test.ts +25 -0
  156. package/src/console/ui-next/src/features/store/lib/query-defaults.ts +85 -3
  157. package/src/console/ui-next/src/features/store/lib/query-gate.ts +30 -4
  158. package/src/console/ui-next/src/features/store/lib/rls-policy.test.ts +139 -0
  159. package/src/console/ui-next/src/features/store/lib/rls-policy.ts +223 -21
  160. package/src/console/ui-next/src/features/store/lib/schema-graph.ts +4 -0
  161. package/src/console/ui-next/src/features/store/lib/sql-catalog.test.ts +24 -0
  162. package/src/console/ui-next/src/features/store/lib/sql-catalog.ts +19 -0
  163. package/src/console/ui-next/src/features/store/lib/store-tree.test.ts +6 -0
  164. package/src/console/ui-next/src/features/store/lib/store-tree.ts +13 -0
  165. package/src/console/ui-next/src/features/store/query/query-console.tsx +48 -14
  166. package/src/console/ui-next/src/features/store/query/query-gate-menu.tsx +19 -20
  167. package/src/console/ui-next/src/features/store/query/query-gate-parts.tsx +2 -1
  168. package/src/console/ui-next/src/features/store/query/query-results.tsx +110 -83
  169. package/src/console/ui-next/src/features/store/schema/schema-table-node.tsx +17 -0
  170. package/src/console/ui-next/src/features/units/call/call-api-panel.tsx +112 -72
  171. package/src/console/ui-next/src/features/units/lib/contract-input.test.ts +206 -1
  172. package/src/console/ui-next/src/features/units/lib/contract-input.ts +55 -0
  173. package/src/console/ui-next/src/features/vault/lib/types.ts +1 -1
  174. package/src/console/ui-next/src/lib/document-meta.test.ts +1 -0
  175. package/src/console/ui-next/src/lib/document-meta.ts +2 -1
  176. package/src/console/ui-next/src/lib/last-module-search.test.ts +1 -0
  177. package/src/console/ui-next/src/lib/last-module-search.ts +2 -1
  178. package/src/console/ui-next/src/lib/shortcut.test.ts +2 -0
  179. package/src/console/ui-next/src/lib/shortcut.ts +3 -0
  180. package/src/console/ui-next/src/router.tsx +10 -1
  181. package/src/console/ui-next/ui-next-seed-app-schema.ts +88 -37
  182. package/src/console/ui-next/ui-next-seed-store.ts +43 -15
  183. package/src/console/xss.gate.test.ts +1 -0
  184. package/src/docker/compose-health.test.ts +27 -0
  185. package/src/docker/compose-health.ts +19 -3
  186. package/src/docker/docker.test.ts +3 -0
  187. package/src/docker/recipes/pgdog.ts +2 -0
  188. package/src/drivers/clock-postgres.ts +13 -9
  189. package/src/drivers/instances-postgres.ts +4 -6
  190. package/src/drivers/journal-postgres.ts +13 -9
  191. package/src/drivers/pg-rls.test.ts +87 -0
  192. package/src/drivers/pg-rls.ts +233 -7
  193. package/src/drivers/pglite.ts +26 -3
  194. package/src/drivers/postgres.test.ts +23 -0
  195. package/src/drivers/postgres.ts +133 -9
  196. package/src/drivers/types.ts +10 -0
  197. package/src/elements/clock/declare.ts +26 -1
  198. package/src/elements/gate/boot.ts +5 -5
  199. package/src/elements/gate/declare.ts +38 -4
  200. package/src/elements/gate/flatten.ts +1 -1
  201. package/src/elements/gate/runtime.ts +1 -1
  202. package/src/elements/store/cache.ts +1 -1
  203. package/src/elements/store/declare.ts +9 -6
  204. package/src/elements/store/emit-drizzle.ts +44 -8
  205. package/src/elements/store/resource.test.ts +1 -1
  206. package/src/elements/store/rls-identity.test.ts +95 -0
  207. package/src/elements/store/rls-identity.ts +116 -0
  208. package/src/elements/store/runtime.ts +18 -0
  209. package/src/elements/store/schema-decl.test.ts +37 -0
  210. package/src/elements/store/schema-decl.ts +260 -41
  211. package/src/elements/store/seed.ts +1 -1
  212. package/src/elements/store/sql-condition.test.ts +12 -0
  213. package/src/elements/store/sql-condition.ts +26 -2
  214. package/src/elements/store/sql-rls-isolation.test.ts +165 -0
  215. package/src/elements/store/sql-rls-stamp.test.ts +117 -0
  216. package/src/elements/store/sql-select.types.test.ts +71 -0
  217. package/src/elements/store/sql-session.ts +135 -35
  218. package/src/elements/store/upsert-app.test.ts +1 -1
  219. package/src/elements/store.ts +17 -0
  220. package/src/http.ts +20 -3
  221. package/src/index.ts +11 -1
  222. package/src/kernel/adopt-barrel-fresh.test.ts +1 -1
  223. package/src/kernel/adopt-routes.ts +30 -2
  224. package/src/kernel/api-key-host-persist.test.ts +143 -0
  225. package/src/kernel/app.ts +145 -16
  226. package/src/kernel/auth-resolve.ts +10 -5
  227. package/src/kernel/auto-cache.test.ts +8 -8
  228. package/src/kernel/auto-registry.test.ts +49 -9
  229. package/src/kernel/boot.test.ts +5 -5
  230. package/src/kernel/boot.ts +2 -2
  231. package/src/kernel/effects-stamping.test.ts +3 -3
  232. package/src/kernel/element-registries.ts +12 -5
  233. package/src/kernel/errors.ts +24 -0
  234. package/src/kernel/flow-units.ts +64 -0
  235. package/src/kernel/flow.ts +29 -17
  236. package/src/kernel/fx-auth-keys.test.ts +88 -0
  237. package/src/kernel/fx-auth-keys.ts +198 -0
  238. package/src/kernel/fx.test.ts +17 -1
  239. package/src/kernel/fx.ts +59 -18
  240. package/src/kernel/horizontal-child.ts +4 -4
  241. package/src/kernel/http-path-pending.ts +20 -0
  242. package/src/kernel/http-resource.ts +4 -0
  243. package/src/kernel/http-stream.test.ts +4 -4
  244. package/src/kernel/pipeline-api-key.test.ts +192 -7
  245. package/src/kernel/pipeline.test.ts +2 -2
  246. package/src/kernel/pipeline.ts +4 -2
  247. package/src/kernel/plugin-elements.test.ts +1 -1
  248. package/src/kernel/plugin-needs.test.ts +1 -1
  249. package/src/kernel/ready.test.ts +2 -2
  250. package/src/kernel/resource-mount.test.ts +14 -0
  251. package/src/kernel/run-duration.test.ts +2 -2
  252. package/src/kernel/stamp-http.test.ts +79 -0
  253. package/src/kernel/stamp-http.ts +75 -0
  254. package/src/kernel/triggers.ts +52 -27
  255. package/src/manifest/types.ts +22 -3
  256. package/src/release/build-lib.ts +1 -0
  257. package/src/release/measure.ts +2 -2
  258. package/src/runtime/json-code-block.test.ts +2 -2
  259. package/src/runtime/serve.test.ts +4 -4
  260. package/src/term.test.ts +52 -4
  261. package/src/term.ts +125 -17
  262. package/src/test/reset-element-registries.ts +8 -3
  263. package/src/cli/ask-seed.test.ts +0 -96
  264. package/src/cli/ask-seed.ts +0 -82
  265. package/src/console/ui-next/dist/assets/cache-glyph-BanhLsEY.js +0 -1
  266. package/src/console/ui-next/dist/assets/call-pii-button-bqkxMrJH.js +0 -1
  267. package/src/console/ui-next/dist/assets/collapsible-DYb0xU8C.js +0 -1
  268. package/src/console/ui-next/dist/assets/confirm-sheet-DDCRmG62.js +0 -1
  269. package/src/console/ui-next/dist/assets/explorer-empty-HjTnVQoR.js +0 -1
  270. package/src/console/ui-next/dist/assets/flows-page-DxDsOd4f.js +0 -1
  271. package/src/console/ui-next/dist/assets/http-method-CJCBYL2j.js +0 -1
  272. package/src/console/ui-next/dist/assets/index-Ce6WKWKM.js +0 -66
  273. package/src/console/ui-next/dist/assets/index-D4Ldtj79.css +0 -2
  274. package/src/console/ui-next/dist/assets/link-DF7SZ9Ek.js +0 -1
  275. package/src/console/ui-next/dist/assets/observability-page-BEZDzyYh.js +0 -4
  276. package/src/console/ui-next/dist/assets/query-gate-parts-1m8m1iNp.js +0 -2
  277. package/src/console/ui-next/dist/assets/react-B1ML8gxg.js +0 -1
  278. package/src/console/ui-next/dist/assets/replica-lag-C8_BRt2x.js +0 -18
  279. package/src/console/ui-next/dist/assets/sheet-form-D-ata7jy.js +0 -1
  280. package/src/console/ui-next/dist/assets/shortcut-Cgzuv4k1.js +0 -1
  281. package/src/console/ui-next/dist/assets/shortcut-keys-DO4IsVqv.js +0 -1
  282. package/src/console/ui-next/dist/assets/store-page-v3LXdYpr.js +0 -45
  283. package/src/console/ui-next/dist/assets/trace-detail-sheet-DFLFfUUX.js +0 -2
  284. package/src/console/ui-next/dist/assets/units-page-C0gW6Kdo.js +0 -1
  285. package/src/console/ui-next/dist/assets/vault-page-DuKzqwzW.js +0 -2
@@ -3,14 +3,23 @@
3
3
  *
4
4
  * A key can never exceed the permissions of whoever created it.
5
5
  * The raw secret is returned exactly once on create / rotate.
6
+ * Hash is HMAC-SHA-256 with the auth pepper (gate.auth.secret).
6
7
  *
7
8
  * @see docs/spec/console.md §3.3 · §9.14
8
9
  */
9
10
 
11
+ import { lookup as dnsLookup } from "node:dns/promises";
12
+ import { parseDurationMs } from "../elements/clock/duration.ts";
10
13
  import { assertAttenuated } from "./attenuation.ts";
11
14
  import type { AuthPlane } from "./planes.ts";
12
15
  import type { ApiKeyRow } from "./tables.ts";
13
16
 
17
+ /** Capability / Manifest resource for `fx.auth` key methods. */
18
+ export const AUTH_API_KEYS_RESOURCE = "auth:api-keys";
19
+
20
+ /** Default HMAC pepper when no `gate.auth.secret` is bound (tests). */
21
+ const DEFAULT_PEPPER = "oke.api-key";
22
+
14
23
  /** Options when creating an API key. */
15
24
  export interface CreateApiKeyOptions {
16
25
  readonly plane: AuthPlane;
@@ -27,6 +36,19 @@ export interface CreateApiKeyOptions {
27
36
  readonly secret?: string;
28
37
  readonly hash?: string;
29
38
  readonly now?: () => number;
39
+ /** HMAC pepper override (defaults to store pepper). */
40
+ readonly pepper?: string;
41
+ }
42
+
43
+ /** Patch for {@link updateApiKey}. */
44
+ export interface UpdateApiKeyOptions {
45
+ readonly name?: string;
46
+ readonly scopes?: readonly string[];
47
+ readonly expiresAt?: number | null;
48
+ readonly rateLimit?: { max: number; per: string } | null;
49
+ readonly ipAllowlist?: readonly string[];
50
+ /** Ceiling for scope changes (stored creatorScopes ∩ live scopes). */
51
+ readonly ceiling: ReadonlySet<string> | Iterable<string>;
30
52
  }
31
53
 
32
54
  /** Result of key creation — secret shown exactly once. */
@@ -36,27 +58,76 @@ export interface CreatedApiKey {
36
58
  readonly secret: string;
37
59
  }
38
60
 
39
- /** In-memory API key store. */
61
+ /** Public key row returned by `fx.auth.listApiKeys` (never includes hash). */
62
+ export interface ApiKeyPublicRow {
63
+ readonly id: string;
64
+ readonly name: string;
65
+ readonly plane: AuthPlane;
66
+ readonly scopes: readonly string[];
67
+ readonly createdAt: number;
68
+ readonly lastUsedAt: number | null;
69
+ readonly expiresAt: number | null;
70
+ readonly revokedAt: number | null;
71
+ readonly rateLimit: { max: number; per: string } | null;
72
+ readonly ipAllowlist: readonly string[];
73
+ }
74
+
75
+ /**
76
+ * API key store — in-memory Map, optionally HMAC-peppered.
77
+ * SQL persist is a write-through adapter bound at boot.
78
+ */
40
79
  export interface ApiKeyStore {
41
80
  keys: Map<string, ApiKeyRow>;
81
+ /** HMAC pepper (`gate.auth.secret`). */
82
+ pepper?: string;
83
+ /** Optional write-through persist (SQL). */
84
+ persist?: (row: ApiKeyRow) => void | Promise<void>;
85
+ /** Sliding-window hits for per-key rateLimit (subject = key id). */
86
+ rateHits?: Map<string, number[]>;
87
+ }
88
+
89
+ /** Options for {@link createApiKeyStore}. */
90
+ export interface CreateApiKeyStoreOptions {
91
+ readonly pepper?: string;
92
+ readonly persist?: (row: ApiKeyRow) => void | Promise<void>;
42
93
  }
43
94
 
44
95
  /**
45
96
  * Create an empty API key store.
97
+ *
98
+ * @param options - Optional HMAC pepper / persist hook
46
99
  */
47
- export function createApiKeyStore(): ApiKeyStore {
48
- return { keys: new Map() };
100
+ export function createApiKeyStore(options: CreateApiKeyStoreOptions = {}): ApiKeyStore {
101
+ return {
102
+ keys: new Map(),
103
+ ...(options.pepper !== undefined ? { pepper: options.pepper } : {}),
104
+ ...(options.persist !== undefined ? { persist: options.persist } : {}),
105
+ };
49
106
  }
50
107
 
51
108
  /**
52
- * Hash an API key secret (SHA-256 hex).
109
+ * HMAC-SHA-256 hex of an API key secret.
53
110
  *
54
111
  * @param secret - Raw secret
112
+ * @param pepper - HMAC key (`gate.auth.secret`); defaults to a test pepper
55
113
  */
56
- export async function hashApiKeySecret(secret: string): Promise<string> {
57
- const bytes = new TextEncoder().encode(secret);
58
- const digest = await crypto.subtle.digest("SHA-256", bytes);
59
- return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
114
+ export async function hashApiKeySecret(
115
+ secret: string,
116
+ pepper: string = DEFAULT_PEPPER,
117
+ ): Promise<string> {
118
+ const key = await crypto.subtle.importKey(
119
+ "raw",
120
+ new TextEncoder().encode(pepper),
121
+ { name: "HMAC", hash: "SHA-256" },
122
+ false,
123
+ ["sign"],
124
+ );
125
+ const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(secret));
126
+ return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
127
+ }
128
+
129
+ async function persistRow(store: ApiKeyStore, row: ApiKeyRow): Promise<void> {
130
+ await store.persist?.(row);
60
131
  }
61
132
 
62
133
  /**
@@ -74,7 +145,8 @@ export async function createApiKey(
74
145
  const now = options.now ?? (() => Date.now());
75
146
  const id = options.id ?? crypto.randomUUID();
76
147
  const secret = options.secret ?? `oke_${id.replace(/-/g, "")}_${randomSecret()}`;
77
- const hash = options.hash ?? (await hashApiKeySecret(secret));
148
+ const pepper = options.pepper ?? store.pepper ?? DEFAULT_PEPPER;
149
+ const hash = options.hash ?? (await hashApiKeySecret(secret, pepper));
78
150
  const creatorScopes = [...options.creatorScopes];
79
151
 
80
152
  const row: ApiKeyRow = {
@@ -93,32 +165,77 @@ export async function createApiKey(
93
165
  revokedAt: null,
94
166
  };
95
167
  store.keys.set(id, row);
168
+ await persistRow(store, row);
96
169
  return { row, secret };
97
170
  }
98
171
 
172
+ /** Resolve a stored hostname to addresses (injected in tests). */
173
+ export type AllowlistLookup = (host: string) => Promise<readonly string[]>;
174
+
175
+ /** Options for {@link authenticateApiKey}. */
176
+ export interface AuthenticateApiKeyOptions {
177
+ readonly now?: () => number;
178
+ /** Client IP for allowlist enforcement. */
179
+ readonly ip?: string;
180
+ /** Hostname → A/AAAA (defaults to system DNS). */
181
+ readonly lookup?: AllowlistLookup;
182
+ }
183
+
99
184
  /**
100
- * Resolve a key by raw secret; enforces expiry and revocation.
185
+ * Resolve a key by raw secret; enforces expiry, revocation, and allowlist.
101
186
  *
102
187
  * @param store - Key store
103
188
  * @param secret - Raw secret
104
- * @param now - Clock
189
+ * @param nowOrOptions - Clock, or clock + client IP
105
190
  */
106
191
  export async function authenticateApiKey(
107
192
  store: ApiKeyStore,
108
193
  secret: string,
109
- now: () => number = () => Date.now(),
194
+ nowOrOptions: (() => number) | AuthenticateApiKeyOptions = () => Date.now(),
110
195
  ): Promise<ApiKeyRow | null> {
111
- const hash = await hashApiKeySecret(secret);
196
+ const options: AuthenticateApiKeyOptions =
197
+ typeof nowOrOptions === "function" ? { now: nowOrOptions } : nowOrOptions;
198
+ const now = options.now ?? (() => Date.now());
199
+ const pepper = store.pepper ?? DEFAULT_PEPPER;
200
+ const hash = await hashApiKeySecret(secret, pepper);
112
201
  for (const row of store.keys.values()) {
113
202
  if (row.hash !== hash) continue;
114
203
  if (row.revokedAt !== null) return null;
115
204
  if (row.expiresAt !== null && row.expiresAt <= now()) return null;
205
+ if (row.ipAllowlist.length > 0) {
206
+ const allowed = await allowlistAllowsIp(row.ipAllowlist, options.ip, options.lookup);
207
+ if (!allowed) return null;
208
+ }
209
+ if (row.rateLimit && !takeKeyRate(store, row.id, row.rateLimit, now())) return null;
116
210
  row.lastUsedAt = now();
211
+ await persistRow(store, row);
117
212
  return row;
118
213
  }
119
214
  return null;
120
215
  }
121
216
 
217
+ /**
218
+ * List keys created by `creatorId` (includes revoked rows for audit).
219
+ *
220
+ * @param store - Key store
221
+ * @param creatorId - Issuer id
222
+ */
223
+ export function listApiKeys(store: ApiKeyStore, creatorId: string): ApiKeyRow[] {
224
+ return [...store.keys.values()]
225
+ .filter((row) => row.creatorId === creatorId)
226
+ .sort((a, b) => a.name.localeCompare(b.name));
227
+ }
228
+
229
+ /**
230
+ * Load one key by id.
231
+ *
232
+ * @param store - Key store
233
+ * @param id - Key id
234
+ */
235
+ export function getApiKey(store: ApiKeyStore, id: string): ApiKeyRow | undefined {
236
+ return store.keys.get(id);
237
+ }
238
+
122
239
  /**
123
240
  * Revoke an API key — irreversible. The row remains for audit / hygiene.
124
241
  *
@@ -134,6 +251,7 @@ export function revokeApiKey(
134
251
  const row = store.keys.get(id);
135
252
  if (!row || row.revokedAt !== null) return null;
136
253
  row.revokedAt = now();
254
+ void persistRow(store, row);
137
255
  return row;
138
256
  }
139
257
 
@@ -150,15 +268,156 @@ export async function rotateApiKey(
150
268
  options: {
151
269
  readonly secret?: string;
152
270
  readonly now?: () => number;
271
+ readonly pepper?: string;
153
272
  } = {},
154
273
  ): Promise<CreatedApiKey | null> {
155
274
  const row = store.keys.get(id);
156
275
  if (!row || row.revokedAt !== null) return null;
157
276
  const secret = options.secret ?? `oke_${id.replace(/-/g, "")}_${randomSecret()}`;
158
- row.hash = await hashApiKeySecret(secret);
277
+ const pepper = options.pepper ?? store.pepper ?? DEFAULT_PEPPER;
278
+ row.hash = await hashApiKeySecret(secret, pepper);
279
+ await persistRow(store, row);
159
280
  return { row, secret };
160
281
  }
161
282
 
283
+ /**
284
+ * Update key metadata. Scope changes re-run {@link assertAttenuated}.
285
+ *
286
+ * @param store - Key store
287
+ * @param id - Key id
288
+ * @param options - Patch + attenuation ceiling
289
+ */
290
+ export function updateApiKey(
291
+ store: ApiKeyStore,
292
+ id: string,
293
+ options: UpdateApiKeyOptions,
294
+ ): ApiKeyRow | null {
295
+ const row = store.keys.get(id);
296
+ if (!row || row.revokedAt !== null) return null;
297
+ if (options.scopes !== undefined) {
298
+ assertAttenuated(options.ceiling, options.scopes, "api key");
299
+ row.scopes = [...options.scopes];
300
+ }
301
+ if (options.name !== undefined) row.name = options.name;
302
+ if (options.expiresAt !== undefined) row.expiresAt = options.expiresAt;
303
+ if (options.rateLimit !== undefined) row.rateLimit = options.rateLimit;
304
+ if (options.ipAllowlist !== undefined) row.ipAllowlist = [...options.ipAllowlist];
305
+ void persistRow(store, row);
306
+ return row;
307
+ }
308
+
309
+ /**
310
+ * Public projection of a key row (no hash / creatorScopes).
311
+ *
312
+ * @param row - Stored row
313
+ */
314
+ export function toApiKeyPublicRow(row: ApiKeyRow): ApiKeyPublicRow {
315
+ return {
316
+ id: row.id,
317
+ name: row.name,
318
+ plane: row.plane,
319
+ scopes: [...row.scopes],
320
+ createdAt: row.createdAt,
321
+ lastUsedAt: row.lastUsedAt,
322
+ expiresAt: row.expiresAt,
323
+ revokedAt: row.revokedAt,
324
+ rateLimit: row.rateLimit,
325
+ ipAllowlist: [...row.ipAllowlist],
326
+ };
327
+ }
328
+
329
+ /**
330
+ * Whether the client IP matches an allowlist entry.
331
+ * Literals compare exactly. Hostnames resolve at verify time (fail closed).
332
+ *
333
+ * @param entries - IPs and/or hostnames
334
+ * @param ip - Client IP
335
+ * @param lookup - Hostname resolver
336
+ */
337
+ export async function allowlistAllowsIp(
338
+ entries: readonly string[],
339
+ ip: string | undefined,
340
+ lookup: AllowlistLookup = lookupAllowlistHost,
341
+ ): Promise<boolean> {
342
+ if (entries.length === 0) return true;
343
+ if (ip === undefined) return false;
344
+ const client = ip.trim().toLowerCase();
345
+ for (const raw of entries) {
346
+ const entry = normalizeAllowlistEntry(raw);
347
+ if (entry.length === 0) continue;
348
+ if (isIpLiteral(entry)) {
349
+ if (entry === client) return true;
350
+ continue;
351
+ }
352
+ try {
353
+ const addrs = await lookup(entry);
354
+ if (addrs.some((addr) => addr.trim().toLowerCase() === client)) return true;
355
+ } catch {
356
+ // This host fails closed; other entries may still match.
357
+ }
358
+ }
359
+ return false;
360
+ }
361
+
362
+ function normalizeAllowlistEntry(raw: string): string {
363
+ let entry = raw.trim().toLowerCase();
364
+ entry = entry.replace(/^https?:\/\//, "");
365
+ const slash = entry.indexOf("/");
366
+ if (slash >= 0) entry = entry.slice(0, slash);
367
+ if (entry.endsWith(".")) entry = entry.slice(0, -1);
368
+ return entry;
369
+ }
370
+
371
+ function isIpLiteral(entry: string): boolean {
372
+ if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(entry)) {
373
+ return entry.split(".").every((octet) => Number(octet) <= 255);
374
+ }
375
+ return entry.includes(":") && /^[0-9a-f:.]+$/.test(entry);
376
+ }
377
+
378
+ async function lookupAllowlistHost(host: string): Promise<readonly string[]> {
379
+ const rows = await dnsLookup(host, { all: true, verbatim: true });
380
+ return rows.map((row) => row.address);
381
+ }
382
+
383
+ /**
384
+ * Client IP from X-Forwarded-For (right-most trusted hop) or X-Real-IP.
385
+ *
386
+ * @param request - Incoming request
387
+ * @param trustedProxyDepth - Hops from the right (default 1)
388
+ */
389
+ export function clientIpFromRequest(request: Request, trustedProxyDepth = 1): string | undefined {
390
+ const xff = request.headers.get("x-forwarded-for");
391
+ if (xff) {
392
+ const hops = xff
393
+ .split(",")
394
+ .map((h) => h.trim())
395
+ .filter((h) => h.length > 0);
396
+ const index = hops.length - trustedProxyDepth;
397
+ if (index >= 0) return hops[index];
398
+ }
399
+ return request.headers.get("x-real-ip")?.trim() || undefined;
400
+ }
401
+
402
+ function takeKeyRate(
403
+ store: ApiKeyStore,
404
+ keyId: string,
405
+ limit: { max: number; per: string },
406
+ nowMs: number,
407
+ ): boolean {
408
+ const windowMs = parseDurationMs(limit.per);
409
+ const hits = store.rateHits ?? (store.rateHits = new Map<string, number[]>());
410
+ const prev = hits.get(keyId) ?? [];
411
+ const kept = prev.filter((t) => nowMs - t < windowMs);
412
+ if (kept.length >= limit.max) {
413
+ hits.set(keyId, kept);
414
+ return false;
415
+ }
416
+ kept.push(nowMs);
417
+ hits.set(keyId, kept);
418
+ return true;
419
+ }
420
+
162
421
  function randomSecret(): string {
163
422
  const bytes = crypto.getRandomValues(new Uint8Array(24));
164
423
  return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
@@ -114,6 +114,62 @@ describe("auth plugin", () => {
114
114
  });
115
115
  });
116
116
 
117
+ describe("API key allowlist", () => {
118
+ test("hostname entry matches the resolved client IP", async () => {
119
+ const { authenticateApiKey } = await import("./api-keys.ts");
120
+ const store = createApiKeyStore();
121
+ const created = await createApiKey(store, {
122
+ plane: "user",
123
+ name: "ci",
124
+ scopes: ["member"],
125
+ creatorId: "u1",
126
+ creatorScopes: ["member"],
127
+ ipAllowlist: ["ci.example.com"],
128
+ });
129
+ const lookup = async (host: string) => (host === "ci.example.com" ? ["203.0.113.10"] : []);
130
+ expect(
131
+ await authenticateApiKey(store, created.secret, {
132
+ ip: "203.0.113.10",
133
+ lookup,
134
+ }),
135
+ ).not.toBeNull();
136
+ expect(
137
+ await authenticateApiKey(store, created.secret, {
138
+ ip: "198.51.100.7",
139
+ lookup,
140
+ }),
141
+ ).toBeNull();
142
+ });
143
+
144
+ test("hostname lookup failure is closed", async () => {
145
+ const { authenticateApiKey } = await import("./api-keys.ts");
146
+ const store = createApiKeyStore();
147
+ const created = await createApiKey(store, {
148
+ plane: "user",
149
+ name: "ci",
150
+ scopes: ["member"],
151
+ creatorId: "u1",
152
+ creatorScopes: ["member"],
153
+ ipAllowlist: ["ci.example.com", "203.0.113.10"],
154
+ });
155
+ const lookup = async () => {
156
+ throw new Error("nxdomain");
157
+ };
158
+ expect(
159
+ await authenticateApiKey(store, created.secret, {
160
+ ip: "203.0.113.10",
161
+ lookup,
162
+ }),
163
+ ).not.toBeNull();
164
+ expect(
165
+ await authenticateApiKey(store, created.secret, {
166
+ ip: "198.51.100.7",
167
+ lookup,
168
+ }),
169
+ ).toBeNull();
170
+ });
171
+ });
172
+
117
173
  describe("API key attenuation", () => {
118
174
  test("key cannot be created with a scope its creator lacks", async () => {
119
175
  const store = createApiKeyStore();
@@ -275,10 +275,13 @@ export function createAuthHttpBindings(
275
275
  } = { refresh, revoke, me };
276
276
  const bindings: Binding[] = [
277
277
  bindAuthHttp(
278
- http.post(`${base}/refresh`).gate.public.gate(...(refreshRate ? [refreshRate] : [])),
278
+ http
279
+ .post(`${base}/refresh`)
280
+ .public()
281
+ .gate(...(refreshRate ? [refreshRate] : [])),
279
282
  refresh,
280
283
  ),
281
- bindAuthHttp(http.post(`${base}/revoke`).gate.public, revoke),
284
+ bindAuthHttp(http.post(`${base}/revoke`).public(), revoke),
282
285
  bindAuthHttp(http.get(`${base}/me`).gate(AUTH_SESSION_GATE), me),
283
286
  ];
284
287
 
@@ -371,11 +374,17 @@ export function createAuthHttpBindings(
371
374
  flows.signUpEmail = signUpEmail;
372
375
  bindings.push(
373
376
  bindAuthHttp(
374
- http.post(`${base}/sign-in/email`).gate.public.gate(...(signInRate ? [signInRate] : [])),
377
+ http
378
+ .post(`${base}/sign-in/email`)
379
+ .public()
380
+ .gate(...(signInRate ? [signInRate] : [])),
375
381
  signInEmail,
376
382
  ),
377
383
  bindAuthHttp(
378
- http.post(`${base}/sign-up/email`).gate.public.gate(...(signUpRate ? [signUpRate] : [])),
384
+ http
385
+ .post(`${base}/sign-up/email`)
386
+ .public()
387
+ .gate(...(signUpRate ? [signUpRate] : [])),
379
388
  signUpEmail,
380
389
  ),
381
390
  );
@@ -406,7 +415,7 @@ export function isSessionFresh(
406
415
  * @param flowDef - Flow
407
416
  */
408
417
  export function bindAuthHttp(trigger: HttpTrigger, flowDef: AnyFlowDef): Binding {
409
- const withPosture = trigger.gates.length > 0 ? trigger : trigger.gate.public;
418
+ const withPosture = trigger.gates.length > 0 ? trigger : trigger.public();
410
419
  const normalized = normalizeTrigger(withPosture);
411
420
  const list = flowDef.triggers as Trigger[];
412
421
  list.push(normalized);
@@ -6,7 +6,7 @@
6
6
 
7
7
  import type { PasswordHashOptions } from "../runtime/types.ts";
8
8
  import type { SessionStore } from "./sessions.ts";
9
- import type { ApiKeyStore } from "./api-keys.ts";
9
+ import { createApiKeyStore, type ApiKeyStore } from "./api-keys.ts";
10
10
  import type { BreachCheckFn } from "./breach-check.ts";
11
11
  import type { PasswordPolicyOptions } from "./password-policy.ts";
12
12
  import {
@@ -111,8 +111,8 @@ export interface GateAuthOptions extends AuthSchemaOptions {
111
111
  */
112
112
  readonly sessions?: SessionStore;
113
113
  /**
114
- * In-memory API key store for Bearer key auth (tests / embedding).
115
- * Distinct from schema `apiKeys` table options.
114
+ * Shared API key store. Created automatically when omitted
115
+ * (`gate.auth.secret` is the HMAC pepper).
116
116
  */
117
117
  readonly apiKeyStore?: ApiKeyStore;
118
118
  /** Injectable clock. */
@@ -170,7 +170,7 @@ export interface ResolvedGateAuth {
170
170
  };
171
171
  readonly hooks: AuthDatabaseHooks | undefined;
172
172
  readonly sessions: SessionStore | undefined;
173
- readonly apiKeyStore: ApiKeyStore | undefined;
173
+ readonly apiKeyStore: ApiKeyStore;
174
174
  readonly now: (() => number) | undefined;
175
175
  }
176
176
 
@@ -278,7 +278,7 @@ export function resolveGateAuth(options: ResolveGateAuthOptions): ResolvedGateAu
278
278
  },
279
279
  hooks: auth.hooks,
280
280
  sessions: auth.sessions,
281
- apiKeyStore: auth.apiKeyStore,
281
+ apiKeyStore: auth.apiKeyStore ?? createApiKeyStore({ pepper: secret }),
282
282
  now: auth.now,
283
283
  };
284
284
  }
@@ -86,9 +86,16 @@ export function assertCrossPlane(
86
86
 
87
87
  function inferFlowFromPath(path: string): string | undefined {
88
88
  // …/flows/bookings/create.ts → bookings.create
89
- const m = /flows\/([^/]+)\/([^/]+)\.(ts|tsx|js)$/.exec(path.replace(/\\/g, "/"));
90
- if (m) return `${m[1]}.${m[2]}`;
91
- return undefined;
89
+ // …/flows/notes/[id]/get.ts → notes.get (never notes.id.get)
90
+ const posix = path.replace(/\\/g, "/");
91
+ const m = /(?:^|\/)flows\/([^/]+)\/(.+)\.(ts|tsx|js)$/.exec(posix);
92
+ if (!m) return undefined;
93
+ const unit = m[1]!;
94
+ const rest = m[2]!;
95
+ const leaf = rest.split("/").pop();
96
+ if (!leaf || leaf === "index" || leaf.startsWith("_")) return undefined;
97
+ if (leaf === "shapes" || leaf === "signals" || leaf.endsWith(".test")) return undefined;
98
+ return `${unit}.${leaf}`;
92
99
  }
93
100
 
94
101
  function inferPlaneFromSource(source: string): "user" | "operator" | undefined {
package/src/auth/index.ts CHANGED
@@ -161,17 +161,41 @@ export {
161
161
  } from "./attenuation.ts";
162
162
 
163
163
  export {
164
+ AUTH_API_KEYS_RESOURCE,
165
+ allowlistAllowsIp,
166
+ authenticateApiKey,
167
+ clientIpFromRequest,
164
168
  createApiKey,
165
169
  createApiKeyStore,
166
- authenticateApiKey,
170
+ getApiKey,
167
171
  hashApiKeySecret,
172
+ listApiKeys,
168
173
  revokeApiKey,
169
174
  rotateApiKey,
175
+ toApiKeyPublicRow,
176
+ updateApiKey,
177
+ type AllowlistLookup,
178
+ type ApiKeyPublicRow,
170
179
  type ApiKeyStore,
180
+ type AuthenticateApiKeyOptions,
171
181
  type CreateApiKeyOptions,
182
+ type CreateApiKeyStoreOptions,
172
183
  type CreatedApiKey,
184
+ type UpdateApiKeyOptions,
173
185
  } from "./api-keys.ts";
174
186
 
187
+ export {
188
+ apiKeySqlExec,
189
+ bindApiKeySqlPersist,
190
+ bindHostApiKeySql,
191
+ bindHostApiKeySqlFromStore,
192
+ ensureApiKeyTable,
193
+ hydrateApiKeyStore,
194
+ persistApiKeyRow,
195
+ type ApiKeySqlExec,
196
+ type HostApiKeySqlRuntime,
197
+ } from "./api-key-sql.ts";
198
+
175
199
  export {
176
200
  createOperatorInviteStore,
177
201
  createOperatorInvite,
@@ -105,6 +105,10 @@ export const AUTH_MODEL_DEFAULT_FIELDS: Readonly<
105
105
  name: "name",
106
106
  scopes: "scopes",
107
107
  expiresAt: "expires_at",
108
+ rateLimit: "rate_limit",
109
+ ipAllowlist: "ip_allowlist",
110
+ creatorId: "creator_id",
111
+ creatorScopes: "creator_scopes",
108
112
  createdAt: "created_at",
109
113
  lastUsedAt: "last_used_at",
110
114
  revokedAt: "revoked_at",
@@ -190,6 +194,10 @@ const CORE_SQL_TYPES: Readonly<Record<string, AuthColumnSqlType>> = {
190
194
  scopes: "TEXT",
191
195
  audience: "TEXT",
192
196
  lastUsedAt: "INTEGER",
197
+ rateLimit: "TEXT",
198
+ ipAllowlist: "TEXT",
199
+ creatorId: "TEXT",
200
+ creatorScopes: "TEXT",
193
201
  };
194
202
 
195
203
  /**
@@ -167,6 +167,68 @@ export default defineConfig({
167
167
  rmSync(dir, { recursive: true, force: true });
168
168
  }
169
169
  });
170
+
171
+ test("applyAiSetup prefers an existing src/core/ai.ts over a thin core.ts barrel", () => {
172
+ const dir = mkdtempSync(join(tmpdir(), "oke-ai-setup-split-"));
173
+ try {
174
+ writeFileSync(
175
+ join(dir, "oke.config.ts"),
176
+ `import { defineConfig } from "okengine/config";
177
+ export default defineConfig({
178
+ drivers: {
179
+ channel: {
180
+ email: {
181
+ dev: "smtp",
182
+ test: "console",
183
+ prod: "smtp",
184
+ },
185
+ },
186
+ },
187
+ images: {
188
+ store: {
189
+ sql: "postgres:18-alpine",
190
+ },
191
+ },
192
+ });
193
+ `,
194
+ "utf8",
195
+ );
196
+ mkdirSync(join(dir, "src", "core"), { recursive: true });
197
+ writeFileSync(
198
+ join(dir, "src", "core.ts"),
199
+ `export * from "./core/store.ts";\nexport * from "./core/ai.ts";\n`,
200
+ "utf8",
201
+ );
202
+ writeFileSync(
203
+ join(dir, "src", "core", "store.ts"),
204
+ `import { store } from "okengine";\n\nexport const db = store.sql("app");\n`,
205
+ "utf8",
206
+ );
207
+ writeFileSync(join(dir, "src", "core", "ai.ts"), `import { ai } from "okengine";\n`, "utf8");
208
+ writeFileSync(
209
+ join(dir, "src", "app.ts"),
210
+ `import "@/core";\nexport const app = {};\n`,
211
+ "utf8",
212
+ );
213
+
214
+ applyAiSetup(dir, {
215
+ driver: "ollama",
216
+ baseUrl: "http://127.0.0.1:11434",
217
+ chatModel: "gemma4:e4b",
218
+ visionModel: "qwen3-vl:4b",
219
+ embedModel: "nomic-embed-text",
220
+ });
221
+
222
+ const barrel = readFileSync(join(dir, "src", "core.ts"), "utf8");
223
+ expect(barrel).not.toContain("ai.model");
224
+ expect(barrel).toContain('export * from "./core/ai.ts"');
225
+ const aiTs = readFileSync(join(dir, "src", "core", "ai.ts"), "utf8");
226
+ expect(aiTs).toContain("smart");
227
+ expect(aiTs).toContain('ai.model("vision"');
228
+ } finally {
229
+ rmSync(dir, { recursive: true, force: true });
230
+ }
231
+ });
170
232
  });
171
233
 
172
234
  describe("parseAiSetupArgs", () => {