okengine 0.16.0 → 0.17.1

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 (150) hide show
  1. package/manifest.v1.schema.json +21 -2
  2. package/package.json +1 -1
  3. package/site/content/docs/ai/llms-txt.mdx +9 -3
  4. package/site/content/docs/elements/clock.mdx +7 -0
  5. package/site/content/docs/elements/flow.mdx +14 -12
  6. package/site/content/docs/elements/gate.mdx +58 -8
  7. package/site/content/docs/elements/signal.mdx +46 -17
  8. package/site/content/docs/elements/store.mdx +23 -17
  9. package/site/content/docs/elements/vault.mdx +23 -0
  10. package/site/content/docs/get-started/project-structure.mdx +4 -4
  11. package/site/content/docs/reference/cli.md +1 -1
  12. package/site/content/docs/reference/client.mdx +72 -17
  13. package/site/content/docs/reference/configuration.mdx +7 -4
  14. package/site/content/docs/reference/errors.mdx +24 -17
  15. package/site/content/docs/reference/fx.mdx +15 -9
  16. package/src/auth/api-key-sql.ts +11 -4
  17. package/src/auth/api-keys.ts +3 -0
  18. package/src/auth/config.ts +19 -0
  19. package/src/auth/index.ts +37 -0
  20. package/src/auth/plugin.ts +6 -1
  21. package/src/auth/sessions.ts +18 -0
  22. package/src/auth/tables.ts +32 -0
  23. package/src/auth/tenant-config.ts +74 -0
  24. package/src/auth/tenant-tables.ts +11 -0
  25. package/src/auth/tenants.test.ts +63 -0
  26. package/src/auth/tenants.ts +360 -0
  27. package/src/cli/build.ts +2 -2
  28. package/src/client/budget.test.ts +1 -1
  29. package/src/client/create.ts +75 -5
  30. package/src/client/index.ts +13 -0
  31. package/src/client/live.test.ts +422 -0
  32. package/src/client/live.ts +389 -0
  33. package/src/client/notes-contract.test.ts +41 -0
  34. package/src/client/types.ts +85 -6
  35. package/src/client-react/index.ts +85 -1
  36. package/src/client-react/use-live.test.ts +129 -0
  37. package/src/compiler/effects-infer.ts +22 -2
  38. package/src/compiler/extract.test.ts +143 -11
  39. package/src/compiler/extract.ts +210 -30
  40. package/src/compiler/fixtures/skyport/src/flows/bookings/index.ts +1 -2
  41. package/src/compiler/fixtures/skyport.expected.json +2 -3
  42. package/src/compiler/response.ts +41 -11
  43. package/src/console/server/app.ts +44 -11
  44. package/src/console/server/console.test.ts +6 -8
  45. package/src/console/server/gates.ts +2 -9
  46. package/src/console/server/store.test.ts +17 -0
  47. package/src/console/server/store.ts +43 -1
  48. package/src/console/ui-next/dist/assets/{access-page-CAGHrA9H.js → access-page-9Wwx1g4Q.js} +1 -1
  49. package/src/console/ui-next/dist/assets/{flows-page-B-OUtiAu.js → flows-page-DydRDi70.js} +1 -1
  50. package/src/console/ui-next/dist/assets/{index-CGoZkILK.js → index-Cg9nxd6m.js} +3 -3
  51. package/src/console/ui-next/dist/assets/{observability-page-BDyXalNR.js → observability-page-D-OzmFOI.js} +1 -1
  52. package/src/console/ui-next/dist/assets/{store-page-Xh8Kn3rx.js → store-page-CS5-aETQ.js} +1 -1
  53. package/src/console/ui-next/dist/assets/{tree-expand-toggle-DkOXA12R.js → tree-expand-toggle-BtyhmWb4.js} +2 -1
  54. package/src/console/ui-next/dist/assets/{units-page-Dpk40kOQ.js → units-page-B_zeux2Y.js} +1 -1
  55. package/src/console/ui-next/dist/assets/{vault-page-B1dB9Ft0.js → vault-page-CsMg7XDW.js} +1 -1
  56. package/src/console/ui-next/dist/index.html +1 -1
  57. package/src/console/ui-next/src/features/flows/fixture.ts +0 -1
  58. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +5 -1
  59. package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +15 -4
  60. package/src/console/ui-next/ui-next-seed-manifest-surface.ts +2 -9
  61. package/src/console/ui-next/ui-next-seed-manifest.ts +0 -1
  62. package/src/drivers/index.ts +2 -0
  63. package/src/drivers/journal-postgres.ts +12 -3
  64. package/src/drivers/pg-rls.ts +26 -2
  65. package/src/drivers/pg-vault-rls.ts +68 -0
  66. package/src/drivers/signal-engine.ts +69 -15
  67. package/src/drivers/signal-live-iter.ts +65 -0
  68. package/src/drivers/signal-nats.ts +2 -1
  69. package/src/drivers/signal-postgres.ts +95 -12
  70. package/src/drivers/signal-redis.ts +2 -1
  71. package/src/drivers/signal-retention.ts +64 -0
  72. package/src/drivers/signal-types.ts +35 -5
  73. package/src/elements/clock/declare.ts +64 -2
  74. package/src/elements/clock/reconcile.ts +98 -25
  75. package/src/elements/clock/runtime.ts +13 -2
  76. package/src/elements/clock.test.ts +28 -0
  77. package/src/elements/clock.ts +9 -1
  78. package/src/elements/gate/permissions.ts +12 -0
  79. package/src/elements/gate.ts +6 -1
  80. package/src/elements/signal/declare.ts +44 -10
  81. package/src/elements/signal/delivery-modes.test.ts +90 -4
  82. package/src/elements/signal/order-lifecycle.test.ts +20 -4
  83. package/src/elements/signal/runtime.ts +42 -0
  84. package/src/elements/signal.test.ts +21 -8
  85. package/src/elements/signal.ts +1 -1
  86. package/src/elements/store/declare.ts +7 -0
  87. package/src/elements/store/rls-identity.test.ts +29 -0
  88. package/src/elements/store/rls-identity.ts +3 -0
  89. package/src/elements/store/schema-decl.ts +63 -3
  90. package/src/elements/store/schema-tenant.ts +41 -0
  91. package/src/elements/store/sql-rls-isolation.test.ts +39 -0
  92. package/src/elements/store.ts +2 -0
  93. package/src/elements/vault/builtin-adapter.ts +15 -2
  94. package/src/elements/vault/declare.ts +8 -0
  95. package/src/elements/vault/runtime.ts +7 -0
  96. package/src/elements/vault/sql-rls-isolation.test.ts +145 -0
  97. package/src/elements/vault/storage.ts +9 -0
  98. package/src/elements/vault/test-helpers.ts +2 -1
  99. package/src/i18n/catalogs/ar.ts +19 -0
  100. package/src/i18n/catalogs/en.ts +19 -0
  101. package/src/index.ts +1 -0
  102. package/src/kernel/adopt-routes.ts +56 -8
  103. package/src/kernel/app-tenant.ts +122 -0
  104. package/src/kernel/app.ts +183 -56
  105. package/src/kernel/auth-resolve.ts +3 -0
  106. package/src/kernel/boot-bind/clock.ts +9 -3
  107. package/src/kernel/boot.ts +2 -0
  108. package/src/kernel/budget.test.ts +1 -1
  109. package/src/kernel/clock-durable.ts +8 -0
  110. package/src/kernel/clock-per-tenant-name.ts +5 -0
  111. package/src/kernel/clock-reconcile.ts +8 -0
  112. package/src/kernel/errors-live-resume.ts +15 -0
  113. package/src/kernel/errors-tenant.ts +29 -0
  114. package/src/kernel/errors.registry.test.ts +31 -3
  115. package/src/kernel/errors.ts +43 -3
  116. package/src/kernel/flow.ts +26 -5
  117. package/src/kernel/fx-auth-keys.ts +6 -1
  118. package/src/kernel/fx-auth-tenants.test.ts +87 -0
  119. package/src/kernel/fx-auth-tenants.ts +286 -0
  120. package/src/kernel/fx-live-stream.ts +149 -0
  121. package/src/kernel/fx-live.test.ts +157 -0
  122. package/src/kernel/fx-runtime.ts +15 -0
  123. package/src/kernel/fx-tenant-store.ts +213 -0
  124. package/src/kernel/fx.test.ts +91 -0
  125. package/src/kernel/fx.ts +173 -13
  126. package/src/kernel/hooks.ts +2 -2
  127. package/src/kernel/http-resource.ts +9 -18
  128. package/src/kernel/index.ts +2 -0
  129. package/src/kernel/journal.ts +12 -0
  130. package/src/kernel/live-http.test.ts +78 -0
  131. package/src/kernel/live-http.ts +114 -0
  132. package/src/kernel/live-resume.test.ts +125 -0
  133. package/src/kernel/on.ts +51 -0
  134. package/src/kernel/pipeline-tenant.ts +49 -0
  135. package/src/kernel/pipeline.test.ts +1 -1
  136. package/src/kernel/pipeline.ts +37 -2
  137. package/src/kernel/resource-mount.test.ts +10 -27
  138. package/src/kernel/tenant-resolve.test.ts +101 -0
  139. package/src/kernel/tenant-resolve.ts +124 -0
  140. package/src/kernel/tenant-roles.test.ts +87 -0
  141. package/src/kernel/triggers.ts +59 -21
  142. package/src/manifest/diff.test.ts +11 -2
  143. package/src/manifest/diff.ts +53 -11
  144. package/src/manifest/fixtures/skyport.excerpt.json +1 -1
  145. package/src/manifest/fixtures/skyport.manifest.json +0 -1
  146. package/src/manifest/types.ts +52 -6
  147. package/src/release/build-lib.ts +13 -1
  148. package/src/release/limits.ts +2 -2
  149. package/src/release/measure.ts +55 -1
  150. package/src/client/live-gap.test.ts +0 -35
