okengine 0.16.0 → 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 (149) 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 +14 -12
  5. package/site/content/docs/elements/gate.mdx +58 -8
  6. package/site/content/docs/elements/signal.mdx +46 -17
  7. package/site/content/docs/elements/store.mdx +23 -17
  8. package/site/content/docs/elements/vault.mdx +23 -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 +72 -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 +15 -9
  15. package/src/auth/api-key-sql.ts +11 -4
  16. package/src/auth/api-keys.ts +3 -0
  17. package/src/auth/config.ts +19 -0
  18. package/src/auth/index.ts +37 -0
  19. package/src/auth/plugin.ts +6 -1
  20. package/src/auth/sessions.ts +18 -0
  21. package/src/auth/tables.ts +32 -0
  22. package/src/auth/tenant-config.ts +74 -0
  23. package/src/auth/tenant-tables.ts +11 -0
  24. package/src/auth/tenants.test.ts +63 -0
  25. package/src/auth/tenants.ts +360 -0
  26. package/src/cli/build.ts +2 -2
  27. package/src/client/budget.test.ts +1 -1
  28. package/src/client/create.ts +75 -5
  29. package/src/client/index.ts +13 -0
  30. package/src/client/live.test.ts +422 -0
  31. package/src/client/live.ts +389 -0
  32. package/src/client/notes-contract.test.ts +41 -0
  33. package/src/client/types.ts +85 -6
  34. package/src/client-react/index.ts +85 -1
  35. package/src/client-react/use-live.test.ts +129 -0
  36. package/src/compiler/effects-infer.ts +22 -2
  37. package/src/compiler/extract.test.ts +143 -11
  38. package/src/compiler/extract.ts +210 -30
  39. package/src/compiler/fixtures/skyport/src/flows/bookings/index.ts +1 -2
  40. package/src/compiler/fixtures/skyport.expected.json +2 -3
  41. package/src/compiler/response.ts +41 -11
  42. package/src/console/server/app.ts +44 -11
  43. package/src/console/server/console.test.ts +6 -8
  44. package/src/console/server/gates.ts +2 -9
  45. package/src/console/server/store.test.ts +17 -0
  46. package/src/console/server/store.ts +43 -1
  47. package/src/console/ui-next/dist/assets/{access-page-CAGHrA9H.js → access-page-CXVWWMmD.js} +1 -1
  48. package/src/console/ui-next/dist/assets/{flows-page-B-OUtiAu.js → flows-page-XDpAJO8f.js} +1 -1
  49. package/src/console/ui-next/dist/assets/{index-CGoZkILK.js → index-BBj2QJCu.js} +3 -3
  50. package/src/console/ui-next/dist/assets/{observability-page-BDyXalNR.js → observability-page-BFaay44m.js} +1 -1
  51. package/src/console/ui-next/dist/assets/{store-page-Xh8Kn3rx.js → store-page-CS5-aETQ.js} +1 -1
  52. package/src/console/ui-next/dist/assets/{tree-expand-toggle-DkOXA12R.js → tree-expand-toggle-BtyhmWb4.js} +2 -1
  53. package/src/console/ui-next/dist/assets/{units-page-Dpk40kOQ.js → units-page-Ca2Z-E52.js} +1 -1
  54. package/src/console/ui-next/dist/assets/{vault-page-B1dB9Ft0.js → vault-page-BmOeAFwg.js} +1 -1
  55. package/src/console/ui-next/dist/index.html +1 -1
  56. package/src/console/ui-next/src/features/flows/fixture.ts +0 -1
  57. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +5 -1
  58. package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +15 -4
  59. package/src/console/ui-next/ui-next-seed-manifest-surface.ts +2 -9
  60. package/src/console/ui-next/ui-next-seed-manifest.ts +0 -1
  61. package/src/drivers/index.ts +2 -0
  62. package/src/drivers/journal-postgres.ts +12 -3
  63. package/src/drivers/pg-rls.ts +26 -2
  64. package/src/drivers/pg-vault-rls.ts +68 -0
  65. package/src/drivers/signal-engine.ts +69 -15
  66. package/src/drivers/signal-live-iter.ts +65 -0
  67. package/src/drivers/signal-nats.ts +2 -1
  68. package/src/drivers/signal-postgres.ts +95 -12
  69. package/src/drivers/signal-redis.ts +2 -1
  70. package/src/drivers/signal-retention.ts +64 -0
  71. package/src/drivers/signal-types.ts +35 -5
  72. package/src/elements/clock/declare.ts +64 -2
  73. package/src/elements/clock/reconcile.ts +98 -25
  74. package/src/elements/clock/runtime.ts +13 -2
  75. package/src/elements/clock.test.ts +28 -0
  76. package/src/elements/clock.ts +9 -1
  77. package/src/elements/gate/permissions.ts +12 -0
  78. package/src/elements/gate.ts +6 -1
  79. package/src/elements/signal/declare.ts +44 -10
  80. package/src/elements/signal/delivery-modes.test.ts +90 -4
  81. package/src/elements/signal/order-lifecycle.test.ts +20 -4
  82. package/src/elements/signal/runtime.ts +42 -0
  83. package/src/elements/signal.test.ts +21 -8
  84. package/src/elements/signal.ts +1 -1
  85. package/src/elements/store/declare.ts +7 -0
  86. package/src/elements/store/rls-identity.test.ts +29 -0
  87. package/src/elements/store/rls-identity.ts +3 -0
  88. package/src/elements/store/schema-decl.ts +63 -3
  89. package/src/elements/store/schema-tenant.ts +41 -0
  90. package/src/elements/store/sql-rls-isolation.test.ts +39 -0
  91. package/src/elements/store.ts +2 -0
  92. package/src/elements/vault/builtin-adapter.ts +15 -2
  93. package/src/elements/vault/declare.ts +8 -0
  94. package/src/elements/vault/runtime.ts +7 -0
  95. package/src/elements/vault/sql-rls-isolation.test.ts +145 -0
  96. package/src/elements/vault/storage.ts +9 -0
  97. package/src/elements/vault/test-helpers.ts +2 -1
  98. package/src/i18n/catalogs/ar.ts +19 -0
  99. package/src/i18n/catalogs/en.ts +19 -0
  100. package/src/index.ts +1 -0
  101. package/src/kernel/adopt-routes.ts +56 -8
  102. package/src/kernel/app-tenant.ts +122 -0
  103. package/src/kernel/app.ts +183 -56
  104. package/src/kernel/auth-resolve.ts +3 -0
  105. package/src/kernel/boot-bind/clock.ts +9 -3
  106. package/src/kernel/boot.ts +2 -0
  107. package/src/kernel/budget.test.ts +1 -1
  108. package/src/kernel/clock-durable.ts +8 -0
  109. package/src/kernel/clock-per-tenant-name.ts +5 -0
  110. package/src/kernel/clock-reconcile.ts +8 -0
  111. package/src/kernel/errors-live-resume.ts +15 -0
  112. package/src/kernel/errors-tenant.ts +29 -0
  113. package/src/kernel/errors.registry.test.ts +31 -3
  114. package/src/kernel/errors.ts +43 -3
  115. package/src/kernel/flow.ts +26 -5
  116. package/src/kernel/fx-auth-keys.ts +6 -1
  117. package/src/kernel/fx-auth-tenants.test.ts +87 -0
  118. package/src/kernel/fx-auth-tenants.ts +286 -0
  119. package/src/kernel/fx-live-stream.ts +149 -0
  120. package/src/kernel/fx-live.test.ts +157 -0
  121. package/src/kernel/fx-runtime.ts +15 -0
  122. package/src/kernel/fx-tenant-store.ts +213 -0
  123. package/src/kernel/fx.test.ts +91 -0
  124. package/src/kernel/fx.ts +173 -13
  125. package/src/kernel/hooks.ts +2 -2
  126. package/src/kernel/http-resource.ts +9 -18
  127. package/src/kernel/index.ts +2 -0
  128. package/src/kernel/journal.ts +12 -0
  129. package/src/kernel/live-http.test.ts +78 -0
  130. package/src/kernel/live-http.ts +114 -0
  131. package/src/kernel/live-resume.test.ts +125 -0
  132. package/src/kernel/on.ts +51 -0
  133. package/src/kernel/pipeline-tenant.ts +49 -0
  134. package/src/kernel/pipeline.test.ts +1 -1
  135. package/src/kernel/pipeline.ts +37 -2
  136. package/src/kernel/resource-mount.test.ts +10 -27
  137. package/src/kernel/tenant-resolve.test.ts +101 -0
  138. package/src/kernel/tenant-resolve.ts +124 -0
  139. package/src/kernel/tenant-roles.test.ts +87 -0
  140. package/src/kernel/triggers.ts +59 -21
  141. package/src/manifest/diff.test.ts +11 -2
  142. package/src/manifest/diff.ts +53 -11
  143. package/src/manifest/fixtures/skyport.excerpt.json +1 -1
  144. package/src/manifest/fixtures/skyport.manifest.json +0 -1
  145. package/src/manifest/types.ts +52 -6
  146. package/src/release/build-lib.ts +13 -1
  147. package/src/release/limits.ts +2 -2
  148. package/src/release/measure.ts +55 -1
  149. package/src/client/live-gap.test.ts +0 -35
