@cosmicdrift/kumiko-bundled-features 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-bundled-features",
3
- "version": "0.264.0",
3
+ "version": "0.265.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -129,12 +129,12 @@
129
129
  "./workflow-runner": "./src/workflow-runner/index.ts"
130
130
  },
131
131
  "dependencies": {
132
- "@cosmicdrift/kumiko-dispatcher-live": "0.264.0",
133
- "@cosmicdrift/kumiko-framework": "0.264.0",
134
- "@cosmicdrift/kumiko-headless": "0.264.0",
135
- "@cosmicdrift/kumiko-renderer": "0.264.0",
136
- "@cosmicdrift/kumiko-renderer-web": "0.264.0",
137
- "@cosmicdrift/kumiko-types": "0.264.0",
132
+ "@cosmicdrift/kumiko-dispatcher-live": "0.265.0",
133
+ "@cosmicdrift/kumiko-framework": "0.265.0",
134
+ "@cosmicdrift/kumiko-headless": "0.265.0",
135
+ "@cosmicdrift/kumiko-renderer": "0.265.0",
136
+ "@cosmicdrift/kumiko-renderer-web": "0.265.0",
137
+ "@cosmicdrift/kumiko-types": "0.265.0",
138
138
  "@mollie/api-client": "^4.5.0",
139
139
  "@node-rs/argon2": "^2.0.2",
140
140
  "@types/mailparser": "^3.4.6",
@@ -163,7 +163,7 @@
163
163
  ],
164
164
  "devDependencies": {
165
165
  "@testing-library/user-event": "^14.6.1",
166
- "@cosmicdrift/kumiko-locale-de": "0.264.0",
167
- "@cosmicdrift/kumiko-locale-es": "0.264.0"
166
+ "@cosmicdrift/kumiko-locale-de": "0.265.0",
167
+ "@cosmicdrift/kumiko-locale-es": "0.265.0"
168
168
  }
169
169
  }
