@cosmicdrift/kumiko-bundled-features 0.200.1 → 0.202.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 (29) hide show
  1. package/package.json +7 -7
  2. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +127 -0
  3. package/src/auth-email-password/handlers/invite-create.write.ts +9 -0
  4. package/src/auth-mfa/__tests__/session-auto-revoke.integration.test.ts +18 -6
  5. package/src/auth-mfa/feature.ts +69 -2
  6. package/src/auth-mfa/handlers/disable.write.ts +7 -0
  7. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +7 -0
  8. package/src/auth-mfa/handlers/enable-confirm.write.ts +7 -0
  9. package/src/auth-mfa/index.ts +4 -0
  10. package/src/auth-mfa/mfa-code-verifier.ts +33 -0
  11. package/src/jobs/web/__tests__/job-runs-screen.test.tsx +5 -3
  12. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +171 -4
  13. package/src/personal-access-tokens/constants.ts +8 -0
  14. package/src/personal-access-tokens/feature.ts +66 -2
  15. package/src/personal-access-tokens/handlers/create.write.ts +94 -36
  16. package/src/personal-access-tokens/index.ts +7 -2
  17. package/src/personal-access-tokens/revoke-for-user.ts +18 -0
  18. package/src/personal-access-tokens/web/i18n.ts +8 -0
  19. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +31 -0
  20. package/src/sessions/__tests__/role-rederivation.integration.test.ts +137 -0
  21. package/src/sessions/__tests__/sessions.integration.test.ts +3 -1
  22. package/src/sessions/session-callbacks.ts +44 -13
  23. package/src/sessions/session-checker-fail-open.test.ts +74 -0
  24. package/src/step-dispatcher/webhook-runner.ts +15 -0
  25. package/src/tenant/handlers/remove-member.write.ts +13 -12
  26. package/src/tenant/handlers/update-member-roles.write.ts +13 -12
  27. package/src/tenant/membership-roles.ts +7 -0
  28. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +23 -1
  29. package/src/user-data-rights/feature.ts +1 -0