package/src/kernel/app.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  type RoutesFromNamespace,
18
18
  type RuntimeRouteMap,
19
19
  } from "./adopt-routes.ts";
20
+ import { liveExposureKey, liveGatesKey, liveMatchKeyFromPath } from "./live-http.ts";
20
21
  // `./boot.ts` pulls in every element + driver module (vault, store, signal,
21
22
  // clock, gate, channel, ai, runs) — a type-only import here keeps that whole
22
23
  // graph out of the cold-start path; `doBoot` below loads it lazily, only
@@ -33,25 +34,24 @@ import {
33
34
  type ResolvedGateConfig,
34
35
  } from "../elements/gate/config.ts";
35
36
  import { requirePackageModule } from "../shared/lazy-src.ts";
37
+ import { lazyRequire } from "./lazy-require.ts";
38
+ import type { DurableResult, RunDurableOptions } from "../elements/clock/durable.ts";
36
39
  import type { TemplateCatalog } from "../elements/channel/runtime.ts";
37
40
  import { parseAcceptLanguage } from "../elements/channel/locale.ts";
38
41
  import { runWithLocale } from "../i18n/locale-context.ts";
39
- import { runDurable } from "../elements/clock/durable.ts";
40
42
  import { isFlow, type AnyFlowDef } from "./flow.ts";
