@cosmicdrift/kumiko-bundled-features 0.277.0 → 0.278.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.277.0",
3
+ "version": "0.278.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.278.0",
133
+ "@cosmicdrift/kumiko-framework": "0.278.0",
134
+ "@cosmicdrift/kumiko-headless": "0.278.0",
135
+ "@cosmicdrift/kumiko-renderer": "0.278.0",
136
+ "@cosmicdrift/kumiko-renderer-web": "0.278.0",
137
+ "@cosmicdrift/kumiko-types": "0.278.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.278.0",
167
+ "@cosmicdrift/kumiko-locale-es": "0.278.0"
168
168
  }
169
169
  }
@@ -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
+ }
@@ -1 +1,16 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.278.0",
4
+ "type": "improvement",
5
+ "title": "bookCapUsage/markCapSoftWarned/readRollingCapUsage — in-process calendar-cap booking bound to the caller's own tenant (fw#2854).",
6
+ "detail": "New helpers in `book-cap-usage.ts`: `bookCapUsage(ctx, options)` and `markCapSoftWarned(ctx, options)` run the calendar-counter create-or-update / soft-warn-flip logic in-process against `ctx.db`/`ctx.user`; `readRollingCapUsage(db, tenantId, options)` sums a rolling window without throwing and works as the `usage` callback for an existing rolling stream. `enforceCapAndMaybeNotify` and `withCapEnforcement` now call `bookCapUsage`/`markCapSoftWarned` in-process instead of dispatching the `SystemAdmin`-only `increment`/`mark-soft-warned` write-handlers through `ctx.write` — a plain `TenantAdmin` caller (no `SystemAdmin` role, no `escapeHatch`, no `ctx.writeAs` identity-switch) can now use calendar cap-enforcement end to end. `withRollingCapEnforcement` still books through the `SystemAdmin`-only `increment-rolling` write-handler via `ctx.write` — the framework's event-ownership rule rejects `unsafeAppendEvent` calls for a foreign feature made in-process, so rolling callers still need a `SystemAdmin` identity.",
7
+ "migration": "No action required for the call signatures — `enforceCapAndMaybeNotify`/`withCapEnforcement`/`withRollingCapEnforcement` keep their existing signatures and result shapes. An app that previously worked around the `SystemAdmin`-only calendar dispatch with its own `escapeHatch` + `ctx.writeAs(SystemAdmin, ...)` wrapper can remove that workaround for `withCapEnforcement`; `withRollingCapEnforcement` callers still need a `SystemAdmin` identity (role or `ctx.writeAs`)."
8
+ },
9
+ {
10
+ "version": "0.278.0",
11
+ "type": "breaking",
12
+ "title": "createStockCapGuard/checkStockCap/withStockCap resolve through the caller's TenantDb, not a raw DbRunner + explicit tenantId (fw#2854).",
13
+ "detail": "`createStockCapGuard`'s resolver is now `(db: TenantDb) => Promise<TCaps>` instead of `(db: DbRunner, tenantId: TenantId) => Promise<TCaps>`. `checkStockCap(db: TenantDb, spec)` drops the separate `tenantId` parameter — the tenant comes from `db.tenantId`, and the count runs through `TenantDb.count` instead of the raw `countWhere`. `StockCapSpec.table` is now typed `SchemaTable | EntityTableMeta` (matching `TenantDb.count`'s table parameter) instead of `Parameters<typeof countWhere>[1]`. `withStockCap` no longer declares an `escapeHatch` on the wrapped handler and no longer reads through `ctx.db.unsafeRaw(...)` — it calls `checkStockCap(ctx.db, spec)` directly, so a `TenantAdmin`-only handler with no `escapeHatch` can use it.",
14
+ "migration": "`createStockCapGuard(async (db, tenantId) => ...)` → `createStockCapGuard(async (db) => ...)`, reading `db.tenantId` instead of the removed second argument. A direct `checkStockCap(runner, tenantId, spec)` call → `checkStockCap(tenantDb, spec)` with a `TenantDb` built via `createTenantDb(runner, tenantId)` (or `ctx.db` inside a handler). If your wrapped handler relied on `withStockCap`'s auto-added `escapeHatch` for some other reason, declare it explicitly on the handler instead — `withStockCap` no longer adds one."
15
+ }
16
+ ]
@@ -1,12 +1,6 @@
1
1
  import { createEntityExecutor, type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { KumikoError } from "@cosmicdrift/kumiko-framework/errors";
3
- import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
4
- import { rollingCapAggregateId } from "./aggregate-id";
5
- import {
6
- CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
7
- CapCounterHandlers,
8
- ROLLING_INCREMENTED_EVENT_QN,
9
- } from "./constants";
3
+ import { markCapSoftWarned, readRollingCapUsage } from "./book-cap-usage";
10
4
  import { capCounterEntity } from "./entity";
11
5
 
12
6
  // Temporal globally provided by the framework's polyfill init
@@ -180,33 +174,11 @@ export async function enforceRollingCap(
180
174
  const softThreshold = options.limit * tolerance.soft;
181
175
  const hardThreshold = options.limit * tolerance.hard;
182
176
 
183
- const aggregateId = rollingCapAggregateId(ctx.user.tenantId, options.capName);
184
- const cutoff = Temporal.Now.instant().subtract({ hours: options.windowDays * 24 });
185
-
186
- // events_tenant_type_idx (tenant_id, aggregate_type, created_at)
187
- // covers the prefix; the additional aggregate_id eq narrows to the
188
- // single rolling-stream. Postgres can use the index even with the
189
- // aggregate_id filter applied as a residual.
190
- const rows = await ctx.db.selectMany<{ payload: { amount?: number } }>(eventsTable, {
191
- tenantId: ctx.user.tenantId,
192
- aggregateType: CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
193
- aggregateId,
194
- type: ROLLING_INCREMENTED_EVENT_QN,
195
- createdAt: { gte: cutoff },
177
+ const value = await readRollingCapUsage(ctx.db, ctx.user.tenantId, {
178
+ capName: options.capName,
179
+ windowDays: options.windowDays,
196
180
  });
197
181
 
198
- let value = 0;
199
- for (const row of rows) {
200
- // @cast-boundary engine-payload — events.payload is jsonb (typed as
201
- // unknown by drizzle's $type<Record<string,unknown>>); narrowing
202
- // the shape here is a deliberate read-side contract for the
203
- // rolling-incremented-event we authored.
204
- const payload = row["payload"] as { amount?: number };
205
- if (typeof payload.amount === "number") {
206
- value += payload.amount;
207
- }
208
- }
209
-
210
182
  if (value >= hardThreshold) {
211
183
  throw new CapExceededError(options.capName, options.limit, value, tolerance);
212
184
  }
@@ -335,11 +307,7 @@ export async function enforceCapAndMaybeNotify(
335
307
  tenantId: ctx.user.tenantId,
336
308
  });
337
309
  // Flip the soft-warned flag so the same period doesn't re-notify.
338
- // We're already inside a write-handler-context, so dispatching the
339
- // mark-soft-warned-handler in-line works via ctx.write (re-uses
340
- // the request user; the handler's own access-check enforces the
341
- // SystemAdmin role on the caller).
342
- await ctx.write(CapCounterHandlers.markSoftWarned, {
310
+ await markCapSoftWarned(ctx, {
343
311
  capName: options.capName,
344
312
  periodStartIso: options.periodStartIso,
345
313
  });
@@ -1,10 +1,6 @@
1
- import { createEntityExecutor, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
- import { Temporal } from "temporal-polyfill";
1
+ import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
3
2
  import { z } from "zod";
4
- import { capCounterAggregateId } from "../aggregate-id";
5
- import { capCounterEntity } from "../entity";
6
-
7
- const { table, executor } = createEntityExecutor("cap-counter", capCounterEntity);
3
+ import { bookCapUsage } from "../book-cap-usage";
8
4
 
9
5
  const incrementSchema = z.object({
10
6
  /** App-defined cap-name. e.g. "platform-mails", "ai-tokens-7day". */
@@ -47,46 +43,10 @@ export const incrementCapHandler: WriteHandlerDef = {
47
43
  access: { roles: ["SystemAdmin"] },
48
44
  handler: async (event, ctx) => {
49
45
  const payload = event.payload as IncrementPayload; // @cast-boundary engine-payload
50
- const aggregateId = capCounterAggregateId(
51
- event.user.tenantId,
52
- payload.capName,
53
- payload.periodStartIso,
54
- );
55
-
56
- // Read existing aggregate's projection-row to decide create vs update.
57
- // ctx.db is auto-tenant-scoped — id-lookup is unique per tenant.
58
- const existing = await ctx.db.selectMany(table, { id: aggregateId }, { limit: 1 });
59
-
60
- if (existing.length === 0) {
61
- return executor.create(
62
- {
63
- id: aggregateId,
64
- capName: payload.capName,
65
- value: payload.amount,
66
- periodStart: Temporal.Instant.from(payload.periodStartIso),
67
- lastSoftWarnedAt: null,
68
- },
69
- event.user,
70
- ctx.db,
71
- );
72
- }
73
-
74
- const currentRow = existing[0];
75
- if (!currentRow) {
76
- // Defensive — length-check above means this is unreachable. Throws
77
- // clearer than a possibly-null deref later.
78
- throw new Error("cap-counter:increment: row vanished between length-check and read");
79
- }
80
- const currentValue = currentRow["value"] as number; // @cast-boundary db-row
81
- const currentVersion = currentRow["version"] as number; // @cast-boundary db-row
82
- return executor.update(
83
- {
84
- id: aggregateId,
85
- version: currentVersion,
86
- changes: { value: currentValue + payload.amount },
87
- },
88
- event.user,
89
- ctx.db,
90
- );
46
+ return bookCapUsage(ctx, {
47
+ capName: payload.capName,
48
+ periodStartIso: payload.periodStartIso,
49
+ amount: payload.amount,
50
+ });
91
51
  },
92
52
  };
@@ -1,10 +1,6 @@
1
- import { createEntityExecutor, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
- import { Temporal } from "temporal-polyfill";
1
+ import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
3
2
  import { z } from "zod";
4
- import { capCounterAggregateId } from "../aggregate-id";
5
- import { capCounterEntity } from "../entity";
6
-
7
- const { table, executor } = createEntityExecutor("cap-counter", capCounterEntity);
3
+ import { markCapSoftWarned } from "../book-cap-usage";
8
4
 
9
5
  // mark-soft-warned — sets lastSoftWarnedAt on the counter so subsequent
10
6
  // soft-cap-hits in the same period don't re-trigger notifications.
@@ -26,32 +22,9 @@ export const markSoftWarnedHandler: WriteHandlerDef = {
26
22
  access: { roles: ["SystemAdmin"] },
27
23
  handler: async (event, ctx) => {
28
24
  const payload = event.payload as z.infer<typeof markSoftWarnedSchema>; // @cast-boundary engine-payload
29
- const aggregateId = capCounterAggregateId(
30
- event.user.tenantId,
31
- payload.capName,
32
- payload.periodStartIso,
33
- );
34
-
35
- const existing = await ctx.db.selectMany(table, { id: aggregateId }, { limit: 1 });
36
- if (existing.length === 0) {
37
- throw new Error(
38
- `cap-counter: cannot mark-soft-warned, no counter found for tenant=${event.user.tenantId} cap=${payload.capName} period=${payload.periodStartIso}`,
39
- );
40
- }
41
- const row = existing[0];
42
- if (!row) {
43
- throw new Error("cap-counter:mark-soft-warned: row vanished between length-check and read");
44
- }
45
- const currentVersion = row["version"] as number; // @cast-boundary db-row
46
-
47
- return executor.update(
48
- {
49
- id: aggregateId,
50
- version: currentVersion,
51
- changes: { lastSoftWarnedAt: Temporal.Now.instant() },
52
- },
53
- event.user,
54
- ctx.db,
55
- );
25
+ return markCapSoftWarned(ctx, {
26
+ capName: payload.capName,
27
+ periodStartIso: payload.periodStartIso,
28
+ });
56
29
  },
57
30
  };
@@ -1,6 +1,14 @@
1
1
  // Public API of the cap-counter bundled-feature.
2
2
 
3
3
  export { capCounterAggregateId, rollingCapAggregateId } from "./aggregate-id";
4
+ export {
5
+ type BookCapUsageOptions,
6
+ bookCapUsage,
7
+ type MarkCapSoftWarnedOptions,
8
+ markCapSoftWarned,
9
+ type ReadRollingCapUsageOptions,
10
+ readRollingCapUsage,
11
+ } from "./book-cap-usage";
4
12
  export {
5
13
  CAP_COUNTER_FEATURE,
6
14
  CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
@@ -3,8 +3,13 @@
3
3
  // only per-app variable was the Caps shape and how to resolve it for a
4
4
  // tenant, so both are now factory parameters.
5
5
 
6
- import { countWhere, type DbRunner, type WhereObject } from "@cosmicdrift/kumiko-framework/db";
7
- import type { TenantId, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
6
+ import type {
7
+ EntityTableMeta,
8
+ SchemaTable,
9
+ TenantDb,
10
+ WhereObject,
11
+ } from "@cosmicdrift/kumiko-framework/db";
12
+ import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
8
13
  import {
9
14
  UnprocessableError,
10
15
  type WriteFailure,
@@ -13,7 +18,7 @@ import {
13
18
  import { enforceStockCap } from "./enforce-cap";
14
19
 
15
20
  export type StockCapSpec<TCaps> = {
16
- readonly table: Parameters<typeof countWhere>[1];
21
+ readonly table: SchemaTable | EntityTableMeta;
17
22
  readonly limit: (caps: TCaps) => number;
18
23
  readonly where?: WhereObject;
19
24
  readonly code: string;
@@ -22,24 +27,19 @@ export type StockCapSpec<TCaps> = {
22
27
  };
23
28
 
24
29
  export type StockCapGuard<TCaps> = {
25
- readonly checkStockCap: (
26
- db: DbRunner,
27
- tenantId: TenantId,
28
- spec: StockCapSpec<TCaps>,
29
- ) => Promise<WriteFailure | null>;
30
+ readonly checkStockCap: (db: TenantDb, spec: StockCapSpec<TCaps>) => Promise<WriteFailure | null>;
30
31
  readonly withStockCap: (handler: WriteHandlerDef, spec: StockCapSpec<TCaps>) => WriteHandlerDef;
31
32
  };
32
33
 
33
34
  export function createStockCapGuard<TCaps>(
34
- resolveTierCaps: (db: DbRunner, tenantId: TenantId) => Promise<TCaps>,
35
+ resolveTierCaps: (db: TenantDb) => Promise<TCaps>,
35
36
  ): StockCapGuard<TCaps> {
36
37
  async function checkStockCap(
37
- db: DbRunner,
38
- tenantId: TenantId,
38
+ db: TenantDb,
39
39
  spec: StockCapSpec<TCaps>,
40
40
  ): Promise<WriteFailure | null> {
41
- const caps = await resolveTierCaps(db, tenantId);
42
- const current = await countWhere(db, spec.table, { ...spec.where, tenantId });
41
+ const caps = await resolveTierCaps(db);
42
+ const current = await db.count(spec.table, { ...spec.where, tenantId: db.tenantId });
43
43
  const { state, limit } = enforceStockCap({
44
44
  current,
45
45
  limit: spec.limit(caps),
@@ -55,15 +55,10 @@ export function createStockCapGuard<TCaps>(
55
55
  }
56
56
 
57
57
  function withStockCap(handler: WriteHandlerDef, spec: StockCapSpec<TCaps>): WriteHandlerDef {
58
- const capReason =
59
- "counts the caller tenant's rows and resolves its tier caps through the raw-runner cap API";
60
58
  return {
61
59
  ...handler,
62
- escapeHatch: handler.escapeHatch
63
- ? { reason: `${handler.escapeHatch.reason} ${capReason}` }
64
- : { reason: capReason },
65
60
  handler: async (event, ctx) => {
66
- const failure = await checkStockCap(ctx.db.unsafeRaw(capReason), event.user.tenantId, spec);
61
+ const failure = await checkStockCap(ctx.db, spec);
67
62
  return failure ?? handler.handler(event, ctx);
68
63
  },
69
64
  };
@@ -1,5 +1,5 @@
1
1
  // withCapEnforcement / withRollingCapEnforcement — handler-wrapper die
2
- // pre-call enforceCap-And-Notify + post-call increment um den
2
+ // pre-call enforceCap-And-Notify + post-call booking um den
3
3
  // gewrappten Handler legen.
4
4
  //
5
5
  // **Warum Wrapper statt manuelle Calls im Handler:**
@@ -8,22 +8,20 @@
8
8
  // pre-call zu machen — beides ist atomic-mit-dem-Handler-zusammen.
9
9
  // Wrapper macht das Pattern explizit + co-located.
10
10
  //
11
- // **Atomicity-Vorbehalt:** Pre-enforce + Handler + Post-Increment
12
- // laufen in DREI getrennten Transaktionen (Dispatcher öffnet jede
13
- // ctx.write-call eine eigene). Bei einem Crash zwischen Handler-
14
- // Success und Post-Increment kommt der Counter unter — Tenant
15
- // kriegt 1-2 Mails extra. Akzeptabel weil Cap-Toleranzen (110/120%)
16
- // genau für solche Drift-Fälle gebaut sind.
11
+ // **Atomicity caveat:** calendar booking runs in-process via bookCapUsage
12
+ // (see book-cap-usage.ts). Rolling booking still dispatches the
13
+ // SystemAdmin-only increment-rolling handler (event ownership), so rolling
14
+ // callers need a SystemAdmin identity.
17
15
  //
18
- // **Kein automatic markSoftWarned:** das passiert in
19
- // enforceCapAndMaybeNotify drin (siehe enforce-cap.ts). Wrapper ruft
20
- // nur den Helper, der den write dispatched.
16
+ // No automatic markSoftWarned here — that's inside enforceCapAndMaybeNotify
17
+ // (enforce-cap.ts).
21
18
 
22
19
  import type {
23
20
  HandlerContext,
24
21
  WriteEvent,
25
22
  WriteHandlerDef,
26
23
  } from "@cosmicdrift/kumiko-framework/engine";
24
+ import { bookCapUsage } from "./book-cap-usage";
27
25
  import { CapCounterHandlers } from "./constants";
28
26
  import {
29
27
  type CapToleranceProfileName,
@@ -103,7 +101,7 @@ export function withCapEnforcement(
103
101
  // Post-success increment. Skip on failure so a failed write
104
102
  // doesn't burn cap-quota. amount default 1.
105
103
  if (result.isSuccess) {
106
- await ctx.write(CapCounterHandlers.increment, {
104
+ await bookCapUsage(ctx, {
107
105
  capName: cap.capName,
108
106
  amount: cap.amount ?? 1,
109
107
  periodStartIso: cap.periodStartIso,
@@ -0,0 +1,138 @@
1
+ // createTierResolver (fw#2854): resolveTier/resolveTierCaps take only a
2
+ // TenantDb — the tenant comes from db.tenantId, so a caller can't request a
3
+ // foreign tenant's tier. Proves defaultTier for an unassigned tenant, the
4
+ // assigned tier for an assigned one, and the same behavior end-to-end
5
+ // through a TenantAdmin-only query handler with no escapeHatch.
6
+
7
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
8
+ import { configValuesTable } from "@cosmicdrift/kumiko-bundled-features/config";
9
+ import { tenantSecretsTable } from "@cosmicdrift/kumiko-bundled-features/secrets";
10
+ import { tenantMembershipsTable, tenantTable } from "@cosmicdrift/kumiko-bundled-features/tenant";
11
+ import { userTable } from "@cosmicdrift/kumiko-bundled-features/user";
12
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
13
+ import { buildEntityTable, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
14
+ import { defineFeature, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
15
+ import {
16
+ createTestUser,
17
+ setupTestStack,
18
+ type TestStack,
19
+ unsafePushTables,
20
+ } from "@cosmicdrift/kumiko-framework/stack";
21
+ import { composeFeatures } from "@cosmicdrift/kumiko-server-runtime/compose-features";
22
+ import { z } from "zod";
23
+ import { tierAssignmentEntity } from "../entity";
24
+ import { tierEngineFeature } from "../feature";
25
+ import { createTierResolver } from "../tier-resolver";
26
+
27
+ type TestCaps = { readonly maxItems: number };
28
+ type TestTier = "free" | "pro";
29
+
30
+ function isTestTierName(value: string): value is TestTier {
31
+ return value === "free" || value === "pro";
32
+ }
33
+
34
+ function capsForTier(tier: TestTier): TestCaps {
35
+ return tier === "pro" ? { maxItems: 5 } : { maxItems: 1 };
36
+ }
37
+
38
+ const { resolveTier, resolveTierCaps } = createTierResolver<TestTier, TestCaps>({
39
+ capsForTier,
40
+ isTierName: isTestTierName,
41
+ defaultTier: "free",
42
+ });
43
+
44
+ const RESOLVED_CAPS_QN = "resolver-probe:query:resolved-caps";
45
+ const resolverProbeFeature = defineFeature("resolver-probe", (r) => {
46
+ r.queryHandler({
47
+ name: "resolved-caps",
48
+ schema: z.object({}),
49
+ access: { roles: ["TenantAdmin"] },
50
+ handler: async (_query, ctx) => resolveTierCaps(ctx.db),
51
+ });
52
+ });
53
+
54
+ const features = composeFeatures([tierEngineFeature, resolverProbeFeature], {
55
+ includeBundled: true,
56
+ });
57
+
58
+ // Same underlying table createTierResolver builds internally — pushed here
59
+ // so the schema exists before the write-handler + resolver read it.
60
+ const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEntity);
61
+
62
+ let stack: TestStack;
63
+ const tenantA = "00000000-0000-4000-8000-0000000000c1" as TenantId;
64
+ const tenantB = "00000000-0000-4000-8000-0000000000c2" as TenantId;
65
+
66
+ beforeAll(async () => {
67
+ stack = await setupTestStack({ features });
68
+ await unsafePushTables(stack.db, {
69
+ config_values: configValuesTable,
70
+ users: userTable,
71
+ tenants: tenantTable,
72
+ tenant_memberships: tenantMembershipsTable,
73
+ tenant_secrets: tenantSecretsTable,
74
+ tier_assignments: tierAssignmentTable,
75
+ });
76
+ });
77
+
78
+ afterAll(async () => stack?.cleanup());
79
+
80
+ beforeEach(async () => {
81
+ await asRawClient(stack.db).unsafe(`TRUNCATE read_tier_assignments RESTART IDENTITY CASCADE`);
82
+ });
83
+
84
+ function sysAdminFor(tenantId: TenantId) {
85
+ return createTestUser({ id: `sys-${tenantId}`, tenantId, roles: ["SystemAdmin", "TenantAdmin"] });
86
+ }
87
+
88
+ function tenantAdminOnlyFor(tenantId: TenantId) {
89
+ return createTestUser({ id: `admin-${tenantId}`, tenantId, roles: ["TenantAdmin"] });
90
+ }
91
+
92
+ async function assignTier(tenantId: TenantId, tier: TestTier): Promise<void> {
93
+ await stack.http.writeOk(
94
+ "tier-engine:write:tier-assignment:create",
95
+ { tier },
96
+ sysAdminFor(tenantId),
97
+ );
98
+ }
99
+
100
+ describe("createTierResolver — resolveTier/resolveTierCaps", () => {
101
+ test("unassigned tenant B resolves to defaultTier, not tenant A's assigned tier", async () => {
102
+ await assignTier(tenantA, "pro");
103
+
104
+ const tierA = await resolveTier(createTenantDb(stack.db, tenantA));
105
+ expect(tierA).toBe("pro");
106
+
107
+ const tierB = await resolveTier(createTenantDb(stack.db, tenantB));
108
+ expect(tierB).toBe("free");
109
+ });
110
+
111
+ test("resolveTierCaps maps the resolved tier via capsForTier", async () => {
112
+ await assignTier(tenantA, "pro");
113
+
114
+ const capsA = await resolveTierCaps(createTenantDb(stack.db, tenantA));
115
+ expect(capsA).toEqual({ maxItems: 5 });
116
+
117
+ const capsB = await resolveTierCaps(createTenantDb(stack.db, tenantB));
118
+ expect(capsB).toEqual({ maxItems: 1 });
119
+ });
120
+
121
+ test("end-to-end via a TenantAdmin-only query handler (no escapeHatch)", async () => {
122
+ await assignTier(tenantA, "pro");
123
+
124
+ const capsA = await stack.http.queryOk<TestCaps>(
125
+ RESOLVED_CAPS_QN,
126
+ {},
127
+ tenantAdminOnlyFor(tenantA),
128
+ );
129
+ expect(capsA).toEqual({ maxItems: 5 });
130
+
131
+ const capsB = await stack.http.queryOk<TestCaps>(
132
+ RESOLVED_CAPS_QN,
133
+ {},
134
+ tenantAdminOnlyFor(tenantB),
135
+ );
136
+ expect(capsB).toEqual({ maxItems: 1 });
137
+ });
138
+ });
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.278.0",
4
+ "type": "breaking",
5
+ "title": "createTierResolver's resolveTier/resolveTierCaps take only a TenantDb — no separate tenantId argument (fw#2854).",
6
+ "detail": "`resolveTier(db)` and `resolveTierCaps(db)` (from `createTierResolver`) now read the tenant from `db.tenantId` instead of taking a second `tenantId` parameter, so a caller can no longer pass a `TenantDb` for one tenant alongside a different `tenantId` and resolve a foreign tenant's tier.",
7
+ "migration": "`resolveTier(ctx.db.raw|runner, tenantId)` → `resolveTier(ctx.db)` (same for `resolveTierCaps`). Outside a handler: `resolveTier(createTenantDb(runner, tenantId))`."
8
+ },
2
9
  {
3
10
  "version": "0.241.0",
4
11
  "type": "breaking",
@@ -3,8 +3,7 @@
3
3
  // only per-app variables were the TierName union and its caps/defaults, so
4
4
  // both are now factory parameters.
5
5
 
6
- import { buildEntityTable, type DbRunner, fetchOne } from "@cosmicdrift/kumiko-framework/db";
7
- import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { buildEntityTable, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
8
7
  import { tierAssignmentEntity } from "./entity";
9
8
 
10
9
  export type TierResolverDeps<TTier extends string, TCaps> = {
@@ -18,14 +17,16 @@ export function createTierResolver<TTier extends string, TCaps>(
18
17
  ) {
19
18
  const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEntity);
20
19
 
21
- async function resolveTier(db: DbRunner, tenantId: TenantId): Promise<TTier> {
22
- const row = await fetchOne<{ tier?: unknown }>(db, tierAssignmentTable, { tenantId });
20
+ async function resolveTier(db: TenantDb): Promise<TTier> {
21
+ const row = await db.fetchOne<{ tier?: unknown }>(tierAssignmentTable, {
22
+ tenantId: db.tenantId,
23
+ });
23
24
  const tier = row?.tier;
24
25
  return typeof tier === "string" && deps.isTierName(tier) ? tier : deps.defaultTier;
25
26
  }
26
27
 
27
- async function resolveTierCaps(db: DbRunner, tenantId: TenantId): Promise<TCaps> {
28
- return deps.capsForTier(await resolveTier(db, tenantId));
28
+ async function resolveTierCaps(db: TenantDb): Promise<TCaps> {
29
+ return deps.capsForTier(await resolveTier(db));
29
30
  }
30
31
 
31
32
  return { tierAssignmentTable, resolveTier, resolveTierCaps };