@cosmicdrift/kumiko-framework 0.264.0 → 0.265.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.264.0",
3
+ "version": "0.265.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -198,7 +198,7 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-types": "0.264.0",
201
+ "@cosmicdrift/kumiko-types": "0.265.0",
202
202
  "bullmq": "^5.76.7",
203
203
  "bun-types": "^1.3.13",
204
204
  "hono": "^4.13.1",
@@ -214,7 +214,7 @@
214
214
  "zod": "^4.4.3"
215
215
  },
216
216
  "devDependencies": {
217
- "@cosmicdrift/kumiko-dispatcher-live": "0.264.0",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.265.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
@@ -636,6 +636,10 @@ describe("POST /api/stream pre-pull race", () => {
636
636
  async resolveAuthClaims(): Promise<Record<string, unknown>> {
637
637
  return {};
638
638
  },
639
+ // Not exercised by this route — always "not_a_member" is fine.
640
+ async resolveActiveMembership() {
641
+ return { kind: "rejected", reason: "not_a_member" };
642
+ },
639
643
  };
640
644
  }
641
645
 
@@ -20,7 +20,7 @@ import { createJwtHelper } from "../jwt";
20
20
  const JWT_SECRET = "auth-routes-cookie-test-secret-min-32-characters";
21
21
 
22
22
  function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
23
- const base: Dispatcher = {
23
+ const merged: Dispatcher = {
24
24
  async write(): Promise<WriteResult> {
25
25
  // Explicit `const: WriteResult` locks the `isSuccess: true` branch of
26
26
  // the union without an `as`-cast (which widens + silences the
@@ -46,8 +46,27 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
46
46
  async resolveAuthClaims(): Promise<Record<string, unknown>> {
47
47
  return {};
48
48
  },
49
+ // Delegates to `merged.query` (not to a pre-override `base`) so the
50
+ // switch-tenant test's `query` override — which answers
51
+ // "tenant:query:memberships" — is what this sees too.
52
+ async resolveActiveMembership(userId, tenantId) {
53
+ const memberships = (await merged.query(
54
+ "tenant:query:memberships",
55
+ { userId },
56
+ TestUsers.user,
57
+ )) as ReadonlyArray<{
58
+ tenantId: string;
59
+ roles: readonly string[];
60
+ }>;
61
+ const membership = memberships.find((m) => m.tenantId === tenantId);
62
+ if (!membership) {
63
+ return { kind: "rejected", reason: "not_a_member" };
64
+ }
65
+ return { kind: "active", membership: { tenantId, roles: membership.roles } };
66
+ },
67
+ ...overrides,
49
68
  };
50
- return { ...base, ...overrides };
69
+ return merged;
51
70
  }
52
71
 
53
72
  async function buildApp(
@@ -33,6 +33,10 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
33
33
  async resolveAuthClaims(): Promise<Record<string, unknown>> {
34
34
  return {};
35
35
  },
36
+ // Not exercised by these routes — always "not_a_member" is fine.
37
+ async resolveActiveMembership() {
38
+ return { kind: "rejected", reason: "not_a_member" };
39
+ },
36
40
  // Stream API added with r.streamHandler (#1446) — stub unused in these routes.
37
41
  async *stream(): AsyncGenerator<unknown> {},
38
42
  };
@@ -45,6 +45,10 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
45
45
  async resolveAuthClaims(): Promise<Record<string, unknown>> {
46
46
  return {};
47
47
  },
48
+ // Not exercised by these routes — always "not_a_member" is fine.
49
+ async resolveActiveMembership() {
50
+ return { kind: "rejected", reason: "not_a_member" };
51
+ },
48
52
  };
49
53
  return { ...base, ...overrides };
50
54
  }
@@ -46,6 +46,10 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
46
46
  async resolveAuthClaims(): Promise<Record<string, unknown>> {
47
47
  return {};
48
48
  },
49
+ // Not exercised by these routes — always "not_a_member" is fine.
50
+ async resolveActiveMembership() {
51
+ return { kind: "rejected", reason: "not_a_member" };
52
+ },
49
53
  };
50
54
  return { ...base, ...overrides };
51
55
  }
@@ -45,6 +45,10 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
45
45
  async resolveAuthClaims(): Promise<Record<string, unknown>> {
46
46
  return {};
47
47
  },
48
+ // Not exercised by these routes — always "not_a_member" is fine.
49
+ async resolveActiveMembership() {
50
+ return { kind: "rejected", reason: "not_a_member" };
51
+ },
48
52
  };
49
53
  return { ...base, ...overrides };
50
54
  }
@@ -38,6 +38,10 @@ function failingDispatcher(): Dispatcher {
38
38
  async resolveAuthClaims(): Promise<Record<string, unknown>> {
39
39
  return {};
40
40
  },
41
+ // Not exercised by these routes — always "not_a_member" is fine.
42
+ async resolveActiveMembership() {
43
+ return { kind: "rejected", reason: "not_a_member" };
44
+ },
41
45
  };
42
46
  }
43
47
 
@@ -1,12 +1,15 @@
1
1
  // buildServer boot-time guards + httpRoute verb wiring (PUT branch).
2
2
 
3
3
  import { describe, expect, spyOn, test } from "bun:test";
4
+ import { z } from "zod";
4
5
  import {
5
6
  createEntity,
6
7
  createFileField,
7
8
  createRegistry,
8
9
  createTextField,
9
10
  defineFeature,
11
+ defineQueryHandler,
12
+ EXT_PRINCIPAL_STATUS,
10
13
  } from "../../engine";
11
14
  import { createInMemorySearchAdapter } from "../../search";
12
15
  import { buildServer } from "../server";
@@ -203,3 +206,60 @@ describe("buildServer — search-adapter boot warning (#2051)", () => {
203
206
  }
204
207
  });
205
208
  });
