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.
- package/manifest.v1.schema.json +21 -2
- package/package.json +1 -1
- package/site/content/docs/elements/clock.mdx +7 -0
- package/site/content/docs/elements/flow.mdx +14 -12
- package/site/content/docs/elements/gate.mdx +58 -8
- package/site/content/docs/elements/signal.mdx +46 -17
- package/site/content/docs/elements/store.mdx +23 -17
- package/site/content/docs/elements/vault.mdx +23 -0
- package/site/content/docs/get-started/project-structure.mdx +4 -4
- package/site/content/docs/reference/cli.md +1 -1
- package/site/content/docs/reference/client.mdx +72 -17
- package/site/content/docs/reference/configuration.mdx +7 -4
- package/site/content/docs/reference/errors.mdx +24 -17
- package/site/content/docs/reference/fx.mdx +15 -9
- package/src/auth/api-key-sql.ts +11 -4
- package/src/auth/api-keys.ts +3 -0
- package/src/auth/config.ts +19 -0
- package/src/auth/index.ts +37 -0
- package/src/auth/plugin.ts +6 -1
- package/src/auth/sessions.ts +18 -0
- package/src/auth/tables.ts +32 -0
- package/src/auth/tenant-config.ts +74 -0
- package/src/auth/tenant-tables.ts +11 -0
- package/src/auth/tenants.test.ts +63 -0
- package/src/auth/tenants.ts +360 -0
- package/src/cli/build.ts +2 -2
- package/src/client/budget.test.ts +1 -1
- package/src/client/create.ts +75 -5
- package/src/client/index.ts +13 -0
- package/src/client/live.test.ts +422 -0
- package/src/client/live.ts +389 -0
- package/src/client/notes-contract.test.ts +41 -0
- package/src/client/types.ts +85 -6
- package/src/client-react/index.ts +85 -1
- package/src/client-react/use-live.test.ts +129 -0
- package/src/compiler/effects-infer.ts +22 -2
- package/src/compiler/extract.test.ts +143 -11
- package/src/compiler/extract.ts +210 -30
- package/src/compiler/fixtures/skyport/src/flows/bookings/index.ts +1 -2
- package/src/compiler/fixtures/skyport.expected.json +2 -3
- package/src/compiler/response.ts +41 -11
- package/src/console/server/app.ts +44 -11
- package/src/console/server/console.test.ts +6 -8
- package/src/console/server/gates.ts +2 -9
- package/src/console/server/store.test.ts +17 -0
- package/src/console/server/store.ts +43 -1
- package/src/console/ui-next/dist/assets/{access-page-CAGHrA9H.js → access-page-CXVWWMmD.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-B-OUtiAu.js → flows-page-XDpAJO8f.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-CGoZkILK.js → index-BBj2QJCu.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-BDyXalNR.js → observability-page-BFaay44m.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-Xh8Kn3rx.js → store-page-CS5-aETQ.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-DkOXA12R.js → tree-expand-toggle-BtyhmWb4.js} +2 -1
- package/src/console/ui-next/dist/assets/{units-page-Dpk40kOQ.js → units-page-Ca2Z-E52.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-B1dB9Ft0.js → vault-page-BmOeAFwg.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/src/features/flows/fixture.ts +0 -1
- package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +5 -1
- package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +15 -4
- package/src/console/ui-next/ui-next-seed-manifest-surface.ts +2 -9
- package/src/console/ui-next/ui-next-seed-manifest.ts +0 -1
- package/src/drivers/index.ts +2 -0
- package/src/drivers/journal-postgres.ts +12 -3
- package/src/drivers/pg-rls.ts +26 -2
- package/src/drivers/pg-vault-rls.ts +68 -0
- package/src/drivers/signal-engine.ts +69 -15
- package/src/drivers/signal-live-iter.ts +65 -0
- package/src/drivers/signal-nats.ts +2 -1
- package/src/drivers/signal-postgres.ts +95 -12
- package/src/drivers/signal-redis.ts +2 -1
- package/src/drivers/signal-retention.ts +64 -0
- package/src/drivers/signal-types.ts +35 -5
- package/src/elements/clock/declare.ts +64 -2
- package/src/elements/clock/reconcile.ts +98 -25
- package/src/elements/clock/runtime.ts +13 -2
- package/src/elements/clock.test.ts +28 -0
- package/src/elements/clock.ts +9 -1
- package/src/elements/gate/permissions.ts +12 -0
- package/src/elements/gate.ts +6 -1
- package/src/elements/signal/declare.ts +44 -10
- package/src/elements/signal/delivery-modes.test.ts +90 -4
- package/src/elements/signal/order-lifecycle.test.ts +20 -4
- package/src/elements/signal/runtime.ts +42 -0
- package/src/elements/signal.test.ts +21 -8
- package/src/elements/signal.ts +1 -1
- package/src/elements/store/declare.ts +7 -0
- package/src/elements/store/rls-identity.test.ts +29 -0
- package/src/elements/store/rls-identity.ts +3 -0
- package/src/elements/store/schema-decl.ts +63 -3
- package/src/elements/store/schema-tenant.ts +41 -0
- package/src/elements/store/sql-rls-isolation.test.ts +39 -0
- package/src/elements/store.ts +2 -0
- package/src/elements/vault/builtin-adapter.ts +15 -2
- package/src/elements/vault/declare.ts +8 -0
- package/src/elements/vault/runtime.ts +7 -0
- package/src/elements/vault/sql-rls-isolation.test.ts +145 -0
- package/src/elements/vault/storage.ts +9 -0
- package/src/elements/vault/test-helpers.ts +2 -1
- package/src/i18n/catalogs/ar.ts +19 -0
- package/src/i18n/catalogs/en.ts +19 -0
- package/src/index.ts +1 -0
- package/src/kernel/adopt-routes.ts +56 -8
- package/src/kernel/app-tenant.ts +122 -0
- package/src/kernel/app.ts +183 -56
- package/src/kernel/auth-resolve.ts +3 -0
- package/src/kernel/boot-bind/clock.ts +9 -3
- package/src/kernel/boot.ts +2 -0
- package/src/kernel/budget.test.ts +1 -1
- package/src/kernel/clock-durable.ts +8 -0
- package/src/kernel/clock-per-tenant-name.ts +5 -0
- package/src/kernel/clock-reconcile.ts +8 -0
- package/src/kernel/errors-live-resume.ts +15 -0
- package/src/kernel/errors-tenant.ts +29 -0
- package/src/kernel/errors.registry.test.ts +31 -3
- package/src/kernel/errors.ts +43 -3
- package/src/kernel/flow.ts +26 -5
- package/src/kernel/fx-auth-keys.ts +6 -1
- package/src/kernel/fx-auth-tenants.test.ts +87 -0
- package/src/kernel/fx-auth-tenants.ts +286 -0
- package/src/kernel/fx-live-stream.ts +149 -0
- package/src/kernel/fx-live.test.ts +157 -0
- package/src/kernel/fx-runtime.ts +15 -0
- package/src/kernel/fx-tenant-store.ts +213 -0
- package/src/kernel/fx.test.ts +91 -0
- package/src/kernel/fx.ts +173 -13
- package/src/kernel/hooks.ts +2 -2
- package/src/kernel/http-resource.ts +9 -18
- package/src/kernel/index.ts +2 -0
- package/src/kernel/journal.ts +12 -0
- package/src/kernel/live-http.test.ts +78 -0
- package/src/kernel/live-http.ts +114 -0
- package/src/kernel/live-resume.test.ts +125 -0
- package/src/kernel/on.ts +51 -0
- package/src/kernel/pipeline-tenant.ts +49 -0
- package/src/kernel/pipeline.test.ts +1 -1
- package/src/kernel/pipeline.ts +37 -2
- package/src/kernel/resource-mount.test.ts +10 -27
- package/src/kernel/tenant-resolve.test.ts +101 -0
- package/src/kernel/tenant-resolve.ts +124 -0
- package/src/kernel/tenant-roles.test.ts +87 -0
- package/src/kernel/triggers.ts +59 -21
- package/src/manifest/diff.test.ts +11 -2
- package/src/manifest/diff.ts +53 -11
- package/src/manifest/fixtures/skyport.excerpt.json +1 -1
- package/src/manifest/fixtures/skyport.manifest.json +0 -1
- package/src/manifest/types.ts +52 -6
- package/src/release/build-lib.ts +13 -1
- package/src/release/limits.ts +2 -2
- package/src/release/measure.ts +55 -1
- 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
|
+
});
|
package/src/kernel/triggers.ts
CHANGED
|
@@ -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
|
-
/**
|
|
40
|
-
|
|
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
|
-
*
|
|
52
|
+
* Expose a `delivery: "live"` signal as SSE on this GET.
|
|
53
|
+
*
|
|
54
|
+
* @param signal - Live signal handle
|
|
51
55
|
*/
|
|
52
|
-
live():
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
gate: createGateAttach((next) => createHttpTrigger(method, path, next,
|
|
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],
|
|
163
|
+
return createHttpTrigger(method, path, [...gates, GATE_PUBLIC_NAME], liveSignal);
|
|
130
164
|
},
|
|
131
|
-
live() {
|
|
132
|
-
return createHttpTrigger(method, path, gates,
|
|
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()
|
|
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
|
-
*
|
|
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 =
|
|
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
|
|
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 = {
|
package/src/manifest/diff.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
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": { "
|
|
20
|
+
"bookings.mine": { "cacheKeys": "computed:sql:bookings/userId" },
|
|
21
21
|
"payments.chargeBooking": {
|
|
22
22
|
"durable": true,
|
|
23
23
|
"steps": ["create-intent", "confirm"],
|
package/src/manifest/types.ts
CHANGED
|
@@ -53,7 +53,13 @@ export type RunsResourceRef = "runs";
|
|
|
53
53
|
export type AuthApiKeysResourceRef = "auth:api-keys";
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
-
*
|
|
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<
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
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. */
|
package/src/release/build-lib.ts
CHANGED
|
@@ -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/
|
|
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" },
|
package/src/release/limits.ts
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
/** Kernel (edge profile) — gzipped minified bundle. */
|
|
8
|
-
export const KERNEL_EDGE_BUDGET_BYTES =
|
|
8
|
+
export const KERNEL_EDGE_BUDGET_BYTES = 16 * 1024;
|
|
9
9
|
|
|
10
10
|
/** Client runtime — gzipped minified bundle. */
|
|
11
|
-
export const CLIENT_BUDGET_BYTES =
|
|
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;
|
package/src/release/measure.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
});
|