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/fx.ts CHANGED
@@ -39,6 +39,10 @@ import type { AiRuntime } from "../elements/ai.ts";
39
39
  import { parseDurationMs } from "../elements/clock/duration.ts";
40
40
  import type { ApiKeyStore } from "../auth/api-keys.ts";
41
41
  import type { FxAuthIdentity, FxAuthKeyMethods } from "./fx-auth-keys.ts";
42
+ import type { FxAuthTenantMethods } from "./fx-auth-tenants.ts";
43
+ import type { TenantStore } from "../auth/tenants.ts";
44
+ import type { SessionCrypto, SessionStore } from "../auth/sessions.ts";
45
+ import type { Manifest } from "../manifest/types.ts";
42
46
  import { createCapabilityToken, type CapabilityToken } from "./capability.ts";
43
47
  import { createEffectLedger, recordEffect, reversibilityOf, type EffectLedger } from "./effects.ts";
44
48
  import { resolveDurationMs } from "./elapsed.ts";
@@ -76,6 +80,15 @@ async function loadRunsWindow(): Promise<typeof import("../runs/window.ts")> {
76
80
  return import("../runs/window.ts");
77
81
  }
78
82
 
83
+ /**
84
+ * Sync-load live SSE + Last-Event-ID resume only when `fx.live` runs.
85
+ * A static import would pin `checkLiveResume` / 410 encoding on every
86
+ * createFx — including Store-only `oke()` graphs.
87
+ */
88
+ function loadFxLiveStream(): typeof import("./fx-live-stream.ts") {
89
+ return lazyRequire(import.meta.dir, ["fx", "live", "stream"].join("-"));
90
+ }
91
+
79
92
  /** Resource ref Flows declare to read the Runs store via {@link Fx.runs}. */
80
93
  export const RUNS_RESOURCE = "runs";
81
94
 
@@ -87,7 +100,7 @@ export const RUNS_RESOURCE = "runs";
87
100
  export const AUTH_API_KEYS_RESOURCE = "auth:api-keys";
88
101
 
89
102
  /**
90
- * Capability ref for {@link Fx.deadLetters} — `signal:<name>`, never a store facet.
103
+ * Capability ref for {@link Fx.deadLetters} / {@link Fx.live} — `signal:<name>`, never a store facet.
91
104
  *
92
105
  * @param name - Signal name
93
106
  */
@@ -116,7 +129,7 @@ export function resolveStoreRef(ref: NamedRef | { readonly ref: ResourceRef }):
116
129
  export type { FxAuthIdentity } from "./fx-auth-keys.ts";
117
130
 
118
131
  /** Auth principal on the user plane. */
119
- export interface FxAuth extends FxAuthIdentity, FxAuthKeyMethods {}
132
+ export interface FxAuth extends FxAuthIdentity, FxAuthKeyMethods, FxAuthTenantMethods {}
120
133
 
121
134
  /** Operator principal on the Console plane. */