@@ -0,0 +1,255 @@
1
+ // End-to-end via real HTTP: switch-tenant and login route through
2
+ // dispatcher.resolveActiveMembership. No sessions feature mounted — proves principal_blocked is enforced at membership-resolution time, not session revocation.
3
+
4
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
5
+ import { randomBytes } from "node:crypto";
6
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
8
+ import {
9
+ setupTestStack,
10
+ type TestStack,
11
+ TestUsers,
12
+ testTenantId,
13
+ unsafeCreateEntityTable,
14
+ unsafePushTables,
15
+ } from "@cosmicdrift/kumiko-framework/stack";
16
+ import {
17
+ createTestEnvelopeCipher,
18
+ resetTestTables,
19
+ updateRows,
20
+ } from "@cosmicdrift/kumiko-framework/testing";
21
+ import {
22
+ createComplianceProfilesFeature,
23
+ tenantComplianceProfileEntity,
24
+ tenantComplianceProfileTable,
25
+ } from "../../compliance-profiles";
26
+ import { createConfigFeature } from "../../config";
27
+ import { createConfigResolver } from "../../config/resolver";
28
+ import { configValuesTable } from "../../config/table";
29
+ import { hashPassword } from "../../shared";
30
+ import {
31
+ createTenantFeature,
32
+ TenantHandlers,
33
+ type TenantLifecycleStatus,
34
+ tenantMembershipsTable,
35
+ } from "../../tenant";
36
+ import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
37
+ import { seedTenantMembership } from "../../tenant/testing";
38
+ import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
39
+ import { resetTenantLifecycleGateCacheForTests } from "../../tenant-lifecycle/lifecycle-gate";
40
+ import { createUserFeature, USER_STATUS, UserHandlers, userEntity, userTable } from "../../user";
41
+ import { AuthErrors, AuthHandlers } from "../constants";
42
+ import { createAuthEmailPasswordFeature } from "../feature";
43
+
44
+ let stack: TestStack;
45
+
46
+ const TENANT_A: TenantId = testTenantId(1);
47
+ const TENANT_B: TenantId = testTenantId(2);
48
+
49
+ beforeAll(async () => {
50
+ const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
51
+ const resolver = createConfigResolver({ cipher: encryption });
52
+
53
+ stack = await setupTestStack({
54
+ features: [
55
+ createConfigFeature(),
56
+ createUserFeature(),
57
+ createTenantFeature(),
58
+ createComplianceProfilesFeature(),
59
+ createTenantLifecycleFeature(),
60
+ createAuthEmailPasswordFeature(),
61
+ ],
62
+ extraContext: { configResolver: resolver, configEncryption: encryption },
63
+ authConfig: {
64
+ membershipQuery: "tenant:query:memberships",
65
+ loginHandler: AuthHandlers.login,
66
+ },
67
+ });
68
+
69
+ await unsafeCreateEntityTable(stack.db, userEntity);
70
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
71
+ await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
72
+ await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
73
+ });
74
+
75
+ afterAll(async () => {
76
+ await stack.cleanup();
77
+ });
78
+
79
+ beforeEach(async () => {
80
+ // Statuses below are written directly via updateRows, bypassing the
81
+ // write handlers that invalidate the lifecycle-gate cache on a real
82
+ // request/cancel-destruction — clear it so a later test reusing TENANT_B
83
+ // doesn't read an earlier test's cached status (mirrors
84
+ // tenant-lifecycle.integration.test.ts).
85
+ resetTenantLifecycleGateCacheForTests();
86
+ await resetTestTables(stack.db, [
87
+ userTable,
88
+ tenantTable,
89
+ tenantComplianceProfileTable,
90
+ tenantMembershipsTable,
91
+ eventsTable,
92
+ ]);
93
+ });
94
+
95
+ async function createTenant(id: TenantId): Promise<void> {
96
+ // testTenantId(n) shares the "00000000-0000-4000-8000-" prefix across
97
+ // every n — the differing suffix must drive the unique `key`, not a
98
+ // slice(0, 8) of the id (which would collide for every test tenant).
99
+ await stack.http.writeOk(
100
+ TenantHandlers.create,
101
+ { id, key: `t-${id.slice(-8)}`, name: "Tenant" },
102
+ TestUsers.systemAdmin,
103
+ );
104
+ }
105
+
106
+ async function setTenantStatus(id: TenantId, status: TenantLifecycleStatus): Promise<void> {
107
+ await updateRows(stack.db, tenantTable, { status }, { id });
108
+ resetTenantLifecycleGateCacheForTests();
109
+ }
110
+
111
+ async function createUser(email: string, password: string): Promise<string> {
112
+ const hash = await hashPassword(password);
113
+ const created = await stack.http.writeOk<{ id: string }>(
114
+ UserHandlers.create,
115
+ { email, passwordHash: hash, displayName: email.split("@")[0] ?? "user" },
116
+ TestUsers.systemAdmin,
117
+ );
118
+ return created.id;
119
+ }
120
+
121
+ async function addMembership(
122
+ userId: string,
123
+ tenantId: TenantId,
124
+ roles: readonly string[] = ["Member"],
125
+ ): Promise<void> {
126
+ await seedTenantMembership(stack.db, { userId, tenantId, roles });
127
+ }
128
+
129
+ async function tokenFor(userId: string, tenantId: TenantId): Promise<string> {
130
+ return stack.jwt.sign({ id: userId, tenantId, roles: ["Member"] });
131
+ }
132
+
133
+ async function switchTenant(token: string, tenantId: TenantId) {
134
+ return stack.http.raw(
135
+ "POST",
136
+ "/api/auth/switch-tenant",
137
+ { tenantId },
138
+ { Authorization: `Bearer ${token}` },
139
+ );
140
+ }
141
+
142
+ describe("switch-tenant :: active-membership building block", () => {
143
+ test("switching into a tenant with no membership → 403 not_a_member", async () => {
144
+ await createTenant(TENANT_A);
145
+ await createTenant(TENANT_B);
146
+ const userId = await createUser("nomember@example.com", "pw-long-enough-1");
147
+ await addMembership(userId, TENANT_A);
148
+
149
+ const token = await tokenFor(userId, TENANT_A);
150
+ const res = await switchTenant(token, TENANT_B);
151
+
152
+ expect(res.status).toBe(403);
153
+ const body = (await res.json()) as { error?: string };
154
+ expect(body.error).toBe("not_a_member");
155
+ });
156
+
157
+ test("switching as a restricted principal who IS a member of the target → 403 principal_blocked (JWT itself stays valid, no sessions feature to revoke it)", async () => {
158
+ await createTenant(TENANT_A);
159
+ await createTenant(TENANT_B);
160
+ const userId = await createUser("restricted@example.com", "pw-long-enough-2");
161
+ await addMembership(userId, TENANT_A);
162
+ await addMembership(userId, TENANT_B);
163
+ await updateRows(stack.db, userTable, { status: USER_STATUS.Restricted }, { id: userId });
164
+
165
+ const token = await tokenFor(userId, TENANT_A);
166
+ const res = await switchTenant(token, TENANT_B);
167
+
168
+ expect(res.status).toBe(403);
169
+ const body = (await res.json()) as { error?: string };
170
+ expect(body.error).toBe("principal_blocked");
171
+ });
172
+
173
+ test("switching into a tenant mid-teardown (destroying/destroyFailed/destroyed) → 410 tenant_unavailable; destroyRequested still allows it (cancel window)", async () => {
174
+ await createTenant(TENANT_A);
175
+ await createTenant(TENANT_B);
176
+ const userId = await createUser("teardown@example.com", "pw-long-enough-3");
177
+ await addMembership(userId, TENANT_A);
178
+ await addMembership(userId, TENANT_B);
179
+ const token = await tokenFor(userId, TENANT_A);
180
+
181
+ for (const status of ["destroying", "destroyFailed", "destroyed"] as const) {
182
+ await setTenantStatus(TENANT_B, status);
183
+ const res = await switchTenant(token, TENANT_B);
184
+ expect(res.status).toBe(410);
185
+ expect(((await res.json()) as { error?: string }).error).toBe("tenant_unavailable");
186
+ }
187
+
188
+ await setTenantStatus(TENANT_B, "destroyRequested");
189
+ const pendingRes = await switchTenant(token, TENANT_B);
190
+ expect(pendingRes.status).toBe(200);
191
+ });
192
+
193
+ test("a non-member switching into a destroying tenant still gets not_a_member, not tenant_unavailable (membership checked before lifecycle — a non-member must not learn the target's teardown state)", async () => {
194
+ await createTenant(TENANT_A);
195
+ await createTenant(TENANT_B);
196
+ const userId = await createUser("outsider@example.com", "pw-long-enough-4");
197
+ await addMembership(userId, TENANT_A);
198
+ await setTenantStatus(TENANT_B, "destroying");
199
+
200
+ const token = await tokenFor(userId, TENANT_A);
201
+ const res = await switchTenant(token, TENANT_B);
202
+
203
+ expect(res.status).toBe(403);
204
+ expect(((await res.json()) as { error?: string }).error).toBe("not_a_member");
205
+ });
206
+ });
207
+
208
+ describe("login :: active-membership building block", () => {
209
+ test("restricted user login → rejected with the existing account_restricted error (gateEnforceAccountStatus runs before gateResolveMembership, behaviour unchanged)", async () => {
210
+ await createTenant(TENANT_A);
211
+ const userId = await createUser("restrictedlogin@example.com", "pw-long-enough-5");
212
+ await addMembership(userId, TENANT_A);
213
+ await updateRows(stack.db, userTable, { status: USER_STATUS.Restricted }, { id: userId });
214
+
215
+ const res = await stack.http.raw("POST", "/api/auth/login", {
216
+ email: "restrictedlogin@example.com",
217
+ password: "pw-long-enough-5",
218
+ });
219
+
220
+ expect(res.status).toBe(422);
221
+ const body = (await res.json()) as { error?: { details?: { reason?: string } } };
222
+ expect(body.error?.details?.reason).toBe(AuthErrors.accountRestricted);
223
+ });
224
+
225
+ test("active user whose last-active tenant is destroying falls through to a second active membership", async () => {
226
+ await createTenant(TENANT_A);
227
+ await createTenant(TENANT_B);
228
+ const userId = await createUser("fallback@example.com", "pw-long-enough-6");
229
+ await addMembership(userId, TENANT_A);
230
+ await addMembership(userId, TENANT_B);
231
+ await updateRows(stack.db, userTable, { lastActiveTenantId: TENANT_A }, { id: userId });
232
+ await setTenantStatus(TENANT_A, "destroying");
233
+
234
+ const res = await stack.http.raw("POST", "/api/auth/login", {
235
+ email: "fallback@example.com",
236
+ password: "pw-long-enough-6",
237
+ });
238
+
239
+ expect(res.status).toBe(200);
240
+ const body = (await res.json()) as { user: { tenantId: string } };
241
+ expect(body.user.tenantId).toBe(TENANT_B);
242
+ });
243
+ });
244
+
245
+ describe("dispatcher.resolveActiveMembership", () => {
246
+ test("unknown principal (no persisted user row) with a membership resolves active", async () => {
247
+ await createTenant(TENANT_A);
248
+ const unknownUserId = crypto.randomUUID();
249
+ await addMembership(unknownUserId, TENANT_A);
250
+
251
+ const result = await stack.dispatcher.resolveActiveMembership(unknownUserId, TENANT_A);
252
+
253
+ expect(result.kind).toBe("active");
254
+ });
255
+ });
@@ -4,6 +4,7 @@ import {
4
4
  createSystemUser,
5
5
  defineWriteHandler,
6
6
  type SessionUser,
7
+ TENANT_MEMBERSHIPS_QUERY,
7
8
  type TenantId,
8
9
  type WriteResult,
9
10
  } from "@cosmicdrift/kumiko-framework/engine";
