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,149 @@
1
+ /**
2
+ * `fx.live` SSE + Last-Event-ID resume — lazy so Store-only `oke()` graphs
3
+ * do not pin `checkLiveResume` / 410 encoding.
4
+ *
5
+ * Do not import `fx.ts` (type or value): that cycle pulls `createFx` into
6
+ * this chunk the same way a static `fx-auth-keys` import would.
7
+ */
8
+
9
+ import { currentAbortSignal, linkAbort } from "./abort-scope.ts";
10
+ import { isDryRun } from "./dry-run.ts";
11
+ import type { EffectKind } from "./effects.ts";
12
+
13
+ const jsonResultBrand = Symbol.for("oke.json");
14
+ const sseFrameBrand = Symbol.for("oke.sse.frame");
15
+
16
+ function sseFrame(data: unknown, id?: string): unknown {
17
+ return id !== undefined ? { [sseFrameBrand]: true, data, id } : { [sseFrameBrand]: true, data };
18
+ }
19
+
20
+ /** Minimal bus surface used by {@link createLiveStream}. */
21
+ export interface LiveStreamRuntime {
22
+ live(
23
+ name: string,
24
+ opts?: { readonly afterId?: string },
25
+ ): AsyncIterable<{ readonly id: string; readonly payload: unknown }>;
26
+ checkLiveResume(name: string, afterId: string): Promise<void>;
27
+ }
28
+
29
+ /** Capability gate used by {@link createLiveStream}. */
30
+ export type LiveStreamGate = <T>(
31
+ kind: EffectKind,
32
+ resource: string,
33
+ body: () => T | Promise<T>,
34
+ ) => Promise<T>;
35
+
36
+ /** Options for {@link createLiveStream}. */
37
+ export interface CreateLiveStreamOptions {
38
+ readonly name: string;
39
+ readonly afterId?: string;
40
+ readonly match?: (payload: unknown) => boolean;
41
+ readonly gated: LiveStreamGate;
42
+ readonly signalRuntime: LiveStreamRuntime | undefined;
43
+ }
44
+
45
+ /**
46
+ * SSE carrier from {@link createLiveStream}.
47
+ *
48
+ * Runtime brand is `Symbol.for("oke.json")` — same as `fx.json.stream`.
49
+ * Callers in `fx.ts` assert this onto `JsonStreamResult`.
50
+ */
51
+ export interface LiveStreamResult {
52
+ readonly kind: "stream";
53
+ readonly status: 200;
54
+ readonly chunks: AsyncIterable<unknown>;
55
+ ready?: () => Promise<void>;
56
+ finalize?: () => Promise<void>;
57
+ }
58
+
59
+ /**
60
+ * Build the SSE carrier for `fx.live`.
61
+ *
62
+ * @param options - Signal name, cursor, gate, runtime
63
+ */
64
+ export function createLiveStream(options: CreateLiveStreamOptions): LiveStreamResult {
65
+ const { name, afterId, match, gated, signalRuntime } = options;
66
+ const bind = async (): Promise<boolean> => {
67
+ await gated("read", `signal:${name}`, async () => undefined);
68
+ if (isDryRun()) return false;
69
+ if (!signalRuntime) throw new Error("fx.live requires a bound signal runtime");
70
+ return true;
71
+ };
72
+ const chunks = (async function* () {
73
+ if (!(await bind())) return;
74
+ const ambient = currentAbortSignal();
75
+ const local = new AbortController();
76
+ const unlink = linkAbort(ambient, local);
77
+ const iter = signalRuntime!.live(name, { afterId })[Symbol.asyncIterator]();
78
+ const onAbort = (): void => {
79
+ void iter.return?.();
80
+ };
81
+ local.signal.addEventListener("abort", onAbort, { once: true });
82
+ try {
83
+ for (;;) {
84
+ if (local.signal.aborted) break;
85
+ const step = await iter.next();
86
+ if (step.done || local.signal.aborted) break;
87
+ if (match && !match(step.value.payload)) continue;
88
+ yield sseFrame(step.value.payload, step.value.id);
89
+ }
90
+ } finally {
91
+ local.signal.removeEventListener("abort", onAbort);
92
+ unlink();
93
+ await iter.return?.();
94
+ if (!local.signal.aborted) local.abort();
95
+ }
96
+ })();
97
+ const carrier: LiveStreamResult = {
98
+ kind: "stream",
99
+ status: 200,
100
+ chunks,
101
+ ready: async () => {
102
+ if (afterId && (await bind())) await signalRuntime!.checkLiveResume(name, afterId);
103
+ },
104
+ };
105
+ // Brand at runtime (`isJsonStreamResult`); not on {@link LiveStreamResult}
106
+ // so this file does not import `fx.ts` unique-symbol types (cycle / TS2353).
107
+ return Object.assign(carrier, { [jsonResultBrand]: true });
108
+ }
109
+
110
+ /**
111
+ * Map OKE1014 to HTTP 410 `{ error: { code: "LiveResumeGap" } }`.
112
+ *
113
+ * @param err - Thrown value
114
+ */
115
+ export function encodeGap(err: unknown): Response | undefined {
116
+ const o = err as { code?: unknown; params?: { signal?: string; afterId?: string } };
117
+ if (o?.code !== 1014) return;
118
+ return Response.json(
119
+ {
120
+ data: null,
121
+ error: {
122
+ code: "LiveResumeGap",
123
+ data: { signal: o.params?.signal ?? "", afterId: o.params?.afterId ?? "" },
124
+ },
125
+ },
126
+ { status: 410 },
127
+ );
128
+ }
129
+
130
+ /**
131
+ * Await stream `ready` before the 200 SSE body.
132
+ *
133
+ * @param stream - Live / json.stream carrier
134
+ * @returns 410 response when the cursor is missing; otherwise `undefined`
135
+ */
136
+ export async function awaitLiveReady(stream: {
137
+ readonly ready?: () => Promise<void>;
138
+ readonly finalize?: () => Promise<void>;
139
+ }): Promise<Response | undefined> {
140
+ try {
141
+ await stream.ready?.();
142
+ } catch (err) {
143
+ await stream.finalize?.();
144
+ const gap = encodeGap(err);
145
+ if (gap) return gap;
146
+ throw err;
147
+ }
148
+ return undefined;
149
+ }
@@ -0,0 +1,157 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { encodeExecuteResult } from "../compiler/response.ts";
3
+ import { memorySignalDriver } from "../drivers/index.ts";
4
+ import { createSignalRuntime, signal, type SignalDecl } from "../elements/signal.ts";
5
+ import { withAbortSignal } from "./abort-scope.ts";
6
+ import { OkeError } from "./errors.ts";
7
+ import { createFx, createFxContext, isSseFrame, signalReadRef } from "./fx.ts";
8
+
9
+ describe("fx.live", () => {
10
+ test("undeclared signal read throws OKE1001", async () => {
11
+ const orderStatus = signal("order-status", { delivery: "live", optional: true });
12
+ const runtime = openRuntime(orderStatus);
13
+ const fx = createFx({
14
+ flow: "orders.events",
15
+ effects: { reads: [signalReadRef("order-status")] },
16
+ signalRuntime: runtime,
17
+ });
18
+ const other = signal("other-status", { delivery: "live", optional: true });
19
+ let err: unknown;
20
+ try {
21
+ const stream = fx.live(other);
22
+ await stream.chunks[Symbol.asyncIterator]().next();
23
+ } catch (e) {
24
+ err = e;
25
+ }
26
+ expect(err).toBeInstanceOf(OkeError);
27
+ const oke = err as OkeError;
28
+ expect(oke.code).toBe(1001);
29
+ expect(oke.causeText).toBe(
30
+ 'Flow "orders.events" reads "signal:other-status" without declaring it.',
31
+ );
32
+ await runtime.close();
33
+ });
34
+
35
+ test("throws when no signal runtime is bound", async () => {
36
+ const fx = createFx({
37
+ flow: "orders.events",
38
+ effects: { reads: [signalReadRef("order-status")] },
39
+ });
40
+ const stream = fx.live("order-status");
41
+ await expect(stream.chunks[Symbol.asyncIterator]().next()).rejects.toThrow(
42
+ "fx.live requires a bound signal runtime",
43
+ );
44
+ });
45
+
46
+ test("ALS abort unsubscribes the live handler", async () => {
47
+ const orderStatus = signal("order-status", { delivery: "live", optional: true });
48
+ const runtime = openRuntime(orderStatus);
49
+ const bus = await runtime.start();
50
+ const { fx } = createFxContext({
51
+ flow: "orders.events",
52
+ effects: { reads: [signalReadRef("order-status")] },
53
+ signalRuntime: runtime,
54
+ });
55
+
56
+ const ctrl = new AbortController();
57
+ await withAbortSignal(ctrl.signal, async () => {
58
+ const stream = fx.live(orderStatus);
59
+ const it = stream.chunks[Symbol.asyncIterator]();
60
+ const pending = it.next();
61
+ const started = Date.now();
62
+ while ((await bus.inspect("order-status"))[0]?.connections !== 1) {
63
+ if (Date.now() - started > 500) throw new Error("live handler did not attach");
64
+ await new Promise((r) => setTimeout(r, 5));
65
+ }
66
+ ctrl.abort();
67
+ const step = await pending;
68
+ expect(step.done).toBe(true);
69
+ });
70
+
71
+ const after = await bus.inspect("order-status");
72
+ expect(after[0]?.connections).toBe(0);
73
+ await runtime.close();
74
+ });
75
+
76
+ test("yields branded SSE frames with id + payload", async () => {
77
+ const orderStatus = signal("order-status", { delivery: "live", optional: true });
78
+ const runtime = openRuntime(orderStatus);
79
+ const bus = await runtime.start();
80
+ await runtime.emit("order-status", { orderId: "ord_1", status: "placed" });
81
+ await bus.drain();
82
+
83
+ const { fx, ledger } = createFxContext({
84
+ flow: "orders.events",
85
+ effects: { reads: [signalReadRef("order-status")] },
86
+ signalRuntime: runtime,
87
+ });
88
+ const stream = fx.live(orderStatus);
89
+ const it = stream.chunks[Symbol.asyncIterator]();
90
+ const step = await it.next();
91
+ expect(step.done).toBe(false);
92
+ expect(isSseFrame(step.value)).toBe(true);
93
+ if (isSseFrame(step.value)) {
94
+ expect(step.value.data).toEqual({ orderId: "ord_1", status: "placed" });
95
+ expect(typeof step.value.id).toBe("string");
96
+ }
97
+ expect(ledger.entries.map((e) => `${e.kind}:${e.resource}`)).toEqual([
98
+ "read:signal:order-status",
99
+ ]);
100
+ await it.return?.();
101
+ await runtime.close();
102
+ });
103
+
104
+ test("afterId skips already-delivered events", async () => {
105
+ const orderStatus = signal("order-status", { delivery: "live", optional: true });
106
+ const runtime = openRuntime(orderStatus);
107
+ const bus = await runtime.start();
108
+ await runtime.emit("order-status", { orderId: "ord_1", status: "placed" });
109
+ await runtime.emit("order-status", { orderId: "ord_1", status: "shipped" });
110
+ await bus.drain();
111
+
112
+ const first = bus.live("order-status")[Symbol.asyncIterator]();
113
+ const a = await first.next();
114
+ await first.return?.();
115
+ const firstId = a.value?.id as string;
116
+
117
+ const { fx } = createFxContext({
118
+ flow: "orders.events",
119
+ effects: { reads: [signalReadRef("order-status")] },
120
+ signalRuntime: runtime,
121
+ });
122
+ const stream = fx.live(orderStatus, { afterId: firstId });
123
+ const it = stream.chunks[Symbol.asyncIterator]();
124
+ const step = await it.next();
125
+ expect(isSseFrame(step.value) && step.value.data).toEqual({
126
+ orderId: "ord_1",
127
+ status: "shipped",
128
+ });
129
+ await it.return?.();
130
+ await runtime.close();
131
+ });
132
+
133
+ test("ready throws OKE1014 for a missing afterId", async () => {
134
+ const orderStatus = signal("order-status", { delivery: "live", optional: true });
135
+ const runtime = openRuntime(orderStatus);
136
+ const fx = createFx({
137
+ flow: "orders.events",
138
+ effects: { reads: [signalReadRef("order-status")] },
139
+ signalRuntime: runtime,
140
+ });
141
+ const stream = fx.live(orderStatus, { afterId: "missing" });
142
+ await expect(stream.ready?.()).rejects.toMatchObject({ code: 1014 });
143
+ const res = await encodeExecuteResult({ output: stream });
144
+ expect(res.status).toBe(410);
145
+ expect(await res.json()).toMatchObject({
146
+ data: null,
147
+ error: { code: "LiveResumeGap", data: { signal: "order-status", afterId: "missing" } },
148
+ });
149
+ await runtime.close();
150
+ });
151
+ });
152
+
153
+ function openRuntime(decl: SignalDecl) {
154
+ const runtime = createSignalRuntime({ driver: memorySignalDriver });
155
+ runtime.register(decl);
156
+ return runtime;
157
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Execute-time `fx` helpers — lazy chunk off Store-only `oke()` construction.
3
+ *
4
+ * `app.ts` / HTTP encode load this via computed require so `createFx` does
5
+ * not sit on graphs that never run a Flow.
6
+ */
7
+
8
+ export {
9
+ createFxContext,
10
+ freezePrincipal,
11
+ isJsonResult,
12
+ isJsonStreamResult,
13
+ isSseFrame,
14
+ resolveName,
15
+ } from "./fx.ts";
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Tenant KV prefix + vault request-time path — lazy chunk.
3
+ *
4
+ * Inlined wrappers would sit on every `createFx` / Store-only `oke()` graph
5
+ * even when `gate.auth.tenant` is off.
6
+ */
7
+
8
+ import type { KvStoreDecl } from "../elements/store.ts";
9
+ import type { VaultRuntime } from "../elements/vault.ts";
10
+ import type { Manifest } from "../manifest/types.ts";
11
+ import type { SessionCrypto, SessionStore } from "../auth/sessions.ts";
12
+ import type { TenantStore } from "../auth/tenants.ts";
13
+ import { throwOke } from "./errors.ts";
14
+ import type { FxAuthIdentity } from "./fx-auth-keys.ts";
15
+ import { bind, type AttachAuthTenantMethodsOptions } from "./fx-auth-tenants.ts";
16
+
17
+ /**
18
+ * Whether this KV namespace is tenant-prefixed.
19
+ *
20
+ * @param decl - KV store decl
21
+ * @param tenantEnabled - `gate.auth.tenant` is on
22
+ */
23
+ export function kvTenantScoped(decl: KvStoreDecl, tenantEnabled: boolean): boolean {
24
+ if (decl.tenantScoped === false) return false;
25
+ if (decl.tenantScoped === true) return true;
26
+ return tenantEnabled;
27
+ }
28
+
29
+ /**
30
+ * Prefix KV args with `{tenantId}:`. Fails loud when no tenant is resolved.
31
+ *
32
+ * @param tenantId - Live `fx.tenant.id`
33
+ * @param decl - KV store decl
34
+ * @param tenantEnabled - Tenancy switch
35
+ * @param prop - Handle method
36
+ * @param args - Original args
37
+ */
38
+ export function rewriteKvArgs(
39
+ tenantId: string | null,
40
+ decl: KvStoreDecl,
41
+ tenantEnabled: boolean,
42
+ prop: string | symbol,
43
+ args: unknown[],
44
+ ): unknown[] {
45
+ if (!kvTenantScoped(decl, tenantEnabled)) return args;
46
+ if (!tenantId) throwOke("TENANT_REQUIRED");
47
+ const prefix = `${tenantId}:`;
48
+ if (prop === "list") {
49
+ const userPrefix = typeof args[0] === "string" ? args[0] : "";
50
+ return [`${prefix}${userPrefix}`];
51
+ }
52
+ if (typeof args[0] !== "string") return args;
53
+ return [`${prefix}${args[0]}`, ...args.slice(1)];
54
+ }
55
+
56
+ /**
57
+ * Strip the tenant prefix from `list()` results.
58
+ *
59
+ * @param tenantId - Live `fx.tenant.id`
60
+ * @param decl - KV store decl
61
+ * @param tenantEnabled - Tenancy switch
62
+ * @param keys - Physical keys
63
+ */
64
+ export function stripKvPrefix(
65
+ tenantId: string | null,
66
+ decl: KvStoreDecl,
67
+ tenantEnabled: boolean,
68
+ keys: string[],
69
+ ): string[] {
70
+ if (!kvTenantScoped(decl, tenantEnabled) || !tenantId) return keys;
71
+ const prefix = `${tenantId}:`;
72
+ return keys.map((k) => (k.startsWith(prefix) ? k.slice(prefix.length) : k));
73
+ }
74
+
75
+ /**
76
+ * Request-time vault path `{tenantId}/{contract}` when the contract is per-tenant.
77
+ *
78
+ * @param tenantId - Live `fx.tenant.id`
79
+ * @param tenantEnabled - Tenancy switch
80
+ * @param contracts - Vault runtime contracts
81
+ * @param contractName - Capability name
82
+ */
83
+ export function vaultStoragePath(
84
+ tenantId: string | null,
85
+ tenantEnabled: boolean,
86
+ contracts: VaultRuntime["contracts"] | undefined,
87
+ contractName: string,
88
+ ): string {
89
+ const decl = contracts?.get(contractName);
90
+ const perTenant =
91
+ decl?.perTenant === true || (tenantEnabled && decl !== undefined && decl.perTenant !== false);
92
+ if (!perTenant) return contractName;
93
+ if (!tenantId) throwOke("TENANT_REQUIRED");
94
+ return `${tenantId}/${contractName}`;
95
+ }
96
+
97
+ /**
98
+ * Compact KV rewrite used by `createFx` so long helper names stay off the
99
+ * Store-only `oke()` graph.
100
+ *
101
+ * @param mode - `in` prefixes args; `out` strips `list()` keys
102
+ * @param tenantId - Live `fx.tenant.id`
103
+ * @param decl - KV store decl
104
+ * @param tenantEnabled - Tenancy switch
105
+ * @param prop - Handle method
106
+ * @param payload - Args or list keys
107
+ */
108
+ export function kv(
109
+ mode: "in" | "out",
110
+ tenantId: string | null,
111
+ decl: KvStoreDecl,
112
+ tenantEnabled: boolean,
113
+ prop: string | symbol,
114
+ payload: unknown,
115
+ ): unknown {
116
+ if (mode === "out") return stripKvPrefix(tenantId, decl, tenantEnabled, payload as string[]);
117
+ return rewriteKvArgs(tenantId, decl, tenantEnabled, prop, payload as unknown[]);
118
+ }
119
+
120
+ /**
121
+ * Compact vault path used by `createFx`.
122
+ *
123
+ * @param tenantId - Live `fx.tenant.id`
124
+ * @param tenantEnabled - Tenancy switch
125
+ * @param contracts - Vault runtime contracts
126
+ * @param contractName - Capability name
127
+ */
128
+ export function path(
129
+ tenantId: string | null,
130
+ tenantEnabled: boolean,
131
+ contracts: VaultRuntime["contracts"] | undefined,
132
+ contractName: string,
133
+ ): string {
134
+ return vaultStoragePath(tenantId, tenantEnabled, contracts, contractName);
135
+ }
136
+
137
+ /**
138
+ * Fail loud when a per-tenant vault row is missing.
139
+ *
140
+ * @param storagePath - Physical `{tenantId}/{contract}` path
141
+ */
142
+ export function missingVault(storagePath: string): Error {
143
+ return new Error(`fx.vault.get: missing per-tenant secret "${storagePath}"`);
144
+ }
145
+
146
+ /**
147
+ * Attach tenant auth methods and KV prefixing onto a live `fx` bag.
148
+ * Called from `createFx` only when tenancy is on.
149
+ *
150
+ * @param fx - Mutable fx bag
151
+ * @param ctx - Tenant id + createFx options + gates
152
+ */
153
+ export function install(
154
+ fx: {
155
+ auth: FxAuthIdentity;
156
+ store: (ref: never) => object;
157
+ },
158
+ ctx: {
159
+ readonly tenantId: string | null;
160
+ readonly options: {
161
+ readonly tenantStore?: TenantStore;
162
+ readonly tenantEnabled?: boolean;
163
+ readonly sessions?: SessionStore;
164
+ readonly sessionCrypto?: SessionCrypto;
165
+ readonly manifest?: Manifest | null;
166
+ };
167
+ readonly now: () => number;
168
+ readonly gated: AttachAuthTenantMethodsOptions["gated"];
169
+ },
170
+ ): void {
171
+ const next = bind(
172
+ fx.auth,
173
+ {
174
+ tenantStore: ctx.options.tenantStore,
175
+ sessions: ctx.options.sessions,
176
+ sessionCrypto: ctx.options.sessionCrypto,
177
+ manifest: ctx.options.manifest ?? undefined,
178
+ },
179
+ ctx.now,
180
+ ctx.gated,
181
+ );
182
+ fx.auth = next;
183
+ if (ctx.options.tenantEnabled !== true) return;
184
+ const inner = fx.store;
185
+ (fx as { store: typeof inner }).store = ((ref: never) => {
186
+ const handle = inner(ref);
187
+ if (
188
+ typeof ref === "object" &&
189
+ ref !== null &&
190
+ "facet" in ref &&
191
+ (ref as KvStoreDecl).facet === "kv"
192
+ ) {
193
+ const decl = ref as KvStoreDecl;
194
+ return new Proxy(handle, {
195
+ get(target, prop, receiver) {
196
+ const val = Reflect.get(target, prop, receiver);
197
+ if (typeof val !== "function" || prop === "then") return val;
198
+ return (...args: unknown[]) => {
199
+ const callArgs = rewriteKvArgs(ctx.tenantId, decl, true, prop, args);
200
+ const result = (val as (...a: unknown[]) => unknown).apply(target, callArgs);
201
+ if (prop === "list") {
202
+ return Promise.resolve(result as Promise<string[]>).then((keys) =>
203
+ stripKvPrefix(ctx.tenantId, decl, true, keys),
204
+ );
205
+ }
206
+ return result;
207
+ };
208
+ },
209
+ });
210
+ }
211
+ return handle;
212
+ }) as typeof inner;
213
+ }
@@ -86,6 +86,52 @@ describe("fx — capability enforcement", () => {
86
86
  expect(ttl).toBeGreaterThan(0);
87
87
  });
88
88
 
89
+ test("kv prefixes keys with tenant id and fails loud without one", async () => {
90
+ const { createStoreRuntime, store } = await import("../elements/store.ts");
91
+ const { memoryKvDriver } = await import("../drivers/index.ts");
92
+ const decl = store.kv("drafts");
93
+ const rt = createStoreRuntime({
94
+ drivers: { kv: memoryKvDriver },
95
+ kv: { drafts: {} },
96
+ });
97
+ rt.register(decl);
98
+ const acme = createFx({
99
+ flow: "drafts.acme",
100
+ effects: { reads: ["kv:drafts"], writes: ["kv:drafts"] },
101
+ storeRuntime: rt,
102
+ tenantEnabled: true,
103
+ tenant: { id: "acme" },
104
+ });
105
+ const globex = createFx({
106
+ flow: "drafts.globex",
107
+ effects: { reads: ["kv:drafts"], writes: ["kv:drafts"] },
108
+ storeRuntime: rt,
109
+ tenantEnabled: true,
110
+ tenant: { id: "globex" },
111
+ });
112
+ await acme.store(decl).set("note", "alice");
113
+ await globex.store(decl).set("note", "bob");
114
+ expect(await acme.store(decl).get("note")).toBe("alice");
115
+ expect(await globex.store(decl).get("note")).toBe("bob");
116
+ expect(await acme.store(decl).list()).toEqual(["note"]);
117
+
118
+ const none = createFx({
119
+ flow: "drafts.none",
120
+ effects: { writes: ["kv:drafts"] },
121
+ storeRuntime: rt,
122
+ tenantEnabled: true,
123
+ tenant: { id: null },
124
+ });
125
+ let err: unknown;
126
+ try {
127
+ await none.store(decl).set("note", "x");
128
+ } catch (e) {
129
+ err = e;
130
+ }
131
+ expect(err).toBeInstanceOf(OkeError);
132
+ expect((err as OkeError).code).toBe(1015);
133
+ });
134
+
89
135
  test("index driverId is meilisearch before first I/O (not a function, not memory)", async () => {
90
136
  const { createStoreRuntime, store } = await import("../elements/store.ts");
91
137
  const { meilisearchDriver } = await import("../drivers/index.ts");
@@ -334,6 +380,28 @@ describe("fx.vault — object surface", () => {
334
380
  /fx\.vault\.status needs a bound Vault backend/,
335
381
  );
336
382
  });
383
+
384
+ test("per-tenant contracts store under {tenantId}/{contract}", async () => {
385
+ const { createVaultRuntime, vault } = await import("../elements/vault.ts");
386
+ const { adapter, rows } = memoryAdapter();
387
+ const stripe = vault.secret("STRIPE_KEY", { perTenant: true, dev: "sk_unused" });
388
+ const runtime = createVaultRuntime({
389
+ secrets: [stripe],
390
+ tenantEnabled: true,
391
+ });
392
+ const fx = createFx({
393
+ flow: "billing.key",
394
+ effects: { secrets: ["STRIPE_KEY"] },
395
+ vaultRuntime: runtime,
396
+ vaultAdapter: adapter as never,
397
+ tenantEnabled: true,
398
+ tenant: { id: "acme" },
399
+ });
400
+ await fx.vault.set("STRIPE_KEY", "sk_acme");
401
+ expect([...rows.keys()]).toEqual(["acme/STRIPE_KEY"]);
402
+ const redacted = await fx.vault.get("STRIPE_KEY");
403
+ expect(redacted.reveal()).toBe("sk_acme");
404
+ });
337
405
  });