41
43
  import { failureFromUnknown, runCompensationPhase } from "./compensate.ts";
42
44
  import { withAbortSignal } from "./abort-scope.ts";
43
45
  import { fxRetry } from "./concurrency.ts";
44
- import {
45
- createFxContext,
46
- freezePrincipal,
47
- isJsonStreamResult,
48
- resolveName,
49
- type CreateFxOptions,
50
- type Fx,
51
- type FxAuth,
52
- type FxOperator,
53
- type FxPrincipal,
54
- type NamedRef,
46
+ import type {
47
+ CreateFxOptions,
48
+ Fx,
49
+ FxAuth,
50
+ FxContext,
51
+ FxOperator,
52
+ FxPrincipal,
53
+ JsonStreamResult,
54
+ NamedRef,
55
55
  } from "./fx.ts";
56
56
  import {
57
57
  currentDevSurface,
@@ -152,6 +152,31 @@ function loadMessages(): typeof import("../i18n/messages.ts") {
152
152
  return requirePackageModule("i18n/messages", "messages");
153
153
  }
154
154
 
155
+ /**
156
+ * `createFx` / JSON result helpers — loaded on first execute, not on Store-only
157
+ * `oke()` construction (same boundary as `boot.ts`).
158
+ */
159
+ function loadFx(): {
160
+ createFxContext: (options: CreateFxOptions) => FxContext;
161
+ freezePrincipal: (p: FxPrincipal) => FxPrincipal;
162
+ isJsonStreamResult: (value: unknown) => value is JsonStreamResult;
163
+ resolveName: (ref: NamedRef) => string;
164
+ } {
165
+ return lazyRequire(import.meta.dir, ["fx", "runtime"].join("-"));
166
+ }
167
+
168
+ /** App-shell tenancy wiring — loaded only when `gate.auth.tenant` is on. */
169
+ function loadAppTenant(): { w: (kind: number, ...args: unknown[]) => unknown } {
170
+ return lazyRequire(import.meta.dir, ["app", "tenant"].join("-"));
171
+ }
172
+
173
+ /** Durable runner — statically imports `createFx`; keep it off Store-only `oke()`. */
174
+ function loadDurable(): {
175
+ runDurable: (opts: RunDurableOptions) => Promise<DurableResult>;
176
+ } {
177
+ return lazyRequire(import.meta.dir, ["clock", "durable"].join("-"));
178
+ }
179
+
155
180
  /** Options for {@link oke}. */
156
181
  export interface OkeOptions {
157
182
  /** Application name (Manifest `app`). */
@@ -465,6 +490,8 @@ export interface OkeApp<D extends Record<string, unknown> = {}, R extends AppRou
465
490
  readonly parentId?: string;
466
491
  /** Explicit run / WideEvent id (defaults to a new UUID). */
467
492
  readonly runId?: string;
493
+ /** Tenant identity for cron / `fx.call` (propagated, unlike auth). */
494
+ readonly tenant?: { readonly id: string | null };
468
495
  },
469
496
  ): Promise<ExecuteResult>;
470
497
  /**
@@ -498,8 +525,12 @@ export interface OkeApp<D extends Record<string, unknown> = {}, R extends AppRou
498
525
  * Invoke all flows bound to an `every` interval.
499
526
  *
500
527
  * @param interval - Interval string (e.g. `"1h"`)
528
+ * @param extras - Optional tenant / parent for per-tenant clocks
501
529
  */
502
- dispatchEvery(interval: string): Promise<ExecuteResult[]>;
530
+ dispatchEvery(
531
+ interval: string,
532
+ extras?: { readonly tenant?: { readonly id: string | null } },
533
+ ): Promise<ExecuteResult[]>;
503
534
  /**
504
535
  * Invoke all flows bound to a CDC table change.
505
536
  *
@@ -610,6 +641,57 @@ function assertHttpBindingReady(binding: Binding): void {
610
641
  }
611
642
  }
612
643
 
644
+ /**
645
+ * Register one HTTP binding: unique method+path, unique live exposure key.
646
+ *
647
+ * @param binding - HTTP binding
648
+ * @param seenHttpRoutes - `METHOD path` keys
649
+ * @param seenLiveExposures - `(signal, gates, match)` keys
650
+ * @param smart - Router
651
+ * @param compiled - Compiled route map
652
+ * @param aot - AoT compile flag
653
+ */
654
+ function registerHttpRoute(
655
+ binding: Binding,
656
+ seenHttpRoutes: Set<string>,
657
+ seenLiveExposures: Map<string, string>,
658
+ smart: { add(method: string, path: string, value: Binding): void },
659
+ compiled: WeakMap<Binding, CompiledRoute>,
660
+ aot: boolean,
661
+ ): void {
662
+ const trigger = binding.trigger;
663
+ if (trigger.kind !== "http") return;
664
+ const routeKey = `${trigger.method} ${trigger.path}`;
665
+ if (seenHttpRoutes.has(routeKey)) {
666
+ throwOke("HTTP_ROUTE_DUPLICATE", {
667
+ method: trigger.method,
668
+ path: trigger.path,
669
+ flow: binding.flow.name || "(unnamed)",
670
+ });
671
+ }
672
+ seenHttpRoutes.add(routeKey);
673
+ if (trigger.liveSignal !== undefined) {
674
+ const matchKey = binding.flow.liveCustomMatch
675
+ ? `custom:${binding.flow.name}`
676
+ : liveMatchKeyFromPath(trigger.path);
677
+ const exposure = liveExposureKey(
678
+ trigger.liveSignal.name,
679
+ liveGatesKey(trigger.gates),
680
+ matchKey,
681
+ );
682
+ if (seenLiveExposures.has(exposure)) {
683
+ throwOke("LIVE_EXPOSURE_DUPLICATE", {
684
+ signal: trigger.liveSignal.name,
685
+ gates: liveGatesKey(trigger.gates) || "(none)",
686
+ match: matchKey || "(firehose)",
687
+ });
688
+ }
689
+ seenLiveExposures.set(exposure, binding.flow.name);
690
+ }
691
+ smart.add(trigger.method, trigger.path, binding);
692
+ compiled.set(binding, compileHttpBinding(binding, aot));
693
+ }
694
+
613
695
  /**
614
696
  * Fold `generated.ts` units into `$routes` and `flowsByName`.
615
697
  *
@@ -790,20 +872,11 @@ export function oke(options: OkeOptions): OkeApp {
790
872
 
791
873
  const smart = createRouter<Binding>(options.router ?? "default");
792
874
  const seenHttpRoutes = new Set<string>();
875
+ const seenLiveExposures = new Map<string, string>();
793
876
  for (const b of adopted) {
794
877
  if (b.trigger.kind === "http") {
795
878
  assertHttpBindingReady(b);
796
- const key = `${b.trigger.method} ${b.trigger.path}`;
797
- if (seenHttpRoutes.has(key)) {
798
- throwOke("HTTP_ROUTE_DUPLICATE", {
799
- method: b.trigger.method,
800
- path: b.trigger.path,
801
- flow: b.flow.name || "(unnamed)",
802
- });
803
- }
804
- seenHttpRoutes.add(key);
805
- smart.add(b.trigger.method, b.trigger.path, b);
806
- compiled.set(b, compileHttpBinding(b, aot));
879
+ registerHttpRoute(b, seenHttpRoutes, seenLiveExposures, smart, compiled, aot);
807
880
  }
808
881
  }
809
882
  // Defer SmartRouter selection until first match so `.plug()` can still
@@ -815,17 +888,7 @@ export function oke(options: OkeOptions): OkeApp {
815
888
  registerFlow(b.flow);
816
889
  if (b.trigger.kind === "http") {
817
890
  assertHttpBindingReady(b);
818
- const key = `${b.trigger.method} ${b.trigger.path}`;
819
- if (seenHttpRoutes.has(key)) {
820
- throwOke("HTTP_ROUTE_DUPLICATE", {
821
- method: b.trigger.method,
822
- path: b.trigger.path,
823
- flow: b.flow.name || "(unnamed)",
824
- });
825
- }
826
- seenHttpRoutes.add(key);
827
- smart.add(b.trigger.method, b.trigger.path, b);
828
- compiled.set(b, compileHttpBinding(b, aot));
891
+ registerHttpRoute(b, seenHttpRoutes, seenLiveExposures, smart, compiled, aot);
829
892
  }
830
893
  }
831
894
 
@@ -944,12 +1007,15 @@ export function oke(options: OkeOptions): OkeApp {
944
1007
  }
945
1008
 
946
1009
  async function handleCronFire(name: string): Promise<void> {
947
- await app.dispatchEvery(name);
1010
+ const extras = gateConfig.auth?.tenant
1011
+ ? (loadAppTenant().w(0, name) as { readonly tenant: { readonly id: string } } | undefined)
1012
+ : undefined;
1013
+ await app.dispatchEvery(name, extras);
948
1014
  // Named clocks (e.g. `clock("expire-stale", { every: "1h" })`) reconcile
949
1015
  // to a store row whose effective interval may differ from the cron name.
950
1016
  const row = await bootResult?.clock?.store.get(name);
951
1017
  if (row?.effectiveEvery && row.effectiveEvery !== name) {
952
- await app.dispatchEvery(row.effectiveEvery);
1018
+ await app.dispatchEvery(row.effectiveEvery, extras);
953
1019
  }
954
1020
  }
955
1021
 
@@ -1004,15 +1070,20 @@ export function oke(options: OkeOptions): OkeApp {
1004
1070
  return;
1005
1071
  }
1006
1072
  const { store, instanceId, leaseMs } = activeJournal();
1073
+ const existing = await store.get(runId);
1007
1074
  try {
1008
- await runDurable({
1075
+ await loadDurable().runDurable({
1009
1076
  flow: flowDef,
1010
1077
  input,
1011
1078
  journalStore: store,
1012
1079
  runId,
1013
1080
  ...(hasJournalLease(store) ? { lease: { instanceId, leaseMs } } : {}),
1014
1081
  now,
1015
- fx: durableResumeFx(),
1082
+ fx: {
1083
+ ...durableResumeFx(),
1084
+ ...(gateConfig.auth?.tenant ? (loadAppTenant().w(5, gateConfig.auth) as object) : {}),
1085
+ ...(existing?.tenant ? { tenant: { id: existing.tenant } } : {}),
1086
+ },
1016
1087
  });
1017
1088
  } catch (err) {
1018
1089
  if (isJournalLeaseBusy(err)) return;
@@ -1176,9 +1247,15 @@ export function oke(options: OkeOptions): OkeApp {
1176
1247
  onCronFire: overrides?.onCronFire ?? handleCronFire,
1177
1248
  onSignal: overrides?.onSignal ?? handleSignalFire,
1178
1249
  onDurableResume: overrides?.onDurableResume ?? handleDurableResume,
1250
+ ...(gateConfig.auth?.tenantStore
1251
+ ? (loadAppTenant().w(1, gateConfig.auth.tenantStore) as object)
1252
+ : {}),
1179
1253
  };
1180
1254
  const result = await bootApplication(merged);
1181
1255
  bootResult = result;
1256
+ if (gateConfig.auth?.tenantStore && result.clock) {
1257
+ loadAppTenant().w(2, gateConfig.auth.tenantStore, result.clock.store, merged.clocks ?? []);
1258
+ }
1182
1259
  if (gateConfig.auth?.apiKeyStore && result.store) {
1183
1260
  const { bindHostApiKeySqlFromStore } = await import("../auth/api-key-sql.ts");
1184
1261
  await bindHostApiKeySqlFromStore(result.store, gateConfig.auth.apiKeyStore);
@@ -1266,6 +1343,8 @@ export function oke(options: OkeOptions): OkeApp {
1266
1343
  readonly parentId?: string;
1267
1344
  /** Explicit run / WideEvent id (defaults to a new UUID). */
1268
1345
  readonly runId?: string;
1346
+ /** Tenant identity for cron / `fx.call` (propagated, unlike auth). */
1347
+ readonly tenant?: { readonly id: string | null };
1269
1348
  },
