okengine 0.16.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/manifest.v1.schema.json +21 -2
  2. package/package.json +1 -1
  3. package/site/content/docs/ai/llms-txt.mdx +9 -3
  4. package/site/content/docs/elements/clock.mdx +7 -0
  5. package/site/content/docs/elements/flow.mdx +14 -12
  6. package/site/content/docs/elements/gate.mdx +58 -8
  7. package/site/content/docs/elements/signal.mdx +46 -17
  8. package/site/content/docs/elements/store.mdx +23 -17
  9. package/site/content/docs/elements/vault.mdx +23 -0
  10. package/site/content/docs/get-started/project-structure.mdx +4 -4
  11. package/site/content/docs/reference/cli.md +1 -1
  12. package/site/content/docs/reference/client.mdx +72 -17
  13. package/site/content/docs/reference/configuration.mdx +7 -4
  14. package/site/content/docs/reference/errors.mdx +24 -17
  15. package/site/content/docs/reference/fx.mdx +15 -9
  16. package/src/auth/api-key-sql.ts +11 -4
  17. package/src/auth/api-keys.ts +3 -0
  18. package/src/auth/config.ts +19 -0
  19. package/src/auth/index.ts +37 -0
  20. package/src/auth/plugin.ts +6 -1
  21. package/src/auth/sessions.ts +18 -0
  22. package/src/auth/tables.ts +32 -0
  23. package/src/auth/tenant-config.ts +74 -0
  24. package/src/auth/tenant-tables.ts +11 -0
  25. package/src/auth/tenants.test.ts +63 -0
  26. package/src/auth/tenants.ts +360 -0
  27. package/src/cli/build.ts +2 -2
  28. package/src/client/budget.test.ts +1 -1
  29. package/src/client/create.ts +75 -5
  30. package/src/client/index.ts +13 -0
  31. package/src/client/live.test.ts +422 -0
  32. package/src/client/live.ts +389 -0
  33. package/src/client/notes-contract.test.ts +41 -0
  34. package/src/client/types.ts +85 -6
  35. package/src/client-react/index.ts +85 -1
  36. package/src/client-react/use-live.test.ts +129 -0
  37. package/src/compiler/effects-infer.ts +22 -2
  38. package/src/compiler/extract.test.ts +143 -11
  39. package/src/compiler/extract.ts +210 -30
  40. package/src/compiler/fixtures/skyport/src/flows/bookings/index.ts +1 -2
  41. package/src/compiler/fixtures/skyport.expected.json +2 -3
  42. package/src/compiler/response.ts +41 -11
  43. package/src/console/server/app.ts +44 -11
  44. package/src/console/server/console.test.ts +6 -8
  45. package/src/console/server/gates.ts +2 -9
  46. package/src/console/server/store.test.ts +17 -0
  47. package/src/console/server/store.ts +43 -1
  48. package/src/console/ui-next/dist/assets/{access-page-CAGHrA9H.js → access-page-9Wwx1g4Q.js} +1 -1
  49. package/src/console/ui-next/dist/assets/{flows-page-B-OUtiAu.js → flows-page-DydRDi70.js} +1 -1
  50. package/src/console/ui-next/dist/assets/{index-CGoZkILK.js → index-Cg9nxd6m.js} +3 -3
  51. package/src/console/ui-next/dist/assets/{observability-page-BDyXalNR.js → observability-page-D-OzmFOI.js} +1 -1
  52. package/src/console/ui-next/dist/assets/{store-page-Xh8Kn3rx.js → store-page-CS5-aETQ.js} +1 -1
  53. package/src/console/ui-next/dist/assets/{tree-expand-toggle-DkOXA12R.js → tree-expand-toggle-BtyhmWb4.js} +2 -1
  54. package/src/console/ui-next/dist/assets/{units-page-Dpk40kOQ.js → units-page-B_zeux2Y.js} +1 -1
  55. package/src/console/ui-next/dist/assets/{vault-page-B1dB9Ft0.js → vault-page-CsMg7XDW.js} +1 -1
  56. package/src/console/ui-next/dist/index.html +1 -1
  57. package/src/console/ui-next/src/features/flows/fixture.ts +0 -1
  58. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +5 -1
  59. package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +15 -4
  60. package/src/console/ui-next/ui-next-seed-manifest-surface.ts +2 -9
  61. package/src/console/ui-next/ui-next-seed-manifest.ts +0 -1
  62. package/src/drivers/index.ts +2 -0
  63. package/src/drivers/journal-postgres.ts +12 -3
  64. package/src/drivers/pg-rls.ts +26 -2
  65. package/src/drivers/pg-vault-rls.ts +68 -0
  66. package/src/drivers/signal-engine.ts +69 -15
  67. package/src/drivers/signal-live-iter.ts +65 -0
  68. package/src/drivers/signal-nats.ts +2 -1
  69. package/src/drivers/signal-postgres.ts +95 -12
  70. package/src/drivers/signal-redis.ts +2 -1
  71. package/src/drivers/signal-retention.ts +64 -0
  72. package/src/drivers/signal-types.ts +35 -5
  73. package/src/elements/clock/declare.ts +64 -2
  74. package/src/elements/clock/reconcile.ts +98 -25
  75. package/src/elements/clock/runtime.ts +13 -2
  76. package/src/elements/clock.test.ts +28 -0
  77. package/src/elements/clock.ts +9 -1
  78. package/src/elements/gate/permissions.ts +12 -0
  79. package/src/elements/gate.ts +6 -1
  80. package/src/elements/signal/declare.ts +44 -10
  81. package/src/elements/signal/delivery-modes.test.ts +90 -4
  82. package/src/elements/signal/order-lifecycle.test.ts +20 -4
  83. package/src/elements/signal/runtime.ts +42 -0
  84. package/src/elements/signal.test.ts +21 -8
  85. package/src/elements/signal.ts +1 -1
  86. package/src/elements/store/declare.ts +7 -0
  87. package/src/elements/store/rls-identity.test.ts +29 -0
  88. package/src/elements/store/rls-identity.ts +3 -0
  89. package/src/elements/store/schema-decl.ts +63 -3
  90. package/src/elements/store/schema-tenant.ts +41 -0
  91. package/src/elements/store/sql-rls-isolation.test.ts +39 -0
  92. package/src/elements/store.ts +2 -0
  93. package/src/elements/vault/builtin-adapter.ts +15 -2
  94. package/src/elements/vault/declare.ts +8 -0
  95. package/src/elements/vault/runtime.ts +7 -0
  96. package/src/elements/vault/sql-rls-isolation.test.ts +145 -0
  97. package/src/elements/vault/storage.ts +9 -0
  98. package/src/elements/vault/test-helpers.ts +2 -1
  99. package/src/i18n/catalogs/ar.ts +19 -0
  100. package/src/i18n/catalogs/en.ts +19 -0
  101. package/src/index.ts +1 -0
  102. package/src/kernel/adopt-routes.ts +56 -8
  103. package/src/kernel/app-tenant.ts +122 -0
  104. package/src/kernel/app.ts +183 -56
  105. package/src/kernel/auth-resolve.ts +3 -0
  106. package/src/kernel/boot-bind/clock.ts +9 -3
  107. package/src/kernel/boot.ts +2 -0
  108. package/src/kernel/budget.test.ts +1 -1
  109. package/src/kernel/clock-durable.ts +8 -0
  110. package/src/kernel/clock-per-tenant-name.ts +5 -0
  111. package/src/kernel/clock-reconcile.ts +8 -0
  112. package/src/kernel/errors-live-resume.ts +15 -0
  113. package/src/kernel/errors-tenant.ts +29 -0
  114. package/src/kernel/errors.registry.test.ts +31 -3
  115. package/src/kernel/errors.ts +43 -3
  116. package/src/kernel/flow.ts +26 -5
  117. package/src/kernel/fx-auth-keys.ts +6 -1
  118. package/src/kernel/fx-auth-tenants.test.ts +87 -0
  119. package/src/kernel/fx-auth-tenants.ts +286 -0
  120. package/src/kernel/fx-live-stream.ts +149 -0
  121. package/src/kernel/fx-live.test.ts +157 -0
  122. package/src/kernel/fx-runtime.ts +15 -0
  123. package/src/kernel/fx-tenant-store.ts +213 -0
  124. package/src/kernel/fx.test.ts +91 -0
  125. package/src/kernel/fx.ts +173 -13
  126. package/src/kernel/hooks.ts +2 -2
  127. package/src/kernel/http-resource.ts +9 -18
  128. package/src/kernel/index.ts +2 -0
  129. package/src/kernel/journal.ts +12 -0
  130. package/src/kernel/live-http.test.ts +78 -0
  131. package/src/kernel/live-http.ts +114 -0
  132. package/src/kernel/live-resume.test.ts +125 -0
  133. package/src/kernel/on.ts +51 -0
  134. package/src/kernel/pipeline-tenant.ts +49 -0
  135. package/src/kernel/pipeline.test.ts +1 -1
  136. package/src/kernel/pipeline.ts +37 -2
  137. package/src/kernel/resource-mount.test.ts +10 -27
  138. package/src/kernel/tenant-resolve.test.ts +101 -0
  139. package/src/kernel/tenant-resolve.ts +124 -0
  140. package/src/kernel/tenant-roles.test.ts +87 -0
  141. package/src/kernel/triggers.ts +59 -21
  142. package/src/manifest/diff.test.ts +11 -2
  143. package/src/manifest/diff.ts +53 -11
  144. package/src/manifest/fixtures/skyport.excerpt.json +1 -1
  145. package/src/manifest/fixtures/skyport.manifest.json +0 -1
  146. package/src/manifest/types.ts +52 -6
  147. package/src/release/build-lib.ts +13 -1
  148. package/src/release/limits.ts +2 -2
  149. package/src/release/measure.ts +55 -1
  150. package/src/client/live-gap.test.ts +0 -35
