@cosmicdrift/kumiko-bundled-features 0.277.0 → 0.280.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 (62) hide show
  1. package/package.json +9 -9
  2. package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +8 -2
  3. package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +6 -6
  4. package/src/auth-mfa-user-data/hooks.ts +4 -6
  5. package/src/cap-counter/__tests__/enforce-cap.test.ts +7 -10
  6. package/src/cap-counter/__tests__/stock-cap-guard.integration.test.ts +123 -6
  7. package/src/cap-counter/__tests__/with-cap-enforcement.integration.test.ts +135 -1
  8. package/src/cap-counter/book-cap-usage.ts +158 -0
  9. package/src/cap-counter/changes.json +16 -1
  10. package/src/cap-counter/enforce-cap.ts +5 -37
  11. package/src/cap-counter/handlers/increment.write.ts +7 -47
  12. package/src/cap-counter/handlers/mark-soft-warned.write.ts +6 -33
  13. package/src/cap-counter/index.ts +8 -0
  14. package/src/cap-counter/stock-cap-guard.ts +14 -19
  15. package/src/cap-counter/with-cap-enforcement.ts +9 -11
  16. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +10 -3
  17. package/src/custom-fields/wire-user-data-rights.ts +40 -24
  18. package/src/data-retention/resolve-for-tenant.ts +18 -6
  19. package/src/delivery/db/queries/preferences.ts +1 -0
  20. package/src/files-tenant-data/hooks.ts +3 -5
  21. package/src/folders/__tests__/folders.integration.test.ts +4 -3
  22. package/src/folders-user-data/__tests__/hooks.integration.test.ts +2 -1
  23. package/src/folders-user-data/hooks.ts +5 -10
  24. package/src/form-draft-user-data/__tests__/hooks-delete-failure.integration.test.ts +7 -1
  25. package/src/form-draft-user-data/__tests__/hooks.integration.test.ts +16 -5
  26. package/src/form-draft-user-data/hooks.ts +3 -6
  27. package/src/notes-history-user-data/__tests__/hooks.integration.test.ts +9 -3
  28. package/src/notes-history-user-data/hooks.ts +3 -4
  29. package/src/personal-access-tokens/handlers/create.write.ts +1 -0
  30. package/src/template-resolver-user-data/hooks.ts +1 -2
  31. package/src/tenant-lifecycle/feature.ts +2 -0
  32. package/src/tenant-lifecycle/run-tenant-destroy.ts +9 -0
  33. package/src/tenant-lifecycle/stages.ts +26 -2
  34. package/src/tier-engine/__tests__/tier-resolver.integration.test.ts +138 -0
  35. package/src/tier-engine/changes.json +7 -0
  36. package/src/tier-engine/tier-resolver.ts +7 -6
  37. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +5 -14
  38. package/src/user-data-rights/__tests__/escape-hatch-audit.integration.test.ts +133 -0
  39. package/src/user-data-rights/__tests__/escape-hatch-declaration.integration.test.ts +198 -0
  40. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +15 -3
  41. package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts +10 -2
  42. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -1
  43. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -3
  44. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +8 -2
  45. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +8 -4
  46. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +15 -5
  47. package/src/user-data-rights/feature.ts +4 -0
  48. package/src/user-data-rights/run-export-jobs.ts +16 -1
  49. package/src/user-data-rights/run-forget-cleanup.ts +44 -2
  50. package/src/user-data-rights/run-user-export.ts +43 -3
  51. package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +7 -6
  52. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +64 -18
  53. package/src/user-data-rights-defaults/hooks/api-token.userdata-hook.ts +2 -3
  54. package/src/user-data-rights-defaults/hooks/config-value.userdata-hook.ts +4 -6
  55. package/src/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.ts +1 -2
  56. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +5 -11
  57. package/src/user-data-rights-defaults/hooks/in-app-message.userdata-hook.ts +2 -3
  58. package/src/user-data-rights-defaults/hooks/job-run.userdata-hook.ts +1 -2
  59. package/src/user-data-rights-defaults/hooks/notification-preference.userdata-hook.ts +4 -6
  60. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +8 -10
  61. package/src/user-data-rights-defaults/hooks/user-session.userdata-hook.ts +2 -3
  62. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +9 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.277.0",
3
+ "version": "0.280.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -129,12 +129,12 @@
129
129
  "./workflow-runner": "./src/workflow-runner/index.ts"
130
130
  },
