@cosmicdrift/kumiko-bundled-features 0.165.1 → 0.165.2

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.165.1",
3
+ "version": "0.165.2",
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>",
@@ -119,11 +119,11 @@
119
119
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
120
120
  },
121
121
  "dependencies": {
122
- "@cosmicdrift/kumiko-dispatcher-live": "0.165.1",
123
- "@cosmicdrift/kumiko-framework": "0.165.1",
124
- "@cosmicdrift/kumiko-headless": "0.165.1",
125
- "@cosmicdrift/kumiko-renderer": "0.165.1",
126
- "@cosmicdrift/kumiko-renderer-web": "0.165.1",
122
+ "@cosmicdrift/kumiko-dispatcher-live": "0.165.2",
123
+ "@cosmicdrift/kumiko-framework": "0.165.2",
124
+ "@cosmicdrift/kumiko-headless": "0.165.2",
125
+ "@cosmicdrift/kumiko-renderer": "0.165.2",
126
+ "@cosmicdrift/kumiko-renderer-web": "0.165.2",
127
127
  "@mollie/api-client": "^4.5.0",
128
128
  "imapflow": "^1.3.3",
129
129
  "mailparser": "^3.9.8",
@@ -148,7 +148,7 @@
148
148
  "LICENSE"
149
149
  ],
150
150
  "peerDependencies": {
151
- "@cosmicdrift/kumiko-types": "^0.165.1"
151
+ "@cosmicdrift/kumiko-types": "^0.165.2"
152
152
  },