@@ -172,7 +172,7 @@ const { data } = await api.auth.signInEmail({ email, password });
172
172
  if (data) session.set(data);
173
173
  ```
174
174
 
175
- React: `useSession(api, session)` from `okengine/client-react`.
175
+ React: `useSession(api, session)` and `useLive(api, signal, input?)` from `okengine/client-react`.
176
176
 
177
177
  | Step | What happens |
178
178
  | ---------------------- | ---------------------------------------------------------------- |
@@ -204,6 +204,7 @@ A 401 with no `{ data, error }` body becomes `TransportError` with `data.status:
204
204
  | `memorySession` | `okengine/client/auth` | In-memory access/refresh bag for `auth.getToken` |
205
205
  | `AUTH_ERROR_CODES` | `okengine/client/auth` | Common auth Flow / gate codes |
206
206
  | `useSession(api, session?)` | `okengine/client-react` | React status + `auth.me` |
207
+ | `useLive(api, signal, …)` | `okengine/client-react` | React `events` / `latest` / `isConnected` |
207
208
 
208
209
  Core `okengine/client` stays under the size budget — helpers are separate exports. Not in
209
210
  core today: cookie jars or plugin `.client()` decorations. Browser apps: also see
@@ -221,7 +222,7 @@ core today: cookie jars or plugin `.client()` decorations. Browser apps: also se
221
222
  | [Flow](/docs/elements/flow) | Direct | `api.unit.flow(input)` — the only public surface |
222
223
  | [Gate](/docs/elements/gate) | Indirect | Bearer via `auth`; denials as `Unauthorized` / `Forbidden` / `RateLimited` |
223
224
  | [Store](/docs/elements/store) | Via Flows | `fx.store` inside Flows; `store.resource` + `on(http.resource…)` → five Flows on `$routes` |
