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
@@ -10,11 +10,14 @@
10
10
  * Gate denial is a typed error value, never a thrown exception.
11
11
  */
12
12
 
13
+ import type { ResolvedTenantAuth } from "../auth/tenant-config.ts";
14
+ import type { TenantStore } from "../auth/tenants.ts";
13
15
  import type { GateEvaluation, GateRuntime } from "../elements/gate.ts";
14
16
  import type { GatePolicyContext } from "../elements/gate/declare.ts";
15
17
  import { fail, type FlowFailure } from "./errors.ts";
16
18
  import type { Fx, FxAuth, FxOperator } from "./fx.ts";
17
19
  import type { HookFn, InvocationContext } from "./hooks.ts";
20
+ import { lazyRequire } from "./lazy-require.ts";
18
21
  import type { RunTelemetry } from "./run-telemetry.ts";
19
22
  import type { HttpTrigger, Trigger } from "./triggers.ts";
20
23
 
@@ -23,11 +26,17 @@ export interface PrincipalBag {
23
26
  readonly auth: {
24
27
  userId: string | null;
25
28
  scopes: Set<string>;
29
+ /** JWT / session scopes — never mutated by tenant-role union. */
30
+ sessionScopes?: Set<string>;
26
31
  verified?: boolean;
32
+ apiKeyId?: string | null;
27
33
  };
28
34
  readonly operator: {
29
35
  id: string | null;
30
36
  };
37
+ readonly tenant: {
38
+ id: string | null;
39
+ };
31
40
  }
32
41
 
33
42
  /** Resolved identity from auth middleware / test harness. */
@@ -39,6 +48,8 @@ export interface ResolvedPrincipal {
39
48
  readonly verified?: boolean;
40
49
  /** Authenticating API key id when Bearer was a key secret. */
41
50
  readonly apiKeyId?: string;
51
+ /** Signed `tid` / API-key tenant claim (tier 1). */
52
+ readonly tenantId?: string | null;
42
53
  }
43
54
 
44
55
  /** Dependencies for {@link createElementPipelineHooks}. */
@@ -53,7 +64,7 @@ export interface PipelineDeps {
53
64
  * @returns Principal on success
54
65
  * @throws On forge / expiry / revoke — pipeline maps to `Unauthorized`
55
66
  */
56
- readonly verifyBearer?: (token: string) => Promise<ResolvedPrincipal>;
67
+ readonly verifyBearer?: (token: string, request?: Request) => Promise<ResolvedPrincipal>;
57
68
  /**
58
69
  * Optional token extraction (e.g. cookie → Bearer when cookies enabled).
59
70
  * When omitted, only `Authorization: Bearer` is read.
@@ -76,6 +87,20 @@ export interface PipelineDeps {
76
87
  readonly principals: PrincipalBag;
77
88
  /** Telemetry collector for the current run (gates dimension). */
78
89
  readonly telemetry: RunTelemetry;
90
+ /**
91
+ * When set, resolve `fx.tenant` after the principal and optionally union
92
+ * tenant-role scopes into the live auth bag.
93
+ */
94
+ readonly tenant?: PipelineTenantDeps;
95
+ }
96
+
97
+ /** Tenant identity + conditional scope union (user-plane tenant-scoped flows). */
98
+ export interface PipelineTenantDeps {
99
+ readonly config: ResolvedTenantAuth;
100
+ readonly store: TenantStore;
101
+ /** Default true when tenancy is on; `flow({ tenantScoped: false })` opts out. */
102
+ readonly flowTenantScoped: boolean;
103
+ readonly flowPlane?: "user" | "operator";
79
104
  }
80
105
 
