@cosmicdrift/kumiko-bundled-features 0.265.0 → 0.268.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/package.json +9 -9
  2. package/src/auth-email-password/__tests__/query-as-member.integration.test.ts +747 -0
  3. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +16 -6
  4. package/src/auth-email-password/handlers/invite-accept.write.ts +19 -6
  5. package/src/auth-email-password/handlers/invite-create.write.ts +1 -2
  6. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +21 -7
  7. package/src/auth-email-password/handlers/login.write.ts +3 -1
  8. package/src/auth-email-password/handlers/signup-confirm.write.ts +9 -5
  9. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +10 -2
  10. package/src/auth-mfa/handlers/enable-start-preauth.write.ts +11 -1
  11. package/src/auth-mfa/handlers/verify.write.ts +10 -2
  12. package/src/auth-mfa/mfa-code-verifier.ts +5 -1
  13. package/src/auth-mfa/mfa-status-checker.ts +7 -1
  14. package/src/billing-foundation/handlers/list-subscriptions.query.ts +1 -3
  15. package/src/cap-counter/stock-cap-guard.ts +6 -1
  16. package/src/crypto-shredding/handlers/forget-subject.write.ts +43 -27
  17. package/src/custom-fields/db/queries/__tests__/unsafe-read-retrying.test.ts +10 -5
  18. package/src/custom-fields/db/queries/field-access.ts +3 -1
  19. package/src/custom-fields/db/queries/quota.ts +3 -1
  20. package/src/custom-fields/handlers/clear-custom-field.write.ts +6 -0
  21. package/src/custom-fields/handlers/define-tenant-field.write.ts +6 -0
  22. package/src/custom-fields/handlers/set-custom-field.write.ts +6 -0
  23. package/src/delivery/jobs.ts +6 -5
  24. package/src/feature-toggles/global-feature-state-table.ts +15 -11
  25. package/src/feature-toggles/handlers/list.query.ts +1 -2
  26. package/src/feature-toggles/handlers/registered.query.ts +1 -2
  27. package/src/form-draft/handlers/discard.write.ts +7 -1
  28. package/src/form-draft/handlers/save.write.ts +11 -1
  29. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +3 -1
  30. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +16 -6
  31. package/src/inbound-mail-foundation/handlers/list-accounts.query.ts +1 -3
  32. package/src/inbound-mail-foundation/handlers/list-messages.query.ts +1 -2
  33. package/src/jobs/handlers/projection-rebuild.job.ts +4 -4
  34. package/src/jobs/handlers/reindex-entity.job.ts +5 -2
  35. package/src/ledger/__tests__/ledger-reports-tenant-isolation.integration.test.ts +148 -0
  36. package/src/ledger/handlers/confirm-schedule-period.write.ts +2 -3
  37. package/src/ledger/handlers/reports.query.ts +2 -3
  38. package/src/ledger/handlers/reverse-transaction.write.ts +1 -2
  39. package/src/managed-pages/handlers/set.write.ts +13 -1
  40. package/src/notes-history/handlers/add-note.write.ts +17 -8
  41. package/src/personal-access-tokens/handlers/create.write.ts +2 -1
  42. package/src/secrets/handlers/list.query.ts +1 -3
  43. package/src/sessions/handlers/revoke-all-for-user.write.ts +9 -2
  44. package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +11 -1
  45. package/src/tenant-lifecycle/handlers/request-destruction.write.ts +10 -3
  46. package/src/tier-engine/feature.ts +11 -19
  47. package/src/tier-engine/handlers/get-tenant-tier.query.ts +7 -6
  48. package/src/tier-engine/handlers/set-tenant-tier.write.ts +6 -2
  49. package/src/user/__tests__/user-global-tenancy.integration.test.ts +97 -0
  50. package/src/user/handlers/update-roles.ts +14 -3
  51. package/src/user/handlers/update.write.ts +14 -1
  52. package/src/user/principal-status.ts +22 -1
  53. package/src/user/schema/user.ts +3 -0
  54. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +3 -1
  55. package/src/user-data-rights/handlers/cancel-deletion.write.ts +11 -10
  56. package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +7 -5
  57. package/src/user-data-rights/handlers/deletion-grace-period.ts +11 -10
  58. package/src/user-data-rights/handlers/download-by-job.query.ts +17 -10
  59. package/src/user-data-rights/handlers/download-by-token.query.ts +21 -12
  60. package/src/user-data-rights/handlers/export-status.query.ts +8 -4
  61. package/src/user-data-rights/handlers/lift-restriction.write.ts +21 -9
  62. package/src/user-data-rights/handlers/my-audit-log.query.ts +7 -4
  63. package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +13 -8
  64. package/src/user-data-rights/handlers/request-deletion.write.ts +2 -1
  65. package/src/user-data-rights/handlers/request-export.write.ts +13 -8
  66. package/src/user-data-rights/handlers/restrict-account.write.ts +18 -13
  67. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +9 -5
  68. package/src/user-data-rights/lib/update-user-lifecycle.ts +2 -2
  69. package/src/user-data-rights/run-forget-cleanup.ts +2 -2
  70. package/src/user-data-rights/schema/export-job.ts +2 -4