224
- | [Signal](/docs/elements/signal) | Via Flows | Emit/consume server-side; no subscribe API on `okengine/client` yet |
225
+ | [Signal](/docs/elements/signal) | Live SSE | `api.live(signal, input?, { onEvent })` HTTP GET, callback + unsubscribe |
225
226
  | [Clock](/docs/elements/clock) | Via Flows | Schedules fire on the server — the client never ticks a clock |
226
227
  | [Vault](/docs/elements/vault) | Via Flows | Secrets stay server-side; never ship them to the browser package |
227
228
  | [Channel](/docs/elements/channel) | Via Flows | `fx.send` in a Flow — the client does not send email/SMS/push |
@@ -233,7 +234,7 @@ Mount a resource, adopt the returned ops, then call the five Flows like any othe
233
234
 
234
235
  ```typescript
235
236
  const notesR = store.resource(db, notes, {/* in, out, list */});
236
- const mounted = on(http.resource("/notes", notesR.all()).public().live());
237
+ const mounted = on(http.resource("/notes", notesR.all()).public());
237
238
  // .adopt({ notes: mounted }) →
238
239
  const page = await api.notes.list({ limit: 20 });
239
240
  const more = await page.next();
@@ -259,24 +260,63 @@ See [Store](/docs/elements/store) for the list query language and schemas. Handw
259
260
  `fx.json.withQuery(rows, input)` for the same envelope. Auth posture for HTTP triggers is
260
261
  covered under [Gate](/docs/elements/gate).
261
262
 
262
- ### Signal and live queries
263
+ ### Live signals
263
264
 
264
- `delivery: "live"` and `http.get(…).live()` are Manifest / driver flags today. `okengine/client`
265
- does **not** expose WebSocket, SSE, or `api.*.subscribe`. Until that ships, poll or call an HTTP
266
- Flow that returns the current state.
265
+ `delivery: "live"` is HTTP SSE. Expose with `.live(signal)` on GET (or `http.live(signal)` for
266
+ `GET /_oke/live/{name}`), then subscribe with a callback. `for await` stays on the server.
267
+
268
+ ```typescript
269
+ const stop = api.live(
270
+ orderStatus,
271
+ { orderId: "ord_1" },
272
+ {
273
+ onEvent: (event) => {
274
+ /* event: { orderId, status } */
275
+ },
276
+ onError: (err) => {
277
+ /* optional — 4xx, envelope, network drop */
278
+ },
279
+ autoResubscribe: false, // default — true reopens after a drop (500ms…30s backoff)
280
+ },
281
+ );
282
+ stop(); // useEffect cleanup
283
+ ```
284
+
285
+ `api.orders.events({ orderId }, { onEvent })` is the same shape on the exposing flow.
286
+
287
+ The client picks the unique exposure whose `matchKey` fields are a subset of the input, preferring
288
+ the largest match (`{ orderId }` beats firehose). A tie needs `via: "unit.flow"`.
289
+
290
+ Reconnects send `Last-Event-ID` from the last `id:` the client actually received.
291
+
292
+ A **410** `LiveResumeGap` (**OKE1014**) means that cursor is gone — `onError` fires, the
293
+ cursor is dropped, and `autoResubscribe` replays the remaining tape after backoff.
294
+
295
+ ```typescript
296
+ import { useLive } from "okengine/client-react";
297
+
298
+ const { events, latest, error, isConnected } = useLive(
299
+ api,
300
+ orderStatus,
301
+ { orderId: "ord_1" },
302
+ {
303
+ autoResubscribe: true,
304
+ },
305
+ );
306
+ ```
267
307
 
268
308
  ## Exports
269
309
 
270
- | Export | Kind | Role |
271
- | ----------------------------------------- | --------- | -------------------------------------------------- |
272
- | `createClient` | function | Typed proxy `api.unit.flow(input?)` |
273
- | `flattenRoutes` | function | `$routes` → flat `unit.flow` REST table |
274
- | `createTransport` | function | Low-level HTTP transport (timeout / retry / auth) |
275
- | `isOk` / `isFail` | function | Envelope predicates |
276
- | `isErrorCode` / `isTransportError` | function | Error narrowing |
277
- | `Client`, `ClientCall`, `ClientResult`, … | types | Contracts, `page.next()` / `for await` of `list()` |
278
- | `Register` | interface | Module-augmentation slot for ambient App types |
279
- | `AppOf` | type | Brand a bare route map as an App |
310
+ | Export | Kind | Role |
311
+ | ----------------------------------------- | --------- | --------------------------------------------------- |
312
+ | `createClient` | function | Typed proxy `api.unit.flow(input?)` plus `api.live` |
313
+ | `flattenRoutes` | function | `$routes` → flat `unit.flow` REST table |
314
+ | `createTransport` | function | Low-level HTTP transport (timeout / retry / auth) |
315
+ | `isOk` / `isFail` | function | Envelope predicates |
316
+ | `isErrorCode` / `isTransportError` | function | Error narrowing |
317
+ | `Client`, `ClientCall`, `ClientResult`, … | types | Contracts, `page.next()` / `for await` of `list()` |
318
+ | `Register` | interface | Module-augmentation slot for ambient App types |
319
+ | `AppOf` | type | Brand a bare route map as an App |
280
320
 
281
321
  Budget: the `./client` export stays under the measured client-runtime cap (hard gate in CI).
282
322
 
@@ -323,6 +363,20 @@ calls `api.auth.refresh({ refreshToken })`. Re-login when rotation fails or no r
323
363
 
324
364
  </Accordion>
325
365
 
366
+ <Accordion title="api.live throws Multiple live exposures">
367
+
368
+ Two routes share the same match shape. Pass `via: "unit.flow"` or call the exposing flow
369
+ (`api.orders.events(input, { onEvent })`).
370
+
371
+ </Accordion>
372
+
373
+ <Accordion title="onError sees LiveResumeGap / HTTP 410">
374
+
375
+ The last `id:` is not on the server tape. The client drops the cursor. With `autoResubscribe: true`
376
+ the next request omits `Last-Event-ID` and replays what remains.
377
+
378
+ </Accordion>
379
+
326
380
  </Accordions>
327
381
 
328
382
  ## Learn more
@@ -332,6 +386,7 @@ calls `api.auth.refresh({ refreshToken })`. Re-login when rotation fails or no r
332
386
  - [Gate](/docs/elements/gate) — policies, `gate.public`, denials
333
387
  - [Store](/docs/elements/store) — `store.resource` and list query language
334
388
  - [Flow](/docs/elements/flow) — `in` / `out` / `errors` and `fx.fail`
389
+ - [Signal](/docs/elements/signal) — `api.live` for `delivery: "live"` SSE
335
390
  - [Errors](/docs/reference/errors) — framework codes vs failure values
336
391
  - [CORS](/docs/plugins/cors) · [CSRF](/docs/plugins/csrf) — browser callers
337
392
  - [CLI Reference](/docs/reference/cli) — `oke client add`, `oke dev`
@@ -181,10 +181,13 @@ i18n: { locales: ["en"], default: "en" },
181
181
 
182
182
  ## tenancy
183
183
 
184
- | Option | Type | Meaning |
185
- | ----------- | --------------------------------- | ---------------------------------------- |
186
- | `isolation` | `"row" \| "schema" \| "database"` | How tenants are separated in the store |
187
- | `resolve` | string \| function | Tenant resolver — reads `ctx.auth.orgId` |
184
+ `oke.config.ts` `tenancy` is **isolation posture** (how rows are separated). Identity —
185
+ who the tenant is is `gate.auth.tenant` on [Gate](/docs/elements/gate#tenants-identity-dimension).
186
+
187
+ | Option | Type | Meaning |
188
+ | ----------- | --------------------------------- | ------------------------------------------------- |
189
+ | `isolation` | `"row" \| "schema" \| "database"` | How tenants are separated in the store |
190
+ | `resolve` | string \| function | Observational resolver (isolation-only manifests) |
188
191
 
189
192
  ## privacy
190
193
 
@@ -29,18 +29,25 @@ string. Custom app codes stay message-less until registered.
29
29
 
30
30
  ## OKE numeric codes
31
31
 
32
- | Code | Name | Cause | Fix |
33
- | ------ | ----------------- | ----------------------------------------------- | -------------------------------------------------- |
34
- | `1001` | undeclared read | Flow reads a resource not in `effects.reads` | Add it to the flow's `effects.reads` |
35
- | `1002` | undeclared write | Flow writes a resource not in `effects.writes` | Add it to the flow's `effects.writes` |
36
- | `1003` | undeclared emit | Flow emits a signal not in `effects.emits` | Add it to the flow's `effects.emits` |
37
- | `1004` | undeclared send | Flow sends a template not in `effects.sends` | Add it to the flow's `effects.sends` |
38
- | `1005` | undeclared ask | Flow asks a prompt not in `effects.asks` | Add it to the flow's `effects.asks` |
39
- | `1006` | undeclared secret | Flow reads a secret not in `effects.secrets` | Add it to the flow's `effects.secrets` |
40
- | `1007` | undeclared call | Flow calls a flow not in `effects.calls` | Add it to the flow's `effects.calls` |
41
- | `1042` | orphan emit | Emit with zero subscribers and `optional` false | Add `on(signal, …)` or declare `optional: true` |
42
- | `1043` | signal schema | Emit payload failed the signal's `schema` | Pass a payload that matches `schema`, or remove it |
43
- | `1101` | schema missing | Domain table absent in `prod` no auto-DDL | Run `oke db migrate` against this environment |
32
+ | Code | Name | Cause | Fix |
33
+ | ------ | -------------------- | ------------------------------------------------- | ---------------------------------------------------- |
34
+ | `1001` | undeclared read | Flow reads a resource not in `effects.reads` | Add it to the flow's `effects.reads` |
35
+ | `1002` | undeclared write | Flow writes a resource not in `effects.writes` | Add it to the flow's `effects.writes` |
36
+ | `1003` | undeclared emit | Flow emits a signal not in `effects.emits` | Add it to the flow's `effects.emits` |
37
+ | `1004` | undeclared send | Flow sends a template not in `effects.sends` | Add it to the flow's `effects.sends` |
38
+ | `1005` | undeclared ask | Flow asks a prompt not in `effects.asks` | Add it to the flow's `effects.asks` |
39
+ | `1006` | undeclared secret | Flow reads a secret not in `effects.secrets` | Add it to the flow's `effects.secrets` |
40
+ | `1007` | undeclared call | Flow calls a flow not in `effects.calls` | Add it to the flow's `effects.calls` |
41
+ | `1011` | HTTP route clash | Two HTTP flows share the same method + path | Give each flow a unique method + path |
42
+ | `1012` | HTTP flow unnamed | Adopted HTTP flow still has no `unit.export` | Export from `flows/<unit>/` or pass a named `flow` |
43
+ | `1013` | live exposure dup | Same signal, gates, and match on two GET routes | Change the gate or path-param filter |
44
+ | `1014` | live resume gap | `Last-Event-ID` is not on the retained tape | Reconnect without the cursor; remaining tape replays |
45
+ | `1015` | tenant required | Tenant-scoped op with no `fx.tenant.id` | `switchTenant`, signed `tid`, or tenant header |
46
+ | `1016` | tenant not member | Client-supplied tenant id is not a membership | Pick from `listTenants` or add the user as a member |
47
+ | `1017` | tenant unknown scope | Tenant role used an invented or `console:*` scope | Use a declared application scope |
48
+ | `1042` | orphan emit | Emit with zero subscribers and `optional` false | Add `on(signal, …)` or declare `optional: true` |
49
+ | `1043` | signal schema | Emit payload failed the signal's `schema` | Pass a payload that matches `schema`, or remove it |
50
+ | `1101` | schema missing | Domain table absent in `prod` — no auto-DDL | Run `oke db migrate` against this environment |
44
51
 
45
52
  <Callout title="Effects are usually inferred">
46
53
  The 1001–1007 family exists for flows that declare effects explicitly. Most apps never write an
@@ -52,11 +59,11 @@ string. Custom app codes stay message-less until registered.
52
59
 
53
60
  Returned, not thrown — the request never reached `do`:
54
61
 
55
- | Code | When | Payload |
56
- | -------------- | -------------------------------------------- | ---------------- |
57
- | `Unauthorized` | Policy denied, request not authenticated | — |
58
- | `Forbidden` | Policy denied, authenticated but not allowed | `gate`, `reason` |
59
- | `RateLimited` | Rate gate budget exhausted | `retryAfterMs` |
62
+ | Code | When | Payload |
63
+ | -------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------ |
64
+ | `Unauthorized` | Policy denied, request not authenticated | — |
65
+ | `Forbidden` | Policy denied, authenticated but not allowed | `gate`, `reason` (`tenant_required` · `not_member` · `unknown_scope` · `session_only` · …) |
66
+ | `RateLimited` | Rate gate budget exhausted | `retryAfterMs` |
60
67
 
61
68
  ## Framework validation failures
62
69
 
@@ -42,6 +42,7 @@ See [Store](/docs/elements/store) for the query-builder surface.
42
42
  | ------------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
43
43
  | `fx.emit(signal, payload?, { key? })` | `emit` | Commits the signal outbox when the call resolves; optional `key` serializes `once` per key; stamps producer run id as `parentRunId` for trace chains; throws **OKE1042** (orphan) or **OKE1043** (schema) |
44
44
  | `fx.deadLetters(signal)` | `read` `signal:<name>` | Dead-lettered messages for that signal. Payload typed from `SignalDecl<T>`. Page with `fx.json.withQuery`. Cross-signal throws **OKE1001**. |
45
+ | `fx.live(signal, { match? })` | `read` `signal:<name>` | Live tape as SSE. Returns `JsonStreamResult` (object chunks, `id:` on the wire). Cross-signal throws **OKE1001**. Do not wrap with `fx.json.stream`. |
45
46
 
46
47
  ## Runs (observability read)
47
48
 
@@ -92,10 +93,14 @@ on(
92
93
  | `fx.auth.revokeApiKey(id)` | `write` `auth:api-keys` | Owner only |
93
94
  | `fx.auth.rotateApiKey(id)` | `write` `auth:api-keys` | New secret once. Owner only |
94
95
  | `fx.auth.updateApiKey(id, …)` | `write` `auth:api-keys` | Name / scopes / expiry / allowlist / rate. Re-attenuates |
96
+ | `fx.auth.listTenants()` | `read` `auth:tenants` | Memberships for the live session. Session only |
97
+ | `fx.auth.switchTenant(id)` | `write` `auth:tenants` | New access+refresh, new family, `tid` on both. Never Set-Cookie. Session only |
98
+ | `fx.auth.createTenant({ name, slug?, id? })` | `write` `auth:tenants` | Creator becomes a member. Session only |
99
+ | `fx.auth.upsertTenantRole({ tenantId, roleName, scopes })` | `write` `auth:tenants` | Application scopes only — `console:*` is unknown_scope |
95
100
 
96
- `fx.call` starts the callee with an **empty** `fx.auth` (fail-closed for authorization). For
97
- audit/attribution only, read `fx.principal` — it propagates the originating identity without
98
- copying into `fx.auth`. Gates never consult `fx.principal`.
101
+ `fx.call` starts the callee with an **empty** `fx.auth` (fail-closed for authorization) and
102
+ propagates `fx.tenant.id`. For audit/attribution only, read `fx.principal` — it propagates the
103
+ originating identity without copying into `fx.auth`. Gates never consult `fx.principal`.
99
104
 
100
105
  ## Concurrency and retry
101
106
 
@@ -214,6 +219,7 @@ keys. Use `cache: false` to opt out, or `cache: "30s"` for a TTL.
214
219
  | `fx.json.with(page)` / `with(data, meta)` | 200 | `{ data, meta, error: null }` — already-built pager |
215
220
  | `fx.json.withQuery(rows, input, spec?)` | 200 | In-memory list page — zero-config `q` / auto-eq / PostgREST |
216
221
  | `fx.json.stream(chunks)` | 200 | `text/event-stream` — JSON `data:` frames, then `data: [DONE]` |
222
+ | `fx.live(signal)` | 200 | Same SSE carrier for a live signal (payload frames + `id:`) |
217
223
 
218
224
  Returning a plain value instead answers 200 with `{ data: value, error: null }` — the helpers exist for status and `meta` control. Pass `fx.stream(...)` into `fx.json.stream` to reach the HTTP client token-by-token.
219
225
 
@@ -275,12 +281,12 @@ Built-in `errors.*` / `oke.*` catalogs localize `fx.fail` messages and thrown
275
281
 
276
282
  ## Principals
277
283
 
278
- | Property | Shape |
279
- | -------------- | -------------------------------------------------------------------------- |
280
- | `fx.auth` | `{ userId, scopes, verified?, apiKeyId? }` plus key methods (session only) |
281
- | `fx.operator` | `{ id: string \| null }` — Console plane |
282
- | `fx.principal` | Read-only origin: `userId`, `operatorId`, `scopes`, `verified?`, `plane?` |
283
- | `fx.tenant` | `{ id: string \| null }` — active tenant |
284
+ | Property | Shape |
285
+ | -------------- | ------------------------------------------------------------------------------------- |
286
+ | `fx.auth` | `{ userId, scopes, verified?, apiKeyId? }` plus key and tenant methods (session only) |
287
+ | `fx.operator` | `{ id: string \| null }` — Console plane |
288
+ | `fx.principal` | Read-only origin: `userId`, `operatorId`, `scopes`, `verified?`, `plane?` |
289
+ | `fx.tenant` | `{ id: string \| null }` — active tenant (propagates on `fx.call`) |
284
290
 
285
291
  **Consequence:** use `fx.auth` / gates for authorization; use `fx.principal` only when a callee
286
292
  must log who started the call chain. A key Bearer sets `userId` to the issuer and `apiKeyId`
@@ -32,8 +32,8 @@ export async function persistApiKeyRow(
32
32
  await sql.execute(
33
33
  `INSERT INTO ${table} (
34
34
  id, plane, hash, name, scopes, expires_at, rate_limit, ip_allowlist,
35
- creator_id, creator_scopes, created_at, last_used_at, revoked_at
36
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
35
+ creator_id, creator_scopes, created_at, last_used_at, revoked_at, tenant_id
36
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
37
37
  ON CONFLICT(id) DO UPDATE SET
38
38
  hash = excluded.hash,
39
39
  name = excluded.name,
@@ -44,7 +44,8 @@ export async function persistApiKeyRow(
44
44
  creator_id = excluded.creator_id,
45
45
  creator_scopes = excluded.creator_scopes,
46
46
  last_used_at = excluded.last_used_at,
47
- revoked_at = excluded.revoked_at`,
47
+ revoked_at = excluded.revoked_at,
48
+ tenant_id = excluded.tenant_id`,
48
49
  [
49
50
  row.id,
50
51
  row.plane,
@@ -59,6 +60,7 @@ export async function persistApiKeyRow(
59
60
  row.createdAt,
60
61
  row.lastUsedAt,
61
62
  row.revokedAt,
63
+ row.tenantId ?? null,
62
64
  ],
63
65
  );
64
66
  }
@@ -140,9 +142,11 @@ export async function ensureApiKeyTable(
140
142
  creator_scopes TEXT NOT NULL DEFAULT '[]',
141
143
  created_at BIGINT NOT NULL,
142
144
  last_used_at BIGINT,
143
- revoked_at BIGINT
145
+ revoked_at BIGINT,
146
+ tenant_id TEXT
144
147
  )
145
148
  `);
149
+ await conn.exec(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS tenant_id TEXT`);
146
150
  }
