@cosmicdrift/kumiko-framework 0.264.1 → 0.266.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 (30) hide show
  1. package/package.json +3 -3
  2. package/src/api/__tests__/api.test.ts +4 -0
  3. package/src/api/__tests__/auth-routes-cookie.test.ts +21 -2
  4. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +4 -0
  5. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +4 -0
  6. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +4 -0
  7. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +4 -0
  8. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +4 -0
  9. package/src/api/__tests__/server-boot-guards.test.ts +60 -0
  10. package/src/api/auth-middleware.ts +2 -7
  11. package/src/api/auth-routes.ts +28 -14
  12. package/src/api/server.ts +14 -1
  13. package/src/changes.json +27 -0
  14. package/src/engine/__tests__/hook-identity-switch-grant.test.ts +84 -3
  15. package/src/engine/active-membership.ts +56 -0
  16. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +331 -15
  17. package/src/engine/boot-validator/access-declarations.ts +101 -27
  18. package/src/engine/boot-validator/zod-shape.ts +92 -1
  19. package/src/engine/extension-names.ts +16 -0
  20. package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +28 -0
  21. package/src/engine/feature-ast/extractors/hooks.ts +8 -4
  22. package/src/engine/index.ts +15 -0
  23. package/src/engine/types/index.ts +4 -0
  24. package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +38 -0
  25. package/src/pipeline/active-membership.ts +178 -0
  26. package/src/pipeline/dispatch-shared.ts +14 -1
  27. package/src/pipeline/dispatcher.ts +22 -1
  28. package/src/pipeline/system-identity-switch.ts +44 -13
  29. package/src/testing/handler-context.ts +4 -0
  30. package/src/ui-types/index.ts +1 -0
@@ -1,4 +1,23 @@
1
- import { ZodArray, ZodDefault, ZodNullable, ZodObject, ZodOptional, type ZodType } from "zod";
1
+ import {
2
+ ZodArray,
3
+ ZodCatch,
4
+ ZodDefault,
5
+ ZodIntersection,
6
+ ZodLazy,
7
+ ZodNonOptional,
8
+ ZodNullable,
9
+ ZodObject,
10
+ ZodOptional,
11
+ ZodPipe,
12
+ ZodPrefault,
13
+ ZodReadonly,
14
+ ZodRecord,
15
+ ZodTuple,
16
+ type ZodType,
17
+ ZodUnion,
18
+ ZodXor,
19
+ } from "zod";
20
+ import type { $ZodType } from "zod/v4/core";
2
21
 
3
22
  // Drills through wrapper types (.nullable(), .optional(), .default()) a
4
23
  // handler's schema may use around its actual object/array shape — e.g. a
@@ -49,3 +68,75 @@ export function getZodRowShape(schema: ZodType | undefined): Record<string, ZodT
49
68
  ? getZodObjectShape(rowsArray.element as ZodType)
50
69
  : undefined;
51
70
  }
