okengine 0.15.2 → 0.17.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 (256) hide show
  1. package/manifest.v1.schema.json +21 -2
  2. package/package.json +1 -1
  3. package/site/content/docs/elements/clock.mdx +7 -0
  4. package/site/content/docs/elements/flow.mdx +20 -12
  5. package/site/content/docs/elements/gate.mdx +108 -3
  6. package/site/content/docs/elements/signal.mdx +46 -17
  7. package/site/content/docs/elements/store.mdx +36 -32
  8. package/site/content/docs/elements/vault.mdx +26 -0
  9. package/site/content/docs/get-started/project-structure.mdx +4 -4
  10. package/site/content/docs/reference/cli.md +1 -1
  11. package/site/content/docs/reference/client.mdx +75 -17
  12. package/site/content/docs/reference/configuration.mdx +7 -4
  13. package/site/content/docs/reference/errors.mdx +24 -17
  14. package/site/content/docs/reference/fx.mdx +39 -22
  15. package/site/content/docs/reference/security.md +1 -1
  16. package/src/auth/api-key-sql.test.ts +47 -0
  17. package/src/auth/api-key-sql.ts +261 -0
  18. package/src/auth/api-keys.ts +276 -14
  19. package/src/auth/auth.test.ts +56 -0
  20. package/src/auth/config.ts +24 -5
  21. package/src/auth/index.ts +62 -1
  22. package/src/auth/plugin.ts +6 -1
  23. package/src/auth/schema.ts +8 -0
  24. package/src/auth/sessions.ts +18 -0
  25. package/src/auth/tables.ts +32 -0
  26. package/src/auth/tenant-config.ts +74 -0
  27. package/src/auth/tenant-tables.ts +11 -0
  28. package/src/auth/tenants.test.ts +63 -0
  29. package/src/auth/tenants.ts +360 -0
  30. package/src/cli/attach-host-console.test.ts +37 -0
  31. package/src/cli/attach-host-console.ts +6 -0
  32. package/src/cli/build.ts +2 -2
  33. package/src/client/budget.test.ts +1 -1
  34. package/src/client/create.ts +75 -5
  35. package/src/client/index.ts +13 -0
  36. package/src/client/live.test.ts +422 -0
  37. package/src/client/live.ts +389 -0
  38. package/src/client/notes-contract.test.ts +41 -0
  39. package/src/client/types.ts +85 -6
  40. package/src/client-react/index.ts +85 -1
  41. package/src/client-react/use-live.test.ts +129 -0
  42. package/src/compiler/effects-infer.ts +37 -2
  43. package/src/compiler/extract.test.ts +143 -11
  44. package/src/compiler/extract.ts +210 -30
  45. package/src/compiler/fixtures/skyport/src/flows/bookings/index.ts +1 -2
  46. package/src/compiler/fixtures/skyport.expected.json +2 -3
  47. package/src/compiler/response.ts +41 -11
  48. package/src/console/server/access.test.ts +116 -8
  49. package/src/console/server/access.ts +70 -23
  50. package/src/console/server/app.ts +45 -11
  51. package/src/console/server/console.test.ts +6 -8
  52. package/src/console/server/dev-identities.test.ts +127 -0
  53. package/src/console/server/dev-identities.ts +229 -0
  54. package/src/console/server/flows-invoke.test.ts +21 -4
  55. package/src/console/server/flows.ts +113 -58
  56. package/src/console/server/gates.test.ts +24 -0
  57. package/src/console/server/gates.ts +10 -30
  58. package/src/console/server/index.ts +1 -0
  59. package/src/console/server/live.test.ts +26 -1
  60. package/src/console/server/live.ts +4 -2
  61. package/src/console/server/operator-db.ts +2 -0
  62. package/src/console/server/state.ts +29 -55
  63. package/src/console/server/store.test.ts +17 -0
  64. package/src/console/server/store.ts +43 -1
  65. package/src/console/ui-next/dist/assets/access-page-CXVWWMmD.js +4 -0
  66. package/src/console/ui-next/dist/assets/{agent-disclosure-C0X1fbWF.js → agent-disclosure-BP0Y0Sux.js} +1 -1
  67. package/src/console/ui-next/dist/assets/cache-glyph-B5X-NM0-.js +1 -0
  68. package/src/console/ui-next/dist/assets/call-pii-button-ChAUiLo9.js +1 -0
  69. package/src/console/ui-next/dist/assets/collapsible-DGnOM2ph.js +1 -0
  70. package/src/console/ui-next/dist/assets/copy-inline-button-DBHhgHKP.js +1 -0
  71. package/src/console/ui-next/dist/assets/detail-header-DhHM1iaZ.js +1 -0
  72. package/src/console/ui-next/dist/assets/dropdown-menu-_NjTEo5_.js +1 -0
  73. package/src/console/ui-next/dist/assets/element-icons-BVXtRyd3.js +1 -0
  74. package/src/console/ui-next/dist/assets/explorer-empty-2uIhBu0_.js +1 -0
  75. package/src/console/ui-next/dist/assets/flows-page-XDpAJO8f.js +1 -0
  76. package/src/console/ui-next/dist/assets/{highlighted-json-CS_O8L-r.js → highlighted-json-Awq7gYdu.js} +1 -1
  77. package/src/console/ui-next/dist/assets/http-method-_UHM2ODJ.js +1 -0
  78. package/src/console/ui-next/dist/assets/index-BBj2QJCu.js +66 -0
  79. package/src/console/ui-next/dist/assets/index-Ck88Jmv8.css +2 -0
  80. package/src/console/ui-next/dist/assets/link-BX6Vqztd.js +1 -0
  81. package/src/console/ui-next/dist/assets/observability-page-BFaay44m.js +4 -0
  82. package/src/console/ui-next/dist/assets/replica-lag-DKRrbvdo.js +18 -0
  83. package/src/console/ui-next/dist/assets/request-meta-DV0ywz7t.js +1 -0
  84. package/src/console/ui-next/dist/assets/shortcut-JQIZlWfm.js +1 -0
  85. package/src/console/ui-next/dist/assets/shortcut-keys-DKxNTe_m.js +1 -0
  86. package/src/console/ui-next/dist/assets/skeleton-D-czQJT6.js +1 -0
  87. package/src/console/ui-next/dist/assets/store-page-CS5-aETQ.js +41 -0
  88. package/src/console/ui-next/dist/assets/trace-detail-sheet-B09O8rA6.js +2 -0
  89. package/src/console/ui-next/dist/assets/tree-expand-toggle-BtyhmWb4.js +55 -0
  90. package/src/console/ui-next/dist/assets/units-page-Ca2Z-E52.js +1 -0
  91. package/src/console/ui-next/dist/assets/{use-vault-list-BLbZhzyF.js → use-vault-list-uk4WVboC.js} +1 -1
  92. package/src/console/ui-next/dist/assets/useMutation-B8EO02Ej.js +1 -0
  93. package/src/console/ui-next/dist/assets/useRender-BE2A9BWC.js +1 -0
  94. package/src/console/ui-next/dist/assets/vault-page-BmOeAFwg.js +2 -0
  95. package/src/console/ui-next/dist/index.html +11 -9
  96. package/src/console/ui-next/index.html +3 -3
  97. package/src/console/ui-next/src/client.ts +167 -0
  98. package/src/console/ui-next/src/components/shell/app-sidebar.tsx +2 -0
  99. package/src/console/ui-next/src/components/shell/command-items.test.ts +1 -0
  100. package/src/console/ui-next/src/components/shell/command-items.ts +8 -0
  101. package/src/console/ui-next/src/components/shell/shell-layout.tsx +2 -1
  102. package/src/console/ui-next/src/components/ui/sheet-form.tsx +8 -3
  103. package/src/console/ui-next/src/features/access/access-page.tsx +347 -0
  104. package/src/console/ui-next/src/features/access/data/use-access.ts +23 -0
  105. package/src/console/ui-next/src/features/access/detail/access-detail.tsx +552 -0
  106. package/src/console/ui-next/src/features/access/explorer/access-list.tsx +125 -0
  107. package/src/console/ui-next/src/features/access/lib/format-when.test.ts +139 -0
  108. package/src/console/ui-next/src/features/access/lib/format-when.ts +296 -0
  109. package/src/console/ui-next/src/features/access/lib/scope-groups.test.ts +34 -0
  110. package/src/console/ui-next/src/features/access/lib/scope-groups.ts +40 -0
  111. package/src/console/ui-next/src/features/access/sheets/access-allow-fields.tsx +236 -0
  112. package/src/console/ui-next/src/features/access/sheets/access-create-sheet.tsx +316 -0
  113. package/src/console/ui-next/src/features/access/sheets/access-edit-sheet.tsx +240 -0
  114. package/src/console/ui-next/src/features/access/sheets/access-expiry-fields.tsx +66 -0
  115. package/src/console/ui-next/src/features/access/sheets/access-refresh-sheet.tsx +104 -0
  116. package/src/console/ui-next/src/features/access/sheets/access-scope-field.tsx +115 -0
  117. package/src/console/ui-next/src/features/access/sheets/access-secret-sheet.tsx +89 -0
  118. package/src/console/ui-next/src/features/access/sheets/access-sheet-search.tsx +49 -0
  119. package/src/console/ui-next/src/features/access/state/access-selection.ts +84 -0
  120. package/src/console/ui-next/src/features/auth/auth-redirect.test.ts +5 -0
  121. package/src/console/ui-next/src/features/auth/auth-redirect.ts +18 -2
  122. package/src/console/ui-next/src/features/flows/fixture.ts +0 -1
  123. package/src/console/ui-next/src/features/not-found/not-found-page.tsx +1 -1
  124. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +5 -1
  125. package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +15 -4
  126. package/src/console/ui-next/src/lib/document-meta.test.ts +1 -0
  127. package/src/console/ui-next/src/lib/document-meta.ts +2 -1
  128. package/src/console/ui-next/src/lib/last-module-search.test.ts +1 -0
  129. package/src/console/ui-next/src/lib/last-module-search.ts +2 -1
  130. package/src/console/ui-next/src/lib/shortcut.test.ts +2 -0
  131. package/src/console/ui-next/src/lib/shortcut.ts +3 -0
  132. package/src/console/ui-next/src/router.tsx +10 -1
  133. package/src/console/ui-next/ui-next-seed-app-schema.ts +88 -37
  134. package/src/console/ui-next/ui-next-seed-manifest-surface.ts +2 -9
  135. package/src/console/ui-next/ui-next-seed-manifest.ts +0 -1
  136. package/src/console/ui-next/ui-next-seed-store.ts +43 -15
  137. package/src/console/xss.gate.test.ts +1 -0
  138. package/src/drivers/index.ts +2 -0
  139. package/src/drivers/journal-postgres.ts +12 -3
  140. package/src/drivers/pg-rls.ts +26 -2
  141. package/src/drivers/pg-vault-rls.ts +68 -0
  142. package/src/drivers/signal-engine.ts +69 -15
  143. package/src/drivers/signal-live-iter.ts +65 -0
  144. package/src/drivers/signal-nats.ts +2 -1
  145. package/src/drivers/signal-postgres.ts +95 -12
  146. package/src/drivers/signal-redis.ts +2 -1
  147. package/src/drivers/signal-retention.ts +64 -0
  148. package/src/drivers/signal-types.ts +35 -5
  149. package/src/elements/clock/declare.ts +64 -2
  150. package/src/elements/clock/reconcile.ts +98 -25
  151. package/src/elements/clock/runtime.ts +13 -2
  152. package/src/elements/clock.test.ts +28 -0
  153. package/src/elements/clock.ts +9 -1
  154. package/src/elements/gate/declare.ts +2 -0
  155. package/src/elements/gate/permissions.ts +12 -0
  156. package/src/elements/gate.ts +6 -1
  157. package/src/elements/signal/declare.ts +44 -10
  158. package/src/elements/signal/delivery-modes.test.ts +90 -4
  159. package/src/elements/signal/order-lifecycle.test.ts +20 -4
  160. package/src/elements/signal/runtime.ts +42 -0
  161. package/src/elements/signal.test.ts +21 -8
  162. package/src/elements/signal.ts +1 -1
  163. package/src/elements/store/cache.ts +1 -1
  164. package/src/elements/store/declare.ts +7 -0
  165. package/src/elements/store/rls-identity.test.ts +29 -0
  166. package/src/elements/store/rls-identity.ts +3 -0
  167. package/src/elements/store/runtime.ts +14 -0
  168. package/src/elements/store/schema-decl.ts +162 -44
  169. package/src/elements/store/schema-tenant.ts +41 -0
  170. package/src/elements/store/sql-rls-isolation.test.ts +39 -0
  171. package/src/elements/store/sql-select.types.test.ts +71 -0
  172. package/src/elements/store/sql-session.ts +54 -32
  173. package/src/elements/store.ts +5 -0
  174. package/src/elements/vault/builtin-adapter.ts +15 -2
  175. package/src/elements/vault/declare.ts +8 -0
  176. package/src/elements/vault/runtime.ts +7 -0
  177. package/src/elements/vault/sql-rls-isolation.test.ts +145 -0
  178. package/src/elements/vault/storage.ts +9 -0
  179. package/src/elements/vault/test-helpers.ts +2 -1
  180. package/src/i18n/catalogs/ar.ts +19 -0
  181. package/src/i18n/catalogs/en.ts +19 -0
  182. package/src/index.ts +2 -0
  183. package/src/kernel/adopt-routes.ts +56 -8
  184. package/src/kernel/api-key-host-persist.test.ts +143 -0
  185. package/src/kernel/app-tenant.ts +122 -0
  186. package/src/kernel/app.ts +196 -57
  187. package/src/kernel/auth-resolve.ts +13 -5
  188. package/src/kernel/boot-bind/clock.ts +9 -3
  189. package/src/kernel/boot.test.ts +4 -4
  190. package/src/kernel/boot.ts +2 -0
  191. package/src/kernel/budget.test.ts +1 -1
  192. package/src/kernel/clock-durable.ts +8 -0
  193. package/src/kernel/clock-per-tenant-name.ts +5 -0
  194. package/src/kernel/clock-reconcile.ts +8 -0
  195. package/src/kernel/errors-live-resume.ts +15 -0
  196. package/src/kernel/errors-tenant.ts +29 -0
  197. package/src/kernel/errors.registry.test.ts +31 -3
  198. package/src/kernel/errors.ts +43 -3
  199. package/src/kernel/flow.ts +26 -5
  200. package/src/kernel/fx-auth-keys.test.ts +88 -0
  201. package/src/kernel/fx-auth-keys.ts +203 -0
  202. package/src/kernel/fx-auth-tenants.test.ts +87 -0
  203. package/src/kernel/fx-auth-tenants.ts +286 -0
  204. package/src/kernel/fx-live-stream.ts +149 -0
  205. package/src/kernel/fx-live.test.ts +157 -0
  206. package/src/kernel/fx-runtime.ts +15 -0
  207. package/src/kernel/fx-tenant-store.ts +213 -0
  208. package/src/kernel/fx.test.ts +108 -1
  209. package/src/kernel/fx.ts +206 -28
  210. package/src/kernel/hooks.ts +2 -2
  211. package/src/kernel/http-resource.ts +9 -18
  212. package/src/kernel/index.ts +2 -0
  213. package/src/kernel/journal.ts +12 -0
  214. package/src/kernel/live-http.test.ts +78 -0
  215. package/src/kernel/live-http.ts +114 -0
  216. package/src/kernel/live-resume.test.ts +125 -0
  217. package/src/kernel/on.ts +51 -0
  218. package/src/kernel/pipeline-api-key.test.ts +192 -7
  219. package/src/kernel/pipeline-tenant.ts +49 -0
  220. package/src/kernel/pipeline.test.ts +1 -1
  221. package/src/kernel/pipeline.ts +41 -4
  222. package/src/kernel/resource-mount.test.ts +10 -27
  223. package/src/kernel/tenant-resolve.test.ts +101 -0
  224. package/src/kernel/tenant-resolve.ts +124 -0
  225. package/src/kernel/tenant-roles.test.ts +87 -0
  226. package/src/kernel/triggers.ts +59 -21
  227. package/src/manifest/diff.test.ts +11 -2
  228. package/src/manifest/diff.ts +53 -11
  229. package/src/manifest/fixtures/skyport.excerpt.json +1 -1
  230. package/src/manifest/fixtures/skyport.manifest.json +0 -1
  231. package/src/manifest/types.ts +58 -6
  232. package/src/release/build-lib.ts +14 -1
  233. package/src/release/limits.ts +2 -2
  234. package/src/release/measure.ts +55 -1
  235. package/src/client/live-gap.test.ts +0 -35
  236. package/src/console/ui-next/dist/assets/cache-glyph-F1FI122b.js +0 -1
  237. package/src/console/ui-next/dist/assets/call-pii-button-DEDCl_j3.js +0 -1
  238. package/src/console/ui-next/dist/assets/collapsible-LPqGvfoz.js +0 -1
  239. package/src/console/ui-next/dist/assets/confirm-sheet-3ptDzXbz.js +0 -1
  240. package/src/console/ui-next/dist/assets/explorer-empty-BvYrygyO.js +0 -1
  241. package/src/console/ui-next/dist/assets/flows-page-Dluo1Bd7.js +0 -1
  242. package/src/console/ui-next/dist/assets/http-method--sWDdXSB.js +0 -1
  243. package/src/console/ui-next/dist/assets/index-Ca3HZMVq.js +0 -66
  244. package/src/console/ui-next/dist/assets/index-UtTDRQpS.css +0 -2
  245. package/src/console/ui-next/dist/assets/link-COeyggt-.js +0 -1
  246. package/src/console/ui-next/dist/assets/observability-page-HK9-BO8z.js +0 -4
  247. package/src/console/ui-next/dist/assets/replica-lag-DBIFFf7d.js +0 -18
  248. package/src/console/ui-next/dist/assets/sheet-form-Dnwa5oOR.js +0 -1
  249. package/src/console/ui-next/dist/assets/shortcut-Cgzuv4k1.js +0 -1
  250. package/src/console/ui-next/dist/assets/shortcut-keys-CoqwEIi0.js +0 -1
  251. package/src/console/ui-next/dist/assets/skeleton-BHcfDYcb.js +0 -1
  252. package/src/console/ui-next/dist/assets/store-page-BSohH3wM.js +0 -41
  253. package/src/console/ui-next/dist/assets/trace-detail-sheet-CFIGRnnA.js +0 -2
  254. package/src/console/ui-next/dist/assets/tree-expand-toggle-BgLl34w7.js +0 -54
  255. package/src/console/ui-next/dist/assets/units-page-B0cFE76A.js +0 -1
  256. package/src/console/ui-next/dist/assets/vault-page-B1SbYe10.js +0 -2
