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
@@ -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
  /**
@@ -139,6 +139,15 @@ describe("diffManifest — fixture pairs", () => {
139
139
  expect(hasCategory(result.changes, "contract-breaking", "delivery")).toBe(true);
140
140
  });
141
141
 
142
+ test("10b · live retention change is contract-breaking", async () => {
143
+ const before = await loadBase();
144
+ before.signals!["order-placed"]!.delivery = "live";
145
+ const after = clone(before);
146
+ after.signals!["order-placed"]!.retention = { maxCount: 10 };
147
+ const result = diffManifest(before, after);
148
+ expect(hasCategory(result.changes, "contract-breaking", "retention")).toBe(true);
149
+ });
150
+
142
151
  test("11 · rate limit expression widened is permission-widening", async () => {
143
152
  const before = await loadBase();
144
153
  const after = clone(before);
@@ -257,7 +266,7 @@ describe("diffManifest — additional branches", () => {
257
266
  properties: { id: { type: "number" } },
258
267
  };
259
268
  flow.durable = true;
260
- flow.live = true;
269
+ flow.live = "order-status";
261
270
  flow.plane = "operator";
262
271
  flow.cost = { estimatePerCall: 1, budget: 2 };
263
272
  flow.pii = "allow";
@@ -598,7 +607,7 @@ describe("diffManifest — line coverage sweep", () => {
598
607
  after.flows!["orders.create"]!.steps = ["a"];
599
608
  after.flows!["orders.create"]!.nondeterministic = false;
600
609
  after.flows!["orders.create"]!.cost = { budget: 1 };
601
- after.flows!["orders.create"]!.live = false;
610
+ delete after.flows!["orders.create"]!.live;
602
611
  after.flows!["orders.create"]!.durable = false;
603
612
  after.flows!["orders.create"]!.in = "CreateOrderV2";
604
613
  after.flows!["orders.create"]!.out = {
@@ -218,7 +218,7 @@ function diffFlow(before: Flow, after: Flow, path: string, out: ManifestChange[]
218
218
  out.push(
219
219
  change(
220
220
  `${path}/live`,
221
- after.live === true ? "effect-widening" : "no-impact",
221
+ after.live !== undefined && after.live !== before.live ? "effect-widening" : "no-impact",
222
222
  kindOf(before.live, after.live),
223
223
  before.live,
224
224
  after.live,
@@ -760,6 +760,18 @@ function diffSignal(before: Signal, after: Signal, path: string, out: ManifestCh
760
760
  ),
761
761
  );
762
762
  }
763
+ if (!deepEqual(before.retention, after.retention)) {
764
+ out.push(
765
+ change(
766
+ `${path}/retention`,
767
+ "contract-breaking",
768
+ kindOf(before.retention, after.retention),
769
+ before.retention,
770
+ after.retention,
771
+ "live retention changed",
772
+ ),
773
+ );
774
+ }
763
775
  }
764
776
 
765
777
  function diffStore(before: Store, after: Store, path: string, out: ManifestChange[]): void {
@@ -1233,18 +1245,48 @@ function diffTenancy(
1233
1245
  );
1234
1246
  return;
1235
1247
  }
1248
+ if (before?.enabled === true && after?.enabled !== true) {
1249
+ out.push(
1250
+ change(
1251
+ "/tenancy/enabled",
1252
+ "permission-widening",
1253
+ "changed",
1254
+ true,
1255
+ after?.enabled,
1256
+ "tenancy disabled",
1257
+ ),
1258
+ );
1259
+ } else if (before?.enabled !== true && after?.enabled === true) {
1260
+ out.push(
1261
+ change("/tenancy/enabled", "no-impact", "changed", before?.enabled, true, "tenancy enabled"),
1262
+ );
1263
+ }
1264
+ if (before?.membershipRequired === true && after?.membershipRequired !== true) {
1265
+ out.push(
1266
+ change(
1267
+ "/tenancy/membershipRequired",
1268
+ "permission-widening",
1269
+ "changed",
1270
+ true,
1271
+ after?.membershipRequired,
1272
+ "tenant membership no longer required",
1273
+ ),
1274
+ );
1275
+ }
1236
1276
  const b = before?.isolation ? ISOLATION_RANK[before.isolation] : undefined;
1237
1277
  const a = after?.isolation ? ISOLATION_RANK[after.isolation] : undefined;
1238
- out.push(
1239
- change(
1240
- "/tenancy/isolation",
1241
- b !== undefined && a !== undefined && a < b ? "permission-widening" : "no-impact",
1242
- "changed",
1243
- before?.isolation,
1244
- after?.isolation,
1245
- `tenancy isolation ${before?.isolation} → ${after?.isolation}`,
1246
- ),
1247
- );
1278
+ if (before?.isolation !== after?.isolation) {
1279
+ out.push(
1280
+ change(
1281
+ "/tenancy/isolation",
1282
+ b !== undefined && a !== undefined && a < b ? "permission-widening" : "no-impact",
1283
+ "changed",
1284
+ before?.isolation,
1285
+ after?.isolation,
1286
+ `tenancy isolation ${before?.isolation} → ${after?.isolation}`,
1287
+ ),
1288
+ );
1289
+ }
1248
1290
  }
1249
1291
 
1250
1292
  function diffI18n(before: I18n | undefined, after: I18n | undefined, out: ManifestChange[]): void {
@@ -17,7 +17,7 @@
17
17
  "slo": { "availability": "99.9%", "latency": { "p99": "200ms" } },
18
18
  "source": "src/flows/bookings/index.ts:18"
19
19
  },
20
- "bookings.mine": { "live": true, "cacheKeys": "computed:sql:bookings/userId" },
20
+ "bookings.mine": { "cacheKeys": "computed:sql:bookings/userId" },
21
21
  "payments.chargeBooking": {
22
22
  "durable": true,
23
23
  "steps": ["create-intent", "confirm"],
@@ -34,7 +34,6 @@
34
34
  "bookings.mine": {
35
35
  "trigger": { "http": { "method": "GET", "path": "/bookings" } },
36
36
  "gates": ["member"],
37
- "live": true,
38
37
  "cacheKeys": "computed:sql:bookings/userId",
39
38
  "effects": {
40
39
  "reads": ["sql:bookings"]
@@ -53,7 +53,13 @@ export type RunsResourceRef = "runs";
53
53
  export type AuthApiKeysResourceRef = "auth:api-keys";
54
54
 
55
55
  /**
56
- * Dead-letter read capability for `fx.deadLetters` — not a store facet.
56
+ * Auth tenant-management capability for `fx.auth` list/switch/create/….
57
+ * Not a store facet — do not declare `sql:oke_tenants`.
58
+ */
59
+ export type AuthTenantsResourceRef = "auth:tenants";
60
+
61
+ /**
62
+ * Dead-letter / live-stream read capability — not a store facet.
57
63
  * Declare on `effects.reads` (never `writes`).
58
64
  */