@@ -6,11 +6,23 @@
6
6
  */
7
7
 
8
8
  import { describe, expect, test } from "bun:test";
9
- import { OKE_ERRORS, OkeError, type OkeErrorDefinition } from "./errors.ts";
9
+ import { OKE_ERRORS, OkeError, lookupOkeError, type OkeErrorDefinition } from "./errors.ts";
10
+ import { LIVE_RESUME_GAP } from "./errors-live-resume.ts";
11
+ import { TENANT_NOT_MEMBER, TENANT_REQUIRED, TENANT_UNKNOWN_SCOPE } from "./errors-tenant.ts";
12
+
13
+ const LAZY_DEFS: readonly OkeErrorDefinition[] = [
14
+ LIVE_RESUME_GAP,
15
+ TENANT_REQUIRED,
16
+ TENANT_NOT_MEMBER,
17
+ TENANT_UNKNOWN_SCOPE,
18
+ ];
10
19
 
11
20
  describe("OKE error-code registry", () => {
12
21
  test("every code is unique", () => {
13
- const codes = Object.values(OKE_ERRORS).map((d) => d.code);
22
+ const codes = [
23
+ ...Object.values(OKE_ERRORS).map((d) => d.code),
24
+ ...LAZY_DEFS.map((d) => d.code),
25
+ ];
14
26
  expect(new Set(codes).size).toBe(codes.length);
15
27
  });
16
28
 
@@ -21,10 +33,16 @@ describe("OKE error-code registry", () => {
21
33
  expect(Number.isInteger(def.code), `${key}.code`).toBe(true);
22
34
  expect(def.code, `${key}.code`).toBeGreaterThan(0);
23
35
  }
36
+ expect(LIVE_RESUME_GAP.cause.trim().length).toBeGreaterThan(0);
37
+ expect(LIVE_RESUME_GAP.fix.trim().length).toBeGreaterThan(0);
38
+ for (const def of LAZY_DEFS) {
39
+ expect(def.cause.trim().length, `${def.code}.cause`).toBeGreaterThan(0);
40
+ expect(def.fix.trim().length, `${def.code}.fix`).toBeGreaterThan(0);
41
+ }
24
42
  });
25
43
 
26
44
  test("OkeError docsUrl matches docs origin /e/{code}", () => {
27
- for (const def of Object.values(OKE_ERRORS) as OkeErrorDefinition[]) {
45
+ for (const def of [...Object.values(OKE_ERRORS), ...LAZY_DEFS] as OkeErrorDefinition[]) {
28
46
  const err = new OkeError(def);
29
47
  expect(err.docsUrl).toBe(`https://oke.omqkhafi.dev/e/${def.code}`);
30
48
  expect(err.message).toContain(err.docsUrl);
@@ -32,4 +50,14 @@ describe("OKE error-code registry", () => {
32
50
  expect(err.message).toContain("→");
33
51
  }
34
52
  });
53
+
54
+ test("lookupOkeError finds the lazy LIVE_RESUME_GAP entry", () => {
55
+ expect(lookupOkeError(1014)).toEqual(LIVE_RESUME_GAP);
56
+ });
57
+
58
+ test("lookupOkeError finds lazy tenant entries", () => {
59
+ expect(lookupOkeError(1015)).toEqual(TENANT_REQUIRED);
60
+ expect(lookupOkeError(1016)).toEqual(TENANT_NOT_MEMBER);
61
+ expect(lookupOkeError(1017)).toEqual(TENANT_UNKNOWN_SCOPE);
62
+ });
35
63
  });
@@ -207,6 +207,15 @@ export const OKE_ERRORS = {
207
207
  cause: "An HTTP flow on {method} {path} has no name.",
208
208
  fix: 'Use `flow("unit.export", {…})` or export it from a `src/flows/<unit>/` file so the tree can stamp `unit.export`.',
209
209
  },
210
+ /**
211
+ * Two live HTTP exposures of the same signal share gates + match shape.
212
+ */
213
+ LIVE_EXPOSURE_DUPLICATE: {
214
+ code: 1013,
215
+ cause:
216
+ 'Live signal "{signal}" is exposed twice with the same gates ({gates}) and match ({match}).',
217
+ fix: "Use a different gate or path-param filter, or drop the extra route.",
218
+ },
210
219
  /**
211
220
  * Emit target has no subscriber (unified-theory §21).
212
221
  * Thrown at emit when `optional` is false and nobody is subscribed.
@@ -233,15 +242,37 @@ export const OKE_ERRORS = {
233
242
  },
234
243
  } as const satisfies Record<string, OkeErrorDefinition>;
235
244
 
245
+ /** Tenant error keys — definitions live in the lazy `errors-tenant` chunk. */
246
+ export type TenantOkeErrorKey = "TENANT_REQUIRED" | "TENANT_NOT_MEMBER" | "TENANT_UNKNOWN_SCOPE";
247
+
248
+ type TenantErrorChunk = {
249
+ readonly TENANT_REQUIRED: OkeErrorDefinition;
250
+ readonly TENANT_NOT_MEMBER: OkeErrorDefinition;
251
+ readonly TENANT_UNKNOWN_SCOPE: OkeErrorDefinition;
252
+ };
253
+
254
+ /**
255
+ * Load OKE1015–1017. Computed stem so Bun.build cannot inline the chunk.
256
+ */
257
+ function loadTenantErrors(): TenantErrorChunk {
258
+ return lazyRequire(import.meta.dir, ["errors", "tenant"].join("-"));
259
+ }
260
+
236
261
  /**
237
262
  * Build and throw a registered framework error.
238
263
  *
239
- * @param key - Key into {@link OKE_ERRORS}
264
+ * @param key - Key into {@link OKE_ERRORS} or a lazy tenant code
240
265
  * @param params - Interpolation params
241
266
  * @returns Never
242
267
  */
243
- export function throwOke(key: keyof typeof OKE_ERRORS, params: OkeErrorParams = {}): never {
244
- throw new OkeError(OKE_ERRORS[key], params);
268
+ export function throwOke(
269
+ key: keyof typeof OKE_ERRORS | TenantOkeErrorKey,
270
+ params: OkeErrorParams = {},
271
+ ): never {
272
+ if (typeof key === "string" && key.startsWith("TENANT_")) {
273
+ throw new OkeError(loadTenantErrors()[key as TenantOkeErrorKey], params);
274
+ }
275
+ throw new OkeError(OKE_ERRORS[key as keyof typeof OKE_ERRORS], params);
245
276
  }
246
277
 
247
278
  /**
@@ -250,6 +281,15 @@ export function throwOke(key: keyof typeof OKE_ERRORS, params: OkeErrorParams =
250
281
  * @param code - Permanent numeric code
251
282
  */
252
283
  export function lookupOkeError(code: OkeErrorCode): OkeErrorDefinition | undefined {
284
+ if (code === 1014) {
285
+ return lazyRequire<typeof import("./errors-live-resume.ts")>(
286
+ import.meta.dir,
287
+ ["errors", "live", "resume"].join("-"),
288
+ ).LIVE_RESUME_GAP;
289
+ }
290
+ if (code === 1015) return loadTenantErrors().TENANT_REQUIRED;
291
+ if (code === 1016) return loadTenantErrors().TENANT_NOT_MEMBER;
292
+ if (code === 1017) return loadTenantErrors().TENANT_UNKNOWN_SCOPE;
253
293
  for (const def of Object.values(OKE_ERRORS)) {
254
294
  if (def.code === code) return def;
255
295
  }
@@ -48,13 +48,15 @@ export interface FlowOptions<I = unknown, O = unknown, E extends FlowErrorMap =
48
48
  readonly effects?: Effects;
49
49
  /** Journal every effect call. */
50
50
  readonly durable?: boolean;
51
+ /**
52
+ * Retry the whole `do` body on thrown errors (same journal session when
53
+ * durable). Prefer {@link Fx.retry} inside {@link Fx.step} for fine control.
54
+ */
51
55
  /**
52
56
  * Retry the whole `do` body on thrown errors (same journal session when
53
57
  * durable). Prefer {@link Fx.retry} inside {@link Fx.step} for fine control.
54
58
  */
55
59
  readonly retry?: FxRetryOptions;
56
- /** Live-query / push result. */
57
- readonly live?: boolean;
58
60
  /**
59
61
  * Cache policy. Read-only flows cache automatically from inferred
60
62
  * effects (`true` / omitted). `false` opts out; a duration string
@@ -89,6 +91,11 @@ export interface FlowOptions<I = unknown, O = unknown, E extends FlowErrorMap =
89
91
  },
90
92
  fx: import("./fx.ts").Fx,
91
93
  ) => unknown | Promise<unknown>;
94
+ /**
95
+ * When `false`, skip tenant-role scope union even if `fx.tenant.id` is set.
96
+ * Default `true` when `gate.auth.tenant` is on.
97
+ */
98
+ readonly tenantScoped?: boolean;
92
99
  /** The behavior. */
93
100
  readonly do: FlowHandler<I, O>;
94
101
  }
@@ -171,8 +178,15 @@ export interface FlowDef<
171
178
  readonly durable: boolean;
172
179
  /** Whole-body retry policy (runtime). */
173
180
  readonly retry: FxRetryOptions | undefined;
174
- /** Live flag. */
175
- readonly live: boolean;
181
+ /**
182
+ * Live signal name when this HTTP flow streams {@link SignalDecl} SSE.
183
+ */
184
+ readonly live: string | undefined;
185
+ /**
186
+ * True when `.live(signal)` is bound to a custom `do` (not synthesized).
187
+ * Boot uniqueness uses `custom:{flowName}` instead of path-param match.
188
+ */
189
+ readonly liveCustomMatch: boolean;
176
190
  /** Cache option. */
177
191
  readonly cache: boolean | string | undefined;
178
192
  /** SLO. */
@@ -181,6 +195,11 @@ export interface FlowDef<
181
195
  readonly plane: FlowPlane | undefined;
182
196
  /** Intentional contract-break acknowledgement for Manifest Diff. */
183
197
  readonly breaking: boolean;
198
+ /**
199
+ * When `false`, this flow is tenant-unaware (no tenant-role scope union).
200
+ * Default `true` when `gate.auth.tenant` is on.
201
+ */
202
+ readonly tenantScoped: boolean | undefined;
184
203
  /**
185
204
  * Optional compensation phase after terminal durable failure.
186
205
  * See {@link FlowOptions.compensate}.
@@ -284,11 +303,13 @@ export function flow(
284
303
  effects: options.effects,
285
304
  durable: options.durable ?? false,
286
305
  retry: options.retry,
287
- live: options.live ?? false,
306
+ live: undefined,
307
+ liveCustomMatch: false,
288
308
  cache: options.cache,
289
309
  slo: options.slo,
290
310
  plane: options.plane,
291
311
  breaking: options.breaking ?? false,
312
+ tenantScoped: options.tenantScoped,
292
313
  compensate: options.compensate as FlowDef["compensate"],
293
314
  do: options.do as FlowHandler,
294
315
  triggers,
@@ -26,8 +26,13 @@ import { fail, type FlowFailure } from "./errors.ts";
26
26
  export interface FxAuthIdentity {
27
27
  /** User-plane subject id, or null when unauthenticated. */
28
28
  readonly userId: string | null;
29
- /** Granted scopes for the live principal. */
29
+ /** Granted scopes for the live principal (may include tenant-role union). */
30
30
  readonly scopes: ReadonlySet<string>;
31
+ /**
32
+ * Session / JWT scopes before tenant-role union.
33
+ * `switchTenant` mints from this set so tenant grants never melt into the token.
34
+ */
35
+ readonly sessionScopes?: ReadonlySet<string>;
31
36
  /** Whether the identity has completed verification (email / MFA). */
32
37
  readonly verified?: boolean;
33
38
  /** Authenticating API key id when Bearer was a key secret; otherwise null. */
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Session-only tenant methods — switchTenant mints a new family; listTenants refuses keys.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { createSessionStore, issueSession, verifyAccess } from "../auth/sessions.ts";
7
+ import { createTenant, createTenantStore } from "../auth/tenants.ts";
8
+ import { isFlowFailure } from "./hooks.ts";
9
+ import { createFx } from "./fx.ts";
10
+
11
+ const SECRET = "tenant-methods-test-secret";
12
+
13
+ describe("fx.auth tenant methods", () => {
14
+ test("listTenants is session-only", async () => {
15
+ const store = createTenantStore();
16
+ await createTenant(store, { name: "Acme", createdBy: "u1", id: "t1" });
17
+ const asKey = createFx({
18
+ flow: "tenants.list",
19
+ effects: { reads: ["auth:tenants"] },
20
+ tenantStore: store,
21
+ tenantEnabled: true,
22
+ auth: { userId: "u1", scopes: new Set(), apiKeyId: "key_1" },
23
+ });
24
+ try {
25
+ await asKey.auth.listTenants();
26
+ throw new Error("expected Forbidden");
27
+ } catch (err) {
28
+ expect(isFlowFailure(err)).toBe(true);
29
+ if (isFlowFailure(err)) {
30
+ expect(err.error.code).toBe("Forbidden");
31
+ expect(err.error.data).toEqual({ gate: "auth:tenants", reason: "session_only" });
32
+ }
33
+ }
34
+
35
+ const asSession = createFx({
36
+ flow: "tenants.list.session",
37
+ effects: { reads: ["auth:tenants"] },
38
+ tenantStore: store,
39
+ tenantEnabled: true,
40
+ auth: { userId: "u1", scopes: new Set(), apiKeyId: null },
41
+ });
42
+ const listed = await asSession.auth.listTenants();
43
+ expect(listed.map((t) => t.id)).toEqual(["t1"]);
44
+ });
45
+
46
+ test("switchTenant issues a new family and copies tid without melting tenant scopes", async () => {
47
+ const tenantStore = createTenantStore();
48
+ await createTenant(tenantStore, { name: "Acme", createdBy: "u1", id: "t1" });
49
+ await createTenant(tenantStore, { name: "Globex", createdBy: "u1", id: "t2" });
50
+ const sessions = createSessionStore();
51
+ const crypto = { secret: SECRET };
52
+ const first = await issueSession(sessions, crypto, {
53
+ id: "u1",
54
+ plane: "user",
55
+ scopes: ["member"],
56
+ tenantId: "t1",
57
+ });
58
+
59
+ const liveScopes = new Set(["member", "booking:create"]);
60
+ const fx = createFx({
61
+ flow: "tenants.switch",
62
+ effects: { writes: ["auth:tenants"] },
63
+ tenantStore,
64
+ tenantEnabled: true,
65
+ sessions,
66
+ sessionCrypto: crypto,
67
+ auth: {
68
+ userId: "u1",
69
+ scopes: liveScopes,
70
+ sessionScopes: new Set(["member"]),
71
+ apiKeyId: null,
72
+ },
73
+ });
74
+ const switched = await fx.auth.switchTenant("t2");
75
+ expect(switched.session.familyId).not.toBe(first.session.familyId);
76
+ expect(switched.session.id).not.toBe(first.session.id);
77
+
78
+ const claims = await verifyAccess(sessions, SECRET, switched.accessToken);
79
+ expect(claims.tid).toBe("t2");
80
+ expect(claims.scopes).toEqual(["member"]);
81
+ expect(claims.sid).toBe(switched.session.id);
82
+
83
+ const firstStill = await verifyAccess(sessions, SECRET, first.accessToken);
84
+ expect(firstStill.tid).toBe("t1");
85
+ expect(firstStill.sid).toBe(first.session.id);
86
+ });
87
+ });
@@ -0,0 +1,286 @@
1
+ /**
2
+ * `fx.auth` tenant methods — session-only, lazy-attached like API keys.
3
+ */
4
+
5
+ import {
6
+ AUTH_TENANTS_RESOURCE,
7
+ addMember,
8
+ createTenant,
9
+ deleteTenant,
10
+ isMember,
11
+ listMembers,
12
+ listTenantsForUser,
13
+ removeMember,
14
+ TenantError,
15
+ upsertTenantRole,
16
+ type TenantMemberRow,
17
+ type TenantPublicRow,
18
+ type TenantRoleRow,
19
+ type TenantRow,
20
+ type TenantStore,
21
+ } from "../auth/tenants.ts";
22
+ import {
23
+ issueSession,
24
+ type IssuedSession,
25
+ type SessionCrypto,
26
+ type SessionStore,
27
+ } from "../auth/sessions.ts";
28
+ import type { Manifest } from "../manifest/types.ts";
29
+ import { deriveModuleActions } from "../elements/gate/permissions.ts";
30
+ import type { EffectKind } from "./effects.ts";
31
+ import { fail, type FlowFailure } from "./errors.ts";
32
+ import type { FxAuthIdentity } from "./fx-auth-keys.ts";
33
+
34
+ /** Options for {@link FxAuth.createTenant}. */
35
+ export interface FxCreateTenantInput {
36
+ readonly name: string;
37
+ readonly slug?: string | null;
38
+ readonly id?: string;
39
+ }
40
+
41
+ /** Options for {@link FxAuth.addMember}. */
42
+ export interface FxAddMemberInput {
43
+ readonly tenantId: string;
44
+ readonly userId: string;
45
+ readonly role?: string;
46
+ }
47
+
48
+ /** Options for {@link FxAuth.upsertTenantRole}. */
49
+ export interface FxUpsertTenantRoleInput {
50
+ readonly tenantId: string;
51
+ readonly roleName: string;
52
+ readonly scopes: readonly string[];
53
+ }
54
+
55
+ /**
56
+ * Session-only tenant primitives attached onto {@link FxAuthIdentity}.
57
+ */
58
+ export interface FxAuthTenantMethods {
59
+ listTenants(): Promise<readonly TenantPublicRow[]>;
60
+ switchTenant(id: string): Promise<IssuedSession>;
61
+ createTenant(input: FxCreateTenantInput): Promise<TenantRow>;
62
+ deleteTenant(id: string): Promise<TenantRow>;
63
+ addMember(input: FxAddMemberInput): Promise<TenantMemberRow>;
64
+ removeMember(tenantId: string, userId: string): Promise<TenantMemberRow>;
65
+ listMembers(tenantId: string): Promise<readonly TenantMemberRow[]>;
66
+ upsertTenantRole(input: FxUpsertTenantRoleInput): Promise<TenantRoleRow>;
67
+ }
68
+
69
+ /** Dependencies for {@link attachTenantMethods}. */
70
+ export interface AttachAuthTenantMethodsOptions {
71
+ readonly auth: FxAuthIdentity;
72
+ readonly store: TenantStore | undefined;
73
+ readonly sessions: SessionStore | undefined;
74
+ readonly crypto: SessionCrypto | undefined;
75
+ readonly manifest: Manifest | undefined;
76
+ readonly now: () => number;
77
+ readonly gated: <T>(kind: EffectKind, resource: string, body: () => T | Promise<T>) => Promise<T>;
78
+ }
79
+
80
+ function sessionOnly(auth: FxAuthIdentity): FlowFailure | null {
81
+ if (auth.apiKeyId) {
82
+ return fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "session_only" });
83
+ }
84
+ if (!auth.userId) {
85
+ return fail("Unauthorized", {});
86
+ }
87
+ return null;
88
+ }
89
+
90
+ function requireStore(store: TenantStore | undefined): TenantStore {
91
+ if (!store) {
92
+ throw fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "no_store" });
93
+ }
94
+ return store;
95
+ }
96
+
97
+ function mapTenantError(err: unknown): never {
98
+ if (err instanceof TenantError) {
99
+ throw fail("Forbidden", {
100
+ gate: AUTH_TENANTS_RESOURCE,
101
+ reason: err.reason,
102
+ ...(err.scope !== undefined ? { scope: err.scope } : {}),
103
+ });
104
+ }
105
+ throw err;
106
+ }
107
+
108
+ /**
109
+ * Attach tenant primitives onto a live identity bag.
110
+ *
111
+ * @param options - Auth bag + tenant store + session crypto
112
+ */
113
+ export function attach(
114
+ options: AttachAuthTenantMethodsOptions,
115
+ ): FxAuthIdentity & FxAuthTenantMethods {
116
+ const { auth, now, gated } = options;
117
+
118
+ const methods: FxAuthTenantMethods = {
119
+ listTenants() {
120
+ return gated("read", AUTH_TENANTS_RESOURCE, async () => {
121
+ const denied = sessionOnly(auth);
122
+ if (denied) throw denied;
123
+ const store = requireStore(options.store);
124
+ return listTenantsForUser(store, auth.userId!);
125
+ });
126
+ },
127
+ switchTenant(id) {
128
+ return gated("write", AUTH_TENANTS_RESOURCE, async () => {
129
+ const denied = sessionOnly(auth);
130
+ if (denied) throw denied;
131
+ const store = requireStore(options.store);
132
+ if (!isMember(store, id, auth.userId!)) {
133
+ throw fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "not_member" });
134
+ }
135
+ const sessions = options.sessions;
136
+ const crypto = options.crypto;
137
+ if (!sessions || !crypto) {
138
+ throw fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "no_sessions" });
139
+ }
140
+ // New family — tab A rotation cannot reuse-detect tab B.
141
+ // Mint from session scopes, never the live tenant-unioned set.
142
+ const minted = auth.sessionScopes ?? auth.scopes;
143
+ return issueSession(sessions, crypto, {
144
+ id: auth.userId!,
145
+ plane: "user",
146
+ scopes: [...minted],
147
+ tenantId: id,
148
+ });
149
+ });
150
+ },
151
+ createTenant(input) {
152
+ return gated("write", AUTH_TENANTS_RESOURCE, async () => {
153
+ const denied = sessionOnly(auth);
154
+ if (denied) throw denied;
155
+ const store = requireStore(options.store);
156
+ try {
157
+ return await createTenant(store, {
158
+ name: input.name,
159
+ createdBy: auth.userId!,
160
+ ...(input.slug !== undefined ? { slug: input.slug } : {}),
161
+ ...(input.id !== undefined ? { id: input.id } : {}),
162
+ now,
163
+ });
164
+ } catch (err) {
165
+ mapTenantError(err);
166
+ }
167
+ });
168
+ },
169
+ deleteTenant(id) {
170
+ return gated("write", AUTH_TENANTS_RESOURCE, async () => {
171
+ const denied = sessionOnly(auth);
172
+ if (denied) throw denied;
173
+ const store = requireStore(options.store);
174
+ if (!isMember(store, id, auth.userId!)) {
175
+ throw fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "not_member" });
176
+ }
177
+ const row = await deleteTenant(store, id);
178
+ if (!row) {
179
+ throw fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "unknown_tenant" });
180
+ }
181
+ return row;
182
+ });
183
+ },
184
+ addMember(input) {
185
+ return gated("write", AUTH_TENANTS_RESOURCE, async () => {
186
+ const denied = sessionOnly(auth);
187
+ if (denied) throw denied;
188
+ const store = requireStore(options.store);
189
+ if (!isMember(store, input.tenantId, auth.userId!)) {
190
+ throw fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "not_member" });
191
+ }
192
+ try {
193
+ return addMember(store, {
194
+ tenantId: input.tenantId,
195
+ userId: input.userId,
196
+ ...(input.role !== undefined ? { role: input.role } : {}),
197
+ now,
198
+ });
199
+ } catch (err) {
200
+ mapTenantError(err);
201
+ }
202
+ });
203
+ },
204
+ removeMember(tenantId, userId) {
205
+ return gated("write", AUTH_TENANTS_RESOURCE, async () => {
206
+ const denied = sessionOnly(auth);
207
+ if (denied) throw denied;
208
+ const store = requireStore(options.store);
209
+ if (!isMember(store, tenantId, auth.userId!)) {
210
+ throw fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "not_member" });
211
+ }
212
+ const row = removeMember(store, tenantId, userId);
213
+ if (!row) {
214
+ throw fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "not_member" });
215
+ }
216
+ return row;
217
+ });
218
+ },
219
+ listMembers(tenantId) {
220
+ return gated("read", AUTH_TENANTS_RESOURCE, async () => {
221
+ const denied = sessionOnly(auth);
222
+ if (denied) throw denied;
223
+ const store = requireStore(options.store);
224
+ if (!isMember(store, tenantId, auth.userId!)) {
225
+ throw fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "not_member" });
226
+ }
227
+ return listMembers(store, tenantId);
228
+ });
229
+ },
230
+ upsertTenantRole(input) {
231
+ return gated("write", AUTH_TENANTS_RESOURCE, async () => {
232
+ const denied = sessionOnly(auth);
233
+ if (denied) throw denied;
234
+ const store = requireStore(options.store);
235
+ if (!isMember(store, input.tenantId, auth.userId!)) {
236
+ throw fail("Forbidden", { gate: AUTH_TENANTS_RESOURCE, reason: "not_member" });
237
+ }
238
+ const catalog = options.manifest ? deriveModuleActions(options.manifest) : [];
239
+ try {
240
+ return upsertTenantRole(store, {
241
+ tenantId: input.tenantId,
242
+ roleName: input.roleName,
243
+ scopes: input.scopes,
244
+ catalog,
245
+ now,
246
+ });
247
+ } catch (err) {
248
+ mapTenantError(err);
249
+ }
250
+ });
251
+ },
252
+ };
253
+
254
+ return Object.assign(auth, methods);
255
+ }
256
+
257
+ /**
258
+ * Compact attach from `createFx` so Store-only graphs do not spell the
259
+ * tenant store / session crypto field names.
260
+ *
261
+ * @param auth - Identity after API-key methods
262
+ * @param options - createFx options bag
263
+ * @param now - Clock
264
+ * @param gated - Capability gate
265
+ */
266
+ export function bind(
267
+ auth: FxAuthIdentity,
268
+ options: {
269
+ readonly tenantStore?: TenantStore;
270
+ readonly sessions?: SessionStore;
271
+ readonly sessionCrypto?: SessionCrypto;
272
+ readonly manifest?: Manifest;
273
+ },
274
+ now: () => number,
275
+ gated: AttachAuthTenantMethodsOptions["gated"],
276
+ ): FxAuthIdentity & FxAuthTenantMethods {
277
+ return attach({
278
+ auth,
279
+ store: options.tenantStore,
280
+ sessions: options.sessions,
281
+ crypto: options.sessionCrypto,
282
+ manifest: options.manifest ?? undefined,
283
+ now,
284
+ gated,
285
+ });
286
+ }