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,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.live().isLive).toBe(true);
72
+ expect(trigger.liveSignal).toBeUndefined();
73
73
  });
74
74
  });
75
75
 
@@ -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) bag.auth.scopes.add(s);
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(...).live()` — same chain as `http.get`.
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 / live", () => {
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?.isLive).toBe(false);
39
- expect(httpOf("/notes", "POST")?.isLive).toBe(false);
38
+ expect(list?.liveSignal).toBeUndefined();
39
+ expect(httpOf("/notes", "POST")?.liveSignal).toBeUndefined();
40
40
  });
41
41
 
42
- test(".gate(member).live() stamps every verb; live is GET only", () => {
42
+ test(".gate(member) stamps every verb", () => {
43
43
  resetBindings();
44
44
  resetFlowSeq();
45
- on(http.resource("/notes", bag()).gate(member).live());
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().live() stamps the sentinel on every verb", () => {
61
+ test(".public() stamps the sentinel on every verb", () => {
67
62
  resetBindings();
68
63
  resetFlowSeq();
69
- on(http.resource("/notes", bag()).public().live());
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
+ }