@@ -13,6 +13,9 @@ import {
13
13
  sessionRevokedSchema,
14
14
  } from "../session-revoked-event";
15
15
 
16
+ const REVOKE_ALL_SESSIONS_REASON =
17
+ "revokes the target user's sessions in every tenant and appends the audit event on the SYSTEM stream";
18
+
16
19
  // Mass-revoke ALL live sessions for a target user — privileged-only.
17
20
  // Used by user-data-rights:restrict-account for account-freeze (DSGVO
18
21
  // Art. 18) and potentially other ops flows ("ban user", "compromised
@@ -37,9 +40,13 @@ export const revokeAllForUserWrite = defineWriteHandler({
37
40
  description:
38
41
  "Irreversibly signs a named user out of all their live sessions, across every tenant unless a tenantId narrows it; use it for operator actions such as freezing or banning an account.",
39
42
  agent: { risk: "high" },
43
+ escapeHatch: {
44
+ reason: REVOKE_ALL_SESSIONS_REASON,
45
+ },
40
46
  handler: async (event, ctx) => {
47
+ const runner = ctx.db.unsafeRaw(REVOKE_ALL_SESSIONS_REASON);
41
48
  const updated = await updateMany<{ id: string }>(
42
- ctx.db.raw,
49
+ runner,
43
50
  userSessionTable,
44
51
  { revokedAt: Temporal.Now.instant() },
45
52
  {
@@ -79,7 +86,7 @@ export const revokeAllForUserWrite = defineWriteHandler({
79
86
  sessionIds: updated.map((row) => row.id),
80
87
  });
81
88
  const reqCtx = requestContext.get();
82
- await append(ctx.db.raw, {
89
+ await append(runner, {
83
90
  aggregateId: generateId(),
84
91
  aggregateType: SESSION_REVOKED_AGGREGATE_TYPE,
85
92
  tenantId: SYSTEM_TENANT_ID,
@@ -15,15 +15,25 @@ type TenantLifecycleRow = {
15
15
  gracePeriodEnd: Temporal.Instant | null;
16
16
  };
17
17
 
18
+ const CANCEL_DESTRUCTION_TENANT_ROW_REASON =
19
+ "reads the caller's tenant row, whose tenant_id is the creating tenant, not the row's own id";
20
+
18
21
  export const cancelDestructionWrite = defineWriteHandler({
19
22
  name: "cancel-destruction",
20
23
  schema: z.object({}),
21
24
  access: { roles: ["TenantOwner", "Admin"] },
22
25
  description:
23
26
  "Returns a tenant whose destruction was requested back to active and clears the grace period; use it to undo a close-account request while the grace period is still running.",
27
+ escapeHatch: {
28
+ reason: CANCEL_DESTRUCTION_TENANT_ROW_REASON,
29
+ },
24
30
  handler: async (event, ctx) => {
25
31
  const tenantId = event.user.tenantId;
26
- const row = await fetchOne<TenantLifecycleRow>(ctx.db.raw, tenantTable, { id: tenantId });
32
+ const row = await fetchOne<TenantLifecycleRow>(
33
+ ctx.db.unsafeRaw(CANCEL_DESTRUCTION_TENANT_ROW_REASON),
34
+ tenantTable,
35
+ { id: tenantId },
36
+ );
27
37
  if (!row) {
28
38
  return writeFailure(new UnprocessableError("tenant_not_found", { details: { tenantId } }));
29
39
  }
@@ -18,6 +18,9 @@ type TenantLifecycleRow = {
18
18
  gracePeriodEnd: Temporal.Instant | null;
19
19
  };
20
20
 
21
+ const REQUEST_DESTRUCTION_TENANT_ROW_REASON =
22
+ "reads the caller's tenant row, whose tenant_id is the creating tenant, its compliance profile, and revokes its sessions via DbRunner helpers";
23
+
21
24
  export const requestDestructionWrite = defineWriteHandler({
22
25
  name: "request-destruction",
23
26
  schema: z.object({}),
@@ -25,9 +28,13 @@ export const requestDestructionWrite = defineWriteHandler({
25
28
  description:
26
29
  "Puts the caller's own tenant into destroyRequested, starts the compliance-profile grace period after which its data is erased, and revokes every session in the tenant; use it when an account owner asks to close their account.",
27
30
  agent: { risk: "high" },
31
+ escapeHatch: {
32
+ reason: REQUEST_DESTRUCTION_TENANT_ROW_REASON,
33
+ },
28
34
  handler: async (event, ctx) => {
29
35
  const tenantId = event.user.tenantId;
30
- const row = await fetchOne<TenantLifecycleRow>(ctx.db.raw, tenantTable, { id: tenantId });
36
+ const runner = ctx.db.unsafeRaw(REQUEST_DESTRUCTION_TENANT_ROW_REASON);
37
+ const row = await fetchOne<TenantLifecycleRow>(runner, tenantTable, { id: tenantId });
31
38
  if (!row) {
32
39
  return writeFailure(new UnprocessableError("tenant_not_found", { details: { tenantId } }));
33
40
  }
@@ -39,7 +46,7 @@ export const requestDestructionWrite = defineWriteHandler({
39
46
  );
40
47
  }
41
48
 
42
- const { profile } = await resolveProfileForTenant({ db: ctx.db.raw, tenantId });
49
+ const { profile } = await resolveProfileForTenant({ db: runner, tenantId });
43
50
  const T = getTemporal();
44
51
  const gracePeriodEnd = addDurationSpec(T.Now.instant(), profile.tenantDestroyGracePeriod);
45
52
 
@@ -71,7 +78,7 @@ export const requestDestructionWrite = defineWriteHandler({
71
78
  });
72
79
 
73
80
  if (await ctx.hasFeature("sessions")) {
74
- await revokeTenantSessions(ctx.db.raw, tenantId);
81
+ await revokeTenantSessions(runner, tenantId);
75
82
  }
76
83
 
77
84
  return {
@@ -46,7 +46,6 @@ import {
46
46
  buildEntityTable,
47
47
  createEventStoreExecutor,
48
48
  createTenantDb,
49
- type DbConnection,
50
49
  } from "@cosmicdrift/kumiko-framework/db";
51
50
  import {
52
51
  defineEntityCreateHandler,
@@ -373,6 +372,8 @@ export function createTierEngineFeature<
373
372
  // neuer Tenant (Memory `feedback_event_store_tenant_consistency`).
374
373
  if (opts.defaultTier !== undefined) {
375
374
  const defaultTier = opts.defaultTier;
375
+ const autoDefaultTierHookReason =
376
+ "seeds the default tier-assignment stream of the tenant just created";
376
377
  r.hook(
377
378
  "postSave",
378
379
  { allOf: "tenant" },
@@ -392,26 +393,17 @@ export function createTierEngineFeature<
392
393
 
393
394
  // tenant is an r.systemScope() feature, so this cross-feature hook gets a
394
395
  // fail-closed ctx.db — reach for ctx.systemDb instead (see entity-handlers.ts).
395
- const db = ctx.systemDb
396
- ? ctx.systemDb.acknowledgeCrossTenant(
397
- `tier-engine auto-default-tier hook on r.systemScope() tenant write (${newTenantId})`,
398
- )
399
- : ctx.db;
396
+ // Event-store paths need the raw DbConnection — TenantDb only exposes
397
+ // select/insert/update/delete, not execute (db.execute(sql`SELECT pg_notify(...)`) would TypeError).
398
+ const rawDb = ctx.systemDb
399
+ ? ctx.systemDb.unsafeRaw(autoDefaultTierHookReason)
400
+ : ctx.db && "unsafeRaw" in ctx.db
401
+ ? ctx.db.unsafeRaw(autoDefaultTierHookReason)
402
+ : undefined;
400
403
  // skip: defensive — inTransaction phase always sets db, but AppContext's
401
404
  // type makes it optional. Throwing would be overreach (lifecycle
402
405
  // blocking), silent-skip is defensive-soft.
403
- if (!db) return;
404
-
405
- // db is a TenantDb in the inTransaction phase (tenant-scoped proxy over
406
- // the real tx). Event-store paths need the raw DbConnection — TenantDb
407
- // only exposes select/insert/update/delete, not execute
408
- // (event-store-append.ts:102 calls db.execute(sql`SELECT pg_notify(...)`)
409
- // → TypeError otherwise). Pattern matches signup-confirm.write.ts:107
410
- // (.raw), not `as DbConnection` — that's a type-lie that only crashes on
411
- // the first .execute() call.
412
- // skip: defensive — should never trip in the inTransaction phase.
413
- if (!("raw" in db)) return;
414
- const rawDb = db.raw as DbConnection; // @cast-boundary db-runner
406
+ if (!rawDb) return;
415
407
 
416
408
  // Idempotency: stream-existence-check vor create. Pattern aus
417
409
  // seedTenant.ts. Bei re-replay (rebuild) nicht versionsbumpen.
@@ -438,7 +430,7 @@ export function createTierEngineFeature<
438
430
  tdb,
439
431
  );
440
432
  },
441
- { phase: HookPhases.inTransaction },
433
+ { phase: HookPhases.inTransaction, escapeHatch: { reason: autoDefaultTierHookReason } },
442
434
  );
443
435
  }
444
436
 
@@ -1,9 +1,5 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import {
3
- buildEntityTable,
4
- createTenantDb,
5
- type DbConnection,
6
- } from "@cosmicdrift/kumiko-framework/db";
2
+ import { buildEntityTable, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
7
3
  import { defineQueryHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
8
4
  import { z } from "zod";
9
5
  import { type TierAssignmentRow, tierAssignmentEntity } from "../entity";
@@ -15,15 +11,20 @@ import { type TierAssignmentRow, tierAssignmentEntity } from "../entity";
15
11
 
16
12
  const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEntity);
17
13
 
14
+ const GET_TENANT_TIER_REASON = "SystemAdmin reads the tier of the tenant named in the payload";
15
+
18
16
  export const getTenantTierQuery = defineQueryHandler({
19
17
  name: "get-tenant-tier",
20
18
  description:
21
19
  "Returns the tier-assignment of any given tenant including where it came from (manual grant, billing sync or signup default), or null; use it to look up another tenant's plan.",
22
20
  schema: z.object({ tenantId: z.string().min(1) }),
23
21
  access: { roles: ["SystemAdmin"] },
22
+ escapeHatch: {
23
+ reason: GET_TENANT_TIER_REASON,
24
+ },
24
25
  handler: async (query, ctx) => {
25
26
  const tenantId = query.payload.tenantId as TenantId; // @cast-boundary engine-bridge
26
- const tdb = createTenantDb(ctx.db.raw as DbConnection, tenantId, "system"); // @cast-boundary db-runner
27
+ const tdb = createTenantDb(ctx.db.unsafeRaw(GET_TENANT_TIER_REASON), tenantId, "system");
27
28
  const row = await fetchOne<TierAssignmentRow>(tdb, tierAssignmentTable, { tenantId });
28
29
  return row ?? null;
29
30
  },
@@ -3,7 +3,6 @@ import {
3
3
  buildEntityTable,
4
4
  createEventStoreExecutor,
5
5
  createTenantDb,
6
- type DbConnection,
7
6
  } from "@cosmicdrift/kumiko-framework/db";
8
7
  import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
9
8
  import { z } from "zod";
@@ -39,6 +38,8 @@ const executor = createEventStoreExecutor(tierAssignmentTable, tierAssignmentEnt
39
38
  entityName: "tier-assignment",
40
39
  });
41
40
 
41
+ const SET_TENANT_TIER_REASON = "SystemAdmin assigns the tier of the tenant named in the payload";
42
+
42
43
  export type SetTenantTierOptions = {
43
44
  /** Nach erfolgreichem Write aufgerufen, damit feature.ts den Resolver-
44
45
  * Cache aktualisieren kann (der Executor-Write feuert den postSave-Hook
@@ -66,9 +67,12 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
66
67
  .refine((t) => !opts.validTiers || opts.validTiers.has(t), { message: "unknown tier" }),
67
68
  }),
68
69
  access: { roles: ["SystemAdmin"] },
70
+ escapeHatch: {
71
+ reason: SET_TENANT_TIER_REASON,
72
+ },
69
73
  handler: async (event, ctx) => {
70
74
  const tenantId = event.payload.tenantId as TenantId; // @cast-boundary engine-bridge
71
- const rawDb = ctx.db.raw as DbConnection; // @cast-boundary db-runner
75
+ const rawDb = ctx.db.unsafeRaw(SET_TENANT_TIER_REASON);
72
76
  const tdb = createTenantDb(rawDb, tenantId, "system");
73
77
  const systemUser = { ...event.user, tenantId };
74
78
  const tier = event.payload.tier;
@@ -0,0 +1,97 @@
1
+ // fw#2858 — userEntity carries `tenancy: "global"` (a person's platform-wide
2
+ // identity, shared across tenants). Reads through ctx.db.global(userTable)
3
+ // work cross-tenant with no escapeHatch; a write attempt through the same
4
+ // accessor is denied without one — userTable stays managed (executor-only),
5
+ // so a hand-written write bypassing the executor must still be gated.
6
+
7
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
9
+ import {
10
+ createTestUser,
11
+ setupTestStack,
12
+ type TestStack,
13
+ TestUsers,
14
+ unsafeCreateEntityTable,
15
+ } from "@cosmicdrift/kumiko-framework/stack";
16
+ import { z } from "zod";
17
+ import { UserHandlers } from "../constants";
18
+ import { createUserFeature } from "../feature";
19
+ import { userEntity, userTable } from "../schema/user";
20
+
21
+ const globalUserAccessFeature = defineFeature("user-global-access-probe", (r) => {
22
+ r.queryHandler({
23
+ name: "read-via-global",
24
+ schema: z.object({ id: z.string() }),
25
+ access: { roles: ["User"] },
26
+ handler: async (query, ctx) => {
27
+ const row = await ctx.db
28
+ .global(userTable)
29
+ .fetchOne<{ id: string; email: string }>({ id: query.payload.id });
30
+ return row ?? null;
31
+ },
32
+ });
33
+
34
+ r.writeHandler({
35
+ name: "write-via-global-without-hatch",
36
+ schema: z.object({}),
37
+ access: { roles: ["User"] },
38
+ handler: async (_event, ctx) => {
39
+ const accessor = ctx.db.global(userTable);
40
+ // @ts-expect-error executor-only brand — userTable's managed write methods
41
+ // are type-hidden through db.global(); the runtime object still carries
42
+ // them, so the escapeHatch gate below is the actual enforcement.
43
+ const row = await accessor.insertOne({ displayName: "hack", email: "hack@example.com" });
44
+ return { isSuccess: true as const, data: row };
45
+ },
46
+ });
47
+ });
48
+
49
+ let stack: TestStack;
50
+ const systemAdmin = TestUsers.systemAdmin;
51
+
52
+ beforeAll(async () => {
53
+ stack = await setupTestStack({ features: [createUserFeature(), globalUserAccessFeature] });
54
+ await unsafeCreateEntityTable(stack.db, userEntity);
55
+ });
56
+
57
+ afterAll(async () => {
58
+ await stack.cleanup();
59
+ });
60
+
61
+ describe("ctx.db.global(userTable) — cross-tenant identity access (fw#2858)", () => {
62
+ test("a handler in tenant A reads the user via db.global(userTable).fetchOne", async () => {
63
+ const created = await stack.http.writeOk<{ id: string }>(
64
+ UserHandlers.create,
65
+ { email: "global-read@example.com", displayName: "Global Read", passwordHash: "seeded-hash" },
66
+ systemAdmin,
67
+ );
68
+
69
+ const readerInTenantA = createTestUser({
70
+ id: crypto.randomUUID(),
71
+ tenantId: "11111111-0000-4000-8000-0000000000a1",
72
+ roles: ["User"],
73
+ });
74
+ const row = await stack.http.queryOk<{ id: string; email: string } | null>(
75
+ "user-global-access-probe:query:read-via-global",
76
+ { id: created.id },
77
+ readerInTenantA,
78
+ );
79
+
80
+ expect(row?.id).toBe(created.id);
81
+ expect(row?.email).toBe("global-read@example.com");
82
+ });
83
+
84
+ test("a write attempt through ctx.db.global(userTable) without escapeHatch fails with access_denied", async () => {
85
+ const writer = createTestUser({
86
+ id: crypto.randomUUID(),
87
+ tenantId: "11111111-0000-4000-8000-0000000000a2",
88
+ roles: ["User"],
89
+ });
90
+ const err = await stack.http.writeErr(
91
+ "user-global-access-probe:write:write-via-global-without-hatch",
92
+ {},
93
+ writer,
94
+ );
95
+ expect(err.code).toBe("access_denied");
96
+ });
97
+ });
@@ -2,6 +2,7 @@ import { asEntityTableMeta, asRawClient, fetchOne } from "@cosmicdrift/kumiko-fr
2
2
  import {
3
3
  acquireNamespacedAdvisoryLock,
4
4
  createEventStoreExecutor,
5
+ type DbRunner,
5
6
  type TenantDb,
6
7
  } from "@cosmicdrift/kumiko-framework/db";
7
8
  import {
@@ -52,13 +53,13 @@ function isActiveSystemAdminRow(row: UserRolesRow): boolean {
52
53
  return isActiveUserRow(row) && parseRoles(row.roles).includes("SystemAdmin");
53
54
  }
54
55
 
55
- async function countOtherActiveSystemAdmins(db: TenantDb, excludeUserId: string): Promise<number> {
56
+ async function countOtherActiveSystemAdmins(db: DbRunner, excludeUserId: string): Promise<number> {
56
57
  const tableName = asEntityTableMeta(userTable)?.tableName;
57
58
  if (!tableName) {
58
59
  throw new InternalError({ message: "user read table meta missing" });
59
60
  }
60
61
  // kumiko-lint-ignore raw-sql jsonb @> prefilter for SystemAdmin roster under advisory lock
61
- const rows = (await asRawClient(db.raw).unsafe(
62
+ const rows = (await asRawClient(db).unsafe(
62
63
  `SELECT id, roles, status, is_deleted AS "isDeleted"
63
64
  FROM ${quoteIdent(tableName)}
64
65
  WHERE is_deleted = false
@@ -128,7 +129,17 @@ export async function applyUserRolesUpdate(
128
129
  // Lock before count+update so two concurrent demotions cannot both
129
130
  // observe otherActiveSystemAdmins >= 1 and leave zero active SystemAdmins.
130
131
  await acquireNamespacedAdvisoryLock(db, LAST_SYSTEM_ADMIN_LOCK_NAMESPACE, "global");
131
- const otherActiveSystemAdmins = await countOtherActiveSystemAdmins(db, event.payload.id);
132
+ // user is r.systemScope() — the count spans every tenant's SystemAdmins,
133
+ // so it needs the raw runner, not the (still tenant-shaped) `db` above.
134
+ if (!ctx.systemDb) {
135
+ throw new InternalError({ message: "user:update-roles requires ctx.systemDb" });
136
+ }
137
+ const otherActiveSystemAdmins = await countOtherActiveSystemAdmins(
138
+ ctx.systemDb.unsafeRaw(
139
+ "jsonb @> prefilter over the global users table to count remaining active SystemAdmins",
140
+ ),
141
+ event.payload.id,
142
+ );
132
143
  if (otherActiveSystemAdmins === 0) {
133
144
  return writeFailure(
134
145
  new ConflictError({
@@ -15,6 +15,9 @@ import { applyUserRolesUpdate } from "./update-roles";
15
15
 
16
16
  const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
17
17
 
18
+ const SYSTEM_ADMIN_DEMOTION_COUNT_REASON =
19
+ "on a SystemAdmin demotion, a jsonb @> prefilter over the global users table counts remaining active SystemAdmins";
20
+
18
21
  // Users can update their OWN profile; SystemAdmin/system can update anyone.
19
22
  // Handler-level access is openToAll — the row guard below is the actual gate,
20
23
  // and field-level access (passwordHash/email write-locked to "privileged")
@@ -40,9 +43,19 @@ export const updateWrite = defineWriteHandler({
40
43
  roles: rolesInputSchema.optional(),
41
44
  }),
42
45
  }),
43
- access: { openToAll: true },
46
+ access: {
47
+ openToAll: {
48
+ reason:
49
+ "Any signed-in user edits their own profile and privileged actors edit any user; " +
50
+ "the self-or-privileged check lives in the handler body, not in userEntity.access.write.",
51
+ personalData: "tenant-members",
52
+ },
53
+ },
44
54
  description:
45
55
  "Changes a user's display name, locale, timezone, email, verification flag, last active tenant or global roles against the version the caller read; callers may edit themselves, while editing someone else or granting roles needs a privileged actor.",
56
+ escapeHatch: {
57
+ reason: SYSTEM_ADMIN_DEMOTION_COUNT_REASON,
58
+ },
46
59
  handler: async (event, ctx) => {
47
60
  const isSelf = event.payload.id === event.user.id;
48
61
  const isPrivileged = hasAccess(event.user, { roles: access.privileged });
@@ -1,5 +1,10 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import type { PrincipalStatus, PrincipalStatusPlugin } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type {
3
+ PrincipalProfile,
4
+ PrincipalStatus,
5
+ PrincipalStatusPlugin,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
3
8
  import { USER_STATUS, type UserStatus, userTable } from "./schema/user";
4
9
 
5
10
  // Locked accounts whose live sessions must be refused. deletionRequested is
@@ -23,4 +28,20 @@ export const principalStatusPlugin: PrincipalStatusPlugin = {
23
28
  if (!row) return "unknown";
24
29
  return isPrincipalBlocked(row.status) ? "blocked" : "active";
25
30
  },
31
+
32
+ // ctx.queryAsMember's global-roles/timezone/locale source — same fields
33
+ // login mints a session from (see login.write.ts gateBuildSession).
34
+ async resolveProfile(userId, { db }): Promise<PrincipalProfile | null> {
35
+ const row = await fetchOne<{ roles: unknown; timezone: string | null; locale: string | null }>(
36
+ db,
37
+ userTable,
38
+ { id: userId },
39
+ );
40
+ if (!row) return null;
41
+ return {
42
+ globalRoles: parseRoles(row.roles),
43
+ ...(row.timezone ? { timezone: row.timezone } : {}),
44
+ ...(row.locale ? { locale: row.locale } : {}),
45
+ };
46
+ },
26
47
  };
@@ -65,6 +65,9 @@ export const userEntity = createEntity({
65
65
  // Tenant-independent identity aggregate — its event stream lives on
66
66
  // SYSTEM_TENANT_ID instead of whichever tenant happened to create it (#497).
67
67
  systemStream: true,
68
+ // Every row lives on SYSTEM_TENANT_ID; writes with another tenantId are
69
+ // rejected (fw#2858).
70
+ tenancy: "global",
68
71
  fields: {
69
72
  // Identity — anyone who can see the user can read the email, but only
70
73
  // privileged roles (SYSTEM auth code, SystemAdmin) may change it.
@@ -178,7 +178,9 @@ describe("my-audit-log — kumiko-framework#1525: no ambient Temporal global", (
178
178
  payload: { limit: 50, from: "2020-01-01T00:00:00Z", to: "2030-01-01T00:00:00Z" },
179
179
  user: alice,
180
180
  } as Parameters<typeof myAuditLogQuery.handler>[0],
181
- { db: { raw: stack.db } } as unknown as Parameters<typeof myAuditLogQuery.handler>[1],
181
+ { db: { unsafeRaw: () => stack.db } } as unknown as Parameters<
182
+ typeof myAuditLogQuery.handler
183
+ >[1],
182
184
  );
183
185
  expect(res.rows.length).toBeGreaterThanOrEqual(1);
184
186
  } finally {
@@ -1,4 +1,3 @@
1
- import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
1
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
2
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
3
  import { z } from "zod";
@@ -6,6 +5,9 @@ import { isWithinGracePeriod } from "../../shared";
6
5
  import { USER_STATUS, userTable } from "../../user";
7
6
  import { updateUserLifecycle } from "../lib/update-user-lifecycle";
8
7
 
8
+ const APPEND_LIFECYCLE_EVENT_REASON =
9
+ "appends the user lifecycle event on the SYSTEM_TENANT_ID user stream";
10
+
9
11
  // POST /api/user/cancel-deletion (S2.U5).
10
12
  //
11
13
  // Innerhalb der Grace-Period kann User seinen Forget-Antrag zurueck-
@@ -23,14 +25,14 @@ export const cancelDeletionWrite = defineWriteHandler({
23
25
  access: { openToAll: true },
24
26
  description:
25
27
  "Withdraws the calling user's own pending account-deletion request and puts the account back to active, accepted only while the grace period is still running.",
28
+ escapeHatch: {
29
+ reason: APPEND_LIFECYCLE_EVENT_REASON,
30
+ },
26
31
  handler: async (event, ctx) => {
27
- // ctx.db.raw (kein TenantDb-Wrapper) weil User-Entity tenant-agnostisch
28
- // ist — siehe request-deletion.write.ts fuer die Begruendung. Cancel
29
- // muss aus jedem Tenant-Mode den User finden + zuruecksetzen koennen.
30
- const row = await fetchOne<{
32
+ const row = await ctx.db.global(userTable).fetchOne<{
31
33
  status: string;
32
34
  gracePeriodEnd: Temporal.Instant | null;
33
- }>(ctx.db.raw, userTable, { id: event.user.id });
35
+ }>({ id: event.user.id });
34
36
 
35
37
  if (!row) {
36
38
  return writeFailure(
@@ -52,12 +54,11 @@ export const cancelDeletionWrite = defineWriteHandler({
52
54
  return writeFailure(new UnprocessableError("grace_period_expired"));
53
55
  }
54
56
 
55
- await updateUserLifecycle(ctx.db.raw, event.user.id, {
57
+ await updateUserLifecycle(ctx.db.unsafeRaw(APPEND_LIFECYCLE_EVENT_REASON), event.user.id, {
56
58
  status: USER_STATUS.Active,
57
59
  gracePeriodEnd: null,
58
- // #354/1: schließt das replay-after-cancel-Fenster — ein noch
59
- // TTL-gültiges email-Token verifiziert gegen die genullte requestId
60
- // nicht mehr und kann keine zweite Grace-Period armen.
60
+ // #354/1: closes the replay-after-cancel window — a still-TTL-valid email
61
+ // token verified against the nulled requestId can no longer arm a second grace period.
61
62
  pendingDeletionRequestId: null,
62
63
  });
63
64
 
@@ -1,4 +1,3 @@
1
- import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
1
  import { defineWriteHandler, type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
3
2
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
3
  import { z } from "zod";
@@ -25,9 +24,11 @@ async function readPendingDeletionRequestId(
25
24
  userId: string,
26
25
  ): Promise<string | null> {
27
26
  try {
28
- const row = await fetchOne<{ pendingDeletionRequestId: string | null }>(ctx.db.raw, userTable, {
29
- id: userId,
30
- });
27
+ const row = await ctx.db
28
+ .global(userTable)
29
+ .fetchOne<{ pendingDeletionRequestId: string | null }>({
30
+ id: userId,
31
+ });
31
32
  return row?.["pendingDeletionRequestId"] ?? null;
32
33
  } catch {
33
34
  return null;
@@ -54,7 +55,8 @@ export function createConfirmDeletionByTokenHandler(opts: ConfirmDeletionByToken
54
55
  reason:
55
56
  "startDeletionGracePeriod reads the tenant compliance profile via ctx.queryAs(SYSTEM, " +
56
57
  "...) to compute the grace period end — the anonymous token holder has no read access " +
57
- "to that tenant-config projection.",
58
+ "to that tenant-config projection. It also appends the user lifecycle event on the " +
59
+ "SYSTEM_TENANT_ID user stream.",
58
60
  },
59
61
  agent: { expose: false },
60
62
  rateLimit: { per: "ip", limit: 10, windowSeconds: 60 },
@@ -1,4 +1,3 @@
1
- import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
1
  import { addDurationSpec, type DurationSpec } from "@cosmicdrift/kumiko-framework/compliance";
3
2
  import { createSystemUser, type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
4
3
  import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
@@ -32,11 +31,9 @@ export async function startDeletionGracePeriod(
32
31
  userId: string,
33
32
  complianceTenantId: string,
34
33
  ): Promise<StartGracePeriodResult> {
35
- const userRow = await fetchOne<{ status: string; email: string; locale: string | null }>(
36
- ctx.db.raw,
37
- userTable,
38
- { id: userId },
39
- );
34
+ const userRow = await ctx.db
35
+ .global(userTable)
36
+ .fetchOne<{ status: string; email: string; locale: string | null }>({ id: userId });
40
37
  if (!userRow) {
41
38
  return {
42
39
  ok: false,
@@ -66,10 +63,14 @@ export async function startDeletionGracePeriod(
66
63
  const T = getTemporal();
67
64
  const gracePeriodEnd = addDurationSpec(T.Now.instant(), gracePeriod);
68
65
 
69
- await updateUserLifecycle(ctx.db.raw, userId, {
70
- status: USER_STATUS.DeletionRequested,
71
- gracePeriodEnd,
72
- });
66
+ await updateUserLifecycle(
67
+ ctx.db.unsafeRaw("appends the user lifecycle event on the SYSTEM_TENANT_ID user stream"),
68
+ userId,
69
+ {
70
+ status: USER_STATUS.DeletionRequested,
71
+ gracePeriodEnd,
72
+ },
73
+ );
73
74
 
74
75
  return {
75
76
  ok: true,