59
65
  export type SignalResourceRef = `signal:${string}`;
@@ -86,10 +92,16 @@ export type JsonSchema = string | Record<string, unknown>;
86
92
  * `sends` / `asks` are irreversible (asks also nondeterministic + cost).
87
93
  */
88
94
  export interface Effects {
89
- /** Store reads, plus `"runs"` for `fx.runs` and `signal:name` for `fx.deadLetters`. */
90
- reads?: Array<ResourceRef | RunsResourceRef | SignalResourceRef | AuthApiKeysResourceRef>;
91
- /** Store writes, plus `"auth:api-keys"` for `fx.auth` mutations. */
92
- writes?: Array<ResourceRef | AuthApiKeysResourceRef>;
95
+ /** Store reads, plus `"runs"` for `fx.runs` and `signal:name` for `fx.deadLetters` / `fx.live`. */
96
+ reads?: Array<
97
+ | ResourceRef
98
+ | RunsResourceRef
99
+ | SignalResourceRef
100
+ | AuthApiKeysResourceRef
101
+ | AuthTenantsResourceRef
102
+ >;
103
+ /** Store writes, plus `"auth:api-keys"` / `"auth:tenants"` for `fx.auth` mutations. */
104
+ writes?: Array<ResourceRef | AuthApiKeysResourceRef | AuthTenantsResourceRef>;
93
105
  /** Emitted signals. */
94
106
  emits?: SignalRef[];
95
107
  /** Channel template sends (irreversible). */
@@ -162,7 +174,8 @@ export interface Flow {
162
174
  source?: string;
163
175
  plane?: FlowPlane;
164
176
  durable?: boolean;
165
- live?: boolean;
177
+ /** Live signal name when this flow streams `delivery: "live"` SSE. */
178
+ live?: string;
166
179
  cache?: boolean | string;
167
180
  cacheKeys?: string;
168
181
  slo?: Slo;
@@ -178,6 +191,19 @@ export interface Flow {
178
191
  allowPii?: boolean;
179
192
  /** Author acknowledges an intentional contract break. */
180
193
  breaking?: boolean;
194
+ /**
195
+ * When `false`, skip tenant-role scope union.
196
+ * Default `true` when `gate.auth.tenant` is on.
197
+ */
198
+ tenantScoped?: boolean;
199
+ }
200
+
201
+ /** Live-tape cap. Omit both fields (or omit `retention`) for an unbounded tape. */
202
+ export interface SignalRetention {
203
+ /** Drop events older than this duration (`"7d"`, `"1h"`, `"30s"`, …). */
204
+ maxAge?: string;
205
+ /** Keep only the newest N live events. */
206
+ maxCount?: number;
181
207
  }
182
208
 
183
209
  /** One Signal declaration. */
@@ -189,6 +215,8 @@ export interface Signal {
189
215
  deadLetter?: boolean;
190
216
  schema?: JsonSchema;
191
217
  optional?: boolean;
218
+ /** Live-tape cap (`delivery: "live"` only). Omitted = unbounded. */
219
+ retention?: SignalRetention;
192
220
  }
193
221
 
194
222
  /** Column / field classification for privacy and retention. */
@@ -247,6 +275,8 @@ export interface Table {
247
275
  rls?: boolean;
248
276
  /** Policy name → Drizzle-shaped spec. */
249
277
  policies?: Record<string, TablePolicy>;
278
+ /** When `false`, skip fail-loud tenant-policy requirement. */
279
+ tenantScoped?: boolean;
250
280
  }
251
281
 
252
282
  /** One Store declaration. */
@@ -264,6 +294,11 @@ export interface Store {
264
294
  * Distinct from Flow `durable` (journaled steps).
265
295
  */
266
296
  durable?: boolean;
297
+ /**
298
+ * KV only — prefix keys with `{tenantId}:` when tenancy is on.
299
+ * Default `true` then; `false` is the global-namespace opt-out.
300
+ */
301
+ tenantScoped?: boolean;
267
302
  }
268
303
 
269
304
  /** Named clock / schedule. */
@@ -274,6 +309,8 @@ export interface Clock {
274
309
  overridable?: boolean;
275
310
  /** Optional human description (falls back to the clock map key). */
276
311
  description?: string;
312
+ /** Expand one `oke_crons` row per tenant. */
313
+ perTenant?: boolean;
277
314
  }
278
315
 
279
316
  /** Named gate — policy, rate, or a flattened `gate.all` handle. */
@@ -305,6 +342,11 @@ export interface SecretContract {
305
342
  * in the clear. Defaults to `true` (secret).
306
343
  */
307
344
  sensitive?: boolean;
345
+ /**
346
+ * Resolve per `fx.tenant.id` at request time (not boot).
347
+ * Default `true` when `gate.auth.tenant` is on.
348
+ */
349
+ perTenant?: boolean;
308
350
  }
309
351
 
310
352
  /** Channel template. */
@@ -400,7 +442,11 @@ export interface Plugin {
400
442
 
401
443
  /** Tenancy configuration (resolver is code; isolation is data). */
402
444
  export interface Tenancy {
445
+ /** `gate.auth.tenant` is on. */
446
+ enabled?: boolean;
403
447
  isolation?: TenancyIsolation;
448
+ /** Pure B2B — every user-plane request needs a tenant. */
449
+ membershipRequired?: boolean;
404
450
  }
405
451
 
406
452
  /** i18n configuration. */
@@ -24,9 +24,21 @@ const ENTRIES: readonly { readonly src: string; readonly out: string }[] = [
24
24
  { src: "src/journal-entry.ts", out: "dist/journal-entry.js" },
25
25
  // Lazy sync chunks — loaded via requirePackageModule / computed require
26
26
  // when the feature actually runs (auth, list page, auto-cache).
27
- { src: "src/kernel/app-auth.ts", out: "dist/app-auth.js" },
27
+ { src: "src/kernel/fx-runtime.ts", out: "dist/fx-runtime.js" },
28
28
  { src: "src/auth/config.ts", out: "dist/auth-config.js" },
29
29
  { src: "src/kernel/fx-auth-keys.ts", out: "dist/fx-auth-keys.js" },
30
+ { src: "src/kernel/fx-auth-tenants.ts", out: "dist/fx-auth-tenants.js" },
31
+ { src: "src/kernel/fx-tenant-store.ts", out: "dist/fx-tenant-store.js" },
32
+ { src: "src/drivers/pg-vault-rls.ts", out: "dist/pg-vault-rls.js" },
33
+ { src: "src/kernel/app-tenant.ts", out: "dist/app-tenant.js" },
34
+ { src: "src/kernel/pipeline-tenant.ts", out: "dist/pipeline-tenant.js" },
35
+ { src: "src/kernel/fx-live-stream.ts", out: "dist/fx-live-stream.js" },
36
+ { src: "src/kernel/errors-live-resume.ts", out: "dist/errors-live-resume.js" },
37
+ { src: "src/kernel/errors-tenant.ts", out: "dist/errors-tenant.js" },
38
+ { src: "src/kernel/clock-durable.ts", out: "dist/clock-durable.js" },
39
+ { src: "src/kernel/clock-reconcile.ts", out: "dist/clock-reconcile.js" },
40
+ { src: "src/kernel/clock-per-tenant-name.ts", out: "dist/clock-per-tenant-name.js" },
41
+ { src: "src/elements/store/schema-tenant.ts", out: "dist/schema-tenant.js" },
30
42
  { src: "src/kernel/list-page.ts", out: "dist/list-page.js" },
31
43
  { src: "src/kernel/http-resource.ts", out: "dist/http-resource.js" },
32
44
  { src: "src/elements/store/cache.ts", out: "dist/store-cache.js" },
@@ -5,10 +5,10 @@
5
5
  */
6
6
 
7
7
  /** Kernel (edge profile) — gzipped minified bundle. */
8
- export const KERNEL_EDGE_BUDGET_BYTES = 15 * 1024;
8
+ export const KERNEL_EDGE_BUDGET_BYTES = 16 * 1024;
9
9
 
10
10
  /** Client runtime — gzipped minified bundle. */
11
- export const CLIENT_BUDGET_BYTES = 3 * 1024;
11
+ export const CLIENT_BUDGET_BYTES = 4 * 1024;
12
12
 
13
13
  /** Console initial load — gzipped html + entry js/css (ui-next SPA, no panel-* split). */
14
14
  export const CONSOLE_BUDGET_BYTES = 700 * 1024;
@@ -5,7 +5,7 @@
5
5
  import { mkdtemp, readdir, rm } from "node:fs/promises";
6
6
  import { tmpdir } from "node:os";
7
7
  import { join, resolve } from "node:path";
8
- import { createRouter } from "../kernel/router.ts";
8
+ import { lazyRequire } from "../kernel/lazy-require.ts";
9
9
  import {
10
10
  type BudgetGroup,
11
11
  OFFICIAL_PLUGIN_BUDGETS,
@@ -228,6 +228,16 @@ export async function measureHttpPingAppBytes(): Promise<HttpPingBudgetSample> {
228
228
  * Gzip size of the kernel edge profile bundle.
229
229
  */
230
230
  export async function measureKernelEdgeGzipBytes(): Promise<number> {
231
+ try {
232
+ return await gzipKernelEdgeInProcess();
233
+ } catch {
234
+ // bun test's in-process Bun.build cannot always resolve the kernel graph.
235
+ return gzipKernelEdgeSpawn();
236
+ }
237
+ }
238
+
239
+ /** In-process edge gzip — used outside `bun test`. */
240
+ async function gzipKernelEdgeInProcess(): Promise<number> {
231
241
  const entry = `${import.meta.dir}/../kernel/budget-entry.ts`;
232
242
  // Browser target matches the historical edge-profile gate.
233
243
  const result = await Bun.build({
@@ -246,6 +256,46 @@ export async function measureKernelEdgeGzipBytes(): Promise<number> {
246
256
  return Bun.gzipSync(new Uint8Array(raw)).byteLength;
247
257
  }
248
258
 
259
+ /** Subprocess edge gzip — bun test cannot always resolve this graph in-process. */
260
+ async function gzipKernelEdgeSpawn(): Promise<number> {
261
+ const entry = resolve(ROOT, "src/kernel/budget-entry.ts");
262
+ const probe = `
263
+ const result = await Bun.build({
264
+ entrypoints: ${JSON.stringify([entry])},
265
+ minify: true,
266
+ target: "browser",
267
+ format: "esm",
268
+ external: ${JSON.stringify([...BUILD_EXTERNALS])},
269
+ });
270
+ if (!result.success) {
271
+ console.error(result.logs.map(String).join("\\n"));
272
+ process.exit(1);
273
+ }
274
+ const artifact = result.outputs[0];
275
+ if (!artifact) { console.error("no output"); process.exit(1); }
276
+ const raw = await artifact.arrayBuffer();
277
+ process.stdout.write(String(Bun.gzipSync(new Uint8Array(raw)).byteLength));
278
+ `;
279
+ const proc = Bun.spawn(["bun", "-e", probe], {
280
+ cwd: ROOT,
281
+ stdout: "pipe",
282
+ stderr: "pipe",
283
+ });
284
+ const [stdout, stderr, exitCode] = await Promise.all([
285
+ new Response(proc.stdout).text(),
286
+ new Response(proc.stderr).text(),
287
+ proc.exited,
288
+ ]);
289
+ if (exitCode !== 0) {
290
+ throw new Error(`kernel edge build failed:\n${stderr || stdout}`);
291
+ }
292
+ const n = Number(stdout.trim());
293
+ if (!Number.isFinite(n) || n <= 0) {
294
+ throw new Error(`kernel edge measure produced ${stdout}`);
295
+ }
296
+ return n;
297
+ }
298
+
249
299
  /**
250
300
  * Gzip size of the client runtime bundle.
251
301
  */
@@ -418,6 +468,10 @@ export async function measureColdStartMedianMs(): Promise<number> {
418
468
  */
419
469
  export function measureRoutingP99Ms(): number {
420
470
  const N = 200;
471
+ const { createRouter } = lazyRequire<typeof import("../kernel/router.ts")>(
472
+ `${import.meta.dir}/../kernel`,
473
+ "router",
474
+ );
421
475
  const router = createRouter<number>("default");
422
476
  for (let i = 0; i < N; i++) {
423
477
  router.add("GET", `/r${i}/:id/leaf`, i);
@@ -1,35 +0,0 @@
1
- /**
2
- * Evidence: createClient has no live-signal subscription today.
3
- *
4
- * `delivery: "live"` is consumed server-side via `bus.live()` (and
5
- * `on(signal, flow)` for flow subscribers). `okengine/client` exposes only
6
- * HTTP REST / RPC — no SSE, WebSocket, or `api.live(…)`.
7
- *
8
- * Design for `client.live(signalName)` is tracked with the Signal docs page;
9
- * do not treat this file as a stub to fill — ship the real surface when ready.
10
- */
11
-
12
- import { describe, expect, test } from "bun:test";
13
-
14
- import * as client from "./index.ts";
15
- import { createClient } from "./create.ts";
16
-
17
- describe("createClient — live signal gap", () => {
18
- test("public client module exports no live / subscribe helpers", () => {
19
- const keys = Object.keys(client).sort();
20
- expect(keys).not.toContain("live");
21
- expect(keys).not.toContain("subscribe");
22
- expect(keys).toContain("createClient");
23
- expect(keys).toContain("createTransport");
24
- });
25
-
26
- test("createClient builds an HTTP call proxy only (no live method on the root)", async () => {
27
- const api = createClient("http://app.test", {
28
- fetch: async () => Response.json({ data: null, error: { code: "NotFound", data: {} } }),
29
- });
30
- // Root is a callable proxy for unit.flow — live is not a first-class API.
31
- expect(typeof api).toBe("function");
32
- expect(Object.prototype.hasOwnProperty.call(api, "live")).toBe(false);
33
- expect("live" in (api as object)).toBe(false);
34
- });
35
- });