81
106
  /**
@@ -152,10 +177,14 @@ export function applyPrincipal(bag: PrincipalBag, resolved: ResolvedPrincipal |
152
177
  if (resolved.userId !== undefined) bag.auth.userId = resolved.userId;
153
178
  if (resolved.scopes !== undefined) {
154
179
  bag.auth.scopes.clear();
155
- for (const s of resolved.scopes) bag.auth.scopes.add(s);
180
+ for (const s of resolved.scopes) {
181
+ bag.auth.scopes.add(s);
182
+ }
156
183
  }
157
184
  if (resolved.verified !== undefined) bag.auth.verified = resolved.verified;
185
+ bag.auth.apiKeyId = resolved.apiKeyId ?? null;
158
186
  }
187
+ if (resolved.tenantId !== undefined) bag.tenant.id = resolved.tenantId;
159
188
  }
160
189
 
161
190
  /**
@@ -192,12 +221,12 @@ export function createElementPipelineHooks(deps: PipelineDeps): {
192
221
  return fail("Unauthorized", {});
193
222
  }
194
223
  try {
195
- const principal = await deps.verifyBearer(token);
224
+ const principal = await deps.verifyBearer(token, ctx.request);
196
225
  applyPrincipal(deps.principals, principal);
197
226
  if (principal.apiKeyId) {
198
227
  deps.telemetry.dimensions.api_key = principal.apiKeyId;
199
228
  }
200
- return;
229
+ return applyTenant(deps, ctx);
201
230
  } catch {
202
231
  // Forge / expiry / revoke → typed Unauthorized (never throw).
203
232
  return fail("Unauthorized", {});
@@ -209,6 +238,7 @@ export function createElementPipelineHooks(deps: PipelineDeps): {
209
238
  const fromState = ctx.state.principal as ResolvedPrincipal | undefined;
210
239
  if (fromState) applyPrincipal(deps.principals, fromState);
211
240
  }
241
+ return applyTenant(deps, ctx);
212
242
  };
213
243
 
214
244
  const beforeHandle: HookFn = async (ctx, fxOrErr) => {
@@ -233,6 +263,13 @@ export function createElementPipelineHooks(deps: PipelineDeps): {
233
263
  return { onAuth, beforeHandle };
234
264
  }
235
265
 
266
+ function applyTenant(deps: PipelineDeps, ctx: InvocationContext): FlowFailure | undefined {
267
+ if (!deps.tenant) return undefined;
268
+ return lazyRequire<{
269
+ run: (d: PipelineDeps, c: InvocationContext) => FlowFailure | undefined;
270
+ }>(import.meta.dir, ["pipeline", "tenant"].join("-")).run(deps, ctx);
271
+ }
272
+
236
273
  /**
237
274
  * Build a policy context from the live `fx` principals.
238
275
  *
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `http.resource(path, ops).gate(...).live()` — same chain as `http.get`.
2
+ * `http.resource(path, ops).gate(...)` — same chain as `http.get`.
3
3
  */
4
4
 
5
5
  import { describe, expect, test } from "bun:test";
@@ -28,21 +28,21 @@ function httpOf(path: string, method: string): HttpTrigger | undefined {
28
28
  return hit?.trigger.kind === "http" ? hit.trigger : undefined;
29
29
  }
30
30
 
