@cosmicdrift/kumiko-bundled-features 0.351.0 → 0.353.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/dist/agent-tools/agent-manifest.js +8 -2
  2. package/dist/agent-tools/manifest-deny-qns.d.ts +3 -0
  3. package/dist/agent-tools/manifest-deny-qns.js +17 -0
  4. package/dist/agent-tools/tool-catalog.d.ts +2 -1
  5. package/dist/agent-tools/tool-catalog.js +4 -1
  6. package/dist/audit/handlers/list.query.js +13 -8
  7. package/dist/auth-email-password/handlers/signup-confirm.write.js +2 -3
  8. package/dist/auth-mfa/schema/user-mfa.d.ts +6 -0
  9. package/dist/auth-mfa/screens.js +2 -2
  10. package/dist/billing-foundation/checkout-core.d.ts +7 -0
  11. package/dist/billing-foundation/checkout-core.js +7 -2
  12. package/dist/billing-foundation/entities.d.ts +8 -0
  13. package/dist/billing-foundation/handlers/start-plan-checkout.write.js +7 -1
  14. package/dist/billing-foundation/projection.d.ts +8 -0
  15. package/dist/cap-counter/book-cap-usage.d.ts +5 -1
  16. package/dist/cap-counter/book-cap-usage.js +65 -44
  17. package/dist/cap-counter/cap-reservation.d.ts +12 -0
  18. package/dist/cap-counter/cap-reservation.js +135 -0
  19. package/dist/cap-counter/constants.d.ts +3 -0
  20. package/dist/cap-counter/constants.js +6 -0
  21. package/dist/cap-counter/entity.d.ts +1 -0
  22. package/dist/cap-counter/feature.js +7 -2
  23. package/dist/cap-counter/handlers/increment-rolling.write.d.ts +5 -0
  24. package/dist/cap-counter/handlers/increment-rolling.write.js +4 -0
  25. package/dist/cap-counter/index.d.ts +4 -2
  26. package/dist/cap-counter/index.js +3 -1
  27. package/dist/cap-counter/payload-field.d.ts +1 -0
  28. package/dist/cap-counter/payload-field.js +11 -0
  29. package/dist/cap-counter/reservation-chain.d.ts +2 -0
  30. package/dist/cap-counter/reservation-chain.js +44 -0
  31. package/dist/cap-counter/stock-cap-guard.d.ts +3 -0
  32. package/dist/cap-counter/tables.d.ts +3 -0
  33. package/dist/cap-counter/tables.js +36 -0
  34. package/dist/cap-counter/value-cap-guard.d.ts +18 -0
  35. package/dist/cap-counter/value-cap-guard.js +33 -0
  36. package/dist/cap-counter/with-cap-enforcement.d.ts +5 -7
  37. package/dist/cap-counter/with-cap-enforcement.js +35 -51
  38. package/dist/cap-overview/access.js +2 -9
  39. package/dist/cap-overview/handlers/tenant-caps-list.query.js +2 -2
  40. package/dist/cap-overview/handlers/tenant-options.query.js +4 -5
  41. package/dist/channel-discord/feature.js +1 -0
  42. package/dist/channel-slack/feature.js +1 -0
  43. package/dist/channel-teams/feature.js +1 -0
  44. package/dist/compliance-profiles/schema/profile-selection.d.ts +10 -8
  45. package/dist/compliance-profiles-ops/handlers/tenants-missing-profile.query.js +2 -4
  46. package/dist/config/handlers/values.query.d.ts +3 -1
  47. package/dist/config/handlers/values.query.js +7 -2
  48. package/dist/config/table.d.ts +6 -0
  49. package/dist/custom-fields/entity.d.ts +4 -0
  50. package/dist/data-retention/schema/tenant-retention-override.d.ts +4 -0
  51. package/dist/delivery/attempt-log-retention.d.ts +9 -0
  52. package/dist/delivery/attempt-log-retention.js +23 -0
  53. package/dist/delivery/attempt-log.d.ts +1 -1
  54. package/dist/delivery/attempt-log.js +21 -2
  55. package/dist/delivery/constants.d.ts +1 -1
  56. package/dist/delivery/constants.js +1 -1
  57. package/dist/delivery/events.d.ts +1 -0
  58. package/dist/delivery/events.js +2 -0
  59. package/dist/delivery/feature.d.ts +1 -0
  60. package/dist/delivery/feature.js +39 -3
  61. package/dist/delivery/index.d.ts +2 -1
  62. package/dist/delivery/index.js +2 -1
  63. package/dist/delivery/mask-recipient-address.d.ts +1 -0
  64. package/dist/delivery/mask-recipient-address.js +41 -0
  65. package/dist/delivery/public-names.d.ts +2 -0
  66. package/dist/delivery/public-names.js +4 -1
  67. package/dist/delivery/tables.d.ts +6 -0
  68. package/dist/delivery/types.d.ts +1 -0
  69. package/dist/delivery/types.js +6 -2
  70. package/dist/document-ingest-foundation/entity.d.ts +4 -0
  71. package/dist/files-tenant-data/hooks.js +3 -2
  72. package/dist/folders/entity.d.ts +8 -0
  73. package/dist/folders/feature.js +2 -10
  74. package/dist/form-draft/db/queries/cleanup.d.ts +1 -1
  75. package/dist/form-draft/db/queries/cleanup.js +4 -3
  76. package/dist/form-draft/entity.d.ts +2 -0
  77. package/dist/form-draft/handlers/cleanup.job.d.ts +5 -1
  78. package/dist/form-draft/handlers/cleanup.job.js +11 -5
  79. package/dist/inbound-mail-foundation/entities.d.ts +25 -0
  80. package/dist/inbound-mail-foundation/projection.d.ts +20 -0
  81. package/dist/jobs/handlers/list.query.d.ts +2 -1
  82. package/dist/jobs/handlers/list.query.js +39 -14
  83. package/dist/jobs/i18n.js +1 -0
  84. package/dist/jobs/job-run-table.d.ts +6 -0
  85. package/dist/ledger/entity.d.ts +24 -8
  86. package/dist/ledger/entity.js +4 -0
  87. package/dist/ledger/schemas.js +8 -2
  88. package/dist/managed-pages/feature.js +28 -10
  89. package/dist/managed-pages/table.d.ts +12 -0
  90. package/dist/notes-history/entity.d.ts +12 -0
  91. package/dist/notes-history/feature.js +2 -10
  92. package/dist/personal-access-tokens/schema/api-token.d.ts +6 -0
  93. package/dist/personal-access-tokens/screens.js +14 -3
  94. package/dist/renderer-foundation/types.d.ts +1 -0
  95. package/dist/renderer-simple/feature.js +4 -6
  96. package/dist/renderer-simple/index.d.ts +1 -0
  97. package/dist/renderer-simple/mail-blocks.d.ts +21 -0
  98. package/dist/renderer-simple/mail-blocks.js +107 -0
  99. package/dist/renderer-simple/simple-renderer.js +21 -3
  100. package/dist/secrets/table.d.ts +1 -0
  101. package/dist/seo/feature.js +28 -6
  102. package/dist/sessions/schema/user-session.d.ts +4 -0
  103. package/dist/shared/assert-no-where-rule-in-ownership-write.d.ts +2 -0
  104. package/dist/shared/assert-no-where-rule-in-ownership-write.js +18 -0
  105. package/dist/shared/index.d.ts +2 -1
  106. package/dist/shared/index.js +2 -1
  107. package/dist/shared/rate-limited-text-response.d.ts +2 -0
  108. package/dist/shared/rate-limited-text-response.js +9 -0
  109. package/dist/tags/entity.d.ts +9 -0
  110. package/dist/tags/feature.js +2 -10
  111. package/dist/template-resolver/table.d.ts +48 -30
  112. package/dist/template-resolver/user-content-table.d.ts +34 -20
  113. package/dist/tenant/decrypt-tenant-names.d.ts +3 -0
  114. package/dist/tenant/decrypt-tenant-names.js +10 -0
  115. package/dist/tenant/handlers/me.query.js +5 -1
  116. package/dist/tenant/handlers/memberships.query.js +3 -2
  117. package/dist/tenant/handlers/tenant-directory.query.js +9 -3
  118. package/dist/tenant/index.d.ts +1 -0
  119. package/dist/tenant/index.js +1 -0
  120. package/dist/tenant/invitation-table.d.ts +16 -8
  121. package/dist/tenant/membership-table.d.ts +4 -0
  122. package/dist/tenant/schema/tenant.d.ts +18 -10
  123. package/dist/tenant/schema/tenant.js +4 -6
  124. package/dist/tenant/screens.js +1 -1
  125. package/dist/tenant-handover/move-entity-graph.js +11 -18
  126. package/dist/tenant-lifecycle/feature.js +1 -0
  127. package/dist/tenant-lifecycle/run-tenant-destroy.d.ts +3 -0
  128. package/dist/tenant-lifecycle/run-tenant-destroy.js +2 -0
  129. package/dist/tenant-lifecycle/stages.d.ts +2 -0
  130. package/dist/tenant-lifecycle/stages.js +7 -1
  131. package/dist/tier-engine/entity.d.ts +2 -0
  132. package/dist/tier-engine/index.d.ts +1 -0
  133. package/dist/tier-engine/index.js +1 -0
  134. package/dist/tier-engine/tier-option-gate.d.ts +32 -0
  135. package/dist/tier-engine/tier-option-gate.js +62 -0
  136. package/dist/tier-engine/tier-resolver.d.ts +2 -0
  137. package/dist/user/handlers/list.query.js +2 -2
  138. package/dist/user/schema/user.d.ts +22 -8
  139. package/dist/user-data-rights/boot-checks.js +5 -0
  140. package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +2 -2
  141. package/dist/user-data-rights/schema/download-attempt.d.ts +14 -0
  142. package/dist/user-data-rights/schema/download-token.d.ts +8 -0
  143. package/dist/user-data-rights/schema/export-job.d.ts +16 -8
  144. package/dist/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.js +25 -6
  145. package/dist/waitlist/entity.d.ts +22 -6
  146. package/dist/workflow-runner/event-trigger.js +6 -3
  147. package/dist/workflow-runner/handlers/resume-run.write.js +3 -0
  148. package/package.json +10 -10
  149. package/src/agent-tools/changes.json +12 -0
  150. package/src/audit/changes.json +6 -0
  151. package/src/cap-counter/changes.json +28 -0
  152. package/src/delivery/changes.json +12 -0
  153. package/src/derivatives-sharp/changes.json +6 -0
  154. package/src/form-draft/changes.json +7 -1
  155. package/src/legal-pages/changes.json +6 -0
  156. package/src/managed-pages/changes.json +6 -0
  157. package/src/renderer-simple/changes.json +6 -0
  158. package/src/seo/changes.json +5 -0
  159. package/src/tenant/changes.json +7 -0
  160. package/src/tenant-handover/changes.json +6 -0
  161. package/src/tier-engine/changes.json +6 -0
  162. package/src/workflow-runner/changes.json +5 -0
  163. package/dist/shared/has-where-rule.d.ts +0 -2
  164. package/dist/shared/has-where-rule.js +0 -10