@@ -298,7 +298,10 @@
298
298
  "enum": ["user", "operator"]
299
299
  },
300
300
  "durable": { "type": "boolean" },
301
- "live": { "type": "boolean" },
301
+ "live": {
302
+ "type": "string",
303
+ "description": "Live signal name when this flow streams delivery: live SSE."
304
+ },
302
305
  "cache": {
303
306
  "oneOf": [{ "type": "boolean" }, { "type": "string", "minLength": 1 }]
304
307
  },
@@ -342,7 +345,23 @@
342
345
  "retries": { "type": "integer", "minimum": 0 },
343
346
  "deadLetter": { "type": "boolean" },
344
347
  "schema": { "$ref": "#/$defs/JsonSchema" },
345
- "optional": { "type": "boolean" }
348
+ "optional": { "type": "boolean" },
349
+ "retention": {
350
+ "description": "Live-tape cap (delivery: live only). Omitted = unbounded.",
351
+ "type": "object",
352
+ "additionalProperties": false,
353
+ "properties": {
354
+ "maxAge": {
355
+ "type": "string",
356
+ "description": "Drop events older than this duration (7d, 1h, 30s, …)."
357
+ },
358
+ "maxCount": {
359
+ "type": "integer",
360
+ "minimum": 1,
361
+ "description": "Keep only the newest N live events."
362
+ }
363
+ }
364
+ }
346
365
  }