338
406
 
339
407
  describe("fx — wholesale swap", () => {
@@ -364,6 +432,9 @@ describe("fx — wholesale swap", () => {
364
432
  async deadLetters() {
365
433
  return [];
366
434
  },
435
+ live() {
436
+ throw new Error("live must not be used");
437
+ },
367
438
  async call() {
368
439
  return null;
369
440
  },
@@ -448,6 +519,26 @@ describe("fx — wholesale swap", () => {
448
519
  updateApiKey: async () => {
449
520
  throw new Error("auth.updateApiKey must not be used");
450
521
  },
522
+ listTenants: async () => [],
523
+ switchTenant: async () => {
524
+ throw new Error("auth.switchTenant must not be used");
525
+ },
526
+ createTenant: async () => {
527
+ throw new Error("auth.createTenant must not be used");
528
+ },
529
+ deleteTenant: async () => {
530
+ throw new Error("auth.deleteTenant must not be used");
531
+ },
532
+ addMember: async () => {
533
+ throw new Error("auth.addMember must not be used");
534
+ },
535
+ removeMember: async () => {
536
+ throw new Error("auth.removeMember must not be used");
537
+ },
538
+ listMembers: async () => [],
539
+ upsertTenantRole: async () => {
540
+ throw new Error("auth.upsertTenantRole must not be used");
541
+ },
451
542
  },
452
543
  operator: { id: null },
453
544
  principal: { userId: "u1", operatorId: null, scopes: new Set(["a"]) },