122
135
  export interface FxOperator {
@@ -366,16 +379,46 @@ export interface JsonResult<T = unknown> {
366
379
  readonly kind?: undefined;
367
380
  }
368
381
 
369
- /** SSE carrier from {@link FxJson.stream}. */
382
+ /** SSE carrier from {@link FxJson.stream} / {@link Fx.live}. */
370
383
  export interface JsonStreamResult {
371
384
  readonly [jsonResultBrand]: true;
372
385
  readonly kind: "stream";
373
386
  readonly status: 200;
374
- readonly chunks: AsyncIterable<string>;
387
+ readonly chunks: AsyncIterable<unknown>;
388
+ /** Awaited before the 200 SSE body; throws OKE1014 on a missing resume cursor. */
389
+ ready?: () => Promise<void>;
375
390
  /** Set by the kernel to commit journal / Runs after the stream settles. */
376
391
  finalize?: () => Promise<void>;
377
392
  }
378
393
 
394
+ const sseFrameBrand: unique symbol = Symbol.for("oke.sse.frame");
395
+
396
+ /** One SSE frame — optional `id:` plus JSON `data:`. */
397
+ export interface SseFrame {
398
+ readonly [sseFrameBrand]: true;
399
+ readonly data: unknown;
400
+ readonly id?: string;
401
+ }
402
+
403
+ /**
404
+ * Brand a stream chunk so {@link encodeSseStream} can emit `id:`.
405
+ *
406
+ * @param data - JSON payload
407
+ * @param id - Optional SSE id
408
+ */
409
+ export function sseFrame(data: unknown, id?: string): SseFrame {
410
+ return id !== undefined ? { [sseFrameBrand]: true, data, id } : { [sseFrameBrand]: true, data };
411
+ }
412
+
413
+ /**
414
+ * True when `value` is a branded SSE frame.
415
+ *
416
+ * @param value - Unknown
417
+ */
418
+ export function isSseFrame(value: unknown): value is SseFrame {
419
+ return typeof value === "object" && value !== null && (value as SseFrame)[sseFrameBrand] === true;
420
+ }
421
+
379
422
  /** True when `value` is an {@link FxJson} JSON-envelope carrier. */
380
423
  export function isJsonResult(value: unknown): value is JsonResult {
381
424
  return (
@@ -429,9 +472,9 @@ export interface FxJson {
429
472
  withQuery<T>(rows: readonly T[], input: unknown, spec?: QueryPageSpec<T>): JsonResult<T[]>;
430
473
  /**
431
474
  * 200 — `text/event-stream` of JSON `data:` frames, then `data: [DONE]`.
432
- * Pass {@link Fx.stream} or any async iterable of token strings.
475
+ * Pass {@link Fx.stream} or any async iterable of chunks.
433
476
  */
434
- stream(chunks: AsyncIterable<string>): JsonStreamResult;
477
+ stream(chunks: AsyncIterable<unknown>): JsonStreamResult;
435
478
  }
436
479
 
437
480
  /**
@@ -645,6 +688,29 @@ export interface Fx {
645
688
  */
646
689
  deadLetters<T>(signal: SignalDecl<T>): Promise<readonly DeadLetter<T>[]>;
647
690
  deadLetters(signal: NamedRef): Promise<readonly DeadLetter[]>;
691
+ /**
692
+ * Stream a `delivery: "live"` signal as SSE (records `read` on `signal:<name>`).
693
+ *
694
+ * HTTP `Last-Event-ID` is applied when `opts.afterId` is omitted. A missing
695
+ * cursor throws OKE1014; `JsonStreamResult.ready` turns that into HTTP 410.
696
+ *
697
+ * @param signal - Signal name or handle
698
+ * @param opts - Payload filter and optional resume cursor
699
+ */
700
+ live<T>(
701
+ signal: SignalDecl<T>,
702
+ opts?: {
703
+ readonly match?: (payload: T) => boolean;
704
+ readonly afterId?: string;
705
+ },
706
+ ): JsonStreamResult;
707
+ live(
708
+ signal: NamedRef,
709
+ opts?: {
710
+ readonly match?: (payload: unknown) => boolean;
711
+ readonly afterId?: string;
712
+ },
713
+ ): JsonStreamResult;
648
714
  /**
649
715
  * Call another flow (records `call`). Stub returns `undefined`.
650
716
  *
@@ -848,6 +914,23 @@ export interface CreateFxOptions {
848
914
  readonly auth?: FxAuthIdentity;
849
915
  /** Shared API key store for {@link Fx.auth} key methods. */
850
916
  readonly apiKeyStore?: ApiKeyStore;
917
+ /** Tenant registry (when `gate.auth.tenant` is on). */
918
+ readonly tenantStore?: TenantStore;
919
+ /** Session store for {@link Fx.auth.switchTenant}. */
920
+ readonly sessions?: SessionStore;
921
+ /** Session crypto for {@link Fx.auth.switchTenant}. */
922
+ readonly sessionCrypto?: SessionCrypto;
923
+ /** Manifest for tenant-role catalog validation. */
924
+ readonly manifest?: Manifest | null;
925
+ /** When true, tenant-scoped KV / vault defaults apply. */
926
+ readonly tenantEnabled?: boolean;
927
+ /**
928
+ * When false, skip tenant-role scope union (tenant-unaware flow).
929
+ * Default true when {@link tenantEnabled}.
930
+ */
931
+ readonly flowTenantScoped?: boolean;
932
+ /** Current flow plane (tenant-role union is user-plane only). */
933
+ readonly flowPlane?: "user" | "operator";
851
934
  /** Operator principal. */
852
935
  readonly operator?: FxOperator;
853
936
  /**
@@ -920,6 +1003,8 @@ export interface CreateFxOptions {
920
1003
  * as `parentRunId` so consuming Flows can join the trace chain.
921
1004
  */
922
1005
  readonly runId?: string;
1006
+ /** HTTP `Last-Event-ID` for {@link Fx.live} resume (tests may pass `opts.afterId`). */
1007
+ readonly lastEventId?: string;
923
1008
  /** Reveal PII through the store runtime (requires `pii:reveal` upstream). */
924
1009
  readonly revealPii?: boolean;
925
1010
  /** Trigger gate names for RLS (`oke.gate` = first policy/public). */
@@ -1011,17 +1096,19 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1011
1096
  const cacheStore = new Map<string, unknown>();
1012
1097
 
1013
1098
  // Computed stem — a static import would pin HMAC / api-keys on every createFx.
1014
- const auth: FxAuth = lazyRequire<typeof import("./fx-auth-keys.ts")>(
1099
+ const authBag = options.auth ?? { userId: null, scopes: new Set() };
1100
+ const keys = lazyRequire<typeof import("./fx-auth-keys.ts")>(
1015
1101
  import.meta.dir,
1016
1102
  ["fx", "auth", "keys"].join("-"),
1017
1103
  ).attach({
1018
- auth: options.auth ?? { userId: null, scopes: new Set() },
1104
+ auth: authBag,
1019
1105
  store: options.apiKeyStore,
1020
1106
  now,
1021
1107
  gated,
1022
1108
  });
1109
+ const auth: FxAuth = keys as FxAuth;
1023
1110
  const operator: FxOperator = options.operator ?? { id: null };
1024
- const tenant: FxTenant = options.tenant ?? { id: null };
1111
+ const tenant: { id: string | null } = options.tenant ?? { id: null };
1025
1112
  const defaultLocale = options.i18n?.defaultLocale ?? "en";
1026
1113
  const locale = options.i18n?.locale ?? defaultLocale;
1027
1114
  const catalogs = options.i18n?.catalogs ?? {};
@@ -1057,6 +1144,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1057
1144
  gateNames: options.rlsGateNames ?? [],
1058
1145
  bypass: false,
1059
1146
  operator: false,
1147
+ ...(options.tenantEnabled === true ? { tenantId: tenant.id } : {}),
1060
1148
  });
1061
1149
  return identity ? { rls: identity } : {};
1062
1150
  }
@@ -1356,6 +1444,33 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1356
1444
  } as SqlStoreHandle;