153
153
  "devDependencies": {
154
154
  "@testing-library/user-event": "^14.6.1",
@@ -172,13 +172,7 @@ describe("access-invalidation mid-stream SSE teardown (#1561)", () => {
172
172
 
173
173
  releaseNextChunk?.();
174
174
 
175
- let thrown: unknown;
176
- try {
177
- await iter.next();
178
- } catch (e) {
179
- thrown = e;
180
- }
181
- expect(thrown).toMatchObject({ code: "access_denied" });
175
+ await expect(iter.next()).rejects.toMatchObject({ code: "access_denied" });
182
176
  });
183
177
 
184
178
  test("tenant role strip mid-stream terminates the open HTTP SSE stream", async () => {
@@ -206,12 +200,6 @@ describe("access-invalidation mid-stream SSE teardown (#1561)", () => {
206
200
 
207
201
  releaseNextChunk?.();
208
202
 
209
- let thrown: unknown;
210
- try {
211
- await iter.next();
212
- } catch (e) {
213
- thrown = e;
214
- }
215
- expect(thrown).toMatchObject({ code: "access_denied" });
203
+ await expect(iter.next()).rejects.toMatchObject({ code: "access_denied" });
216
204
  });
217
205
  });
@@ -0,0 +1,39 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { USER_STATUS } from "../../user";
3
+ import type { AuthUserRow } from "../auth-user-row";
4
+ import { gateEnforceAccountStatus, gateEnforceEmailVerified } from "../handlers/login.write";
5
+
6
+ function row(over: Partial<AuthUserRow> = {}): AuthUserRow {
7
+ return { id: "00000000-0000-4000-8000-000000000001", passwordHash: "x", ...over };
8
+ }
9
+
10
+ describe("login.write gates (fw#1284)", () => {
11
+ test("gateEnforceEmailVerified rejects when strict and unverified", () => {
12
+ const g = gateEnforceEmailVerified(row({ emailVerified: false }), true);
13
+ expect(g.ok).toBe(false);
14
+ if (!g.ok) expect(g.result.isSuccess).toBe(false);
15
+ });
16
+
17
+ test("gateEnforceEmailVerified passes when not strict", () => {
18
+ expect(gateEnforceEmailVerified(row({ emailVerified: false }), false).ok).toBe(true);
19
+ });
20
+
21
+ test("gateEnforceAccountStatus rejects restricted", () => {
22
+ const g = gateEnforceAccountStatus(row({ status: USER_STATUS.Restricted }));
23
+ expect(g.ok).toBe(false);
24
+ });
25
+
26
+ test("gateEnforceAccountStatus rejects deletion_requested as invalid_creds shape", () => {
27
+ const g = gateEnforceAccountStatus(row({ status: USER_STATUS.DeletionRequested }));
28
+ expect(g.ok).toBe(false);
29
+ if (!g.ok) {
30
+ expect(g.result.isSuccess).toBe(false);
31
+ // anti-enumeration — same family as invalid credentials
32
+ expect(g.result).toMatchObject({ isSuccess: false });
33
+ }
34
+ });
35
+
36
+ test("gateEnforceAccountStatus passes active", () => {
37
+ expect(gateEnforceAccountStatus(row({ status: USER_STATUS.Active })).ok).toBe(true);
38
+ });
39
+ });
@@ -11,7 +11,7 @@ import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
11
11
  import { z } from "zod";
12
12
  import { verifyDummyPassword, verifyPassword } from "../../shared";
13
13
  import { USER_STATUS, UserQueries } from "../../user";
14
- import { parseAuthUserRow } from "../auth-user-row";
14
+ import { type AuthUserRow, parseAuthUserRow } from "../auth-user-row";
15
15
  import {
16
16
  AUTH_LOCKOUT_DEFAULT_DURATION_MINUTES,
17
17
  AUTH_LOCKOUT_DEFAULT_MAX_FAILED_ATTEMPTS,
@@ -72,6 +72,170 @@ type LoginResult =
72
72
  | { readonly kind: "mfa-challenge"; readonly challengeToken: string }
73
73
  | { readonly kind: "mfa-setup-required"; readonly preauthSetupToken: string };
74
74
 
75
+ type Membership = { readonly tenantId: TenantId; readonly roles: readonly string[] };
76
+
77
+ type GateReject = { readonly ok: false; readonly result: WriteResult<LoginResult> };
78
+ type GateOk<T> = { readonly ok: true; readonly value: T };
79
+ type GateOutcome<T> = GateReject | GateOk<T>;
80
+
81
+ function reject(result: WriteResult<LoginResult>): GateReject {
82
+ return { ok: false, result };
83
+ }
84
+
85
+ function ok<T>(value: T): GateOk<T> {
86
+ return { ok: true, value };
87
+ }
88
+
89
+ /** Uniform response on any credential miss — burns argon2 cost (#774). */
90
+ export async function gateResolveAuthUser(
91
+ ctx: HandlerContext,
92
+ systemUser: SessionUser,
93
+ email: string,
94
+ password: string,
95
+ ): Promise<GateOutcome<AuthUserRow>> {
96
+ const found = parseAuthUserRow(await ctx.queryAs(systemUser, UserQueries.findForAuth, { email }));
97
+ if (!found?.passwordHash || found.isDeleted) {
98
+ await verifyDummyPassword(password);
99
+ return reject(invalidCredentials());
100
+ }
101
+ return ok(found);
102
+ }
103
+
104
+ /**
105
+ * Lockout BEFORE password verify — locked accounts can't be password-probed.
106
+ * Fail-open without Redis (IP rate-limit still covers partially).
107
+ */
108
+ export async function gateEnforceLockout(
109
+ ctx: HandlerContext,
110
+ userId: string,
111
+ ): Promise<GateOutcome<undefined>> {
112
+ if (!ctx.redis) return ok(undefined);
113
+ const state = await getLockoutState(ctx.redis, userId);
114
+ if (state?.lockedUntil !== null && state?.lockedUntil !== undefined) {
115
+ const now = Date.now();
116
+ if (state.lockedUntil > now) {
117
+ const retryAfterSeconds = Math.max(1, Math.ceil((state.lockedUntil - now) / 1000));
118
+ return reject(accountLocked(retryAfterSeconds));
119
+ }
120
+ }
121
+ return ok(undefined);
122
+ }
123
+
124
+ /** Verify password; record miss / clear lockout on hit. */
125
+ export async function gateVerifyPassword(
126
+ ctx: HandlerContext,
127
+ found: AuthUserRow,
128
+ password: string,
129
+ maxFailedAttempts: number,
130
+ lockoutDurationMinutes: number,
131
+ ): Promise<GateOutcome<undefined>> {
132
+ const passwordHash = found.passwordHash;
133
+ if (!passwordHash) return reject(invalidCredentials());
134
+ const passwordOk = await verifyPassword(passwordHash, password);
135
+ if (!passwordOk) {
136
+ if (ctx.redis) {
137
+ await recordFailedAttempt(ctx.redis, found.id, maxFailedAttempts, lockoutDurationMinutes);
138
+ }
139
+ return reject(invalidCredentials());
140
+ }
141
+ // Clear before MFA — MFA has its own attempt-cap; don't password-lock
142
+ // users who occasionally mistype across otherwise-successful logins.
143
+ if (ctx.redis) {
144
+ await clearLockoutState(ctx.redis, found.id);
145
+ }
146
+ return ok(undefined);
147
+ }
148
+
149
+ /** Strict email verification — after password, before session. */
150
+ export function gateEnforceEmailVerified(
151
+ found: AuthUserRow,
152
+ strictVerification: boolean,
153
+ ): GateOutcome<undefined> {
154
+ if (strictVerification && found.emailVerified !== true) {
155
+ return reject(emailNotVerified());
156
+ }
157
+ return ok(undefined);
158
+ }
159
+
160
+ /** DSGVO Art. 18 freeze + forget-path anti-enumeration. */
161
+ export function gateEnforceAccountStatus(found: AuthUserRow): GateOutcome<undefined> {
162
+ if (found.status === USER_STATUS.Restricted) {
163
+ return reject(accountRestricted());
164
+ }
165
+ if (found.status === USER_STATUS.DeletionRequested || found.status === USER_STATUS.Deleted) {
166
+ return reject(invalidCredentials());
167
+ }
168
+ return ok(undefined);
169
+ }
170
+
171
+ /** Pick membership (last-active preferred); merge global + tenant roles. */
172
+ export async function gateResolveMembership(
173
+ ctx: HandlerContext,
174
+ systemUser: SessionUser,
175
+ found: AuthUserRow,
176
+ ): Promise<GateOutcome<{ readonly chosen: Membership; readonly mergedRoles: readonly string[] }>> {
177
+ const memberships = (await ctx.queryAs(systemUser, "tenant:query:memberships", {
178
+ userId: found.id,
179
+ })) as Array<Membership>; // @cast-boundary db-runner
180
+
181
+ if (memberships.length === 0) {
182
+ return reject(noMembership());
183
+ }
184
+
185
+ const preferred =
186
+ found.lastActiveTenantId !== null && found.lastActiveTenantId !== undefined
187
+ ? memberships.find((m) => m.tenantId === found.lastActiveTenantId)
188
+ : undefined;
189
+ const chosen = preferred ?? memberships[0];
190
+ if (!chosen) {
191
+ return reject(noMembership());
192
+ }
193
+
194
+ const globalRoles = parseRoles(found.roles ?? null);
195
+ // buildSessionRoles calls stripForbiddenMembershipRoles to strip reserved
196
+ // roles only (globalRoles keeps SystemAdmin) — read-time backstop against a
197
+ // rebuild-resurrected role.
198
+ const mergedRoles = buildSessionRoles(globalRoles, chosen.roles);
199
+ return ok({ chosen, mergedRoles });
200
+ }
201
+
202
+ /** MFA challenge / setup-required / proceed. */
203
+ export async function gateEnforceMfa(
204
+ ctx: HandlerContext,
205
+ opts: LoginHandlerOptions,
206
+ userId: string,
207
+ tenantId: TenantId,
208
+ mergedRoles: readonly string[],
209
+ ): Promise<GateOutcome<LoginResult | undefined>> {
210
+ if (!opts.mfaStatusChecker) return ok(undefined);
211
+ const mfaStatus = await opts.mfaStatusChecker(ctx, userId, tenantId, mergedRoles);
212
+ if ("challengeToken" in mfaStatus) {
213
+ return ok({ kind: "mfa-challenge", challengeToken: mfaStatus.challengeToken });
214
+ }
215
+ if ("setupRequired" in mfaStatus) {
216
+ return ok({ kind: "mfa-setup-required", preauthSetupToken: mfaStatus.preauthSetupToken });
217
+ }
218
+ return ok(undefined);
219
+ }
220
+
221
+ /** Auth-claims hooks → session. */
222
+ export async function gateBuildSession(
223
+ ctx: HandlerContext,
224
+ userId: string,
225
+ tenantId: TenantId,
226
+ mergedRoles: readonly string[],
227
+ ): Promise<GateOutcome<{ readonly kind: "auth-session"; readonly session: SessionUser }>> {
228
+ const baseSession: SessionUser = {
229
+ id: userId,
230
+ tenantId,
231
+ roles: mergedRoles,
232
+ };
233
+ const claims = await ctx.resolveAuthClaims(baseSession);
234
+ const session: SessionUser =
235
+ Object.keys(claims).length > 0 ? { ...baseSession, claims } : baseSession;
236
+ return ok({ kind: "auth-session", session });
237
+ }
238
+
75
239
  // Login — unauthenticated entry point. The route is wired public (no JWT
76
240
  // middleware), synthesising a guest SessionUser for the handler's access
77
241
  // check. Everything inside the handler goes through ctx.queryAs(system, ...)
@@ -93,177 +257,46 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
93
257
  handler: async (event, ctx): Promise<WriteResult<LoginResult>> => {
94
258
  const systemUser = createSystemUser(SYSTEM_USER_ID);
95
259
 
96
- const found = parseAuthUserRow(
97
- await ctx.queryAs(systemUser, UserQueries.findForAuth, {
98
- email: event.payload.email,
99
- }),
260
+ const userGate = await gateResolveAuthUser(
261
+ ctx,
262
+ systemUser,
263
+ event.payload.email,
264
+ event.payload.password,
100
265
  );
266
+ if (!userGate.ok) return userGate.result;
267
+ const found = userGate.value;
101
268
 
102
- // Uniform response on any credential mismatch (no user, wrong password,
103
- // soft-deleted user) prevents email enumeration.
104
- if (!found?.passwordHash || found.isDeleted) {
105
- // Burn the same argon2 verify cost as the hit path so response
106
- // latency doesn't reveal whether the email is registered (#774).
107
- await verifyDummyPassword(event.payload.password);
108
- return invalidCredentials();
109
- }
110
-
111
- // Lockout gate — runs BEFORE password verification so a locked account
112
- // can't be bruteforce-probed for passwords (and also can't be probed
113
- // for a timing-oracle on the bcrypt verify). If Redis isn't wired,
114
- // lockout is silently skipped — login still works, brute-force
115
- // protection just degrades to the IP-rate-limiter at the edge.
116
- //
117
- // Deliberately fail-open here, unlike auth-mfa's enable-confirm-preauth
118
- // (which fails closed without Redis): the secret guarded by THIS gate
119
- // is a full password, not a 6-digit code — locking out every login
120
- // app-wide because Redis is briefly unavailable is a self-inflicted
121
- // outage across every tenant, for a backstop that the IP-rate-limiter
122
- // still partially covers. auth-mfa's blast radius is one user's MFA
123
- // enrollment, not global login availability — different tradeoff.
124
- if (ctx.redis) {
125
- const state = await getLockoutState(ctx.redis, found.id);
126
- if (state?.lockedUntil !== null && state?.lockedUntil !== undefined) {
127
- const now = Date.now();
128
- if (state.lockedUntil > now) {
129
- const retryAfterSeconds = Math.max(1, Math.ceil((state.lockedUntil - now) / 1000));
130
- return accountLocked(retryAfterSeconds);
131
- }
132
- // lockedUntil in the past — shouldn't normally happen because the
133
- // Redis TTL on the until-key expires the key at the same moment
134
- // as the value. Clock skew / replication lag could surface this;
135
- // fall through to password verification. The counter is NOT
136
- // reset — next miss re-locks immediately (strict-semantic, see
137
- // lockout-store.ts).
138
- }
139
- }
140
-
141
- const passwordOk = await verifyPassword(found.passwordHash, event.payload.password);
142
- if (!passwordOk) {
143
- if (ctx.redis) {
144
- await recordFailedAttempt(ctx.redis, found.id, maxFailedAttempts, lockoutDurationMinutes);
145
- }
146
- return invalidCredentials();
147
- }
148
-
149
- // Clear the lockout state as soon as the password is proven — the
150
- // password itself is the thing this counter guards, and MFA (if
151
- // gated below) has its own separate attempt-cap. Doing this before
152
- // the MFA gate matters: without it, an MFA user who occasionally
153
- // mistypes their password accumulates failures across otherwise-
154
- // successful logins and eventually gets password-locked out even
155
- // though every login they completed was legitimate.
156
- if (ctx.redis) {
157
- await clearLockoutState(ctx.redis, found.id);
158
- }
269
+ const lockoutGate = await gateEnforceLockout(ctx, found.id);
270
+ if (!lockoutGate.ok) return lockoutGate.result;
159
271
 
160
- // Strict verification gate — runs AFTER password check so an attacker
161
- // probing "email_not_verified" needs valid credentials first. The
162
- // remaining enumeration surface is "valid-cred + unverified" → accepted
163
- // leak because the signup flow already told the user "check your email".
164
- if (strictVerification && found.emailVerified !== true) {
165
- return emailNotVerified();
166
- }
167
-
168
- // S2.U6 — DSGVO Art. 18 Account-Freeze. Restricted users koennen sich
169
- // nicht einloggen; lift-restriction-Endpoint ist der einzige Ausgang
170
- // (siehe lift-restriction.write.ts Header — typisch via Magic-Link
171
- // oder Operator-Tool, da Login geblockt). Auth-side Block ist hard-
172
- // requirement; ohne den koennte der User mit Login-Sessions trotz
173
- // Restriction-Flag durchschreiben.
174
- //
175
- // DeletionRequested + Deleted kollabieren bewusst auf invalid_creds
176
- // (anti-enumeration im Forget-Pfad) — Restricted ist user-initiiert,
177
- // distinct error ist hier safe.
178
- if (found.status === USER_STATUS.Restricted) {
179
- return accountRestricted();
180
- }
181
- if (found.status === USER_STATUS.DeletionRequested || found.status === USER_STATUS.Deleted) {
182
- return invalidCredentials();
183
- }
184
-
185
- // Resolve tenant + roles via the tenant feature's memberships query.
186
- // Returns [] if the user has no memberships — MVP: no login without an
187
- // invitation, so we refuse with a dedicated error.
188
- const memberships = (await ctx.queryAs(systemUser, "tenant:query:memberships", {
189
- userId: found.id,
190
- })) as Array<{ tenantId: TenantId; roles: readonly string[] }>; // @cast-boundary db-runner
272
+ const passwordGate = await gateVerifyPassword(
273
+ ctx,
274
+ found,
275
+ event.payload.password,
276
+ maxFailedAttempts,
277
+ lockoutDurationMinutes,
278
+ );
279
+ if (!passwordGate.ok) return passwordGate.result;
191
280
 
192
- if (memberships.length === 0) {
193
- return noMembership();
194
- }
281
+ const emailGate = gateEnforceEmailVerified(found, strictVerification);
282
+ if (!emailGate.ok) return emailGate.result;
195
283
 
196
- const preferred =
197
- found.lastActiveTenantId !== null && found.lastActiveTenantId !== undefined
198
- ? memberships.find((m) => m.tenantId === found.lastActiveTenantId)
199
- : undefined;
200
- const chosen = preferred ?? memberships[0];
201
- if (!chosen) {
202
- return noMembership();
203
- }
284
+ const statusGate = gateEnforceAccountStatus(found);
285
+ if (!statusGate.ok) return statusGate.result;
204
286
 
205
- // Globale Rollen aus user.roles + tenant-membership-roles mergen.
206
- // Globale Rollen (SystemAdmin etc.) bleiben so über alle tenants
207
- // gleich; tenant-spezifische Rollen (Admin, User) kommen aus der
208
- // membership. Dedupe via Set damit eine Rolle die in beiden Quellen
209
- // steht nicht doppelt im Session-Roles landet.
210
- //
211
- // Computed BEFORE the MFA gate (moved up from its original spot below
212
- // baseSession) because the gate's "admins" enforcement policy needs
213
- // the MERGED set — a SystemAdmin whose admin-ness lives only in
214
- // globalRoles would be missed if only chosen.roles were passed.
215
- const globalRoles = parseRoles(found.roles ?? null);
216
- // buildSessionRoles calls stripForbiddenMembershipRoles to strip reserved
217
- // only (globalRoles keeps SystemAdmin) — read-time backstop against a
218
- // rebuild-resurrected role.
219
- const mergedRoles = buildSessionRoles(globalRoles, chosen.roles);
287
+ const membershipGate = await gateResolveMembership(ctx, systemUser, found);
288
+ if (!membershipGate.ok) return membershipGate.result;
289
+ const { chosen, mergedRoles } = membershipGate.value;
220
290
 
221
- // Second-factor gate. Runs AFTER password verification (correct
222
- // credentials proven) and AFTER tenant resolution (need chosen.tenantId
223
- // to scope the MFA-enabled check). Three outcomes: enrolled → mint a
224
- // challenge instead of a session (/auth/mfa/verify completes the
225
- // login); policy demands MFA but the user never enrolled → block with
226
- // mfa-setup-required (no session, no challenge — see auth-mfa's
227
- // config.ts for why this deliberately hard-blocks); neither → proceed.
228
- if (opts.mfaStatusChecker) {
229
- const mfaStatus = await opts.mfaStatusChecker(ctx, found.id, chosen.tenantId, mergedRoles);
230
- if ("challengeToken" in mfaStatus) {
231
- return {
232
- isSuccess: true,
233
- data: { kind: "mfa-challenge", challengeToken: mfaStatus.challengeToken },
234
- };
235
- }
236
- if ("setupRequired" in mfaStatus) {
237
- return {
238
- isSuccess: true,
239
- data: { kind: "mfa-setup-required", preauthSetupToken: mfaStatus.preauthSetupToken },
240
- };
241
- }
291
+ const mfaGate = await gateEnforceMfa(ctx, opts, found.id, chosen.tenantId, mergedRoles);
292
+ if (!mfaGate.ok) return mfaGate.result;
293
+ if (mfaGate.value !== undefined) {
294
+ return { isSuccess: true, data: mfaGate.value };
242
295
  }
243
296
 
244
- const baseSession: SessionUser = {
245
- id: found.id,
246
- tenantId: chosen.tenantId,
247
- roles: mergedRoles,
248
- };
249
-
250
- // Features can contribute identity facts (team IDs, feature flags, ...)
251
- // via r.authClaims(). ctx.resolveAuthClaims is a thin pass-through to
252
- // dispatcher.resolveAuthClaims — same impl also used by the switch-tenant
253
- // route, so login + tenant-switch stay in sync.
254
- //
255
- // Best-effort: if no feature registered a hook, we get an empty record
256
- // back and simply omit the `claims` field from the session (keeps the
257
- // shape clean for the JWT layer, which already spreads claims
258
- // conditionally based on presence).
259
- const claims = await ctx.resolveAuthClaims(baseSession);
260
- const session: SessionUser =
261
- Object.keys(claims).length > 0 ? { ...baseSession, claims } : baseSession;
262
-
263
- return {
264
- isSuccess: true,
265
- data: { kind: "auth-session", session },
266
- };
297
+ const sessionGate = await gateBuildSession(ctx, found.id, chosen.tenantId, mergedRoles);
298
+ if (!sessionGate.ok) return sessionGate.result;
299
+ return { isSuccess: true, data: sessionGate.value };
267
300
  },
268
301
  });
269
302
  }
@@ -84,10 +84,16 @@ afterAll(async () => {
84
84
  await stack.cleanup();
85
85
  });
86
86
 
87
- type RawUserMfaRow = { id: string; totpSecret: string; version: number };
87
+ type RawUserMfaRow = {
88
+ id: string;
89
+ userId: string;
90
+ totpSecret: string;
91
+ recoveryCodes: string;
92
+ version: number;
93
+ };
88
94
 
89
- async function readRawRow(): Promise<RawUserMfaRow> {
90
- const rows = await selectMany<RawUserMfaRow>(stack.db, userMfaTable, {});
95
+ async function readRawRow(userId?: string): Promise<RawUserMfaRow> {
96
+ const rows = await selectMany<RawUserMfaRow>(stack.db, userMfaTable, userId ? { userId } : {});
91
97
  const row = rows[0];
92
98
  if (!row) throw new Error("no user-mfa row");
93
99
  return row;
@@ -195,7 +201,7 @@ describe("auth-mfa KEK-rotation job — unrecognized values are not silently ski
195
201
  user,
196
202
  );
197
203
 
198
- const beforeRow = await readRawRow();
204
+ const beforeRow = await readRawRow(user.id);
199
205
  const garbage = "not-a-stored-envelope";
200
206
  await asRawClient(stack.db).unsafe(`UPDATE read_user_mfa SET totp_secret = $1 WHERE id = $2`, [
201
207
  garbage,
@@ -241,7 +247,76 @@ describe("auth-mfa KEK-rotation job — unrecognized values are not silently ski
241
247
  );
242
248
  expect(rejectedWarning).toBeDefined();
243
249
 
244
- const afterRow = await readRawRow();
250
+ const afterRow = await readRawRow(user.id);
245
251
  expect(afterRow.totpSecret).toBe(garbage);
246
252
  });
253
+
254
+ test("a non-envelope recoveryCodes is counted failed, never alreadyCurrent, and is left untouched", async () => {
255
+ mutableProvider.replace(
256
+ createEnvMasterKeyProvider({
257
+ env: {
258
+ KUMIKO_SECRETS_MASTER_KEY_V1: v1Key,
259
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
260
+ },
261
+ }),
262
+ );
263
+
264
+ const user = createTestUser({ id: 3, roles: ["User"] });
265
+ const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
266
+ AuthMfaHandlers.enableStart,
267
+ { accountLabel: "user-3@example.com" },
268
+ user,
269
+ );
270
+ const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
271
+ const secret = base32Decode(secretParam);
272
+ await stack.http.writeOk(
273
+ AuthMfaHandlers.enableConfirm,
274
+ { setupToken: start.setupToken, code: currentTotpCode(secret) },
275
+ user,
276
+ );
277
+
278
+ const beforeRow = await readRawRow(user.id);
279
+ const garbage = "not-a-stored-envelope";
280
+ await asRawClient(stack.db).unsafe(
281
+ `UPDATE read_user_mfa SET recovery_codes = $1 WHERE id = $2`,
282
+ [garbage, beforeRow.id],
283
+ );
284
+
285
+ type CapturedLog = { info: string[]; warn: string[] };
286
+ const captured: CapturedLog = { info: [], warn: [] };
287
+ const capturingLog = {
288
+ info: (msg: string) => {
289
+ captured.info.push(msg);
290
+ },
291
+ warn: (msg: string) => {
292
+ captured.warn.push(msg);
293
+ },
294
+ error: () => {},
295
+ debug: () => {},
296
+ child: () => capturingLog,
297
+ };
298
+
299
+ await mfaReencryptJob({}, {
300
+ db: stack.db,
301
+ registry: stack.registry,
302
+ masterKeyProvider: mutableProvider,
303
+ log: capturingLog,
304
+ } as AppContext);
305
+
306
+ const completeLine = captured.info.find((line) =>
307
+ line.includes("[auth-mfa:reencrypt] complete:"),
308
+ );
309
+ if (!completeLine) throw new Error("job did not log a completion summary");
310
+ const result = JSON.parse(completeLine.slice(completeLine.indexOf("{"))) as {
311
+ migrated: number;
312
+ failed: number;
313
+ alreadyCurrent: number;
314
+ };
315
+ expect(result.migrated).toBe(0);
316
+ expect(result.failed).toBeGreaterThanOrEqual(1);
317
+ expect(result.alreadyCurrent).toBe(0);
318
+
319
+ const afterRow = await readRawRow(user.id);
320
+ expect(afterRow.recoveryCodes).toBe(garbage);
321
+ });
247
322
  });
