@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
|
@@ -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 {
|
|
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
|
|
184
|
-
|
|
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
|
-
|
|
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 {
|
|
2
|
-
import { Temporal } from "temporal-polyfill";
|
|
1
|
+
import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
2
|
import { z } from "zod";
|
|
4
|
-
import {
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
payload.
|
|
53
|
-
payload.
|
|
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 {
|
|
2
|
-
import { Temporal } from "temporal-polyfill";
|
|
1
|
+
import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
2
|
import { z } from "zod";
|
|
4
|
-
import {
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
payload.
|
|
32
|
-
|
|
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
|
};
|
package/src/cap-counter/index.ts
CHANGED
|
@@ -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
|
|
7
|
-
|
|
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:
|
|
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:
|
|
35
|
+
resolveTierCaps: (db: TenantDb) => Promise<TCaps>,
|
|
35
36
|
): StockCapGuard<TCaps> {
|
|
36
37
|
async function checkStockCap(
|
|
37
|
-
db:
|
|
38
|
-
tenantId: TenantId,
|
|
38
|
+
db: TenantDb,
|
|
39
39
|
spec: StockCapSpec<TCaps>,
|
|
40
40
|
): Promise<WriteFailure | null> {
|
|
41
|
-
const caps = await resolveTierCaps(db
|
|
42
|
-
const current = await
|
|
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
|
|
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
|
|
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
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
-
//
|
|
19
|
-
//
|
|
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
|
|
104
|
+
await bookCapUsage(ctx, {
|
|
107
105
|
capName: cap.capName,
|
|
108
106
|
amount: cap.amount ?? 1,
|
|
109
107
|
periodStartIso: cap.periodStartIso,
|
|
@@ -71,8 +71,14 @@ const propertyTable = buildEntityTable("property", propertyEntity);
|
|
|
71
71
|
// Host entity gets its own EXT_USER_DATA-registration too — that's the
|
|
72
72
|
// canonical setup. The host's anonymize hook NULLS inserted_by_id; the
|
|
73
73
|
// custom-fields wiring contributes export only (#972).
|
|
74
|
+
// fw#2914 — "property" is a managed r.entity table (EXECUTOR_ONLY-branded), so
|
|
75
|
+
// TenantDb's typed write methods reject it; these host hooks need raw SQL,
|
|
76
|
+
// still filtered by inserted_by_id + tenant_id.
|
|
77
|
+
const HOST_HOOK_REASON =
|
|
78
|
+
"fw#2914 test fixture: host export/delete against a managed entity table needs raw SQL (TenantDb's typed write API rejects EXECUTOR_ONLY tables); filtered by inserted_by_id + tenant_id";
|
|
79
|
+
|
|
74
80
|
const hostExportHook: UserDataExportHook = async (ctx) => {
|
|
75
|
-
const rows = await asRawClient(ctx.db).unsafe(
|
|
81
|
+
const rows = await asRawClient(ctx.db.unsafeRaw(HOST_HOOK_REASON)).unsafe(
|
|
76
82
|
`SELECT id, name FROM read_t15c_properties WHERE inserted_by_id = $1 AND tenant_id = $2`,
|
|
77
83
|
[ctx.userId, ctx.tenantId],
|
|
78
84
|
);
|
|
@@ -86,13 +92,13 @@ const hostExportHook: UserDataExportHook = async (ctx) => {
|
|
|
86
92
|
|
|
87
93
|
const hostDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
|
|
88
94
|
if (strategy === "delete") {
|
|
89
|
-
await asRawClient(ctx.db).unsafe(
|
|
95
|
+
await asRawClient(ctx.db.unsafeRaw(HOST_HOOK_REASON)).unsafe(
|
|
90
96
|
`DELETE FROM read_t15c_properties WHERE inserted_by_id = $1 AND tenant_id = $2`,
|
|
91
97
|
[ctx.userId, ctx.tenantId],
|
|
92
98
|
);
|
|
93
99
|
} else {
|
|
94
100
|
// anonymize: clear owner, keep row + customFields.
|
|
95
|
-
await asRawClient(ctx.db).unsafe(
|
|
101
|
+
await asRawClient(ctx.db.unsafeRaw(HOST_HOOK_REASON)).unsafe(
|
|
96
102
|
`UPDATE read_t15c_properties SET inserted_by_id = NULL WHERE inserted_by_id = $1 AND tenant_id = $2`,
|
|
97
103
|
[ctx.userId, ctx.tenantId],
|
|
98
104
|
);
|
|
@@ -111,6 +117,7 @@ const propertyFeature = defineFeature("property-t15c", (r) => {
|
|
|
111
117
|
r.useExtension(EXT_USER_DATA, "property", {
|
|
112
118
|
export: hostExportHook,
|
|
113
119
|
delete: hostDeleteHook,
|
|
120
|
+
escapeHatch: { reason: HOST_HOOK_REASON },
|
|
114
121
|
});
|
|
115
122
|
|
|
116
123
|
const { executor } = createEntityExecutor("property", propertyEntity);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// T1.5c — user-data-rights wiring for custom-fields.
|
|
2
2
|
|
|
3
|
-
import { extractTableName } from "@cosmicdrift/kumiko-framework/db";
|
|
4
|
-
import type {
|
|
3
|
+
import { type DbRunner, extractTableName } from "@cosmicdrift/kumiko-framework/db";
|
|
4
|
+
import type { UserDataExportSnippet, UserDataHookCtx } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
5
|
import { EXT_USER_DATA, type FeatureRegistrar } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
6
|
import { selectCustomFieldsHostRows } from "./db/queries/user-data-rights";
|
|
7
7
|
|
|
@@ -27,6 +27,41 @@ function asCustomFieldsHostRow(value: unknown): CustomFieldsHostRow | null {
|
|
|
27
27
|
return { id: value.id, customFields: Object.fromEntries(Object.entries(cf)) };
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
// fw#2914 — selectCustomFieldsHostRows runs raw SQL against a dynamic host
|
|
31
|
+
// table name (opts.entityTable varies per call site), so it can't go through
|
|
32
|
+
// TenantDb's typed method API (table param must be a known SchemaTable). The
|
|
33
|
+
// SQL text itself still filters `tenant_id = ctx.tenantId`, so this stays a
|
|
34
|
+
// single-tenant read — the escapeHatch is only for the raw-SQL mechanism, not
|
|
35
|
+
// for cross-tenant access.
|
|
36
|
+
const CUSTOM_FIELDS_EXPORT_REASON =
|
|
37
|
+
"custom-fields export reads a generic customFields column via raw SQL against a dynamic host table name, not expressible through TenantDb's typed method API; the query still filters tenant_id = ctx.tenantId";
|
|
38
|
+
|
|
39
|
+
async function exportCustomFields(
|
|
40
|
+
db: DbRunner,
|
|
41
|
+
ctx: UserDataHookCtx,
|
|
42
|
+
tableName: string,
|
|
43
|
+
opts: WireCustomFieldsUserDataRightsOptions,
|
|
44
|
+
): Promise<UserDataExportSnippet | null> {
|
|
45
|
+
const rows = await selectCustomFieldsHostRows(
|
|
46
|
+
db,
|
|
47
|
+
tableName,
|
|
48
|
+
opts.userIdColumn,
|
|
49
|
+
ctx.userId,
|
|
50
|
+
ctx.tenantId,
|
|
51
|
+
);
|
|
52
|
+
const snippetRows: Array<{ id: string; customFields: Record<string, unknown> }> = [];
|
|
53
|
+
for (const raw of rows) {
|
|
54
|
+
const row = asCustomFieldsHostRow(raw);
|
|
55
|
+
if (!row) continue;
|
|
56
|
+
const customFields = row.customFields;
|
|
57
|
+
if (customFields && Object.keys(customFields).length > 0) {
|
|
58
|
+
snippetRows.push({ id: row.id, customFields });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (snippetRows.length === 0) return null;
|
|
62
|
+
return { entity: `${opts.entityName}.customFields`, rows: snippetRows };
|
|
63
|
+
}
|
|
64
|
+
|
|
30
65
|
// Export-only wiring: custom fields hold supplemental business data, not PII
|
|
31
66
|
// (#972) — there is nothing to redact on user-forget, only Art. 20 export.
|
|
32
67
|
export function wireCustomFieldsUserDataRightsFor<TReg extends FeatureRegistrar<string>>(
|
|
@@ -35,29 +70,10 @@ export function wireCustomFieldsUserDataRightsFor<TReg extends FeatureRegistrar<
|
|
|
35
70
|
): void {
|
|
36
71
|
const tableName = extractTableName(opts.entityTable, "custom-fields/wire-user-data-rights");
|
|
37
72
|
|
|
38
|
-
const exportHook: UserDataExportHook = async (ctx) => {
|
|
39
|
-
const rows = await selectCustomFieldsHostRows(
|
|
40
|
-
ctx.db,
|
|
41
|
-
tableName,
|
|
42
|
-
opts.userIdColumn,
|
|
43
|
-
ctx.userId,
|
|
44
|
-
ctx.tenantId,
|
|
45
|
-
);
|
|
46
|
-
const snippetRows: Array<{ id: string; customFields: Record<string, unknown> }> = [];
|
|
47
|
-
for (const raw of rows) {
|
|
48
|
-
const row = asCustomFieldsHostRow(raw);
|
|
49
|
-
if (!row) continue;
|
|
50
|
-
const customFields = row.customFields;
|
|
51
|
-
if (customFields && Object.keys(customFields).length > 0) {
|
|
52
|
-
snippetRows.push({ id: row.id, customFields });
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
if (snippetRows.length === 0) return null;
|
|
56
|
-
return { entity: `${opts.entityName}.customFields`, rows: snippetRows };
|
|
57
|
-
};
|
|
58
|
-
|
|
59
73
|
// biome-ignore lint/correctness/useHookAtTopLevel: r.useExtension is a registrar API, not a React hook.
|
|
60
74
|
r.useExtension(EXT_USER_DATA, opts.entityName, {
|
|
61
|
-
export:
|
|
75
|
+
export: async (ctx: UserDataHookCtx) =>
|
|
76
|
+
exportCustomFields(ctx.db.unsafeRaw(CUSTOM_FIELDS_EXPORT_REASON), ctx, tableName, opts),
|
|
77
|
+
escapeHatch: { reason: CUSTOM_FIELDS_EXPORT_REASON },
|
|
62
78
|
});
|
|
63
79
|
}
|
|
@@ -7,15 +7,22 @@
|
|
|
7
7
|
// + `resolveRetentionPolicy`, also kein Drift-Risiko.
|
|
8
8
|
|
|
9
9
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
|
-
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
10
|
+
import type { DbRunner, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
11
11
|
import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
12
12
|
import { parseRetentionOverrideOrNull } from "./_internal/parse-override";
|
|
13
13
|
import type { RetentionPresetKey } from "./presets";
|
|
14
14
|
import { type EffectiveRetentionPolicy, resolveRetentionPolicy } from "./resolver";
|
|
15
15
|
import { tenantRetentionOverrideTable } from "./schema/tenant-retention-override";
|
|
16
16
|
|
|
17
|
+
function isTenantDb(db: DbRunner | TenantDb): db is TenantDb {
|
|
18
|
+
return typeof (db as Partial<TenantDb>).unsafeRaw === "function";
|
|
19
|
+
}
|
|
20
|
+
|
|
17
21
|
export interface ResolveForTenantArgs {
|
|
18
|
-
|
|
22
|
+
// fw#2914 — a EXT_USER_DATA hook's ctx.db is a TenantDb (method-form);
|
|
23
|
+
// the cleanup cron's own db is still a raw DbRunner. Both are valid here —
|
|
24
|
+
// this lookup is a plain tenant-scoped read either way.
|
|
25
|
+
readonly db: DbRunner | TenantDb;
|
|
19
26
|
readonly registry: Registry;
|
|
20
27
|
readonly tenantId: TenantId;
|
|
21
28
|
readonly entityName: string;
|
|
@@ -43,10 +50,15 @@ export async function resolveRetentionPolicyForTenant(
|
|
|
43
50
|
const overrideRow =
|
|
44
51
|
args.preloadedOverride !== undefined
|
|
45
52
|
? args.preloadedOverride
|
|
46
|
-
: (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
53
|
+
: isTenantDb(args.db)
|
|
54
|
+
? ((await args.db.fetchOne<{ config: string | null }>(tenantRetentionOverrideTable, {
|
|
55
|
+
tenantId: args.tenantId,
|
|
56
|
+
entityName: args.entityName,
|
|
57
|
+
})) ?? null)
|
|
58
|
+
: ((await fetchOne(args.db, tenantRetentionOverrideTable, {
|
|
59
|
+
tenantId: args.tenantId,
|
|
60
|
+
entityName: args.entityName,
|
|
61
|
+
})) as { config: string | null } | null); // @cast-boundary db-runner
|
|
50
62
|
|
|
51
63
|
const tenantOverride = parseRetentionOverrideOrNull(
|
|
52
64
|
overrideRow?.config ?? null,
|
|
@@ -18,8 +18,7 @@
|
|
|
18
18
|
// cross into another tenant's keys (the provider's own list() prefix
|
|
19
19
|
// already scopes it).
|
|
20
20
|
|
|
21
|
-
import {
|
|
22
|
-
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
21
|
+
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
23
22
|
import {
|
|
24
23
|
createSystemUser,
|
|
25
24
|
type StorageProviderDestroyTenantHook,
|
|
@@ -34,13 +33,12 @@ import {
|
|
|
34
33
|
const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName: "fileRef" });
|
|
35
34
|
|
|
36
35
|
export const fileRefTenantDestroyHook: TenantDataDestroyHook = async (ctx) => {
|
|
37
|
-
const rows = await selectMany<{ id: string }>(
|
|
36
|
+
const rows = await ctx.db.selectMany<{ id: string }>(fileRefsTable, {
|
|
38
37
|
tenantId: ctx.tenantId,
|
|
39
38
|
});
|
|
40
39
|
const user = createSystemUser(ctx.tenantId);
|
|
41
|
-
const db = createTenantDb(ctx.db, ctx.tenantId, "system");
|
|
42
40
|
for (const row of rows) {
|
|
43
|
-
const result = await crud.forget({ id: row.id }, user, db);
|
|
41
|
+
const result = await crud.forget({ id: row.id }, user, ctx.db);
|
|
44
42
|
// Executor writes return {isSuccess:false} instead of throwing — a
|
|
45
43
|
// discarded result would report this destroy stage "succeeded" while the
|
|
46
44
|
// row (and its PII fileName) survives. Throw so the pipeline's
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
19
19
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
20
|
+
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
20
21
|
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
21
22
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
22
23
|
import {
|
|
@@ -402,7 +403,7 @@ describe("folders-user-data — tenantScopedDelete hooks", () => {
|
|
|
402
403
|
test("multi-user tenant: no-op, rows survive", async () => {
|
|
403
404
|
await seedOneFolderWithAssignment();
|
|
404
405
|
const ctx = {
|
|
405
|
-
db: stack.db,
|
|
406
|
+
db: createTenantDb(stack.db, admin.tenantId, "tenant"),
|
|
406
407
|
registry: stack.registry,
|
|
407
408
|
tenantId: admin.tenantId,
|
|
408
409
|
userId: admin.id,
|
|
@@ -417,7 +418,7 @@ describe("folders-user-data — tenantScopedDelete hooks", () => {
|
|
|
417
418
|
test("anonymize strategy: no-op even on a single-user tenant", async () => {
|
|
418
419
|
await seedOneFolderWithAssignment();
|
|
419
420
|
const ctx = {
|
|
420
|
-
db: stack.db,
|
|
421
|
+
db: createTenantDb(stack.db, admin.tenantId, "tenant"),
|
|
421
422
|
registry: stack.registry,
|
|
422
423
|
tenantId: admin.tenantId,
|
|
423
424
|
userId: admin.id,
|
|
@@ -432,7 +433,7 @@ describe("folders-user-data — tenantScopedDelete hooks", () => {
|
|
|
432
433
|
test("single-user tenant + delete: rows are purged", async () => {
|
|
433
434
|
await seedOneFolderWithAssignment();
|
|
434
435
|
const ctx = {
|
|
435
|
-
db: stack.db,
|
|
436
|
+
db: createTenantDb(stack.db, admin.tenantId, "tenant"),
|
|
436
437
|
registry: stack.registry,
|
|
437
438
|
tenantId: admin.tenantId,
|
|
438
439
|
userId: admin.id,
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
13
13
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
14
|
+
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
14
15
|
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
15
16
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
16
17
|
import {
|
|
@@ -87,7 +88,7 @@ describe("folderAssignmentExportHook", () => {
|
|
|
87
88
|
);
|
|
88
89
|
|
|
89
90
|
const snippet = await folderAssignmentExportHook({
|
|
90
|
-
db: stack.db,
|
|
91
|
+
db: createTenantDb(stack.db, admin.tenantId, "tenant"),
|
|
91
92
|
registry: stack.registry,
|
|
92
93
|
tenantId: admin.tenantId,
|
|
93
94
|
userId: admin.id,
|
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
// Mirrors credit-user-data — standard tenant-scoped pattern, no name-stripping
|
|
5
5
|
// (a folder name is tenant data, not per-user PII).
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
import { createTenantDb, type EventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
7
|
+
import type { EventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
9
8
|
import {
|
|
10
9
|
createEntityExecutor,
|
|
11
10
|
createSystemUser,
|
|
@@ -27,9 +26,7 @@ const { table: folderAssignmentTable, executor: folderAssignmentExecutor } = cre
|
|
|
27
26
|
// all tenant folders in-app, so bundling them into the user's export is no new
|
|
28
27
|
// exposure — it gives the data subject the organisation of the loans they work with.
|
|
29
28
|
export const folderExportHook: UserDataExportHook = async (ctx) => {
|
|
30
|
-
const rows = await
|
|
31
|
-
tenantId: ctx.tenantId,
|
|
32
|
-
});
|
|
29
|
+
const rows = await ctx.db.selectMany(folderTable, { tenantId: ctx.tenantId });
|
|
33
30
|
if (rows.length === 0) return null;
|
|
34
31
|
return { entity: "folder", rows };
|
|
35
32
|
};
|
|
@@ -38,7 +35,7 @@ export const folderAssignmentExportHook: UserDataExportHook = async (ctx) => {
|
|
|
38
35
|
// folderAssignmentEntity is softDelete: true — a cleared assignment
|
|
39
36
|
// (isDeleted: true) is a removed folder membership, not something the GDPR
|
|
40
37
|
// export should still surface as current data.
|
|
41
|
-
const rows = await
|
|
38
|
+
const rows = await ctx.db.selectMany(folderAssignmentTable, {
|
|
42
39
|
tenantId: ctx.tenantId,
|
|
43
40
|
isDeleted: false,
|
|
44
41
|
});
|
|
@@ -65,11 +62,9 @@ function tenantScopedDelete(
|
|
|
65
62
|
// eventless, so a projection rebuild resurrects the rows. Bounded — forget
|
|
66
63
|
// only fires for single-user tenants.
|
|
67
64
|
const systemUser = createSystemUser(ctx.tenantId);
|
|
68
|
-
|
|
69
|
-
const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
|
|
70
|
-
const rows = await selectMany<{ id: string }>(ctx.db, table, { tenantId: ctx.tenantId });
|
|
65
|
+
const rows = await ctx.db.selectMany<{ id: string }>(table, { tenantId: ctx.tenantId });
|
|
71
66
|
for (const row of rows) {
|
|
72
|
-
await executor.delete({ id: row.id }, systemUser,
|
|
67
|
+
await executor.delete({ id: row.id }, systemUser, ctx.db);
|
|
73
68
|
}
|
|
74
69
|
};
|
|
75
70
|
}
|