@@ -178,7 +179,9 @@ export async function gateResolveMembership(
178
179
  systemUser: SessionUser,
179
180
  found: AuthUserRow,
180
181
  ): Promise<GateOutcome<{ readonly chosen: Membership; readonly mergedRoles: readonly string[] }>> {
181
- const memberships = (await ctx.queryAs(systemUser, "tenant:query:memberships", {
182
+ // Still needed for candidate ORDER (preferred tenant first) — the actual
183
+ // active/blocked/teardown decision comes from ctx.resolveActiveMembership below.
184
+ const memberships = (await ctx.queryAs(systemUser, TENANT_MEMBERSHIPS_QUERY, {
182
185
  userId: found.id,
183
186
  })) as Array<Membership>; // @cast-boundary db-runner
184
187
 
@@ -190,17 +193,34 @@ export async function gateResolveMembership(
190
193
  found.lastActiveTenantId !== null && found.lastActiveTenantId !== undefined
191
194
  ? memberships.find((m) => m.tenantId === found.lastActiveTenantId)
192
195
  : undefined;
193
- const chosen = preferred ?? memberships[0];
194
- if (!chosen) {
195
- return reject(noMembership());
196
- }
196
+ const candidates = preferred
197
+ ? [preferred, ...memberships.filter((m) => m !== preferred)]
198
+ : memberships;
197
199
 
198
200
  const globalRoles = parseRoles(found.roles ?? null);
199
- // buildSessionRoles calls stripForbiddenMembershipRoles to strip reserved
200
- // roles only (globalRoles keeps SystemAdmin) — read-time backstop against a
201
- // rebuild-resurrected role.
202
- const mergedRoles = buildSessionRoles(globalRoles, chosen.roles);
203
- return ok({ chosen, mergedRoles });
201
+
202
+ for (const candidate of candidates) {
203
+ const active = await ctx.resolveActiveMembership(found.id, candidate.tenantId);
204
+ if (active.kind === "rejected") {
205
+ // A blocked principal is blocked for every tenant, so stop here;
206
+ // not_a_member/tenant_teardown are per-tenant — try the next candidate.
207
+ if (active.reason === "principal_blocked") {
208
+ return reject(invalidCredentials());
209
+ }
210
+ continue;
211
+ }
212
+ const chosen: Membership = {
213
+ tenantId: active.membership.tenantId,
214
+ roles: active.membership.roles,
215
+ };
216
+ // buildSessionRoles calls stripForbiddenMembershipRoles to strip reserved
217
+ // roles only (globalRoles keeps SystemAdmin) — read-time backstop against a
218
+ // rebuild-resurrected role.
219
+ const mergedRoles = buildSessionRoles(globalRoles, chosen.roles);
220
+ return ok({ chosen, mergedRoles });
221
+ }
222
+
223
+ return reject(noMembership());
204
224
  }
205
225
 
206
226
  // MFA challenge / setup-required / proceed. Excludes "auth-session" (this
@@ -266,8 +286,8 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
266
286
  escapeHatch: {
267
287
  reason:
268
288
  "Unauthenticated login has no caller identity yet — it looks up the user row by " +
269
- "email and resolves tenant memberships via ctx.queryAs(SYSTEM, ...) before a " +
270
- "session exists.",
289
+ "email via ctx.queryAs(SYSTEM, ...) and resolves tenant membership via " +
290
+ "ctx.resolveActiveMembership before a session exists.",
271
291
  },
272
292
  description:
273
293
  "Signs a user in with email and password, running the lockout, email-verification, account-status, tenant-membership and MFA gates, and answering with a session or with an MFA challenge or setup requirement.",
@@ -16,13 +16,19 @@ import {
16
16
  } from "@cosmicdrift/kumiko-framework/testing";
17
17
  import { AuthHandlers as AuthEmailPasswordHandlers } from "../../auth-email-password/constants";
18
18
  import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
19
+ import {
20
+ createComplianceProfilesFeature,
21
+ tenantComplianceProfileEntity,
22
+ } from "../../compliance-profiles";
19
23
  import { createConfigFeature } from "../../config";
20
24
  import { createConfigResolver } from "../../config/resolver";
21
25
  import { configValuesTable } from "../../config/table";
22
26
  import { hashPassword } from "../../shared";
23
27
  import { createTenantFeature } from "../../tenant";
24
28
  import { tenantMembershipsTable } from "../../tenant/membership-table";
25
- import { tenantEntity } from "../../tenant/schema/tenant";
29
+ import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
30
+ import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
31
+ import { resetTenantLifecycleGateCacheForTests } from "../../tenant-lifecycle/lifecycle-gate";
26
32
  import { USER_STATUS } from "../../user";
27
33
  import { createUserFeature } from "../../user/feature";
28
34
  import { userEntity, userTable } from "../../user/schema/user";
@@ -62,6 +68,8 @@ beforeAll(async () => {
62
68
  createConfigFeature(),
63
69
  createUserFeature(),
64
70
  createTenantFeature(),
71
+ createComplianceProfilesFeature(),
72
+ createTenantLifecycleFeature(),
65
73
  authMfaFeature,
66
74
  createAuthEmailPasswordFeature({
67
75
  mfaStatusChecker: mfaStatusCheckerFromFeature(authMfaFeature),
@@ -75,6 +83,7 @@ beforeAll(async () => {
75
83
  });
76
84
  await unsafeCreateEntityTable(stack.db, userEntity);
77
85
  await unsafeCreateEntityTable(stack.db, tenantEntity);
86
+ await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
78
87
  await unsafeCreateEntityTable(stack.db, userMfaEntity);
79
88
  await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
80
89
  });
@@ -350,6 +359,37 @@ describe("mfa verify — re-checks account state the way login.write.ts does", (
350
359
  expectErrorIncludes(err, "invalid_challenge_token");
351
360
  });
352
361
 
362
+ test("tenant enters teardown between login and verify → challenge rejected", async () => {
363
+ const { user, secret } = await enableMfaFor(11);
364
+ const challengeToken = challengeFor(user.id, user.tenantId);
365
+
366
+ // user.tenantId is the shared default test tenant (TestUsers.admin) —
367
+ // restore it to "active" afterwards so later tests reusing it aren't affected.
368
+ await seedRow(stack.db, tenantTable, {
369
+ id: user.tenantId,
370
+ tenantId: user.tenantId,
371
+ key: `t-${user.tenantId.slice(-8)}`,
372
+ name: "Tenant",
373
+ status: "destroying",
374
+ });
375
+ resetTenantLifecycleGateCacheForTests();
376
+
377
+ try {
378
+ const err = await stack.http.writeErr(
379
+ AuthMfaHandlers.verify,
380
+ { challengeToken, code: currentTotpCode(secret) },
381
+ GUEST,
382
+ );
383
+ expectErrorIncludes(err, "invalid_challenge_token");
384
+ } finally {
385
+ await asRawClient(stack.db).unsafe(
386
+ `UPDATE "${tenantTable.tableName}" SET status = $1 WHERE id = $2`,
387
+ ["active", user.tenantId],
388
+ );
389
+ resetTenantLifecycleGateCacheForTests();
390
+ }
391
+ });
392
+
353
393
  test("user row homed in a DIFFERENT tenant than the challenge → verify succeeds (#1235)", async () => {
354
394
  const { user, secret } = await enableMfaFor(8);
355
395
 
@@ -66,8 +66,8 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
66
66
  access: { roles: ["all"] },
67
67
  escapeHatch: {
68
68
  reason:
69
- "Pre-auth MFA enrollment step has no session yet — re-checks status and membership via " +
70
- "ctx.queryAs(SYSTEM, user:findForAuth / tenant:query:memberships) for the setup token's user.",
69
+ "Pre-auth MFA enrollment step has no session yet — re-checks status via ctx.queryAs(SYSTEM, " +
70
+ "user:findForAuth) and membership via ctx.resolveActiveMembership for the setup token's user.",
71
71
  },
72
72
  description:
73
73
  "Completes the enrollment that unblocks a sign-in forced into two-factor setup: verifies the code against the pre-auth setup token, stores the factor and derives the session the blocked login never got.",
@@ -165,11 +165,8 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
165
165
 
166
166
  const globalRoles = parseRoles(userRow?.roles ?? null);
167
167
 
168
- const memberships = (await ctx.queryAs(systemUser, "tenant:query:memberships", {
169
- userId,
170
- })) as ReadonlyArray<{ tenantId: string; roles: readonly string[] }>; // @cast-boundary engine-payload
171
- const membership = memberships.find((m) => m.tenantId === tenantId);
172
- if (!membership) return invalidSetupToken();
168
+ const active = await ctx.resolveActiveMembership(userId, tenantId);
169
+ if (active.kind === "rejected") return invalidSetupToken();
173
170
 
174
171
  const result = await executor.create(
175
172
  {
@@ -195,7 +192,7 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
195
192
  // roles from the membership portion (globalRoles keeps SystemAdmin) —
196
193
  // read-time backstop against a rebuild-resurrected role, same as
197
194
  // verify.write.ts.
198
- const mergedRoles = buildSessionRoles(globalRoles, membership.roles);
195
+ const mergedRoles = buildSessionRoles(globalRoles, active.membership.roles);
199
196
 
200
197
  const baseSession: SessionUser = {
201
198
  id: userId,
@@ -49,7 +49,7 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
49
49
  escapeHatch: {
50
50
  reason:
51
51
  "Pre-auth MFA step has no session yet — re-derives it via ctx.queryAs(SYSTEM, " +
52
- "user:findForAuth / tenant:query:memberships) for the user the challenge token names.",
52
+ "user:findForAuth) and ctx.resolveActiveMembership for the user the challenge token names.",
53
53
  },
54
54
  description:
55
55
  "Finishes a two-step sign-in by checking a TOTP or recovery code against the challenge token that login handed back, under a per-account attempt cap, and derives the resulting session.",
@@ -166,19 +166,14 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
166
166
 
167
167
  const globalRoles = parseRoles(userRow?.roles ?? null);
168
168
 
169
- const memberships = (await ctx.queryAs(systemUser, "tenant:query:memberships", {
170
- userId,
171
- })) as ReadonlyArray<{ tenantId: string; roles: readonly string[] }>; // @cast-boundary engine-payload
172
- const membership = memberships.find((m) => m.tenantId === tenantId);
173
- // Membership revoked between login and verify (race, or a stale
174
- // challenge token from before removal) — login.write.ts would have
175
- // refused with noMembership() at the same juncture; mirror it here
176
- // instead of silently falling back to globalRoles only.
177
- if (!membership) return invalidChallengeToken();
169
+ // Membership revoked or principal blocked between login and verify —
170
+ // mirror login.write.ts's refusal instead of falling back to globalRoles only.
171
+ const active = await ctx.resolveActiveMembership(userId, tenantId);
172
+ if (active.kind === "rejected") return invalidChallengeToken();
178
173
  // buildSessionRoles calls stripForbiddenMembershipRoles to strip reserved
179
174
  // roles from the membership portion (globalRoles keeps SystemAdmin) —
180
175
  // read-time backstop against a rebuild-resurrected role.
181
- const mergedRoles = buildSessionRoles(globalRoles, membership.roles);
176
+ const mergedRoles = buildSessionRoles(globalRoles, active.membership.roles);
182
177
 
183
178
  const baseSession: SessionUser = {
184
179
  id: userId,
@@ -18,7 +18,7 @@ import { generateId, parseRoles } from "@cosmicdrift/kumiko-framework/utils";
18
18
  import { Temporal } from "temporal-polyfill";
19
19
  import { encryptForDirectWrite } from "../shared";
20
20
  import { tenantMembershipsTable } from "../tenant";
21
- import { USER_STATUS, type UserStatus, userTable } from "../user";
21
+ import { isPrincipalBlocked, type UserStatus, userTable } from "../user";
22
22
  import { DEFAULT_SESSION_EXPIRY_MS, LAST_SEEN_REFRESH_MS } from "./constants";
23
23
  import { userSessionEntity, userSessionTable } from "./schema/user-session";
24
24
  import {
@@ -27,19 +27,8 @@ import {
27
27
  sessionRevokedSchema,
28
28
  } from "./session-revoked-event";
29
29
 
30
- // Locked accounts whose live sessions must be refused. deletionRequested is
31
- // intentionally absent — it's a reversible grace period and the user needs
32
- // their session to reach cancel-deletion.
33
- const BLOCKED_STATUSES: ReadonlySet<UserStatus> = new Set([
34
- USER_STATUS.Restricted,
35
- USER_STATUS.Deleted,
36
- ]);
37
-
38
- // Shared with personal-access-tokens' resolver — a PAT belonging to a
39
- // locked-out principal must be refused the same way a live session is.
40
- export function isPrincipalBlocked(status: UserStatus): boolean {
41
- return BLOCKED_STATUSES.has(status);
42
- }
30
+ // Re-exported so existing `../sessions` importers keep working unchanged.
31
+ export { isPrincipalBlocked };
43
32
 
44
33
  // Why the callbacks live at the raw-DB level rather than going through the
45
34
  // dispatcher: session-create/revoke/check run on the hot path of every
@@ -29,6 +29,8 @@ import type { Hono } from "hono";
29
29
  import { createConfigFeature } from "../../config/feature";
30
30
  import { createConfigResolver } from "../../config/resolver";
31
31
  import { configValuesTable } from "../../config/table";
32
+ import { createUserFeature } from "../../user/feature";
33
+ import { userEntity } from "../../user/schema/user";
32
34
  import { TenantHandlers, TenantQueries } from "../constants";
33
35
  import { createTenantFeature } from "../feature";
34
36
  import { tenantMembershipsTable } from "../membership-table";
@@ -65,12 +67,14 @@ beforeAll(async () => {
65
67
  db = testDb.db;
66
68
 
67
69
  await unsafeCreateEntityTable(db, tenantEntity);
70
+ await unsafeCreateEntityTable(db, userEntity);
68
71
  await unsafePushTables(db, { tenantMembershipsTable, configValuesTable });
69
72
  await createEventsTable(db);
70
73
 
71
74
  const configFeature = createConfigFeature();
75
+ const userFeature = createUserFeature();
72
76
  const tenantFeature = createTenantFeature();
73
- const registry = createRegistry([configFeature, tenantFeature, billingFeature]);
77
+ const registry = createRegistry([configFeature, userFeature, tenantFeature, billingFeature]);
74
78
  const resolver = createConfigResolver();
75
79
 
76
80
  const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
@@ -5,7 +5,9 @@ import {
5
5
  EXT_SEARCH_ADAPTER,
6
6
  EXT_STORAGE_PROVIDER,
7
7
  EXT_TENANT_DATA,
8
+ EXT_TENANT_LIFECYCLE_STATUS,
8
9
  type FeatureDefinition,
10
+ type TenantLifecycleStatusPlugin,
9
11
  } from "@cosmicdrift/kumiko-framework/engine";
10
12
  import { validateTenantDataHookCoverage } from "./boot-checks";
11
13
  import {
@@ -32,7 +34,14 @@ import {
32
34
  } from "./events";
33
35
  import { cancelDestructionWrite } from "./handlers/cancel-destruction.write";
34
36
  import { requestDestructionWrite } from "./handlers/request-destruction.write";
35
- import { runTenantDestructionSweep } from "./run-tenant-destroy";
37
+ import { resolveTenantLifecycleGate, runTenantDestructionSweep } from "./run-tenant-destroy";
38
+
39
+ const tenantLifecycleStatusPlugin: TenantLifecycleStatusPlugin = {
40
+ async resolveStatus(tenantId, { db }) {
41
+ const gate = await resolveTenantLifecycleGate(db, tenantId);
42
+ return gate ? { status: gate.status } : null;
43
+ },
44
+ };
36
45
 
37
46
  export function createTenantLifecycleFeature(): FeatureDefinition {
38
47
  return defineFeature("tenant-lifecycle", (r) => {
@@ -54,6 +63,11 @@ export function createTenantLifecycleFeature(): FeatureDefinition {
54
63
  r.extendsRegistrar(EXT_STORAGE_PROVIDER, {});
55
64
  r.extendsRegistrar(EXT_INFRA_RESOURCE, {});
56
65
 
66
+ // Self-extension, same pattern as `user`/EXT_PRINCIPAL_STATUS — declares
67
+ // AND fulfils tenantLifecycleStatus so teardown state is visible with no per-app wiring.
68
+ r.extendsRegistrar(EXT_TENANT_LIFECYCLE_STATUS, {});
69
+ r.useExtension(EXT_TENANT_LIFECYCLE_STATUS, "tenant-lifecycle", tenantLifecycleStatusPlugin);
70
+
57
71
  // GDPR-storage guard V4 (#1314) — moved off the framework-internal
58
72
  // boot-validator onto this feature's own r.bootCheck(), since it owns
59
73
  // EXT_TENANT_DATA: its own mount is the trigger, matching the original
@@ -1,4 +1,8 @@
1
- import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
1
+ import {
2
+ defineFeature,
3
+ EXT_PRINCIPAL_STATUS,
4
+ type FeatureDefinition,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
2
6
  import { createWrite } from "./handlers/create.write";
3
7
  import { detailQuery } from "./handlers/detail.query";
4
8
  import { findForAuthQuery } from "./handlers/find-for-auth.query";
@@ -6,6 +10,7 @@ import { listQuery } from "./handlers/list.query";
6
10
  import { meQuery } from "./handlers/me.query";
7
11
  import { updateWrite } from "./handlers/update.write";
8
12
  import { USER_I18N } from "./i18n";
13
+ import { principalStatusPlugin } from "./principal-status";
9
14
  import { userEntity } from "./schema/user";
10
15
  import { userEditScreen, userListScreen } from "./screens";
11
16
 
@@ -25,6 +30,11 @@ export function createUserFeature(): FeatureDefinition {
25
30
  r.systemScope();
26
31
  r.entity("user", userEntity);
27
32
 
33
+ // Self-extension: `user` declares AND fulfils principalStatus (precedent:
34
+ // tier-engine/feature.ts) — wires the blocked-principal check into every stack that mounts `user`.
35
+ r.extendsRegistrar(EXT_PRINCIPAL_STATUS, {});
36
+ r.useExtension(EXT_PRINCIPAL_STATUS, "user", principalStatusPlugin);
37
+
28
38
  const handlers = {
29
39
  create: r.writeHandler(createWrite),
30
40
  update: r.writeHandler(updateWrite),
package/src/user/index.ts CHANGED
@@ -5,6 +5,7 @@ export {
5
5
  type UserStreamBackfillResult,
6
6
  } from "./db/queries/stream-tenant-backfill";
7
7
  export { createUserFeature } from "./feature";
8
+ export { isPrincipalBlocked, principalStatusPlugin } from "./principal-status";
8
9
  export type { UserStatus } from "./schema/user";
9
10
  export {
10
11
  USER_ANONYMIZED_DISPLAY_NAME,
@@ -0,0 +1,26 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { PrincipalStatus, PrincipalStatusPlugin } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { USER_STATUS, type UserStatus, userTable } from "./schema/user";
4
+
5
+ // Locked accounts whose live sessions must be refused. deletionRequested is
6
+ // intentionally absent — it's a reversible grace period and the user needs their session to reach cancel-deletion.
7
+ const BLOCKED_STATUSES: ReadonlySet<UserStatus> = new Set([
8
+ USER_STATUS.Restricted,
9
+ USER_STATUS.Deleted,
10
+ ]);
11
+
12
+ // Shared with personal-access-tokens' resolver and sessions' sessionChecker
13
+ // — a PAT or a live session belonging to a locked-out principal is refused the same way.
14
+ export function isPrincipalBlocked(status: UserStatus): boolean {
15
+ return BLOCKED_STATUSES.has(status);
16
+ }
17
+
18
+ // Registered via r.useExtension(EXT_PRINCIPAL_STATUS, "user", principalStatusPlugin)
19
+ // so resolveActiveMembershipFn can reject a blocked principal without knowing about `user`'s table.
20
+ export const principalStatusPlugin: PrincipalStatusPlugin = {
21
+ async resolveStatus(userId, { db }): Promise<PrincipalStatus> {
22
+ const row = await fetchOne<{ status: UserStatus }>(db, userTable, { id: userId });
23
+ if (!row) return "unknown";
24
+ return isPrincipalBlocked(row.status) ? "blocked" : "active";
25
+ },
26
+ };