@@ -0,0 +1,137 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { randomBytes } from "node:crypto";
3
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
4
+ import type { SessionCreator } from "@cosmicdrift/kumiko-framework/api";
5
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
6
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
7
+ import {
8
+ setupTestStack,
9
+ type TestStack,
10
+ testTenantId,
11
+ unsafeCreateEntityTable,
12
+ unsafePushTables,
13
+ } from "@cosmicdrift/kumiko-framework/stack";
14
+ import {
15
+ createLateBoundHolder,
16
+ createTestEnvelopeCipher,
17
+ updateRows,
18
+ } from "@cosmicdrift/kumiko-framework/testing";
19
+ import { AuthHandlers } from "../../auth-email-password/constants";
20
+ import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
21
+ import { createConfigFeature } from "../../config";
22
+ import { createConfigResolver } from "../../config/resolver";
23
+ import { configValuesTable } from "../../config/table";
24
+ import { TenantQueries } from "../../tenant/constants";
25
+ import { createTenantFeature } from "../../tenant/feature";
26
+ import { tenantInvitationEntity } from "../../tenant/invitation-table";
27
+ import { tenantMembershipsTable } from "../../tenant/membership-table";
28
+ import { tenantEntity } from "../../tenant/schema/tenant";
29
+ import { createUserFeature } from "../../user/feature";
30
+ import { userEntity, userTable } from "../../user/schema/user";
31
+ import { createSessionsFeature } from "../feature";
32
+ import { userSessionEntity, userSessionTable } from "../schema/user-session";
33
+ import { createSessionCallbacks, type SessionCallbacks } from "../session-callbacks";
34
+ import { sessionCallbacksFromLateBound } from "../testing";
35
+ import { makeSessionHelpers } from "./test-helpers";
36
+
37
+ // Proves the core DoD of #2148: a role change written directly to
38
+ // tenantMembershipsTable takes effect on the very next request made with an
39
+ // ALREADY-ISSUED, still-live token — no re-login, no JWT expiry needed.
40
+ // Deliberately bypasses update-member-roles.write.ts (which force-revokes
41
+ // the session on a role change) so the effect under test isn't masked by
42
+ // that unrelated, already-correct revoke-on-change behavior.
43
+
44
+ let stack: TestStack;
45
+ let h: ReturnType<typeof makeSessionHelpers>;
46
+ let sessionCreator: SessionCreator;
47
+ const callbacks = createLateBoundHolder<SessionCallbacks>("session-callbacks");
48
+
49
+ const encryptionKey = randomBytes(32).toString("base64");
50
+
51
+ // Matches TestUsers.systemAdmin.tenantId — same rationale as
52
+ // membership-revoke.integration.test.ts.
53
+ const TENANT_A: TenantId = testTenantId(1);
54
+
55
+ beforeAll(async () => {
56
+ const encryption = createTestEnvelopeCipher(encryptionKey);
57
+ const resolver = createConfigResolver({ cipher: encryption });
58
+ const bound = sessionCallbacksFromLateBound(callbacks);
59
+
60
+ stack = await setupTestStack({
61
+ features: [
62
+ createConfigFeature(),
63
+ createUserFeature(),
64
+ createTenantFeature(),
65
+ createAuthEmailPasswordFeature(),
66
+ authFoundationFeature,
67
+ createSessionsFeature(),
68
+ ],
69
+ extraContext: { configResolver: resolver, configEncryption: encryption },
70
+ authConfig: {
71
+ ...bound.asAuthConfig(),
72
+ membershipQuery: "tenant:query:memberships",
73
+ loginHandler: AuthHandlers.login,
74
+ },
75
+ });
76
+ callbacks.set(createSessionCallbacks({ db: stack.db }));
77
+ const creator = bound.asAuthConfig().sessionCreator;
78
+ if (!creator) throw new Error("sessionCreator missing from bound auth config");
79
+ sessionCreator = creator;
80
+ h = makeSessionHelpers(stack, TENANT_A, sessionCreator);
81
+
82
+ await unsafeCreateEntityTable(stack.db, userEntity);
83
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
84
+ await unsafeCreateEntityTable(stack.db, tenantInvitationEntity);
85
+ await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
86
+ await unsafeCreateEntityTable(stack.db, userSessionEntity);
87
+ });
88
+
89
+ afterAll(async () => {
90
+ await stack.cleanup();
91
+ });
92
+
93
+ beforeEach(async () => {
94
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
95
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
96
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${userSessionTable.tableName}"`);
97
+ });
98
+
99
+ describe("sessionChecker re-derives roles from the DB on every request", () => {
100
+ test("a DB-side membership role change unlocks a role-gated action for the SAME live token", async () => {
101
+ // 1. Log in with an initial tenant-membership role set that does NOT
102
+ // satisfy the admin gate below.
103
+ const { userId } = await h.seedUser("role-upgrade@example.com", "first-password", {
104
+ roles: ["User"],
105
+ });
106
+ const { token } = await h.login("role-upgrade@example.com", "first-password");
107
+
108
+ // 2. tenant:query:invitations is gated on access.admin
109
+ // (["TenantAdmin", "Admin", "SystemAdmin"]) — the freshly-logged-in
110
+ // "User" role does not satisfy it.
111
+ const before = await h.authedPost("/api/query", token, {
112
+ type: TenantQueries.invitations,
113
+ payload: {},
114
+ });
115
+ expect(before.status).toBe(403);
116
+
117
+ // 3. Elevate the role by writing DIRECTLY to tenantMembershipsTable —
118
+ // explicitly NOT via update-member-roles.write.ts, whose
119
+ // force-revoke-on-change would kill the very token this test needs
120
+ // to keep using.
121
+ await updateRows(
122
+ stack.db,
123
+ tenantMembershipsTable,
124
+ { roles: JSON.stringify(["Admin"]) },
125
+ { userId, tenantId: TENANT_A },
126
+ );
127
+
128
+ // 4. Same, still-unrevoked, still-unexpired token — the next request
129
+ // re-derives roles from the DB and now passes the gate.
130
+ const after = await h.authedPost("/api/query", token, {
131
+ type: TenantQueries.invitations,
132
+ payload: {},
133
+ });
134
+ expect(after.status).toBe(200);
135
+ expect(await after.json()).toEqual({ data: [] });
136
+ });
137
+ });
@@ -494,7 +494,9 @@ describe("sessions feature — login → check → revoke → rejected", () => {
494
494
 
495
495
  // Direct invariant: Bob's sid + Alice's userId → missing (not live/revoked).
496
496
  expect(await callbacks.get().sessionChecker(bobLogin.sid, alice.userId)).toBe("missing");
497
- expect(await callbacks.get().sessionChecker(bobLogin.sid, bob.userId)).toBe("live");
497
+ const bobResult = await callbacks.get().sessionChecker(bobLogin.sid, bob.userId);
498
+ const bobStatus = typeof bobResult === "string" ? bobResult : bobResult.status;
499
+ expect(bobStatus).toBe("live");
498
500
 
499
501
  // HTTP path: mint Alice's identity onto Bob's sid (stolen-sid scenario).
500
502
  const forged = await stack.jwt.sign({
@@ -1,5 +1,5 @@
1
1
  import type {
2
- AuthSessionStatus,
2
+ AuthSessionCheckResult,
3
3
  SessionChecker,
4
4
  SessionCreator,
5
5
  SessionMassRevoker,
@@ -11,12 +11,13 @@ export type { SessionMassRevoker } from "@cosmicdrift/kumiko-framework/api";
11
11
 
12
12
  import { fetchOne, insertOne, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
13
13
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
14
- import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
15
- import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
14
+ import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
15
+ import { buildSessionRoles, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
16
16
  import { append } from "@cosmicdrift/kumiko-framework/event-store";
17
- import { generateId } from "@cosmicdrift/kumiko-framework/utils";
17
+ import { generateId, parseRoles } from "@cosmicdrift/kumiko-framework/utils";
18
18
  import { Temporal } from "temporal-polyfill";
19
19
  import { encryptForDirectWrite } from "../shared";
20
+ import { tenantMembershipsTable } from "../tenant";
20
21
  import { USER_STATUS, type UserStatus, userTable } from "../user";
21
22
  import { DEFAULT_SESSION_EXPIRY_MS } from "./constants";
22
23
  import { userSessionEntity, userSessionTable } from "./schema/user-session";
@@ -113,9 +114,10 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
113
114
  );
114
115
  },
115
116
 
116
- async sessionChecker(sid: string, expectedUserId: string): Promise<AuthSessionStatus> {
117
+ async sessionChecker(sid: string, expectedUserId: string): Promise<AuthSessionCheckResult> {
117
118
  const row = await fetchOne<{
118
119
  userId: string;
120
+ tenantId: TenantId;
119
121
  revokedAt: unknown;
120
122
  expiresAt: { epochMilliseconds: number };
121
123
  }>(db, userSessionTable, { id: sid });
@@ -137,14 +139,43 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
137
139
  // revocation is primary; never turn a user-row miss into a global
138
140
  // lockout. (+1 PK read on read_users per authenticated request.)
139
141
  //
140
- // Fail-open covers a THROW too, not just a null-miss: this read sits on
141
- // the hot path of every authenticated request, so a DB timeout / lock
142
- // contention / pool exhaustion here must not turn into a global lockout.
143
- const user = await fetchOne<{ status: UserStatus }>(db, userTable, {
144
- id: expectedUserId,
145
- }).catch(() => null);
146
- if (user && isPrincipalBlocked(user.status)) return "blocked";
147
- return "live";
142
+ // Fail-open covers a THROW *and* a null-miss, not just a throw: this
143
+ // read sits on the hot path of every authenticated request, and a
144
+ // missing row here means we have no DB-confirmed roles to derive from
145
+ // (e.g. a bootstrap/system actor with no persisted user row) — that is
146
+ // a different situation from tenantMembershipsTable below, where a
147
+ // missing row is a legitimate "no tenant roles" outcome. Both branches
148
+ // return the bare "live" string (no re-derived roles) — the middleware
149
+ // falls back to the JWT's frozen roles claim.
150
+ let user: { status: UserStatus; roles: string | null } | undefined;
151
+ try {
152
+ user = await fetchOne<{ status: UserStatus; roles: string | null }>(db, userTable, {
153
+ id: expectedUserId,
154
+ });
155
+ } catch {
156
+ return "live";
157
+ }
158
+ if (!user) return "live";
159
+ if (isPrincipalBlocked(user.status)) return "blocked";
160
+
161
+ // Same fail-open reasoning as the userTable lookup above — a transient
162
+ // DB error on the membership read must not lock the user out. A
163
+ // missing row (not a throw) is a valid outcome: the user genuinely has
164
+ // no tenant-scoped roles for row.tenantId, so membershipRoles is [].
165
+ let membershipRoles: readonly string[];
166
+ try {
167
+ const membership = await fetchOne<{ roles: string | null }>(db, tenantMembershipsTable, {
168
+ userId: expectedUserId,
169
+ tenantId: row.tenantId,
170
+ });
171
+ membershipRoles = membership ? parseRoles(membership.roles) : [];
172
+ } catch {
173
+ return "live";
174
+ }
175
+
176
+ const globalRoles = parseRoles(user.roles);
177
+ const roles = buildSessionRoles(globalRoles, membershipRoles);
178
+ return { status: "live", roles } as const;
148
179
  },
149
180
 
150
181
  async sessionMassRevoker(userId: string): Promise<number> {
@@ -2,6 +2,7 @@ import { describe, expect, spyOn, test } from "bun:test";
2
2
  import * as bunDb from "@cosmicdrift/kumiko-framework/bun-db";
3
3
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
4
4
  import { Temporal } from "temporal-polyfill";
5
+ import { tenantMembershipsTable } from "../tenant/membership-table";
5
6
  import { USER_STATUS, userTable } from "../user/schema/user";
6
7
  import { userSessionTable } from "./schema/user-session";
7
8
  import { createSessionCallbacks } from "./session-callbacks";
@@ -74,4 +75,77 @@ describe("sessionChecker fail-open on user-lookup throw", () => {
74
75
  spy.mockRestore();
75
76
  }
76
77
  });
78
+
79
+ test("membership lookup THROW → live (not 500)", async () => {
80
+ const db = {} as DbConnection;
81
+ const cbs = createSessionCallbacks({ db });
82
+ const sid = "00000000-0000-4000-8000-00000000sid3";
83
+ const userId = "00000000-0000-4000-8000-00000000usr3";
84
+ const tenantId = "00000000-0000-4000-8000-000000tenant";
85
+ const farFutureMs = Temporal.Now.instant().add({ hours: 1 }).epochMilliseconds;
86
+
87
+ const spy = spyOn(bunDb, "fetchOne").mockImplementation((async (_db, table) => {
88
+ if (table === userSessionTable) {
89
+ return {
90
+ userId,
91
+ tenantId,
92
+ revokedAt: null,
93
+ expiresAt: { epochMilliseconds: farFutureMs },
94
+ };
95
+ }
96
+ if (table === userTable) {
97
+ return { status: USER_STATUS.Active, roles: null };
98
+ }
99
+ if (table === tenantMembershipsTable) {
100
+ throw new Error("simulated pool exhaustion");
101
+ }
102
+ throw new Error("unexpected table in sessionChecker spy");
103
+ }) as FetchOne);
104
+
105
+ try {
106
+ expect(await cbs.sessionChecker(sid, userId)).toBe("live");
107
+ } finally {
108
+ spy.mockRestore();
109
+ }
110
+ });
111
+ });
112
+
113
+ describe("sessionChecker role re-derivation", () => {
114
+ test("live session composes global + tenant-membership roles fresh from the DB", async () => {
115
+ const db = {} as DbConnection;
116
+ const cbs = createSessionCallbacks({ db });
117
+ const sid = "00000000-0000-4000-8000-00000000sid4";
118
+ const userId = "00000000-0000-4000-8000-00000000usr4";
119
+ const tenantId = "00000000-0000-4000-8000-000000tenan2";
120
+ const farFutureMs = Temporal.Now.instant().add({ hours: 1 }).epochMilliseconds;
121
+
122
+ const spy = spyOn(bunDb, "fetchOne").mockImplementation((async (_db, table) => {
123
+ if (table === userSessionTable) {
124
+ return {
125
+ userId,
126
+ tenantId,
127
+ revokedAt: null,
128
+ expiresAt: { epochMilliseconds: farFutureMs },
129
+ };
130
+ }
131
+ if (table === userTable) {
132
+ return { status: USER_STATUS.Active, roles: JSON.stringify(["Support"]) };
133
+ }
134
+ if (table === tenantMembershipsTable) {
135
+ return { roles: JSON.stringify(["User"]) };
136
+ }
137
+ throw new Error("unexpected table in sessionChecker spy");
138
+ }) as FetchOne);
139
+
140
+ try {
141
+ const result = await cbs.sessionChecker(sid, userId);
142
+ if (typeof result === "string") {
143
+ throw new Error(`expected object result, got bare string "${result}"`);
144
+ }
145
+ expect(result.status).toBe("live");
146
+ expect([...result.roles].sort()).toEqual(["Support", "User"]);
147
+ } finally {
148
+ spy.mockRestore();
149
+ }
150
+ });
77
151
  });
@@ -39,6 +39,20 @@ export function setWebhookFetch(fn: typeof fetch): void {
39
39
  }
40
40
 
41
41
  export async function performWebhookDispatch(spec: WebhookSpec): Promise<WebhookDispatchResult> {
42
+ // SSRF guard at the primitive boundary: only http(s), and never follow
43
+ // redirects — a 3xx could point at an internal/metadata target and the
44
+ // spec carries secrets (auth) that would be forwarded there. A webhook
45
+ // destination that redirects now surfaces as a delivery error instead.
46
+ let url: URL;
47
+ try {
48
+ url = new URL(spec.url);
49
+ } catch {
50
+ return { ok: false, error: `invalid url "${spec.url}"` };
51
+ }
52
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
53
+ return { ok: false, error: `unsupported url scheme "${url.protocol}"` };
54
+ }
55
+
42
56
  const headers: Record<string, string> = { "content-type": "application/json", ...spec.headers };
43
57
  if (spec.auth) {
44
58
  const secret = secretResolver(spec.auth.secretRef);
@@ -55,6 +69,7 @@ export async function performWebhookDispatch(spec: WebhookSpec): Promise<Webhook
55
69
  const res = await fetchImpl(spec.url, {
56
70
  method: spec.method,
57
71
  headers,
72
+ redirect: "manual",
58
73
  body: spec.body !== undefined ? JSON.stringify(spec.body) : undefined,
59
74
  });
60
75
  if (!res.ok) {
@@ -45,24 +45,25 @@ export const removeMemberWrite = defineWriteHandler({
45
45
  );
46
46
  }
47
47
 
48
+ // Revoke THIS tenant's sessions BEFORE the delete — a removed member must
49
+ // not keep a valid session for the window between the delete and a
50
+ // post-delete revoke. Best-effort cross-feature call: sessions may not be
51
+ // mounted (registry lookup, see above). If the delete then fails, the
52
+ // member is logged out but the membership is intact — safe direction.
53
+ const revoker = ctx.registry.getWriteHandler(REVOKE_ALL_SESSIONS_QN);
54
+ if (revoker) {
55
+ await ctx.writeAs(createSystemUser(event.payload.tenantId), REVOKE_ALL_SESSIONS_QN, {
56
+ userId: event.payload.userId,
57
+ tenantId: event.payload.tenantId,
58
+ });
59
+ }
60
+
48
61
  const result = await executor.delete(
49
62
  { id: (existing as DbRow)["id"] as string }, // @cast-boundary db-row
50
63
  event.user,
51
64
  db,
52
65
  );
53
66
 
54
- // Revoke only this tenant's sessions — a multi-tenant user stays logged
55
- // in to tenants they're still a member of. Best-effort cross-feature
56
- // call: sessions may not be mounted (registry lookup, see above).
57
- if (result.isSuccess) {
58
- const revoker = ctx.registry.getWriteHandler(REVOKE_ALL_SESSIONS_QN);
59
- if (revoker) {
60
- await ctx.writeAs(createSystemUser(event.payload.tenantId), REVOKE_ALL_SESSIONS_QN, {
61
- userId: event.payload.userId,
62
- tenantId: event.payload.tenantId,
63
- });
64
- }
65
- }
66
67
  return withResponseData(result, event.payload);
67
68
  },
68
69
  });
@@ -62,6 +62,19 @@ export const updateMemberRolesWrite = defineWriteHandler({
62
62
  // silent overwrite. Per-membership parallelism is rare; if it happens,
63
63
  // the client retries on the error.
64
64
  const row = existing as DbRow; // @cast-boundary generic-record
65
+ // A role change can be security-relevant (e.g. demoting an Admin) — the
66
+ // user must re-authenticate with the new roles, everywhere. Revoke BEFORE
67
+ // the update closes the window where the demoted user keeps a valid
68
+ // session until the revoke write lands. Best-effort cross-feature call:
69
+ // sessions may not be mounted (registry lookup instead of a hard
70
+ // requires, see above).
71
+ const revoker = ctx.registry.getWriteHandler(REVOKE_ALL_SESSIONS_QN);
72
+ if (revoker) {
73
+ await ctx.writeAs(createSystemUser(event.user.tenantId), REVOKE_ALL_SESSIONS_QN, {
74
+ userId: event.payload.userId,
75
+ });
76
+ }
77
+
65
78
  const result = await executor.update(
66
79
  {
67
80
  id: row["id"] as string, // @cast-boundary db-row
@@ -72,18 +85,6 @@ export const updateMemberRolesWrite = defineWriteHandler({
72
85
  db,
73
86
  );
74
87
 
75
- // A role change can be security-relevant (e.g. demoting an Admin) — the
76
- // user must re-authenticate with the new roles, everywhere, not just in
77
- // this tenant. Best-effort cross-feature call: sessions may not be
78
- // mounted (registry lookup instead of a hard requires, see above).
79
- if (result.isSuccess) {
80
- const revoker = ctx.registry.getWriteHandler(REVOKE_ALL_SESSIONS_QN);
81
- if (revoker) {
82
- await ctx.writeAs(createSystemUser(event.user.tenantId), REVOKE_ALL_SESSIONS_QN, {
83
- userId: event.payload.userId,
84
- });
85
- }
86
- }
87
88
  return withResponseData(result, event.payload);
88
89
  },
89
90
  });
@@ -18,6 +18,13 @@ export function reservedMembershipRoleError(role: string): AccessDeniedError {
18
18
  });
19
19
  }
20
20
 
21
+ export function unassignableMembershipRoleError(role: string): AccessDeniedError {
22
+ return new AccessDeniedError({
23
+ message: `role "${role}" cannot be assigned by this inviter`,
24
+ details: { reason: "unassignable_membership_role", role },
25
+ });
26
+ }
27
+
21
28
  export function assertAssignableMembershipRoles(roles: readonly string[]): void {
22
29
  const forbidden = findForbiddenMembershipRole(roles);
23
30
  if (forbidden !== undefined) throw reservedMembershipRoleError(forbidden);
@@ -34,7 +34,7 @@ import {
34
34
  import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing";
35
35
  import { createComplianceProfilesFeature } from "../../compliance-profiles";
36
36
  import { createConfigFeature } from "../../config";
37
- import { createConfigResolver } from "../../config/resolver";
37
+ import { buildEnvConfigOverrides, createConfigResolver } from "../../config/resolver";
38
38
  import { configValueEntity } from "../../config/table";
39
39
  import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
40
40
  import { createSessionsFeature } from "../../sessions";
@@ -147,6 +147,28 @@ describe("tenant-model config resolution (seam)", () => {
147
147
  });
148
148
  expect(model).toBe("multi-user");
149
149
  });
150
+
151
+ test("TENANT_MODEL env var bridges through the real registry to resolveAppTenantModel", async () => {
152
+ // Mirrors the cascade.integration.test.ts env seam test, but pins it at
153
+ // the real tenantModel key: registry → buildEnvConfigOverrides →
154
+ // resolveAppTenantModel, so a key-qualification mismatch between the
155
+ // feature's `env` declaration and the bridge would fail here, not just
156
+ // on a stub registry.
157
+ const keyDef = stack.registry.getConfigKey(TENANT_MODEL_CONFIG_KEY);
158
+ expect(keyDef).toBeDefined();
159
+ expect(keyDef?.env).toBe("TENANT_MODEL");
160
+
161
+ const overrides = buildEnvConfigOverrides(stack.registry, { TENANT_MODEL: "single-user" });
162
+ expect(overrides.get(TENANT_MODEL_CONFIG_KEY)).toBe("single-user");
163
+
164
+ const model = await resolveAppTenantModel({
165
+ registry: stack.registry,
166
+ configResolver: createConfigResolver({ appOverrides: overrides }),
167
+ db: stack.db,
168
+ userId: SYSTEM_USER_ID,
169
+ });
170
+ expect(model).toBe("single-user");
171
+ });
150
172
  });
151
173
 
152
174
  describe("forget pipeline honours the effective tenant model", () => {
@@ -179,6 +179,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
179
179
  createSystemConfig("select", {
180
180
  default: "multi-user",
181
181
  options: ["single-user", "multi-user"],
182
+ env: "TENANT_MODEL",
182
183
  }),
183
184
  );
184
185