209
+
210
+ describe("buildServer — auth membershipQuery requires a principalStatus provider", () => {
211
+ const MEMBERSHIP_QN = "tenant:query:memberships";
212
+
213
+ // The registry auto-qualifies a SHORT handler name as `<feature>:query:<name>`,
214
+ // so the feature must be named "tenant" for MEMBERSHIP_QN to come out right.
215
+ const membershipQueryFeature = defineFeature("tenant", (r) => {
216
+ r.queryHandler(
217
+ defineQueryHandler({
218
+ name: "memberships",
219
+ schema: z.object({ userId: z.string() }),
220
+ handler: async () => [],
221
+ access: { roles: ["all"] },
222
+ }),
223
+ );
224
+ });
225
+
226
+ const principalStatusFeature = defineFeature("has-principal-status", (r) => {
227
+ r.extendsRegistrar(EXT_PRINCIPAL_STATUS, {});
228
+ r.useExtension(EXT_PRINCIPAL_STATUS, "has-principal-status", {
229
+ resolveStatus: async () => "active" as const,
230
+ });
231
+ });
232
+
233
+ test("throws when membershipQuery is registered but no feature provides principalStatus", () => {
234
+ expect(() =>
235
+ buildServer({
236
+ registry: createRegistry([membershipQueryFeature]),
237
+ context: {},
238
+ jwtSecret: JWT_SECRET,
239
+ auth: { membershipQuery: MEMBERSHIP_QN },
240
+ }),
241
+ ).toThrow(/no feature provides the "principalStatus" contract/);
242
+ });
243
+
244
+ test("boots when a feature provides principalStatus", () => {
245
+ expect(() =>
246
+ buildServer({
247
+ registry: createRegistry([membershipQueryFeature, principalStatusFeature]),
248
+ context: {},
249
+ jwtSecret: JWT_SECRET,
250
+ auth: { membershipQuery: MEMBERSHIP_QN },
251
+ }),
252
+ ).not.toThrow();
253
+ });
254
+
255
+ test("boots when the membershipQuery handler isn't registered at all (e.g. features:[] tests)", () => {
256
+ expect(() =>
257
+ buildServer({
258
+ registry: createRegistry([]),
259
+ context: {},
260
+ jwtSecret: JWT_SECRET,
261
+ auth: { membershipQuery: MEMBERSHIP_QN },
262
+ }),
263
+ ).not.toThrow();
264
+ });
265
+ });
@@ -1,5 +1,6 @@
1
1
  import type { Context, Next } from "hono";
2
2
  import { getCookie } from "hono/cookie";
3
+ import { TENANT_TEARDOWN_STATUSES } from "../engine/active-membership";
3
4
  import { createAnonymousUser } from "../engine/system-user";
4
5
  import type { SessionUser, TenantId } from "../engine/types";
5
6
  import { parseTenantId } from "../engine/types/identifiers";
@@ -592,12 +593,6 @@ type RejectArgs = {
592
593
  details?: Record<string, unknown>;
593
594
  };
594
595
 
595
- const TENANT_LIFECYCLE_BLOCKED = new Set([
596
- "destroyRequested",
597
- "destroying",
598
- "destroyFailed",
599
- "destroyed",
600
- ]);
601
596
  const TENANT_LIFECYCLE_CANCEL_QN = "tenant-lifecycle:write:cancel-destruction";
602
597
 
