@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
@@ -51,16 +51,17 @@ function requireTenantScopedDeps(
51
51
  ): { db: DbConnection; registry: Registry } {
52
52
  const registry = ctx.registry;
53
53
  if (!registry) throw new Error("delivery job: missing registry in job context");
54
- // delivery is r.systemScope()'d (feature.ts), so JobContext.systemDb is
55
- // guaranteed here. assertTenantMatch(tenantId).raw hands appendAttemptEvent/
56
- // buildChannelContext the underlying DbConnection — safe because both
57
- // filter by the same tenantId themselves (see JobContext.systemDb doc).
54
+ // delivery is r.systemScope()'d, so JobContext.systemDb is guaranteed; unsafeRaw
55
+ // hands the underlying DbConnection to both — safe since they filter by tenantId themselves.
58
56
  if (!ctx.systemDb) {
59
57
  throw new InternalError({
60
58
  message: "delivery job: ctx.systemDb missing on a system-scoped job",
61
59
  });
62
60
  }
63
- const db = ctx.systemDb.assertTenantMatch(tenantId).raw as DbConnection; // @cast-boundary db-operator — TenantDb.raw is DbRunner, jobs never run inside a DbTx
61
+ ctx.systemDb.assertTenantMatch(tenantId);
62
+ const db = ctx.systemDb.unsafeRaw(
63
+ "delivery attempt append and channel context filter by this job's tenantId themselves",
64
+ ) as DbConnection; // @cast-boundary db-operator — DbRunner narrows to DbConnection, jobs never run inside a DbTx
64
65
  return { db, registry };
65
66
  }
66
67
 
@@ -1,13 +1,16 @@
1
1
  import { asEntityTableMeta } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import {
3
3
  boolean,
4
+ declareGlobalTenancy,
4
5
  type EntityTableMeta,
5
6
  instant,
6
7
  integer,
7
8
  table as pgTable,
9
+ type SchemaTable,
8
10
  sql,
9
11
  text,
10
12
  } from "@cosmicdrift/kumiko-framework/db";
13
+ import type { TenancyBrand } from "@cosmicdrift/kumiko-types/tenancy-brand";
11
14
 
12
15
  // Global feature-toggle override state. One row per feature that has ever
13
16
  // been explicitly flipped by an operator. Missing row = "no override,