1357
1445
  }
1358
1446
 
1447
+ function loadFxTenantStore(): {
1448
+ kv: (
1449
+ mode: "in" | "out",
1450
+ tenantId: string | null,
1451
+ decl: KvStoreDecl,
1452
+ enabled: boolean,
1453
+ prop: string | symbol,
1454
+ payload: unknown,
1455
+ ) => unknown;
1456
+ path: (
1457
+ tenantId: string | null,
1458
+ enabled: boolean,
1459
+ contracts: { get(name: string): { perTenant?: boolean } | undefined } | undefined,
1460
+ name: string,
1461
+ ) => string;
1462
+ missingVault: (storagePath: string) => Error;
1463
+ } {
1464
+ return lazyRequire(import.meta.dir, ["fx", "tenant", "store"].join("-"));
1465
+ }
1466
+
1467
+ const tenantOn = options.tenantEnabled === true;
1468
+
1469
+ function tenantVaultPath(name: string): string {
1470
+ if (!tenantOn) return name;
1471
+ return loadFxTenantStore().path(tenant.id, true, options.vaultRuntime?.contracts, name);
1472
+ }
1473
+
1359
1474
  function storeHandle(ref: SqlStoreDecl): SqlStoreHandle;
1360
1475
  function storeHandle(ref: KvStoreDecl): KvStoreFxHandle;
1361
1476
  function storeHandle(ref: FilesStoreDecl): FilesStoreFxHandle;