603
598
  async function requestsCancelDestruction(c: Context): Promise<boolean> {
@@ -634,7 +629,7 @@ async function rejectIfTenantTeardown(
634
629
  ): Promise<Response | undefined> {
635
630
  if (!resolveTenantLifecycleStatus) return undefined;
636
631
  const lifecycle = await resolveTenantLifecycleStatus(tenantId);
637
- if (!lifecycle || !TENANT_LIFECYCLE_BLOCKED.has(lifecycle.status)) return undefined;
632
+ if (!lifecycle || !TENANT_TEARDOWN_STATUSES.has(lifecycle.status)) return undefined;
638
633
  if (lifecycle.status === "destroyRequested" && (await requestsCancelDestruction(c))) {
639
634
  return undefined;
640
635
  }
@@ -6,9 +6,16 @@ import type Redis from "ioredis";
6
6
  import { z } from "zod";
7
7
  import { buildSessionRoles } from "../engine/membership-roles";
8
8
  import { createSystemUser } from "../engine/system-user";
9
- import { type SessionUser, SYSTEM_TENANT_ID, type TenantId } from "../engine/types";
9
+ import {
10
+ type ActiveMembershipRejection,
11
+ type ActiveMembershipResult,
12
+ type SessionUser,
13
+ SYSTEM_TENANT_ID,
14
+ type TenantId,
15
+ } from "../engine/types";
10
16
  import { NotFoundError } from "../errors";
11
17
  import type { Dispatcher } from "../pipeline/dispatcher";
18
+ import { assertUnreachable } from "../utils";
12
19
  import { parseRoles } from "../utils/serialization";
13
20
  import { Routes } from "./api-constants";
14
21
  import {
@@ -167,6 +174,19 @@ function isUnknownHandlerError(e: unknown): boolean {
167
174
  return details?.entity === "handler";
168
175
  }
169
176
 
177
+ function switchTenantRejectionResponse(c: Context, reason: ActiveMembershipRejection): Response {
178
+ switch (reason) {
179
+ case "not_a_member":
180
+ return c.json({ error: "not_a_member" }, 403);
181
+ case "principal_blocked":
182
+ return c.json({ error: "principal_blocked" }, 403);
183
+ case "tenant_teardown":
184
+ return c.json({ error: "tenant_unavailable" }, 410);
185
+ default:
186
+ return assertUnreachable(reason, "ActiveMembershipRejection");
187
+ }
188
+ }
189
+
170
190
  type MembershipRow = {
171
191
  userId: string;
172
192
  tenantId: TenantId;
@@ -1324,17 +1344,11 @@ export function createAuthRoutes(
1324
1344
  return c.json({ error: "already_in_tenant" }, 400);
1325
1345
  }
1326
1346
 
1327
- // Check membership — uses the system identity because membershipQuery is
1328
- // locked to the system role. The auth-route is trusted server code; it
1329
- // asks the question on the user's behalf, not as the user.
1330
- let memberships: MembershipRow[];
1347
+ // Membership + principal-blocked + tenant-teardown in one building
1348
+ // block — trusted server code asking on the user's behalf, same trust boundary as before.
1349
+ let activeMembership: ActiveMembershipResult;
1331
1350
  try {
1332
- // @cast-boundary engine-payload — generic dispatcher.query result
1333
- memberships = (await dispatcher.query(
1334
- config.membershipQuery,
1335
- { userId: user.id },
1336
- createSystemUser(user.tenantId),
1337
- )) as MembershipRow[];
1351
+ activeMembership = await dispatcher.resolveActiveMembership(user.id, targetTenantId);
1338
1352
  } catch (e) {
1339
1353
  // No membershipQuery wired → switching tenants is just not offered in
1340
1354
  // this deployment. Any other error propagates so a broken query handler
@@ -1343,10 +1357,10 @@ export function createAuthRoutes(
1343
1357
  return c.json({ error: "tenant_switch_not_available" }, 400);
1344
1358
  }
1345
1359
 
1346
- const membership = memberships.find((m) => m.tenantId === targetTenantId);
1347
- if (!membership) {
1348
- return c.json({ error: "not_a_member" }, 403);
1360
+ if (activeMembership.kind === "rejected") {
1361
+ return switchTenantRejectionResponse(c, activeMembership.reason);
1349
1362
  }
1363
+ const membership = activeMembership.membership;
1350
1364
 
1351
1365
  // Globale Rollen aus user-feature lesen wenn userQuery wired —
1352
1366
  // tenant-unabhängige Rollen (SystemAdmin etc.) überleben so den
package/src/api/server.ts CHANGED
@@ -2,7 +2,7 @@ import { Hono } from "hono";
2
2
  import type { DbConnection, PgClient } from "../db/connection";
3
3
  import { createTenantDb } from "../db/tenant-db";
4
4
  import { createDerivativesContext } from "../derivatives/derivatives-context";
5
- import { EXT_FILE_PROVIDER } from "../engine/extension-names";
5
+ import { EXT_FILE_PROVIDER, EXT_PRINCIPAL_STATUS } from "../engine/extension-names";
6
6
  import { runsInLane } from "../engine/run-in";
7
7
  import { createAnonymousUser } from "../engine/system-user";
8
8
  import {
@@ -443,6 +443,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
443
443
  ...options.dispatcherOptions,
444
444
  lifecycle,
445
445
  sseBroker,
446
+ ...(options.auth ? { membershipQuery: options.auth.membershipQuery } : {}),
446
447
  });
447
448
 
448
449
  // Async event-dispatcher — the replacement for the old transactional
@@ -756,6 +757,18 @@ export function buildServer(options: ServerOptions): KumikoServer {
756
757
  // Public auth routes (login) need to be registered BEFORE the generic
757
758
  // api routes so Hono matches them first.
758
759
  if (options.auth) {
760
+ // A membershipQuery handler is registered but nothing fulfils
761
+ // principalStatus — surface that misconfig at boot instead of an InternalError on first login/switch-tenant.
762
+ if (
763
+ options.registry.getQueryHandler(options.auth.membershipQuery) &&
764
+ options.registry.getExtensionUsages(EXT_PRINCIPAL_STATUS).length === 0
765
+ ) {
766
+ throw new Error(
767
+ `[kumiko] auth membershipQuery "${options.auth.membershipQuery}" is registered but no feature ` +
768
+ 'provides the "principalStatus" contract — mount the user feature (tenant switch and sign-in ' +
769
+ "need it to reject blocked principals).",
770
+ );
771
+ }
759
772
  app.route("/api", createAuthRoutes(dispatcher, jwt, options.auth));
760
773
  }
761
774
  app.route(
package/src/changes.json CHANGED
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.265.0",
4
+ "type": "improvement",
5
+ "title": "Active-membership building block (resolveActiveMembership) composes membership + principalStatus + tenant-lifecycle checks (fw#2872).",
6
+ "detail": "New `dispatcher.resolveActiveMembership(userId, tenantId)` / `ctx.resolveActiveMembership` returns an `ActiveMembershipResult` — either `active` with the raw membership roles, or `rejected` with reason `not_a_member` | `principal_blocked` | `tenant_teardown` (membership is checked first, so a non-member can't learn a foreign tenant's lifecycle/blocked state). It composes two new framework contracts, `EXT_PRINCIPAL_STATUS` (fulfilled by the bundled `user` feature) and `EXT_TENANT_LIFECYCLE_STATUS` (fulfilled by `tenant-lifecycle`), plus `TENANT_TEARDOWN_STATUSES` and the default `TENANT_MEMBERSHIPS_QUERY` handler name, and is now used by switch-tenant, login and auth-mfa verify/enable-confirm-preauth instead of each independently composing membership + status checks. `ctx.resolveActiveMembership` requires the same SYSTEM-identity grant as `ctx.queryAs`/`ctx.writeAs` with a system user (`r.systemScope()` or a declared `escapeHatch`), since it queries memberships as SYSTEM internally. Behaviourally: `POST /api/auth/switch-tenant` now answers 403 `principal_blocked` for a blocked principal and 410 `tenant_unavailable` for a target tenant in teardown (a tenant in `destroyRequested` is still allowed so its owner can cancel destruction); login skips a last-active tenant that is in teardown and falls through to the next active membership; MFA verify and enable-confirm-preauth reject a tenant in teardown; `buildServer` now throws at boot when the auth `membershipQuery` handler is registered but no feature provides the `principalStatus` contract (mount the bundled `user` feature); and `isPrincipalBlocked` now lives in `user` (still re-exported from `sessions` for existing importers).",
7
+ "migration": "No action for apps mounting the bundled `user` feature (runProdApp/runDevApp always do). A custom stack that registers the membership query without the `user` feature must mount `user` or register an `r.useExtension(EXT_PRINCIPAL_STATUS, ...)` provider, otherwise buildServer throws at boot. Clients of POST /api/auth/switch-tenant should handle the new 403 `principal_blocked` and 410 `tenant_unavailable` responses. A handler calling `ctx.resolveActiveMembership` needs an `escapeHatch` declaration (or `r.systemScope()`) the same way it would for a SYSTEM `queryAs`/`writeAs` call."
8
+ },
2
9
  {
3
10
  "version": "0.263.0",
4
11
  "type": "breaking",
@@ -1,8 +1,12 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
- import { AccessDeniedError } from "../../errors";
3
- import { createGatedIdentitySwitch, type QueryAsFn } from "../../pipeline/system-identity-switch";
2
+ import { AccessDeniedError, FrameworkReasons } from "../../errors";
3
+ import {
4
+ createGatedIdentitySwitch,
5
+ type QueryAsFn,
6
+ type ResolveActiveMembershipFn,
7
+ } from "../../pipeline/system-identity-switch";
4
8
  import { createEntity, createRegistry, createSystemUser, defineFeature } from "../index";
5
- import type { AppContext, PostSaveHookFn, SaveContext } from "../types";
9
+ import type { ActiveMembershipResult, AppContext, PostSaveHookFn, SaveContext } from "../types";
6
10
  import type { TenantId } from "../types/identifiers";
7
11
 
8
12
  // fw#2859: r.hook({ allOf }, ...) must get the same identity-switch gate as the handler-keyed path.
@@ -102,4 +106,81 @@ describe("r.hook({ allOf }, ...) identity-switch gate", () => {
102
106
  });
103
107
  }).toThrow(/non-empty string/);
104
108
  });
109
+
110
+ test("entity-wide hook WITHOUT escapeHatch does not inherit the handler's resolveActiveMembership grant", async () => {
111
+ const activeResult: ActiveMembershipResult = {
112
+ kind: "active",
113
+ membership: { tenantId: TENANT, roles: [] },
114
+ };
115
+ const ungatedResolveActiveMembership = mock(async () => activeResult);
116
+ const handlerCtx = {
117
+ ...createGatedIdentitySwitch('handler "test:write:thing:create"', true, {
118
+ queryAs: mock(async () => "ok") as QueryAsFn,
119
+ writeAs: async () => ({ isSuccess: true as const, data: null }),
120
+ }),
121
+ resolveActiveMembership: ungatedResolveActiveMembership as ResolveActiveMembershipFn,
122
+ };
123
+
124
+ let caught: unknown;
125
+ const feature = defineFeature("test", (r) => {
126
+ const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
127
+ const hookFn: PostSaveHookFn = async (_result, ctx) => {
128
+ const asContext = ctx as unknown as { resolveActiveMembership: ResolveActiveMembershipFn };
129
+ try {
130
+ await asContext.resolveActiveMembership("user-1", TENANT);
131
+ } catch (err) {
132
+ caught = err;
133
+ }
134
+ };
135
+ r.hook("postSave", { allOf: thing }, hookFn);
136
+ });
137
+
138
+ const registry = createRegistry([feature]);
139
+ const hooks = registry.getEntityPostSaveHooks("thing");
140
+ expect(hooks).toHaveLength(1);
141
+
142
+ await hooks[0]?.(dummySaveContext, handlerCtx as unknown as AppContext);
143
+
144
+ expect(caught).toBeInstanceOf(AccessDeniedError);
145
+ expect((caught as AccessDeniedError).details).toEqual({
146
+ reason: FrameworkReasons.systemIdentitySwitchDenied,
147
+ });
148
+ expect(ungatedResolveActiveMembership).not.toHaveBeenCalled();
149
+ });
150
+
151
+ test("entity-wide hook WITH escapeHatch reaches the original resolveActiveMembership even when the handler's own grant was false", async () => {
152
+ const activeResult: ActiveMembershipResult = {
153
+ kind: "active",
154
+ membership: { tenantId: TENANT, roles: ["User"] },
155
+ };
156
+ const ungatedResolveActiveMembership = mock(async () => activeResult);
157
+ const handlerCtx = {
158
+ ...createGatedIdentitySwitch('handler "test:write:thing:create"', false, {
159
+ queryAs: mock(async () => "ok") as QueryAsFn,
160
+ writeAs: async () => ({ isSuccess: true as const, data: null }),
161
+ }),
162
+ resolveActiveMembership: ungatedResolveActiveMembership as ResolveActiveMembershipFn,
163
+ };
164
+
165
+ let resolveResult: unknown;
166
+ const feature = defineFeature("test", (r) => {
167
+ const thing = r.entity("thing", createEntity({ table: "things", fields: {} }));
168
+ const hookFn: PostSaveHookFn = async (_result, ctx) => {
169
+ const asContext = ctx as unknown as { resolveActiveMembership: ResolveActiveMembershipFn };
170
+ resolveResult = await asContext.resolveActiveMembership("user-1", TENANT);
171
+ };
172
+ r.hook("postSave", { allOf: thing }, hookFn, {
173
+ escapeHatch: { reason: "test: allOf hook needs SYSTEM" },
174
+ });
175
+ });
176
+
177
+ const registry = createRegistry([feature]);
178
+ const hooks = registry.getEntityPostSaveHooks("thing");
179
+ expect(hooks).toHaveLength(1);
180
+
181
+ await hooks[0]?.(dummySaveContext, handlerCtx as unknown as AppContext);
182
+
183
+ expect(resolveResult).toEqual(activeResult);
184
+ expect(ungatedResolveActiveMembership).toHaveBeenCalledWith("user-1", TENANT);
185
+ });
105
186
  });
@@ -0,0 +1,56 @@
1
+ // principalStatus / tenantLifecycleStatus contracts consumed by
2
+ // pipeline/active-membership.ts. Kept in engine/ (extension-registry surface), same split as tier-resolver-extension.ts.
3
+
4
+ import type { DbConnection } from "../db/connection";
5
+ import type { TenantId } from "./types/identifiers";
6
+
7
+ // "unknown" = no persisted principal row for this userId — the caller
8
+ // (INTERACTIVE_SIGN_IN_POLICY.allowUnknownPrincipal) decides whether that
9
+ // counts as active or blocked; a background resolver for a stored userId
10
+ // with a since-deleted row should not silently treat it as active.
11
+ export type PrincipalStatus = "active" | "blocked" | "unknown";
12
+
13
+ // Registered via r.useExtension(EXT_PRINCIPAL_STATUS, "user", principalStatusPlugin).
14
+ export type PrincipalStatusPlugin = {
15
+ readonly resolveStatus: (
16
+ userId: string,
17
+ deps: { readonly db: DbConnection },
18
+ ) => Promise<PrincipalStatus>;
19
+ };
20
+
21
+ // `null` means the tenant has no lifecycle row (never entered teardown).
22
+ export type TenantLifecycleStatusPlugin = {
23
+ readonly resolveStatus: (
24
+ tenantId: TenantId,
25
+ deps: { readonly db: DbConnection },
26
+ ) => Promise<{ readonly status: string } | null>;
27
+ };
28
+
29
+ // extension-usage `options` is engine-payload (unknown) — structurally
30
+ // validate instead of casting blind, same pattern as isFileProviderPlugin.
31
+ function hasResolveStatusFn(v: unknown): v is { readonly resolveStatus: unknown } {
32
+ return (
33
+ typeof v === "object" &&
34
+ v !== null &&
35
+ "resolveStatus" in v &&
36
+ typeof v.resolveStatus === "function"
37
+ );
38
+ }
39
+
40
+ export function isPrincipalStatusPlugin(v: unknown): v is PrincipalStatusPlugin {
41
+ return hasResolveStatusFn(v);
42
+ }
43
+
44
+ export function isTenantLifecycleStatusPlugin(v: unknown): v is TenantLifecycleStatusPlugin {
45
+ return hasResolveStatusFn(v);
46
+ }
47
+
48
+ // Same values auth-middleware.ts used to keep as its own private
49
+ // TENANT_LIFECYCLE_BLOCKED set — moved here so the framework's active-
50
+ // membership check and the request-level 410 gate can't drift apart.
51
+ export const TENANT_TEARDOWN_STATUSES: ReadonlySet<string> = new Set([
52
+ "destroyRequested",
53
+ "destroying",
54
+ "destroyFailed",
55
+ "destroyed",
56
+ ]);
@@ -155,6 +155,22 @@ export const EXT_EXTERNAL_RESOURCE = "externalResource" as const;
155
155
  */
156
156
  export const EXT_INFRA_RESOURCE = "infraResource" as const;
157
157
 
158
+ /**
159
+ * `principalStatus` — "is this principal blocked from signing in" contract,
160
+ * consulted by the active-membership building block; fulfilled by the bundled `user` feature.
161
+ */
162
+ export const EXT_PRINCIPAL_STATUS = "principalStatus" as const;
163
+
164
+ /**
165
+ * `tenantLifecycleStatus` — tenant-teardown status contract, fulfilled by
166
+ * the bundled `tenant-lifecycle` feature; absent means no tenant is ever "in teardown".
167
+ */
168
+ export const EXT_TENANT_LIFECYCLE_STATUS = "tenantLifecycleStatus" as const;
169
+
170
+ // Default membership-list query handler name. Accepted coupling, same
171
+ // pattern as FILE_PROVIDER_CONFIG_KEY — the `tenant` feature registers a handler under this literal.
172
+ export const TENANT_MEMBERSHIPS_QUERY = "tenant:query:memberships" as const;
173
+
158
174
  /**
159
175
  * Union aller standardisierten Extension-Namen der Datenschutz-Surface.
160
176
  * Nicht alle Extensions im System sind in dieser Liste — andere
@@ -1,6 +1,14 @@
1
1
  // Public API
2
2
 
3
3
  export { hasAccess } from "./access";
4
+ export {
5
+ isPrincipalStatusPlugin,
6
+ isTenantLifecycleStatusPlugin,
7
+ type PrincipalStatus,
8
+ type PrincipalStatusPlugin,
9
+ TENANT_TEARDOWN_STATUSES,
10
+ type TenantLifecycleStatusPlugin,
11
+ } from "./active-membership";
4
12
  export {
5
13
  collectWriteHandlerQns,
6
14
  SECURITY_BASELINE_FEATURE_NAMES,
@@ -90,12 +98,15 @@ export {
90
98
  EXT_EXTERNAL_RESOURCE,
91
99
  EXT_FILE_PROVIDER,
92
100
  EXT_INFRA_RESOURCE,
101
+ EXT_PRINCIPAL_STATUS,
93
102
  EXT_SEARCH_ADAPTER,
94
103
  EXT_STORAGE_PROVIDER,
95
104
  EXT_TENANT_DATA,
105
+ EXT_TENANT_LIFECYCLE_STATUS,
96
106
  EXT_USER_DATA,
97
107
  EXT_USER_DATA_ORDER,
98
108
  FILE_PROVIDER_CONFIG_KEY,
109
+ TENANT_MEMBERSHIPS_QUERY,
99
110
  } from "./extension-names";
100
111
  export type {
101
112
  StorageProviderDestroyTenantHook,
@@ -283,6 +294,9 @@ export {
283
294
  export type {
284
295
  AccessRule,
285
296
  ActionFormScreenDefinition,
297
+ ActiveMembership,
298
+ ActiveMembershipRejection,
299
+ ActiveMembershipResult,
286
300
  AgentExposure,
287
301
  AgentHandlerHints,
288
302
  AgentRisk,
@@ -138,6 +138,9 @@ export type {
138
138
  } from "@cosmicdrift/kumiko-types/fields";
139
139
  export type {
140
140
  AccessRule,
141
+ ActiveMembership,
142
+ ActiveMembershipRejection,
143
+ ActiveMembershipResult,
141
144
  AgentExposure,
142
145
  AgentHandlerHints,
143
146
  AgentRisk,
@@ -75,6 +75,24 @@ const probeFeature = defineFeature("idswitch-probe", (r) => {
75
75
  access: { roles: ["Admin"] },
76
76
  });
77
77
 
78
+ // --- ctx.resolveActiveMembership: gated the same as a SYSTEM queryAs/writeAs ---
79
+ r.queryHandler(
80
+ "resolve-active-membership-no-hatch",
81
+ z.object({}),
82
+ async (query, ctx) => ctx.resolveActiveMembership(query.user.id, query.user.tenantId),
83
+ { access: { roles: ["User"] } },
84
+ );
85
+
86
+ r.queryHandler(
87
+ "resolve-active-membership-with-hatch",
88
+ z.object({}),
89
+ async (query, ctx) => ctx.resolveActiveMembership(query.user.id, query.user.tenantId),
90
+ {
91
+ access: { roles: ["User"] },
92
+ escapeHatch: { reason: "test: needs SYSTEM to resolve membership" },
93
+ },
94
+ );
95
+
78
96
  // --- Direct SYSTEM switch: gated by the CALLING handler's own escapeHatch ---
79
97
  r.writeHandler(
80
98
  "write-as-system-no-hatch",
@@ -276,6 +294,26 @@ describe("ctx.queryAs(SYSTEM, ...) — gated by the calling handler's escapeHatc
276
294
  });
277
295
  });
278
296
 
297
+ describe("ctx.resolveActiveMembership — gated like a SYSTEM queryAs/writeAs", () => {
298
+ test("WITHOUT escapeHatch: fails with access_denied", async () => {
299
+ const err = await stack.http.queryErr(
300
+ "idswitch-probe:query:resolve-active-membership-no-hatch",
301
+ {},
302
+ user,
303
+ );
304
+ expect(err.code).toBe("access_denied");
305
+ });
306
+
307
+ test("WITH escapeHatch: the SYSTEM-identity gate lets the call through (no membershipQuery wired here, so it fails downstream instead)", async () => {
308
+ const err = await stack.http.queryErr(
309
+ "idswitch-probe:query:resolve-active-membership-with-hatch",
310
+ {},
311
+ user,
312
+ );
313
+ expect(err.code).not.toBe("access_denied");
314
+ });
315
+ });
316
+
279
317
  describe("ctx.queryAs(nonSystemUser, ...) — never gated by this feature", () => {
280
318
  test("delegates to the target's own access check: denied when the target user lacks the role", async () => {
281
319
  const err = await stack.http.queryErr(
@@ -0,0 +1,178 @@
1
+ // One framework-owned composition of membership / principal-status /
2
+ // tenant-lifecycle checks so login, switch-tenant and MFA completion can't independently drift.
3
+
4
+ import type { DbConnection } from "../db/connection";
5
+ import {
6
+ isPrincipalStatusPlugin,
7
+ isTenantLifecycleStatusPlugin,
8
+ type PrincipalStatusPlugin,
9
+ TENANT_TEARDOWN_STATUSES,
10
+ type TenantLifecycleStatusPlugin,
11
+ } from "../engine/active-membership";
12
+ import { EXT_PRINCIPAL_STATUS, EXT_TENANT_LIFECYCLE_STATUS } from "../engine/extension-names";
13
+ import { createSystemUser } from "../engine/system-user";
14
+ import type { ActiveMembershipResult, Registry } from "../engine/types";
15
+ import type { TenantId } from "../engine/types/identifiers";
16
+ import { InternalError } from "../errors";
17
+ import { executeQuery } from "./dispatch-query";
18
+ import { type DispatchContext, resolveDbSource } from "./dispatch-shared";
19
+
20
+ export type ActiveMembershipPolicy = {
21
+ // destroyRequested still counts as active — owners must be able to cancel
22
+ // destruction; auth-middleware's own 410 gate then only admits that write.
23
+ readonly allowPendingDestruction: boolean;
24
+ // Whether a userId with no persisted principal row counts as active — a
25
+ // future background caller may instead want to fail closed.
26
+ readonly allowUnknownPrincipal: boolean;
27
+ };
28
+
29
+ export const INTERACTIVE_SIGN_IN_POLICY: ActiveMembershipPolicy = {
30
+ allowPendingDestruction: true,
31
+ allowUnknownPrincipal: true,
32
+ };
33
+
34
+ type RawMembershipRow = {
35
+ readonly tenantId: string;
36
+ readonly roles: readonly string[];
37
+ readonly tenantName?: string;
38
+ readonly tenantKey?: string;
39
+ };
40
+
41
+ // membershipQuery is a per-app-registered handler — engine-payload, not
42
+ // trusted shape. Structural validation instead of a blind cast.
43
+ function isMembershipRow(v: unknown): v is RawMembershipRow {
44
+ if (typeof v !== "object" || v === null) return false;
45
+ if (!("tenantId" in v) || typeof v.tenantId !== "string") return false;
46
+ if (!("roles" in v) || !Array.isArray(v.roles) || !v.roles.every((r) => typeof r === "string")) {
47
+ return false;
48
+ }
49
+ if ("tenantName" in v && v.tenantName !== undefined && typeof v.tenantName !== "string") {
50
+ return false;
51
+ }
52
+ if ("tenantKey" in v && v.tenantKey !== undefined && typeof v.tenantKey !== "string") {
53
+ return false;
54
+ }
55
+ return true;
56
+ }
57
+
58
+ async function findMembership(
59
+ ctx: DispatchContext,
60
+ userId: string,
61
+ tenantId: TenantId,
62
+ ): Promise<RawMembershipRow | undefined> {
63
+ const rawMemberships = await executeQuery(
64
+ ctx,
65
+ ctx.membershipQuery,
66
+ { userId },
67
+ createSystemUser(tenantId),
68
+ );
69
+ if (!Array.isArray(rawMemberships) || !rawMemberships.every(isMembershipRow)) {
70
+ throw new InternalError({
71
+ message:
72
+ `dispatcher.resolveActiveMembership: membershipQuery "${ctx.membershipQuery}" returned ` +
73
+ "a malformed result — expected an array of {tenantId, roles, tenantName?, tenantKey?} rows.",
74
+ });
75
+ }
76
+ return rawMemberships.find((m) => m.tenantId === tenantId);
77
+ }
78
+
79
+ function resolvePrincipalPlugin(registry: Registry): PrincipalStatusPlugin {
80
+ const usages = registry.getExtensionUsages(EXT_PRINCIPAL_STATUS);
81
+ if (usages.length !== 1) {
82
+ throw new InternalError({
83
+ message:
84
+ usages.length === 0
85
+ ? `dispatcher.resolveActiveMembership: no "${EXT_PRINCIPAL_STATUS}" provider registered — mount the user feature.`
86
+ : `dispatcher.resolveActiveMembership: multiple "${EXT_PRINCIPAL_STATUS}" providers registered — exactly one is expected.`,
87
+ });
88
+ }
89
+ const usage = usages[0];
90
+ if (!usage || !isPrincipalStatusPlugin(usage.options)) {
91
+ throw new InternalError({
92
+ message: `dispatcher.resolveActiveMembership: "${usage?.entityName}" registered under "${EXT_PRINCIPAL_STATUS}" without a resolveStatus(userId, {db}) — extension options must be a PrincipalStatusPlugin.`,
93
+ });
94
+ }
95
+ return usage.options;
96
+ }
97
+
98
+ function resolveLifecyclePlugin(registry: Registry): TenantLifecycleStatusPlugin | undefined {
99
+ const usages = registry.getExtensionUsages(EXT_TENANT_LIFECYCLE_STATUS);
100
+ if (usages.length > 1) {
101
+ throw new InternalError({
102
+ message: `dispatcher.resolveActiveMembership: multiple "${EXT_TENANT_LIFECYCLE_STATUS}" providers registered — exactly one (or zero) is expected.`,
103
+ });
104
+ }
105
+ const usage = usages[0];
106
+ if (!usage) return undefined;
107
+ if (!isTenantLifecycleStatusPlugin(usage.options)) {
108
+ throw new InternalError({
109
+ message: `dispatcher.resolveActiveMembership: "${usage.entityName}" registered under "${EXT_TENANT_LIFECYCLE_STATUS}" without a resolveStatus(tenantId, {db}) — extension options must be a TenantLifecycleStatusPlugin.`,
110
+ });
111
+ }
112
+ return usage.options;
113
+ }
114
+
115
+ function isTeardownRejected(
116
+ status: { readonly status: string } | null,
117
+ policy: ActiveMembershipPolicy,
118
+ ): boolean {
119
+ return (
120
+ status !== null &&
121
+ TENANT_TEARDOWN_STATUSES.has(status.status) &&
122
+ !(status.status === "destroyRequested" && policy.allowPendingDestruction)
123
+ );
124
+ }
125
+
126
+ // Order is load-bearing: a non-member must never learn a foreign tenant's
127
+ // lifecycle/blocked state, so membership is resolved and checked FIRST.
128
+ export async function resolveActiveMembershipFn(
129
+ ctx: DispatchContext,
130
+ userId: string,
131
+ tenantId: TenantId,
132
+ policy: ActiveMembershipPolicy,
133
+ ): Promise<ActiveMembershipResult> {
134
+ const { registry } = ctx;
135
+
136
+ const membership = await findMembership(ctx, userId, tenantId);
137
+ if (!membership) {
138
+ return { kind: "rejected", reason: "not_a_member" };
139
+ }
140
+
141
+ const principalPlugin = resolvePrincipalPlugin(registry);
142
+ const dbSource = resolveDbSource(ctx, undefined);
143
+ if (!dbSource) {
144
+ throw new InternalError({
145
+ message:
146
+ "dispatcher.resolveActiveMembership requires a database connection — none is configured.",
147
+ });
148
+ }
149
+ // resolveDbSource(ctx, undefined) never returns a tx, only AppContext.db —
150
+ // the dispatcher boundary holds that as the root DbConnection, same as buildAuthClaimsContext.
151
+ const db = dbSource as DbConnection; // @cast-boundary db-operator
152
+
153
+ const principalStatus = await principalPlugin.resolveStatus(userId, { db });
154
+ if (
155
+ principalStatus === "blocked" ||
156
+ (principalStatus === "unknown" && !policy.allowUnknownPrincipal)
157
+ ) {
158
+ return { kind: "rejected", reason: "principal_blocked" };
159
+ }
160
+
161
+ const lifecyclePlugin = resolveLifecyclePlugin(registry);
162
+ if (lifecyclePlugin) {
163
+ const lifecycle = await lifecyclePlugin.resolveStatus(tenantId, { db });
164
+ if (isTeardownRejected(lifecycle, policy)) {
165
+ return { kind: "rejected", reason: "tenant_teardown" };
166
+ }
167
+ }
168
+
169
+ return {
170
+ kind: "active",
171
+ membership: {
172
+ tenantId,
173
+ roles: membership.roles,
174
+ ...(membership.tenantName !== undefined ? { tenantName: membership.tenantName } : {}),
175
+ ...(membership.tenantKey !== undefined ? { tenantKey: membership.tenantKey } : {}),
176
+ },
177
+ };
178
+ }
@@ -64,6 +64,7 @@ import {
64
64
  } from "../observability";
65
65
  import { buildBucketKey } from "../rate-limit";
66
66
  import { createTzContext, isValidIanaTimeZone } from "../time";
67
+ import { INTERACTIVE_SIGN_IN_POLICY, resolveActiveMembershipFn } from "./active-membership";
67
68
  import { appendDomainEventCore } from "./append-event-core";
68
69
  import { resolveAuthClaims as runAuthClaimsResolver } from "./auth-claims-resolver";
69
70
  import { executeQuery } from "./dispatch-query";
@@ -75,7 +76,7 @@ import {
75
76
  } from "./dispatcher-utils";
76
77
  import type { IdempotencyGuard } from "./idempotency";
77
78
  import type { LifecycleHooks } from "./lifecycle-pipeline";
78
- import { createGatedIdentitySwitch } from "./system-identity-switch";
79
+ import { createGatedIdentitySwitch, systemIdentitySwitchDenied } from "./system-identity-switch";
79
80
  import type { TenantTimezoneCache } from "./tenant-timezone-cache";
80
81
 
81
82
  // Framework/pipeline stays bundled-features-free, so this can't import the
@@ -123,6 +124,9 @@ export type DispatchContext = {
123
124
  tenantTimezoneCache: TenantTimezoneCache;
124
125
  tracer: ReturnType<typeof getFallbackTracer>;
125
126
  meter: ReturnType<typeof getFallbackMeter>;
127
+ // Qualified name of the membership-list query handler consulted by
128
+ // resolveActiveMembershipFn — defaults to TENANT_MEMBERSHIPS_QUERY, overridable via DispatcherOptions.
129
+ membershipQuery: string;
126
130
  };
127
131
 
128
132
  // Narrowing-helper: AppContext.db ist DbConnection|TenantDb|undefined. Die
@@ -591,6 +595,15 @@ export async function buildHandlerContext(
591
595
  // dispatcher.resolveAuthClaims) cannot drift.
592
596
  resolveAuthClaims: (claimsUser: SessionUser) => resolveAuthClaimsFn(ctx, claimsUser), // @wrapper-known semantic-alias
593
597
 
598
+ // Thin pass-through, same reasoning as resolveAuthClaims above — one
599
+ // resolve impl lives on the dispatcher so callers can't drift apart.
600
+ // Internally queries memberships as SYSTEM, so it needs the same grant
601
+ // as a SYSTEM queryAs/writeAs (r.systemScope() or escapeHatch).
602
+ resolveActiveMembership: (userId: string, tenantId: TenantId) => {
603
+ if (!allowSystemIdentity) throw systemIdentitySwitchDenied(`handler "${type}"`);
604
+ return resolveActiveMembershipFn(ctx, userId, tenantId, INTERACTIVE_SIGN_IN_POLICY); // @wrapper-known semantic-alias
605
+ },
606
+
594
607
  // Feature-effective check for in-handler opt-in logic. Scope:
595
608
  // **current user's tenant** — for cross-tenant lookups (rare,
596
609
  // SysAdmin operations) read effectiveFeatures(otherTenantId) directly.
@@ -1,10 +1,20 @@
1
1
  import type { SseBroker } from "../api/sse-broker";
2
2
  import type { buildEntityTable } from "../db/table-builder";
3
+ import { TENANT_MEMBERSHIPS_QUERY } from "../engine/extension-names";
3
4
  import type { defineTransitions } from "../engine/state-machine";
4
5
  import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
5
- import type { AppContext, JobRunnerRef, Registry, SessionUser, WriteResult } from "../engine/types";
6
+ import type {
7
+ ActiveMembershipResult,
8
+ AppContext,
9
+ JobRunnerRef,
10
+ Registry,
11
+ SessionUser,
12
+ WriteResult,
13
+ } from "../engine/types";
14
+ import type { TenantId } from "../engine/types/identifiers";
6
15
  import { reraiseAsKumikoError } from "../errors";
7
16
  import { getFallbackMeter, getFallbackTracer, registerStandardMetrics } from "../observability";
17
+ import { INTERACTIVE_SIGN_IN_POLICY, resolveActiveMembershipFn } from "./active-membership";
8
18
  import { runBatch, unwrapSingle } from "./dispatch-batch";
9
19
  import { executeQuery } from "./dispatch-query";
10
20
  import type { BatchCommand, BatchResult, DispatchContext } from "./dispatch-shared";
@@ -25,6 +35,9 @@ export type DispatcherOptions = {
25
35
  idempotency?: IdempotencyGuard;
26
36
  lifecycle?: LifecycleHooks;
27
37
  jobRunner?: JobRunnerRef;
38
+ // Qualified name of the membership-list query handler consulted by
39
+ // dispatcher.resolveActiveMembership. Defaults to TENANT_MEMBERSHIPS_QUERY.
40
+ membershipQuery?: string;
28
41
  // Resolves the effective-feature set per tenant — the dispatcher uses
29
42
  // it to gate calls to handlers of disabled features (403 feature_disabled)
30
43
  // and to populate ctx.hasFeature. Absent = all features treated as
@@ -81,6 +94,9 @@ export type Dispatcher = {
81
94
  // This is the single resolve implementation — ctx.resolveAuthClaims is a
82
95
  // thin pass-through so both entry points can't drift.
83
96
  resolveAuthClaims(user: SessionUser): Promise<Record<string, unknown>>;
97
+ // Membership check for interactive sign-in paths (login, MFA completion,
98
+ // tenant switch) — single resolve implementation so callers can't independently drift.
99
+ resolveActiveMembership(userId: string, tenantId: TenantId): Promise<ActiveMembershipResult>;
84
100
  };
85
101
 
86
102
  export function createDispatcher(
@@ -89,6 +105,7 @@ export function createDispatcher(
89
105
  options: DispatcherOptions = {},
90
106
  ): Dispatcher {
91
107
  const { idempotency, lifecycle, jobRunner, effectiveFeatures, sseBroker } = options;
108
+ const membershipQuery = options.membershipQuery ?? TENANT_MEMBERSHIPS_QUERY;
92
109
 
93
110
  // Pre-build tables and transition maps for auto-guard (avoid per-request allocation)
94
111
  const tableCache = new Map<string, ReturnType<typeof buildEntityTable>>();
@@ -116,6 +133,7 @@ export function createDispatcher(
116
133
  tenantTimezoneCache,
117
134
  tracer: dispatcherTracer,
118
135
  meter: dispatcherMeter,
136
+ membershipQuery,
119
137
  };
120
138
 
121
139
  return {
@@ -143,5 +161,8 @@ export function createDispatcher(
143
161
  },
144
162
 
145
163
  resolveAuthClaims: (user) => resolveAuthClaimsFn(ctx, user),
164
+
165
+ resolveActiveMembership: (userId, tenantId) =>
166
+ resolveActiveMembershipFn(ctx, userId, tenantId, INTERACTIVE_SIGN_IN_POLICY),
146
167
  };
147
168
  }
@@ -1,14 +1,20 @@
1
1
  import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user";
2
2
  import type {
3
+ ActiveMembershipResult,
3
4
  EscapeHatchDeclaration,
4
5
  LifecycleHookFn,
5
6
  SessionUser,
6
7
  WriteResult,
7
8
  } from "../engine/types";
9
+ import type { TenantId } from "../engine/types/identifiers";
8
10
  import { AccessDeniedError, FrameworkReasons, InternalError } from "../errors";
9
11
 
10
12
  export type QueryAsFn = (user: SessionUser, qn: string, payload: unknown) => Promise<unknown>;
11
13
  export type WriteAsFn = (user: SessionUser, qn: string, payload: unknown) => Promise<WriteResult>;
14
+ export type ResolveActiveMembershipFn = (
15
+ userId: string,
16
+ tenantId: TenantId,
17
+ ) => Promise<ActiveMembershipResult>;
12
18
  export type IdentitySwitch = { readonly queryAs: QueryAsFn; readonly writeAs: WriteAsFn };
13
19
 
14
20
  export function isSystemIdentity(user: SessionUser): boolean {
@@ -22,7 +28,7 @@ export function isSystemIdentitySwitchAllowed(
22
28
  return !isSystemIdentity(asUser) || allowSystemIdentity;
23
29
  }
24
30
 
25
- function systemIdentitySwitchDenied(callerLabel: string): AccessDeniedError {
31
+ export function systemIdentitySwitchDenied(callerLabel: string): AccessDeniedError {
26
32
  return new AccessDeniedError({
27
33
  message:
28
34
  `${callerLabel} may not switch identity to SYSTEM — declare r.systemScope() or ` +
@@ -57,9 +63,9 @@ export function createGatedIdentitySwitch(
57
63
  return gated;
58
64
  }
59
65
 
60
- function readIdentitySwitchFn<TFn extends QueryAsFn | WriteAsFn>(
66
+ function readIdentitySwitchFn<TFn extends QueryAsFn | WriteAsFn | ResolveActiveMembershipFn>(
61
67
  context: object,
62
- key: "queryAs" | "writeAs",
68
+ key: "queryAs" | "writeAs" | "resolveActiveMembership",
63
69
  ): TFn | undefined {
64
70
  if (!(key in context)) return undefined;
65
71
  const value = (context as Record<string, unknown>)[key];
@@ -74,6 +80,12 @@ function unavailableIdentitySwitchFn(callerLabel: string, kind: "queryAs" | "wri
74
80
  };
75
81
  }
76
82
 
83
+ function deniedResolveActiveMembership(callerLabel: string): ResolveActiveMembershipFn {
84
+ return async () => {
85
+ throw systemIdentitySwitchDenied(callerLabel);
86
+ };
87
+ }
88
+
77
89
  // Fail-closed fallback for a ctx not built by the dispatcher bridge (unit-test stub, duplicated module instance).
78
90
  function fallbackUngatedIdentitySwitch(
79
91
  callerLabel: string,
@@ -86,29 +98,48 @@ function fallbackUngatedIdentitySwitch(
86
98
  };
87
99
  }
88
100
 
89
- export function withHookIdentitySwitchGrant<TContext extends object>(
90
- context: TContext,
101
+ // Reuses the ORIGINAL ungated pair when known, so this grant doesn't compose with the caller's.
102
+ function gatedIdentitySwitchFields(
91
103
  callerLabel: string,
92
104
  escapeHatch: EscapeHatchDeclaration | undefined,
93
- ): TContext {
94
- const ctxQueryAs = readIdentitySwitchFn<QueryAsFn>(context, "queryAs");
95
- const ctxWriteAs = readIdentitySwitchFn<WriteAsFn>(context, "writeAs");
96
- if (!ctxQueryAs && !ctxWriteAs) return context;
97
-
98
- // Reuse the ORIGINAL ungated pair when known, so this grant doesn't compose with the caller's.
105
+ ctxQueryAs: QueryAsFn | undefined,
106
+ ctxWriteAs: WriteAsFn | undefined,
107
+ ): Partial<IdentitySwitch> {
108
+ if (!ctxQueryAs && !ctxWriteAs) return {};
99
109
  const ungated: IdentitySwitch =
100
110
  (ctxQueryAs && ungatedByGated.get(ctxQueryAs)) ??
101
111
  (ctxWriteAs && ungatedByGated.get(ctxWriteAs)) ??
102
112
  fallbackUngatedIdentitySwitch(callerLabel, ctxQueryAs, ctxWriteAs);
103
-
104
113
  const gated = createGatedIdentitySwitch(callerLabel, escapeHatch !== undefined, ungated);
105
114
  return {
106
- ...context,
107
115
  ...(ctxQueryAs && { queryAs: gated.queryAs }),
108
116
  ...(ctxWriteAs && { writeAs: gated.writeAs }),
109
117
  };
110
118
  }
111
119
 
120
+ export function withHookIdentitySwitchGrant<TContext extends object>(
121
+ context: TContext,
122
+ callerLabel: string,
123
+ escapeHatch: EscapeHatchDeclaration | undefined,
124
+ ): TContext {
125
+ const ctxQueryAs = readIdentitySwitchFn<QueryAsFn>(context, "queryAs");
126
+ const ctxWriteAs = readIdentitySwitchFn<WriteAsFn>(context, "writeAs");
127
+ const ctxResolveActiveMembership = readIdentitySwitchFn<ResolveActiveMembershipFn>(
128
+ context,
129
+ "resolveActiveMembership",
130
+ );
131
+ if (!ctxQueryAs && !ctxWriteAs && !ctxResolveActiveMembership) return context;
132
+
133
+ return {
134
+ ...context,
135
+ ...gatedIdentitySwitchFields(callerLabel, escapeHatch, ctxQueryAs, ctxWriteAs),
136
+ ...(ctxResolveActiveMembership &&
137
+ escapeHatch === undefined && {
138
+ resolveActiveMembership: deniedResolveActiveMembership(callerLabel),
139
+ }),
140
+ };
141
+ }
142
+
112
143
  // Re-gates a hook's own ctx.queryAs/ctx.writeAs instead of inheriting the handler's grant.
113
144
  export function bindHookIdentitySwitchGrant(
114
145
  fn: LifecycleHookFn,
@@ -57,6 +57,7 @@ export function bridgeStub(opts?: {
57
57
  | "loadAggregateWithSnapshot"
58
58
  | "queryProjection"
59
59
  | "resolveAuthClaims"
60
+ | "resolveActiveMembership"
60
61
  | "hasFeature"
61
62
  | "metrics"
62
63
  | "metricsFor"
@@ -118,6 +119,9 @@ export function bridgeStub(opts?: {
118
119
  resolveAuthClaims: notAvailable(
119
120
  "resolveAuthClaims",
120
121
  ) as unknown as HandlerContext["resolveAuthClaims"],
122
+ resolveActiveMembership: notAvailable(
123
+ "resolveActiveMembership",
124
+ ) as unknown as HandlerContext["resolveActiveMembership"],
121
125
  // Stub defaults to always-enabled — matches the dispatcher's behaviour
122
126
  // when no effectiveFeatures resolver is wired (tests without toggles).
123
127
  hasFeature: async () => true,