147
151
 
148
152
  /**
@@ -203,6 +207,9 @@ function rowFromSql(raw: Record<string, unknown>): ApiKeyRow | null {
203
207
  createdAt: asNumberOrNull(raw.created_at ?? raw.createdAt) ?? 0,
204
208
  lastUsedAt: asNumberOrNull(raw.last_used_at ?? raw.lastUsedAt),
205
209
  revokedAt: asNumberOrNull(raw.revoked_at ?? raw.revokedAt),
210
+ ...(asString(raw.tenant_id ?? raw.tenantId) !== null
211
+ ? { tenantId: asString(raw.tenant_id ?? raw.tenantId) }
212
+ : {}),
206
213
  };
207
214
  }
208
215
 
@@ -31,6 +31,8 @@ export interface CreateApiKeyOptions {
31
31
  readonly expiresAt?: number | null;
32
32
  readonly rateLimit?: { max: number; per: string } | null;
33
33
  readonly ipAllowlist?: readonly string[];
34
+ /** Optional tenant claim copied onto the key principal. */
35
+ readonly tenantId?: string | null;
34
36
  /** Injectable id / secret / hash (tests). */
35
37
  readonly id?: string;
36
38
  readonly secret?: string;
@@ -163,6 +165,7 @@ export async function createApiKey(
163
165
  createdAt: now(),
164
166
  lastUsedAt: null,
165
167
  revokedAt: null,
168
+ ...(options.tenantId !== undefined ? { tenantId: options.tenantId } : {}),
166
169
  };