@@ -0,0 +1,135 @@
1
+ import { deleteMany, runInOwnTransaction } from "@cosmicdrift/kumiko-framework/db";
2
+ import { reraiseAsKumikoError } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { appendEventInTenantDb, getStreamVersionInTenantDb, } from "@cosmicdrift/kumiko-framework/event-store";
4
+ import { generateId } from "@cosmicdrift/kumiko-framework/utils";
5
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
6
+ import { rollingCapAggregateId } from "./aggregate-id.js";
7
+ import { applyCapDeltaOn, readRollingCapUsage, requireOutsideTransactionDb, retryCounterWriteOnVersionConflict, retryOnStreamVersionConflict, } from "./book-cap-usage.js";
8
+ import { CAP_COUNTER_ROLLING_AGGREGATE_TYPE, CAP_RESERVATION_TTL_MINUTES, ROLLING_INCREMENTED_EVENT_QN, ROLLING_RELEASED_EVENT_QN, } from "./constants.js";
9
+ import { capReservationsTable } from "./tables.js";
10
+ const NOTHING_TO_RELEASE = { isSuccess: true, data: {} };
11
+ async function insertReservationRow(txDb, id, draft) {
12
+ const now = Temporal.Now.instant();
13
+ await txDb.insertOne(capReservationsTable, {
14
+ id,
15
+ capName: draft.capName,
16
+ kind: draft.kind,
17
+ periodStartIso: draft.periodStartIso ?? null,
18
+ amount: draft.amount,
19
+ expiresAt: now.add({ minutes: CAP_RESERVATION_TTL_MINUTES }),
20
+ createdAt: now,
21
+ });
22
+ }
23
+ function toHandle(ctx, id) {
24
+ return {
25
+ release: () => releaseCapReservation(ctx, id),
26
+ confirmInTransaction: (confirm) => confirmCapReservation(confirm, id),
27
+ };
28
+ }
29
+ // The counter increment and the reservation row commit together, so a booked amount never exists
30
+ // without a row that can give it back.
31
+ export async function reserveCalendarCap(ctx, options) {
32
+ const outsideDb = requireOutsideTransactionDb(ctx);
33
+ const id = generateId();
34
+ const reserved = await retryCounterWriteOnVersionConflict(() => runInOwnTransaction(outsideDb, async (txDb) => {
35
+ const booked = await applyCapDeltaOn(txDb, ctx.user, options, "add");
36
+ if (!booked.isSuccess)
37
+ return booked;
38
+ await insertReservationRow(txDb, id, {
39
+ capName: options.capName,
40
+ kind: "calendar",
41
+ periodStartIso: options.periodStartIso,
42
+ amount: options.amount,
43
+ });
44
+ return booked;
45
+ }));
46
+ if (!reserved.isSuccess)
47
+ throw reraiseAsKumikoError(reserved.error);
48
+ return toHandle(ctx, id);
49
+ }
50
+ function rollingEvent(user, capName, amount, expectedVersion, type) {
51
+ return {
52
+ aggregateId: rollingCapAggregateId(user.tenantId, capName),
53
+ aggregateType: CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
54
+ expectedVersion,
55
+ type,
56
+ payload: { capName, amount },
57
+ metadata: { userId: user.id },
58
+ };
59
+ }
60
+ // The append is version-guarded: a booker that read the usage before another one appended loses the
61
+ // 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 outsideDb = requireOutsideTransactionDb(ctx);
64
+ const id = generateId();
65
+ const aggregateId = rollingCapAggregateId(ctx.user.tenantId, options.capName);
66
+ await retryOnStreamVersionConflict(() => runInOwnTransaction(outsideDb, async (txDb) => {
67
+ const version = await getStreamVersionInTenantDb(txDb, aggregateId);
68
+ const usage = await readRollingCapUsage(txDb, ctx.user.tenantId, options);
69
+ options.guardCurrentValue?.(usage);
70
+ await appendEventInTenantDb(txDb, rollingEvent(ctx.user, options.capName, options.amount, version, ROLLING_INCREMENTED_EVENT_QN));
71
+ await insertReservationRow(txDb, id, {
72
+ capName: options.capName,
73
+ kind: "rolling",
74
+ amount: options.amount,
75
+ });
76
+ return NOTHING_TO_RELEASE;
77
+ }));
78
+ return toHandle(ctx, id);
79
+ }
80
+ // Deleting the row takes its row lock inside the handler transaction, so a release started after a
81
+ // failed COMMIT waits for the real outcome and finds the row gone when the commit went through.
82
+ async function confirmCapReservation(confirm, id) {
83
+ await deleteMany(confirm.tx, capReservationsTable, { id, tenantId: confirm.tenantId });
84
+ }
85
+ async function giveBackCalendar(txDb, user, row) {
86
+ if (row.periodStartIso === null) {
87
+ throw new Error(`cap-counter: calendar reservation ${row.id} has no period to release against`);
88
+ }
89
+ return applyCapDeltaOn(txDb, user, { capName: row.capName, periodStartIso: row.periodStartIso, amount: row.amount }, "subtract");
90
+ }
91
+ async function giveBackRolling(txDb, user, row) {
92
+ const version = await getStreamVersionInTenantDb(txDb, rollingCapAggregateId(user.tenantId, row.capName));
93
+ await appendEventInTenantDb(txDb, rollingEvent(user, row.capName, row.amount, version, ROLLING_RELEASED_EVENT_QN));
94
+ return NOTHING_TO_RELEASE;
95
+ }
96
+ async function giveBackReservation(txDb, user, id) {
97
+ const row = await txDb.fetchOne(capReservationsTable, { id });
98
+ if (!row)
99
+ return NOTHING_TO_RELEASE;
100
+ // Waits for a confirm still holding the row; zero rows back means that confirm committed.
101
+ const claimed = await txDb.updateMany(capReservationsTable, { expiresAt: row.expiresAt }, { id });
102
+ if (claimed.length === 0)
103
+ return NOTHING_TO_RELEASE;
104
+ const given = row.kind === "rolling"
105
+ ? await giveBackRolling(txDb, user, row)
106
+ : await giveBackCalendar(txDb, user, row);
107
+ if (!given.isSuccess)
108
+ return given;
109
+ await txDb.deleteMany(capReservationsTable, { id });
110
+ return given;
111
+ }
112
+ export async function releaseCapReservation(ctx, id) {
113
+ const outsideDb = requireOutsideTransactionDb(ctx);
114
+ const released = await retryOnStreamVersionConflict(() => retryCounterWriteOnVersionConflict(() => runInOwnTransaction(outsideDb, (txDb) => giveBackReservation(txDb, ctx.user, id))));
115
+ if (!released.isSuccess)
116
+ throw reraiseAsKumikoError(released.error);
117
+ }
118
+ // A failed release leaves its row behind; giving it back once the TTL passed bounds how long that
119
+ // capacity stays counted.
120
+ export async function releaseExpiredCapReservations(ctx, capName) {
121
+ const outsideDb = requireOutsideTransactionDb(ctx);
122
+ const expired = await outsideDb.selectMany(capReservationsTable, {
123
+ tenantId: ctx.user.tenantId,
124
+ capName,
125
+ expiresAt: { lt: Temporal.Now.instant() },
126
+ });
127
+ for (const { id } of expired) {
128
+ try {
129
+ await releaseCapReservation(ctx, id);
130
+ }
131
+ catch (error) {
132
+ ctx.log?.error("releasing an expired cap reservation failed", { id, capName, error });
133
+ }
134
+ }
135
+ }
@@ -3,6 +3,9 @@ export declare const CAP_COUNTER_LIST_SCREEN_ID: "cap-list";
3
3
  export declare const CAP_COUNTER_ROLLING_AGGREGATE_TYPE: "cap-counter-rolling";