31
- describe("http.resource — gate / live", () => {
32
- test("bare mount has no gates and is not live", () => {
31
+ describe("http.resource — gate", () => {
32
+ test("bare mount has no gates and is not a live exposure", () => {
33
33
  resetBindings();
34
34
  resetFlowSeq();
35
35
  on(http.resource("/notes", bag()));
36
36
  const list = httpOf("/notes", "GET");
37
37
  expect(list?.gates).toEqual([]);
38
- expect(list?.isLive).toBe(false);
39
- expect(httpOf("/notes", "POST")?.isLive).toBe(false);
38
+ expect(list?.liveSignal).toBeUndefined();
39
+ expect(httpOf("/notes", "POST")?.liveSignal).toBeUndefined();
40
40
  });
41
41
 
42
- test(".gate(member).live() stamps every verb; live is GET only", () => {
42
+ test(".gate(member) stamps every verb", () => {
43
43
  resetBindings();
44
44
  resetFlowSeq();
45
- on(http.resource("/notes", bag()).gate(member).live());
45
+ on(http.resource("/notes", bag()).gate(member));
46
46
 
47
47
  const list = httpOf("/notes", "GET");
48
48
  const create = httpOf("/notes", "POST");
@@ -55,35 +55,18 @@ describe("http.resource — gate / live", () => {
55
55
  expect(get?.gates.map((g) => (typeof g === "string" ? g : g.name))).toEqual(["member"]);
56
56
  expect(update?.gates.map((g) => (typeof g === "string" ? g : g.name))).toEqual(["member"]);
57
57
  expect(remove?.gates.map((g) => (typeof g === "string" ? g : g.name))).toEqual(["member"]);
58
-
59
- expect(list?.isLive).toBe(true);
60
- expect(get?.isLive).toBe(true);
61
- expect(create?.isLive).toBe(false);
62
- expect(update?.isLive).toBe(false);
63
- expect(remove?.isLive).toBe(false);
58
+ expect(list?.liveSignal).toBeUndefined();
64
59
  });
65
60
 
66
- test(".public().live() stamps the sentinel on every verb", () => {
61
+ test(".public() stamps the sentinel on every verb", () => {
67
62
  resetBindings();
68
63
  resetFlowSeq();
69
- on(http.resource("/notes", bag()).public().live());
64
+ on(http.resource("/notes", bag()).public());
70
65
  expect(httpOf("/notes", "GET")?.gates.map((g) => (typeof g === "string" ? g : g.name))).toEqual(
71
66
  ["public"],
72
67
  );
73
68
  expect(
74
69
  httpOf("/notes", "POST")?.gates.map((g) => (typeof g === "string" ? g : g.name)),
75
70
  ).toEqual(["public"]);
76
- expect(httpOf("/notes", "GET")?.isLive).toBe(true);
77
- expect(httpOf("/notes", "POST")?.isLive).toBe(false);
78
- });
79
-
80
- test(".live().gate(member) order does not matter", () => {
81
- resetBindings();
82
- resetFlowSeq();
83
- on(http.resource("/notes", bag()).live().gate(member));
84
- expect(httpOf("/notes", "GET")?.isLive).toBe(true);
85
- expect(httpOf("/notes", "GET")?.gates.map((g) => (typeof g === "string" ? g : g.name))).toEqual(
86
- ["member"],
87
- );
88
71
  });
89
72
  });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Three-tier tenant identity resolution.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { createTenant, createTenantStore } from "../auth/tenants.ts";
7
+ import { resolveTenantAuth } from "../auth/tenant-config.ts";
8
+ import { isFlowFailure } from "./hooks.ts";
9
+ import { resolveRequestTenant } from "./tenant-resolve.ts";
10
+
11
+ describe("resolveRequestTenant", () => {
12
+ test("claim trusts tid without a membership query", async () => {
13
+ const store = createTenantStore();
14
+ const result = resolveRequestTenant({
15
+ config: resolveTenantAuth(true),
16
+ auth: { userId: "u1", scopes: new Set() },
17
+ claimTenantId: "not-a-member",
18
+ store,
19
+ });
20
+ expect(result.id).toBe("not-a-member");
21
+ expect(result.failure).toBeUndefined();
22
+ });
23
+
24
+ test("header never trusts a non-member", async () => {
25
+ const store = createTenantStore();
26
+ await createTenant(store, { name: "Acme", createdBy: "owner", id: "acme" });
27
+ const result = resolveRequestTenant({
28
+ config: resolveTenantAuth({ source: "header" }),
29
+ auth: { userId: "u1", scopes: new Set() },
30
+ claimTenantId: null,
31
+ request: new Request("https://app.example/x", { headers: { "x-oke-tenant": "acme" } }),
32
+ store,
33
+ });
34
+ expect(result.id).toBeNull();
35
+ expect(result.failure && isFlowFailure(result.failure)).toBe(true);
36
+ if (result.failure && isFlowFailure(result.failure)) {
37
+ expect(result.failure.error.code).toBe("Forbidden");
38
+ expect(result.failure.error.data).toEqual({
39
+ gate: "auth:tenants",
40
+ reason: "not_member",
41
+ });
42
+ }
43
+ });
44
+
45
+ test("header accepts a real membership", async () => {
46
+ const store = createTenantStore();
47
+ await createTenant(store, { name: "Acme", createdBy: "u1", id: "acme" });
48
+ const result = resolveRequestTenant({
49
+ config: resolveTenantAuth({ source: "header" }),
50
+ auth: { userId: "u1", scopes: new Set() },
51
+ claimTenantId: null,
52
+ request: new Request("https://app.example/x", { headers: { "x-oke-tenant": "acme" } }),
53
+ store,
54
+ });
55
+ expect(result.id).toBe("acme");
56
+ });
57
+
58
+ test("required + authenticated + no tenant is Forbidden tenant_required", () => {
59
+ const store = createTenantStore();
60
+ const result = resolveRequestTenant({
61
+ config: resolveTenantAuth({ required: true }),
62
+ auth: { userId: "u1", scopes: new Set() },
63
+ claimTenantId: null,
64
+ store,
65
+ });
66
+ expect(result.failure && isFlowFailure(result.failure)).toBe(true);
67
+ if (result.failure && isFlowFailure(result.failure)) {
68
+ expect(result.failure.error.data).toEqual({
69
+ gate: "auth:tenants",
70
+ reason: "tenant_required",
71
+ });
72
+ }
73
+ });
74
+
75
+ test("resolve callback still checks membership unless authoritative", async () => {
76
+ const store = createTenantStore();
77
+ const denied = resolveRequestTenant({
78
+ config: resolveTenantAuth({
79
+ source: "resolve",
80
+ resolve: () => "ghost",
81
+ }),
82
+ auth: { userId: "u1", scopes: new Set() },
83
+ claimTenantId: null,
84
+ store,
85
+ });
86
+ expect(denied.failure && isFlowFailure(denied.failure)).toBe(true);
87
+
88
+ await createTenant(store, { name: "Acme", createdBy: "u1", id: "acme" });
89
+ const trusted = resolveRequestTenant({
90
+ config: resolveTenantAuth({
91
+ source: "resolve",
92
+ authoritative: true,
93
+ resolve: () => "ghost",
94
+ }),
95
+ auth: { userId: "u1", scopes: new Set() },
96
+ claimTenantId: null,
97
+ store,
98
+ });
99
+ expect(trusted.id).toBe("ghost");
100
+ });
101
+ });
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Per-request tenant identity resolution (three tiers).
3
+ *
4
+ * Tenant is an identity dimension like `fx.auth` — not a second authz system.
5
+ */
6
+
7
+ import { isMember, type TenantStore } from "../auth/tenants.ts";
8
+ import {
9
+ type ResolvedTenantAuth,
10
+ type TenantAuthOptions,
11
+ type TenantSource,
12
+ } from "../auth/tenant-config.ts";
13
+ import { fail, type FlowFailure } from "./errors.ts";
14
+ import type { FxAuthIdentity } from "./fx-auth-keys.ts";
15
+
16
+ export type { ResolvedTenantAuth, TenantAuthOptions, TenantSource };
17
+
18
+ /** Result of {@link resolveRequestTenant}. */
19
+ export interface RequestTenantResult {
20
+ readonly id: string | null;
21
+ readonly failure?: FlowFailure;
22
+ }
23
+
24
+ /** Options for {@link resolveRequestTenant}. */
25
+ export interface ResolveRequestTenantOptions {
26
+ readonly config: ResolvedTenantAuth;
27
+ readonly auth: FxAuthIdentity;
28
+ readonly claimTenantId: string | null;
29
+ readonly request?: Request;
30
+ readonly store: TenantStore;
31
+ }
32
+
33
+ /**
34
+ * Resolve tenant id for this request.
35
+ *
36
+ * Tier 1 (`claim`): signed `tid` / API-key `tenantId` — no membership query.
37
+ * Tier 2 (`header` / `subdomain`): client-supplied — membership required.
38
+ * Tier 3 (`resolve`): callback; membership required unless `authoritative`.
39
+ *
40
+ * @param options - Config + live identity + request
41
+ */
42
+ export function resolveRequestTenant(options: ResolveRequestTenantOptions): RequestTenantResult {
43
+ const { config, auth, claimTenantId, request, store } = options;
44
+ const userId = auth.userId;
45
+ const supplied = sourceValue(config, claimTenantId, request, auth);
46
+
47
+ if (supplied === null || supplied === "") {
48
+ if (config.required && userId) {
49
+ return {
50
+ id: null,
51
+ failure: fail("Forbidden", { gate: "auth:tenants", reason: "tenant_required" }),
52
+ };
53
+ }
54
+ return { id: null };
55
+ }
56
+
57
+ if (config.source === "claim") {
58
+ return { id: supplied };
59
+ }
60
+
61
+ if (config.source === "resolve" && config.authoritative) {
62
+ return { id: supplied };
63
+ }
64
+
65
+ if (!userId) {
66
+ return {
67
+ id: null,
68
+ failure: fail("Unauthorized", {}),
69
+ };
70
+ }
71
+ if (!isMember(store, supplied, userId)) {
72
+ return {
73
+ id: null,
74
+ failure: fail("Forbidden", { gate: "auth:tenants", reason: "not_member" }),
75
+ };
76
+ }
77
+ return { id: supplied };
78
+ }
79
+
80
+ function sourceValue(
81
+ config: ResolvedTenantAuth,
82
+ claimTenantId: string | null,
83
+ request: Request | undefined,
84
+ auth: FxAuthIdentity,
85
+ ): string | null {
86
+ if (config.source === "claim") {
87
+ return claimTenantId;
88
+ }
89
+ // Internal / cron / fx.call have no HTTP request — keep the stamped claim.
90
+ if (!request && (config.source === "header" || config.source === "subdomain")) {
91
+ return claimTenantId;
92
+ }
93
+ if (config.source === "header") {
94
+ const raw = request?.headers.get(config.header)?.trim();
95
+ return raw && raw.length > 0 ? raw : null;
96
+ }
97
+ if (config.source === "subdomain") {
98
+ return subdomainLabel(request);
99
+ }
100
+ if (config.source === "resolve" && config.resolve) {
101
+ const out = config.resolve({
102
+ auth: { userId: auth.userId, tenantId: claimTenantId },
103
+ request,
104
+ claimTenantId,
105
+ });
106
+ return out ?? null;
107
+ }
108
+ return claimTenantId;
109
+ }
110
+
111
+ /**
112
+ * First Host label as a tenant slug/id (`acme.example.com` → `acme`).
113
+ *
114
+ * @param request - Incoming request
115
+ */
116
+ export function subdomainLabel(request: Request | undefined): string | null {
117
+ const host = request?.headers.get("host")?.split(":")[0]?.trim().toLowerCase();
118
+ if (!host) return null;
119
+ const parts = host.split(".");
120
+ if (parts.length < 3) return null;
121
+ const label = parts[0];
122
+ if (!label || label === "www") return null;
123
+ return label;
124
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Tenant-role scope union is conditional — never JWT-melt, never on
3
+ * `tenantScoped: false` flows.
4
+ */
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { createTenant, createTenantStore, upsertTenantRole } from "../auth/tenants.ts";
8
+ import { gate } from "../elements/gate.ts";
9
+ import { oke } from "./app.ts";
10
+ import { flow, resetFlowSeq } from "./flow.ts";
11
+ import { isFlowFailure } from "./hooks.ts";
12
+ import { on, resetBindings } from "./on.ts";
13
+ import { http } from "./triggers.ts";
14
+
15
+ const SECRET = "tenant-roles-test-secret";
16
+
17
+ describe("tenant-role scope union", () => {
18
+ test("tenant-granted scope does not authorize a tenantScoped: false route", async () => {
19
+ resetBindings();
20
+ resetFlowSeq();
21
+ const member = gate.policy("member", ({ auth }) => !!auth.verified);
22
+ const booking = gate.scope("booking:create");
23
+ const tenantStore = createTenantStore();
24
+ await createTenant(tenantStore, { name: "Acme", createdBy: "u1", id: "t1" });
25
+ upsertTenantRole(tenantStore, {
26
+ tenantId: "t1",
27
+ roleName: "member",
28
+ scopes: ["booking:create"],
29
+ catalog: ["booking:create", "member"],
30
+ });
31
+
32
+ on(
33
+ http.get("/global").gate(booking),
34
+ flow("global.ping", {
35
+ tenantScoped: false,
36
+ do: () => "global",
37
+ }),
38
+ );
39
+ on(
40
+ http.get("/tenant").gate(booking),
41
+ flow("tenant.ping", {
42
+ do: () => "tenant",
43
+ }),
44
+ );
45
+
46
+ const app = oke({
47
+ name: "tenant-roles",
48
+ env: "test",
49
+ startScheduler: false,
50
+ gate: {
51
+ policies: [member, booking],
52
+ auth: { secret: SECRET, tenant: true, tenantStore, http: false },
53
+ },
54
+ });
55
+ await app.boot({ env: "test", startScheduler: false });
56
+
57
+ const principal = {
58
+ plane: "user" as const,
59
+ userId: "u1",
60
+ scopes: [] as string[],
61
+ verified: true,
62
+ tenantId: "t1",
63
+ };
64
+
65
+ const leak = await app.execute(
66
+ app.flow("global.ping")!,
67
+ undefined,
68
+ http.get("/global").gate(booking),
69
+ { principal },
70
+ );
71
+ expect(leak.failure && isFlowFailure(leak.failure)).toBe(true);
72
+ if (leak.failure && isFlowFailure(leak.failure)) {
73
+ expect(leak.failure.error.code).toBe("Forbidden");
74
+ }
75
+
76
+ const allowed = await app.execute(
77
+ app.flow("tenant.ping")!,
78
+ undefined,
79
+ http.get("/tenant").gate(booking),
80
+ { principal },
81
+ );
82
+ expect(allowed.failure).toBeUndefined();
83
+ expect(allowed.output).toBe("tenant");
84
+
85
+ await app.bootResult?.close();
86
+ });
87
+ });
@@ -36,8 +36,10 @@ export interface HttpTrigger<M extends HttpMethod = HttpMethod, P extends string
36
36
  readonly method: M;
37
37
  readonly path: P;
38
38
  readonly gates: readonly GateRef[];
39
- /** Whether `.live()` was applied. */
40
- readonly isLive: boolean;
39
+ /**
40
+ * Signal this GET exposes as a live SSE feed (`undefined` when not a live route).
41
+ */
42
+ readonly liveSignal?: SignalSource;
41
43
  /**
42
44
  * Attach gates (registration order). `gate.all` handles and arrays flatten.
43
45
  */
@@ -47,9 +49,29 @@ export interface HttpTrigger<M extends HttpMethod = HttpMethod, P extends string
47
49
  */
48
50
  public(): HttpTrigger<M, P>;
49
51
  /**
50
- * Mark the HTTP trigger as live (push result to subscribers).
52
+ * Expose a `delivery: "live"` signal as SSE on this GET.
53
+ *
54
+ * @param signal - Live signal handle
51
55
  */
52
- live(): HttpTrigger<M, P>;
56
+ live(signal: SignalSource): LiveHttpTrigger<M, P>;
57
+ }
58
+
59
+ /**
60
+ * HTTP GET trigger that exposes a live signal (`on(trigger)` synthesizes the Flow).
61
+ *
62
+ * `.gate` / `.public` keep {@link LiveHttpTrigger.liveSignal} so
63
+ * `on(http.live(signal).gate(member))` typechecks.
64
+ *
65
+ * @typeParam M - HTTP method literal
66
+ * @typeParam P - Path template literal
67
+ */
68
+ export interface LiveHttpTrigger<
69
+ M extends HttpMethod = "GET",
70
+ P extends string = string,
71
+ > extends HttpTrigger<M, P> {
72
+ readonly liveSignal: SignalSource;
73
+ readonly gate: GateAttach<LiveHttpTrigger<M, P>>;
74
+ public(): LiveHttpTrigger<M, P>;
53
75
  }
54
76
 
55
77
  /** Clock / interval trigger (`every("1h")`). */
@@ -105,31 +127,43 @@ export function createGateAttach<T>(
105
127
  }
106
128
 
107
129
  /**
108
- * Build an HTTP trigger with `.gate` / `.public` / `.live`.
130
+ * Build an HTTP trigger with `.gate` / `.public` / `.live(signal)`.
109
131
  *
110
132
  * @param method - HTTP verb
111
133
  * @param path - Route path
112
134
  * @param gates - Attached gate refs
113
- * @param isLive - Live flag
135
+ * @param liveSignal - Live signal when `.live(signal)` was applied
114
136
  */
137
+ export function createHttpTrigger<M extends HttpMethod, P extends string>(
138
+ method: M,
139
+ path: P,
140
+ gates: readonly GateRef[],
141
+ liveSignal: SignalSource,
142
+ ): LiveHttpTrigger<M, P>;
143
+ export function createHttpTrigger<M extends HttpMethod, P extends string>(
144
+ method: M,
145
+ path: P,
146
+ gates?: readonly GateRef[],
147
+ liveSignal?: SignalSource,
148
+ ): HttpTrigger<M, P>;
115
149
  export function createHttpTrigger<M extends HttpMethod, P extends string>(
116
150
  method: M,
117
151
  path: P,
118
152
  gates: readonly GateRef[] = [],
119
- isLive = false,
153
+ liveSignal?: SignalSource,
120
154
  ): HttpTrigger<M, P> {
121
155
  const trigger: HttpTrigger<M, P> = {
122
156
  kind: "http",
123
157
  method,
124
158
  path,
125
159
  gates,
126
- isLive,
127
- gate: createGateAttach((next) => createHttpTrigger(method, path, next, isLive), gates),
160
+ ...(liveSignal !== undefined ? { liveSignal } : {}),
161
+ gate: createGateAttach((next) => createHttpTrigger(method, path, next, liveSignal), gates),
128
162
  public() {
129
- return createHttpTrigger(method, path, [...gates, GATE_PUBLIC_NAME], isLive);
163
+ return createHttpTrigger(method, path, [...gates, GATE_PUBLIC_NAME], liveSignal);
130
164
  },
131
- live() {
132
- return createHttpTrigger(method, path, gates, true);
165
+ live(signal: SignalSource) {
166
+ return createHttpTrigger(method, path, gates, signal);
133
167
  },
134
168
  };
135
169
  return trigger;
@@ -150,14 +184,12 @@ export interface ResourceFlowBag {
150
184
  /**
151
185
  * A mounted resource — the single argument to the `on(http.resource(…))`
152
186
  * overload. Branded so `on` can tell it apart from a plain trigger.
153
- * Same chain as {@link HttpTrigger}: `.gate(...)` / `.public()` / `.live()`.
187
+ * Same chain as {@link HttpTrigger}: `.gate(...)` / `.public()`.
154
188
  */
155
189
  export interface ResourceMount {
156
190
  readonly [resourceMountBrand]: true;
157
191
  readonly mounts: ReadonlyArray<{ readonly trigger: HttpTrigger; readonly flow: unknown }>;
158
192
  readonly gates: readonly GateRef[];
159
- /** Whether `.live()` was applied (GET list + get). */
160
- readonly isLive: boolean;
161
193
  /**
162
194
  * Attach gates to every verb (registration order). `gate.all` and arrays flatten.
163
195
  */
@@ -166,10 +198,6 @@ export interface ResourceMount {
166
198
  * Attach the unauthenticated public sentinel to every verb.
167
199
  */
168
200
  public(): ResourceMount;
169
- /**
170
- * Mark list and get as live (GET mounts). Mutations stay request/response.
171
- */
172
- live(): ResourceMount;
173
201
  }
174
202
  /** Brand for {@link ResourceMount}. */
175
203
  export const resourceMountBrand: unique symbol = Symbol.for("oke.resource.mount");
@@ -254,10 +282,16 @@ export interface HttpTriggerNamespace {
254
282
  query<P extends string>(path: P): HttpTrigger<"QUERY", P>;
255
283
  /**
256
284
  * Mount a CRUD resource (list/create on `path`, get/update/remove on
257
- * `path/:id`) for the `on(http.resource(…))` overload. Chain `.gate(...)`,
258
- * `.public()`, and `.live()` like {@link HttpTrigger}.
285
+ * `path/:id`) for the `on(http.resource(…))` overload. Chain `.gate(...)`
286
+ * and `.public()` like {@link HttpTrigger}.
259
287
  */
260
288
  resource<P extends string>(path: P, ops: ResourceFlowBag): ResourceMount;
289
+ /**
290
+ * Default live firehose: `GET /_oke/live/{signal}`. Chain `.gate(...)`.
291
+ *
292
+ * @param signal - `delivery: "live"` handle
293
+ */
294
+ live(signal: SignalSource): LiveHttpTrigger<"GET">;
261
295
  }
262
296
 
263
297
  /** Bind an HTTP verb constructor (`http.get`, `http.query`, …). */
@@ -287,6 +321,10 @@ export const http: HttpTriggerNamespace = {
287
321
  head: httpVerb("HEAD"),
288
322
  query: httpVerb("QUERY"),
289
323
  resource: (path, ops) => loadHttpResource().httpResource(path, ops),
324
+ live(signal) {
325
+ const path = `/_oke/live/${encodeURIComponent(signal.name)}`;
326
+ return createHttpTrigger("GET", path, [], signal);
327
+ },
290
328
  };
291
329
 
292
330
  /**