131
131
  "dependencies": {
132
- "@cosmicdrift/kumiko-dispatcher-live": "0.277.0",
133
- "@cosmicdrift/kumiko-framework": "0.277.0",
134
- "@cosmicdrift/kumiko-headless": "0.277.0",
135
- "@cosmicdrift/kumiko-renderer": "0.277.0",
136
- "@cosmicdrift/kumiko-renderer-web": "0.277.0",
137
- "@cosmicdrift/kumiko-types": "0.277.0",
132
+ "@cosmicdrift/kumiko-dispatcher-live": "0.280.0",
133
+ "@cosmicdrift/kumiko-framework": "0.280.0",
134
+ "@cosmicdrift/kumiko-headless": "0.280.0",
135
+ "@cosmicdrift/kumiko-renderer": "0.280.0",
136
+ "@cosmicdrift/kumiko-renderer-web": "0.280.0",
137
+ "@cosmicdrift/kumiko-types": "0.280.0",
138
138
  "@mollie/api-client": "^4.5.0",
139
139
  "@node-rs/argon2": "^2.0.2",
140
140
  "@types/mailparser": "^3.4.6",
@@ -163,7 +163,7 @@
163
163
  ],
164
164
  "devDependencies": {
165
165
  "@testing-library/user-event": "^14.6.1",
166
- "@cosmicdrift/kumiko-locale-de": "0.277.0",
167
- "@cosmicdrift/kumiko-locale-es": "0.277.0"
166
+ "@cosmicdrift/kumiko-locale-de": "0.280.0",
167
+ "@cosmicdrift/kumiko-locale-es": "0.280.0"
168
168
  }
169
169
  }
@@ -14,6 +14,7 @@ import {
14
14
  InMemoryKmsAdapter,
15
15
  isPiiCiphertext,
16
16
  } from "@cosmicdrift/kumiko-framework/crypto";
17
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
17
18
  import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