347
366
  },
348
367
  "ColumnClassification": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okengine",
3
- "version": "0.15.2",
3
+ "version": "0.17.0",
4
4
  "description": "One law. Eight elements. Ten exports. One package. One manifest. Every backend need is derived, never added.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -102,6 +102,12 @@ difference.
102
102
  | `timezone` | string | `"UTC"` | IANA timezone for cron evaluation |
103
103
  | `overridable` | boolean | `false` | Allow the Console to **edit** the schedule (pause is separate) |
104
104
  | `description` | string | — | Human title in the Console (falls back to the clock name) |
105
+ | `perTenant` | boolean | `false` | Expand to one `oke_crons` row per tenant (`{name}#{tenantId}`). Prefer `clock.perTenant(name, opts)` |
106
+
107
+ <Callout title="Per-tenant clocks never tick the template">
108
+ Bind `on(clock.perTenant("invoices", { every: "1h" }), flow(...))`. Rows are `invoices#acme`;
109
+ tenant create/delete adds or orphans them. Fire stamps `fx.tenant.id`.
110
+ </Callout>
105
111
 
106
112
  ### `fx.clock`
107
113
 
@@ -222,6 +228,7 @@ The registry is off in `test` and when there is no shared SQL URL. `dev`/`prod`
222
228
 
223
229
  - [Project structure](/docs/get-started/project-structure) — clock flows in the tree get a name, never a URL
224
230
  - [Flow](/docs/elements/flow) — `on(trigger, flow)` and the `fx` surface