167
170
  store.keys.set(id, row);
168
171
  await persistRow(store, row);
@@ -16,6 +16,12 @@ import {
16
16
  type ResolvedAuthSchema,
17
17
  } from "./schema.ts";
18
18
  import { sessionCryptoFromAuthOptions, type AuthSessionOptions } from "./plugin.ts";
19
+ import { createTenantStore, type TenantStore } from "./tenants.ts";
20
+ import {
21
+ resolveTenantAuth,
22
+ type ResolvedTenantAuth,
23
+ type TenantAuthOptions,
24
+ } from "./tenant-config.ts";
19
25
 
20
26
  /** Email + password method knobs. */
21
27
  export interface EmailAndPasswordOptions {
@@ -115,6 +121,15 @@ export interface GateAuthOptions extends AuthSchemaOptions {
115
121
  * (`gate.auth.secret` is the HMAC pepper).
116
122
  */
117
123
  readonly apiKeyStore?: ApiKeyStore;
124
+ /**
125
+ * Multi-tenancy as an identity dimension. `true` enables defaults
126
+ * (`source: "claim"`, `required: false`). Off when omitted.
127
+ */
128
+ readonly tenant?: boolean | TenantAuthOptions;
129
+ /**
130
+ * Shared tenant registry. Created automatically when {@link tenant} is on.
131
+ */
132
+ readonly tenantStore?: TenantStore;
118
133
  /** Injectable clock. */
119
134
  readonly now?: () => number;
120
135
  }
@@ -171,6 +186,8 @@ export interface ResolvedGateAuth {
171
186
  readonly hooks: AuthDatabaseHooks | undefined;
172
187
  readonly sessions: SessionStore | undefined;
173
188
  readonly apiKeyStore: ApiKeyStore;
189
+ readonly tenant: ResolvedTenantAuth | undefined;
190
+ readonly tenantStore: TenantStore | undefined;
174
191
  readonly now: (() => number) | undefined;
175
192
  }
176
193
 
@@ -279,6 +296,8 @@ export function resolveGateAuth(options: ResolveGateAuthOptions): ResolvedGateAu
279
296
  hooks: auth.hooks,
280
297
  sessions: auth.sessions,
281
298
  apiKeyStore: auth.apiKeyStore ?? createApiKeyStore({ pepper: secret }),
299
+ tenant: auth.tenant ? resolveTenantAuth(auth.tenant) : undefined,
300
+ tenantStore: auth.tenant ? (auth.tenantStore ?? createTenantStore()) : auth.tenantStore,
282
301
  now: auth.now,
283
302
  };