@@ -1607,7 +1722,19 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1607
1722
  const vaultSurface: FxVault = {
1608
1723
  get(secret) {
1609
1724
  const name = resolveName(secret);
1610
- return gatedSecret(name, () => {
1725
+ return gatedSecret(name, async () => {
1726
+ const path = tenantVaultPath(name);
1727
+ if (path !== name) {
1728
+ if (options.vaultAdapter) {
1729
+ const rec = await options.vaultAdapter.get(path);
1730
+ if (!rec) {
1731
+ throw loadFxTenantStore().missingVault(path);
1732
+ }
1733
+ return new Redacted(rec.value);
1734
+ }
1735
+ const value = secrets[path] ?? secrets[name] ?? `[secret:${path}]`;
1736
+ return new Redacted(value);
1737
+ }
1611
1738
  const value = options.vaultRuntime
1612
1739
  ? options.vaultRuntime.read(name)
1613
1740
  : (secrets[name] ?? `[secret:${name}]`);
@@ -1618,7 +1745,8 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1618
1745
  const name = resolveName(path);
1619
1746
  return gatedSecret(name, async () => {
1620
1747
  refuseDryRunVaultWrite("set", name);
1621
- const written = await vaultAdapterFor("set").set(name, value, {
1748
+ const storage = tenantVaultPath(name);
1749
+ const written = await vaultAdapterFor("set").set(storage, value, {
1622
1750
  ...(setOptions?.ttlMs !== undefined ? { ttlMs: setOptions.ttlMs } : {}),
1623
1751
  ...(setOptions?.metadata !== undefined ? { metadata: setOptions.metadata } : {}),
1624
1752
  actor: vaultActor,
@@ -1630,7 +1758,8 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1630
1758
  const name = resolveName(path);
1631
1759
  return gatedSecret(name, async () => {
1632
1760
  refuseDryRunVaultWrite("rotate", name);
1633
- const written = await vaultAdapterFor("rotate").rotate(name, value, {
1761
+ const storage = tenantVaultPath(name);
1762
+ const written = await vaultAdapterFor("rotate").rotate(storage, value, {
1634
1763
  actor: vaultActor,
1635
1764
  });
1636
1765
  return { path: written.path, version: written.version };
@@ -1640,7 +1769,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1640
1769
  const name = resolveName(path);
1641
1770
  return gatedSecret(name, async () => {
1642
1771
  refuseDryRunVaultWrite("delete", name);
1643
- return vaultAdapterFor("delete").delete(name, { actor: vaultActor });
1772
+ return vaultAdapterFor("delete").delete(tenantVaultPath(name), { actor: vaultActor });
1644
1773
  });
1645
1774
  },
1646
1775
  async list(prefix) {
@@ -1723,6 +1852,18 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1723
1852
  return options.signalRuntime.deadLetters(name);
1724
1853
  });
1725
1854
  },
1855
+ live(
1856
+ signal: NamedRef,
1857
+ opts?: { readonly match?: (payload: unknown) => boolean; readonly afterId?: string },
1858
+ ) {
1859
+ return loadFxLiveStream().createLiveStream({
1860
+ name: resolveName(signal),
1861
+ afterId: opts?.afterId ?? options.lastEventId,
1862
+ match: opts?.match,
1863
+ gated,
1864
+ signalRuntime: options.signalRuntime,
1865
+ }) as JsonStreamResult;
1866
+ },
1726
1867
  call(flow, input) {
1727
1868
  const name = resolveName(flow);
1728
1869
  return gated("call", name, async () => {
@@ -2003,5 +2144,24 @@ export function createFxContext(options: CreateFxOptions): FxContext {
2003
2144
  },
2004
2145
  };
2005
2146
 
2147
+ if (options.tenantStore !== undefined || options.tenantEnabled === true) {
2148
+ lazyRequire<{
2149
+ install: (
2150
+ bag: { auth: FxAuthIdentity; store: (ref: never) => object },
2151
+ ctx: {
2152
+ readonly tenantId: string | null;
2153
+ readonly options: typeof options;
2154
+ readonly now: () => number;
2155
+ readonly gated: typeof gated;
2156
+ },
2157
+ ) => void;
2158
+ }>(import.meta.dir, ["fx", "tenant", "store"].join("-")).install(fx, {
2159
+ tenantId: tenant.id,
2160
+ options,
2161
+ now,
2162
+ gated,
2163
+ });
2164
+ }
2165
+
2006
2166
  return { fx, ledger, capability };
2007
2167
  }
@@ -156,7 +156,7 @@ export type PipelineEncoder = (result: {
156
156
  readonly failure?: FlowFailure | undefined;
157
157
  readonly output?: unknown;
158
158
  readonly error?: unknown;
159
- }) => Response;
159
+ }) => Response | Promise<Response>;
160
160
 
161
161
  /**
162
162
  * Run the seven-stage pipeline around `handler`.
@@ -234,7 +234,7 @@ export async function runPipeline(
234
234
  // (short-circuit wins; encoder input mirrors the app layer exactly).
235
235
  if (encode !== undefined && ctx.response === undefined) {
236
236
  const failure = isFlowFailure(ctx.error) ? ctx.error : undefined;
237
- ctx.response = encode({ failure, output: ctx.result, error: ctx.error });
237
+ ctx.response = await encode({ failure, output: ctx.result, error: ctx.error });
238
238
  }
239
239
 
240
240
  await runStage("onResponse", hooks, ctx, fx);
@@ -22,37 +22,28 @@ import {
22
22
  * @param path - Base path (`/notes`)
23
23
  * @param ops - The five FlowDefs (usually `resource.all()`)
24
24
  * @param gates - Shared gate chain
25
- * @param isLive - Live on GET list + get
26
25
  */
27
26
  export function httpResource<P extends string>(
28
27
  path: P,
29
28
  ops: ResourceFlowBag,
30
29
  gates: readonly GateRef[] = [],
31
- isLive = false,
32
30
  ): ResourceMount {
33
31
  const id = `${path}/:id` as `${P}/:id`;
34
- const verb = <M extends HttpMethod>(
35
- method: M,
36
- p: P | `${P}/:id`,
37
- live: boolean,
38
- ): HttpTrigger<M> => createHttpTrigger(method, p, gates, live);
32
+ const verb = <M extends HttpMethod>(method: M, p: P | `${P}/:id`): HttpTrigger<M> =>
33
+ createHttpTrigger(method, p, gates);
39
34
  const mount: ResourceMount = {
40
35
  [resourceMountBrand]: true,
41
36
  gates,
42
- isLive,
43
37
  mounts: [
44
- { trigger: verb("GET", path, isLive), flow: ops.list },
45
- { trigger: verb("POST", path, false), flow: ops.create },
46
- { trigger: verb("GET", id, isLive), flow: ops.get },
47
- { trigger: verb("PATCH", id, false), flow: ops.update },
48
- { trigger: verb("DELETE", id, false), flow: ops.remove },
38
+ { trigger: verb("GET", path), flow: ops.list },
39
+ { trigger: verb("POST", path), flow: ops.create },
40
+ { trigger: verb("GET", id), flow: ops.get },
41
+ { trigger: verb("PATCH", id), flow: ops.update },
42
+ { trigger: verb("DELETE", id), flow: ops.remove },
49
43
  ],
50
- gate: createGateAttach((next) => httpResource(path, ops, next, isLive), gates),
44
+ gate: createGateAttach((next) => httpResource(path, ops, next), gates),
51
45
  public() {
52
- return httpResource(path, ops, [...gates, GATE_PUBLIC_NAME], isLive);
53
- },
54
- live() {
55
- return httpResource(path, ops, gates, true);
46
+ return httpResource(path, ops, [...gates, GATE_PUBLIC_NAME]);
56
47
  },
57
48
  };
58
49
  return mount;
@@ -78,6 +78,7 @@ export type {
78
78
  AppFlowRoute,
79
79
  AppRouteMap,
80
80
  FlowNamespace,
81
+ LiveAppFlowRoute,
81
82
  RouteFromFlow,
82
83
  RoutesFromAdoptArgs,
83
84
  } from "./adopt-routes.ts";
@@ -359,6 +360,7 @@ export {
359
360
  type HttpMethod,
360
361
  type HttpTrigger,
361
362
  type InternalTrigger,
363
+ type LiveHttpTrigger,
362
364
  type ResourceFlowBag,
363
365
  type ResourceMount,
364
366
  type SignalAsTrigger,
@@ -104,6 +104,8 @@ export interface JournalRun {
104
104
  leaseExpiresAt?: number;
105
105
  readonly createdAt: number;
106
106
  updatedAt: number;
107
+ /** Isolation context for resume (`fx.tenant`). */
108
+ tenant?: string | null;
107
109
  }
108
110
 
109
111
  /**
@@ -366,6 +368,12 @@ export interface JournalSession {
366
368
  readonly runId: string;
367
369
  /** Underlying run snapshot (mutated as entries append). */
368
370
  readonly run: JournalRun;
371
+ /**
372
+ * Persist isolation context so resume restamps {@link Fx.tenant}.
373
+ *
374
+ * @param id - Tenant id (null clears)
375
+ */
376
+ stampTenant(id: string | null): Promise<void>;
369
377
  /**
370
378
  * Replay or execute a named step. Never re-runs `fn` when already journaled.
371
379
  *
@@ -607,6 +615,10 @@ export function createJournal(options: CreateJournalOptions): Journal {
607
615
  undoStack() {
608
616
  return undos;
609
617
  },
618
+ async stampTenant(id) {
619
+ run.tenant = id;
620
+ await persist();
621
+ },
610
622
  beginRegistrationPass() {
611
623
  registrationPass = true;
612
624
  },
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Live HTTP exposure uniqueness (OKE1013) and GET-only synthesis.
3
+ */
4
+
5
+ import { describe, expect, test, beforeEach } from "bun:test";
6
+ import { z } from "zod";
7
+ import { gate } from "../elements/gate.ts";
8
+ import { signal, resetSignals } from "../elements/signal/declare.ts";
9
+ import { oke } from "./app.ts";
10
+ import { resetFlowSeq } from "./flow.ts";
11
+ import { on, resetBindings } from "./on.ts";
12
+ import { stampFlowName } from "./stamp-http.ts";
13
+ import { http } from "./triggers.ts";
14
+
15
+ const member = gate.policy("member", ({ auth }) => !!auth.verified);
16
+ const admin = gate.policy("admin", ({ auth }) => !!auth.verified);
17
+ const partner = gate.policy("partner", ({ auth }) => !!auth.verified);
18
+
19
+ const orderStatus = () =>
20
+ signal("order-status", {
21
+ delivery: "live",
22
+ optional: true,
23
+ schema: z.object({
24
+ orderId: z.string(),
25
+ status: z.enum(["placed", "fulfilling", "shipped"]),
26
+ }),
27
+ });
28
+
29
+ beforeEach(() => {
30
+ resetBindings();
31
+ resetFlowSeq();
32
+ resetSignals();
33
+ });
34
+
35
+ describe("live HTTP uniqueness", () => {
36
+ test("member :orderId + admin firehose both boot", () => {
37
+ const sig = orderStatus();
38
+ const events = on(http.get("/orders/:orderId/events").gate(member).live(sig));
39
+ const feed = on(http.get("/admin/order-status").gate(admin).live(sig));
40
+ stampFlowName(events, "orders.events");
41
+ stampFlowName(feed, "admin.adminFeed");
42
+ expect(() => oke({ name: "t", autoBoot: false })).not.toThrow();
43
+ });
44
+
45
+ test("same match, different gates boot (via disambiguates on the client)", () => {
46
+ const sig = orderStatus();
47
+ const a = on(http.get("/orders/:orderId/events").gate(member).live(sig));
48
+ const b = on(http.get("/partners/:orderId/events").gate(partner).live(sig));
49
+ stampFlowName(a, "orders.events");
50
+ stampFlowName(b, "partners.events");
51
+ expect(() => oke({ name: "t", autoBoot: false })).not.toThrow();
52
+ });
53
+
54
+ test("two member firehoses on different paths fail OKE1013", () => {
55
+ const sig = orderStatus();
56
+ const a = on(http.get("/feed-a").gate(member).live(sig));
57
+ const b = on(http.get("/feed-b").gate(member).live(sig));
58
+ stampFlowName(a, "orders.feedA");
59
+ stampFlowName(b, "orders.feedB");
60
+ expect(() => oke({ name: "t", autoBoot: false })).toThrow(/OKE1013/);
61
+ });
62
+
63
+ test("duplicate GET path still OKE1011", () => {
64
+ const sig = orderStatus();
65
+ const a = on(http.get("/orders/:orderId/events").gate(member).live(sig));
66
+ const b = on(http.get("/orders/:orderId/events").gate(admin).live(sig));
67
+ stampFlowName(a, "orders.events");
68
+ stampFlowName(b, "admin.events");
69
+ expect(() => oke({ name: "t", autoBoot: false })).toThrow(/OKE1011/);
70
+ });
71
+
72
+ test("POST .live(signal) is rejected", () => {
73
+ const sig = orderStatus();
74
+ expect(() => on(http.post("/orders").gate(member).live(sig))).toThrow(
75
+ /live exposure must be GET/,
76
+ );
77
+ });
78
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Live HTTP helpers — path params, auto-match, exposure uniqueness, synthesis.
3
+ */
4
+
5
+ import type { SignalDecl } from "../elements/signal/declare.ts";
6
+ import type { SignalResourceRef } from "../manifest/types.ts";
7
+ import type { Fx } from "./fx.ts";
8
+ import { flow, type AnyFlowDef } from "./flow.ts";
9
+ import type { GateRef, SignalSource } from "./triggers.ts";
10
+
11
+ /** Default GET path for {@link http.live}. */
12
+ export const LIVE_HTTP_PREFIX = "/_oke/live/";
13
+
14
+ /**
15
+ * Default HTTP path for a live-signal firehose.
16
+ *
17
+ * @param signalName - Signal name
18
+ */
19
+ export function liveHttpPath(signalName: string): string {
20
+ return `${LIVE_HTTP_PREFIX}${encodeURIComponent(signalName)}`;
21
+ }
22
+
23
+ /**
24
+ * `:param` names on an HTTP path template, first occurrence order.
25
+ *
26
+ * @param path - Route path
27
+ */
28
+ export function httpPathParams(path: string): string[] {
29
+ const names: string[] = [];
30
+ const re = /:([A-Za-z_][\w]*)/g;
31
+ let match: RegExpExecArray | null = re.exec(path);
32
+ while (match) {
33
+ const name = match[1];
34
+ if (name !== undefined && !names.includes(name)) names.push(name);
35
+ match = re.exec(path);
36
+ }
37
+ return names;
38
+ }
39
+
40
+ /**
41
+ * Sorted auto-match field list joined for uniqueness (`""` = firehose).
42
+ *
43
+ * @param path - HTTP path template
44
+ */
45
+ export function liveMatchKeyFromPath(path: string): string {
46
+ return httpPathParams(path).toSorted().join(",");
47
+ }
48
+
49
+ /**
50
+ * Sorted flattened gate names (`public` included).
51
+ *
52
+ * @param gates - Trigger gates
53
+ */
54
+ export function liveGatesKey(gates: readonly GateRef[]): string {
55
+ return gates
56
+ .map((g) => (typeof g === "string" ? g : g.name))
57
+ .toSorted()
58
+ .join(",");
59
+ }
60
+
61
+ /**
62
+ * Boot uniqueness key: `(signalName, gatesKey, matchKey)`.
63
+ *
64
+ * @param signalName - Signal name
65
+ * @param gatesKey - {@link liveGatesKey}
66
+ * @param matchKey - {@link liveMatchKeyFromPath} or `custom:{flow}`
67
+ */
68
+ export function liveExposureKey(signalName: string, gatesKey: string, matchKey: string): string {
69
+ return `${signalName}\0${gatesKey}\0${matchKey}`;
70
+ }
71
+
72
+ /**
73
+ * Auto-match: payload fields equal same-named input fields.
74
+ *
75
+ * @param payload - Signal payload
76
+ * @param input - HTTP input (path params)
77
+ * @param fields - Field names (path params)
78
+ */
79
+ export function payloadAutoMatch(
80
+ payload: unknown,
81
+ input: unknown,
82
+ fields: readonly string[],
83
+ ): boolean {
84
+ if (fields.length === 0) return true;
85
+ if (payload === null || typeof payload !== "object") return true;
86
+ const row = payload as Record<string, unknown>;
87
+ const params =
88
+ input !== null && typeof input === "object" ? (input as Record<string, unknown>) : {};
89
+ for (const key of fields) {
90
+ if (!(key in row)) continue;
91
+ if (row[key] !== params[key]) return false;
92
+ }
93
+ return true;
94
+ }
95
+
96
+ /**
97
+ * Synthesize a stream Flow for `on(http.get(path).live(signal))`.
98
+ *
99
+ * @param signal - Live signal handle
100
+ * @param path - HTTP path (auto-match keys)
101
+ */
102
+ export function synthesizeLiveFlow(signal: SignalSource, path: string): AnyFlowDef {
103
+ const fields = httpPathParams(path);
104
+ const name = signal.name;
105
+ const schema = "schema" in signal ? (signal as SignalDecl).schema : undefined;
106
+ return flow({
107
+ effects: { reads: [`signal:${name}` as SignalResourceRef] },
108
+ ...(schema !== undefined ? { out: schema } : {}),
109
+ do: (input, fx: Fx) =>
110
+ fx.live(signal, {
111
+ match: (payload) => payloadAutoMatch(payload, input, fields),
112
+ }),
113
+ });
114
+ }