18
19
  import {
19
20
  setupTestStack,
@@ -319,7 +320,7 @@ describe("auth flows with active KMS + blind index", () => {
319
320
  expect(isPiiCiphertext(rawInvitation["email"])).toBe(true);
320
321
 
321
322
  const result = await tenantInvitationExportHook({
322
- db: stack.db,
323
+ db: createTenantDb(stack.db, TENANT_A_ID, "tenant"),
323
324
  registry: stack.registry,
324
325
  tenantId: TENANT_A_ID,
325
326
  userId: bobId,
@@ -347,7 +348,12 @@ describe("auth flows with active KMS + blind index", () => {
347
348
  expect(isPiiCiphertext(rawBefore["invited_by"])).toBe(true);
348
349
 
349
350
  await tenantInvitationDeleteHook(
350
- { db: stack.db, registry: stack.registry, tenantId: TENANT_A_ID, userId: aliceId },
351
+ {
352
+ db: createTenantDb(stack.db, TENANT_A_ID, "tenant"),
353
+ registry: stack.registry,
354
+ tenantId: TENANT_A_ID,
355
+ userId: aliceId,
356
+ },
351
357
  "delete",
352
358
  );
353
359
 
@@ -3,7 +3,7 @@
3
3
  // (not just leave it dangling for a future projection rebuild to resurrect).
4
4
 
5
5
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
- import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
6
+ import { configureEntityFieldEncryption, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
7
7
  import {
8
8
  createTestUser,
9
9
  setupTestStack,
@@ -77,7 +77,7 @@ describe("userMfaExportHook / userMfaDeleteHook", () => {
77
77
  const user = await enrollUser("export-1");
78
78
 
79
79
  const snippet = await userMfaExportHook({
80
- db: stack.db,
80
+ db: createTenantDb(stack.db, user.tenantId, "tenant"),
81
81
  registry: stack.registry,
82
82
  tenantId: user.tenantId,
83
83
  userId: user.id,
@@ -94,7 +94,7 @@ describe("userMfaExportHook / userMfaDeleteHook", () => {
94
94
  test("export returns null for a user who never enrolled", async () => {
95
95
  const user = createTestUser({ id: "never-enrolled", roles: ["User"] });
96
96
  const snippet = await userMfaExportHook({
97
- db: stack.db,
97
+ db: createTenantDb(stack.db, user.tenantId, "tenant"),
98
98
  registry: stack.registry,
99
99
  tenantId: user.tenantId,
100
100
  userId: user.id,
@@ -106,7 +106,7 @@ describe("userMfaExportHook / userMfaDeleteHook", () => {
106
106
  const user = await enrollUser("delete-1");
107
107
 
108
108
  const before = await userMfaExportHook({
109
- db: stack.db,
109
+ db: createTenantDb(stack.db, user.tenantId, "tenant"),
110
110
  registry: stack.registry,
111
111
  tenantId: user.tenantId,
112
112
  userId: user.id,
@@ -115,7 +115,7 @@ describe("userMfaExportHook / userMfaDeleteHook", () => {
115
115
 
116
116
  await userMfaDeleteHook(
117
117
  {
118
- db: stack.db,
118
+ db: createTenantDb(stack.db, user.tenantId, "tenant"),
119
119
  registry: stack.registry,
120
120
  tenantId: user.tenantId,
121
121
  userId: user.id,
@@ -124,7 +124,7 @@ describe("userMfaExportHook / userMfaDeleteHook", () => {
124
124
  );
125
125
 
126
126
  const after = await userMfaExportHook({
127
- db: stack.db,
127
+ db: createTenantDb(stack.db, user.tenantId, "tenant"),
128
128
  registry: stack.registry,
129
129
  tenantId: user.tenantId,
130
130
  userId: user.id,
@@ -7,8 +7,7 @@
7
7
  // replays via the event, a raw DELETE would be resurrected by a
8
8
  // projection rebuild).
9
9
 
10
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
11
- import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
10
+ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
12
11
  import {
13
12
  createSystemUser,
14
13
  type UserDataDeleteHook,
@@ -26,7 +25,7 @@ const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
26
25
  // tokenHash exclusion in user-data-rights-defaults). Just confirms
27
26
  // enrollment + when, which is what a data subject actually needs to see.
28
27
  export const userMfaExportHook: UserDataExportHook = async (ctx) => {
29
- const rows = await selectMany<{ id: string; enabledAt: unknown }>(ctx.db, userMfaTable, {
28
+ const rows = await ctx.db.selectMany<{ id: string; enabledAt: unknown }>(userMfaTable, {
30
29
  userId: ctx.userId,
31
30
  tenantId: ctx.tenantId,
32
31
  });
@@ -41,15 +40,14 @@ export const userMfaExportHook: UserDataExportHook = async (ctx) => {
41
40
  // secret can't be anonymized, and `personal: { of: "userId" }` already
42
41
  // means this row belongs to exactly one user (never shared tenant data).
43
42
  export const userMfaDeleteHook: UserDataDeleteHook = async (ctx) => {
44
- const rows = await selectMany<{ id: string }>(ctx.db, userMfaTable, {
43
+ const rows = await ctx.db.selectMany<{ id: string }>(userMfaTable, {
45
44
  userId: ctx.userId,
46
45
  tenantId: ctx.tenantId,
47
46
  });
48
47
  // skip: nothing enrolled for this user — no row to erase
49
48
  if (rows.length === 0) return;
50
49
  const systemUser = createSystemUser(ctx.tenantId);
51
- const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
52
50
  for (const row of rows) {
53
- assertErased(await executor.forget({ id: row.id }, systemUser, tdb), "user-mfa", row.id);
51
+ assertErased(await executor.forget({ id: row.id }, systemUser, ctx.db), "user-mfa", row.id);
54
52
  }
55
53
  };
@@ -296,14 +296,16 @@ describe("enforceCapAndMaybeNotify — calendar", () => {
296
296
  expect(notify).not.toHaveBeenCalled();
297
297
  });
298
298
 
299
- test("soft-hit, crossed=true → notifier mit info-payload + ctx.write markSoftWarned", async () => {
299
+ // markCapSoftWarned (fw#2854) now runs the real event-store executor
300
+ // against ctx.db instead of a mockable ctx.write call — this pure-mock
301
+ // ctx.db can't satisfy that, so it throws after notify() already fired.
302
+ // The DB flag-flip itself is covered by cap-counter.integration.test.ts's
303
+ // real-stack "scenario 6" test.
304
+ test("soft-hit, crossed=true → notifier fires with the info-payload", async () => {
300
305
  const ctx = stubCalendarCtx([{ value: 1100, lastSoftWarnedAt: null }]);
301
- const write = mock(async () => ({ isSuccess: true, data: {} }));
302
- (ctx as unknown as { write: typeof write }).write = write;
303
306
  const notify = mock();
304
307
 
305
- const result = await enforceCapAndMaybeNotify(ctx, { ...baseOpts, notify });
306
- expect(result.state).toBe("soft-hit");
308
+ await expect(enforceCapAndMaybeNotify(ctx, { ...baseOpts, notify })).rejects.toThrow();
307
309
  expect(notify).toHaveBeenCalledTimes(1);
308
310
  expect(notify).toHaveBeenCalledWith({
309
311
  capName: "mails-per-month",
@@ -311,11 +313,6 @@ describe("enforceCapAndMaybeNotify — calendar", () => {
311
313
  limit: 1000,
312
314
  tenantId: "tenant-test",
313
315
  });
314
- expect(write).toHaveBeenCalledTimes(1);
315
- expect(write).toHaveBeenCalledWith("cap-counter:write:mark-soft-warned", {
316
- capName: "mails-per-month",
317
- periodStartIso: PERIOD,
318
- });
319
316
  });
320
317
 
321
318
  test("soft-hit, crossed=false (already warned) → notifier NICHT erneut aufgerufen", async () => {
@@ -1,20 +1,29 @@
1
1
  // Regression test for the tenant-scope override bug: checkStockCap used to
2
2
  // spread `spec.where` AFTER the injected `tenantId`, so a `where` object
3
3
  // carrying its own `tenantId` key silently overrode the caller's real tenant
4
- // and the count ran against the wrong tenant's rows.
4
+ // and the count ran against the wrong tenant's rows. fw#2854: checkStockCap
5
+ // now takes a TenantDb (not a raw DbRunner + explicit tenantId) — the
6
+ // tenant-scoping guarantee comes from TenantDb.count's own readWhere
7
+ // narrowing instead of a hand-rolled spread order.
5
8
 
6
9
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
7
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
8
- import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
9
- import { createEntityExecutor, type SessionUser } from "@cosmicdrift/kumiko-framework/engine";
10
+ import { createTenantDb, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
11
+ import {
12
+ createEntityExecutor,
13
+ defineFeature,
14
+ type SessionUser,
15
+ type WriteHandlerDef,
16
+ } from "@cosmicdrift/kumiko-framework/engine";
10
17
  import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
11
18
  import {
19
+ createTestUser,
12
20
  setupTestStack,
13
21
  type TestStack,
14
22
  testTenantId,
15
23
  unsafeCreateEntityTable,
16
24
  } from "@cosmicdrift/kumiko-framework/stack";
17
25
  import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
26
+ import { z } from "zod";
18
27
  import { capCounterEntity } from "../entity";
19
28
  import { createStockCapGuard } from "../stock-cap-guard";
20
29
 
@@ -62,7 +71,7 @@ describe("checkStockCap tenant scoping", () => {
62
71
  }
63
72
 
64
73
  const guard = createStockCapGuard(async () => ({}));
65
- const failure = await guard.checkStockCap(db, realTenant, {
74
+ const failure = await guard.checkStockCap(createTenantDb(db, realTenant), {
66
75
  table: capCounterTable,
67
76
  limit: () => 1,
68
77
  // A where object that (accidentally or maliciously) carries its own
@@ -84,7 +93,7 @@ describe("checkStockCap tenant scoping", () => {
84
93
  }
85
94
 
86
95
  const guard = createStockCapGuard(async () => ({}));
87
- const failure = await guard.checkStockCap(db, tenant, {
96
+ const failure = await guard.checkStockCap(createTenantDb(db, tenant), {
88
97
  table: capCounterTable,
89
98
  limit: () => 1,
90
99
  where: { capName: "x" },
@@ -96,3 +105,111 @@ describe("checkStockCap tenant scoping", () => {
96
105
  expect(failure).not.toBeNull();
97
106
  });
98
107
  });
108
+
109
+ // =============================================================================
110
+ // withStockCap over HTTP — proves the guard works end-to-end through the
111
+ // real dispatcher for a TenantAdmin-only handler with no escapeHatch (fw#2854
112
+ // withStockCap no longer declares one — checkStockCap resolves through the
113
+ // caller's own TenantDb, no unfiltered read needed).
114
+ // =============================================================================
115
+
116
+ const STOCK_LIMIT = 2;
117
+ const stockGuard = createStockCapGuard(async () => ({ maxItems: STOCK_LIMIT }));
118
+
119
+ const createItemSchema = z.object({ capName: z.string() });
120
+
121
+ const createItemHandler: WriteHandlerDef = {
122
+ name: "create-item",
123
+ schema: createItemSchema,
124
+ access: { roles: ["TenantAdmin"] },
125
+ handler: async (event, ctx) => {
126
+ const payload = event.payload as z.infer<typeof createItemSchema>; // @cast-boundary engine-payload
127
+ return executor.create(
128
+ {
129
+ tenantId: event.user.tenantId,
130
+ capName: payload.capName,
131
+ value: 0,
132
+ periodStart: "2026-07-01T00:00:00Z",
133
+ },
134
+ event.user,
135
+ ctx.db,
136
+ );
137
+ },
138
+ };
139
+
140
+ const guardedCreateItemHandler = stockGuard.withStockCap(createItemHandler, {
141
+ table: capCounterTable,
142
+ limit: (caps: { maxItems: number }) => caps.maxItems,
143
+ where: { capName: "http-stock-item" },
144
+ code: "stock_cap_exceeded",
145
+ i18nKey: "cap.stock-exceeded",
146
+ field: "capName",
147
+ });
148
+
149
+ const STOCK_ITEM_QN = "stock-item:write:create-item";
150
+ const stockItemFeature = defineFeature("stock-item", (r) => {
151
+ r.writeHandler(guardedCreateItemHandler);
152
+ });
153
+
154
+ describe("withStockCap over HTTP — TenantAdmin-only caller, no escapeHatch", () => {
155
+ let httpStack: TestStack;
156
+
157
+ beforeAll(async () => {
158
+ httpStack = await setupTestStack({ features: [stockItemFeature] });
159
+ await unsafeCreateEntityTable(httpStack.db, capCounterEntity, "cap-counter");
160
+ await createEventsTable(httpStack.db);
161
+ });
162
+
163
+ afterAll(async () => {
164
+ await httpStack.cleanup();
165
+ });
166
+
167
+ beforeEach(async () => {
168
+ await resetTestTables(httpStack.db, [capCounterTable, eventsTable]);
169
+ });
170
+
171
+ function tenantAdminOnlyFor(tenantNumber: number) {
172
+ return createTestUser({
173
+ id: tenantNumber,
174
+ tenantId: testTenantId(tenantNumber),
175
+ roles: ["TenantAdmin"],
176
+ });
177
+ }
178
+
179
+ test("tenant A reaches its cap over HTTP and gets the failure", async () => {
180
+ const admin = tenantAdminOnlyFor(3101);
181
+
182
+ await httpStack.http.writeOk(STOCK_ITEM_QN, { capName: "http-stock-item" }, admin);
183
+ await httpStack.http.writeOk(STOCK_ITEM_QN, { capName: "http-stock-item" }, admin);
184
+
185
+ const error = await httpStack.http.writeErr(
186
+ STOCK_ITEM_QN,
187
+ { capName: "http-stock-item" },
188
+ admin,
189
+ );
190
+ expect(error.httpStatus).toBe(422);
191
+ expect(error.i18nKey).toBe("cap.stock-exceeded");
192
+ expect(error.details).toMatchObject({ reason: "stock_cap_exceeded", current: 2, limit: 2 });
193
+ });
194
+
195
+ test("tenant B with 0 rows still succeeds (tenant isolation through the real dispatcher)", async () => {
196
+ const admin = tenantAdminOnlyFor(3102);
197
+ const other = tenantAdminOnlyFor(3103);
198
+
199
+ await httpStack.http.writeOk(STOCK_ITEM_QN, { capName: "http-stock-item" }, admin);
200
+ await httpStack.http.writeOk(STOCK_ITEM_QN, { capName: "http-stock-item" }, admin);
201
+
202
+ // Tenant A is now at its cap, but tenant B has zero rows of its own.
203
+ const created = await httpStack.http.writeOk(
204
+ STOCK_ITEM_QN,
205
+ { capName: "http-stock-item" },
206
+ other,
207
+ );
208
+ expect(created["data"]).toMatchObject({ capName: "http-stock-item" });
209
+
210
+ const otherCount = await createTenantDb(httpStack.db, other.tenantId).count(capCounterTable, {
211
+ capName: "http-stock-item",
212
+ });
213
+ expect(otherCount).toBe(1);
214
+ });
215
+ });
@@ -9,7 +9,7 @@
9
9
  // verbrannt für gescheiterte writes)
10
10
 
11
11
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
12
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
12
+ import { createTenantDb, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
13
13
  import {
14
14
  createEntityExecutor,
15
15
  defineFeature,
@@ -25,6 +25,7 @@ import {
25
25
  } from "@cosmicdrift/kumiko-framework/stack";
26
26
  import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
27
27
  import { z } from "zod";
28
+ import { bookCapUsage, readRollingCapUsage } from "../book-cap-usage";
28
29
  import { CapCounterQueries } from "../constants";
29
30
  import type { SoftHitNotifier } from "../enforce-cap";
30
31
  import { capCounterEntity } from "../entity";
@@ -87,9 +88,58 @@ const wrappedRolling = withRollingCapEnforcement(
87
88
 
88
89
  const NEWSLETTER_QN = "newsletter:write:send-newsletter";
89
90
  const NEWSLETTER_ROLLING_QN = "newsletter:write:send-rolling";
91
+
92
+ // =============================================================================
93
+ // TenantAdmin-only probes (fw#2854 gap): the users above carry BOTH
94
+ // TenantAdmin and SystemAdmin, which hid the bug that enforceCapAndMaybeNotify
95
+ // / withCapEnforcement / withRollingCapEnforcement used to dispatch
96
+ // SystemAdmin-only handlers (ctx.write(CapCounterHandlers.increment) etc.) —
97
+ // a plain TenantAdmin caller with no escapeHatch would have gotten
98
+ // access_denied. These handlers carry ONLY TenantAdmin and no escapeHatch,
99
+ // proving the in-process booking helpers (book-cap-usage.ts) work for
100
+ // ordinary tenant callers.
101
+ // =============================================================================
102
+
103
+ const tenantOnlyInnerHandler: WriteHandlerDef = {
104
+ name: "send-newsletter-tenant-only",
105
+ schema: z.object({ to: z.string() }),
106
+ access: { roles: ["TenantAdmin"] },
107
+ handler: async (_event, _ctx) => ({ isSuccess: true as const, data: { sent: true } }),
108
+ };
109
+
110
+ const TENANT_ONLY_PERIOD = "2026-08-01T00:00:00Z";
111
+
112
+ const wrappedCalendarTenantOnly = withCapEnforcement(tenantOnlyInnerHandler, () => ({
113
+ capName: "newsletter-cap-tenant-only",
114
+ periodStartIso: TENANT_ONLY_PERIOD,
115
+ limit: 10,
116
+ profile: "burstable",
117
+ notify: recordingNotifier,
118
+ }));
119
+
120
+ const OUTSIDE_TX_CAP_NAME = "outside-tx-booking-cap";
121
+ const bookOutsideTxThenFailHandler: WriteHandlerDef = {
122
+ name: "book-outside-tx-then-fail",
123
+ schema: z.object({}),
124
+ access: { roles: ["TenantAdmin"] },
125
+ handler: async (_event, ctx) => {
126
+ await bookCapUsage(ctx, {
127
+ capName: OUTSIDE_TX_CAP_NAME,
128
+ periodStartIso: TENANT_ONLY_PERIOD,
129
+ outsideTransaction: true,
130
+ });
131
+ throw new Error("boom-after-booking");
132
+ },
133
+ };
134
+
135
+ const NEWSLETTER_TENANT_ONLY_QN = "newsletter:write:send-newsletter-tenant-only";
136
+ const BOOK_OUTSIDE_TX_QN = "newsletter:write:book-outside-tx-then-fail";
137
+
90
138
  const newsletterFeature = defineFeature("newsletter", (r) => {
91
139
  r.writeHandler(wrappedCalendar);
92
140
  r.writeHandler(wrappedRolling);
141
+ r.writeHandler(wrappedCalendarTenantOnly);
142
+ r.writeHandler(bookOutsideTxThenFailHandler);
93
143
  });
94
144
 
95
145
  // =============================================================================
@@ -122,6 +172,14 @@ function adminFor(tenantNumber: number) {
122
172
  });
123
173
  }
124
174
 
175
+ function tenantAdminOnlyFor(tenantNumber: number) {
176
+ return createTestUser({
177
+ id: tenantNumber,
178
+ tenantId: testTenantId(tenantNumber),
179
+ roles: ["TenantAdmin"],
180
+ });
181
+ }
182
+
125
183
  async function readCounter(user: ReturnType<typeof adminFor>, capName: string, period: string) {
126
184
  return (await stack.http.queryOk(
127
185
  CapCounterQueries.getCounter,
@@ -277,3 +335,79 @@ describe("withRollingCapEnforcement — rolling", () => {
277
335
  expect(sendCallCount).toBe(7); // wrapper hat den blockierten handler NICHT gerufen
278
336
  });
279
337
  });
338
+
339
+ // =============================================================================
340
+ // TenantAdmin-only calendar callers (no SystemAdmin, no escapeHatch) — fw#2854
341
+ // =============================================================================
342
+
343
+ describe("withCapEnforcement — calendar, TenantAdmin-only callers", () => {
344
+ test("calendar: TenantAdmin-only caller books usage, soft-warn flag gets set, hard cap blocks", async () => {
345
+ const user = tenantAdminOnlyFor(2201);
346
+
347
+ // limit=10, burstable(soft=1.1,hard=1.2) → soft=11, hard=12. 12 successful
348
+ // calls cross the soft threshold on the 12th (pre-check sees value=11);
349
+ // the 13th sees value=12 >= hard and blocks.
350
+ for (let i = 0; i < 12; i++) {
351
+ await stack.http.writeOk(NEWSLETTER_TENANT_ONLY_QN, { to: `${i}@x.de` }, user);
352
+ }
353
+ const row = await readCounter(user, "newsletter-cap-tenant-only", TENANT_ONLY_PERIOD);
354
+ expect(row).not.toBeNull();
355
+ expect(row!["value"]).toBe(12);
356
+ expect(row!["lastSoftWarnedAt"]).not.toBeNull();
357
+
358
+ const blocked = await stack.http.writeErr(
359
+ NEWSLETTER_TENANT_ONLY_QN,
360
+ { to: "blocked@x.de" },
361
+ user,
362
+ );
363
+ expect(blocked.code).toBe("cap_exceeded");
364
+ expect(blocked.httpStatus).toBe(429);
365
+ });
366
+
367
+ test("tenant B's counter is unaffected by tenant A's TenantAdmin-only bookings", async () => {
368
+ const tenantA = tenantAdminOnlyFor(2301);
369
+ const tenantB = tenantAdminOnlyFor(2302);
370
+
371
+ for (let i = 0; i < 3; i++) {
372
+ await stack.http.writeOk(NEWSLETTER_TENANT_ONLY_QN, { to: `${i}@x.de` }, tenantA);
373
+ }
374
+
375
+ const rowA = await readCounter(tenantA, "newsletter-cap-tenant-only", TENANT_ONLY_PERIOD);
376
+ expect(rowA!["value"]).toBe(3);
377
+
378
+ const rowB = await readCounter(tenantB, "newsletter-cap-tenant-only", TENANT_ONLY_PERIOD);
379
+ expect(rowB).toBeNull();
380
+ });
381
+
382
+ test("bookCapUsage(..., outsideTransaction: true) survives the handler's own rollback", async () => {
383
+ const user = tenantAdminOnlyFor(2401);
384
+
385
+ const error = await stack.http.writeErr(BOOK_OUTSIDE_TX_QN, {}, user);
386
+ expect(error.httpStatus).toBeGreaterThanOrEqual(400);
387
+
388
+ const row = await readCounter(user, OUTSIDE_TX_CAP_NAME, TENANT_ONLY_PERIOD);
389
+ expect(row).not.toBeNull();
390
+ expect(row!["value"]).toBe(1);
391
+ });
392
+
393
+ test("readRollingCapUsage: tenant B reads 0 for the same capName after tenant A booked", async () => {
394
+ const tenantA = adminFor(2501);
395
+ const tenantB = adminFor(2502);
396
+
397
+ await stack.http.writeOk(NEWSLETTER_ROLLING_QN, { to: "a@x.de" }, tenantA);
398
+
399
+ const usageA = await readRollingCapUsage(
400
+ createTenantDb(stack.db, tenantA.tenantId),
401
+ tenantA.tenantId,
402
+ { capName: "newsletter-rolling-cap", windowDays: 7 },
403
+ );
404
+ expect(usageA).toBe(1);
405
+
406
+ const usageB = await readRollingCapUsage(
407
+ createTenantDb(stack.db, tenantB.tenantId),
408
+ tenantB.tenantId,
409
+ { capName: "newsletter-rolling-cap", windowDays: 7 },
410
+ );
411
+ expect(usageB).toBe(0);
412
+ });
413
+ });
@@ -0,0 +1,158 @@
1
+ // In-process cap booking for the caller's own tenant; no SystemAdmin dispatch needed.
2
+
3
+ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
4
+ import {
5
+ createEntityExecutor,
6
+ type HandlerContext,
7
+ type TenantId,
8
+ type WriteResult,
9
+ } from "@cosmicdrift/kumiko-framework/engine";
10
+ import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
11
+ import { Temporal } from "temporal-polyfill";
12
+ import { z } from "zod";
13
+ import { capCounterAggregateId, rollingCapAggregateId } from "./aggregate-id";
14
+ import { CAP_COUNTER_ROLLING_AGGREGATE_TYPE, ROLLING_INCREMENTED_EVENT_QN } from "./constants";
15
+ import { capCounterEntity } from "./entity";
16
+
17
+ const { table, executor } = createEntityExecutor("cap-counter", capCounterEntity);
18
+
19
+ const capBookingSchema = z.object({
20
+ capName: z.string().min(1).max(100),
21
+ periodStartIso: z.string().min(1),
22
+ amount: z.number().int().positive().default(1),
23
+ });
24
+
25
+ export type BookCapUsageOptions = {
26
+ readonly capName: string;
27
+ readonly periodStartIso: string;
28
+ readonly amount?: number;
29
+ readonly outsideTransaction?: boolean;
30
+ };
31
+
32
+ function requireOutsideTransactionDb(ctx: HandlerContext): TenantDb {
33
+ if (!ctx.dbOutsideTransaction) {
34
+ throw new Error(
35
+ "cap-counter.bookCapUsage: outsideTransaction requested but ctx.dbOutsideTransaction is undefined",
36
+ );
37
+ }
38
+ return ctx.dbOutsideTransaction;
39
+ }
40
+
41
+ export async function bookCapUsage(
42
+ ctx: HandlerContext,
43
+ options: BookCapUsageOptions,
44
+ ): Promise<WriteResult> {
45
+ const parsed = capBookingSchema.parse(options);
46
+ const db = options.outsideTransaction ? requireOutsideTransactionDb(ctx) : ctx.db;
47
+ const aggregateId = capCounterAggregateId(
48
+ ctx.user.tenantId,
49
+ parsed.capName,
50
+ parsed.periodStartIso,
51
+ );
52
+
53
+ const existing = await db.selectMany(table, { id: aggregateId }, { limit: 1 });
54
+ if (existing.length === 0) {
55
+ return executor.create(
56
+ {
57
+ id: aggregateId,
58
+ capName: parsed.capName,
59
+ value: parsed.amount,
60
+ periodStart: Temporal.Instant.from(parsed.periodStartIso),
61
+ lastSoftWarnedAt: null,
62
+ },
63
+ ctx.user,
64
+ db,
65
+ );
66
+ }
67
+
68
+ const currentRow = existing[0];
69
+ if (!currentRow) {
70
+ throw new Error("cap-counter.bookCapUsage: row vanished between length-check and read");
71
+ }
72
+ const currentValue = currentRow["value"] as number; // @cast-boundary db-row
73
+ const currentVersion = currentRow["version"] as number; // @cast-boundary db-row
74
+ return executor.update(
75
+ {
76
+ id: aggregateId,
77
+ version: currentVersion,
78
+ changes: { value: currentValue + parsed.amount },
79
+ },
80
+ ctx.user,
81
+ db,
82
+ );
83
+ }
84
+
85
+ export type MarkCapSoftWarnedOptions = {
86
+ readonly capName: string;
87
+ readonly periodStartIso: string;
88
+ };
89
+
90
+ export async function markCapSoftWarned(
91
+ ctx: HandlerContext,
92
+ options: MarkCapSoftWarnedOptions,
93
+ ): Promise<WriteResult> {
94
+ const aggregateId = capCounterAggregateId(
95
+ ctx.user.tenantId,
96
+ options.capName,
97
+ options.periodStartIso,
98
+ );
99
+
100
+ const existing = await ctx.db.selectMany(table, { id: aggregateId }, { limit: 1 });
101
+ if (existing.length === 0) {
102
+ throw new Error(
103
+ `cap-counter: cannot mark-soft-warned, no counter found for tenant=${ctx.user.tenantId} cap=${options.capName} period=${options.periodStartIso}`,
104
+ );
105
+ }
106
+ const row = existing[0];
107
+ if (!row) {
108
+ throw new Error("cap-counter.markCapSoftWarned: row vanished between length-check and read");
109
+ }
110
+ const currentVersion = row["version"] as number; // @cast-boundary db-row
111
+
112
+ return executor.update(
113
+ {
114
+ id: aggregateId,
115
+ version: currentVersion,
116
+ changes: { lastSoftWarnedAt: Temporal.Now.instant() },
117
+ },
118
+ ctx.user,
119
+ ctx.db,
120
+ );
121
+ }
122
+
123
+ export type ReadRollingCapUsageOptions = {
124
+ readonly capName: string;
125
+ readonly windowDays: number;
126
+ };
127
+
128
+ // Sums usage without throwing — for callers that only need the raw number
129
+ // (e.g. a CapSpec.usage callback), not the enforce-and-throw path.
130
+ export async function readRollingCapUsage(
131
+ db: TenantDb,
132
+ tenantId: TenantId,
133
+ options: ReadRollingCapUsageOptions,
134
+ ): Promise<number> {
135
+ const aggregateId = rollingCapAggregateId(tenantId, options.capName);
136
+ const cutoff = Temporal.Now.instant().subtract({ hours: options.windowDays * 24 });
137
+
138
+ const rows = await db.selectMany<{ payload: { amount?: number } }>(eventsTable, {
139
+ tenantId,
140
+ aggregateType: CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
141
+ aggregateId,
142
+ type: ROLLING_INCREMENTED_EVENT_QN,
143
+ createdAt: { gte: cutoff },
144
+ });
145
+
146
+ let value = 0;
147
+ for (const row of rows) {
148
+ // @cast-boundary engine-payload — events.payload is jsonb (typed as
149
+ // unknown by drizzle's $type<Record<string,unknown>>); narrowing the
150
+ // shape here mirrors enforceRollingCap's own read-side contract.
151
+ const payload = row["payload"] as { amount?: number };
152
+ if (typeof payload.amount === "number") {
153
+ value += payload.amount;
154
+ }
155
+ }
156
+
157
+ return value;
158
+ }