@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.
- package/package.json +9 -9
- package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +8 -2
- package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +6 -6
- package/src/auth-mfa-user-data/hooks.ts +4 -6
- package/src/cap-counter/__tests__/enforce-cap.test.ts +7 -10
- package/src/cap-counter/__tests__/stock-cap-guard.integration.test.ts +123 -6
- package/src/cap-counter/__tests__/with-cap-enforcement.integration.test.ts +135 -1
- package/src/cap-counter/book-cap-usage.ts +158 -0
- package/src/cap-counter/changes.json +16 -1
- package/src/cap-counter/enforce-cap.ts +5 -37
- package/src/cap-counter/handlers/increment.write.ts +7 -47
- package/src/cap-counter/handlers/mark-soft-warned.write.ts +6 -33
- package/src/cap-counter/index.ts +8 -0
- package/src/cap-counter/stock-cap-guard.ts +14 -19
- package/src/cap-counter/with-cap-enforcement.ts +9 -11
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +10 -3
- package/src/custom-fields/wire-user-data-rights.ts +40 -24
- package/src/data-retention/resolve-for-tenant.ts +18 -6
- package/src/delivery/db/queries/preferences.ts +1 -0
- package/src/files-tenant-data/hooks.ts +3 -5
- package/src/folders/__tests__/folders.integration.test.ts +4 -3
- package/src/folders-user-data/__tests__/hooks.integration.test.ts +2 -1
- package/src/folders-user-data/hooks.ts +5 -10
- package/src/form-draft-user-data/__tests__/hooks-delete-failure.integration.test.ts +7 -1
- package/src/form-draft-user-data/__tests__/hooks.integration.test.ts +16 -5
- package/src/form-draft-user-data/hooks.ts +3 -6
- package/src/notes-history-user-data/__tests__/hooks.integration.test.ts +9 -3
- package/src/notes-history-user-data/hooks.ts +3 -4
- package/src/personal-access-tokens/handlers/create.write.ts +1 -0
- package/src/template-resolver-user-data/hooks.ts +1 -2
- package/src/tenant-lifecycle/feature.ts +2 -0
- package/src/tenant-lifecycle/run-tenant-destroy.ts +9 -0
- package/src/tenant-lifecycle/stages.ts +26 -2
- package/src/tier-engine/__tests__/tier-resolver.integration.test.ts +138 -0
- package/src/tier-engine/changes.json +7 -0
- package/src/tier-engine/tier-resolver.ts +7 -6
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +5 -14
- package/src/user-data-rights/__tests__/escape-hatch-audit.integration.test.ts +133 -0
- package/src/user-data-rights/__tests__/escape-hatch-declaration.integration.test.ts +198 -0
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +15 -3
- package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts +10 -2
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -1
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -3
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +8 -2
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +8 -4
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +15 -5
- package/src/user-data-rights/feature.ts +4 -0
- package/src/user-data-rights/run-export-jobs.ts +16 -1
- package/src/user-data-rights/run-forget-cleanup.ts +44 -2
- package/src/user-data-rights/run-user-export.ts +43 -3
- package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +7 -6
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +64 -18
- package/src/user-data-rights-defaults/hooks/api-token.userdata-hook.ts +2 -3
- package/src/user-data-rights-defaults/hooks/config-value.userdata-hook.ts +4 -6
- package/src/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.ts +1 -2
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +5 -11
- package/src/user-data-rights-defaults/hooks/in-app-message.userdata-hook.ts +2 -3
- package/src/user-data-rights-defaults/hooks/job-run.userdata-hook.ts +1 -2
- package/src/user-data-rights-defaults/hooks/notification-preference.userdata-hook.ts +4 -6
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +8 -10
- package/src/user-data-rights-defaults/hooks/user-session.userdata-hook.ts +2 -3
- 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.
|
|
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.
|
|
133
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
135
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
167
|
-
"@cosmicdrift/kumiko-locale-es": "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
|
-
{
|
|
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 {
|
|
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 }>(
|
|
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 }>(
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
8
|
-
import {
|
|
9
|
-
|
|
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
|
|
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
|
+
}
|