@@ -5,9 +5,9 @@
5
5
  // parses the stored JSON.
6
6
  //
7
7
  // A row whose value isn't a current-cipher envelope (malformed JSON, or
8
- // any pre-envelope format) isn't a supported input here cipher.decrypt
9
- // rejects it, so it's counted `failed` rather than silently treated as
10
- // already current. There is no legacy single-key decrypt path anymore.
8
+ // any pre-envelope format) is counted `failed` before any decrypt attempt
9
+ // and left untouched migrateRow short-circuits on `unrecognized` instead
10
+ // of relying on cipher.decrypt to reject it. No legacy single-key path.
11
11
  //
12
12
  // Idempotent: a re-run skips rows already on the current version. Every
13
13
  // write goes through the event-store executor (config values are
@@ -1,5 +1,5 @@
1
1
  import { collectPiiSubjectFields } from "@cosmicdrift/kumiko-framework/crypto";
2
- import { buildEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
2
+ import { deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
3
3
  import {
4
4
  access,
5
5
  createEntity,
@@ -164,9 +164,9 @@ export const seenMessageEntity = createEntity({
164
164
  // Plain EntityTableMeta (kein branded EntityTable) — unmanaged Direct-
165
165
  // Write-Stores, Handler schreiben via ctx.db (siehe user-session.ts-
166
166
  // Rationale).
167
- export const syncCursorTable = buildEntityTableMeta("mail-sync-cursor", syncCursorEntity, {
167
+ export const syncCursorTable = deriveEntityTableMeta("mail-sync-cursor", syncCursorEntity, {
168
168
  source: "unmanaged",
169
169
  });
170
- export const seenMessageTable = buildEntityTableMeta("mail-seen-message", seenMessageEntity, {
170
+ export const seenMessageTable = deriveEntityTableMeta("mail-seen-message", seenMessageEntity, {
171
171
  source: "unmanaged",
172
172
  });
@@ -3,7 +3,7 @@ import {
3
3
  EXT_TOKEN_VERIFIER,
4
4
  } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
5
5
  import { PAT_TOKEN_PREFIX } from "@cosmicdrift/kumiko-framework/api";
6
- import { buildEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
6
+ import { deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
7
7
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import { PAT_DEFAULT_RATE_LIMIT, PAT_FEATURE, PAT_SCREEN_ID, type PatRateLimit } from "./constants";
9
9
  import { buildAvailableScopesQuery } from "./handlers/available-scopes.query";
@@ -67,7 +67,7 @@ export function createPersonalAccessTokensFeature(
67
67
  // Direct-write store like store_user_sessions: create/revoke write it, the
68
68
  // resolver point-reads it. r.entity would make it a rebuildable projection
69
69
  // whose replay (no token events) would wipe every live token (#498/#494).
70
- r.storeTable(buildEntityTableMeta("api-token", apiTokenEntity, { source: "unmanaged" }), {
70
+ r.storeTable(deriveEntityTableMeta("api-token", apiTokenEntity, { source: "unmanaged" }), {
71
71
  reason: "read_side.api_tokens_direct_write",
72
72
  // create.write encrypts `name` via encryptForDirectWrite (#820).
73
73
  piiEncryptedOnWrite: true,
@@ -1,4 +1,4 @@
1
- import { buildEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
1
+ import { deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
2
2
  import {
3
3
  access,
4
4
  createEntity,
@@ -55,9 +55,9 @@ export const apiTokenEntity = createEntity({
55
55
  indexes: [{ unique: true, columns: ["tokenHash"], name: "store_api_tokens_hash_unique" }],
56
56
  });
57
57
 
58
- // buildEntityTableMeta (not buildEntityTable): this is a direct-write store, so
58
+ // deriveEntityTableMeta (not buildEntityTable): this is a direct-write store, so
59
59
  // the table must be a WritableTable (post ES-write-brand #742) — same as
60
60
  // sessions' userSessionTable. buildEntityTable is branded executor-only.
61
- export const apiTokenTable = buildEntityTableMeta("api-token", apiTokenEntity, {
61
+ export const apiTokenTable = deriveEntityTableMeta("api-token", apiTokenEntity, {
62
62
  source: "unmanaged",
63
63
  });
@@ -42,7 +42,7 @@ import { SessionHandlers, SessionQueries } from "../constants";
42
42
  import { createSessionsFeature } from "../feature";
43
43
  import { userSessionEntity, userSessionTable } from "../schema/user-session";
44
44
  import { createSessionCallbacks, type SessionCallbacks } from "../session-callbacks";
45
- import { SESSION_REVOKED_EVENT_QN, type SessionRevokedPayload } from "../session-revoked-event";
45
+ import { SESSION_REVOKED_EVENT_QN, sessionRevokedSchema } from "../session-revoked-event";
46
46
  import { sessionCallbacksFromLateBound } from "../testing";
47
47
  import { makeSessionHelpers } from "./test-helpers";
48
48
 
@@ -216,7 +216,7 @@ describe("sessions feature — login → check → revoke → rejected", () => {
216
216
 
217
217
  const events = await selectMany(stack.db, eventsTable, { type: SESSION_REVOKED_EVENT_QN });
218
218
  expect(events).toHaveLength(1);
219
- const payload = events[0]?.["payload"] as SessionRevokedPayload;
219
+ const payload = sessionRevokedSchema.parse(events[0]?.["payload"]);
220
220
  expect(payload.userId).toBe(userId);
221
221
  expect(payload.sessionIds).toEqual([sid]);
222
222
  });
@@ -243,6 +243,22 @@ describe("sessions feature — login → check → revoke → rejected", () => {
243
243
  expect(events).toHaveLength(1); // only the first, successful revoke
244
244
  });
245
245
 
246
+ test("revoke-all-others with no other live sessions emits no event", async () => {
247
+ await h.seedUser("noop-others@example.com", "pw-long-enough");
248
+ const only = await h.login("noop-others@example.com", "pw-long-enough");
249
+
250
+ const res = await h.authedPost("/api/write", only.token, {
251
+ type: SessionHandlers.revokeAllOthers,
252
+ payload: {},
253
+ });
254
+ expect(res.status).toBe(200);
255
+ const body = (await res.json()) as { data: { count: number } };
256
+ expect(body.data.count).toBe(0);
257
+
258
+ const events = await selectMany(stack.db, eventsTable, { type: SESSION_REVOKED_EVENT_QN });
259
+ expect(events).toHaveLength(0);
260
+ });
261
+
246
262
  test("revoke-all-others appends one event listing every revoked sid", async () => {
247
263
  const { userId } = await h.seedUser("event3@example.com", "pw-long-enough");
248
264
  const a = await h.login("event3@example.com", "pw-long-enough");
@@ -257,7 +273,7 @@ describe("sessions feature — login → check → revoke → rejected", () => {
257
273
 
258
274
  const events = await selectMany(stack.db, eventsTable, { type: SESSION_REVOKED_EVENT_QN });
259
275
  expect(events).toHaveLength(1);
260
- const payload = events[0]?.["payload"] as SessionRevokedPayload;
276
+ const payload = sessionRevokedSchema.parse(events[0]?.["payload"]);
261
277
  expect(payload.userId).toBe(userId);
262
278
  expect(new Set(payload.sessionIds)).toEqual(new Set([a.sid, c.sid]));
263
279
  });
@@ -3,7 +3,7 @@ import {
3
3
  type SessionStore,
4
4
  type SessionStoreProvider,
5
5
  } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
6
- import { buildEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
6
+ import { deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
7
7
  import {
8
8
  access,
9
9
  defineFeature,
@@ -111,11 +111,14 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
111
111
  // (#498/#494). r.storeTable keeps the migration DDL but opts the
112
112
  // table out of implicit rebuild, like jobs/channel-in-app/feature-toggles
113
113
  // which are direct-write stores too.
114
- r.storeTable(buildEntityTableMeta("user-session", userSessionEntity, { source: "unmanaged" }), {
115
- reason: "read_side.user_sessions_direct_write",
116
- // sessionCreator encrypts ip/userAgent via encryptForDirectWrite (#820).
117
- piiEncryptedOnWrite: true,
118
- });
114
+ r.storeTable(
115
+ deriveEntityTableMeta("user-session", userSessionEntity, { source: "unmanaged" }),
116
+ {
117
+ reason: "read_side.user_sessions_direct_write",
118
+ // sessionCreator encrypts ip/userAgent via encryptForDirectWrite (#820).
119
+ piiEncryptedOnWrite: true,
120
+ },
121
+ );
119
122
 
120
123
  // Self-registers as auth-foundation's sessionStore provider (#1371) —
121
124
  // wraps the same createSessionCallbacks() used by the manual
@@ -1,4 +1,4 @@
1
- import { buildEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
1
+ import { deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
2
2
  import {
3
3
  access,
4
4
  createEntity,
@@ -69,11 +69,10 @@ export const userSessionEntity = createEntity({
69
69
  });
70
70
 
71
71
  // Plain EntityTableMeta, NOT a branded EntityTable: user-session is an
72
- // unmanaged direct-write store (r.unmanagedTable in feature.ts, no event
73
- // stream — revocation is a column write, not an aggregate). The feature's
74
- // handlers write it directly via ctx.db; the meta carries no executor-only
75
- // brand so those writes stay legal. See feature.ts for the rebuild-exclusion
76
- // rationale (#494/#498).
77
- export const userSessionTable = buildEntityTableMeta("user-session", userSessionEntity, {
72
+ // unmanaged direct-write store (r.storeTable in feature.ts, no event stream —
73
+ // revocation is a column write, not an aggregate). Handlers write via ctx.db;
74
+ // the meta carries no executor-only brand so those writes stay legal. See
75
+ // feature.ts for the rebuild-exclusion rationale (#494/#498).
76
+ export const userSessionTable = deriveEntityTableMeta("user-session", userSessionEntity, {
78
77
  source: "unmanaged",
79
78
  });
@@ -1,8 +1,7 @@
1
- // Verifies the core promise: set a tenant's tenant-settings:config:currency
2
- // to CHF, and a new entity without an explicit currency inherits CHF instead
3
- // of a hard-coded EUR literal.
1
+ // Package-level cases the recipe does NOT cover: unknown field at define time,
2
+ // and missing tenant-settings mount fails loud when currency is omitted.
4
3
 
5
- import { afterAll, beforeAll, expect, test } from "bun:test";
4
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
5
  import {
7
6
  configValuesTable,
8
7
  createConfigAccessorFactory,
@@ -26,7 +25,6 @@ import {
26
25
  unsafeCreateEntityTable,
27
26
  unsafePushTables,
28
27
  } from "@cosmicdrift/kumiko-framework/stack";
29
- import { createTenantSettingsFeature } from "../feature";
30
28
  import { defineCreateWithTenantDefaults } from "../tenant-defaults";
31
29
 
32
30
  const invoiceEntity = createEntity({
@@ -40,76 +38,57 @@ const invoiceEntity = createEntity({
40
38
 
41
39
  const ACCESS = { roles: ["Admin"] } as const;
42
40
 
43
- const invoiceFeature = defineFeature("invoice", (r) => {
44
- r.entity("invoice", invoiceEntity);
45
- r.writeHandler(
41
+ test("defineCreateWithTenantDefaults throws on an unknown currency field", () => {
42
+ expect(() =>
46
43
  defineCreateWithTenantDefaults("invoice", invoiceEntity, {
47
44
  access: ACCESS,
48
- currencyFields: ["amount"],
49
- localeField: "language",
45
+ currencyFields: ["notARealField"],
50
46
  }),
51
- );
47
+ ).toThrow(/unknown field "notARealField"/);
52
48
  });
53
49
 
54
- const tenantId = testTenantId(1);
55
- const admin: SessionUser = { id: "admin-1", tenantId, roles: ["Admin"] };
56
-
57
- let stack: TestStack;
58
-
59
- beforeAll(async () => {
60
- const resolver = createConfigResolver();
61
- stack = await setupTestStack({
62
- features: [createConfigFeature(), createTenantSettingsFeature(), invoiceFeature],
63
- extraContext: ({ registry }) => ({
64
- configResolver: resolver,
65
- _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
66
- }),
50
+ describe("without tenant-settings mount", () => {
51
+ const invoiceFeature = defineFeature("invoice", (r) => {
52
+ r.entity("invoice", invoiceEntity);
53
+ r.writeHandler(
54
+ defineCreateWithTenantDefaults("invoice", invoiceEntity, {
55
+ access: ACCESS,
56
+ currencyFields: ["amount"],
57
+ localeField: "language",
58
+ }),
59
+ );
67
60
  });
68
- await unsafePushTables(stack.db, { configValuesTable });
69
- await unsafeCreateEntityTable(stack.db, invoiceEntity);
70
- await createEventsTable(stack.db);
71
- await pushEntityProjectionTables(stack, stack.registry);
72
- });
73
-
74
- afterAll(async () => {
75
- await stack.cleanup();
76
- });
77
61
 
78
- test("ohne Tenant-Override übernimmt eine neue Entity den Feature-Default (EUR/en)", async () => {
79
- const invoice = await stack.http.writeOk<{
80
- data: { amount: { amount: number; currency: string }; language: string };
81
- }>("invoice:write:invoice:create", { title: "Rechnung 1", amount: { amount: 1000 } }, admin);
82
- expect(invoice.data.amount).toEqual({ amount: 1000, currency: "EUR" });
83
- expect(invoice.data.language).toBe("en");
84
- });
85
-
86
- test("Tenant setzt CHF/de — neue Entity ohne explizite Werte übernimmt die Tenant-Config, nicht das Feature-Default", async () => {
87
- await stack.http.writeOk(
88
- "config:write:set",
89
- { key: "tenant-settings:config:currency", value: "CHF" },
90
- admin,
91
- );
92
- await stack.http.writeOk(
93
- "config:write:set",
94
- { key: "tenant-settings:config:locale", value: "de" },
95
- admin,
96
- );
62
+ const tenantId = testTenantId(1);
63
+ const admin: SessionUser = { id: "admin-1", tenantId, roles: ["Admin"] };
64
+ let stack: TestStack;
97
65
 
98
- const invoice = await stack.http.writeOk<{
99
- data: { amount: { amount: number; currency: string }; language: string };
100
- }>("invoice:write:invoice:create", { title: "Rechnung 2", amount: { amount: 2000 } }, admin);
66
+ beforeAll(async () => {
67
+ const resolver = createConfigResolver();
68
+ // config feature only no createTenantSettingsFeature()
69
+ stack = await setupTestStack({
70
+ features: [createConfigFeature(), invoiceFeature],
71
+ extraContext: ({ registry }) => ({
72
+ configResolver: resolver,
73
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
74
+ }),
75
+ });
76
+ await unsafePushTables(stack.db, { configValuesTable });
77
+ await unsafeCreateEntityTable(stack.db, invoiceEntity);
78
+ await createEventsTable(stack.db);
79
+ await pushEntityProjectionTables(stack, stack.registry);
80
+ });
101
81
 
102
- expect(invoice.data.amount).toEqual({ amount: 2000, currency: "CHF" });
103
- expect(invoice.data.language).toBe("de");
104
- });
82
+ afterAll(async () => {
83
+ await stack.cleanup();
84
+ });
105
85
 
106
- test("expliziter Wert im Payload gewinnt gegen die Tenant-Config", async () => {
107
- const invoice = await stack.http.writeOk<{
108
- data: { amount: { amount: number; currency: string } };
109
- }>(
110
- "invoice:write:invoice:create",
111
- { title: "Rechnung 3", amount: { amount: 3000, currency: "USD" } },
112
- admin,
113
- );
114
- expect(invoice.data.amount).toEqual({ amount: 3000, currency: "USD" });
86
+ test("omitting money.currency fails loud (no silent feature-default fill)", async () => {
87
+ const err = await stack.http.writeErr(
88
+ "invoice:write:invoice:create",
89
+ { title: "Invoice", amount: { amount: 1000 } },
90
+ admin,
91
+ );
92
+ expect(err.httpStatus).toBeGreaterThanOrEqual(400);
93
+ });
115
94
  });
@@ -521,6 +521,19 @@ describe("r.httpRoute :: /user-export/by-token (Magic-Link e2e)", () => {
521
521
  const body = (await res.json()) as { error?: string };
522
522
  expect(body.error).toBe("missing_token");
523
523
  });
524
+
525
+ test("POST-Exchange: invalid token → 404 + download.notFound i18nKey", async () => {
526
+ const res = await stack.app.fetch(
527
+ new Request("http://test/user-export/by-token", {
528
+ method: "POST",
529
+ headers: { "content-type": "application/json" },
530
+ body: JSON.stringify({ token: "fake-xxxxx" }),
531
+ }),
532
+ );
533
+ expect(res.status).toBe(404);
534
+ const body = (await res.json()) as { error: { i18nKey: string } };
535
+ expect(body.error.i18nKey).toBe("userDataRights.errors.download.notFound");
536
+ });
524
537
  });
525
538
 
526
539
  describe("download-by-job :: cross-user + cross-tenant", () => {
@@ -47,7 +47,7 @@ import { userSessionEntity, userSessionTable } from "../../sessions/schema/user-
47
47
  import { createSessionCallbacks, type SessionCallbacks } from "../../sessions/session-callbacks";
48
48
  import {
49
49
  SESSION_REVOKED_EVENT_QN,
50
- type SessionRevokedPayload,
50
+ sessionRevokedSchema,
51
51
  } from "../../sessions/session-revoked-event";
52
52
  import { sessionCallbacksFromLateBound, withMintedSession } from "../../sessions/testing";
53
53
  import { hashPassword } from "../../shared";
@@ -415,6 +415,26 @@ describe("S2.U6 :: Login-Block fuer Restricted/DeletionRequested/Deleted", () =>
415
415
  });
416
416
 
417
417
  describe("S2.U6 :: Cross-Feature sessions.revokeAllForUser direct", () => {
418
+ test("Privileged revoke-all-for-user with no live sessions emits no event", async () => {
419
+ const { userId } = await seedAliceWithMembership();
420
+ const systemUser = await mintActor({
421
+ id: "00000000-0000-4000-8000-000000000000",
422
+ tenantId: TENANT,
423
+ roles: ["SystemAdmin"],
424
+ });
425
+
426
+ const result = await stack.http.writeOk<{ count: number; userId: string }>(
427
+ SessionHandlers.revokeAllForUser,
428
+ { userId },
429
+ systemUser,
430
+ );
431
+ expect(result.count).toBe(0);
432
+ expect(result.userId).toBe(userId);
433
+
434
+ const events = await selectMany(stack.db, eventsTable, { type: SESSION_REVOKED_EVENT_QN });
435
+ expect(events).toHaveLength(0);
436
+ });
437
+
418
438
  test("Privileged-Caller revoked alle live sessions eines Users", async () => {
419
439
  const { userId } = await seedAliceWithMembership();
420
440
  // 2 Sessions erzeugen via Login + zweiter Login.
@@ -454,7 +474,7 @@ describe("S2.U6 :: Cross-Feature sessions.revokeAllForUser direct", () => {
454
474
  // target user's session tenant). One event, both sids listed.
455
475
  const events = await selectMany(stack.db, eventsTable, { type: SESSION_REVOKED_EVENT_QN });
456
476
  expect(events).toHaveLength(1);
457
- const payload = events[0]?.["payload"] as SessionRevokedPayload;
477
+ const payload = sessionRevokedSchema.parse(events[0]?.["payload"]);
458
478
  expect(payload.userId).toBe(userId);
459
479
  expect(new Set(payload.sessionIds)).toEqual(new Set(liveBefore.map((s) => s.id)));
460
480
  });
@@ -26,8 +26,8 @@ import { updateUserLifecycle } from "../lib/update-user-lifecycle";
26
26
  // "Cross-Tenant-Semantik"). Without a membership check, a TenantAdmin
27
27
  // from tenant A could unrestrict/reactivate a user who has never been a
28
28
  // member of tenant A. Only SystemAdmin (platform-wide) skips the check;
29
- // TenantAdmin/Admin must have an active membership in the target's own
30
- // tenantId.
29
+ // the target must have a membership row in the acting admin's
30
+ // `event.user.tenantId` (row existence only — no active-status field).
31
31
  //
32
32
  // State-Transitions:
33
33
  // Restricted → Active ✓
@@ -34,8 +34,8 @@ import { updateUserLifecycle } from "../lib/update-user-lifecycle";
34
34
  // user-data-rights.md "Cross-Tenant-Semantik"). Without a membership
35
35
  // check, a TenantAdmin from tenant A could restrict/unrestrict a user who
36
36
  // has never been a member of tenant A. Only SystemAdmin (platform-wide)
37
- // skips the check; TenantAdmin/Admin must have an active membership in
38
- // the target's own tenantId.
37
+ // skips the check; the target must have a membership row in the acting
38
+ // admin's `event.user.tenantId` (row existence only — no active-status field).
39
39
  //
40
40
  // State-Transitions:
41
41
  // Active → Restricted ✓ (dieser Handler)