4
4
  export declare const ROLLING_INCREMENTED_EVENT_SHORT: "rolling-incremented";
5
5
  export declare const ROLLING_INCREMENTED_EVENT_QN: "cap-counter:event:rolling-incremented";
6
+ export declare const ROLLING_RELEASED_EVENT_SHORT: "rolling-released";
7
+ export declare const ROLLING_RELEASED_EVENT_QN: "cap-counter:event:rolling-released";
8
+ export declare const CAP_RESERVATION_TTL_MINUTES = 60;
6
9
  export declare const CapCounterHandlers: {
7
10
  readonly increment: "cap-counter:write:increment";
8
11
  readonly incrementRolling: "cap-counter:write:increment-rolling";
@@ -16,6 +16,12 @@ export const CAP_COUNTER_ROLLING_AGGREGATE_TYPE = "cap-counter-rolling";
16
16
  // Beide MÜSSEN konsistent sein (drift-pin im feature-test).
17
17
  export const ROLLING_INCREMENTED_EVENT_SHORT = "rolling-incremented";
18
18
  export const ROLLING_INCREMENTED_EVENT_QN = "cap-counter:event:rolling-incremented";
19
+ // Gives back a reserved rolling amount; the window sum is incremented minus released.
20
+ export const ROLLING_RELEASED_EVENT_SHORT = "rolling-released";
21
+ export const ROLLING_RELEASED_EVENT_QN = "cap-counter:event:rolling-released";
22
+ // A reservation row older than this is treated as a lost release and given back before the next
23
+ // reservation for the same cap, which bounds the over-count a failed release can leave behind.
24
+ export const CAP_RESERVATION_TTL_MINUTES = 60;
19
25
  // Qualified write handler names (QN format: scope:type:name).
20
26
  export const CapCounterHandlers = {
21
27
  increment: "cap-counter:write:increment",
@@ -3,6 +3,7 @@ export declare const capCounterEntity: import("@cosmicdrift/kumiko-types/fields"
3
3
  readonly type: "text";
4
4
  readonly description?: string;
5
5
  readonly maxLength?: number;
6
+ readonly minLength?: number;
6
7
  readonly required?: boolean;
7
8
  readonly searchable?: boolean;
8
9
  readonly sortable?: boolean;
@@ -45,13 +45,14 @@
45
45
  // config, kein secrets, kein tenant-feature nötig. Tenant-Scoping kommt
46
46
  // vom Framework-Default (Base-Column tenantId).
47
47
  import { access, defineEntityListHandler, defineFeature, } from "@cosmicdrift/kumiko-framework/engine";
48
- import { CAP_COUNTER_FEATURE, CAP_COUNTER_LIST_SCREEN_ID, ROLLING_INCREMENTED_EVENT_SHORT, } from "./constants.js";
48
+ import { CAP_COUNTER_FEATURE, CAP_COUNTER_LIST_SCREEN_ID, ROLLING_INCREMENTED_EVENT_SHORT, ROLLING_RELEASED_EVENT_SHORT, } from "./constants.js";
49
49
  import { capCounterEntity } from "./entity.js";
50
50
  import { getCounterQuery } from "./handlers/get-counter.query.js";
51
51
  import { incrementCapHandler } from "./handlers/increment.write.js";
52
- import { incrementRollingCapHandler, rollingIncrementedSchema, } from "./handlers/increment-rolling.write.js";
52
+ import { incrementRollingCapHandler, rollingIncrementedSchema, rollingReleasedSchema, } from "./handlers/increment-rolling.write.js";
53
53
  import { markSoftWarnedHandler } from "./handlers/mark-soft-warned.write.js";
54
54
  import { CAP_COUNTER_I18N } from "./i18n.js";
55
+ import { capReservationsTableMeta } from "./tables.js";
55
56
  export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
56
57
  r.describe("Tracks per-tenant usage against configurable limits using two complementary storage models: calendar-period counters (one projection row per tenant/capName/period, reset implicitly by period rollover) and rolling-window counters (append-only event stream, no projection). Use `enforceCap` / `enforceRollingCap` (or the `withCapEnforcement` / `withRollingCapEnforcement` handler wrappers) in your write-handlers to check limits with soft-warn and hard-block tolerances; call `enforceCapAndMaybeNotify` when you also want to trigger a delivery notification on soft-threshold hits.");
57
58
  r.uiHints({
@@ -60,12 +61,16 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
60
61
  recommended: false,
61
62
  });
62
63
  r.entity("cap-counter", capCounterEntity);
64
+ r.storeTable(capReservationsTableMeta, {
65
+ reason: "coordination.cap_reservation_ledger",
66
+ });
63
67
  // Custom Domain-Event für Rolling-Counter. r.defineEvent registriert
64
68
  // das Schema beim Registry; ctx.unsafeAppendEvent im Handler nutzt
65
69
  // dasselbe Schema für Append-Time-Validation. QN nach Prefixing:
66
70
  // "cap-counter:event:rolling-incremented" (siehe
67
71
  // ROLLING_INCREMENTED_EVENT_QN).
68
72
  r.defineEvent(ROLLING_INCREMENTED_EVENT_SHORT, rollingIncrementedSchema, { piiFields: "none" });
73
+ r.defineEvent(ROLLING_RELEASED_EVENT_SHORT, rollingReleasedSchema, { piiFields: "none" });
69
74
  // Custom write-handlers.
70
75
  // - increment: Calendar-Period (CRUD via projection-row).
71
76
  // - increment-rolling: Rolling-Window (Custom-Event, no projection).
@@ -6,4 +6,9 @@ export declare const rollingIncrementedSchema: z.ZodObject<{
6
6
  capName: z.ZodString;
7
7
  amount: z.ZodDefault<z.ZodNumber>;
8
8
  }, z.core.$strip>;
9
+ /** Payload of the event that gives a reserved rolling amount back. */
10
+ export declare const rollingReleasedSchema: z.ZodObject<{
11
+ capName: z.ZodString;
12
+ amount: z.ZodNumber;
13
+ }, z.core.$strip>;
9
14
  export declare const incrementRollingCapHandler: WriteHandlerDef;
@@ -31,6 +31,10 @@ const incrementRollingSchema = z.object({
31
31
  /** Schema des emittierten Custom-Events. Identisch zum Input-Schema:
32
32
  * der Caller zahlt amount, wir hängen es 1:1 an den Stream. */
33
33
  export const rollingIncrementedSchema = incrementRollingSchema;
34
+ /** Payload of the event that gives a reserved rolling amount back. */
35
+ export const rollingReleasedSchema = incrementRollingSchema.extend({
36
+ amount: z.number().int().positive(),
37
+ });
34
38
  // Rolling-Increment-Handler — append-only. Race-frei: zwei parallele
35
39
  // Increments für (tenant, cap) hängen sich am selben aggregate-stream
36
40
  // in unterschiedlichen versions auf, das event-store ordert.
@@ -1,8 +1,10 @@
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 { CAP_COUNTER_FEATURE, CAP_COUNTER_ROLLING_AGGREGATE_TYPE, CapCounterHandlers, CapCounterQueries, ROLLING_INCREMENTED_EVENT_QN, ROLLING_INCREMENTED_EVENT_SHORT, } from "./constants.js";
3
+ 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
4
  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
5
  export { capCounterEntity } from "./entity.js";
6
6
  export { capCounterFeature } from "./feature.js";
7
- export { type CapLimitContext, createStockCapGuard, type StockCapGuard, type StockCapSpec, } from "./stock-cap-guard.js";
7
+ export { readPayloadField } from "./payload-field.js";
8
+ export { type CapGuardContext, type CapLimitContext, createStockCapGuard, type StockCapGuard, type StockCapSpec, } from "./stock-cap-guard.js";
9
+ export { createValueCapGuard, type ValueCapGuard, type ValueCapSpec, } from "./value-cap-guard.js";
8
10
  export { type CalendarCapDef, type CalendarCapResolver, type RollingCapDef, type RollingCapResolver, withCapEnforcement, withRollingCapEnforcement, } from "./with-cap-enforcement.js";
@@ -1,9 +1,11 @@
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 { CAP_COUNTER_FEATURE, CAP_COUNTER_ROLLING_AGGREGATE_TYPE, CapCounterHandlers, CapCounterQueries, ROLLING_INCREMENTED_EVENT_QN, ROLLING_INCREMENTED_EVENT_SHORT, } from "./constants.js";
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";
5
5
  export { CAP_TOLERANCES, CapExceededError, currentCalendarMonthStartIso, enforceCap, enforceCapAndMaybeNotify, enforceRollingCap, enforceRollingCapAndMaybeNotify, enforceStockCap, } from "./enforce-cap.js";
6
6
  export { capCounterEntity } from "./entity.js";
7
7
  export { capCounterFeature } from "./feature.js";
8
+ export { readPayloadField } from "./payload-field.js";
8
9
  export { createStockCapGuard, } from "./stock-cap-guard.js";
10
+ export { createValueCapGuard, } from "./value-cap-guard.js";
9
11
  export { withCapEnforcement, withRollingCapEnforcement, } from "./with-cap-enforcement.js";
@@ -0,0 +1 @@
1
+ export declare function readPayloadField(payload: unknown, field: string): unknown;
@@ -0,0 +1,11 @@
1
+ import { isPlainObject } from "@cosmicdrift/kumiko-framework/utils";
2
+ // Create payloads are flat, entity update payloads carry the edited fields under `changes`
3
+ // (`{ id, version, changes }`), so a guard that follows one field has to look at both.
4
+ export function readPayloadField(payload, field) {
5
+ if (!isPlainObject(payload))
6
+ return undefined;
7
+ if (field in payload)
8
+ return payload[field];
9
+ const { changes } = payload;
10
+ return isPlainObject(changes) ? changes[field] : undefined;
11
+ }
@@ -0,0 +1,2 @@
1
+ import type { PreTransactionReservation } from "@cosmicdrift/kumiko-types/handlers";
2
+ export declare function chainReservations(inner: PreTransactionReservation | undefined, own: PreTransactionReservation): PreTransactionReservation;
@@ -0,0 +1,44 @@
1
+ function toHandle(outcome) {
2
+ if (outcome === undefined)
3
+ return { release: async () => { }, confirmInTransaction: async () => { } };
4
+ if (typeof outcome === "function") {
5
+ return { release: outcome, confirmInTransaction: async () => { } };
6
+ }
7
+ return outcome;
8
+ }
9
+ // Runs `inner` first, then `own`; a failing `own` gives the inner reservation back before it
10
+ // throws. Release goes in reverse order, confirm covers both.
11
+ export function chainReservations(inner, own) {
12
+ if (!inner)
13
+ return own;
14
+ return async (event, ctx) => {
15
+ const innerHandle = toHandle(await inner(event, ctx));
16
+ let ownHandle;
17
+ try {
18
+ ownHandle = toHandle(await own(event, ctx));
19
+ }
20
+ catch (error) {
21
+ try {
22
+ await innerHandle.release();
23
+ }
24
+ catch (releaseError) {
25
+ ctx.log?.error("releasing the inner cap reservation failed", { error: releaseError });
26
+ }
27
+ throw error;
28
+ }
29
+ return {
30
+ release: async () => {
31
+ try {
32
+ await ownHandle.release();
33
+ }
34
+ finally {
35
+ await innerHandle.release();
36
+ }
37
+ },
38
+ confirmInTransaction: async (confirm) => {
39
+ await innerHandle.confirmInTransaction(confirm);
40
+ await ownHandle.confirmInTransaction(confirm);
41
+ },
42
+ };
43
+ };
44
+ }
@@ -4,6 +4,9 @@ import { type WriteFailure } from "@cosmicdrift/kumiko-framework/errors";
4
4
  export type CapLimitContext = {
5
5
  readonly config?: ConfigAccessor;
6
6
  };
7
+ export type CapGuardContext = CapLimitContext & {
8
+ readonly db: TenantDb;
9
+ };
7
10
  export type StockCapSpec<TCaps> = {
8
11
  readonly table: SchemaTable | EntityTableMeta;
9
12
  readonly limit: (caps: TCaps) => number;
@@ -0,0 +1,3 @@
1
+ import { type EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
2
+ export declare const capReservationsTable: import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable;
3
+ export declare const capReservationsTableMeta: EntityTableMeta;
@@ -0,0 +1,36 @@
1
+ import { defineUnmanagedTable, index, instant, integer, table as pgTable, text, uuid, } from "@cosmicdrift/kumiko-framework/db";
2
+ const CAP_RESERVATIONS_TENANT_CAP_EXPIRES_INDEX = "store_cap_reservations_tenant_id_cap_name_expires_at_idx";
3
+ // One row per capacity that was booked before a handler transaction and is not settled yet. The
4
+ // handler transaction deletes it on its own commit (confirm); a release or the TTL sweep deletes it
5
+ // together with the counter decrement. Unmanaged: the rows are a coordination aid, not state with
6
+ // an event history.
7
+ export const capReservationsTable = pgTable("store_cap_reservations", {
8
+ id: uuid("id").primaryKey(),
9
+ tenantId: uuid("tenant_id").notNull(),
10
+ capName: text("cap_name").notNull(),
11
+ kind: text("kind").notNull().$type(),
12
+ // Calendar only: raw string the counter's aggregate id was derived from; a normalized instant could point at another counter.
13
+ periodStartIso: text("period_start_iso"),
14
+ amount: integer("amount").notNull(),
15
+ expiresAt: instant("expires_at").notNull(),
16
+ createdAt: instant("created_at").notNull(),
17
+ }, (t) => [index(CAP_RESERVATIONS_TENANT_CAP_EXPIRES_INDEX).on(t.tenantId, t.capName, t.expiresAt)]);
18
+ export const capReservationsTableMeta = defineUnmanagedTable({
19
+ tableName: "store_cap_reservations",
20
+ indexes: [
21
+ {
22
+ name: CAP_RESERVATIONS_TENANT_CAP_EXPIRES_INDEX,
23
+ columns: ["tenant_id", "cap_name", "expires_at"],
24
+ },
25
+ ],
26
+ columns: [
27
+ { name: "id", pgType: "uuid", notNull: true, primaryKey: true },
28
+ { name: "tenant_id", pgType: "uuid", notNull: true },
29
+ { name: "cap_name", pgType: "text", notNull: true },
30
+ { name: "kind", pgType: "text", notNull: true },
31
+ { name: "period_start_iso", pgType: "text", notNull: false },
32
+ { name: "amount", pgType: "integer", notNull: true },
33
+ { name: "expires_at", pgType: "timestamptz", notNull: true },
34
+ { name: "created_at", pgType: "timestamptz", notNull: true },
35
+ ],
36
+ });
@@ -0,0 +1,18 @@
1
+ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { type WriteFailure } from "@cosmicdrift/kumiko-framework/errors";
4
+ import type { CapGuardContext, CapLimitContext } from "./stock-cap-guard.js";
5
+ export type ValueCapSpec<TCaps> = {
6
+ readonly field: string;
7
+ /** `undefined` = no lower bound for this tier. */
8
+ readonly min?: (caps: TCaps) => number | undefined;
9
+ /** `undefined` = no upper bound for this tier. */
10
+ readonly max?: (caps: TCaps) => number | undefined;
11
+ readonly code: string;
12
+ readonly i18nKey: string;
13
+ };
14
+ export type ValueCapGuard<TCaps> = {
15
+ readonly checkValueCap: (ctx: CapGuardContext, value: unknown, spec: ValueCapSpec<TCaps>) => Promise<WriteFailure | null>;
16
+ readonly withValueCap: (handler: WriteHandlerDef, spec: ValueCapSpec<TCaps>) => WriteHandlerDef;
17
+ };
18
+ export declare function createValueCapGuard<TCaps>(resolveTierCaps: (db: TenantDb, context: CapLimitContext) => Promise<TCaps>): ValueCapGuard<TCaps>;
@@ -0,0 +1,33 @@
1
+ // Per-tier bounds on a single written number (e.g. a retention period or a page size),
2
+ // next to the stock-cap guard, which bounds how many rows may exist.
3
+ import { UnprocessableError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { readPayloadField } from "./payload-field.js";
5
+ function isOutOfBounds(value, min, max) {
6
+ return (min !== undefined && value < min) || (max !== undefined && value > max);
7
+ }
8
+ export function createValueCapGuard(resolveTierCaps) {
9
+ async function checkValueCap(ctx, value, spec) {
10
+ // skip: an absent or non-numeric value is the schema's business, not a cap violation.
11
+ if (typeof value !== "number")
12
+ return null;
13
+ const caps = await resolveTierCaps(ctx.db, ctx.config ? { config: ctx.config } : {});
14
+ const min = spec.min?.(caps);
15
+ const max = spec.max?.(caps);
16
+ if (!isOutOfBounds(value, min, max))
17
+ return null;
18
+ return writeFailure(new UnprocessableError(spec.code, {
19
+ i18nKey: spec.i18nKey,
20
+ details: { field: spec.field, value, min, max },
21
+ }));
22
+ }
23
+ function withValueCap(handler, spec) {
24
+ return {
25
+ ...handler,
26
+ handler: async (event, ctx) => {
27
+ const failure = await checkValueCap(ctx, readPayloadField(event.payload, spec.field), spec);
28
+ return failure ?? handler.handler(event, ctx);
29
+ },
30
+ };
31
+ }
32
+ return { checkValueCap, withValueCap };
33
+ }
@@ -44,13 +44,11 @@ export type RollingCapResolver = (event: WriteEvent, ctx: HandlerContext) => Pro
44
44
  /**
45
45
  * Wrap a write-handler with rolling-window cap-enforcement.
46
46
  *
47
- * Same flow as `withCapEnforcement` but uses
48
- * `enforceRollingCapAndMaybeNotify` + dispatches
49
- * `cap-counter:write:increment-rolling` post-success.
47
+ * Same flow as `withCapEnforcement` but uses `enforceRollingCapAndMaybeNotify` and reserves by
48
+ * appending a `rolling-incremented` event before the handler transaction; a release appends
49
+ * `rolling-released`.
50
50
  *
51
- * **Notification-Storm-Caveat:** rolling-counter trackt KEIN
52
- * lastSoftWarnedAt — der Notifier feuert bei JEDEM Call solange
53
- * der counter im soft-Bereich ist. Caller sollte einen TTL-Cache
54
- * (`Map<capName, lastNotifiedAt>`) im notify-callback einbauen.
51
+ * Rolling counters track no lastSoftWarnedAt, so the notifier fires on every call while usage
52
+ * sits in the soft range; throttle inside the notify callback (e.g. a per-capName TTL cache).
55
53
  */
56
54
  export declare function withRollingCapEnforcement(handler: WriteHandlerDef, capResolver: RollingCapResolver): WriteHandlerDef;
@@ -6,26 +6,26 @@
6
6
  // the wrapper keeps the pattern explicit and co-located.
7
7
  //
8
8
  // **Calendar reservation:** the wrapper only declares the cap; the dispatcher runs the
9
- // reservation (reserveBeforeTransaction) before the handler transaction opens, in its own short
10
- // committed write, and releases it after that transaction ended without committing (rollback,
11
- // failure result, failed COMMIT). No connection is held across the handler, so capped requests
12
- // cannot exhaust the pool, and the counter stream is not locked while the handler runs. If a
13
- // COMMIT fails with an unknown outcome the release can under-count; a wrapped handler must not
14
- // book the same counter itself. Reached through a nested ctx.write the cap is not reserved, so
15
- // the dispatcher rejects that call unless the top-level batch reserved the same handler.
9
+ // reservation (reserveBeforeTransaction) after all pre-handler gates and before the handler
10
+ // transaction opens, in its own short committed write (counter increment plus a
11
+ // store_cap_reservations row). The handler transaction deletes the row on its own commit; a release
12
+ // after a rollback, failure result or failed COMMIT only gives back what is still booked, so an
13
+ // unknown COMMIT outcome cannot under-count. No connection is held across the handler. Rows a
14
+ // crashed process left behind expire after CAP_RESERVATION_TTL_MINUTES and are given back before the
15
+ // next reserve of the same cap. Reached through a nested ctx.write the cap is not reserved, so the
16
+ // dispatcher rejects that call unless the top-level batch reserved the same handler.
16
17
  //
17
- // Rolling booking still dispatches the SystemAdmin-only increment-rolling
18
- // handler and has no reservation: it would need a compensating event type,
19
- // a changed readRollingCapUsage and a version-guarded append behind the
20
- // SystemAdmin dispatch. Rolling callers need a SystemAdmin identity until
21
- // cap-counter declares an explicit foreign-booking opt-in.
18
+ // The wrapper spreads the wrapped handler and chains its reserveBeforeTransaction: inner reserves
19
+ // first, release runs in reverse, and an outer failure gives the inner reservation back.
20
+ //
21
+ // Rolling caps reserve the same way: a version-guarded `rolling-incremented` append plus a
22
+ // reservation row, undone by a `rolling-released` event. The window sum is incremented minus released.
22
23
  //
23
24
  // No automatic markSoftWarned here — that's inside enforceCapAndMaybeNotify
24
25
  // (enforce-cap.ts).
25
- import { reraiseAsKumikoError } from "@cosmicdrift/kumiko-framework/errors";
26
- import { bookCapUsage, releaseCapUsage } from "./book-cap-usage.js";
27
- import { CapCounterHandlers } from "./constants.js";
26
+ import { releaseExpiredCapReservations, reserveCalendarCap, reserveRollingCap, } from "./cap-reservation.js";
28
27
  import { assertBelowHardCap, enforceCapAndMaybeNotify, enforceRollingCapAndMaybeNotify, } from "./enforce-cap.js";
28
+ import { chainReservations } from "./reservation-chain.js";
29
29
  /**
30
30
  * Wrap a write-handler with calendar-period cap-enforcement.
31
31
  *
@@ -39,8 +39,9 @@ import { assertBelowHardCap, enforceCapAndMaybeNotify, enforceRollingCapAndMaybe
39
39
  export function withCapEnforcement(handler, capResolver) {
40
40
  return {
41
41
  ...handler,
42
- reserveBeforeTransaction: async (event, ctx) => {
42
+ reserveBeforeTransaction: chainReservations(handler.reserveBeforeTransaction, async (event, ctx) => {
43
43
  const cap = await capResolver(event, ctx);
44
+ await releaseExpiredCapReservations(ctx, cap.capName);
44
45
  await enforceCapAndMaybeNotify(ctx, {
45
46
  capName: cap.capName,
46
47
  periodStartIso: cap.periodStartIso,
@@ -51,47 +52,31 @@ export function withCapEnforcement(handler, capResolver) {
51
52
  ...(cap.amount !== undefined && { amount: cap.amount }),
52
53
  });
53
54
  const amount = cap.amount ?? 1;
54
- const reserved = await bookCapUsage(ctx, {
55
+ return reserveCalendarCap(ctx, {
55
56
  capName: cap.capName,
56
57
  amount,
57
58
  periodStartIso: cap.periodStartIso,
58
- outsideTransaction: true,
59
59
  guardCurrentValue: (currentValue) => assertBelowHardCap(currentValue + amount - 1, cap),
60
60
  });
61
- if (!reserved.isSuccess)
62
- throw reraiseAsKumikoError(reserved.error);
63
- return async () => {
64
- const released = await releaseCapUsage(ctx, {
65
- capName: cap.capName,
66
- amount,
67
- periodStartIso: cap.periodStartIso,
68
- outsideTransaction: true,
69
- });
70
- if (!released.isSuccess)
71
- throw reraiseAsKumikoError(released.error);
72
- };
73
- },
61
+ }),
74
62
  };
75
63
  }
76
64
  /**
77
65
  * Wrap a write-handler with rolling-window cap-enforcement.
78
66
  *
79
- * Same flow as `withCapEnforcement` but uses
80
- * `enforceRollingCapAndMaybeNotify` + dispatches
81
- * `cap-counter:write:increment-rolling` post-success.
67
+ * Same flow as `withCapEnforcement` but uses `enforceRollingCapAndMaybeNotify` and reserves by
68
+ * appending a `rolling-incremented` event before the handler transaction; a release appends
69
+ * `rolling-released`.
82
70
  *
83
- * **Notification-Storm-Caveat:** rolling-counter trackt KEIN
84
- * lastSoftWarnedAt — der Notifier feuert bei JEDEM Call solange
85
- * der counter im soft-Bereich ist. Caller sollte einen TTL-Cache
86
- * (`Map<capName, lastNotifiedAt>`) im notify-callback einbauen.
71
+ * Rolling counters track no lastSoftWarnedAt, so the notifier fires on every call while usage
72
+ * sits in the soft range; throttle inside the notify callback (e.g. a per-capName TTL cache).
87
73
  */
88
74
  export function withRollingCapEnforcement(handler, capResolver) {
89
75
  return {
90
- name: handler.name,
91
- schema: handler.schema,
92
- access: handler.access,
93
- handler: async (event, ctx) => {
76
+ ...handler,
77
+ reserveBeforeTransaction: chainReservations(handler.reserveBeforeTransaction, async (event, ctx) => {
94
78
  const cap = await capResolver(event, ctx);
79
+ await releaseExpiredCapReservations(ctx, cap.capName);
95
80
  await enforceRollingCapAndMaybeNotify(ctx, {
96
81
  capName: cap.capName,
97
82
  windowDays: cap.windowDays,
@@ -99,14 +84,13 @@ export function withRollingCapEnforcement(handler, capResolver) {
99
84
  profile: cap.profile,
100
85
  notify: cap.notify,
101
86
  });
102
- const result = await handler.handler(event, ctx);
103
- if (result.isSuccess) {
104
- await ctx.write(CapCounterHandlers.incrementRolling, {
105
- capName: cap.capName,
106
- amount: cap.amount ?? 1,
107
- });
108
- }
109
- return result;
110
- },
87
+ const amount = cap.amount ?? 1;
88
+ return reserveRollingCap(ctx, {
89
+ capName: cap.capName,
90
+ windowDays: cap.windowDays,
91
+ amount,
92
+ guardCurrentValue: (usage) => assertBelowHardCap(usage + amount - 1, cap),
93
+ });
94
+ }),
111
95
  };
112
96
  }
@@ -1,4 +1,4 @@
1
- import { access } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { access, BUILT_IN_MEMBERSHIP_ROLES } from "@cosmicdrift/kumiko-framework/engine";
2
2
  // Usage numbers are an operator concern by default; wider visibility is an
3
3
  // explicit `usageVisibleTo` opt-in on createCapOverviewFeature. The my-caps
4
4
  // screen and the query that fills its cards must carry the SAME rule, or the
@@ -7,14 +7,7 @@ export const DEFAULT_CAP_USAGE_ROLES = access.roles(...access.admin);
7
7
  // Roles the engine itself knows (role-assignment.ts ranks). App-declared roles
8
8
  // (assignableRole) only exist once the registry is composed, which is after
9
9
  // this feature is defined, so they cannot be validated here.
10
- const BUILT_IN_USAGE_VISIBLE_ROLES = new Set([
11
- "User",
12
- "Member",
13
- "Editor",
14
- "Admin",
15
- "TenantAdmin",
16
- "SystemAdmin",
17
- ]);
10
+ const BUILT_IN_USAGE_VISIBLE_ROLES = new Set(BUILT_IN_MEMBERSHIP_ROLES);
18
11
  export function resolveCapUsageRoles(usageVisibleTo) {
19
12
  if (usageVisibleTo === undefined)
20
13
  return DEFAULT_CAP_USAGE_ROLES;
@@ -4,7 +4,7 @@ import { definePagedQueryHandler, MAX_LIST_LIMIT, } from "@cosmicdrift/kumiko-fr
4
4
  import { InternalError, ValidationError } from "@cosmicdrift/kumiko-framework/errors";
5
5
  import * as z from "zod";
6
6
  import { subscriptionsProjectionTable } from "../../billing-foundation/index.js";
7
- import { tenantTable } from "../../tenant/index.js";
7
+ import { decryptTenantNames, tenantTable } from "../../tenant/index.js";
8
8
  import { tierAssignmentEntity } from "../../tier-engine/index.js";
9
9
  import { capFieldName } from "../constants.js";
10
10
  import { computeFraction } from "../usage-math.js";
@@ -115,7 +115,7 @@ export function createTenantCapsListQuery(caps, listCaps) {
115
115
  });
116
116
  }
117
117
  const db = ctx.systemDb.acknowledgeCrossTenant("cap-overview:tenant-caps:list — SystemAdmin platform-wide tenant overview");
118
- const tenants = await selectMany(db, tenantTable, {});
118
+ const tenants = await decryptTenantNames(await selectMany(db, tenantTable, {}), "cap-overview:tenant-caps:list");
119
119
  const assignments = await selectMany(db, tierAssignmentTable, {});
120
120
  const subscriptions = await selectMany(db, subscriptionsProjectionTable, {});
121
121
  const assignmentByTenant = new Map(assignments.map((row) => [row.tenantId, row]));