@cosmicdrift/kumiko-bundled-features 0.357.0 → 0.359.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/dist/cap-counter/book-cap-usage.d.ts +11 -5
- package/dist/cap-counter/book-cap-usage.js +54 -13
- package/dist/cap-counter/calendar-cap-usage-reservation.d.ts +17 -0
- package/dist/cap-counter/calendar-cap-usage-reservation.js +53 -0
- package/dist/cap-counter/cap-reservation.d.ts +15 -6
- package/dist/cap-counter/cap-reservation.js +80 -31
- package/dist/cap-counter/enforce-cap.d.ts +5 -5
- package/dist/cap-counter/enforce-cap.js +7 -20
- package/dist/cap-counter/index.d.ts +1 -0
- package/dist/cap-counter/index.js +1 -0
- package/dist/cap-counter/with-cap-enforcement.js +4 -19
- package/dist/crypto-shredding/handlers/forget-subject.write.js +0 -1
- package/dist/custom-fields/handlers/update-tenant-field.write.js +15 -6
- package/dist/custom-fields/schemas.d.ts +32 -23
- package/dist/custom-fields/schemas.js +21 -11
- package/dist/jobs/job-run-logger.js +21 -17
- package/dist/jobs/job-run-table.js +10 -3
- package/dist/notes-history/entity.js +2 -1
- package/dist/notes-history-user-data/hooks.d.ts +1 -2
- package/dist/notes-history-user-data/hooks.js +3 -36
- package/dist/notes-history-user-data/index.js +1 -1
- package/package.json +9 -9
- package/src/cap-counter/changes.json +7 -0
- package/src/custom-fields/changes.json +6 -0
- package/src/notes-history/changes.json +6 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
-
import { type HandlerContext, type SessionUser, type TenantId, type WriteResult } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { type HandlerContext, type JobContext, type SessionUser, type TenantId, type WriteResult } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
export declare function retryCounterWriteOnVersionConflict(writeAttempt: () => Promise<WriteResult>): Promise<WriteResult>;
|
|
4
4
|
export declare function retryOnStreamVersionConflict<T>(attempt: () => Promise<T>): Promise<T>;
|
|
5
5
|
export type BookCapUsageOptions = {
|
|
@@ -9,16 +9,22 @@ export type BookCapUsageOptions = {
|
|
|
9
9
|
readonly outsideTransaction?: boolean;
|
|
10
10
|
readonly guardCurrentValue?: (currentValue: number) => void;
|
|
11
11
|
};
|
|
12
|
-
export
|
|
13
|
-
export
|
|
14
|
-
|
|
12
|
+
export type CapCounterContext = HandlerContext | JobContext;
|
|
13
|
+
export type CapCounterScope = {
|
|
14
|
+
readonly user: SessionUser;
|
|
15
|
+
readonly readDb: TenantDb;
|
|
16
|
+
readonly writeDb: TenantDb;
|
|
17
|
+
};
|
|
18
|
+
export declare function resolveCapCounterScope(ctx: CapCounterContext, outsideTransaction: boolean): CapCounterScope;
|
|
19
|
+
export declare function bookCapUsage(ctx: CapCounterContext, options: BookCapUsageOptions): Promise<WriteResult>;
|
|
20
|
+
export declare function releaseCapUsage(ctx: CapCounterContext, options: Omit<BookCapUsageOptions, "guardCurrentValue">): Promise<WriteResult>;
|
|
15
21
|
export declare function applyCapDeltaOn(db: TenantDb, user: SessionUser, options: BookCapUsageOptions, direction: "add" | "subtract"): Promise<WriteResult>;
|
|
16
22
|
export type MarkCapSoftWarnedOptions = {
|
|
17
23
|
readonly capName: string;
|
|
18
24
|
readonly periodStartIso: string;
|
|
19
25
|
readonly outsideTransaction?: boolean;
|
|
20
26
|
};
|
|
21
|
-
export declare function markCapSoftWarned(ctx:
|
|
27
|
+
export declare function markCapSoftWarned(ctx: CapCounterContext, options: MarkCapSoftWarnedOptions): Promise<WriteResult>;
|
|
22
28
|
export type ReadRollingCapUsageOptions = {
|
|
23
29
|
readonly capName: string;
|
|
24
30
|
readonly windowDays: number;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// In-process cap booking for the caller's own tenant; no SystemAdmin dispatch needed.
|
|
2
2
|
import { runInOwnTransaction } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import { createEntityExecutor, } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
5
|
import { eventsTable, VersionConflictError } from "@cosmicdrift/kumiko-framework/event-store";
|
|
5
6
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
6
7
|
import * as z from "zod";
|
|
@@ -48,11 +49,51 @@ export async function retryOnStreamVersionConflict(attempt) {
|
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
// A tenant-mode db pins every row it writes to its own tenant; counter and reservation row would
|
|
53
|
+
// silently land on different tenants if that is not the user's.
|
|
54
|
+
function assertDbServesUser(db, user) {
|
|
55
|
+
if (db.mode === "tenant" && db.tenantId !== user.tenantId) {
|
|
56
|
+
throw new AccessDeniedError({
|
|
57
|
+
message: "cap-counter: the context's database belongs to a different tenant than its user",
|
|
58
|
+
});
|
|
54
59
|
}
|
|
55
|
-
return
|
|
60
|
+
return db;
|
|
61
|
+
}
|
|
62
|
+
// Jobs write in place (their db is not transaction-bound); systemScope handlers reach both dbs via
|
|
63
|
+
// systemDb, because their ctx.db / ctx.dbOutsideTransaction are fail-closed proxies.
|
|
64
|
+
export function resolveCapCounterScope(ctx, outsideTransaction) {
|
|
65
|
+
// A job context spread into a handler-shaped one (user set) is a handler context: its user, not
|
|
66
|
+
// the job's systemUser, names the metered tenant.
|
|
67
|
+
if (!("user" in ctx) || !ctx.user) {
|
|
68
|
+
const jobUser = "systemUser" in ctx ? ctx.systemUser : undefined;
|
|
69
|
+
if (!jobUser) {
|
|
70
|
+
throw new Error("cap-counter: ctx.user.tenantId missing — required to resolve the counter");
|
|
71
|
+
}
|
|
72
|
+
const db = assertDbServesUser(ctx.db, jobUser);
|
|
73
|
+
return { user: jobUser, readDb: db, writeDb: db };
|
|
74
|
+
}
|
|
75
|
+
const { user } = ctx;
|
|
76
|
+
if (!user.tenantId) {
|
|
77
|
+
throw new Error("cap-counter: ctx.user.tenantId missing — required to resolve the counter");
|
|
78
|
+
}
|
|
79
|
+
if (ctx.systemDb) {
|
|
80
|
+
const readDb = ctx.systemDb.assertTenantMatch(user.tenantId);
|
|
81
|
+
const writeDb = outsideTransaction
|
|
82
|
+
? ctx.systemDb.outsideTransaction.assertTenantMatch(user.tenantId)
|
|
83
|
+
: readDb;
|
|
84
|
+
return { user, readDb, writeDb };
|
|
85
|
+
}
|
|
86
|
+
const writeDb = outsideTransaction ? ctx.dbOutsideTransaction : ctx.db;
|
|
87
|
+
if (!writeDb) {
|
|
88
|
+
throw new Error(outsideTransaction
|
|
89
|
+
? "cap-counter: outsideTransaction requested but ctx.dbOutsideTransaction is undefined"
|
|
90
|
+
: "cap-counter: ctx.db missing — run inside a handler context");
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
user,
|
|
94
|
+
readDb: assertDbServesUser(ctx.db, user),
|
|
95
|
+
writeDb: assertDbServesUser(writeDb, user),
|
|
96
|
+
};
|
|
56
97
|
}
|
|
57
98
|
export async function bookCapUsage(ctx, options) {
|
|
58
99
|
return applyCapDelta(ctx, options, "add");
|
|
@@ -96,19 +137,20 @@ export async function applyCapDeltaOn(db, user, options, direction) {
|
|
|
96
137
|
}, user, db);
|
|
97
138
|
}
|
|
98
139
|
async function applyCapDelta(ctx, options, direction) {
|
|
140
|
+
const { user, writeDb } = resolveCapCounterScope(ctx, options.outsideTransaction === true);
|
|
99
141
|
if (options.outsideTransaction) {
|
|
100
|
-
|
|
101
|
-
return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(outsideDb, (txDb) => applyCapDeltaOn(txDb, ctx.user, options, direction)));
|
|
142
|
+
return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(writeDb, (txDb) => applyCapDeltaOn(txDb, user, options, direction)));
|
|
102
143
|
}
|
|
103
|
-
return retryCounterWriteOnVersionConflict(() => applyCapDeltaOn(
|
|
144
|
+
return retryCounterWriteOnVersionConflict(() => applyCapDeltaOn(writeDb, user, options, direction));
|
|
104
145
|
}
|
|
105
146
|
export async function markCapSoftWarned(ctx, options) {
|
|
106
147
|
const parsed = capPeriodSchema.parse(options);
|
|
107
|
-
const
|
|
148
|
+
const { user, writeDb } = resolveCapCounterScope(ctx, options.outsideTransaction === true);
|
|
149
|
+
const aggregateId = capCounterAggregateId(user.tenantId, parsed.capName, parsed.periodStartIso);
|
|
108
150
|
async function attemptMark(db) {
|
|
109
151
|
const existing = await db.selectMany(table, { id: aggregateId }, { limit: 1 });
|
|
110
152
|
if (existing.length === 0) {
|
|
111
|
-
throw new Error(`cap-counter: cannot mark-soft-warned, no counter found for tenant=${
|
|
153
|
+
throw new Error(`cap-counter: cannot mark-soft-warned, no counter found for tenant=${user.tenantId} cap=${parsed.capName} period=${parsed.periodStartIso}`);
|
|
112
154
|
}
|
|
113
155
|
const row = existing[0];
|
|
114
156
|
if (!row) {
|
|
@@ -124,13 +166,12 @@ export async function markCapSoftWarned(ctx, options) {
|
|
|
124
166
|
id: aggregateId,
|
|
125
167
|
version: currentVersion,
|
|
126
168
|
changes: { lastSoftWarnedAt: Temporal.Now.instant() },
|
|
127
|
-
},
|
|
169
|
+
}, user, db);
|
|
128
170
|
}
|
|
129
171
|
if (options.outsideTransaction) {
|
|
130
|
-
|
|
131
|
-
return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(outsideDb, attemptMark));
|
|
172
|
+
return retryCounterWriteOnVersionConflict(() => runInOwnTransaction(writeDb, attemptMark));
|
|
132
173
|
}
|
|
133
|
-
return retryCounterWriteOnVersionConflict(() => attemptMark(
|
|
174
|
+
return retryCounterWriteOnVersionConflict(() => attemptMark(writeDb));
|
|
134
175
|
}
|
|
135
176
|
async function sumRollingEventAmounts(db, tenantId, options, type) {
|
|
136
177
|
const aggregateId = rollingCapAggregateId(tenantId, options.capName);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CapCounterContext } from "./book-cap-usage.js";
|
|
2
|
+
import { type CalendarCapReservation } from "./cap-reservation.js";
|
|
3
|
+
import { type SoftHitNotifier } from "./enforce-cap.js";
|
|
4
|
+
import type { CalendarCapDef } from "./with-cap-enforcement.js";
|
|
5
|
+
export type CalendarCapUsageReservationSpec = Omit<CalendarCapDef, "amount" | "notify"> & {
|
|
6
|
+
readonly amount: number;
|
|
7
|
+
readonly notify?: SoftHitNotifier;
|
|
8
|
+
readonly outsideTransaction?: boolean;
|
|
9
|
+
};
|
|
10
|
+
export type CalendarCapUsageReservation = {
|
|
11
|
+
readonly amount: number;
|
|
12
|
+
readonly counterValue: number;
|
|
13
|
+
settle(actualAmount: number): Promise<void>;
|
|
14
|
+
release(): Promise<void>;
|
|
15
|
+
};
|
|
16
|
+
export declare function reserveEnforcedCalendarCap(ctx: CapCounterContext, cap: CalendarCapUsageReservationSpec): Promise<CalendarCapReservation>;
|
|
17
|
+
export declare function reserveCalendarCapUsage(ctx: CapCounterContext, spec: CalendarCapUsageReservationSpec): Promise<CalendarCapUsageReservation>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { releaseExpiredCapReservations, reserveCalendarCap, settleCalendarCapReservation, } from "./cap-reservation.js";
|
|
2
|
+
import { assertBelowHardCap, enforceCapAndMaybeNotify, } from "./enforce-cap.js";
|
|
3
|
+
// Shared by withCapEnforcement and the public reservation API so both enforce identically.
|
|
4
|
+
export async function reserveEnforcedCalendarCap(ctx, cap) {
|
|
5
|
+
const outsideTransaction = cap.outsideTransaction ?? true;
|
|
6
|
+
await releaseExpiredCapReservations(ctx, cap.capName, outsideTransaction);
|
|
7
|
+
if (cap.notify) {
|
|
8
|
+
await enforceCapAndMaybeNotify(ctx, {
|
|
9
|
+
capName: cap.capName,
|
|
10
|
+
periodStartIso: cap.periodStartIso,
|
|
11
|
+
limit: cap.limit,
|
|
12
|
+
profile: cap.profile,
|
|
13
|
+
notify: cap.notify,
|
|
14
|
+
amount: cap.amount,
|
|
15
|
+
markSoftWarnedOutsideTransaction: outsideTransaction,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return reserveCalendarCap(ctx, {
|
|
19
|
+
capName: cap.capName,
|
|
20
|
+
amount: cap.amount,
|
|
21
|
+
periodStartIso: cap.periodStartIso,
|
|
22
|
+
guardCurrentValue: (currentValue) => assertBelowHardCap(currentValue + cap.amount - 1, cap),
|
|
23
|
+
}, outsideTransaction);
|
|
24
|
+
}
|
|
25
|
+
export async function reserveCalendarCapUsage(ctx, spec) {
|
|
26
|
+
const outsideTransaction = spec.outsideTransaction ?? true;
|
|
27
|
+
const reservation = await reserveEnforcedCalendarCap(ctx, spec);
|
|
28
|
+
let state = "open";
|
|
29
|
+
return {
|
|
30
|
+
amount: spec.amount,
|
|
31
|
+
counterValue: reservation.counterValue,
|
|
32
|
+
async settle(actualAmount) {
|
|
33
|
+
if (state !== "open") {
|
|
34
|
+
throw new Error(`cap-counter: reservation for "${spec.capName}" is already ${state}`);
|
|
35
|
+
}
|
|
36
|
+
state = "settled";
|
|
37
|
+
try {
|
|
38
|
+
await settleCalendarCapReservation(ctx, reservation.reservationId, { capName: spec.capName, periodStartIso: spec.periodStartIso, actualAmount }, outsideTransaction);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
state = "open";
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
async release() {
|
|
46
|
+
// skip: release after settle, or a second release, is a no-op by contract.
|
|
47
|
+
if (state !== "open")
|
|
48
|
+
return;
|
|
49
|
+
await reservation.release();
|
|
50
|
+
state = "released";
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -1,12 +1,21 @@
|
|
|
1
|
-
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
1
|
import type { ReservationHandle } from "@cosmicdrift/kumiko-types/handlers";
|
|
3
|
-
import { type BookCapUsageOptions, type ReadRollingCapUsageOptions } from "./book-cap-usage.js";
|
|
2
|
+
import { type BookCapUsageOptions, type CapCounterContext, type ReadRollingCapUsageOptions } from "./book-cap-usage.js";
|
|
3
|
+
export type CalendarCapReservation = ReservationHandle & {
|
|
4
|
+
readonly reservationId: string;
|
|
5
|
+
readonly counterValue: number;
|
|
6
|
+
};
|
|
4
7
|
export type ReserveCalendarCapOptions = Required<Pick<BookCapUsageOptions, "capName" | "periodStartIso" | "amount">> & Pick<BookCapUsageOptions, "guardCurrentValue">;
|
|
5
|
-
export declare function reserveCalendarCap(ctx:
|
|
8
|
+
export declare function reserveCalendarCap(ctx: CapCounterContext, options: ReserveCalendarCapOptions, outsideTransaction?: boolean): Promise<CalendarCapReservation>;
|
|
6
9
|
export type ReserveRollingCapOptions = ReadRollingCapUsageOptions & {
|
|
7
10
|
readonly amount: number;
|
|
8
11
|
readonly guardCurrentValue?: (currentValue: number) => void;
|
|
9
12
|
};
|
|
10
|
-
export declare function reserveRollingCap(ctx:
|
|
11
|
-
export declare function releaseCapReservation(ctx:
|
|
12
|
-
export
|
|
13
|
+
export declare function reserveRollingCap(ctx: CapCounterContext, options: ReserveRollingCapOptions, outsideTransaction?: boolean): Promise<ReservationHandle>;
|
|
14
|
+
export declare function releaseCapReservation(ctx: CapCounterContext, id: string, outsideTransaction?: boolean): Promise<void>;
|
|
15
|
+
export type SettleCalendarCapOptions = {
|
|
16
|
+
readonly capName: string;
|
|
17
|
+
readonly periodStartIso: string;
|
|
18
|
+
readonly actualAmount: number;
|
|
19
|
+
};
|
|
20
|
+
export declare function settleCalendarCapReservation(ctx: CapCounterContext, id: string, options: SettleCalendarCapOptions, outsideTransaction?: boolean): Promise<void>;
|
|
21
|
+
export declare function releaseExpiredCapReservations(ctx: CapCounterContext, capName: string, outsideTransaction?: boolean): Promise<void>;
|
|
@@ -4,14 +4,15 @@ import { appendEventInTenantDb, getStreamVersionInTenantDb, } from "@cosmicdrift
|
|
|
4
4
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
5
5
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
6
6
|
import { rollingCapAggregateId } from "./aggregate-id.js";
|
|
7
|
-
import { applyCapDeltaOn, readRollingCapUsage,
|
|
7
|
+
import { applyCapDeltaOn, readRollingCapUsage, resolveCapCounterScope, retryCounterWriteOnVersionConflict, retryOnStreamVersionConflict, } from "./book-cap-usage.js";
|
|
8
8
|
import { CAP_COUNTER_ROLLING_AGGREGATE_TYPE, CAP_RESERVATION_TTL_MINUTES, ROLLING_INCREMENTED_EVENT_QN, ROLLING_RELEASED_EVENT_QN, } from "./constants.js";
|
|
9
9
|
import { capReservationsTable } from "./tables.js";
|
|
10
10
|
const NOTHING_TO_RELEASE = { isSuccess: true, data: {} };
|
|
11
|
-
async function insertReservationRow(txDb, id, draft) {
|
|
11
|
+
async function insertReservationRow(txDb, user, id, draft) {
|
|
12
12
|
const now = Temporal.Now.instant();
|
|
13
13
|
await txDb.insertOne(capReservationsTable, {
|
|
14
14
|
id,
|
|
15
|
+
tenantId: user.tenantId,
|
|
15
16
|
capName: draft.capName,
|
|
16
17
|
kind: draft.kind,
|
|
17
18
|
periodStartIso: draft.periodStartIso ?? null,
|
|
@@ -20,22 +21,31 @@ async function insertReservationRow(txDb, id, draft) {
|
|
|
20
21
|
createdAt: now,
|
|
21
22
|
});
|
|
22
23
|
}
|
|
23
|
-
function toHandle(ctx, id) {
|
|
24
|
+
function toHandle(ctx, id, outsideTransaction) {
|
|
24
25
|
return {
|
|
25
|
-
release: () => releaseCapReservation(ctx, id),
|
|
26
|
+
release: () => releaseCapReservation(ctx, id, outsideTransaction),
|
|
26
27
|
confirmInTransaction: (confirm) => confirmCapReservation(confirm, id),
|
|
27
28
|
};
|
|
28
29
|
}
|
|
29
30
|
// The counter increment and the reservation row commit together, so a booked amount never exists
|
|
30
31
|
// without a row that can give it back.
|
|
31
|
-
export async function reserveCalendarCap(ctx, options) {
|
|
32
|
-
const
|
|
32
|
+
export async function reserveCalendarCap(ctx, options, outsideTransaction = true) {
|
|
33
|
+
const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
|
|
33
34
|
const id = generateId();
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
// The last attempt is the one that committed, so this holds the value its increment started from.
|
|
36
|
+
let valueBeforeBooking = 0;
|
|
37
|
+
const observedOptions = {
|
|
38
|
+
...options,
|
|
39
|
+
guardCurrentValue: (currentValue) => {
|
|
40
|
+
options.guardCurrentValue?.(currentValue);
|
|
41
|
+
valueBeforeBooking = currentValue;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
const reserved = await retryCounterWriteOnVersionConflict(() => runInOwnTransaction(writeDb, async (txDb) => {
|
|
45
|
+
const booked = await applyCapDeltaOn(txDb, user, observedOptions, "add");
|
|
36
46
|
if (!booked.isSuccess)
|
|
37
47
|
return booked;
|
|
38
|
-
await insertReservationRow(txDb, id, {
|
|
48
|
+
await insertReservationRow(txDb, user, id, {
|
|
39
49
|
capName: options.capName,
|
|
40
50
|
kind: "calendar",
|
|
41
51
|
periodStartIso: options.periodStartIso,
|
|
@@ -45,7 +55,11 @@ export async function reserveCalendarCap(ctx, options) {
|
|
|
45
55
|
}));
|
|
46
56
|
if (!reserved.isSuccess)
|
|
47
57
|
throw reraiseAsKumikoError(reserved.error);
|
|
48
|
-
return
|
|
58
|
+
return {
|
|
59
|
+
...toHandle(ctx, id, outsideTransaction),
|
|
60
|
+
reservationId: id,
|
|
61
|
+
counterValue: valueBeforeBooking + options.amount,
|
|
62
|
+
};
|
|
49
63
|
}
|
|
50
64
|
function rollingEvent(user, capName, amount, expectedVersion, type) {
|
|
51
65
|
return {
|
|
@@ -59,23 +73,23 @@ function rollingEvent(user, capName, amount, expectedVersion, type) {
|
|
|
59
73
|
}
|
|
60
74
|
// The append is version-guarded: a booker that read the usage before another one appended loses the
|
|
61
75
|
// race and repeats the whole attempt, so the hard-cap check always sees the stream it appends to.
|
|
62
|
-
export async function reserveRollingCap(ctx, options) {
|
|
63
|
-
const
|
|
76
|
+
export async function reserveRollingCap(ctx, options, outsideTransaction = true) {
|
|
77
|
+
const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
|
|
64
78
|
const id = generateId();
|
|
65
|
-
const aggregateId = rollingCapAggregateId(
|
|
66
|
-
await retryOnStreamVersionConflict(() => runInOwnTransaction(
|
|
79
|
+
const aggregateId = rollingCapAggregateId(user.tenantId, options.capName);
|
|
80
|
+
await retryOnStreamVersionConflict(() => runInOwnTransaction(writeDb, async (txDb) => {
|
|
67
81
|
const version = await getStreamVersionInTenantDb(txDb, aggregateId);
|
|
68
|
-
const usage = await readRollingCapUsage(txDb,
|
|
82
|
+
const usage = await readRollingCapUsage(txDb, user.tenantId, options);
|
|
69
83
|
options.guardCurrentValue?.(usage);
|
|
70
|
-
await appendEventInTenantDb(txDb, rollingEvent(
|
|
71
|
-
await insertReservationRow(txDb, id, {
|
|
84
|
+
await appendEventInTenantDb(txDb, rollingEvent(user, options.capName, options.amount, version, ROLLING_INCREMENTED_EVENT_QN));
|
|
85
|
+
await insertReservationRow(txDb, user, id, {
|
|
72
86
|
capName: options.capName,
|
|
73
87
|
kind: "rolling",
|
|
74
88
|
amount: options.amount,
|
|
75
89
|
});
|
|
76
90
|
return NOTHING_TO_RELEASE;
|
|
77
91
|
}));
|
|
78
|
-
return toHandle(ctx, id);
|
|
92
|
+
return toHandle(ctx, id, outsideTransaction);
|
|
79
93
|
}
|
|
80
94
|
// Deleting the row takes its row lock inside the handler transaction, so a release started after a
|
|
81
95
|
// failed COMMIT waits for the real outcome and finds the row gone when the commit went through.
|
|
@@ -94,39 +108,74 @@ async function giveBackRolling(txDb, user, row) {
|
|
|
94
108
|
return NOTHING_TO_RELEASE;
|
|
95
109
|
}
|
|
96
110
|
async function giveBackReservation(txDb, user, id) {
|
|
97
|
-
const
|
|
111
|
+
const ownRow = { id, tenantId: user.tenantId };
|
|
112
|
+
const row = await txDb.fetchOne(capReservationsTable, ownRow);
|
|
98
113
|
if (!row)
|
|
99
114
|
return NOTHING_TO_RELEASE;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (claimed.length === 0)
|
|
115
|
+
const claimed = await claimReservationRow(txDb, user, row);
|
|
116
|
+
if (!claimed)
|
|
103
117
|
return NOTHING_TO_RELEASE;
|
|
104
118
|
const given = row.kind === "rolling"
|
|
105
119
|
? await giveBackRolling(txDb, user, row)
|
|
106
120
|
: await giveBackCalendar(txDb, user, row);
|
|
107
121
|
if (!given.isSuccess)
|
|
108
122
|
return given;
|
|
109
|
-
await txDb.deleteMany(capReservationsTable,
|
|
123
|
+
await txDb.deleteMany(capReservationsTable, ownRow);
|
|
110
124
|
return given;
|
|
111
125
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
126
|
+
// Waits for a confirm still holding the row; false means that confirm (or another release) won.
|
|
127
|
+
async function claimReservationRow(txDb, user, row) {
|
|
128
|
+
const claimed = await txDb.updateMany(capReservationsTable, { expiresAt: row.expiresAt }, { id: row.id, tenantId: user.tenantId });
|
|
129
|
+
return claimed.length > 0;
|
|
130
|
+
}
|
|
131
|
+
export async function releaseCapReservation(ctx, id, outsideTransaction = true) {
|
|
132
|
+
const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
|
|
133
|
+
const released = await retryOnStreamVersionConflict(() => retryCounterWriteOnVersionConflict(() => runInOwnTransaction(writeDb, (txDb) => giveBackReservation(txDb, user, id))));
|
|
115
134
|
if (!released.isSuccess)
|
|
116
135
|
throw reraiseAsKumikoError(released.error);
|
|
117
136
|
}
|
|
137
|
+
function bookCalendarDelta(txDb, user, period, delta) {
|
|
138
|
+
if (delta === 0)
|
|
139
|
+
return Promise.resolve({ isSuccess: true, data: {} });
|
|
140
|
+
return applyCapDeltaOn(txDb, user, { ...period, amount: Math.abs(delta) }, delta > 0 ? "add" : "subtract");
|
|
141
|
+
}
|
|
142
|
+
// Replaces the reserved amount by the actual one in a single transaction. When the row is already
|
|
143
|
+
// gone (swept or released), the reserved amount was given back, so the full actual amount is booked.
|
|
144
|
+
export async function settleCalendarCapReservation(ctx, id, options, outsideTransaction = true) {
|
|
145
|
+
if (!Number.isInteger(options.actualAmount) || options.actualAmount < 0) {
|
|
146
|
+
throw new Error(`cap-counter: settle amount must be a non-negative integer, got ${options.actualAmount}`);
|
|
147
|
+
}
|
|
148
|
+
const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
|
|
149
|
+
const settled = await retryCounterWriteOnVersionConflict(() => runInOwnTransaction(writeDb, async (txDb) => {
|
|
150
|
+
const row = await txDb.fetchOne(capReservationsTable, {
|
|
151
|
+
id,
|
|
152
|
+
tenantId: user.tenantId,
|
|
153
|
+
});
|
|
154
|
+
const claimed = row ? await claimReservationRow(txDb, user, row) : false;
|
|
155
|
+
if (!row || !claimed) {
|
|
156
|
+
return bookCalendarDelta(txDb, user, options, options.actualAmount);
|
|
157
|
+
}
|
|
158
|
+
const booked = await bookCalendarDelta(txDb, user, options, options.actualAmount - row.amount);
|
|
159
|
+
if (!booked.isSuccess)
|
|
160
|
+
return booked;
|
|
161
|
+
await txDb.deleteMany(capReservationsTable, { id, tenantId: user.tenantId });
|
|
162
|
+
return booked;
|
|
163
|
+
}));
|
|
164
|
+
if (!settled.isSuccess)
|
|
165
|
+
throw reraiseAsKumikoError(settled.error);
|
|
166
|
+
}
|
|
118
167
|
// A failed release leaves its row behind; giving it back once the TTL passed bounds how long that
|
|
119
168
|
// capacity stays counted.
|
|
120
|
-
export async function releaseExpiredCapReservations(ctx, capName) {
|
|
121
|
-
const
|
|
122
|
-
const expired = await
|
|
123
|
-
tenantId:
|
|
169
|
+
export async function releaseExpiredCapReservations(ctx, capName, outsideTransaction = true) {
|
|
170
|
+
const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
|
|
171
|
+
const expired = await writeDb.selectMany(capReservationsTable, {
|
|
172
|
+
tenantId: user.tenantId,
|
|
124
173
|
capName,
|
|
125
174
|
expiresAt: { lt: Temporal.Now.instant() },
|
|
126
175
|
});
|
|
127
176
|
for (const { id } of expired) {
|
|
128
177
|
try {
|
|
129
|
-
await releaseCapReservation(ctx, id);
|
|
178
|
+
await releaseCapReservation(ctx, id, outsideTransaction);
|
|
130
179
|
}
|
|
131
180
|
catch (error) {
|
|
132
181
|
ctx.log?.error("releasing an expired cap reservation failed", { id, capName, error });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
1
|
import { KumikoError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
2
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
3
|
+
import { type CapCounterContext } from "./book-cap-usage.js";
|
|
4
4
|
/**
|
|
5
5
|
* Cap-Toleranz-Profile — pro Cap-Typ asymmetrisch. Quelle: Memory
|
|
6
6
|
* `project_pricing_byok_caps` §3 + `docs/plans/marketing/produkt/
|
|
@@ -79,7 +79,7 @@ export declare function assertBelowHardCap(valueSeenByLastUnit: number, cap: {
|
|
|
79
79
|
* (apps-count) need stricter serialization at the create-handler
|
|
80
80
|
* level (e.g. uniqueness-index on apps.tenantId+slot-number).
|
|
81
81
|
*/
|
|
82
|
-
export declare function enforceCap(ctx:
|
|
82
|
+
export declare function enforceCap(ctx: CapCounterContext, options: {
|
|
83
83
|
readonly capName: string;
|
|
84
84
|
readonly periodStartIso: string;
|
|
85
85
|
readonly limit: number;
|
|
@@ -110,7 +110,7 @@ export declare function enforceCap(ctx: HandlerContext, options: {
|
|
|
110
110
|
* Projection mit pre-aggregierten daily-buckets. Heute nicht
|
|
111
111
|
* vorgezogen.
|
|
112
112
|
*/
|
|
113
|
-
export declare function enforceRollingCap(ctx:
|
|
113
|
+
export declare function enforceRollingCap(ctx: CapCounterContext, options: {
|
|
114
114
|
readonly capName: string;
|
|
115
115
|
readonly windowDays: number;
|
|
116
116
|
readonly limit: number;
|
|
@@ -162,7 +162,7 @@ export type SoftHitNotifier = (info: {
|
|
|
162
162
|
* verzweigen (z.B. UI-Toast bei soft-hit zusätzlich zur
|
|
163
163
|
* Email-Notification).
|
|
164
164
|
*/
|
|
165
|
-
export declare function enforceCapAndMaybeNotify(ctx:
|
|
165
|
+
export declare function enforceCapAndMaybeNotify(ctx: CapCounterContext, options: {
|
|
166
166
|
readonly capName: string;
|
|
167
167
|
readonly periodStartIso: string;
|
|
168
168
|
readonly limit: number;
|
|
@@ -181,7 +181,7 @@ export declare function enforceCapAndMaybeNotify(ctx: HandlerContext, options: {
|
|
|
181
181
|
* oder er ruft `enforceRollingCapAndMaybeNotify` nur einmal pro
|
|
182
182
|
* Tag/Stunde auf (Hourly-Cron statt pro Request).
|
|
183
183
|
*/
|
|
184
|
-
export declare function enforceRollingCapAndMaybeNotify(ctx:
|
|
184
|
+
export declare function enforceRollingCapAndMaybeNotify(ctx: CapCounterContext, options: {
|
|
185
185
|
readonly capName: string;
|
|
186
186
|
readonly windowDays: number;
|
|
187
187
|
readonly limit: number;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { KumikoError, reraiseAsKumikoError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
4
|
-
import { markCapSoftWarned, readRollingCapUsage } from "./book-cap-usage.js";
|
|
4
|
+
import { markCapSoftWarned, readRollingCapUsage, resolveCapCounterScope, } from "./book-cap-usage.js";
|
|
5
5
|
import { capCounterEntity } from "./entity.js";
|
|
6
6
|
const { table } = createEntityExecutor("cap-counter", capCounterEntity);
|
|
7
7
|
export const CAP_TOLERANCES = {
|
|
@@ -45,16 +45,14 @@ export function assertBelowHardCap(valueSeenByLastUnit, cap) {
|
|
|
45
45
|
* level (e.g. uniqueness-index on apps.tenantId+slot-number).
|
|
46
46
|
*/
|
|
47
47
|
export async function enforceCap(ctx, options) {
|
|
48
|
-
|
|
49
|
-
throw new Error("cap-counter.enforceCap: ctx.db missing — run inside a handler context");
|
|
50
|
-
}
|
|
48
|
+
const { user, readDb } = resolveCapCounterScope(ctx, false);
|
|
51
49
|
const amount = options.amount ?? 1;
|
|
52
50
|
if (!Number.isInteger(amount) || amount < 1) {
|
|
53
51
|
throw new Error(`cap-counter.enforceCap: amount must be a positive integer, got ${options.amount}`);
|
|
54
52
|
}
|
|
55
53
|
const tolerance = CAP_TOLERANCES[options.profile];
|
|
56
54
|
const softThreshold = options.limit * tolerance.soft;
|
|
57
|
-
const rows = await
|
|
55
|
+
const rows = await readDb.selectMany(table, { tenantId: user.tenantId, capName: options.capName, periodStart: options.periodStartIso }, { limit: 1 });
|
|
58
56
|
const row = rows[0];
|
|
59
57
|
const storedValue = row ? row["value"] : 0; // @cast-boundary db-row
|
|
60
58
|
// The last of `amount` units sees this value before its own increment.
|
|
@@ -94,16 +92,11 @@ export async function enforceCap(ctx, options) {
|
|
|
94
92
|
* vorgezogen.
|
|
95
93
|
*/
|
|
96
94
|
export async function enforceRollingCap(ctx, options) {
|
|
97
|
-
|
|
98
|
-
throw new Error("cap-counter.enforceRollingCap: ctx.db missing — run inside a handler context");
|
|
99
|
-
}
|
|
100
|
-
if (!ctx.user?.tenantId) {
|
|
101
|
-
throw new Error("cap-counter.enforceRollingCap: ctx.user.tenantId missing — required to compute aggregate-id");
|
|
102
|
-
}
|
|
95
|
+
const { user, readDb } = resolveCapCounterScope(ctx, false);
|
|
103
96
|
const tolerance = CAP_TOLERANCES[options.profile];
|
|
104
97
|
const softThreshold = options.limit * tolerance.soft;
|
|
105
98
|
const hardThreshold = options.limit * tolerance.hard;
|
|
106
|
-
const value = await readRollingCapUsage(
|
|
99
|
+
const value = await readRollingCapUsage(readDb, user.tenantId, {
|
|
107
100
|
capName: options.capName,
|
|
108
101
|
windowDays: options.windowDays,
|
|
109
102
|
});
|
|
@@ -184,14 +177,11 @@ export async function enforceCapAndMaybeNotify(ctx, options) {
|
|
|
184
177
|
...(options.amount !== undefined && { amount: options.amount }),
|
|
185
178
|
});
|
|
186
179
|
if (result.state === "soft-hit" && result.crossed) {
|
|
187
|
-
if (!ctx.user?.tenantId) {
|
|
188
|
-
throw new Error("cap-counter.enforceCapAndMaybeNotify: ctx.user.tenantId missing — required for notification");
|
|
189
|
-
}
|
|
190
180
|
await options.notify({
|
|
191
181
|
capName: options.capName,
|
|
192
182
|
value: result.value,
|
|
193
183
|
limit: options.limit,
|
|
194
|
-
tenantId: ctx.user.tenantId,
|
|
184
|
+
tenantId: resolveCapCounterScope(ctx, false).user.tenantId,
|
|
195
185
|
});
|
|
196
186
|
// Flip the soft-warned flag so the same period doesn't re-notify.
|
|
197
187
|
const marked = await markCapSoftWarned(ctx, {
|
|
@@ -222,14 +212,11 @@ export async function enforceRollingCapAndMaybeNotify(ctx, options) {
|
|
|
222
212
|
profile: options.profile,
|
|
223
213
|
});
|
|
224
214
|
if (result.state === "soft-hit") {
|
|
225
|
-
if (!ctx.user?.tenantId) {
|
|
226
|
-
throw new Error("cap-counter.enforceRollingCapAndMaybeNotify: ctx.user.tenantId missing — required for notification");
|
|
227
|
-
}
|
|
228
215
|
await options.notify({
|
|
229
216
|
capName: options.capName,
|
|
230
217
|
value: result.value,
|
|
231
218
|
limit: options.limit,
|
|
232
|
-
tenantId: ctx.user.tenantId,
|
|
219
|
+
tenantId: resolveCapCounterScope(ctx, false).user.tenantId,
|
|
233
220
|
});
|
|
234
221
|
}
|
|
235
222
|
return result;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { capCounterAggregateId, rollingCapAggregateId } from "./aggregate-id.js";
|
|
2
2
|
export { type BookCapUsageOptions, bookCapUsage, type MarkCapSoftWarnedOptions, markCapSoftWarned, type ReadRollingCapUsageOptions, readRollingCapUsage, } from "./book-cap-usage.js";
|
|
3
|
+
export { type CalendarCapUsageReservation, type CalendarCapUsageReservationSpec, reserveCalendarCapUsage, } from "./calendar-cap-usage-reservation.js";
|
|
3
4
|
export { CAP_COUNTER_FEATURE, CAP_COUNTER_ROLLING_AGGREGATE_TYPE, CapCounterHandlers, CapCounterQueries, ROLLING_INCREMENTED_EVENT_QN, ROLLING_INCREMENTED_EVENT_SHORT, ROLLING_RELEASED_EVENT_QN, ROLLING_RELEASED_EVENT_SHORT, } from "./constants.js";
|
|
4
5
|
export { CAP_TOLERANCES, CapExceededError, type CapToleranceProfile, type CapToleranceProfileName, currentCalendarMonthStartIso, type EnforceCapResult, enforceCap, enforceCapAndMaybeNotify, enforceRollingCap, enforceRollingCapAndMaybeNotify, enforceStockCap, type SoftHitNotifier, type StockCapResult, } from "./enforce-cap.js";
|
|
5
6
|
export { capCounterEntity } from "./entity.js";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Public API of the cap-counter bundled-feature.
|
|
2
2
|
export { capCounterAggregateId, rollingCapAggregateId } from "./aggregate-id.js";
|
|
3
3
|
export { bookCapUsage, markCapSoftWarned, readRollingCapUsage, } from "./book-cap-usage.js";
|
|
4
|
+
export { reserveCalendarCapUsage, } from "./calendar-cap-usage-reservation.js";
|
|
4
5
|
export { CAP_COUNTER_FEATURE, CAP_COUNTER_ROLLING_AGGREGATE_TYPE, CapCounterHandlers, CapCounterQueries, ROLLING_INCREMENTED_EVENT_QN, ROLLING_INCREMENTED_EVENT_SHORT, ROLLING_RELEASED_EVENT_QN, ROLLING_RELEASED_EVENT_SHORT, } from "./constants.js";
|
|
5
6
|
export { CAP_TOLERANCES, CapExceededError, currentCalendarMonthStartIso, enforceCap, enforceCapAndMaybeNotify, enforceRollingCap, enforceRollingCapAndMaybeNotify, enforceStockCap, } from "./enforce-cap.js";
|
|
6
7
|
export { capCounterEntity } from "./entity.js";
|
|
@@ -23,8 +23,9 @@
|
|
|
23
23
|
//
|
|
24
24
|
// No automatic markSoftWarned here — that's inside enforceCapAndMaybeNotify
|
|
25
25
|
// (enforce-cap.ts).
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
26
|
+
import { reserveEnforcedCalendarCap } from "./calendar-cap-usage-reservation.js";
|
|
27
|
+
import { releaseExpiredCapReservations, reserveRollingCap } from "./cap-reservation.js";
|
|
28
|
+
import { assertBelowHardCap, enforceRollingCapAndMaybeNotify, } from "./enforce-cap.js";
|
|
28
29
|
import { chainReservations } from "./reservation-chain.js";
|
|
29
30
|
/**
|
|
30
31
|
* Wrap a write-handler with calendar-period cap-enforcement.
|
|
@@ -41,23 +42,7 @@ export function withCapEnforcement(handler, capResolver) {
|
|
|
41
42
|
...handler,
|
|
42
43
|
reserveBeforeTransaction: chainReservations(handler.reserveBeforeTransaction, async (event, ctx) => {
|
|
43
44
|
const cap = await capResolver(event, ctx);
|
|
44
|
-
|
|
45
|
-
await enforceCapAndMaybeNotify(ctx, {
|
|
46
|
-
capName: cap.capName,
|
|
47
|
-
periodStartIso: cap.periodStartIso,
|
|
48
|
-
limit: cap.limit,
|
|
49
|
-
profile: cap.profile,
|
|
50
|
-
notify: cap.notify,
|
|
51
|
-
markSoftWarnedOutsideTransaction: true,
|
|
52
|
-
...(cap.amount !== undefined && { amount: cap.amount }),
|
|
53
|
-
});
|
|
54
|
-
const amount = cap.amount ?? 1;
|
|
55
|
-
return reserveCalendarCap(ctx, {
|
|
56
|
-
capName: cap.capName,
|
|
57
|
-
amount,
|
|
58
|
-
periodStartIso: cap.periodStartIso,
|
|
59
|
-
guardCurrentValue: (currentValue) => assertBelowHardCap(currentValue + amount - 1, cap),
|
|
60
|
-
});
|
|
45
|
+
return reserveEnforcedCalendarCap(ctx, { ...cap, amount: cap.amount ?? 1 });
|
|
61
46
|
}),
|
|
62
47
|
};
|
|
63
48
|
}
|
|
@@ -229,7 +229,6 @@ async function sweepDerivedSubjectData(ctx, subject, subjectKey, crossTenantRunn
|
|
|
229
229
|
if (ctx.registry.features.has("notes-history")) {
|
|
230
230
|
await eraseNoteBodiesReachingSubject({
|
|
231
231
|
db: crossTenantSubjectRunner,
|
|
232
|
-
registry: ctx.registry,
|
|
233
232
|
subject,
|
|
234
233
|
erasedBy: audit.erasedBy,
|
|
235
234
|
requestId: audit.requestId,
|
|
@@ -4,13 +4,14 @@ import { fieldDefinitionAggregateId } from "../aggregate-id.js";
|
|
|
4
4
|
import { DEFAULT_FIELD_DEFINITION_WRITE_ROLES } from "../constants.js";
|
|
5
5
|
import { fieldDefinitionExecutor } from "../executor.js";
|
|
6
6
|
import { buildFieldDefinitionColumns } from "../lib/field-definition-row.js";
|
|
7
|
-
import { updateFieldPayloadSchema } from "../schemas.js";
|
|
7
|
+
import { fieldTypeSchema, updateFieldPayloadSchema } from "../schemas.js";
|
|
8
8
|
export function createUpdateTenantFieldHandler(opts = {}) {
|
|
9
9
|
return {
|
|
10
10
|
name: "update-tenant-field",
|
|
11
11
|
schema: updateFieldPayloadSchema,
|
|
12
12
|
access: { roles: opts.roles ?? DEFAULT_FIELD_DEFINITION_WRITE_ROLES },
|
|
13
|
-
description: "Replaces a tenant-owned custom-field definition with a complete new spec
|
|
13
|
+
description: "Replaces a tenant-owned custom-field definition with a complete new spec; serializedField.type may be omitted, in which case the stored type applies, and a different type is refused. Use it to relabel or re-constrain an existing field rather than to create or remove one.",
|
|
14
|
+
excludedFields: ["type"],
|
|
14
15
|
handler: async (event, ctx) => {
|
|
15
16
|
const payload = event.payload; // @cast-boundary engine-payload
|
|
16
17
|
const tenantId = event.user.tenantId;
|
|
@@ -22,16 +23,24 @@ export function createUpdateTenantFieldHandler(opts = {}) {
|
|
|
22
23
|
if (!existing) {
|
|
23
24
|
return failNotFound("field-definition", aggregateId);
|
|
24
25
|
}
|
|
25
|
-
|
|
26
|
+
const storedType = fieldTypeSchema.safeParse(existing["type"]);
|
|
27
|
+
if (!storedType.success) {
|
|
28
|
+
throw new Error(`update-tenant-field: stored definition ${aggregateId} has an invalid type`);
|
|
29
|
+
}
|
|
30
|
+
const requestedType = payload.serializedField.type;
|
|
31
|
+
if (requestedType !== undefined && requestedType !== storedType.data) {
|
|
26
32
|
return failUnprocessable("field_type_immutable", {
|
|
27
33
|
entityName: payload.entityName,
|
|
28
34
|
fieldKey: payload.fieldKey,
|
|
29
|
-
currentType:
|
|
30
|
-
requestedType
|
|
35
|
+
currentType: storedType.data,
|
|
36
|
+
requestedType,
|
|
31
37
|
});
|
|
32
38
|
}
|
|
33
39
|
// entityName/fieldKey sind die Identität — nicht Teil der changes.
|
|
34
|
-
const { entityName: _entityName, fieldKey: _fieldKey, ...changes } = buildFieldDefinitionColumns(
|
|
40
|
+
const { entityName: _entityName, fieldKey: _fieldKey, ...changes } = buildFieldDefinitionColumns({
|
|
41
|
+
...payload,
|
|
42
|
+
serializedField: { ...payload.serializedField, type: storedType.data },
|
|
43
|
+
});
|
|
35
44
|
return fieldDefinitionExecutor.update({ id: aggregateId, changes }, event.user, ctx.db, {
|
|
36
45
|
skipOptimisticLock: true,
|
|
37
46
|
});
|
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
|
+
export declare const fieldTypeSchema: z.ZodEnum<{
|
|
3
|
+
boolean: "boolean";
|
|
4
|
+
date: "date";
|
|
5
|
+
embedded: "embedded";
|
|
6
|
+
enum: "enum";
|
|
7
|
+
money: "money";
|
|
8
|
+
number: "number";
|
|
9
|
+
text: "text";
|
|
10
|
+
}>;
|
|
2
11
|
export declare const customFieldAccessSchema: z.ZodOptional<z.ZodObject<{
|
|
3
12
|
read: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
4
13
|
write: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
@@ -8,15 +17,6 @@ export declare const defineFieldPayloadSchema: z.ZodObject<{
|
|
|
8
17
|
entityName: z.ZodString;
|
|
9
18
|
fieldKey: z.ZodString;
|
|
10
19
|
serializedField: z.ZodObject<{
|
|
11
|
-
type: z.ZodEnum<{
|
|
12
|
-
boolean: "boolean";
|
|
13
|
-
date: "date";
|
|
14
|
-
embedded: "embedded";
|
|
15
|
-
enum: "enum";
|
|
16
|
-
money: "money";
|
|
17
|
-
number: "number";
|
|
18
|
-
text: "text";
|
|
19
|
-
}>;
|
|
20
20
|
fieldAccess: z.ZodOptional<z.ZodObject<{
|
|
21
21
|
read: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
22
22
|
write: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
@@ -28,6 +28,15 @@ export declare const defineFieldPayloadSchema: z.ZodObject<{
|
|
|
28
28
|
delete: "delete";
|
|
29
29
|
}>;
|
|
30
30
|
}, z.core.$strip>>;
|
|
31
|
+
type: z.ZodEnum<{
|
|
32
|
+
boolean: "boolean";
|
|
33
|
+
date: "date";
|
|
34
|
+
embedded: "embedded";
|
|
35
|
+
enum: "enum";
|
|
36
|
+
money: "money";
|
|
37
|
+
number: "number";
|
|
38
|
+
text: "text";
|
|
39
|
+
}>;
|
|
31
40
|
}, z.core.$loose>;
|
|
32
41
|
required: z.ZodDefault<z.ZodBoolean>;
|
|
33
42
|
searchable: z.ZodDefault<z.ZodBoolean>;
|
|
@@ -38,16 +47,11 @@ export type DefineFieldPayload = z.infer<typeof defineFieldPayloadSchema>;
|
|
|
38
47
|
export declare const updateFieldPayloadSchema: z.ZodObject<{
|
|
39
48
|
entityName: z.ZodString;
|
|
40
49
|
fieldKey: z.ZodString;
|
|
50
|
+
required: z.ZodDefault<z.ZodBoolean>;
|
|
51
|
+
searchable: z.ZodDefault<z.ZodBoolean>;
|
|
52
|
+
displayOrder: z.ZodDefault<z.ZodNumber>;
|
|
53
|
+
label: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
41
54
|
serializedField: z.ZodObject<{
|
|
42
|
-
type: z.ZodEnum<{
|
|
43
|
-
boolean: "boolean";
|
|
44
|
-
date: "date";
|
|
45
|
-
embedded: "embedded";
|
|
46
|
-
enum: "enum";
|
|
47
|
-
money: "money";
|
|
48
|
-
number: "number";
|
|
49
|
-
text: "text";
|
|
50
|
-
}>;
|
|
51
55
|
fieldAccess: z.ZodOptional<z.ZodObject<{
|
|
52
56
|
read: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
53
57
|
write: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
@@ -59,13 +63,18 @@ export declare const updateFieldPayloadSchema: z.ZodObject<{
|
|
|
59
63
|
delete: "delete";
|
|
60
64
|
}>;
|
|
61
65
|
}, z.core.$strip>>;
|
|
66
|
+
type: z.ZodOptional<z.ZodEnum<{
|
|
67
|
+
boolean: "boolean";
|
|
68
|
+
date: "date";
|
|
69
|
+
embedded: "embedded";
|
|
70
|
+
enum: "enum";
|
|
71
|
+
money: "money";
|
|
72
|
+
number: "number";
|
|
73
|
+
text: "text";
|
|
74
|
+
}>>;
|
|
62
75
|
}, z.core.$loose>;
|
|
63
|
-
required: z.ZodDefault<z.ZodBoolean>;
|
|
64
|
-
searchable: z.ZodDefault<z.ZodBoolean>;
|
|
65
|
-
displayOrder: z.ZodDefault<z.ZodNumber>;
|
|
66
|
-
label: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
67
76
|
}, z.core.$strip>;
|
|
68
|
-
export type UpdateFieldPayload =
|
|
77
|
+
export type UpdateFieldPayload = z.infer<typeof updateFieldPayloadSchema>;
|
|
69
78
|
export declare const deleteFieldPayloadSchema: z.ZodObject<{
|
|
70
79
|
entityName: z.ZodString;
|
|
71
80
|
fieldKey: z.ZodString;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
2
|
import { SUPPORTED_FIELD_TYPES } from "./constants.js";
|
|
3
3
|
// Field-Type-Validator — pinnt valid type-Werte für fieldDefinition.
|
|
4
|
-
const fieldTypeSchema = z.enum(SUPPORTED_FIELD_TYPES);
|
|
4
|
+
export const fieldTypeSchema = z.enum(SUPPORTED_FIELD_TYPES);
|
|
5
5
|
// Per-field access-control. When set, `set/clear-custom-field` handlers
|
|
6
6
|
// require the calling user to hold at least one of the listed roles —
|
|
7
7
|
// in addition to the handler-level RBAC. Absent or empty `write` means
|
|
@@ -33,9 +33,7 @@ export const customFieldAccessSchema = z
|
|
|
33
33
|
// Everything else stays loose pending B2's per-type discriminated-union —
|
|
34
34
|
// EXCEPT `sensitive`, which is explicitly rejected below: custom fields
|
|
35
35
|
// carry supplemental business data only, PII support was removed (#972).
|
|
36
|
-
const
|
|
37
|
-
.looseObject({
|
|
38
|
-
type: fieldTypeSchema,
|
|
36
|
+
const serializedFieldBaseShape = {
|
|
39
37
|
fieldAccess: customFieldAccessSchema,
|
|
40
38
|
// `retention` (T1.5d): per-field expiry. The retention-cron strips
|
|
41
39
|
// values whose host-row `modified_at` is older than `keepFor`. Strategy
|
|
@@ -50,9 +48,19 @@ const serializedFieldSchema = z
|
|
|
50
48
|
strategy: z.enum(["delete", "anonymize"]),
|
|
51
49
|
})
|
|
52
50
|
.optional(),
|
|
53
|
-
}
|
|
51
|
+
};
|
|
52
|
+
const rejectSensitive = [
|
|
53
|
+
(v) => !("sensitive" in v),
|
|
54
|
+
"custom fields don't support PII — `sensitive` was removed (#972). Model personal data as a schema entity field with a pii/userOwned/tenantOwned annotation instead.",
|
|
55
|
+
];
|
|
56
|
+
const serializedFieldSchema = z
|
|
57
|
+
.looseObject({ type: fieldTypeSchema, ...serializedFieldBaseShape })
|
|
54
58
|
.refine((v) => typeof v["type"] === "string", "serializedField must have a string `type`")
|
|
55
|
-
.refine(
|
|
59
|
+
.refine(...rejectSensitive);
|
|
60
|
+
// Update may omit `type`: it is immutable, so the handler takes it from the stored definition.
|
|
61
|
+
const updateSerializedFieldSchema = z
|
|
62
|
+
.looseObject({ type: fieldTypeSchema.optional(), ...serializedFieldBaseShape })
|
|
63
|
+
.refine(...rejectSensitive);
|
|
56
64
|
// i18n-labels — `{ de: "...", en: "...", ... }`. Mindestens ein Eintrag.
|
|
57
65
|
const labelSchema = z.record(z.string().min(2).max(8), z.string().min(1));
|
|
58
66
|
// Payload für `define-tenant-field` und `define-system-field` write-handler.
|
|
@@ -69,11 +77,13 @@ export const defineFieldPayloadSchema = z.object({
|
|
|
69
77
|
displayOrder: z.number().int().min(0).default(0),
|
|
70
78
|
label: labelSchema.optional(),
|
|
71
79
|
});
|
|
72
|
-
// Payload
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
export const updateFieldPayloadSchema = defineFieldPayloadSchema
|
|
80
|
+
// Payload for `update-tenant-field`: full replacement like define (the edit
|
|
81
|
+
// screen sends the complete new state; entityName+fieldKey are the identity),
|
|
82
|
+
// except that `serializedField.type` may be omitted. type is immutable; a
|
|
83
|
+
// differing value is refused in the handler.
|
|
84
|
+
export const updateFieldPayloadSchema = defineFieldPayloadSchema.extend({
|
|
85
|
+
serializedField: updateSerializedFieldSchema,
|
|
86
|
+
});
|
|
77
87
|
// Payload für `delete-tenant-field` / `delete-system-field`.
|
|
78
88
|
export const deleteFieldPayloadSchema = z.object({
|
|
79
89
|
entityName: z.string().min(1).max(64),
|
|
@@ -25,6 +25,14 @@ const DEFAULT_CACHE_MAX_ENTRIES = 10_000;
|
|
|
25
25
|
// complete/failed; either way the cache entry has no value. Falls back
|
|
26
26
|
// to DB-lookup if actually needed.
|
|
27
27
|
const DEFAULT_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
28
|
+
// Run rows hang on SYSTEM_TENANT_ID and a null trigger is a cron/system run;
|
|
29
|
+
// job-run-table.ts declares whenAbsent: "tenant" for exactly that case, so the
|
|
30
|
+
// logger must resolve the same subject the entity chain would (fw#3654).
|
|
31
|
+
function jobRunSubject(triggeredById) {
|
|
32
|
+
return triggeredById === null
|
|
33
|
+
? { kind: "tenant", tenantId: SYSTEM_TENANT_ID }
|
|
34
|
+
: { kind: "user", userId: triggeredById };
|
|
35
|
+
}
|
|
28
36
|
// Same per-subject encryption as encryptFailureError/encryptStartedPayload,
|
|
29
37
|
// applied to a single value — shared so payload/message/error all go
|
|
30
38
|
// through the identical erased-key fallback. A key erased right before (or
|
|
@@ -33,9 +41,9 @@ const DEFAULT_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
|
33
41
|
// sentinel like decryptPiiValueForSubject does on the read side, instead of
|
|
34
42
|
// throwing and dropping the rest of the batch — or, for onJobStart, the
|
|
35
43
|
// whole run row.
|
|
36
|
-
async function encryptOrSentinel(kms,
|
|
44
|
+
async function encryptOrSentinel(kms, subject, value, field) {
|
|
37
45
|
try {
|
|
38
|
-
return await encryptPiiValueForSubject(kms,
|
|
46
|
+
return await encryptPiiValueForSubject(kms, subject, value, { requestId: "jobs:job-run-logger" }, field);
|
|
39
47
|
}
|
|
40
48
|
catch (e) {
|
|
41
49
|
if (!(e instanceof KeyErasedError))
|
|
@@ -48,19 +56,18 @@ async function encryptOrSentinel(kms, subjectUserId, value, field) {
|
|
|
48
56
|
// jobRunLogsTable — that table is unmanaged/direct-write, so there is no
|
|
49
57
|
// event-piiFields catalog to lean on here either. "message" is the AAD
|
|
50
58
|
// field name and must match the field passed to decryptStoredPii on read
|
|
51
|
-
// (detail.query.ts) or decrypt fails loud. Same skip
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
59
|
+
// (detail.query.ts) or decrypt fails loud. Same skip rule as the payload:
|
|
60
|
+
// absent KMS (rollout mode) stays plaintext. Bounded concurrency (#2307) —
|
|
61
|
+
// unbounded Promise.all would fire one getOrCreateDek per log line at once
|
|
62
|
+
// against the KMS adapter's pool.
|
|
55
63
|
async function encryptLogMessages(logs, triggeredById) {
|
|
56
|
-
if (triggeredById === null)
|
|
57
|
-
return [...logs];
|
|
58
64
|
const kms = configuredPiiSubjectKms();
|
|
59
65
|
if (!kms)
|
|
60
66
|
return [...logs];
|
|
67
|
+
const subject = jobRunSubject(triggeredById);
|
|
61
68
|
return mapWithConcurrency(logs, KMS_POOL_CONCURRENCY, async (log) => ({
|
|
62
69
|
...log,
|
|
63
|
-
message: await encryptOrSentinel(kms,
|
|
70
|
+
message: await encryptOrSentinel(kms, subject, log.message, "message"),
|
|
64
71
|
}));
|
|
65
72
|
}
|
|
66
73
|
// Mirrors encryptFailureError for the failure-path `error` column (#2307):
|
|
@@ -70,29 +77,26 @@ async function encryptLogMessages(logs, triggeredById) {
|
|
|
70
77
|
// separate, per-row batch path outside onJobFailed) applies the identical
|
|
71
78
|
// per-subject encryption instead of writing `error` in the clear.
|
|
72
79
|
export async function encryptFailureError(error, triggeredById) {
|
|
73
|
-
if (triggeredById === null)
|
|
74
|
-
return error;
|
|
75
80
|
const kms = configuredPiiSubjectKms();
|
|
76
81
|
if (!kms)
|
|
77
82
|
return error;
|
|
78
|
-
return encryptOrSentinel(kms, triggeredById, error, "error");
|
|
83
|
+
return encryptOrSentinel(kms, jobRunSubject(triggeredById), error, "error");
|
|
79
84
|
}
|
|
80
85
|
// The run-started payload can carry arbitrary user data; triggeredById
|
|
81
86
|
// names its owning user. No event-PII catalog involved (#2243 removed the
|
|
82
87
|
// jobRun r.defineEvent registrations, so there is nothing to catalog) —
|
|
83
88
|
// the subject is known statically, so we encrypt directly. A null subject
|
|
84
|
-
// (system cron runs, recipient-less triggers)
|
|
85
|
-
//
|
|
86
|
-
// rule. Absent KMS adapter stays plaintext too (rollout mode, unchanged).
|
|
89
|
+
// (system cron runs, recipient-less triggers) encrypts under the system
|
|
90
|
+
// tenant key. Absent KMS adapter stays plaintext (rollout mode, unchanged).
|
|
87
91
|
// A key already erased by the time the trigger lands (#2307) must not fail
|
|
88
92
|
// the whole run-start write — sentinel like the log/error paths.
|
|
89
93
|
async function encryptStartedPayload(payload, triggeredById) {
|
|
90
|
-
if (payload === null
|
|
94
|
+
if (payload === null)
|
|
91
95
|
return payload;
|
|
92
96
|
const kms = configuredPiiSubjectKms();
|
|
93
97
|
if (!kms)
|
|
94
98
|
return payload;
|
|
95
|
-
return encryptOrSentinel(kms, triggeredById, payload, "payload");
|
|
99
|
+
return encryptOrSentinel(kms, jobRunSubject(triggeredById), payload, "payload");
|
|
96
100
|
}
|
|
97
101
|
// pg_advisory_xact_lock namespace (int4): 'tjfl' as ASCII, disjoint from the
|
|
98
102
|
// framework's other fixed advisory-lock keys.
|
|
@@ -29,11 +29,18 @@ export const jobRunEntity = createEntity({
|
|
|
29
29
|
status: createTextField({ required: true, personal: false, reason: "system_metadata" }),
|
|
30
30
|
// The run-started payload can carry arbitrary caller-supplied data,
|
|
31
31
|
// encrypted by job-run-logger.ts under the triggering user's DEK (see
|
|
32
|
-
// jobs-pii-kms.integration.test.ts) — same subject as `triggeredById
|
|
33
|
-
|
|
32
|
+
// jobs-pii-kms.integration.test.ts) — same subject as `triggeredById`;
|
|
33
|
+
// cron runs have none and use the system tenant key (whenAbsent).
|
|
34
|
+
payload: createTextField({
|
|
35
|
+
personal: { of: "triggeredById", whenAbsent: "tenant" },
|
|
36
|
+
find: "none",
|
|
37
|
+
}),
|
|
34
38
|
// Failure messages can echo payload content back — same subject/DEK as
|
|
35
39
|
// `payload` (job-run-logger.ts encryptFailureError).
|
|
36
|
-
error: createTextField({
|
|
40
|
+
error: createTextField({
|
|
41
|
+
personal: { of: "triggeredById", whenAbsent: "tenant" },
|
|
42
|
+
find: "none",
|
|
43
|
+
}),
|
|
37
44
|
attempt: createNumberField({ required: true, default: 1, integer: true }),
|
|
38
45
|
startedAt: createTimestampField({ required: true }),
|
|
39
46
|
finishedAt: createTimestampField(),
|
|
@@ -67,9 +67,10 @@ export function createNoteEntryEntity(access, parents) {
|
|
|
67
67
|
}),
|
|
68
68
|
// Stamped once at write time, never re-resolved later — the history
|
|
69
69
|
// needs the name as it was then, not whatever the account is called now.
|
|
70
|
+
// whenAbsent: authorless notes (system-written) encrypt under the tenant key.
|
|
70
71
|
authorName: createTextField({
|
|
71
72
|
maxLength: 200,
|
|
72
|
-
personal: { of: "authorId" },
|
|
73
|
+
personal: { of: "authorId", whenAbsent: "tenant" },
|
|
73
74
|
find: "none",
|
|
74
75
|
}),
|
|
75
76
|
body: createLongTextField({
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { type SubjectId } from "@cosmicdrift/kumiko-framework/crypto";
|
|
2
2
|
import type { DbRunner, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
-
import type {
|
|
3
|
+
import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
export declare const noteEntryExportHook: UserDataExportHook;
|
|
5
5
|
export declare function eraseNoteBodiesReachingSubject(args: {
|
|
6
6
|
readonly db: DbRunner | TenantDb;
|
|
7
|
-
readonly registry: Registry;
|
|
8
7
|
readonly subject: SubjectId;
|
|
9
8
|
readonly erasedBy: string;
|
|
10
9
|
readonly requestId: string;
|
|
@@ -5,9 +5,7 @@
|
|
|
5
5
|
// export-only, erasure via crypto-shredding.
|
|
6
6
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
7
7
|
import { configuredPiiSubjectKms } from "@cosmicdrift/kumiko-framework/crypto";
|
|
8
|
-
import { resolveRetentionPolicyForTenant, resolveTenantRetentionPreset, } from "../data-retention/index.js";
|
|
9
8
|
import { noteEntryTable, noteMentionTable } from "../notes-history/index.js";
|
|
10
|
-
import { policyToStrategy } from "../user-data-rights/index.js";
|
|
11
9
|
// note-entry has no per-tenant scope quirk (unlike folders) — it's genuinely
|
|
12
10
|
// per-user content, so the export filters by authorId directly.
|
|
13
11
|
export const noteEntryExportHook = async (ctx) => {
|
|
@@ -56,10 +54,9 @@ async function selectNotesReachingSubject(db, subject) {
|
|
|
56
54
|
// @-mentions the subject (`note-mention`, fw#2787) or hangs on it as host.
|
|
57
55
|
// Free-text mentions are NOT detected — the documented gap in entity.ts.
|
|
58
56
|
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
// automated forget-cleanup applies to every other entity.
|
|
57
|
+
// Reached bodies are shredded regardless of the host entity's retention strategy:
|
|
58
|
+
// the note text is free-form PII, and a host's anonymize/blockDelete protects the
|
|
59
|
+
// row, not its content, just like the host's own PII fields get anonymized.
|
|
63
60
|
//
|
|
64
61
|
// Without a mounted KMS this erases nothing: record-owned fields fall back to
|
|
65
62
|
// plaintext framework-wide (see pii-field-encryption.ts), so apps that need
|
|
@@ -70,36 +67,7 @@ export async function eraseNoteBodiesReachingSubject(args) {
|
|
|
70
67
|
if (!kms)
|
|
71
68
|
return;
|
|
72
69
|
const notes = await selectNotesReachingSubject(args.db, args.subject);
|
|
73
|
-
const dataRetentionMounted = args.registry.features.has("data-retention");
|
|
74
|
-
const presetByTenant = new Map();
|
|
75
|
-
const strategyByTenantAndEntityType = new Map();
|
|
76
70
|
for (const note of notes) {
|
|
77
|
-
const strategyKey = `${note.tenantId}:${note.entityType}`;
|
|
78
|
-
let strategy = strategyByTenantAndEntityType.get(strategyKey);
|
|
79
|
-
if (strategy === undefined) {
|
|
80
|
-
let tenantPreset = presetByTenant.get(note.tenantId);
|
|
81
|
-
if (tenantPreset === undefined) {
|
|
82
|
-
tenantPreset = await resolveTenantRetentionPreset({
|
|
83
|
-
db: args.db,
|
|
84
|
-
registry: args.registry,
|
|
85
|
-
tenantId: note.tenantId,
|
|
86
|
-
});
|
|
87
|
-
presetByTenant.set(note.tenantId, tenantPreset);
|
|
88
|
-
}
|
|
89
|
-
const hostPolicy = await resolveRetentionPolicyForTenant({
|
|
90
|
-
db: args.db,
|
|
91
|
-
registry: args.registry,
|
|
92
|
-
tenantId: note.tenantId,
|
|
93
|
-
entityName: note.entityType,
|
|
94
|
-
preloadedTenantPreset: tenantPreset,
|
|
95
|
-
// Without data-retention there is no override table to read.
|
|
96
|
-
...(!dataRetentionMounted && { preloadedOverride: null }),
|
|
97
|
-
});
|
|
98
|
-
strategy = policyToStrategy(hostPolicy.policy?.strategy ?? null);
|
|
99
|
-
strategyByTenantAndEntityType.set(strategyKey, strategy);
|
|
100
|
-
}
|
|
101
|
-
if (strategy === "anonymize")
|
|
102
|
-
continue;
|
|
103
71
|
await kms.eraseKey({ kind: "record", entity: "note-entry", id: note.id }, {
|
|
104
72
|
requestId: args.requestId,
|
|
105
73
|
userId: args.erasedBy,
|
|
@@ -110,7 +78,6 @@ export async function eraseNoteBodiesReachingSubject(args) {
|
|
|
110
78
|
export const noteEntryDeleteHook = async (ctx) => {
|
|
111
79
|
await eraseNoteBodiesReachingSubject({
|
|
112
80
|
db: ctx.db,
|
|
113
|
-
registry: ctx.registry,
|
|
114
81
|
subject: { kind: "user", userId: ctx.userId },
|
|
115
82
|
erasedBy: ctx.userId,
|
|
116
83
|
requestId: "notes-history-user-data:forget-reached-notes",
|
|
@@ -8,7 +8,7 @@ import { defineFeature, EXT_USER_DATA } from "@cosmicdrift/kumiko-framework/engi
|
|
|
8
8
|
import { noteEntryDeleteHook, noteEntryExportHook, noteMentionDeleteHook, noteMentionExportHook, } from "./hooks.js";
|
|
9
9
|
export { eraseNoteBodiesReachingSubject } from "./hooks.js";
|
|
10
10
|
export const notesHistoryUserDataFeature = defineFeature("notes-history-user-data", (r) => {
|
|
11
|
-
r.describe("GDPR (Art. 20 export / Art. 17 erasure) coverage for the `notes-history` feature's `note-entry` and `note-mention` entities. Mounts the export hook so a user's authored notes are included in the user-data export bundle; the note-entry delete hook crypto-shreds the row-subject key of each note that structurally @-mentions the forgotten user (`note-mention`) or hangs on the user as host entity (
|
|
11
|
+
r.describe("GDPR (Art. 20 export / Art. 17 erasure) coverage for the `notes-history` feature's `note-entry` and `note-mention` entities. Mounts the export hook so a user's authored notes are included in the user-data export bundle; the note-entry delete hook crypto-shreds the row-subject key of each note that structurally @-mentions the forgotten user (`note-mention`) or hangs on the user as host entity (regardless of the host entity's retention strategy: the note text is free PII, the host's anonymize/blockDelete protects the row, not the content), keeping the append-only history intact for all other notes. The manual `crypto-shredding` forget-subject command reaches the same notes for user and record subjects through the exported `eraseNoteBodiesReachingSubject`, and the export lists them under `note-mention`. `note-mention`'s own hooks are a no-op — it is a plain FK pointer, not separately exportable content. Kept separate from `notes-history` so notes consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `notes-history`.");
|
|
12
12
|
// user-data-rights is the hard dependency (EXT_USER_DATA host). notes-history
|
|
13
13
|
// is OPTIONAL: if it's mounted toggleable(default=false), a hard r.requires
|
|
14
14
|
// would throw an "effectively disabled" boot warning even though the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.359.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>",
|
|
@@ -516,12 +516,12 @@
|
|
|
516
516
|
}
|
|
517
517
|
},
|
|
518
518
|
"dependencies": {
|
|
519
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
520
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
521
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
522
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
523
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
524
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
519
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.359.0",
|
|
520
|
+
"@cosmicdrift/kumiko-framework": "0.359.0",
|
|
521
|
+
"@cosmicdrift/kumiko-headless": "0.359.0",
|
|
522
|
+
"@cosmicdrift/kumiko-renderer": "0.359.0",
|
|
523
|
+
"@cosmicdrift/kumiko-renderer-web": "0.359.0",
|
|
524
|
+
"@cosmicdrift/kumiko-types": "0.359.0",
|
|
525
525
|
"@mollie/api-client": "^4.5.0",
|
|
526
526
|
"@node-rs/argon2": "^2.0.2",
|
|
527
527
|
"@types/mailparser": "^3.4.6",
|
|
@@ -1050,8 +1050,8 @@
|
|
|
1050
1050
|
],
|
|
1051
1051
|
"devDependencies": {
|
|
1052
1052
|
"@testing-library/user-event": "^14.6.1",
|
|
1053
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
1054
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
1053
|
+
"@cosmicdrift/kumiko-locale-de": "0.359.0",
|
|
1054
|
+
"@cosmicdrift/kumiko-locale-es": "0.359.0",
|
|
1055
1055
|
"jsqr": "^1.4.0"
|
|
1056
1056
|
}
|
|
1057
1057
|
}
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.358.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Reserve and settle cap usage outside the dispatcher",
|
|
6
|
+
"detail": "reserveCalendarCapUsage(ctx, { capName, periodStartIso, limit, profile, amount, notify? }) runs the same sweep, soft-hit notification and guarded hard-cap reservation as withCapEnforcement and returns a handle. settle(actualAmount) replaces the reserved amount by the actual one in one transaction (and books the full actual amount if the reservation was swept or released meanwhile); release() gives it back. Parallel callers cannot exceed the hard cap. It works with a HandlerContext (also r.systemScope() handlers) and a JobContext. Pattern: `const r = await reserveCalendarCapUsage(ctx, {...}); try { ...; await r.settle(actual) } catch (e) { await r.release(); throw e }`. bookCapUsage, markCapSoftWarned, releaseCapUsage and the enforceCap family accept a JobContext as well.",
|
|
7
|
+
"migration": "withCapEnforcement and bookCapUsage now work on r.systemScope() handlers; before, they failed on the fail-closed ctx.db. Reservation rows carry the tenant of ctx.user explicitly, and a context whose tenant-mode db belongs to another tenant than ctx.user is rejected with access_denied. enforceCap now filters the counter by ctx.user.tenantId, so a system-mode db no longer reads another tenant's counter."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.352.0",
|
|
4
11
|
"type": "breaking",
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.359.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "update-tenant-field keeps the stored type when serializedField.type is omitted",
|
|
6
|
+
"detail": "custom-fields: update-tenant-field accepts serializedField without type and keeps the stored type; a different type is still refused with 422 field_type_immutable. The handler declares excludedFields [\"type\"]."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.338.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.358.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "forget-subject shreds note bodies even when the host entity has anonymize or blockDelete retention",
|
|
6
|
+
"detail": "Note bodies that @-mention the forgotten subject or hang on it as host entity were skipped when the host entity's retention strategy was anonymize or blockDelete, so the note text stayed readable while the host's own PII fields were anonymized. The note text is free-form PII, and a retention strategy protects the row, not its content, so these bodies are now always crypto-shredded. This applies to the manual `forget-subject` command and to the automated Art. 17 delete hook."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.354.0",
|
|
4
10
|
"type": "breaking",
|