1270
1349
  ): Promise<ExecuteResult> {
1271
1350
  registerFlow(flowDef);
@@ -1290,8 +1369,8 @@ export function oke(options: OkeOptions): OkeApp {
1290
1369
  resolvedLocale,
1291
1370
  defaultLocale,
1292
1371
  });
1293
- const signal = extras?.request?.signal;
1294
- return signal ? withAbortSignal(signal, run) : run();
1372
+ const abort = extras?.request?.signal;
1373
+ return abort ? withAbortSignal(abort, run) : run();
1295
1374
  });
1296
1375
  }
1297
1376
 
@@ -1314,12 +1393,15 @@ export function oke(options: OkeOptions): OkeApp {
1314
1393
  readonly locale?: string;
1315
1394
  readonly parentId?: string;
1316
1395
  readonly runId?: string;
1396
+ readonly tenant?: { readonly id: string | null };
1317
1397
  };
1318
1398
  readonly resolvedLocale: string;
1319
1399
  readonly defaultLocale: string;
1320
1400
  }): Promise<ExecuteResult> {
1321
1401
  const { flowDef, input, trigger, extras, resolvedLocale, defaultLocale } = args;
1322
1402
  const booted = await ensureBoot();
1403
+ const tenantEnabled = gateConfig.auth?.tenant !== undefined;
1404
+ const flowTenantScoped = flowDef.tenantScoped ?? tenantEnabled;
1323
1405
 
1324
1406
  const unitBag = flowDef.unit !== undefined ? unitHooks.get(flowDef.unit) : undefined;
1325
1407
  // app (hooks + plugs) → unit (hooks + plugs) → flow (hooks + plugs)
@@ -1375,6 +1457,7 @@ export function oke(options: OkeOptions): OkeApp {
1375
1457
  apiKeyId: options.fx?.auth?.apiKeyId,
1376
1458
  },
1377
1459
  operator: { id: options.fx?.operator?.id ?? null },
1460
+ tenant: { id: extras?.tenant?.id ?? options.fx?.tenant?.id ?? null },
1378
1461
  };