231
+ - [Gate](/docs/elements/gate) — `clock.perTenant` when `gate.auth.tenant` is on
225
232
  - [fx · Runs](/docs/reference/fx#runs-observability-read) — native SLO checkers via `on(every(…))` + `fx.runs`
226
233
  - [Signal](/docs/elements/signal) — reacting to events instead of time
227
234
 
@@ -71,16 +71,17 @@ curl -X POST localhost:6530/orders -d '{"sku":"SKU-1","qty":2}' -H 'content-type
71
71
 
72
72
  ## Anatomy of a Flow
73
73
 
74
- | Part | Role |
75
- | ------------ | --------------------------------------------------------------------------------- |
76
- | trigger | What starts the flow — `http`, a signal, `every`, a row change |
77
- | `in` | Input contract — validated before `do` runs; bad input is a 422 |
78
- | `out` | Output contract — the return value is checked against it |
79
- | `errors` | Typed failures — **returned** with `fx.fail`, never thrown |
80
- | `retry` | Optional whole-`do` backoff on thrown errors (same journal) |
81
- | `cache` | Optional — `false` opts out; `"30s"` adds a TTL. Read-only flows cache by default |
82
- | `do` | The work every read, write, emit, and call goes through `fx` |
83
- | `compensate` | Durable onlyoptional hook after auto per-step `{ undo }` (same journal) |
74
+ | Part | Role |
75
+ | -------------- | ------------------------------------------------------------------------------------ |
76
+ | trigger | What starts the flow — `http`, a signal, `every`, a row change |
77
+ | `in` | Input contract — validated before `do` runs; bad input is a 422 |
78
+ | `out` | Output contract — the return value is checked against it |
79
+ | `errors` | Typed failures — **returned** with `fx.fail`, never thrown |
80
+ | `retry` | Optional whole-`do` backoff on thrown errors (same journal) |
81
+ | `cache` | Optional — `false` opts out; `"30s"` adds a TTL. Read-only flows cache by default |
82
+ | `tenantScoped` | Default `true` when `gate.auth.tenant` is on set `false` to skip tenant-role union |
83
+ | `do` | The workevery read, write, emit, and call goes through `fx` |
84
+ | `compensate` | Durable only — optional hook after auto per-step `{ undo }` (same journal) |
84
85
 
85
86
  Failures are values, not exceptions:
86
87
 
@@ -104,6 +105,12 @@ Built-in codes attach a localized `message` ([Errors](/docs/reference/errors));
104
105
  [Store](/docs/elements/store#handwritten-http-lists).
105
106
  </Callout>
106
107
 
108
+ <Callout title="Helpers take the real Fx">
109
+ Type a helper's `fx` as the exported `Fx` (`import type { Fx } from
110
+ "okengine"`). A narrower `{ store: … }` will not match `store()` overloads
111
+ — that is the `as never` trap. See [fx](/docs/reference/fx).
112
+ </Callout>
113
+
107
114
  ## The five triggers
108
115
 
109
116
  <FlowTriggers />
@@ -133,8 +140,8 @@ export const findOrder = on(
133
140
  ### signal — another flow emits
134
141
 
135
142
  The producer emits through `fx`. For `once` and `broadcast` the consumer is the same species —
136
- `on(signal, flow)`, no `subscribe()` registration. `live` replay is the exception: `bus.live()` on
137
- the server, not a Flow.
143
+ `on(signal, flow)`, no `subscribe()` registration. `live` replay is HTTP SSE: `.live(signal)` on a
144
+ GET trigger, `fx.live` as the stream carrier, `api.live` on the client.
138
145
 
139
146
  ```typescript
140
147
  await fx.emit(orderPlaced, { orderId: id }); // inside the producing flow
@@ -203,6 +210,7 @@ Everything a flow may touch, on one object:
203
210
  | `fx.store(db).select/insert/…` | read / write | SQL, KV, files, index sessions |
204
211
  | `fx.emit(signal, payload)` | emit | Publish a signal (transactional with writes) |
205
212
  | `fx.deadLetters(signal)` | read | Dead-lettered messages for that signal |
213
+ | `fx.live(signal, { match? })` | read | Live signal SSE carrier (`JsonStreamResult`) |
206
214
  | `fx.send(template, opts)` | send | Reach a human (email · SMS · …) |
207
215
  | `fx.ask(prompt, input)` | ask | Call a versioned AI prompt |
208
216
  | `fx.run(agent, input)` | ask | Run a bounded agent |
@@ -236,6 +236,7 @@ export const app = oke({
236
236
  | `session.singleSessionPerUser` | `false` | Opt-in: issuing a session revokes other families for that user |
237
237
  | `cookies.enabled` | `false` | Opt-in HttpOnly cookie mirror (Bearer stays default) |
238
238
  | `secondaryStorage.enabled` | `false` | Hot auth data in `store.kv` when configured |
239
+ | `tenant` | unset | Opt-in identity dimension — `true` or `{ required, source, header, resolve, authoritative }` |
239
240
  | `user` / `session` / … | defaults | `modelName` / `fields` / `additionalFields` |
240
241
 
241
242
  HIBP via `createHibpBreachCheck` from `okengine/auth` (k-anonymity range API; requires
@@ -256,6 +257,83 @@ Customize tables, then `oke schema generate` (`.oke/schema/oke.ts`; `--check` in
256
257
  sign-in shapes: [Plugins](/docs/plugins). Call auth from
257
258
  [createClient](/docs/reference/client) — helpers in `okengine/client/auth`.
258
259
 
260
+ ## Tenants (identity dimension)
261
+
262
+ `gate.auth.tenant: true` (or an options bag) is **not** a second authorization system and not an
263
+ organizations product. Tenant is a dimension of identity, like `fx.auth` / `fx.operator`.
264
+
265
+ ```typescript
266
+ export const app = oke({
267
+ name: "shop",
268
+ gate: { auth: { tenant: true } },
269
+ });
270
+ ```
271
+
272
+ | Option | Default | Meaning |
273
+ | --------------- | -------------- | ------------------------------------------------------------------------------------------------ |
274
+ | `required` | `false` | Pure B2B: authenticated user-plane requests without a tenant are `Forbidden` / `tenant_required` |
275
+ | `source` | `"claim"` | `"claim"` · `"header"` · `"subdomain"` · `"resolve"` |
276
+ | `header` | `x-oke-tenant` | Header name when `source` is `"header"` |
277
+ | `resolve` | unset | Callback that returns an id; membership is still checked unless `authoritative` |
278
+ | `authoritative` | `false` | Trust `resolve` without a membership query |
279
+
280
+ Three tiers, fail-safe:
281
+
282
+ 1. **Claim** — signed JWT `tid` or API-key `tenantId`. The id is trusted; no membership query.
283
+ 2. **Header / subdomain** — never trust a client-supplied id without membership.
284
+ 3. **`resolve`** — still membership-checked unless `authoritative: true`.
285
+
286
+ `required: false` (default) is B2C+B2B: `fx.tenant.id` may be `null`. Internal / cron / `fx.call`
287
+ have no HTTP request — header and subdomain sources keep the stamped claim.
288
+
289
+ `fx.auth.switchTenant(id)` issues a **new** access+refresh pair and a **new** family (so tab A
290
+ rotation cannot reuse-detect tab B). It never Set-Cookies.
291
+
292
+ Refresh copies that row's `tid`; tenant-role scopes never melt into the JWT.
293
+
294
+ `fx.auth.listTenants` / `createTenant` / `addMember` / `upsertTenantRole` are session-only
295
+ (`auth:tenants`). Tenant roles may grant application scopes only — `console:*` fails like an
296
+ unknown name.
297
+
298
+ Live `fx.auth.scopes` unions those grants only when `fx.tenant.id` is set, the flow is
299
+ tenant-scoped (default on; `flow({ tenantScoped: false })` opts out), and user-plane.
300
+ `gate.scope()` still reads `auth.scopes.has(name)`.
301
+
302
+ SQL stamps `oke.tenant()` when tenancy is on — [Store](/docs/elements/store) and
303
+ built-in [Vault](/docs/elements/vault#tenant-isolation-built-in-store) ciphertext.
304
+
305
+ ## API keys
306
+
307
+ A key is the **issuer with fewer gates** — not a second permission system.
308
+ Bearer secrets authenticate as the creator; `fx.auth.scopes` shrink to the key.
309
+
310
+ | Field | Session | API key |
311
+ | ----------------------------------- | -------------- | ----------------------- |
312
+ | `fx.auth.userId` / `fx.operator.id` | subject | creator (`creatorId`) |
313
+ | `fx.auth.scopes` | session scopes | key scopes (attenuated) |
314
+ | `fx.auth.apiKeyId` | `null` | key id |
315
+ | WideEvent `principal` | subject | issuer |
316
+ | `dimensions.api_key` | absent | key id |
317
+
318
+ `gate.auth` always creates a shared key store (HMAC-SHA-256 with `gate.auth.secret`).
319
+ Do not pass `apiKeyStore` unless you are injecting a test Map.
320
+
321
+ <Callout title="Keys live on the app database">
322
+ With `store.sql()`, boot hydrates public `oke_api_keys` on that connection (`DATABASE_URL`) and
323
+ write-through persists create / rotate / revoke. Console **Access** attaches this host store — it
324
+ does not keep a second key database.
325
+ </Callout>
326
+
327
+ Session Flows manage keys via [fx](/docs/reference/fx) (`createApiKey`, `listApiKeys`,
328
+ `revokeApiKey`, `rotateApiKey`, `updateApiKey`). A key-authenticated caller cannot.
329
+
330
+ Declare `effects.reads: ["auth:api-keys"]` / `effects.writes: ["auth:api-keys"]` — never
331
+ `sql:oke_api_keys`. Console **Access** (`:6533` → `/access`) mints operator keys as the
332
+ operator, and user-plane keys only as a picked issuer (`creatorUserId`).
333
+
334
+ Verify rejects expired, revoked, allowlist-miss, and over-rate secrets with `401`
335
+ and no `api_key` dimension.
336
+
259
337
  ## Identity and decisions
260
338
 
261
339
  Gates decide on principals that already exist. `gate.auth` issues hybrid sessions onto
@@ -268,8 +346,8 @@ edit live today.
268
346
 
269
347
  <Callout title="RLS reads the stamped principal">
270
348
  After HTTP Gate passes, user-plane `fx.store` stamps `oke.gate()`, `oke.user()`, and
271
- `oke.has_scope()`. Rate, cron, CDC, and signal stay unstamped. Helpers:
272
- [Store](/docs/elements/store).
349
+ `oke.has_scope()`. With `gate.auth.tenant` on, a fourth GUC `oke.tenant()` is set. Rate, cron,
350
+ CDC, and signal stay unstamped. Helpers: [Store](/docs/elements/store).
273
351
  </Callout>
274
352
 
275
353
  ## Troubleshooting
@@ -292,6 +370,12 @@ decisions belong inside `do` as typed `errors`.
292
370
  `Forbidden` means authenticated but a policy said no. Check `verified` or a missing scope —
293
371
  the failure includes the gate name and reason.
294
372
 
373
+ </Accordion>
374
+ <Accordion title="Forbidden tenant_required / not_member">
375
+
376
+ `required: true` and no resolved tenant → `tenant_required`. Header/subdomain id that is not a
377
+ membership → `not_member`. Call `fx.auth.switchTenant(id)` or send a signed `tid`.
378
+
295
379
  </Accordion>
296
380
  <Accordion title="Prod boot: gate.auth: secret is required">
297
381
 
@@ -310,6 +394,27 @@ character classes.
310
394
  `"ip"` for public surfaces (sign-up, password reset), `"user"` for authenticated quotas.
311
395
  Keying an authenticated endpoint by IP punishes shared NAT.
312
396
 
397
+ </Accordion>
398
+ <Accordion title="API key Bearer returns 401">
399
+
400
+ Expired, revoked, allowlist miss, or over `rateLimit`. `ipAllowlist` uses the
401
+ same X-Forwarded-For hop as `ip-allowlist` (IP or hostname; lookup failure is closed).
402
+ Rotate if the secret was shown once and then lost.
403
+
404
+ </Accordion>
405
+ <Accordion title="Keys vanish after an app restart">
406
+
407
+ No SQL store, or a new `gate.auth.secret`. Declare `store.sql()`, keep
408
+ `DATABASE_URL` (or the same PGlite datadir) and the auth secret stable.
409
+ Console does not persist keys in `oke_console`.
410
+
411
+ </Accordion>
412
+ <Accordion title="Key can call a route the issuer cannot">
413
+
414
+ That is a bug in the mint. `createApiKey` / `fx.auth.createApiKey` re-run
415
+ `assertAttenuated` against the issuer's live scopes. Custom `gate.policy`
416
+ predicates that ignore scopes still see `verified: true`.
417
+
313
418
  </Accordion>
314
419
  </Accordions>
315
420
 
@@ -317,7 +422,7 @@ Keying an authenticated endpoint by IP punishes shared NAT.
317
422
 
318
423
  - [Project structure](/docs/get-started/project-structure) — `http.get()` and `flow({…})` fill path and name from the file tree
319
424
  - [Flow](/docs/elements/flow) — the trigger pipeline gates plug into
320
- - [fx](/docs/reference/fx) — `fx.auth`, `fx.operator`, `fx.principal`
425
+ - [fx](/docs/reference/fx) — `fx.auth`, `fx.operator`, `fx.principal`, `fx.tenant`
321
426
  - [Client](/docs/reference/client) — Bearer `createClient` + `okengine/client/auth`
322
427
  - [Plugins](/docs/plugins) — username, magic link, OTP, TOTP, passkeys
323
428
  - [Vault](/docs/elements/vault) — credentials your policies protect
@@ -72,7 +72,7 @@ export const sendConfirmation = on(
72
72
  </Steps>
73
73
 
74
74
  That's the loop for `once` and `broadcast`: declare → `fx.emit` → `on(signal, flow)`.
75
- `live` keeps the same emit; the listener is `bus.live()`, not a Flow.
75
+ `live` keeps the same emit; expose it over HTTP with `.live(signal)` and subscribe from `api.live`.
76
76
 
77
77
  ## The three delivery physics
78
78
 
@@ -90,6 +90,7 @@ The declaration is identical in shape for all three — switching physics later
90
90
  | `retries` | number | `3` | Retry budget for `once` — dead-letter when `attempts > retries` (`retries + 1` total invocations) |
91
91
  | `deadLetter` | boolean | `true` | Preserve exhausted messages in the DLQ (`once`) |
92
92
  | `optional` | boolean | `false` | Allow emitting while nobody subscribes (skip the orphan check) |
93
+ | `retention` | `{ maxAge?, maxCount? }` | unbounded | Live tape cap (`delivery: "live"` only). Both limits AND-combine when set |
93
94
 
94
95
  ## One order, three modes
95
96
 
@@ -124,6 +125,7 @@ export const orderStatus = signal("order-status", {
124
125
  }),
125
126
  delivery: "live",
126
127
  optional: true, // clients may not be connected yet
128
+ retention: { maxAge: "24h", maxCount: 500 },
127
129
  });
128
130
  ```
129
131
 
@@ -182,19 +184,31 @@ export const notifyCustomer = on(
182
184
  );
183
185
  ```
184
186
 
185
- `live` — retain the tape. A late subscriber replays **full history** (`placed → fulfilling → shipped`), then keeps going.
187
+ `live` — retain the tape. A late subscriber replays history (`placed → fulfilling → shipped`), then keeps going. Omit `retention` and that history is **unbounded** — declare `maxAge` / `maxCount` in production.
186
188
 
187
- The listener is **`bus.live()`**, not `on(orderStatus, flow)`. `on(signal, flow)` does not replay retained live payloads. Today that API is server-side; `createClient` has no SSE / WebSocket / `client.live` yet see [Client](/docs/reference/client#signal-and-live-queries).
189
+ The listener is **not** `on(orderStatus, flow)` that trigger does not replay retained payloads. Expose GET SSE with `.live(signal)`, then subscribe from the typed client. `optional: true` is required so emits succeed before anyone is connected.
188
190
 
189
191
  ```typescript
190
- const bus = app.bootResult?.signal?.bus;
191
- if (!bus) throw new Error("signal runtime not booted");
192
+ export const events = on(http.get("/orders/:orderId/events").gate(member).live(orderStatus));
192
193
 
193
- const unsub = await bus.live("order-status", (payload) => {
194
- /* placed → fulfilling → shipped */
195
- });
194
+ export const adminFeed = on(http.get("/admin/order-status").gate(admin).live(orderStatus));
195
+
196
+ const stop = api.live(
197
+ orderStatus,
198
+ { orderId: "ord_1" },
199
+ {
200
+ onEvent: (event) => {
201
+ /* placed → fulfilling → shipped */
202
+ },
203
+ },
204
+ );
205
+ stop();
196
206
  ```
197
207
 
208
+ Same signal, two audiences: member filter on `:orderId`, admin firehose. Duplicate `(signal, gates, match)` is **OKE1013**. Same HTTP path is still **OKE1011**. Firehose default: `on(http.live(orderStatus).gate(member))` → `GET /_oke/live/order-status`.
209
+
210
+ Server-side `fx.live` **is** the SSE carrier (`JsonStreamResult`). Custom `do` is allowed; authors do not wrap `fx.json.stream`. Reconnects send `Last-Event-ID`; a missing cursor is **OKE1014** / HTTP 410, not a silent full replay. `autoResubscribe: true` backs off 500ms…30s and, after a 410, replays the remaining tape.
211
+
198
212
  **Why separate signals:** delivery is fixed per declaration. Competing work stays on `once`; fan-out stays on `broadcast`; the timeline stays on `live`. Switching a word later is cheap; mixing physics on one name is not.
199
213
 
200
214
  ## When delivery fails
@@ -252,12 +266,16 @@ Committed `once` messages survive process death. A claim sets `lockedBy` and a v
252
266
 
253
267
  <SignalLiveReplay />
254
268
 
255
- `live` retains every delivered message and replays the **full history** to a late `bus.live()` subscriber. There is no TTL or max-count window on that retention today (the Console payload monitor shows only the newest 50 for display).
269
+ `live` retains delivered messages and replays them to a late subscriber. Omit `retention` for an unbounded tape (a production hazard). `maxAge` (clock duration) and `maxCount` AND-combine when both are set. Drivers prune on write and when `live()` opens no background sweeper.
270
+
271
+ Reconnects send SSE `Last-Event-ID`. If that id was pruned or never existed, the server returns **410** `{ error: { code: "LiveResumeGap" } }` (**OKE1014**) before opening the stream. The client clears the cursor; `autoResubscribe` then replays whatever remains.
256
272
 
257
- <Callout title="Client subscription is not shipped yet">
258
- `delivery: "live"` means the **driver** retains and replays. Listen with `bus.live()` on the
259
- server not `on(signal, flow)`. `createClient` has no SSE yet — poll HTTP. See
260
- [Client](/docs/reference/client#signal-and-live-queries).
273
+ The Console payload monitor still shows only the newest 50 for display — that cap is not the retained tape.
274
+
275
+ <Callout title="HTTP live is GET SSE">
276
+ Expose with `http.get(path).live(signal)` or `http.live(signal)`. Subscribe with `api.live(signal,
277
+ input?, {onEvent})` — callback + unsubscribe, not `for await`. See
278
+ [Client](/docs/reference/client#live-signals).
261
279
  </Callout>
262
280
 
263
281
  ## Orphaned signal config
@@ -291,7 +309,7 @@ Pairing an arbitrary Store insert with emit inside **one** shared SQL transactio
291
309
  You emitted a signal that nobody currently subscribes to. For `once` and `broadcast`, wire
292
310
  `on(signal, flow)` before emit.
293
311
 
294
- `live` listeners are `bus.live()`, not a Flow — set `optional: true` when they may connect later.
312
+ `live` listeners are HTTP SSE (`api.live` / `fx.live`), not a Flow — set `optional: true` when they may connect later.
295
313
 
296
314
  </Accordion>
297
315
  <Accordion title="Emit fails with OKE1043 (schema)">
@@ -306,13 +324,24 @@ When `attempts > retries` the message moves to the DLQ (if `deadLetter: true`)
306
324
  </Accordion>
307
325
  <Accordion title="fx.deadLetters throws OKE1001">
308
326
 
309
- The flow read a signal it did not declare. The compiler infers `reads: ["signal:<name>"]` from `fx.deadLetters(signal)`. A different handle needs its own read.
327
+ The flow read a signal it did not declare. The compiler infers `reads: ["signal:<name>"]` from `fx.deadLetters(signal)` and `fx.live(signal)`. A different handle needs its own read.
328
+
329
+ </Accordion>
330
+ <Accordion title="Boot fails with OKE1013 (live exposure duplicate)">
331
+
332
+ Two GET routes expose the same live signal with the same gates and match.
333
+ Change the gate or path-param filter, or drop the extra route (same path is **OKE1011**).
334
+
335
+ </Accordion>
336
+ <Accordion title="Subscribe gets HTTP 410 LiveResumeGap (OKE1014)">
337
+
338
+ The `Last-Event-ID` cursor is not on the retained tape (pruned, never existed, or the memory bus restarted). The gap is visible in `onError`. With `autoResubscribe: true` the next request omits the header and replays what remains.
310
339
 
311
340
  </Accordion>
312
341
  <Accordion title="on(liveSignal, flow) never replays history">
313
342
 
314
- `on(signal, flow)` listens for `once` and `broadcast`. `live` replay is `bus.live()` on the Signal
315
- bus — a Flow trigger does not replay retained payloads.
343
+ `on(signal, flow)` listens for `once` and `broadcast`. `live` replay is HTTP SSE (`api.live` /
344
+ `fx.live`) — a Flow trigger does not replay retained payloads.
316
345
 
317
346
  </Accordion>
318
347
  <Accordion title="once vs broadcast vs live — how do I choose?">
@@ -91,22 +91,17 @@ Install reviews the `CREATE EXTENSION` SQL; **Advanced** can set `SCHEMA`,
91
91
  When a newer packaged version exists, the name chip shows **Upgrade**
92
92
  (`ALTER EXTENSION … UPDATE`). Names link to the project or Postgres docs.
93
93
 
94
- Each SQL table row shows whether Postgres RLS is on (emerald shield)
95
- or off (muted shield). The resource header repeats that as an **RLS**
96
- chip before **PII** **N RLS** when enabled and policies exist. KV,
97
- files, and index have no badge. Enabling RLS on create flips that
98
- shield.
99
-
100
- **RLS Policies** lists `pg_policies` plus Manifest policies (`declared` /
101
- `live`). Two-finger click a row to open its sheet (Edit / Save on editable
102
- columns); two-finger click empty grid to create. Create policy pins
103
- SQL | Code (`store.schema.policy` + `pgPolicy`); drag the handle to
104
- resize the preview.
94
+ SQL table rows and the resource header show RLS (emerald / muted shield;
95
+ **N RLS** when policies exist) before **PII**. KV, files, and index have
96
+ no badgeenabling RLS on create flips the shield.
97
+
98
+ **RLS Policies** lists `pg_policies` plus Manifest `declared` / `live`.
99
+ Two-finger click a row for its sheet, or empty grid to create. Create
100
+ pins SQL | Code (`store.schema.policy` + `pgPolicy`); drag to resize.
101
+
105
102
  **Templates** use `oke.user()` / `oke.gate()` / `oke.has_scope()`.
106
- Owner cards open a **Column** select of the table fields (guessed
107
- `creator_email` / `owner_email` when present) with the same PK / FK
108
- marks as schema cards. Create stays blocked until a real column is
109
- picked.
103
+ Owner cards pick a **Column** (guess `creator_email` / `owner_email`)
104
+ with PK / FK marks. Create stays blocked until a column is picked.
110
105
 
111
106
  Create policy picks a Gate posture (Read / Write / Both), or **Policy &
112
107
  scope**. Those picks fill `USING` / `WITH CHECK`. `TO` stays `public` —
@@ -204,10 +199,10 @@ const notesR = store.resource(db, notes, {
204
199
  },
205
200
  });
206
201
 
207
- const mounted = on(http.resource("/notes", notesR.all()).public().live());
202
+ const mounted = on(http.resource("/notes", notesR.all()).public());
208
203
  ```
209
204
 
210
- `.gate(...)` / `.live()` chain like `http.get` — gates on every verb, live on list and get.
205
+ `.gate(...)` / `.public()` chain like `http.get` — gates on every verb. Live SSE is `.live(signal)` on a GET, not on `http.resource`.
211
206
 
212
207
  The list endpoint's URL is the whole query language:
213
208
 
@@ -296,6 +291,9 @@ await fx.store(db).update(notes).set({ title: input.title }).where(eq(notes.id,
296
291
  await fx.store(db).delete(notes).where(lt(notes.createdAt, cutoff));
297
292
  ```
298
293
 
294
+ `select().from(notes)` infers the declared row (`string` / `number` / `null` from
295
+ `field.*`). A `select({ title: notes.title })` projection stays untyped.
296
+
299
297
  <Callout title="One table per call — no relational with:">
300
298
  `fx.store` is deliberately **single-table**: Drizzle's relational `findMany({ with: … })` is not
301
299
  available through `fx`. Compose joins as separate single-table reads (or `fx.call`) so every
@@ -306,17 +304,18 @@ await fx.store(db).delete(notes).where(lt(notes.createdAt, cutoff));
306
304
 
307
305
  The recommended path: declare tables ORM-agnostically, then let `oke db` emit real Drizzle (`pgTable` for Postgres / PGLite) into `src/db/schema.drizzle.ts`.
308
306
 
309
- | Field API | Meaning |
310
- | -------------------------------------------- | ---------------------------------------------------------------- |
311
- | `field.text()` / `field.integer()` | v1 column primitives |
312
- | `.primaryKey()` · `.notNull()` · `.unique()` | constraints |
313
- | `.default(v)` · `.defaultFn(id \| now)` | defaults |
314
- | `.pii()` · `.sensitive()` · `.retain("30d")` | privacy classification |
315
- | `.as("sql_name")` | override the automatic `camelCase → snake_case` |
316
- | `.describe("…")` | human title in the Console (falls back to key) |
317
- | `.references(() => col, { onDelete })` | foreign key |
318
- | `store.schema.rls()` | `pgTable.withRLS` when there are no policies |
319
- | `store.schema.policy.gate/owner/scope` | happy-path RLS (`oke.gate()` / `oke.user()` / `oke.has_scope()`) |
307
+ | Field API | Meaning |
308
+ | --------------------------------------------- | --------------------------------------------------------------------------------- |
309
+ | `field.text()` / `field.integer()` | v1 column primitives |
310
+ | `.primaryKey()` · `.notNull()` · `.unique()` | constraints |
311
+ | `.default(v)` · `.defaultFn(id \| now)` | defaults |
312
+ | `.pii()` · `.sensitive()` · `.retain("30d")` | privacy classification |
313
+ | `.as("sql_name")` | override the automatic `camelCase → snake_case` |
314
+ | `.describe("…")` | human title in the Console (falls back to key) |
315
+ | `.references(() => col, { onDelete })` | foreign key |
316
+ | `store.schema.rls()` | `pgTable.withRLS` when there are no policies |
317
+ | `store.schema.policy.gate/owner/scope/tenant` | happy-path RLS (`oke.gate()` / `oke.user()` / `oke.has_scope()` / `oke.tenant()`) |
318
+ | `store.schema.unscoped()` | Shared table — required when tenancy is on and there is no tenant policy |
320
319
 
321
320
  Third argument on `store.schema.table` is Drizzle-shaped extras:
322
321
 
@@ -331,6 +330,7 @@ export const bookings = store.schema.table(
331
330
  store.schema.policy.gate("member", { for: "select" }),
332
331
  store.schema.policy.owner("owner", { for: "all" }),
333
332
  store.schema.policy.scope("booking:create", { for: "insert" }),
333
+ store.schema.policy.tenant("tenant_id"),
334
334
  ],
335
335
  );
336
336
  ```
@@ -340,9 +340,9 @@ Helpers emit stable names (`gate_member_select`). Raw
340
340
  escape hatch — predicates use `oke.*`, never `current_setting`.
341
341
 
342
342
  <Callout title="User-plane fx.store applies RLS">
343
- HTTP / resource flows stamp Gate identity onto every postgres / pglite statement (`SET LOCAL ROLE
344
- oke_app` + `set_config` in one pinned transaction). Table-owner bypass ends there; operator / cron
345
- / CDC / signal / catalog stay unstamped.
343
+ HTTP / resource flows stamp Gate identity (`SET LOCAL ROLE oke_app` + `set_config`). Operator /
344
+ cron / CDC / signal stay unstamped. Tenancy on: also `oke.tenant()`; tables need `policy.tenant`
345
+ or `unscoped()`.
346
346
  </Callout>
347
347
 
348
348
  #### Foreign keys and relations
@@ -692,6 +692,10 @@ export const drafts = store.kv("drafts", { durable: true, description: "Compose
692
692
  **Consequence:** `oke db seed` into cache Redis looks fine until compose recreates the
693
693
  container — Console Store then shows **No rows.** Seeded namespaces need `{ durable: true }`.
694
694
 
695
+ With `gate.auth.tenant` on, KV keys are prefixed `{tenantId}:` (logical keys in `do` stay
696
+ unprefixed). Missing `fx.tenant.id` throws **OKE1015**. Opt out with
697
+ `store.kv("sessions", { tenantScoped: false })`.
698
+
695
699
  Gate rates and Signal stay on `REDIS_URL`. Missing `DATABASE_URL` with the postgres driver
696
700
  fails boot: `oke boot: durable store.kv needs DATABASE_URL`.
697
701
 
@@ -1130,7 +1134,7 @@ Default `store.kv` is cache Redis with no AOF — a recreate drops keys. SQL see
1130
1134
  - [Project structure](/docs/get-started/project-structure) — `list.ts` + `http.get()` is `GET /notes` named `notes.list`
1131
1135
  - [Flow](/docs/elements/flow) — the `fx.store` session inside `do`
1132
1136
  - [AI](/docs/elements/ai) — `ai.embed` into a vector `store.index`, searched via `fx.search`
1133
- - [Gate](/docs/elements/gate) — `pii:reveal` and other permissions on flows
1137
+ - [Gate](/docs/elements/gate) — `pii:reveal`, `gate.auth.tenant`, and `oke.tenant()`
1134
1138
  - [CLI Reference](/docs/reference/cli) — `oke db push` · `generate` · `migrate`
1135
1139
  - [Configuration](/docs/reference/configuration) — `drivers.store` maps and `images` pins
1136
1140
  - [Environment variables](/docs/reference/environment-variables) — Redis · S3 · meilisearch URLs
@@ -151,6 +151,7 @@ Every name passed to `vault.env.required` is registered, so boot reports missing
151
151
  | `schema` | zod / Standard Schema | Validated at boot; a bad value fails boot like a missing one |
152
152
  | `dev` | string | Local-only fallback when no source provides a value (never used in prod) |
153
153
  | `sensitive` | boolean | Override the default (`true` for secrets, `false` for config) |
154
+ | `perTenant` | boolean | Resolve at request time under `{tenantId}/{contract}`. Default `true` when `gate.auth.tenant` is on. Boot skips these contracts. |
154
155
 
155
156
  ### Zero-setup Compose fallback
156
157
 
@@ -201,6 +202,9 @@ The **Console** (`:6533`) **Vault** page (`/vault`) groups **Secrets** and
201
202
  **Config**. Secrets are write-only: a salted fingerprint (`sha256:…`), never
202
203
  the cleartext. Config rows show the value — that is `vault.config()`.
203
204
 
205
+ API keys are principals — mint them on **Access** (`/access`), not Vault.
206
+ See [Gate](/docs/elements/gate#api-keys).
207
+
204
208
  Add (`+`) creates a contract from Console as well as from source. Those rows
205
209
  show `console` until you declare the same name in the Manifest.
206
210
 
@@ -294,6 +298,20 @@ For production built-in stores, set `vault.encryption.masterKey` to a KMS source
294
298
  `@aws-sdk/client-kms`) or use `managed` with a provider below. `oke doctor` warns when prod would
295
299
  take the master key from the environment.
296
300
 
301
+ ## Tenant isolation (built-in store)
302
+
303
+ When `gate.auth.tenant` is on, `perTenant` contracts resolve at request time under
304
+ `{tenantId}/{contract}`. Boot skips those contracts.
305
+
306
+ The built-in Postgres table `oke_vault_secrets` carries `tenant_id` and the same
307
+ `oke.tenant()` helper as domain tables — not a second GUC.
308
+
309
+ <Callout title="RLS on ciphertext rows">
310
+ `tenant_id = oke.tenant() OR tenant_id IS NULL`. ENABLE without FORCE: the adapter (owner) still
311
+ sees every row; Console / `oke_app` cannot list another tenant's ciphertext. Global `NULL` rows
312
+ stay visible to every stamped tenant.
313
+ </Callout>
314
+
297
315
  ## Managed providers (official)
298
316
 
299
317
  Set `drivers.vault` to `"managed"` and `OKE_VAULT_PROVIDER` to an id below. Omit the provider when
@@ -346,6 +364,13 @@ radius before you rotate, and `is:overdue` when a secret is past its
346
364
  Prefer `oke vault unseal --key -` and pipe the key on stdin, or omit `--key` on a
347
365
  TTY and type it at the hidden prompt. Avoid `--key <base64>` on shared hosts.
348
366
 
367
+ </Accordion>
368
+ <Accordion title="Console SQL lists another tenant's vault rows">
369
+
370
+ User-plane SQL is stamped `oke.tenant()`. The policy hides other tenants' ciphertext
371
+ and keeps global `NULL` rows. The Vault adapter is table owner, so ENABLE (not FORCE)
372
+ does not hide rows from itself.
373
+
349
374
  </Accordion>
350
375
  <Accordion title="Expired secrets still take space in Postgres">
351
376
 
@@ -365,6 +390,7 @@ Schedule the live command with cron when you want automatic cleanup.
365
390
 
366
391
  - [Project structure](/docs/get-started/project-structure) — `flow({…})` names as `unit.export`; `flow("billing.charge")` still wins
367
392
  - [Flow](/docs/elements/flow) — how `fx.vault.get` reads secrets inside `do`
393
+ - [Gate](/docs/elements/gate) — `perTenant` contracts when `gate.auth.tenant` is on
368
394
  - [CLI Reference](/docs/reference/cli) — `oke vault set` · `list` · `import`
369
395
 
370
396
  ## Next
@@ -701,7 +701,7 @@ Plugin `.binding()` is never inferred. File tree is app flows only.
701
701
 
702
702
  ```typescript title="flows/my/index.ts"
703
703
  export const tasks = on(
704
- http.get("/me/tasks").gate(member).live(),
704
+ http.get("/me/tasks").gate(member),
705
705
  flow("my.tasks", {
706
706
  do: async (_input, fx) => fx.store(db).select().from(tasks),
707
707
  }),
@@ -773,9 +773,9 @@ import { noteCreated } from "./signals";
773
773
 
774
774
  ### `http.resource` in a tree
775
775
 
776
- Five reserved leaves reproduce `GET|POST /notes` and `GET|PATCH|DELETE /notes/:id`, but not one shared `.gate()` / `.live()` and not `store.resource().all()` in a single declaration.
776
+ Five reserved leaves reproduce `GET|POST /notes` and `GET|PATCH|DELETE /notes/:id`, but not one shared `.gate()` and not `store.resource().all()` in a single declaration.
777
777
 
778
- Put `on(http.resource("/notes", ops).public().live())` in `route.ts`. Inference is skipped — the five verbs already declare those paths. Extra actions (`[id]/archive.ts`) sit beside it. Pathless `http.resource()` is not a thing.
778
+ Put `on(http.resource("/notes", ops).public())` in `route.ts`. Inference is skipped — the five verbs already declare those paths. Extra actions (`[id]/archive.ts`) sit beside it. Pathless `http.resource()` is not a thing.
779
779
 
780
780
  | Verb | Path |
781
781
  | -------- | ------------------- |
@@ -790,7 +790,7 @@ Put `on(http.resource("/notes", ops).public().live())` in `route.ts`. Inference
790
790
 
791
791
  ```typescript title="flows/notes/route.ts"
792
792
  const notesR = store.resource(db, notes, { in: NewNote, out: Note });
793
- export const mounted = on(http.resource("/notes", notesR.all()).public().live());
793
+ export const mounted = on(http.resource("/notes", notesR.all()).public());
794
794
  ```
795
795
 
796
796
  </Tab>
@@ -65,7 +65,7 @@ oke docker clean --yes # non-TTY: current project only
65
65
  oke docker clean --all --yes # non-TTY: every oke-dev-* project on this machine
66
66
  oke images pin # tags → digests in oke.images.lock
67
67
 
68
- oke build --target edge # < 15 kB kernel profile
68
+ oke build --target edge # < 16 kB kernel profile
69
69
  oke eval # run prompt eval sets; fails CI on regression
70
70
  oke ai setup # configure AI driver + models (TTY wizard or flags)
71
71
  oke branch prod --at "yesterday" # fork journaled state into a sandbox