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,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP Last-Event-ID resume and 410 LiveResumeGap before SSE.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
6
|
+
import { signal, resetSignals } from "../elements/signal/declare.ts";
|
|
7
|
+
import { oke } from "./app.ts";
|
|
8
|
+
import { flow, resetFlowSeq } from "./flow.ts";
|
|
9
|
+
import { on, resetBindings } from "./on.ts";
|
|
10
|
+
import { stampFlowName } from "./stamp-http.ts";
|
|
11
|
+
import { http } from "./triggers.ts";
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
resetBindings();
|
|
15
|
+
resetFlowSeq();
|
|
16
|
+
resetSignals();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("live Last-Event-ID resume", () => {
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
resetBindings();
|
|
22
|
+
resetFlowSeq();
|
|
23
|
+
resetSignals();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("skips through Last-Event-ID and 410s a pruned cursor", async () => {
|
|
27
|
+
const orderStatus = signal("order-status", { delivery: "live", optional: true });
|
|
28
|
+
const feed = on(http.get("/feed").public().live(orderStatus));
|
|
29
|
+
stampFlowName(feed, "orders.feed");
|
|
30
|
+
on(
|
|
31
|
+
http.post("/emit").public(),
|
|
32
|
+
flow("orders.emit", {
|
|
33
|
+
do: async (_input, fx) => {
|
|
34
|
+
await fx.emit(orderStatus, { status: "placed" });
|
|
35
|
+
await fx.emit(orderStatus, { status: "shipped" });
|
|
36
|
+
},
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const app = oke({ name: "live-resume" });
|
|
41
|
+
const emitted = await app.fetch(new Request("http://localhost/emit", { method: "POST" }));
|
|
42
|
+
expect(emitted.status).toBe(204);
|
|
43
|
+
|
|
44
|
+
const firstCtrl = new AbortController();
|
|
45
|
+
const first = await app.fetch(
|
|
46
|
+
new Request("http://localhost/feed", { signal: firstCtrl.signal }),
|
|
47
|
+
);
|
|
48
|
+
expect(first.status).toBe(200);
|
|
49
|
+
expect(first.headers.get("content-type")).toMatch(/text\/event-stream/);
|
|
50
|
+
const firstFrames = await readSseFrames(first, 2);
|
|
51
|
+
firstCtrl.abort();
|
|
52
|
+
expect(firstFrames.map((f) => f.data)).toEqual([{ status: "placed" }, { status: "shipped" }]);
|
|
53
|
+
const cursor = firstFrames[0]!.id;
|
|
54
|
+
expect(cursor).toBeTruthy();
|
|
55
|
+
|
|
56
|
+
const resumeCtrl = new AbortController();
|
|
57
|
+
const resume = await app.fetch(
|
|
58
|
+
new Request("http://localhost/feed", {
|
|
59
|
+
signal: resumeCtrl.signal,
|
|
60
|
+
headers: { "last-event-id": cursor! },
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
expect(resume.status).toBe(200);
|
|
64
|
+
const rest = await readSseFrames(resume, 1);
|
|
65
|
+
resumeCtrl.abort();
|
|
66
|
+
expect(rest.map((f) => f.data)).toEqual([{ status: "shipped" }]);
|
|
67
|
+
|
|
68
|
+
const gap = await app.fetch(
|
|
69
|
+
new Request("http://localhost/feed", {
|
|
70
|
+
headers: { "last-event-id": "never-existed" },
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
expect(gap.status).toBe(410);
|
|
74
|
+
expect(gap.headers.get("content-type")).toMatch(/application\/json/);
|
|
75
|
+
expect(await gap.json()).toMatchObject({
|
|
76
|
+
data: null,
|
|
77
|
+
error: {
|
|
78
|
+
code: "LiveResumeGap",
|
|
79
|
+
data: { signal: "order-status", afterId: "never-existed" },
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
async function readSseFrames(
|
|
86
|
+
res: Response,
|
|
87
|
+
n: number,
|
|
88
|
+
): Promise<Array<{ id?: string; data: unknown }>> {
|
|
89
|
+
const reader = res.body?.getReader();
|
|
90
|
+
if (!reader) throw new Error("no body");
|
|
91
|
+
const dec = new TextDecoder();
|
|
92
|
+
let buf = "";
|
|
93
|
+
const out: Array<{ id?: string; data: unknown }> = [];
|
|
94
|
+
try {
|
|
95
|
+
while (out.length < n) {
|
|
96
|
+
const { done, value } = await reader.read();
|
|
97
|
+
if (done) break;
|
|
98
|
+
buf += dec.decode(value, { stream: true });
|
|
99
|
+
let sep = buf.indexOf("\n\n");
|
|
100
|
+
while (sep >= 0 && out.length < n) {
|
|
101
|
+
const raw = buf.slice(0, sep);
|
|
102
|
+
buf = buf.slice(sep + 2);
|
|
103
|
+
const frame = parseFrame(raw);
|
|
104
|
+
if (frame) out.push(frame);
|
|
105
|
+
sep = buf.indexOf("\n\n");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} finally {
|
|
109
|
+
reader.releaseLock();
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseFrame(raw: string): { id?: string; data: unknown } | undefined {
|
|
115
|
+
const dataLines: string[] = [];
|
|
116
|
+
let id: string | undefined;
|
|
117
|
+
for (const line of raw.split("\n")) {
|
|
118
|
+
if (line.startsWith("id:")) id = line.slice(3).replace(/^ /, "");
|
|
119
|
+
if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
|
|
120
|
+
}
|
|
121
|
+
if (dataLines.length === 0) return undefined;
|
|
122
|
+
const data = dataLines.join("\n");
|
|
123
|
+
if (data === "[DONE]") return undefined;
|
|
124
|
+
return { ...(id !== undefined ? { id } : {}), data: JSON.parse(data) as unknown };
|
|
125
|
+
}
|
package/src/kernel/on.ts
CHANGED
|
@@ -7,16 +7,28 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { isFlow, type AnyFlowDef, type FlowDef, type FlowErrorMap } from "./flow.ts";
|
|
10
|
+
import { lazyRequire } from "./lazy-require.ts";
|
|
10
11
|
import {
|
|
11
12
|
isResourceMount,
|
|
12
13
|
normalizeTrigger,
|
|
13
14
|
type BoundTriggerOf,
|
|
15
|
+
type HttpMethod,
|
|
16
|
+
type HttpTrigger,
|
|
17
|
+
type LiveHttpTrigger,
|
|
14
18
|
type ResourceFlowBag,
|
|
15
19
|
type ResourceMount,
|
|
16
20
|
type SignalSource,
|
|
17
21
|
type Trigger,
|
|
18
22
|
} from "./triggers.ts";
|
|
19
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Sync-load live HTTP synthesis only when `on(http.*.live(signal))` runs.
|
|
26
|
+
* A static import would pin that graph on every `on` / edge ping bundle.
|
|
27
|
+
*/
|
|
28
|
+
function loadLiveHttp(): typeof import("./live-http.ts") {
|
|
29
|
+
return lazyRequire(import.meta.dir, ["live", "http"].join("-"));
|
|
30
|
+
}
|
|
31
|
+
|
|
20
32
|
/** One registered `on(trigger, flow)` binding. */
|
|
21
33
|
export interface Binding {
|
|
22
34
|
readonly trigger: Trigger;
|
|
@@ -43,6 +55,15 @@ export function on<
|
|
|
43
55
|
trigger: T,
|
|
44
56
|
flowDef: FlowDef<I, O, E, D, Trigger | undefined>,
|
|
45
57
|
): FlowDef<I, O, E, D, BoundTriggerOf<T>>;
|
|
58
|
+
/**
|
|
59
|
+
* Expose a live signal: `on(http.get(path).gate(g).live(signal))`.
|
|
60
|
+
* Synthesizes the stream Flow (name stamped by `.adopt`).
|
|
61
|
+
*
|
|
62
|
+
* @param trigger - HTTP GET with `.live(signal)`
|
|
63
|
+
*/
|
|
64
|
+
export function on<T extends LiveHttpTrigger<HttpMethod, string>>(
|
|
65
|
+
trigger: T,
|
|
66
|
+
): FlowDef<unknown, unknown, FlowErrorMap, {}, T>;
|
|
46
67
|
/**
|
|
47
68
|
* Mount a CRUD resource (`http.resource(path, ops)`): registers the five
|
|
48
69
|
* verb bindings and returns the ops bag (unit keys `list` · `create` ·
|
|
@@ -81,10 +102,40 @@ export function on(
|
|
|
81
102
|
}
|
|
82
103
|
return ops as unknown as ResourceFlowBag;
|
|
83
104
|
}
|
|
105
|
+
const asHttp =
|
|
106
|
+
typeof triggerOrMount === "object" &&
|
|
107
|
+
triggerOrMount !== null &&
|
|
108
|
+
"kind" in triggerOrMount &&
|
|
109
|
+
(triggerOrMount as HttpTrigger).kind === "http"
|
|
110
|
+
? (triggerOrMount as HttpTrigger)
|
|
111
|
+
: undefined;
|
|
112
|
+
const liveSignal = asHttp?.liveSignal;
|
|
113
|
+
const synthesized = liveSignal !== undefined && flowDef === undefined;
|
|
114
|
+
if (synthesized && asHttp && liveSignal !== undefined) {
|
|
115
|
+
if (asHttp.method !== "GET") {
|
|
116
|
+
throw new TypeError("on(http.*.live(signal)): live exposure must be GET");
|
|
117
|
+
}
|
|
118
|
+
flowDef = loadLiveHttp().synthesizeLiveFlow(liveSignal, asHttp.path) as FlowDef<
|
|
119
|
+
any,
|
|
120
|
+
any,
|
|
121
|
+
any,
|
|
122
|
+
any,
|
|
123
|
+
Trigger | undefined
|
|
124
|
+
>;
|
|
125
|
+
}
|
|
84
126
|
if (!isFlow(flowDef)) {
|
|
85
127
|
throw new TypeError("on() expected a flow() definition as the second argument");
|
|
86
128
|
}
|
|
87
129
|
const normalized = normalizeTrigger(triggerOrMount as Trigger | SignalSource);
|
|
130
|
+
if (normalized.kind === "http" && normalized.liveSignal !== undefined) {
|
|
131
|
+
if (normalized.method !== "GET") {
|
|
132
|
+
throw new TypeError("on(http.*.live(signal)): live exposure must be GET");
|
|
133
|
+
}
|
|
134
|
+
(flowDef as { live: string | undefined }).live = normalized.liveSignal.name;
|
|
135
|
+
if (!synthesized) {
|
|
136
|
+
(flowDef as { liveCustomMatch: boolean }).liveCustomMatch = true;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
88
139
|
const list = flowDef.triggers as Trigger[];
|
|
89
140
|
list.push(normalized);
|
|
90
141
|
// Stamp runtime carrier for the first bound trigger (type follows BoundTriggerOf).
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant identity + role-scope union — lazy chunk.
|
|
3
|
+
*
|
|
4
|
+
* A static import from {@link ./pipeline.ts} would pin tenant-resolve and
|
|
5
|
+
* the tenant store (and `isApplicationScope`) on every `oke()` graph,
|
|
6
|
+
* including Store-only apps that never enable `gate.auth.tenant`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { tenantScopesForMember } from "../auth/tenants.ts";
|
|
10
|
+
import type { FlowFailure } from "./errors.ts";
|
|
11
|
+
import type { PipelineDeps } from "./pipeline.ts";
|
|
12
|
+
import type { InvocationContext } from "./hooks.ts";
|
|
13
|
+
import { resolveRequestTenant } from "./tenant-resolve.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve tenant id and conditionally union tenant-role scopes.
|
|
17
|
+
*
|
|
18
|
+
* @param deps - Pipeline deps (`tenant` must be set)
|
|
19
|
+
* @param ctx - Invocation
|
|
20
|
+
*/
|
|
21
|
+
export function applyPipelineTenant(
|
|
22
|
+
deps: PipelineDeps,
|
|
23
|
+
ctx: InvocationContext,
|
|
24
|
+
): FlowFailure | undefined {
|
|
25
|
+
const tenant = deps.tenant;
|
|
26
|
+
if (!tenant) return undefined;
|
|
27
|
+
const auth = deps.principals.auth;
|
|
28
|
+
auth.sessionScopes ??= new Set(auth.scopes);
|
|
29
|
+
const claimTenantId = deps.principals.tenant.id;
|
|
30
|
+
const result = resolveRequestTenant({
|
|
31
|
+
config: tenant.config,
|
|
32
|
+
auth: deps.principals.auth,
|
|
33
|
+
claimTenantId,
|
|
34
|
+
request: ctx.request,
|
|
35
|
+
store: tenant.store,
|
|
36
|
+
});
|
|
37
|
+
if (result.failure) return result.failure;
|
|
38
|
+
deps.principals.tenant.id = result.id;
|
|
39
|
+
const userId = deps.principals.auth.userId;
|
|
40
|
+
if (result.id && userId && tenant.flowTenantScoped && tenant.flowPlane !== "operator") {
|
|
41
|
+
for (const scope of tenantScopesForMember(tenant.store, result.id, userId)) {
|
|
42
|
+
deps.principals.auth.scopes.add(scope);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Short name so the Store-only `oke()` graph does not spell {@link applyPipelineTenant}. */
|
|
49
|
+
export { applyPipelineTenant as run };
|
|
@@ -69,7 +69,7 @@ describe("http trigger — .public()", () => {
|
|
|
69
69
|
test("attaches the public sentinel without calling gate()", () => {
|
|
70
70
|
const trigger = http.get("/health").public();
|
|
71
71
|
expect(trigger.gates.map((g) => (typeof g === "string" ? g : g.name))).toEqual(["public"]);
|
|
72
|
-
expect(trigger.
|
|
72
|
+
expect(trigger.liveSignal).toBeUndefined();
|
|
73
73
|
});
|
|
74
74
|
});
|
|
75
75
|
|
package/src/kernel/pipeline.ts
CHANGED
|
@@ -10,11 +10,14 @@
|
|
|
10
10
|
* Gate denial is a typed error value, never a thrown exception.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import type { ResolvedTenantAuth } from "../auth/tenant-config.ts";
|
|
14
|
+
import type { TenantStore } from "../auth/tenants.ts";
|
|
13
15
|
import type { GateEvaluation, GateRuntime } from "../elements/gate.ts";
|
|
14
16
|
import type { GatePolicyContext } from "../elements/gate/declare.ts";
|
|
15
17
|
import { fail, type FlowFailure } from "./errors.ts";
|
|
16
18
|
import type { Fx, FxAuth, FxOperator } from "./fx.ts";
|
|
17
19
|
import type { HookFn, InvocationContext } from "./hooks.ts";
|
|
20
|
+
import { lazyRequire } from "./lazy-require.ts";
|
|
18
21
|
import type { RunTelemetry } from "./run-telemetry.ts";
|
|
19
22
|
import type { HttpTrigger, Trigger } from "./triggers.ts";
|
|
20
23
|
|
|
@@ -23,12 +26,17 @@ export interface PrincipalBag {
|
|
|
23
26
|
readonly auth: {
|
|
24
27
|
userId: string | null;
|
|
25
28
|
scopes: Set<string>;
|
|
29
|
+
/** JWT / session scopes — never mutated by tenant-role union. */
|
|
30
|
+
sessionScopes?: Set<string>;
|
|
26
31
|
verified?: boolean;
|
|
27
32
|
apiKeyId?: string | null;
|
|
28
33
|
};
|
|
29
34
|
readonly operator: {
|
|
30
35
|
id: string | null;
|
|
31
36
|
};
|
|
37
|
+
readonly tenant: {
|
|
38
|
+
id: string | null;
|
|
39
|
+
};
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
/** Resolved identity from auth middleware / test harness. */
|
|
@@ -40,6 +48,8 @@ export interface ResolvedPrincipal {
|
|
|
40
48
|
readonly verified?: boolean;
|
|
41
49
|
/** Authenticating API key id when Bearer was a key secret. */
|
|
42
50
|
readonly apiKeyId?: string;
|
|
51
|
+
/** Signed `tid` / API-key tenant claim (tier 1). */
|
|
52
|
+
readonly tenantId?: string | null;
|
|
43
53
|
}
|
|
44
54
|
|
|
45
55
|
/** Dependencies for {@link createElementPipelineHooks}. */
|
|
@@ -77,6 +87,20 @@ export interface PipelineDeps {
|
|
|
77
87
|
readonly principals: PrincipalBag;
|
|
78
88
|
/** Telemetry collector for the current run (gates dimension). */
|
|
79
89
|
readonly telemetry: RunTelemetry;
|
|
90
|
+
/**
|
|
91
|
+
* When set, resolve `fx.tenant` after the principal and optionally union
|
|
92
|
+
* tenant-role scopes into the live auth bag.
|
|
93
|
+
*/
|
|
94
|
+
readonly tenant?: PipelineTenantDeps;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Tenant identity + conditional scope union (user-plane tenant-scoped flows). */
|
|
98
|
+
export interface PipelineTenantDeps {
|
|
99
|
+
readonly config: ResolvedTenantAuth;
|
|
100
|
+
readonly store: TenantStore;
|
|
101
|
+
/** Default true when tenancy is on; `flow({ tenantScoped: false })` opts out. */
|
|
102
|
+
readonly flowTenantScoped: boolean;
|
|
103
|
+
readonly flowPlane?: "user" | "operator";
|
|
80
104
|
}
|
|
81
105
|
|
|
82
106
|
/**
|
|
@@ -153,11 +177,14 @@ export function applyPrincipal(bag: PrincipalBag, resolved: ResolvedPrincipal |
|
|
|
153
177
|
if (resolved.userId !== undefined) bag.auth.userId = resolved.userId;
|
|
154
178
|
if (resolved.scopes !== undefined) {
|
|
155
179
|
bag.auth.scopes.clear();
|
|
156
|
-
for (const s of resolved.scopes)
|
|
180
|
+
for (const s of resolved.scopes) {
|
|
181
|
+
bag.auth.scopes.add(s);
|
|
182
|
+
}
|
|
157
183
|
}
|
|
158
184
|
if (resolved.verified !== undefined) bag.auth.verified = resolved.verified;
|
|
159
185
|
bag.auth.apiKeyId = resolved.apiKeyId ?? null;
|
|
160
186
|
}
|
|
187
|
+
if (resolved.tenantId !== undefined) bag.tenant.id = resolved.tenantId;
|
|
161
188
|
}
|
|
162
189
|
|
|
163
190
|
/**
|
|
@@ -199,7 +226,7 @@ export function createElementPipelineHooks(deps: PipelineDeps): {
|
|
|
199
226
|
if (principal.apiKeyId) {
|
|
200
227
|
deps.telemetry.dimensions.api_key = principal.apiKeyId;
|
|
201
228
|
}
|
|
202
|
-
return;
|
|
229
|
+
return applyTenant(deps, ctx);
|
|
203
230
|
} catch {
|
|
204
231
|
// Forge / expiry / revoke → typed Unauthorized (never throw).
|
|
205
232
|
return fail("Unauthorized", {});
|
|
@@ -211,6 +238,7 @@ export function createElementPipelineHooks(deps: PipelineDeps): {
|
|
|
211
238
|
const fromState = ctx.state.principal as ResolvedPrincipal | undefined;
|
|
212
239
|
if (fromState) applyPrincipal(deps.principals, fromState);
|
|
213
240
|
}
|
|
241
|
+
return applyTenant(deps, ctx);
|
|
214
242
|
};
|
|
215
243
|
|
|
216
244
|
const beforeHandle: HookFn = async (ctx, fxOrErr) => {
|
|
@@ -235,6 +263,13 @@ export function createElementPipelineHooks(deps: PipelineDeps): {
|
|
|
235
263
|
return { onAuth, beforeHandle };
|
|
236
264
|
}
|
|
237
265
|
|
|
266
|
+
function applyTenant(deps: PipelineDeps, ctx: InvocationContext): FlowFailure | undefined {
|
|
267
|
+
if (!deps.tenant) return undefined;
|
|
268
|
+
return lazyRequire<{
|
|
269
|
+
run: (d: PipelineDeps, c: InvocationContext) => FlowFailure | undefined;
|
|
270
|
+
}>(import.meta.dir, ["pipeline", "tenant"].join("-")).run(deps, ctx);
|
|
271
|
+
}
|
|
272
|
+
|
|
238
273
|
/**
|
|
239
274
|
* Build a policy context from the live `fx` principals.
|
|
240
275
|
*
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `http.resource(path, ops).gate(...)
|
|
2
|
+
* `http.resource(path, ops).gate(...)` — same chain as `http.get`.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { describe, expect, test } from "bun:test";
|
|
@@ -28,21 +28,21 @@ function httpOf(path: string, method: string): HttpTrigger | undefined {
|
|
|
28
28
|
return hit?.trigger.kind === "http" ? hit.trigger : undefined;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
describe("http.resource — gate
|
|
32
|
-
test("bare mount has no gates and is not live", () => {
|
|
31
|
+
describe("http.resource — gate", () => {
|
|
32
|
+
test("bare mount has no gates and is not a live exposure", () => {
|
|
33
33
|
resetBindings();
|
|
34
34
|
resetFlowSeq();
|
|
35
35
|
on(http.resource("/notes", bag()));
|
|
36
36
|
const list = httpOf("/notes", "GET");
|
|
37
37
|
expect(list?.gates).toEqual([]);
|
|
38
|
-
expect(list?.
|
|
39
|
-
expect(httpOf("/notes", "POST")?.
|
|
38
|
+
expect(list?.liveSignal).toBeUndefined();
|
|
39
|
+
expect(httpOf("/notes", "POST")?.liveSignal).toBeUndefined();
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
-
test(".gate(member)
|
|
42
|
+
test(".gate(member) stamps every verb", () => {
|
|
43
43
|
resetBindings();
|
|
44
44
|
resetFlowSeq();
|
|
45
|
-
on(http.resource("/notes", bag()).gate(member)
|
|
45
|
+
on(http.resource("/notes", bag()).gate(member));
|
|
46
46
|
|
|
47
47
|
const list = httpOf("/notes", "GET");
|
|
48
48
|
const create = httpOf("/notes", "POST");
|
|
@@ -55,35 +55,18 @@ describe("http.resource — gate / live", () => {
|
|
|
55
55
|
expect(get?.gates.map((g) => (typeof g === "string" ? g : g.name))).toEqual(["member"]);
|
|
56
56
|
expect(update?.gates.map((g) => (typeof g === "string" ? g : g.name))).toEqual(["member"]);
|
|
57
57
|
expect(remove?.gates.map((g) => (typeof g === "string" ? g : g.name))).toEqual(["member"]);
|
|
58
|
-
|
|
59
|
-
expect(list?.isLive).toBe(true);
|
|
60
|
-
expect(get?.isLive).toBe(true);
|
|
61
|
-
expect(create?.isLive).toBe(false);
|
|
62
|
-
expect(update?.isLive).toBe(false);
|
|
63
|
-
expect(remove?.isLive).toBe(false);
|
|
58
|
+
expect(list?.liveSignal).toBeUndefined();
|
|
64
59
|
});
|
|
65
60
|
|
|
66
|
-
test(".public()
|
|
61
|
+
test(".public() stamps the sentinel on every verb", () => {
|
|
67
62
|
resetBindings();
|
|
68
63
|
resetFlowSeq();
|
|
69
|
-
on(http.resource("/notes", bag()).public()
|
|
64
|
+
on(http.resource("/notes", bag()).public());
|
|
70
65
|
expect(httpOf("/notes", "GET")?.gates.map((g) => (typeof g === "string" ? g : g.name))).toEqual(
|
|
71
66
|
["public"],
|
|
72
67
|
);
|
|
73
68
|
expect(
|
|
74
69
|
httpOf("/notes", "POST")?.gates.map((g) => (typeof g === "string" ? g : g.name)),
|
|
75
70
|
).toEqual(["public"]);
|
|
76
|
-
expect(httpOf("/notes", "GET")?.isLive).toBe(true);
|
|
77
|
-
expect(httpOf("/notes", "POST")?.isLive).toBe(false);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
test(".live().gate(member) order does not matter", () => {
|
|
81
|
-
resetBindings();
|
|
82
|
-
resetFlowSeq();
|
|
83
|
-
on(http.resource("/notes", bag()).live().gate(member));
|
|
84
|
-
expect(httpOf("/notes", "GET")?.isLive).toBe(true);
|
|
85
|
-
expect(httpOf("/notes", "GET")?.gates.map((g) => (typeof g === "string" ? g : g.name))).toEqual(
|
|
86
|
-
["member"],
|
|
87
|
-
);
|
|
88
71
|
});
|
|
89
72
|
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Three-tier tenant identity resolution.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { createTenant, createTenantStore } from "../auth/tenants.ts";
|
|
7
|
+
import { resolveTenantAuth } from "../auth/tenant-config.ts";
|
|
8
|
+
import { isFlowFailure } from "./hooks.ts";
|
|
9
|
+
import { resolveRequestTenant } from "./tenant-resolve.ts";
|
|
10
|
+
|
|
11
|
+
describe("resolveRequestTenant", () => {
|
|
12
|
+
test("claim trusts tid without a membership query", async () => {
|
|
13
|
+
const store = createTenantStore();
|
|
14
|
+
const result = resolveRequestTenant({
|
|
15
|
+
config: resolveTenantAuth(true),
|
|
16
|
+
auth: { userId: "u1", scopes: new Set() },
|
|
17
|
+
claimTenantId: "not-a-member",
|
|
18
|
+
store,
|
|
19
|
+
});
|
|
20
|
+
expect(result.id).toBe("not-a-member");
|
|
21
|
+
expect(result.failure).toBeUndefined();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("header never trusts a non-member", async () => {
|
|
25
|
+
const store = createTenantStore();
|
|
26
|
+
await createTenant(store, { name: "Acme", createdBy: "owner", id: "acme" });
|
|
27
|
+
const result = resolveRequestTenant({
|
|
28
|
+
config: resolveTenantAuth({ source: "header" }),
|
|
29
|
+
auth: { userId: "u1", scopes: new Set() },
|
|
30
|
+
claimTenantId: null,
|
|
31
|
+
request: new Request("https://app.example/x", { headers: { "x-oke-tenant": "acme" } }),
|
|
32
|
+
store,
|
|
33
|
+
});
|
|
34
|
+
expect(result.id).toBeNull();
|
|
35
|
+
expect(result.failure && isFlowFailure(result.failure)).toBe(true);
|
|
36
|
+
if (result.failure && isFlowFailure(result.failure)) {
|
|
37
|
+
expect(result.failure.error.code).toBe("Forbidden");
|
|
38
|
+
expect(result.failure.error.data).toEqual({
|
|
39
|
+
gate: "auth:tenants",
|
|
40
|
+
reason: "not_member",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("header accepts a real membership", async () => {
|
|
46
|
+
const store = createTenantStore();
|
|
47
|
+
await createTenant(store, { name: "Acme", createdBy: "u1", id: "acme" });
|
|
48
|
+
const result = resolveRequestTenant({
|
|
49
|
+
config: resolveTenantAuth({ source: "header" }),
|
|
50
|
+
auth: { userId: "u1", scopes: new Set() },
|
|
51
|
+
claimTenantId: null,
|
|
52
|
+
request: new Request("https://app.example/x", { headers: { "x-oke-tenant": "acme" } }),
|
|
53
|
+
store,
|
|
54
|
+
});
|
|
55
|
+
expect(result.id).toBe("acme");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("required + authenticated + no tenant is Forbidden tenant_required", () => {
|
|
59
|
+
const store = createTenantStore();
|
|
60
|
+
const result = resolveRequestTenant({
|
|
61
|
+
config: resolveTenantAuth({ required: true }),
|
|
62
|
+
auth: { userId: "u1", scopes: new Set() },
|
|
63
|
+
claimTenantId: null,
|
|
64
|
+
store,
|
|
65
|
+
});
|
|
66
|
+
expect(result.failure && isFlowFailure(result.failure)).toBe(true);
|
|
67
|
+
if (result.failure && isFlowFailure(result.failure)) {
|
|
68
|
+
expect(result.failure.error.data).toEqual({
|
|
69
|
+
gate: "auth:tenants",
|
|
70
|
+
reason: "tenant_required",
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("resolve callback still checks membership unless authoritative", async () => {
|
|
76
|
+
const store = createTenantStore();
|
|
77
|
+
const denied = resolveRequestTenant({
|
|
78
|
+
config: resolveTenantAuth({
|
|
79
|
+
source: "resolve",
|
|
80
|
+
resolve: () => "ghost",
|
|
81
|
+
}),
|
|
82
|
+
auth: { userId: "u1", scopes: new Set() },
|
|
83
|
+
claimTenantId: null,
|
|
84
|
+
store,
|
|
85
|
+
});
|
|
86
|
+
expect(denied.failure && isFlowFailure(denied.failure)).toBe(true);
|
|
87
|
+
|
|
88
|
+
await createTenant(store, { name: "Acme", createdBy: "u1", id: "acme" });
|
|
89
|
+
const trusted = resolveRequestTenant({
|
|
90
|
+
config: resolveTenantAuth({
|
|
91
|
+
source: "resolve",
|
|
92
|
+
authoritative: true,
|
|
93
|
+
resolve: () => "ghost",
|
|
94
|
+
}),
|
|
95
|
+
auth: { userId: "u1", scopes: new Set() },
|
|
96
|
+
claimTenantId: null,
|
|
97
|
+
store,
|
|
98
|
+
});
|
|
99
|
+
expect(trusted.id).toBe("ghost");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request tenant identity resolution (three tiers).
|
|
3
|
+
*
|
|
4
|
+
* Tenant is an identity dimension like `fx.auth` — not a second authz system.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { isMember, type TenantStore } from "../auth/tenants.ts";
|
|
8
|
+
import {
|
|
9
|
+
type ResolvedTenantAuth,
|
|
10
|
+
type TenantAuthOptions,
|
|
11
|
+
type TenantSource,
|
|
12
|
+
} from "../auth/tenant-config.ts";
|
|
13
|
+
import { fail, type FlowFailure } from "./errors.ts";
|
|
14
|
+
import type { FxAuthIdentity } from "./fx-auth-keys.ts";
|
|
15
|
+
|
|
16
|
+
export type { ResolvedTenantAuth, TenantAuthOptions, TenantSource };
|
|
17
|
+
|
|
18
|
+
/** Result of {@link resolveRequestTenant}. */
|
|
19
|
+
export interface RequestTenantResult {
|
|
20
|
+
readonly id: string | null;
|
|
21
|
+
readonly failure?: FlowFailure;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Options for {@link resolveRequestTenant}. */
|
|
25
|
+
export interface ResolveRequestTenantOptions {
|
|
26
|
+
readonly config: ResolvedTenantAuth;
|
|
27
|
+
readonly auth: FxAuthIdentity;
|
|
28
|
+
readonly claimTenantId: string | null;
|
|
29
|
+
readonly request?: Request;
|
|
30
|
+
readonly store: TenantStore;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve tenant id for this request.
|
|
35
|
+
*
|
|
36
|
+
* Tier 1 (`claim`): signed `tid` / API-key `tenantId` — no membership query.
|
|
37
|
+
* Tier 2 (`header` / `subdomain`): client-supplied — membership required.
|
|
38
|
+
* Tier 3 (`resolve`): callback; membership required unless `authoritative`.
|
|
39
|
+
*
|
|
40
|
+
* @param options - Config + live identity + request
|
|
41
|
+
*/
|
|
42
|
+
export function resolveRequestTenant(options: ResolveRequestTenantOptions): RequestTenantResult {
|
|
43
|
+
const { config, auth, claimTenantId, request, store } = options;
|
|
44
|
+
const userId = auth.userId;
|
|
45
|
+
const supplied = sourceValue(config, claimTenantId, request, auth);
|
|
46
|
+
|
|
47
|
+
if (supplied === null || supplied === "") {
|
|
48
|
+
if (config.required && userId) {
|
|
49
|
+
return {
|
|
50
|
+
id: null,
|
|
51
|
+
failure: fail("Forbidden", { gate: "auth:tenants", reason: "tenant_required" }),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return { id: null };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (config.source === "claim") {
|
|
58
|
+
return { id: supplied };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (config.source === "resolve" && config.authoritative) {
|
|
62
|
+
return { id: supplied };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!userId) {
|
|
66
|
+
return {
|
|
67
|
+
id: null,
|
|
68
|
+
failure: fail("Unauthorized", {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (!isMember(store, supplied, userId)) {
|
|
72
|
+
return {
|
|
73
|
+
id: null,
|
|
74
|
+
failure: fail("Forbidden", { gate: "auth:tenants", reason: "not_member" }),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return { id: supplied };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function sourceValue(
|
|
81
|
+
config: ResolvedTenantAuth,
|
|
82
|
+
claimTenantId: string | null,
|
|
83
|
+
request: Request | undefined,
|
|
84
|
+
auth: FxAuthIdentity,
|
|
85
|
+
): string | null {
|
|
86
|
+
if (config.source === "claim") {
|
|
87
|
+
return claimTenantId;
|
|
88
|
+
}
|
|
89
|
+
// Internal / cron / fx.call have no HTTP request — keep the stamped claim.
|
|
90
|
+
if (!request && (config.source === "header" || config.source === "subdomain")) {
|
|
91
|
+
return claimTenantId;
|
|
92
|
+
}
|
|
93
|
+
if (config.source === "header") {
|
|
94
|
+
const raw = request?.headers.get(config.header)?.trim();
|
|
95
|
+
return raw && raw.length > 0 ? raw : null;
|
|
96
|
+
}
|
|
97
|
+
if (config.source === "subdomain") {
|
|
98
|
+
return subdomainLabel(request);
|
|
99
|
+
}
|
|
100
|
+
if (config.source === "resolve" && config.resolve) {
|
|
101
|
+
const out = config.resolve({
|
|
102
|
+
auth: { userId: auth.userId, tenantId: claimTenantId },
|
|
103
|
+
request,
|
|
104
|
+
claimTenantId,
|
|
105
|
+
});
|
|
106
|
+
return out ?? null;
|
|
107
|
+
}
|
|
108
|
+
return claimTenantId;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* First Host label as a tenant slug/id (`acme.example.com` → `acme`).
|
|
113
|
+
*
|
|
114
|
+
* @param request - Incoming request
|
|
115
|
+
*/
|
|
116
|
+
export function subdomainLabel(request: Request | undefined): string | null {
|
|
117
|
+
const host = request?.headers.get("host")?.split(":")[0]?.trim().toLowerCase();
|
|
118
|
+
if (!host) return null;
|
|
119
|
+
const parts = host.split(".");
|
|
120
|
+
if (parts.length < 3) return null;
|
|
121
|
+
const label = parts[0];
|
|
122
|
+
if (!label || label === "www") return null;
|
|
123
|
+
return label;
|
|
124
|
+
}
|