1379
1462
 
1380
1463
  // Principal injection: test harness (`env: "test"`) OR console-trusted
@@ -1391,6 +1474,8 @@ export function oke(options: OkeOptions): OkeApp {
1391
1474
  const cookieOpts = gateConfig.auth?.cookies;
1392
1475
  const cookieToken = wiredAuth?.tokenFromCookieHeader;
1393
1476
  const apiKeyStore = gateConfig.auth?.apiKeyStore;
1477
+ const tenantEnabled = gateConfig.auth?.tenant !== undefined;
1478
+ const flowTenantScoped = flowDef.tenantScoped ?? tenantEnabled;
1394
1479
  const elementHooks = createElementPipelineHooks({
1395
1480
  gates: booted.gate,
1396
1481
  principals,
@@ -1415,13 +1500,29 @@ export function oke(options: OkeOptions): OkeApp {
1415
1500
  return cookieToken(request.headers.get("cookie"), cookieOpts);
1416
1501
  }
1417
1502
  : undefined,
1503
+ ...(gateConfig.auth?.tenant && gateConfig.auth.tenantStore
1504
+ ? (loadAppTenant().w(
1505
+ 3,
1506
+ gateConfig.auth.tenant,
1507
+ gateConfig.auth.tenantStore,
1508
+ flowTenantScoped,
1509
+ flowDef.plane,
1510
+ ) as object)
1511
+ : {}),
1418
1512
  });
1419
1513
 
1420
1514
  // Element hooks run first in the app-level onAuth/beforeHandle chain —
1421
1515
  // principal resolution and gate checks precede user-registered hooks.
1422
1516
  hooks = mergeHooks(
1423
1517
  {
1424
- onAuth: [elementHooks.onAuth],
1518
+ onAuth: [
1519
+ elementHooks.onAuth,
1520
+ async () => {
1521
+ if (journalSession && principals?.tenant.id) {
1522
+ await journalSession.stampTenant(principals.tenant.id);
1523
+ }
1524
+ },
1525
+ ],
1425
1526
  beforeHandle: [elementHooks.beforeHandle],
1426
1527
  },
1427
1528
  composedHooks,
@@ -1446,12 +1547,13 @@ export function oke(options: OkeOptions): OkeApp {
1446
1547
  }
1447
1548
 
1448
1549
  const effects = flowDef.effects ?? capability?.declared;
1449
- const { fx, ledger } = createFxContext({
1550
+ const { fx, ledger } = loadFx().createFxContext({
1450
1551
  ...options.fx,
1451
1552
  flow: flowDef.name,
1452
1553
  effects,
1453
1554
  runTelemetry: telemetry,
1454
1555
  runId,
1556
+ lastEventId: extras?.request?.headers.get("last-event-id") || undefined,
1455
1557
  now,
1456
1558
  i18n: {
1457
1559
  locale: resolvedLocale,
@@ -1459,8 +1561,28 @@ export function oke(options: OkeOptions): OkeApp {
1459
1561
  catalogs: loadMessages().getMessageCatalogs(),
1460
1562
  ...options.fx?.i18n,
1461
1563
  },
1462
- ...(principals ? { auth: principals.auth, operator: principals.operator } : {}),
1564
+ ...(principals
1565
+ ? {
1566
+ auth: principals.auth,
1567
+ operator: principals.operator,
1568
+ tenant: principals.tenant,
1569
+ }
1570
+ : extras?.tenant
1571
+ ? { tenant: { id: extras.tenant.id } }
1572
+ : {}),
1463
1573
  apiKeyStore: gateConfig.auth?.apiKeyStore,
1574
+ sessions: authBinding?.sessions,
1575
+ manifest: options.manifest,
1576
+ ...(tenantEnabled || extras?.tenant
1577
+ ? (loadAppTenant().w(4, {
1578
+ enabled: tenantEnabled,
1579
+ store: gateConfig.auth?.tenantStore,
1580
+ scoped: flowTenantScoped,
1581
+ plane: flowDef.plane,
1582
+ tenant: principals?.tenant ?? (extras?.tenant ? { id: extras.tenant.id } : undefined),
1583
+ bind: authBinding,
1584
+ }) as object)
1585
+ : {}),
1464
1586
  ...(extras?.originPrincipal ? { principal: extras.originPrincipal } : {}),
1465
1587
  ...(extras?.trustedInvoke === true && extras.revealPii === true ? { revealPii: true } : {}),
1466
1588
  rlsGateNames: gateNamesOf(trigger),
@@ -1488,9 +1610,10 @@ export function oke(options: OkeOptions): OkeApp {
1488
1610
  callInput,
1489
1611
  { kind: "internal" } satisfies InternalTrigger,
1490
1612
  {
1491
- originPrincipal: freezePrincipal(fx.principal),
1613
+ originPrincipal: loadFx().freezePrincipal(fx.principal),
1492
1614
  locale: resolvedLocale,
1493
1615
  parentId: runId,
1616
+ ...(tenantEnabled ? { tenant: { id: fx.tenant.id } } : {}),
1494
1617
  ...(extras?.trustedInvoke === true && extras.revealPii === true
1495
1618
  ? { trustedInvoke: true, revealPii: true }
1496
1619
  : {}),
@@ -1592,7 +1715,7 @@ export function oke(options: OkeOptions): OkeApp {
1592
1715
  durable: flowDef.durable,
1593
1716
  effects: putEffects,
1594
1717
  });
1595
- if (storeAfter && output !== undefined && !isJsonStreamResult(output)) {
1718
+ if (storeAfter && output !== undefined && !loadFx().isJsonStreamResult(output)) {
1596
1719
  const ttlMs =
1597
1720
  typeof flowDef.cache === "string" ? cache.parseTtlMs(flowDef.cache) : undefined;
1598
1721
  storeRt.putTier1(
@@ -1641,7 +1764,7 @@ export function oke(options: OkeOptions): OkeApp {
1641
1764
 
1642
1765
  const endedAt = now();
1643
1766
  const durationMs = resolveDurationMs(endedAt - startedAt, performance.now() - startedHr);
1644
- const streamOut = isJsonStreamResult(result.output) ? result.output : undefined;
1767
+ const streamOut = loadFx().isJsonStreamResult(result.output) ? result.output : undefined;
1645
1768
 
1646
1769
  const finalizeRun = async (): Promise<void> => {
1647
1770
  // Stream rows land in Traces only after close. Measure wall time then
@@ -1710,7 +1833,11 @@ export function oke(options: OkeOptions): OkeApp {
1710
1833
  }
1711
1834
  };
1712
1835
 
1713
- if (streamOut && !isTerminalFailure(result)) {
1836
+ if (
1837
+ streamOut &&
1838
+ !isTerminalFailure(result) &&
1839
+ result.response?.headers.get("content-type")?.includes("text/event-stream")
1840
+ ) {
1714
1841
  streamOut.finalize = finalizeRun;
1715
1842
  } else {
1716
1843
  await finalizeRun();
@@ -1817,7 +1944,7 @@ export function oke(options: OkeOptions): OkeApp {
1817
1944
  for (const [runId, sleeper] of [...sleepingRuns.entries()]) {
1818
1945
  if (t < sleeper.wakeAt) continue;
1819
1946
  sleepingRuns.delete(runId);
1820
- const result = await runDurable({
1947
+ const result = await loadDurable().runDurable({
1821
1948
  flow: sleeper.flow,
1822
1949
  input: sleeper.input,
1823
1950
  journalStore: store,
@@ -1873,7 +2000,7 @@ export function oke(options: OkeOptions): OkeApp {
1873
2000
  },
1874
2001
  flow(ref) {
1875
2002
  if (isFlow(ref)) return flowsByName.get(ref.name) ?? ref;
1876
- return flowsByName.get(resolveName(ref as NamedRef));
2003
+ return flowsByName.get(loadFx().resolveName(ref as NamedRef));
1877
2004
  },
1878
2005
  adopt(...args: readonly unknown[]) {
1879
2006
  const flows = accumulateAdoptArgs(args, routes);
@@ -1969,7 +2096,7 @@ export function oke(options: OkeOptions): OkeApp {
1969
2096
  );
1970
2097
  runLabel = internalResult.runId;
1971
2098
  cacheLabel = internalResult.cache;
1972
- return respond(encodeExecuteResult(internalResult));
2099
+ return respond(await encodeExecuteResult(internalResult));
1973
2100
  }
1974
2101
  }
1975
2102
 
@@ -2048,10 +2175,10 @@ export function oke(options: OkeOptions): OkeApp {
2048
2175
  runLabel = result.runId;
2049
2176
  cacheLabel = result.cache;
2050
2177
 
2051
- return respond(encodeExecuteResult(result));
2178
+ return respond(await encodeExecuteResult(result));
2052
2179
  },
2053
2180
  async dispatchSignal(signal, payload, meta) {
2054
- const name = resolveName(signal);
2181
+ const name = loadFx().resolveName(signal);
2055
2182
  const results: ExecuteResult[] = [];
2056
2183
  for (const b of adopted) {
2057
2184
  if (b.trigger.kind === "signal" && b.trigger.name === name) {
@@ -2064,11 +2191,11 @@ export function oke(options: OkeOptions): OkeApp {
2064
2191
  }
2065
2192
  return results;
2066
2193
  },
2067
- async dispatchEvery(interval) {
2194
+ async dispatchEvery(interval, extras) {
2068
2195
  const results: ExecuteResult[] = [];
2069
2196
  for (const b of adopted) {
2070
2197
  if (b.trigger.kind === "every" && b.trigger.interval === interval) {
2071
- results.push(await execute(b.flow, undefined, b.trigger as EveryTrigger));
2198
+ results.push(await execute(b.flow, undefined, b.trigger as EveryTrigger, extras));
2072
2199
  }
2073
2200
  }
2074
2201
  return results;
@@ -2089,7 +2216,7 @@ export function oke(options: OkeOptions): OkeApp {
2089
2216
  async call(ref, input) {
2090
2217
  const flowDef = isFlow(ref)
2091
2218
  ? (flowsByName.get(ref.name) ?? ref)
2092
- : flowsByName.get(resolveName(ref as NamedRef));
2219
+ : flowsByName.get(loadFx().resolveName(ref as NamedRef));
2093
2220
  if (!flowDef) {
2094
2221
  return undefined;
2095
2222
  }
@@ -84,6 +84,7 @@ export function claimsToPrincipal(claims: AccessClaims): ResolvedPrincipal {
84
84
  userId: claims.sub,
85
85
  scopes: claims.scopes,
86
86
  verified: true,
87
+ ...(claims.tid !== undefined ? { tenantId: claims.tid } : {}),
87
88
  };
88
89
  }
89
90
 
@@ -104,6 +105,7 @@ export function apiKeyRowToPrincipal(row: ApiKeyRow): ResolvedPrincipal {
104
105
  scopes: row.scopes,
105
106
  verified: true,
106
107
  apiKeyId: row.id,
108
+ ...(row.tenantId !== undefined ? { tenantId: row.tenantId } : {}),
107
109
  };
108
110
  }
109
111
  return {
@@ -112,6 +114,7 @@ export function apiKeyRowToPrincipal(row: ApiKeyRow): ResolvedPrincipal {
112
114
  scopes: row.scopes,
113
115
  verified: true,
114
116
  apiKeyId: row.id,
117
+ ...(row.tenantId !== undefined ? { tenantId: row.tenantId } : {}),
115
118
  };
116
119
  }
117
120
 
@@ -60,12 +60,16 @@ export async function bindClock(
60
60
  if (prebuilt) {
61
61
  clock = prebuilt;
62
62
  } else if (clockDriver === "frozen" || env === "test") {
63
- clock = createTestClockRuntime(now(), { instanceId: options.instanceId });
63
+ clock = createTestClockRuntime(now(), {
64
+ instanceId: options.instanceId,
65
+ ...(options.tenantIds ? { tenantIds: options.tenantIds } : {}),
66
+ });
64
67
  } else if (clockDriver === "memory") {
65
68
  clock = createClockRuntime({
66
69
  instanceId: options.instanceId,
67
70
  now,
68
71
  store: createMemoryCronStore(),
72
+ ...(options.tenantIds ? { tenantIds: options.tenantIds } : {}),
69
73
  });
70
74
  } else if (clockDriver === "file") {
71
75
  const path = resolve(process.cwd(), DEFAULT_FILE_CRON_PATH);
@@ -74,6 +78,7 @@ export async function bindClock(
74
78
  instanceId: options.instanceId,
75
79
  now,
76
80
  store: createFileCronStore(path),
81
+ ...(options.tenantIds ? { tenantIds: options.tenantIds } : {}),
77
82
  });
78
83
  } else if (clockDriver === "postgres") {
79
84
  const url = process.env.DATABASE_URL ?? process.env.OKE_STORE_SQL_URL ?? undefined;
@@ -89,6 +94,7 @@ export async function bindClock(
89
94
  instanceId: options.instanceId,
90
95
  now,
91
96
  store,
97
+ ...(options.tenantIds ? { tenantIds: options.tenantIds } : {}),
92
98
  });
93
99
  } else {
94
100
  throw new Error(
@@ -118,8 +124,8 @@ export async function bindClock(
118
124
  if (options.onCronFire) {
119
125
  const fire = options.onCronFire;
120
126
  for (const name of clockDecls.keys()) {
121
- clock.onCron(name, async () => {
122
- await fire(name);
127
+ clock.onCron(name, async (row) => {
128
+ await fire(row.name);
123
129
  });
124
130
  }
125
131
  }
@@ -152,6 +152,8 @@ export interface BootOptions {
152
152
  * @param name - Cron name or every-interval
153
153
  */
154
154
  readonly onCronFire?: (name: string) => void | Promise<void>;
155
+ /** Tenant ids for per-tenant clock expansion at reconcile. */
156
+ readonly tenantIds?: () => readonly string[] | Promise<readonly string[]>;
155
157
  /**
156
158
  * Dispatch a signal message to bound flows.
157
159
  *
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Kernel edge profile budget — AGENTS.md / unified-theory §24.
3
- * Limit: < 15 kB gzipped (minified bundle).
3
+ * Limit: < 16 kB gzipped (minified bundle).
4
4
  */
5
5
 
6
6
  import { describe, expect, test } from "bun:test";
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Durable runner — lazy chunk off Store-only `oke()` construction.
3
+ *
4
+ * `runDurable` statically imports `createFx`; keeping that edge off graphs
5
+ * that never run a durable Flow.
6
+ */
7
+
8
+ export { runDurable } from "../elements/clock/durable.ts";
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Parse `{template}#{tenantId}` — lazy chunk off Store-only `oke()` graphs.
3
+ */
4
+
5
+ export { parsePerTenantCronName } from "../elements/clock/declare.ts";
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Clock per-tenant row hooks — lazy chunk.
3
+ *
4
+ * A static import from `app.ts` would pin Clock reconcile on every `oke()`
5
+ * graph, including Store-only apps that never enable `gate.auth.tenant`.
6
+ */
7
+
8
+ export { orphanPerTenantCronRows, putPerTenantCronRows } from "../elements/clock/reconcile.ts";
@@ -0,0 +1,15 @@
1
+ /**
2
+ * OKE1014 — kept off the Store-only `oke()` graph.
3
+ *
4
+ * Signal drivers throw this when a Last-Event-ID is missing from the tape.
5
+ * Loaded via computed `import.meta.require` from {@link lookupOkeError}.
6
+ */
7
+
8
+ import type { OkeErrorDefinition } from "./errors.ts";
9
+
10
+ /** Last-Event-ID is missing from the retained live tape. */
11
+ export const LIVE_RESUME_GAP: OkeErrorDefinition = {
12
+ code: 1014,
13
+ cause: 'Cursor "{afterId}" missing on "{signal}".',
14
+ fix: "Reconnect without Last-Event-ID.",
15
+ };
@@ -0,0 +1,29 @@
1
+ /**
2
+ * OKE1015–1017 — kept off the Store-only `oke()` graph.
3
+ *
4
+ * Tenant KV/vault and membership failures. Loaded via computed
5
+ * `import.meta.require` from {@link throwOke} / {@link lookupOkeError}.
6
+ */
7
+
8
+ import type { OkeErrorDefinition } from "./errors.ts";
9
+
10
+ /** Tenant-scoped op ran with no resolved `fx.tenant.id`. */
11
+ export const TENANT_REQUIRED: OkeErrorDefinition = {
12
+ code: 1015,
13
+ cause: "This operation needs a tenant, but none is resolved for this request.",
14
+ fix: "Call fx.auth.switchTenant(id), send a signed tid claim, or pass the tenant header.",
15
+ };
16
+
17
+ /** Client-supplied tenant id is not a real membership. */
18
+ export const TENANT_NOT_MEMBER: OkeErrorDefinition = {
19
+ code: 1016,
20
+ cause: 'The caller is not a member of tenant "{tenant}".',
21
+ fix: "Pick a tenant from fx.auth.listTenants() or add the user as a member.",
22
+ };
23
+
24
+ /** Tenant role mapped an invented or operator-plane scope. */
25
+ export const TENANT_UNKNOWN_SCOPE: OkeErrorDefinition = {
26
+ code: 1017,
27
+ cause: 'Tenant role scope "{scope}" is not a declared application scope.',
28
+ fix: "Use a name from this app's Manifest catalog (never console:*).",
29
+ };