284
303
  }
package/src/auth/index.ts CHANGED
@@ -260,4 +260,41 @@ export {
260
260
  type RoleRow,
261
261
  type SessionRow,
262
262
  type TablePlane,
263
+ type TenantMemberRow,
264
+ type TenantRoleRow,
265
+ type TenantRow,
263
266
  } from "./tables.ts";
267
+
268
+ export { AUTH_TENANT_TABLES } from "./tenant-tables.ts";
269
+
270
+ export {
271
+ AUTH_TENANTS_RESOURCE,
272
+ DEFAULT_TENANT_ROLE,
273
+ TenantError,
274
+ addMember,
275
+ createTenant,
276
+ createTenantStore,
277
+ deleteTenant,
278
+ getMember,
279
+ isMember,
280
+ listMembers,
281
+ listTenantIds,
282
+ listTenantsForUser,
283
+ removeMember,
284
+ tenantRoleScopeFailure,
285
+ tenantScopesForMember,
286
+ upsertTenantRole,
287
+ type CreateTenantOptions,
288
+ type TenantPublicRow,
289
+ type TenantStore,
290
+ type TenantStoreHooks,
291
+ type UpsertTenantRoleOptions,
292
+ } from "./tenants.ts";
293
+
294
+ export {
295
+ resolveTenantAuth,
296
+ type ResolvedTenantAuth,
297
+ type TenantAuthOptions,
298
+ type TenantResolveContext,
299
+ type TenantSource,
300
+ } from "./tenant-config.ts";
@@ -9,6 +9,7 @@ import { plugin, type PluginDef } from "../kernel/plugin.ts";
9
9
  import type { BreachCheckFn } from "./breach-check.ts";