@@ -17,17 +20,18 @@ import {
17
20
  // name IS the identity here. No tenantId: this is a global override that
18
21
  // applies across every tenant (per-tenant toggles are intentionally out of
19
22
  // scope, see core-feature-toggles.md).
20
- export const globalFeatureStateTable = pgTable("store_global_feature_state", {
21
- featureName: text("feature_name").primaryKey(),
22
- enabled: boolean("enabled").notNull(),
23
- // Optimistic-lock column. The set-handler reads the existing row, then
24
- // updates with `WHERE feature_name = ? AND version = ?`; a 0-row update
25
- // means someone else wrote concurrently — the handler retries the fetch.
26
- version: integer("version").notNull().default(1),
27
- updatedAt: instant("updated_at").default(sql`now()`).notNull(),
28
- // UserId (text — SessionUser.id is a uuid string post-ES).
29
- updatedBy: text("updated_by"),
30
- });
23
+ export const globalFeatureStateTable: SchemaTable & TenancyBrand<"global"> = declareGlobalTenancy(
24
+ pgTable("store_global_feature_state", {
25
+ featureName: text("feature_name").primaryKey(),
26
+ enabled: boolean("enabled").notNull(),
27
+ // Optimistic-lock column: the set-handler updates `WHERE feature_name = ? AND
28
+ // version = ?`; a 0-row update means a concurrent write, so it retries the fetch.
29
+ version: integer("version").notNull().default(1),
30
+ updatedAt: instant("updated_at").default(sql`now()`).notNull(),
31
+ // UserId (text — SessionUser.id is a uuid string post-ES).
32
+ updatedBy: text("updated_by"),
33
+ }),
34
+ );
31
35
 
32
36
  // r.storeTable meta — without this, collectTableMetas(FEATURES) never
33
37
  // sees the table, so `kumiko schema generate` reports no changes and no
@@ -1,4 +1,3 @@
1
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
1
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
2
  import { z } from "zod";
4
3
  import { FEATURE_TOGGLE_CROSS_TENANT_REASON } from "../constants";
@@ -32,7 +31,7 @@ export const listQuery = defineQueryHandler({
32
31
  updatedAt: Temporal.Instant;
33
32
  updatedBy: string;
34
33
  };
35
- const rows = await selectMany<Row>(db.raw, globalFeatureStateTable);
34
+ const rows = await db.global(globalFeatureStateTable).selectMany<Row>();
36
35
  return {
37
36
  items: rows.map((r) => ({
38
37
  featureName: r.featureName,
@@ -1,4 +1,3 @@
1
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
1
  import { defineQueryHandler, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
3
2
  import { z } from "zod";
4
3
  import { FEATURE_TOGGLE_CROSS_TENANT_REASON } from "../constants";
@@ -36,7 +35,7 @@ export const registeredQuery = defineQueryHandler({
36
35
  const db = ctx.systemDb.acknowledgeCrossTenant(FEATURE_TOGGLE_CROSS_TENANT_REASON);
37
36
 
38
37
  type OverrideRow = { featureName: string; enabled: boolean };
39
- const overrideRows = await selectMany<OverrideRow>(db.raw, globalFeatureStateTable);
38
+ const overrideRows = await db.global(globalFeatureStateTable).selectMany<OverrideRow>();
40
39
  const overrides = new Map(overrideRows.map((r) => [r.featureName, r.enabled]));
41
40
 
42
41
  // SystemAdmin operator-tooling: das listing soll die PLATTFORM-truth
@@ -17,6 +17,9 @@ const fileRefExecutor = createEventStoreExecutor(fileRefsTable, fileRefEntity, {
17
17
  entityName: "fileRef",
18
18
  });
19
19
 
20
+ const DISCARD_DRAFT_FILE_REFS_REASON =
21
+ "matches draft storage keys against the caller's own file_refs rows via raw SQL";
22
+
20
23
  // discard — the caller's own draft only. Ownership is enforced by the
21
24
  // lookup predicate (tenantId + ownerId + draftKey), not by a separate
22
25
  // permission check: a foreign user's discard call for someone else's
@@ -30,6 +33,9 @@ export const discardDraftWrite = defineWriteHandler({
30
33
  description:
31
34
  "Deletes the calling user's draft for one draftKey and, when releaseFiles is set, hard-deletes the file references that draft alone uploaded; use it after a successful submit or when the user abandons the form.",
32
35
  agent: { risk: "high" },
36
+ escapeHatch: {
37
+ reason: DISCARD_DRAFT_FILE_REFS_REASON,
38
+ },
33
39
  handler: async (event, ctx) => {
34
40
  const ownerId = event.user.id;
35
41
  const existing = await lookupDraft(
@@ -62,7 +68,7 @@ export const discardDraftWrite = defineWriteHandler({
62
68
  // predates the draft row, so the insertedAt filter must not apply.
63
69
  const isCreateMode = event.payload.draftKey.includes(":new:");
64
70
  const ownedRefs = await filterOwnedFileRefs(
65
- ctx.db.raw,
71
+ ctx.db.unsafeRaw(DISCARD_DRAFT_FILE_REFS_REASON),
66
72
  event.user.tenantId,
67
73
  ownerId,
68
74
  candidateKeys,
@@ -31,12 +31,18 @@ function isDraftKeyConflict(failure: WriteFailure): boolean {
31
31
  );
32
32
  }
33
33
 
34
+ const SAVE_DRAFT_COUNT_REASON =
35
+ "COUNT(*) of the caller's own drafts without loading 64KB value blobs";
36
+
34
37
  export const saveDraftWrite = defineWriteHandler({
35
38
  name: "save",
36
39
  schema: saveDraftPayloadSchema,
37
40
  access: FORM_DRAFT_ACCESS,
38
41
  description:
39
42
  "Upserts the calling user's draft for one draftKey with the given form values and step index, stamping savedAt server-side and refusing a brand-new draft once the per-owner draft cap is reached; use it to persist an in-progress form before the real entity exists.",
43
+ escapeHatch: {
44
+ reason: SAVE_DRAFT_COUNT_REASON,
45
+ },
40
46
  handler: async (event, ctx) => {
41
47
  const ownerId = event.user.id;
42
48
  const { draftKey, values, stepIndex } = event.payload;
@@ -55,7 +61,11 @@ export const saveDraftWrite = defineWriteHandler({
55
61
  // Cap check only on the create path — an update never grows the number
56
62
  // of drafts this owner has, so a user already at the limit can still
57
63
  // keep saving their existing drafts.
58
- const draftCount = await countDraftsByOwner(ctx.db.raw, event.user.tenantId, ownerId);
64
+ const draftCount = await countDraftsByOwner(
65
+ ctx.db.unsafeRaw(SAVE_DRAFT_COUNT_REASON),
66
+ event.user.tenantId,
67
+ ownerId,
68
+ );
59
69
  if (draftCount >= FORM_DRAFT_MAX_PER_OWNER) {
60
70
  return failUnprocessable("draft_limit_reached", { limit: FORM_DRAFT_MAX_PER_OWNER });
61
71
  }
@@ -396,7 +396,9 @@ describe("scenario 2d: ingest-message — thread-rollup retry exhaustion (#1229)
396
396
  const accountId = await connectSharedAccount(admin);
397
397
  const messageIdHeader = "exhaustion-root@example.com";
398
398
 
399
- const tenantDb = createTenantDb(db, admin.tenantId, "system");
399
+ const tenantDb = createTenantDb(db, admin.tenantId, "system", undefined, undefined, undefined, {
400
+ unsafeRaw: { reason: "test: hand-built ctx driving ingestMessageHandler directly" },
401
+ });
400
402
  let threadAppendAttempts = 0;
401
403
  const ctx = {
402
404
  db: tenantDb,
@@ -20,7 +20,7 @@
20
20
  // SystemAdmin-only: Aufrufer ist der Watch/Sync-Supervisor bzw. der
21
21
  // Poll-Cron mit programmatic SystemUser — nie ein Tenant-Request.
22
22
 
23
- import { countWhere, insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
23
+ import { countWhere } from "@cosmicdrift/kumiko-framework/bun-db";
24
24
  import {
25
25
  configuredPiiSubjectKms,
26
26
  encryptPiiFieldValues,
@@ -106,11 +106,16 @@ const THREAD_ROLLUP_MAX_ATTEMPTS = 5;
106
106
  // — 'inbm' as ASCII, keeps it disjoint from the framework's other fixed
107
107
  // single-int advisory-lock keys (schema bootstrap, es-ops boot).
108
108
  const THREAD_ROLLUP_LOCK_NAMESPACE = 0x696e626d;
109
+ const THREAD_ROLLUP_ADVISORY_LOCK_REASON =
110
+ "takes the per-thread advisory lock and counts the caller tenant's thread messages; TenantDb has no lock/count API";
109
111
  export const ingestMessageHandler: WriteHandlerDef = {
110
112
  name: "ingest-message",
111
113
  schema: ingestMessageSchema,
112
114
  access: { roles: ["SystemAdmin"] },
113
115
  agent: { expose: false },
116
+ escapeHatch: {
117
+ reason: THREAD_ROLLUP_ADVISORY_LOCK_REASON,
118
+ },
114
119
  handler: async (event, ctx) => {
115
120
  // @cast-boundary engine-payload — dispatcher-zod-validated payload
116
121
  const payload = event.payload as IngestMessagePayload;
@@ -125,7 +130,7 @@ export const ingestMessageHandler: WriteHandlerDef = {
125
130
  // Check ist billig und macht den Handler rebuild-robust falls
126
131
  // store_mail_seen_messages je getruncated wird).
127
132
  // ---------------------------------------------------------------
128
- const seenRows = await selectMany(ctx.db.raw, seenMessageTable, {
133
+ const seenRows = await ctx.db.selectMany(seenMessageTable, {
129
134
  accountId: payload.accountId,
130
135
  providerMessageId: payload.providerMessageId,
131
136
  });
@@ -138,7 +143,7 @@ export const ingestMessageHandler: WriteHandlerDef = {
138
143
  const existingEvents = await ctx.loadAggregate(messageAggId);
139
144
  if (existingEvents.length > 0) {
140
145
  // Stream existiert, Anchor fehlte → Anchor nachziehen, dann raus.
141
- await insertOne(ctx.db.raw, seenMessageTable, {
146
+ await ctx.db.insertOne(seenMessageTable, {
142
147
  tenantId,
143
148
  accountId: payload.accountId,
144
149
  providerMessageId: payload.providerMessageId,
@@ -267,7 +272,12 @@ export const ingestMessageHandler: WriteHandlerDef = {
267
272
  )
268
273
  : threadPlainPii;
269
274
 
270
- await acquireNamespacedAdvisoryLock(ctx.db.raw, THREAD_ROLLUP_LOCK_NAMESPACE, threadAggId);
275
+ const threadRollupRunner = ctx.db.unsafeRaw(THREAD_ROLLUP_ADVISORY_LOCK_REASON);
276
+ await acquireNamespacedAdvisoryLock(
277
+ threadRollupRunner,
278
+ THREAD_ROLLUP_LOCK_NAMESPACE,
279
+ threadAggId,
280
+ );
271
281
 
272
282
  let threadAppendOk = false;
273
283
  for (let attempt = 1; attempt <= THREAD_ROLLUP_MAX_ATTEMPTS && !threadAppendOk; attempt++) {
@@ -278,7 +288,7 @@ export const ingestMessageHandler: WriteHandlerDef = {
278
288
  const previousLastAt = previousThread?.lastMessageAtIso ?? "";
279
289
  const newLastAt =
280
290
  payload.receivedAtIso > previousLastAt ? payload.receivedAtIso : previousLastAt;
281
- const messageCount = await countWhere(ctx.db.raw, inboundMessagesProjectionTable, {
291
+ const messageCount = await countWhere(threadRollupRunner, inboundMessagesProjectionTable, {
282
292
  tenantId,
283
293
  threadKey,
284
294
  });
@@ -308,7 +318,7 @@ export const ingestMessageHandler: WriteHandlerDef = {
308
318
  // 6. Dedup-Anchor. Läuft in derselben TX wie die appends — stirbt
309
319
  // der Handler, rollt alles zusammen zurück.
310
320
  // ---------------------------------------------------------------
311
- await insertOne(ctx.db.raw, seenMessageTable, {
321
+ await ctx.db.insertOne(seenMessageTable, {
312
322
  tenantId,
313
323
  accountId: payload.accountId,
314
324
  providerMessageId: payload.providerMessageId,
@@ -7,8 +7,6 @@
7
7
  // selbst + TenantAdmin/SystemAdmin (Compliance-Sicht). Filter läuft
8
8
  // in-memory nach dem tenant-scoped select — Volumen ist die Anzahl
9
9
  // verbundener Postfächer eines Tenants (einstellig bis zweistellig).
10
-
11
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
12
10
  import {
13
11
  configuredPiiSubjectKms,
14
12
  decryptPiiFieldValues,
@@ -28,7 +26,7 @@ export const listAccountsQuery: QueryHandlerDef = {
28
26
  description:
29
27
  "Lists the connected mailboxes of the caller's tenant with the mailbox address decrypted, hiding personal mailboxes the caller neither owns nor administers; use it for the mail connect and settings views.",
30
28
  handler: async (_query, ctx) => {
31
- const allRows = await selectMany(ctx.db.raw, mailAccountsProjectionTable, {
29
+ const allRows = await ctx.db.selectMany(mailAccountsProjectionTable, {
32
30
  tenantId: ctx.user.tenantId,
33
31
  });
34
32
  const rows = allRows.filter((row) => isVisibleToCaller(row, ctx.user));
@@ -8,7 +8,6 @@
8
8
  // das Inbox-Cockpit lädt begrenzte Zeiträume; ein `limit`-Feld ist
9
9
  // vorwärtskompatibel ergänzbar ohne den QN zu brechen.
10
10
 
11
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
12
11
  import {
13
12
  configuredPiiSubjectKms,
14
13
  decryptPiiFieldValues,
@@ -42,7 +41,7 @@ export const listMessagesQuery: QueryHandlerDef = {
42
41
  if (payload.threadKey) filter["threadKey"] = payload.threadKey;
43
42
  if (payload.scope) filter["scope"] = payload.scope;
44
43
 
45
- const allRows = await selectMany(ctx.db.raw, inboundMessagesProjectionTable, filter);
44
+ const allRows = await ctx.db.selectMany(inboundMessagesProjectionTable, filter);
46
45
  // Scope-Sichtbarkeit (Plan Entscheidung 2): Messages persönlicher
47
46
  // Postfächer nur für Owner + TenantAdmin/SystemAdmin.
48
47
  const rows = allRows.filter((row) => isVisibleToCaller(row, ctx.user));
@@ -27,10 +27,10 @@ export const projectionRebuildJob: JobHandlerFn = async (rawPayload, ctx): Promi
27
27
  "[jobs:projection-rebuild] ctx.registry missing — job context requires the registry.",
28
28
  });
29
29
  }
30
- // Global rebuild by design (streams every tenant's events, not just one) —
31
- // acknowledgeCrossTenant documents that instead of the previous implicit
32
- // ctx.db cast. .raw is the same DbConnection ctx.db carried before.
33
- const db = ctx.systemDb.acknowledgeCrossTenant("global projection rebuild").raw as DbConnection; // @cast-boundary db-operator
30
+ // Global rebuild by design (streams every tenant's events) — acknowledgeCrossTenant
31
+ // documents that instead of the previous implicit ctx.db cast.
32
+ ctx.systemDb.acknowledgeCrossTenant("global projection rebuild");
33
+ const db = ctx.systemDb.unsafeRaw("projection rebuild spans every tenant's rows") as DbConnection; // @cast-boundary db-operator — DbRunner narrows to DbConnection, jobs never run inside a DbTx
34
34
  const result = await rebuildProjection(projection, {
35
35
  db,
36
36
  registry: ctx.registry,
@@ -42,9 +42,12 @@ export const reindexEntityJob: JobHandlerFn = async (rawPayload, ctx): Promise<v
42
42
  "[jobs:reindex-entity] ctx.systemDb missing — the owning feature must declare r.systemScope().",
43
43
  });
44
44
  }
45
- // .raw bypasses tenant filtering, safe here only because reindexEntity
45
+ // unsafeRaw bypasses tenant filtering, safe here only because reindexEntity
46
46
  // filters `WHERE tenant_id = $1` itself with this same tenantId.
47
- const db = ctx.systemDb.assertTenantMatch(tenantId).raw;
47
+ ctx.systemDb.assertTenantMatch(tenantId);
48
+ const db = ctx.systemDb.unsafeRaw(
49
+ "reindexEntity filters WHERE tenant_id = $1 itself with this job's tenantId",
50
+ );
48
51
  const result = await reindexEntity(db, ctx.registry, ctx.searchAdapter, entity, tenantId);
49
52
  ctx.log?.info?.(
50
53
  `[jobs:reindex-entity] tenant=${tenantId} reindexed ${entity}: ${result.indexedRows}/${result.scannedRows} rows indexed` +
@@ -0,0 +1,148 @@
1
+ // Regression for fw#2858: reports.query.ts's loadBooks() reads accounts and
2
+ // transactions via a raw DbRunner with an explicit `tenantId` where-clause.
3
+ // If that filter is ever dropped (e.g. a future refactor swaps back to an
4
+ // unfiltered runner), a tenant's report would silently aggregate every other
5
+ // tenant's books. Two tenants book distinct, non-cancelling amounts so a
6
+ // cross-tenant leak changes the report totals, not just adds zero-sum noise.
7
+
8
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
9
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
10
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
11
+ import {
12
+ createTestUser,
13
+ setupTestStack,
14
+ type TestStack,
15
+ unsafeCreateEntityTable,
16
+ } from "@cosmicdrift/kumiko-framework/stack";
17
+ import { type AccountType, LedgerHandlers, LedgerQueries } from "../constants";
18
+ import { accountEntity, scheduleEntity, transactionEntity } from "../entity";
19
+ import { createLedgerFeature } from "../feature";
20
+ import type { Posting } from "../schemas";
21
+
22
+ const ledgerFeature = createLedgerFeature();
23
+
24
+ let stack: TestStack;
25
+
26
+ beforeAll(async () => {
27
+ stack = await setupTestStack({ features: [ledgerFeature] });
28
+ await unsafeCreateEntityTable(stack.db, accountEntity);
29
+ await unsafeCreateEntityTable(stack.db, transactionEntity);
30
+ await unsafeCreateEntityTable(stack.db, scheduleEntity);
31
+ await createEventsTable(stack.db);
32
+ });
33
+
34
+ afterAll(async () => {
35
+ await stack.cleanup();
36
+ });
37
+
38
+ beforeEach(async () => {
39
+ await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
40
+ await asRawClient(stack.db).unsafe("DELETE FROM read_ledger_accounts");
41
+ await asRawClient(stack.db).unsafe("DELETE FROM read_ledger_transactions");
42
+ await asRawClient(stack.db).unsafe("DELETE FROM read_ledger_schedules");
43
+ });
44
+
45
+ const tenantA = createTestUser({ roles: ["TenantAdmin"] });
46
+ const tenantB = createTestUser({
47
+ roles: ["TenantAdmin"],
48
+ tenantId: "00000000-0000-4000-8000-0000000000bb",
49
+ });
50
+
51
+ async function createAccount(
52
+ name: string,
53
+ type: AccountType,
54
+ user: typeof tenantA,
55
+ ): Promise<string> {
56
+ const acc = await stack.http.writeOk<{ id: string }>(
57
+ LedgerHandlers.createAccount,
58
+ { name, type },
59
+ user,
60
+ );
61
+ return acc.id;
62
+ }
63
+
64
+ async function createTransaction(
65
+ lines: readonly Posting[],
66
+ opts: { date?: string; description?: string },
67
+ user: typeof tenantA,
68
+ ): Promise<void> {
69
+ await stack.http.writeOk(
70
+ LedgerHandlers.createTransaction,
71
+ { date: opts.date ?? "2026-01-15", description: opts.description ?? "Entry", lines },
72
+ user,
73
+ );
74
+ }
75
+
76
+ async function seedBooks(user: typeof tenantA, capital: number, income: number, expense: number) {
77
+ const bank = await createAccount("Bank", "asset", user);
78
+ const equity = await createAccount("Equity", "equity", user);
79
+ const rent = await createAccount("Rent", "income", user);
80
+ const cost = await createAccount("Cost", "expense", user);
81
+ await createTransaction(
82
+ [
83
+ { accountId: bank, amount: capital },
84
+ { accountId: equity, amount: -capital },
85
+ ],
86
+ { date: "2026-01-01", description: "Capital" },
87
+ user,
88
+ );
89
+ await createTransaction(
90
+ [
91
+ { accountId: bank, amount: income },
92
+ { accountId: rent, amount: -income },
93
+ ],
94
+ { date: "2026-01-10", description: "Income" },
95
+ user,
96
+ );
97
+ await createTransaction(
98
+ [
99
+ { accountId: cost, amount: expense },
100
+ { accountId: bank, amount: -expense },
101
+ ],
102
+ { date: "2026-01-20", description: "Expense" },
103
+ user,
104
+ );
105
+ return { bank };
106
+ }
107
+
108
+ describe("ledger integration — report tenant isolation", () => {
109
+ test("reportBalances excludes the other tenant's accounts and amounts", async () => {
110
+ const { bank: bankA } = await seedBooks(tenantA, 500000, 100000, 30000);
111
+ const { bank: bankB } = await seedBooks(tenantB, 200000, 50000, 10000);
112
+
113
+ const reportA = await stack.http.queryOk<{
114
+ accounts: Array<{ id: string; balance: number }>;
115
+ trialBalance: number;
116
+ }>(LedgerQueries.reportBalances, {}, tenantA);
117
+ expect(reportA.accounts).toHaveLength(4);
118
+ expect(reportA.accounts.some((a) => a.id === bankB)).toBe(false);
119
+ expect(reportA.accounts.find((a) => a.id === bankA)?.balance).toBe(570000);
120
+
121
+ const reportB = await stack.http.queryOk<{
122
+ accounts: Array<{ id: string; balance: number }>;
123
+ trialBalance: number;
124
+ }>(LedgerQueries.reportBalances, {}, tenantB);
125
+ expect(reportB.accounts).toHaveLength(4);
126
+ expect(reportB.accounts.some((a) => a.id === bankA)).toBe(false);
127
+ expect(reportB.accounts.find((a) => a.id === bankB)?.balance).toBe(240000);
128
+ });
129
+
130
+ test("reportIncomeStatement does not sum the other tenant's income/expense", async () => {
131
+ await seedBooks(tenantA, 500000, 100000, 30000);
132
+ await seedBooks(tenantB, 200000, 50000, 10000);
133
+
134
+ const stmtA = await stack.http.queryOk<{
135
+ income: number;
136
+ expense: number;
137
+ netIncome: number;
138
+ }>(LedgerQueries.reportIncomeStatement, {}, tenantA);
139
+ expect(stmtA).toEqual({ income: 100000, expense: 30000, netIncome: 70000 });
140
+
141
+ const stmtB = await stack.http.queryOk<{
142
+ income: number;
143
+ expense: number;
144
+ netIncome: number;
145
+ }>(LedgerQueries.reportIncomeStatement, {}, tenantB);
146
+ expect(stmtB).toEqual({ income: 50000, expense: 10000, netIncome: 40000 });
147
+ });
148
+ });
@@ -1,4 +1,3 @@
1
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
1
  import type {
3
2
  AccessRule,
4
3
  WriteHandlerDef,
@@ -68,14 +67,14 @@ export function createConfirmSchedulePeriodHandler(
68
67
  // ponytail: read-then-write, so two confirms racing the same period could
69
68
  // double-book; add a unique index on (tenantId, reference) when concurrent
70
69
  // confirms become real.
71
- const candidates = await selectMany(ctx.db.raw, transactionTable, {
70
+ const candidates = await ctx.db.selectMany(transactionTable, {
72
71
  tenantId: event.user.tenantId,
73
72
  reference,
74
73
  });
75
74
  const candidateIds = candidates.map((r) => String(r["id"]));
76
75
  const stornos =
77
76
  candidateIds.length > 0
78
- ? await selectMany(ctx.db.raw, transactionTable, {
77
+ ? await ctx.db.selectMany(transactionTable, {
79
78
  tenantId: event.user.tenantId,
80
79
  reference: { in: candidateIds },
81
80
  })
@@ -1,4 +1,3 @@
1
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
1
  import type { AccessRule, QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
3
2
  import { z } from "zod";
4
3
  import { DEFAULT_LEDGER_ACCESS } from "../constants";
@@ -25,8 +24,8 @@ type QueryCtx = Parameters<QueryHandlerDef["handler"]>[1];
25
24
  async function loadBooks(
26
25
  ctx: QueryCtx,
27
26
  ): Promise<{ accounts: LedgerAccount[]; entries: LedgerEntry[] }> {
28
- const accountRows = await selectMany(ctx.db.raw, accountTable, { tenantId: ctx.user.tenantId });
29
- const txRows = await selectMany(ctx.db.raw, transactionTable, { tenantId: ctx.user.tenantId });
27
+ const accountRows = await ctx.db.selectMany(accountTable, { tenantId: ctx.user.tenantId });
28
+ const txRows = await ctx.db.selectMany(transactionTable, { tenantId: ctx.user.tenantId });
30
29
  return { accounts: toAccounts(accountRows), entries: toEntries(txRows) };
31
30
  }
32
31
 
@@ -1,4 +1,3 @@
1
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
1
  import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
3
2
  import {
4
3
  ConflictError,
@@ -48,7 +47,7 @@ export function createReverseTransactionHandler(
48
47
  // an independently-balanced Storno (the global trial balance stays 0
49
48
  // either way, masking the bug), doubling the per-account effect. The
50
49
  // reference column ties a Storno back to its original 1:1.
51
- const alreadyReversed = await selectMany(ctx.db.raw, transactionTable, {
50
+ const alreadyReversed = await ctx.db.selectMany(transactionTable, {
52
51
  tenantId: event.user.tenantId,
53
52
  reference: payload.id,
54
53
  });
@@ -23,6 +23,9 @@ const langSchema = z
23
23
 
24
24
  const executor = createEventStoreExecutor(pagesTable, pageEntity, { entityName: "page" });
25
25
 
26
+ const SET_PAGE_TENANT_OVERRIDE_REASON =
27
+ "SystemAdmin tenantIdOverride re-scopes the page write to the target tenant";
28
+
26
29
  // Upsert einer Page — eine Operation pro (tenantId, slug, lang). Tenant-
27
30
  // Scope default aus event.user; SystemAdmin kann via `tenantIdOverride`
28
31
  // für einen anderen Tenant schreiben (typisch SYSTEM_TENANT_ID für app-
@@ -45,6 +48,9 @@ export const setWrite = defineWriteHandler({
45
48
  access: { roles: ["TenantAdmin", "SystemAdmin"] },
46
49
  description:
47
50
  "Creates or overwrites one managed page addressed by slug and language, keeping the existing published flag, description and OG image when the payload omits them; use it for content edits and publish toggles, and as SystemAdmin to write another tenant's pages.",
51
+ escapeHatch: {
52
+ reason: SET_PAGE_TENANT_OVERRIDE_REASON,
53
+ },
48
54
  handler: async (event, ctx) => {
49
55
  const db = ctx.db;
50
56
  const override = event.payload.tenantIdOverride;
@@ -63,7 +69,13 @@ export const setWrite = defineWriteHandler({
63
69
  // on both the existing-check and the executor's stream reads; re-scope to the target.
64
70
  // Safe: the override branch is SystemAdmin-gated above.
65
71
  const scopedDb =
66
- override !== undefined ? createTenantDb(db.raw, override as TenantId, "tenant") : db; // @cast-boundary engine-bridge
72
+ override !== undefined
73
+ ? createTenantDb(
74
+ db.unsafeRaw(SET_PAGE_TENANT_OVERRIDE_REASON),
75
+ override as TenantId, // @cast-boundary engine-bridge
76
+ "tenant",
77
+ )
78
+ : db;
67
79
  const existing = await fetchOne<PageRow>(scopedDb, pagesTable, {
68
80
  tenantId,
69
81
  slug: event.payload.slug,
@@ -7,6 +7,9 @@ import { DEFAULT_NOTES_HISTORY_ACCESS } from "../constants";
7
7
  import { noteEntryExecutor, noteMentionExecutor } from "../executor";
8
8
  import { type AddNotePayload, addNotePayloadSchema } from "../schemas";
9
9
 
10
+ const READ_AUTHOR_DISPLAY_NAME_REASON =
11
+ "reads the author's displayName from the global users table inside a savepoint so a missing user feature cannot poison the note transaction";
12
+
10
13
  // add-note — appends a note-entry to (entityType, entityId). authorId is
11
14
  // NEVER read from the payload: it is always the authenticated caller
12
15
  // (event.user.id), so a note can't be authored as someone else. No update or
@@ -28,6 +31,9 @@ export function createAddNoteHandler(
28
31
  access,
29
32
  description:
30
33
  "Appends a note to one host entity's history, stamping the author from the authenticated caller rather than the payload; use it for every remark and correction alike, because entries can never be edited or removed afterwards.",
34
+ escapeHatch: {
35
+ reason: READ_AUTHOR_DISPLAY_NAME_REASON,
36
+ },
31
37
  handler: async (event, ctx) => {
32
38
  const payload = event.payload as AddNotePayload; // @cast-boundary engine-payload
33
39
 
@@ -47,16 +53,19 @@ export function createAddNoteHandler(
47
53
 
48
54
  let authorName: string | null = null;
49
55
  try {
50
- // read_users is tenant-agnostic → ctx.db.raw, not the tenant-scoped ctx.db.
56
+ // read_users is tenant-agnostic → ctx.db.unsafeRaw, not the tenant-scoped ctx.db.
51
57
  // Bun.SQL poisons the whole tx after any error inside it, even one that's
52
58
  // caught — a bare try/catch here would take the note write down with it.
53
- authorName = await runInSavepointIfSupported(ctx.db.raw, async (sp) => {
54
- const userRow = await fetchOne<{ displayName: string | null }>(sp, userTable, {
55
- id: event.user.id,
56
- });
57
- if (!userRow?.displayName) return null;
58
- return decryptStoredPii(userRow.displayName, "displayName", "notes-history:add-note");
59
- });
59
+ authorName = await runInSavepointIfSupported(
60
+ ctx.db.unsafeRaw(READ_AUTHOR_DISPLAY_NAME_REASON),
61
+ async (sp) => {
62
+ const userRow = await fetchOne<{ displayName: string | null }>(sp, userTable, {
63
+ id: event.user.id,
64
+ });
65
+ if (!userRow?.displayName) return null;
66
+ return decryptStoredPii(userRow.displayName, "displayName", "notes-history:add-note");
67
+ },
68
+ );
60
69
  } catch (e) {
61
70
  ctx.log?.warn("notes-history: authorName lookup failed", {
62
71
  error: e,
@@ -61,7 +61,8 @@ export function createPatCreateHandler(opts: CreatePatOptions = {}) {
61
61
  escapeHatch: {
62
62
  reason:
63
63
  "Reads the caller's own passwordHash (privileged-only field) via ctx.queryAs(SYSTEM, " +
64
- "user:findForAuth) to re-verify the current password before minting the token.",
64
+ "user:findForAuth) to re-verify the current password before minting the token. Also " +
65
+ "reads the MFA enrollment of the user being re-authenticated via the mfaVerifier callback.",
65
66
  },
66
67
  description:
67
68
  "Mints a personal access token for the calling user after re-verifying their password (and MFA code when enrolled) and returns the plaintext token exactly once; use it when a user needs a long-lived API credential.",
@@ -1,4 +1,3 @@
1
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
1
  import { type AccessRule, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
2
  import { z } from "zod";
4
3
  import { DEFAULT_SECRETS_ACCESS } from "../constants";
@@ -15,14 +14,13 @@ export function createListHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS) {
15
14
  schema: z.object({}),
16
15
  access,
17
16
  handler: async (event, ctx) => {
18
- const rows = await selectMany<{
17
+ const rows = await ctx.db.selectMany<{
19
18
  key: string;
20
19
  kekVersion: number;
21
20
  metadata: { redactedPreview?: string; hint?: string };
22
21
  lastRotatedAt: unknown;
23
22
  insertedAt: unknown;
24
23
  }>(
25
- ctx.db.raw,
26
24
  tenantSecretsTable,
27
25
  { tenantId: event.user.tenantId },
28
26
  {