71
+
72
+ type SingleInnerSchema =
73
+ | ZodOptional
74
+ | ZodNullable
75
+ | ZodDefault
76
+ | ZodPrefault
77
+ | ZodNonOptional
78
+ | ZodCatch
79
+ | ZodReadonly
80
+ | ZodLazy;
81
+
82
+ const SINGLE_INNER_SCHEMA_CLASSES = [
83
+ ZodOptional,
84
+ ZodNullable,
85
+ ZodDefault,
86
+ ZodPrefault,
87
+ ZodNonOptional,
88
+ ZodCatch,
89
+ ZodReadonly,
90
+ ZodLazy,
91
+ ] as const;
92
+
93
+ function isSingleInnerSchema(schema: $ZodType): schema is SingleInnerSchema {
94
+ return SINGLE_INNER_SCHEMA_CLASSES.some((schemaClass) => schema instanceof schemaClass);
95
+ }
96
+
97
+ function combinatorChildSchemas(schema: $ZodType): readonly $ZodType[] {
98
+ if (schema instanceof ZodUnion || schema instanceof ZodXor) return schema.options;
99
+ if (schema instanceof ZodIntersection) return [schema.def.left, schema.def.right];
100
+ // z.preprocess() carries the accepted shape on `out`, .transform() on `in`.
101
+ if (schema instanceof ZodPipe) return [schema.in, schema.out];
102
+ return [];
103
+ }
104
+
105
+ function childSchemas(schema: $ZodType): readonly $ZodType[] {
106
+ if (schema instanceof ZodObject) return Object.values(schema.shape);
107
+ if (schema instanceof ZodArray) return [schema.element];
108
+ if (schema instanceof ZodRecord) return [schema.valueType];
109
+ if (schema instanceof ZodTuple) {
110
+ const { items, rest } = schema.def;
111
+ return rest ? [...items, rest] : items;
112
+ }
113
+ if (isSingleInnerSchema(schema)) return [schema.unwrap()];
114
+ return combinatorChildSchemas(schema);
115
+ }
116
+
117
+ // A z.lazy() getter that builds a fresh schema per call never revisits a node.
118
+ const MAX_WALKED_SCHEMAS = 10_000;
119
+
120
+ // Object keys at any depth, so a field nested in an intersection, union or an
121
+ // update's `changes` object is found too.
122
+ export function collectZodObjectKeys(schema: $ZodType | undefined): ReadonlySet<string> {
123
+ const keys = new Set<string>();
124
+ if (schema === undefined) return keys;
125
+ const visited = new Set<$ZodType>();
126
+ const pending: $ZodType[] = [schema];
127
+ for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
128
+ if (visited.has(next)) continue;
129
+ if (visited.size >= MAX_WALKED_SCHEMAS) {
130
+ throw new Error(
131
+ `collectZodObjectKeys: schema has more than ${MAX_WALKED_SCHEMAS} nodes — ` +
132
+ "a z.lazy() getter probably returns a new schema on every call; hoist it into a constant.",
133
+ );
134
+ }
135
+ visited.add(next);
136
+ if (next instanceof ZodObject) {
137
+ for (const key of Object.keys(next.shape)) keys.add(key);
138
+ }
139
+ pending.push(...childSchemas(next));
140
+ }
141
+ return keys;
142
+ }
@@ -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
@@ -0,0 +1,28 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readOptionalAccessRule } from "../extractors/hooks";
3
+
4
+ describe("readOptionalAccessRule — openToAll.personalData", () => {
5
+ test("keeps personalData: tenant-members on the object form", () => {
6
+ expect(
7
+ readOptionalAccessRule({
8
+ openToAll: { reason: "members edit their team roster", personalData: "tenant-members" },
9
+ }),
10
+ ).toEqual({
11
+ openToAll: { reason: "members edit their team roster", personalData: "tenant-members" },
12
+ });
13
+ });
14
+
15
+ test("drops an unknown personalData value", () => {
16
+ expect(
17
+ readOptionalAccessRule({
18
+ openToAll: { reason: "signup", personalData: "public-intake" },
19
+ }),
20
+ ).toEqual({ openToAll: { reason: "signup" } });
21
+ });
22
+
23
+ test("the deprecated openToAll: true form carries no personalData", () => {
24
+ expect(readOptionalAccessRule({ openToAll: true, personalData: "tenant-members" })).toEqual({
25
+ openToAll: true,
26
+ });
27
+ });
28
+ });
@@ -42,12 +42,16 @@ export function readOptionalAccessRule(value: unknown): AccessRule | undefined {
42
42
  if (Array.isArray(value["roles"]) && value["roles"].every((r) => typeof r === "string")) {
43
43
  return { roles: value["roles"] as readonly string[] };
44
44
  }
45
- const publicIntake = value["publicIntake"] === true ? { publicIntake: true as const } : {};
46
45
  if (value["openToAll"] === true) {
47
- return { openToAll: true, ...publicIntake };
46
+ return { openToAll: true };
48
47
  }
49
- if (isPlainObject(value["openToAll"]) && typeof value["openToAll"]["reason"] === "string") {
50
- return { openToAll: { reason: value["openToAll"]["reason"] }, ...publicIntake };
48
+ const openToAll = value["openToAll"];
49
+ if (isPlainObject(openToAll) && typeof openToAll["reason"] === "string") {
50
+ const personalData =
51
+ openToAll["personalData"] === "tenant-members"
52
+ ? { personalData: "tenant-members" as const }
53
+ : {};
54
+ return { openToAll: { reason: openToAll["reason"], ...personalData } };
51
55
  }
52
56
  return undefined;
53
57
  }
@@ -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,
@@ -418,6 +432,7 @@ export type {
418
432
  OnDeleteStrategy,
419
433
  OpenToAllAccessRule,
420
434
  OpenToAllDeclaration,
435
+ OpenToAllPersonalData,
421
436
  ParentRefDef,
422
437
  PersonalAnnotations,
423
438
  PersonalAnnotationsLongText,
@@ -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,
@@ -175,6 +178,7 @@ export type {
175
178
  NotifyPriority,
176
179
  OpenToAllAccessRule,
177
180
  OpenToAllDeclaration,
181
+ OpenToAllPersonalData,
178
182
  QualifiedEventName,
179
183
  QueryEvent,
180
184
  QueryHandlerDef,
@@ -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
  }