@cosmicdrift/kumiko-bundled-features 0.201.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.201.0",
3
+ "version": "0.202.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>",
@@ -126,12 +126,12 @@
126
126
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
127
127
  },
128
128
  "dependencies": {
129
- "@cosmicdrift/kumiko-dispatcher-live": "0.201.0",
130
- "@cosmicdrift/kumiko-framework": "0.201.0",
131
- "@cosmicdrift/kumiko-headless": "0.201.0",
132
- "@cosmicdrift/kumiko-renderer": "0.201.0",
133
- "@cosmicdrift/kumiko-renderer-web": "0.201.0",
134
- "@cosmicdrift/kumiko-types": "0.201.0",
129
+ "@cosmicdrift/kumiko-dispatcher-live": "0.202.0",
130
+ "@cosmicdrift/kumiko-framework": "0.202.0",
131
+ "@cosmicdrift/kumiko-headless": "0.202.0",
132
+ "@cosmicdrift/kumiko-renderer": "0.202.0",
133
+ "@cosmicdrift/kumiko-renderer-web": "0.202.0",
134
+ "@cosmicdrift/kumiko-types": "0.202.0",
135
135
  "@mollie/api-client": "^4.5.0",
136
136
  "@node-rs/argon2": "^2.0.2",
137
137
  "@types/mailparser": "^3.4.6",
@@ -30,6 +30,18 @@ import { currentTotpCode } from "../totp";
30
30
  // account — every OTHER live session must be signed out (stolen-session
31
31
  // defense), but the session that performed the change must survive.
32
32
 
33
+ // sessionChecker's "live" outcome can now carry re-derived roles as
34
+ // `{status: "live", roles: [...]}` instead of the bare string — these tests
35
+ // only care about liveness, not the exact role set, so unwrap the status.
36
+ async function checkerStatus(
37
+ callbacks: ReturnType<typeof createSessionCallbacks>,
38
+ sid: string,
39
+ userId: string,
40
+ ): Promise<string> {
41
+ const result = await callbacks.sessionChecker(sid, userId);
42
+ return typeof result === "string" ? result : result.status;
43
+ }
44
+
33
45
  let stack: TestStack;
34
46
  let sessionCallbacks: ReturnType<typeof createSessionCallbacks>;
35
47
 
@@ -94,8 +106,8 @@ describe("session auto-revoke on MFA state changes", () => {
94
106
  user,
95
107
  );
96
108
 
97
- expect(await sessionCallbacks.sessionChecker(currentSid, userId)).toBe("live");
98
- expect(await sessionCallbacks.sessionChecker(otherSid, userId)).toBe("revoked");
109
+ expect(await checkerStatus(sessionCallbacks, currentSid, userId)).toBe("live");
110
+ expect(await checkerStatus(sessionCallbacks, otherSid, userId)).toBe("revoked");
99
111
  });
100
112
 
101
113
  test("disable revokes every OTHER session but keeps the caller's", async () => {
@@ -130,8 +142,8 @@ describe("session auto-revoke on MFA state changes", () => {
130
142
  caller,
131
143
  );
132
144
 
133
- expect(await sessionCallbacks.sessionChecker(currentSid, userId)).toBe("live");
134
- expect(await sessionCallbacks.sessionChecker(otherSid, userId)).toBe("revoked");
145
+ expect(await checkerStatus(sessionCallbacks, currentSid, userId)).toBe("live");
146
+ expect(await checkerStatus(sessionCallbacks, otherSid, userId)).toBe("revoked");
135
147
  });
136
148
 
137
149
  test("a failed disable attempt does NOT revoke any session", async () => {
@@ -163,7 +175,7 @@ describe("session auto-revoke on MFA state changes", () => {
163
175
  const err = await stack.http.writeErr(AuthMfaHandlers.disable, { code: "000000" }, caller);
164
176
  expectErrorIncludes(err, "invalid_totp_code");
165
177
 
166
- expect(await sessionCallbacks.sessionChecker(currentSid, userId)).toBe("live");
167
- expect(await sessionCallbacks.sessionChecker(otherSid, userId)).toBe("live");
178
+ expect(await checkerStatus(sessionCallbacks, currentSid, userId)).toBe("live");
179
+ expect(await checkerStatus(sessionCallbacks, otherSid, userId)).toBe("live");
168
180
  });
169
181
  });
@@ -49,10 +49,12 @@ const stubNav: NavApi = {
49
49
  route: undefined,
50
50
  navigate: () => {},
51
51
  replace: () => {},
52
- hrefFor: (target) =>
53
- target.entityId !== undefined
52
+ hrefFor: (target) => {
53
+ if (!("screenId" in target)) return "";
54
+ return target.entityId !== undefined
54
55
  ? `/${target.screenId}/${target.entityId}`
55
- : `/${target.screenId}`,
56
+ : `/${target.screenId}`;
57
+ },
56
58
  searchParams: {},
57
59
  setSearchParams: () => {},
58
60
  };
@@ -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
  });