10
10
  import type { PasswordPolicyOptions } from "./password-policy.ts";
11
11
  import { AUTH_TABLES } from "./tables.ts";
12
+ import { AUTH_TENANT_TABLES } from "./tenant-tables.ts";
12
13
 
13
14
  /** Session knobs beyond raw access/refresh TTLs. */
14
15
  export interface AuthSessionOptions {
@@ -83,7 +84,11 @@ export function auth(options: AuthPluginOptions = {}): PluginDef {
83
84
  /* principals resolved by session / API key middleware at runtime */
84
85
  });
85
86
 
86
- for (const name of Object.values(AUTH_TABLES)) {
87
+ const tables: readonly string[] = [
88
+ ...Object.values(AUTH_TABLES),
89
+ ...Object.values(AUTH_TENANT_TABLES),
90
+ ];
91
+ for (const name of tables) {
87
92
  builder = builder.table(name, undefined, {
88
93
  plane: name.includes("operator")
89
94
  ? "operator"
@@ -43,6 +43,11 @@ export interface AccessClaims {
43
43
  * Validated when the verifier supplies {@link VerifyAccessOptions.audience}.
44
44
  */
45
45
  readonly aud?: string;
46
+ /**
47
+ * Active tenant for this access token (request-scoped).
48
+ * Never stored on the session row — switching issues a new token pair.
49
+ */
50
+ readonly tid?: string;
46
51
  }
47
52
 
48
53
  /** Options for {@link createSessionStore}. */
@@ -106,6 +111,8 @@ export async function issueSession(
106
111
  readonly id: string;
107
112
  readonly plane: AuthPlane;
108
113
  readonly scopes: Iterable<string>;
114
+ /** Optional tenant claim (not stored on the session row). */
115
+ readonly tenantId?: string | null;
109
116
  },
110
117
  ): Promise<IssuedSession> {
111
118
  const now = crypto.now ?? (() => Date.now());
@@ -136,6 +143,7 @@ export async function issueSession(
136
143
  store.sessions.set(sessionId, session);
137
144
 
138
145
  const refreshRaw = `rt_${cryptoRandomId()}`;
146
+ const tid = principal.tenantId ?? undefined;
139
147
  const refreshRow: RefreshTokenRow = {
140
148
  id: cryptoRandomId(),
141
149
  sessionId,
@@ -144,6 +152,7 @@ export async function issueSession(
144
152
  expiresAt: t + refreshTtl,
145
153
  usedAt: null,
146
154
  revokedAt: null,
155
+ ...(tid !== undefined && tid !== "" ? { tid } : {}),
147
156
  };
148
157
  store.refresh.set(refreshRow.id, refreshRow);
149
158
 
@@ -156,6 +165,7 @@ export async function issueSession(
156
165
  iat: t,
157
166
  exp: accessExpiresAt,
158
167
  ...(crypto.audience !== undefined ? { aud: crypto.audience } : {}),
168
+ ...(tid !== undefined && tid !== "" ? { tid } : {}),
159
169
  });
160
170
 
161
171
  return { session, accessToken, refreshToken: refreshRaw, accessExpiresAt };
@@ -211,6 +221,7 @@ export async function rotateRefresh(
211
221
  const refreshTtl = crypto.refreshTtlMs ?? REFRESH_TTL_MS;
212
222
 
213
223
  const newRefreshRaw = `rt_${cryptoRandomId()}`;
224
+ const rotatedTid = existing.tid;
214
225
  const newRefresh: RefreshTokenRow = {
215
226
  id: cryptoRandomId(),
216
227
  sessionId: session.id,
@@ -219,6 +230,9 @@ export async function rotateRefresh(
219
230
  expiresAt: t + refreshTtl,
220
231
  usedAt: null,
221
232
  revokedAt: null,
233
+ ...(rotatedTid !== undefined && rotatedTid !== null && rotatedTid !== ""
234
+ ? { tid: rotatedTid }
235
+ : {}),
222
236
  };
223
237
  store.refresh.set(newRefresh.id, newRefresh);
224
238
 
@@ -231,6 +245,9 @@ export async function rotateRefresh(
231
245
  iat: t,
232
246
  exp: accessExpiresAt,
233
247
  ...(session.audience !== undefined ? { aud: session.audience } : {}),
248
+ ...(rotatedTid !== undefined && rotatedTid !== null && rotatedTid !== ""
249
+ ? { tid: rotatedTid }
250
+ : {}),
234
251
  });
235
252
 
236
253
  return {
@@ -291,6 +308,7 @@ export async function issueSessionWithScopes(
291
308
  readonly id: string;
292
309
  readonly plane: AuthPlane;
293
310
  readonly scopes: Iterable<string>;
311
+ readonly tenantId?: string | null;
294
312
  },
295
313
  ): Promise<IssuedSession> {
296
314
  return issueSession(store, crypto, principal);
@@ -98,6 +98,8 @@ export interface ApiKeyRow {
98
98
  lastUsedAt: number | null;
99
99
  /** Epoch-ms when revoked; `null` while active. */
100
100
  revokedAt: number | null;
101
+ /** Optional tenant claim (tier-1 API-key tenancy). */
102
+ tenantId?: string | null;
101
103
  }
102
104
 
103
105
  /** Pending operator invitation (invite-only plane). */
@@ -136,4 +138,34 @@ export interface RefreshTokenRow {
136
138
  expiresAt: number;
137
139
  usedAt: number | null;
138
140
  revokedAt: number | null;
141
+ /** Tenant id stamped on this refresh row (request-scoped; not on the session). */
142
+ tid?: string | null;
143
+ }
144
+
145
+ /** Tenant registry row. */
146
+ export interface TenantRow {
147
+ id: string;
148
+ name: string;
149
+ slug: string | null;
150
+ createdAt: number;
151
+ createdBy: string;
152
+ }
153
+
154
+ /** N:N membership — one user, many tenants; one tenant, many users. */
155
+ export interface TenantMemberRow {
156
+ id: string;
157
+ tenantId: string;
158
+ userId: string;
159
+ /** Tenant role name (expanded via {@link TenantRoleRow}). */
160
+ role: string;
161
+ createdAt: number;
162
+ }
163
+
164
+ /** Tenant-scoped role → application scope names. */
165
+ export interface TenantRoleRow {
166
+ tenantId: string;
167
+ roleName: string;
168
+ scopes: string[];
169
+ createdAt: number;
170
+ updatedAt: number;
139
171
  }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * `gate.auth.tenant` config — identity dimension opt-in.
3
+ */
4
+
5
+ /** How the request names a tenant. */
6
+ export type TenantSource = "claim" | "header" | "subdomain" | "resolve";
7
+
8
+ /** Public `gate.auth.tenant` bag (boolean shorthand or options). */
9
+ export interface TenantAuthOptions {
10
+ /**
11
+ * Pure B2B: every user-plane request needs a tenant.
12
+ * Default `false` (B2C+B2B coexistence).
13
+ */
14
+ readonly required?: boolean;
15
+ /** Resolution source. Default `"claim"`. */
16
+ readonly source?: TenantSource;
17
+ /** Header name for `source: "header"`. Default `x-oke-tenant`. */
18
+ readonly header?: string;
19
+ /**
20
+ * Tier-3 escape hatch. When it returns an id, membership is still
21
+ * checked unless {@link TenantAuthOptions.authoritative} is true.
22
+ */
23
+ readonly resolve?: (ctx: TenantResolveContext) => string | null | undefined;
24
+ /**
25
+ * When true, {@link TenantAuthOptions.resolve} is trusted without a
26
+ * membership query. Default false (fail-safe).
27
+ */
28
+ readonly authoritative?: boolean;
29
+ }
30
+
31
+ /** Context passed to {@link TenantAuthOptions.resolve}. */
32
+ export interface TenantResolveContext {
33
+ readonly auth: {
34
+ readonly userId: string | null;
35
+ readonly tenantId?: string | null;
36
+ };
37
+ readonly request?: Request;
38
+ readonly claimTenantId: string | null;
39
+ }
40
+
41
+ /** Fully resolved tenant auth config. */
42
+ export interface ResolvedTenantAuth {
43
+ readonly enabled: true;
44
+ readonly required: boolean;
45
+ readonly source: TenantSource;
46
+ readonly header: string;
47
+ readonly resolve?: TenantAuthOptions["resolve"];
48
+ readonly authoritative: boolean;
49
+ }
50
+
51
+ /**
52
+ * Normalize `gate.auth.tenant`.
53
+ *
54
+ * @param input - `true` or options bag
55
+ */
56
+ export function resolveTenantAuth(input: true | TenantAuthOptions): ResolvedTenantAuth {
57
+ if (input === true) {
58
+ return {
59
+ enabled: true,
60
+ required: false,
61
+ source: "claim",
62
+ header: "x-oke-tenant",
63
+ authoritative: false,
64
+ };
65
+ }
66
+ return {
67
+ enabled: true,
68
+ required: input.required === true,
69
+ source: input.source ?? "claim",
70
+ header: input.header ?? "x-oke-tenant",
71
+ ...(input.resolve !== undefined ? { resolve: input.resolve } : {}),
72
+ authoritative: input.authoritative === true,
73
+ };
74
+ }