@cosmicdrift/kumiko-bundled-features 0.337.1 → 0.338.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 (139) hide show
  1. package/dist/agent-tools/tool-catalog.js +37 -17
  2. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +1 -0
  3. package/dist/auth-email-password/handlers/invite-accept.write.js +1 -0
  4. package/dist/auth-email-password/handlers/invite-info.query.js +1 -0
  5. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +1 -0
  6. package/dist/auth-email-password/handlers/signup-confirm.write.js +1 -0
  7. package/dist/auth-email-password/handlers/signup-request.write.js +1 -1
  8. package/dist/auth-mfa/handlers/enable-start-preauth.write.js +1 -0
  9. package/dist/billing-foundation/aggregate-id.d.ts +2 -0
  10. package/dist/billing-foundation/aggregate-id.js +8 -0
  11. package/dist/billing-foundation/checkout-core.d.ts +3 -0
  12. package/dist/billing-foundation/checkout-core.js +3 -0
  13. package/dist/billing-foundation/constants.d.ts +31 -0
  14. package/dist/billing-foundation/constants.js +35 -0
  15. package/dist/billing-foundation/consumer-protection/confirmation-mail.d.ts +21 -0
  16. package/dist/billing-foundation/consumer-protection/confirmation-mail.js +125 -0
  17. package/dist/billing-foundation/consumer-protection/consent-gate.d.ts +32 -0
  18. package/dist/billing-foundation/consumer-protection/consent-gate.js +88 -0
  19. package/dist/billing-foundation/consumer-protection/consent-locale.d.ts +5 -0
  20. package/dist/billing-foundation/consumer-protection/consent-locale.js +17 -0
  21. package/dist/billing-foundation/consumer-protection/consent-text.d.ts +11 -0
  22. package/dist/billing-foundation/consumer-protection/consent-text.js +28 -0
  23. package/dist/billing-foundation/consumer-protection/issue-confirmation.d.ts +15 -0
  24. package/dist/billing-foundation/consumer-protection/issue-confirmation.js +149 -0
  25. package/dist/billing-foundation/consumer-protection/register-consent.d.ts +3 -0
  26. package/dist/billing-foundation/consumer-protection/register-consent.js +63 -0
  27. package/dist/billing-foundation/consumer-protection/register-termination.d.ts +3 -0
  28. package/dist/billing-foundation/consumer-protection/register-termination.js +12 -0
  29. package/dist/billing-foundation/consumer-protection/termination-mail.d.ts +43 -0
  30. package/dist/billing-foundation/consumer-protection/termination-mail.js +76 -0
  31. package/dist/billing-foundation/consumer-protection/termination-pages.d.ts +8 -0
  32. package/dist/billing-foundation/consumer-protection/termination-pages.js +239 -0
  33. package/dist/billing-foundation/consumer-protection/termination-record.d.ts +51 -0
  34. package/dist/billing-foundation/consumer-protection/termination-record.js +162 -0
  35. package/dist/billing-foundation/consumer-protection/termination-request.d.ts +45 -0
  36. package/dist/billing-foundation/consumer-protection/termination-request.js +263 -0
  37. package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +62 -0
  38. package/dist/billing-foundation/consumer-protection/termination-texts.js +168 -0
  39. package/dist/billing-foundation/events.d.ts +86 -2
  40. package/dist/billing-foundation/events.js +76 -2
  41. package/dist/billing-foundation/feature.js +6 -2
  42. package/dist/billing-foundation/handlers/billing-plans.query.js +19 -1
  43. package/dist/billing-foundation/handlers/create-checkout-session.write.js +33 -2
  44. package/dist/billing-foundation/handlers/process-event.write.d.ts +2 -2
  45. package/dist/billing-foundation/handlers/process-event.write.js +43 -15
  46. package/dist/billing-foundation/handlers/process-payment-event.write.d.ts +2 -1
  47. package/dist/billing-foundation/handlers/process-payment-event.write.js +6 -2
  48. package/dist/billing-foundation/handlers/start-plan-checkout.write.js +15 -1
  49. package/dist/billing-foundation/handlers/sync-subscription.write.js +0 -1
  50. package/dist/billing-foundation/i18n.js +58 -0
  51. package/dist/billing-foundation/index.d.ts +3 -2
  52. package/dist/billing-foundation/index.js +2 -1
  53. package/dist/billing-foundation/types.d.ts +49 -9
  54. package/dist/billing-foundation/validate-options.js +47 -10
  55. package/dist/billing-foundation/web/billing-plans-panel.js +40 -3
  56. package/dist/billing-foundation/web/cancel-contract-dialog.d.ts +7 -0
  57. package/dist/billing-foundation/web/cancel-contract-dialog.js +57 -0
  58. package/dist/billing-foundation/web/checkout-consent-dialog.d.ts +28 -0
  59. package/dist/billing-foundation/web/checkout-consent-dialog.js +38 -0
  60. package/dist/billing-foundation/webhook-handler.js +5 -2
  61. package/dist/cap-counter/with-cap-enforcement.js +7 -3
  62. package/dist/cap-overview/access.d.ts +2 -1
  63. package/dist/cap-overview/access.js +26 -5
  64. package/dist/cap-overview/feature.d.ts +4 -0
  65. package/dist/cap-overview/feature.js +8 -6
  66. package/dist/cap-overview/handlers/caps-usage.query.d.ts +1 -1
  67. package/dist/cap-overview/handlers/caps-usage.query.js +6 -4
  68. package/dist/cap-overview/handlers/tenant-caps-list.query.js +12 -7
  69. package/dist/cap-overview/index.d.ts +2 -2
  70. package/dist/cap-overview/index.js +2 -2
  71. package/dist/cap-overview/screens.d.ts +1 -1
  72. package/dist/cap-overview/screens.js +17 -16
  73. package/dist/config/handlers/set.write.js +18 -2
  74. package/dist/crypto-shredding/feature.js +3 -2
  75. package/dist/crypto-shredding/handlers/forget-subject.write.d.ts +1 -1
  76. package/dist/crypto-shredding/handlers/forget-subject.write.js +24 -13
  77. package/dist/custom-fields/feature.js +9 -6
  78. package/dist/custom-fields/handlers/clear-custom-field.write.js +1 -0
  79. package/dist/custom-fields/handlers/define-tenant-field.write.js +1 -0
  80. package/dist/custom-fields/handlers/set-custom-field.write.js +1 -0
  81. package/dist/delivery/delivery-service.js +9 -7
  82. package/dist/document-ingest-foundation/feature.js +3 -2
  83. package/dist/form-draft/handlers/discard.write.js +1 -0
  84. package/dist/form-draft/handlers/save.write.js +1 -0
  85. package/dist/inbound-mail-foundation/handlers/ingest-message.write.js +1 -0
  86. package/dist/managed-pages/handlers/set.write.js +1 -0
  87. package/dist/notes-history/executor.d.ts +2 -0
  88. package/dist/notes-history/executor.js +9 -1
  89. package/dist/notes-history/feature.d.ts +2 -3
  90. package/dist/notes-history/feature.js +2 -1
  91. package/dist/notes-history/handlers/add-note.write.d.ts +2 -1
  92. package/dist/notes-history/handlers/add-note.write.js +20 -3
  93. package/dist/notes-history-user-data/hooks.js +26 -8
  94. package/dist/sessions/handlers/revoke-all-for-user.write.js +1 -0
  95. package/dist/subscription-mollie/plugin-methods.js +1 -0
  96. package/dist/subscription-mollie/verify-webhook.js +5 -3
  97. package/dist/subscription-stripe/plugin-methods.d.ts +3 -1
  98. package/dist/subscription-stripe/plugin-methods.js +78 -13
  99. package/dist/subscription-stripe/verify-webhook.js +7 -3
  100. package/dist/tags/executor.d.ts +2 -0
  101. package/dist/tags/executor.js +9 -1
  102. package/dist/tags/feature.d.ts +4 -3
  103. package/dist/tags/feature.js +4 -2
  104. package/dist/tags/handlers/assign-tag.write.d.ts +2 -1
  105. package/dist/tags/handlers/assign-tag.write.js +4 -4
  106. package/dist/tags/handlers/remove-tag.write.d.ts +2 -1
  107. package/dist/tags/handlers/remove-tag.write.js +3 -3
  108. package/dist/tenant-handover/handlers/claim.write.js +1 -0
  109. package/dist/tenant-lifecycle/handlers/cancel-destruction.write.js +1 -0
  110. package/dist/tenant-lifecycle/handlers/request-destruction.write.js +1 -0
  111. package/dist/tier-engine/handlers/get-tenant-tier.query.js +1 -0
  112. package/dist/tier-engine/handlers/set-tenant-tier.write.js +1 -0
  113. package/dist/user/handlers/detail.query.js +1 -0
  114. package/dist/user/handlers/list.query.js +1 -0
  115. package/dist/user-data-rights/handlers/cancel-deletion.write.js +1 -0
  116. package/dist/user-data-rights/handlers/download-attempt-list.query.js +1 -0
  117. package/dist/user-data-rights/handlers/download-by-job.query.js +1 -0
  118. package/dist/user-data-rights/handlers/download-by-token.query.js +1 -0
  119. package/dist/user-data-rights/handlers/export-status.query.js +1 -0
  120. package/dist/user-data-rights/handlers/lift-restriction.write.js +1 -0
  121. package/dist/user-data-rights/handlers/my-audit-log.query.js +1 -0
  122. package/dist/user-data-rights/handlers/request-deletion-by-email.write.js +1 -0
  123. package/dist/user-data-rights/handlers/request-export.write.js +1 -0
  124. package/dist/user-data-rights/handlers/run-forget-cleanup.write.js +1 -0
  125. package/dist/user-data-rights/run-forget-cleanup.js +30 -15
  126. package/package.json +9 -9
  127. package/src/agent-tools/changes.json +14 -0
  128. package/src/billing-foundation/changes.json +49 -0
  129. package/src/cap-counter/changes.json +7 -0
  130. package/src/cap-overview/changes.json +14 -0
  131. package/src/config/changes.json +7 -0
  132. package/src/crypto-shredding/changes.json +13 -0
  133. package/src/custom-fields/changes.json +7 -0
  134. package/src/delivery/changes.json +7 -0
  135. package/src/notes-history/changes.json +7 -0
  136. package/src/subscription-mollie/changes.json +13 -0
  137. package/src/subscription-stripe/changes.json +13 -0
  138. package/src/tags/changes.json +7 -0
  139. package/src/user-data-rights/changes.json +6 -0
@@ -1,6 +1,6 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { buildEntityTable, decodeCursor, encodeCursor } from "@cosmicdrift/kumiko-framework/db";
3
- import { definePagedQueryHandler, MAX_LIST_LIMIT } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { definePagedQueryHandler, MAX_LIST_LIMIT, } from "@cosmicdrift/kumiko-framework/engine";
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";
@@ -21,11 +21,16 @@ function billingLabel(subscription, tierSource) {
21
21
  }
22
22
  const FILTER_OP = z.enum(["eq", "ne", "lt", "gt", "in"]);
23
23
  const SORTABLE_FIELDS = ["name", "tier", "billing"];
24
- function capTierKey(capId, tier) {
25
- return JSON.stringify([capId, tier]);
24
+ function tenantCapKey(tenantId, capId) {
25
+ return JSON.stringify([tenantId, capId]);
26
26
  }
27
- async function resolveLimitsByCapAndTier(caps, tiers, context) {
28
- const entries = await Promise.all(caps.flatMap((cap) => [...tiers].map(async (tier) => [capTierKey(cap.id, tier), await cap.limit(tier, context)])));
27
+ // One config accessor per tenant: limits may read tenant-scoped config keys, so
28
+ // a limit shared across tenants of the same tier would show the wrong value.
29
+ async function resolveLimitsByTenantAndCap(caps, tenants, configFor) {
30
+ const entries = await Promise.all(tenants.flatMap(({ tenantId, tier }) => {
31
+ const context = { config: configFor?.(tenantId) };
32
+ return caps.map(async (cap) => [tenantCapKey(tenantId, cap.id), await cap.limit(tier, context)]);
33
+ }));
29
34
  return new Map(entries);
30
35
  }
31
36
  function isSortableField(field) {
@@ -183,7 +188,7 @@ export function createTenantCapsListQuery(caps, listCaps) {
183
188
  const perTenantEntries = await Promise.all(pageTenantIds.map(async (tenantId) => [tenantId, await cap.usage(db, tenantId)]));
184
189
  usageByCap.set(cap.id, new Map(perTenantEntries));
185
190
  }));
186
- const limitByCapAndTier = await resolveLimitsByCapAndTier(listedCaps, new Set(page.map((row) => row.tier)), { config: ctx.config });
191
+ const limitByTenantAndCap = await resolveLimitsByTenantAndCap(listedCaps, page, ctx.configFor);
187
192
  const rows = page.map((row) => {
188
193
  const capFields = {};
189
194
  for (const cap of listedCaps) {
@@ -191,7 +196,7 @@ export function createTenantCapsListQuery(caps, listCaps) {
191
196
  // only an explicit `null` value means "not measured".
192
197
  const rawUsed = usageByCap.get(cap.id)?.get(row.tenantId);
193
198
  const used = rawUsed === undefined ? 0 : rawUsed;
194
- const limit = limitByCapAndTier.get(capTierKey(cap.id, row.tier)) ?? null;
199
+ const limit = limitByTenantAndCap.get(tenantCapKey(row.tenantId, cap.id)) ?? null;
195
200
  capFields[capFieldName(cap.id)] =
196
201
  used === null
197
202
  ? { used: null, limit, fraction: 0 }
@@ -1,6 +1,6 @@
1
- export { MY_CAPS_ACCESS_ROLES } from "./access.js";
1
+ export { DEFAULT_CAP_USAGE_ROLES } from "./access.js";
2
2
  export { CAP_CARDS_PANEL_COMPONENT, CAP_OVERVIEW_FEATURE, CAP_USAGE_CELL_COMPONENT, CapOverviewQueries, capFieldName, MY_CAPS_SCREEN_ID, PLATFORM_TENANT_CAPS_SCREEN_ID, TENANT_CAP_LIST_SCREEN_ID, } from "./constants.js";
3
3
  export { type CreateCapOverviewOptions, createCapOverviewFeature } from "./feature.js";
4
- export { createTenantCapListScreen, myCapsScreen, platformTenantCapsScreen, } from "./screens.js";
4
+ export { createMyCapsScreen, createTenantCapListScreen, platformTenantCapsScreen, } from "./screens.js";
5
5
  export type { CapSpec, CapUsage, CapUsageTone, CapUsageWithMeta } from "./types.js";
6
6
  export { computeFraction, computeTone } from "./usage-math.js";
@@ -1,6 +1,6 @@
1
1
  // Public API of the cap-overview bundled-feature.
2
- export { MY_CAPS_ACCESS_ROLES } from "./access.js";
2
+ export { DEFAULT_CAP_USAGE_ROLES } from "./access.js";
3
3
  export { CAP_CARDS_PANEL_COMPONENT, CAP_OVERVIEW_FEATURE, CAP_USAGE_CELL_COMPONENT, CapOverviewQueries, capFieldName, MY_CAPS_SCREEN_ID, PLATFORM_TENANT_CAPS_SCREEN_ID, TENANT_CAP_LIST_SCREEN_ID, } from "./constants.js";
4
4
  export { createCapOverviewFeature } from "./feature.js";
5
- export { createTenantCapListScreen, myCapsScreen, platformTenantCapsScreen, } from "./screens.js";
5
+ export { createMyCapsScreen, createTenantCapListScreen, platformTenantCapsScreen, } from "./screens.js";
6
6
  export { computeFraction, computeTone } from "./usage-math.js";
@@ -1,5 +1,5 @@
1
1
  import type { ScreenDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import type { CapSpec } from "./types.js";
3
3
  export declare function createTenantCapListScreen(caps: readonly CapSpec[], listCaps: readonly string[], tiers?: readonly string[]): ScreenDefinition;
4
- export declare const myCapsScreen: ScreenDefinition;
4
+ export declare function createMyCapsScreen(usageRoles: readonly string[]): ScreenDefinition;
5
5
  export declare const platformTenantCapsScreen: ScreenDefinition;
@@ -1,4 +1,3 @@
1
- import { MY_CAPS_ACCESS_ROLES } from "./access.js";
2
1
  import { CAP_CARDS_PANEL_COMPONENT, CAP_USAGE_CELL_COMPONENT, CapOverviewQueries, capFieldName, MY_CAPS_SCREEN_ID, PLATFORM_TENANT_CAPS_SCREEN_ID, TENANT_CAP_LIST_SCREEN_ID, } from "./constants.js";
3
2
  // `tiers` has no room for a separate per-tier display label (it's just
4
3
  // `readonly string[]`) — tier-engine itself has no enumerated tier list to
@@ -55,21 +54,23 @@ export function createTenantCapListScreen(caps, listCaps, tiers) {
55
54
  dormant: true,
56
55
  };
57
56
  }
58
- export const myCapsScreen = {
59
- id: MY_CAPS_SCREEN_ID,
60
- type: "dashboard",
61
- panels: [
62
- {
63
- kind: "custom",
64
- id: "cap-cards",
65
- component: { react: { __component: CAP_CARDS_PANEL_COMPONENT } },
66
- },
67
- ],
68
- access: { roles: MY_CAPS_ACCESS_ROLES },
69
- // No nav wiring yet (see feature.ts header) — no nav area to resolve in
70
- // isolation.
71
- dormant: true,
72
- };
57
+ export function createMyCapsScreen(usageRoles) {
58
+ return {
59
+ id: MY_CAPS_SCREEN_ID,
60
+ type: "dashboard",
61
+ panels: [
62
+ {
63
+ kind: "custom",
64
+ id: "cap-cards",
65
+ component: { react: { __component: CAP_CARDS_PANEL_COMPONENT } },
66
+ },
67
+ ],
68
+ access: { roles: usageRoles },
69
+ // No nav wiring yet (see feature.ts header) — no nav area to resolve in
70
+ // isolation.
71
+ dormant: true,
72
+ };
73
+ }
73
74
  export const platformTenantCapsScreen = {
74
75
  id: PLATFORM_TENANT_CAPS_SCREEN_ID,
75
76
  type: "dashboard",
@@ -2,7 +2,7 @@ import { requestContext } from "@cosmicdrift/kumiko-framework/api";
2
2
  import { configuredPiiSubjectKms, encryptPiiValueForSubject, } from "@cosmicdrift/kumiko-framework/crypto";
3
3
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
4
4
  import { ConfigScopes, defineWriteHandler, isEncryptedAtRest, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
5
- import { InternalError, UnprocessableError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
5
+ import { InternalError, isKumikoError, UnprocessableError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
6
6
  import * as z from "zod";
7
7
  import { requireConfigEncryption, requireSystemDb } from "../feature.js";
8
8
  import { MASKED } from "../read-redaction.js";
@@ -14,7 +14,7 @@ const executor = createEventStoreExecutor(configValuesTable, configValueEntity,
14
14
  });
15
15
  export const setWrite = defineWriteHandler({
16
16
  name: "set",
17
- description: "Stores a config value for one key at the requested scope (user, tenant or system) after type, bounds and pattern validation, encrypting it when the key declares that; use it to change a setting.",
17
+ description: "Stores a config value for one key at the requested scope (user, tenant or system) after type, bounds, pattern and the key's own validator, encrypting it when the key declares that; use it to change a setting.",
18
18
  schema: z.object({
19
19
  key: z.string(),
20
20
  value: z.union([z.string(), z.number(), z.boolean()]),
@@ -67,6 +67,22 @@ export const setWrite = defineWriteHandler({
67
67
  const patternError = validatePattern(event.payload.value, keyDef);
68
68
  if (patternError)
69
69
  return writeFailure(patternError);
70
+ if (keyDef.validate) {
71
+ try {
72
+ await keyDef.validate(event.payload.value, {
73
+ key: event.payload.key,
74
+ scope,
75
+ tenantId,
76
+ userId,
77
+ db,
78
+ });
79
+ }
80
+ catch (error) {
81
+ if (isKumikoError(error))
82
+ return writeFailure(error);
83
+ throw error;
84
+ }
85
+ }
70
86
  // The batch idempotency cache stores this result and only masks entity
71
87
  // writeOnly fields, so an at-rest-encrypted value must not be echoed.
72
88
  const echoedValue = isEncryptedAtRest(keyDef) ? MASKED : event.payload.value;
@@ -8,8 +8,9 @@ export function createCryptoShreddingFeature() {
8
8
  displayLabel: "Crypto-Shredding",
9
9
  category: "compliance",
10
10
  });
11
- // `reason` is operator free text and may name the subject; encrypting it under
12
- // the subject key is pointless (that key is being erased) — see #2776.
11
+ // `reason` is operator free text and may name the subject, yet stays plaintext: the forget
12
+ // erases the subject key before the audit append, so a forget of the actor's own tenant
13
+ // would fail to seal the reason under that tenant's key.
13
14
  r.defineEvent("subject-forgotten", subjectForgottenSchema, { piiFields: "none" });
14
15
  r.defineEvent("forget-denied", subjectForgetDeniedSchema, { piiFields: "none" });
15
16
  r.writeHandler(forgetSubjectWrite);
@@ -21,7 +21,7 @@ export declare const subjectForgottenSchema: z.ZodObject<{
21
21
  forgottenBy: z.ZodString;
22
22
  }, z.core.$strip>;
23
23
  export declare const subjectForgetDeniedSchema: z.ZodObject<{
24
- subjectKeyDigest: z.ZodString;
24
+ subjectKeyDigest: z.ZodOptional<z.ZodString>;
25
25
  subjectKind: z.ZodEnum<{
26
26
  record: "record";
27
27
  tenant: "tenant";
@@ -1,8 +1,7 @@
1
- import { createHash } from "node:crypto";
2
1
  import { requestContext } from "@cosmicdrift/kumiko-framework/api";
3
2
  import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
4
- import { configuredPiiSubjectKms, subjectIdSchema, subjectIdToKey, } from "@cosmicdrift/kumiko-framework/crypto";
5
- import { nullBlindIndexesForSubject, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "@cosmicdrift/kumiko-framework/db";
3
+ import { computeBlindIndex, configuredBlindIndexKey, configuredPiiSubjectKms, subjectIdSchema, subjectIdToKey, } from "@cosmicdrift/kumiko-framework/crypto";
4
+ import { nullBlindIndexesForSubject, recordEventsOwnedExclusivelyByTenant, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "@cosmicdrift/kumiko-framework/db";
6
5
  import { defineWriteHandler, } from "@cosmicdrift/kumiko-framework/engine";
7
6
  import { AccessDeniedError, InternalError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
8
7
  import { append } from "@cosmicdrift/kumiko-framework/event-store";
@@ -26,7 +25,7 @@ export const subjectForgottenSchema = z.object({
26
25
  forgottenBy: z.string().min(1),
27
26
  });
28
27
  export const subjectForgetDeniedSchema = z.object({
29
- subjectKeyDigest: z.string().min(1),
28
+ subjectKeyDigest: z.string().min(1).optional(),
30
29
  subjectKind: z.enum(["user", "tenant", "record"]),
31
30
  reason: z.string().min(10),
32
31
  forgottenBy: z.string().min(1),
@@ -78,13 +77,20 @@ async function resolveTenantScopeDenial(db, features, user, raw) {
78
77
  if (!features.has("tenant"))
79
78
  return undefined;
80
79
  const entity = findRegisteredEntity(features, raw.entity);
81
- if (!entity) {
82
- return writeFailure(new AccessDeniedError({ details: { reason: RECORD_ENTITY_NOT_REGISTERED } }));
80
+ const rowInTenant = entity !== undefined &&
81
+ (await recordRowExistsInTenant(db, features, raw.entity, raw.id, user.tenantId));
82
+ if (rowInTenant)
83
+ return undefined;
84
+ // A deleted row or a custom aggregate without an entity has no projection
85
+ // row to check; its event stream still proves which tenant owns it.
86
+ if (await recordEventsOwnedExclusivelyByTenant(db, raw.entity, raw.id, user.tenantId)) {
87
+ return undefined;
83
88
  }
84
- const ownedInTenant = await recordRowExistsInTenant(db, features, raw.entity, raw.id, user.tenantId);
85
- return ownedInTenant
86
- ? undefined
87
- : writeFailure(new AccessDeniedError({ details: { reason: TARGET_RECORD_NOT_ADMIN_TENANT } }));
89
+ return writeFailure(new AccessDeniedError({
90
+ details: {
91
+ reason: entity ? TARGET_RECORD_NOT_ADMIN_TENANT : RECORD_ENTITY_NOT_REGISTERED,
92
+ },
93
+ }));
88
94
  }
89
95
  // Without the tenant feature there's no membership table to check against —
90
96
  // no scoping concept exists to enforce (single/no-tenant apps only;
@@ -160,15 +166,19 @@ async function appendDenialAuditEvent(ctx, event, subjectKey, subjectKind, denia
160
166
  message: `[crypto-shredding] event "${SUBJECT_FORGET_DENIED_EVENT_NAME}" is not registered.`,
161
167
  }));
162
168
  }
169
+ const blindIndexKey = configuredBlindIndexKey();
163
170
  const payload = subjectForgetDeniedSchema.parse({
164
171
  // The denial lands in the REQUESTING actor's own tenant-scoped stream —
165
172
  // it must never materialise the foreign subject's identifiers there. A
166
173
  // plaintext subjectKey/aggregateId would survive as a permanent record
167
174
  // for the prober and would still be present when the owning tenant
168
175
  // later runs its own (legitimate) forget-subject for that subject. The
169
- // digest still lets an operator correlate repeated probes of the same
170
- // subject without exposing it (fw#2452).
171
- subjectKeyDigest: createHash("sha256").update(subjectKey, "utf8").digest("base64url"),
176
+ // digest is keyed (a bare hash of a guessable id could be recomputed from
177
+ // a candidate id) and lets an operator correlate repeated probes of the
178
+ // same subject; without a blind-index key no digest is stored at all.
179
+ ...(blindIndexKey !== undefined && {
180
+ subjectKeyDigest: computeBlindIndex(blindIndexKey, subjectKey),
181
+ }),
172
182
  subjectKind,
173
183
  reason: event.payload.reason,
174
184
  forgottenBy: event.user.id,
@@ -216,6 +226,7 @@ export const forgetSubjectWrite = defineWriteHandler({
216
226
  // not be able to reach it at all.
217
227
  agent: { expose: false, risk: "high" },
218
228
  escapeHatch: {
229
+ grants: ["unsafeRaw"],
219
230
  reason: "denial audit append names the prober's own tenant stream on the outside-transaction db; " +
220
231
  "the tenant-scope and retention checks run against the subject's tenant, not necessarily the caller's; " +
221
232
  "the blind-index sweep and search purge address the subject across tenants; the user " +
@@ -136,12 +136,15 @@ export function resolveFieldDefinitionListRoles(opts) {
136
136
  if (opts.valueWriteRoles === undefined && opts.fieldDefinitionWriteRoles === undefined) {
137
137
  return DEFAULT_FIELD_DEFINITION_LIST_ROLES;
138
138
  }
139
- // Write-roles override replaces the default role vocab — don't keep unioning
140
- // TenantAdmin onto list when the app opted into its own write roles.
141
- if (opts.fieldDefinitionWriteRoles !== undefined) {
142
- return [...new Set([...(opts.valueWriteRoles ?? []), ...opts.fieldDefinitionWriteRoles])];
143
- }
144
- return [...new Set([...(opts.valueWriteRoles ?? []), ...DEFAULT_FIELD_DEFINITION_LIST_ROLES])];
139
+ // The default stays in the union: with valueWriteRoles unset, TenantAdmin still
140
+ // saves values and must be able to load the FormSection.
141
+ return [
142
+ ...new Set([
143
+ ...(opts.valueWriteRoles ?? []),
144
+ ...(opts.fieldDefinitionWriteRoles ?? []),
145
+ ...DEFAULT_FIELD_DEFINITION_LIST_ROLES,
146
+ ]),
147
+ ];
145
148
  }
146
149
  // Backwards-compat-wrapper. Bestehende Caller (z.B. integration-tests,
147
150
  // host-apps) nutzen weiterhin `createCustomFieldsFeature()`. Returnt den
@@ -26,6 +26,7 @@ export const clearCustomFieldHandler = {
26
26
  access: { roles: DEFAULT_VALUE_WRITE_ROLES },
27
27
  description: "Removes the stored value of one custom field from a single host entity row after re-checking that field's per-field write roles; use it to blank a field the user emptied instead of writing a null value.",
28
28
  escapeHatch: {
29
+ grants: ["unsafeRaw"],
29
30
  reason: CLEAR_CUSTOM_FIELD_REASON,
30
31
  },
31
32
  handler: async (event, ctx) => {
@@ -15,6 +15,7 @@ export function createDefineTenantFieldHandler(opts = {}) {
15
15
  access: { roles: opts.roles ?? DEFAULT_FIELD_DEFINITION_WRITE_ROLES },
16
16
  description: "Creates a custom-field definition owned by the caller's own tenant on the named entity, rejecting the write once the tenant's definition quota is reached; use it when one tenant needs an extra field the other tenants must not see.",
17
17
  escapeHatch: {
18
+ grants: ["unsafeRaw"],
18
19
  reason: DEFINE_TENANT_FIELD_REASON,
19
20
  },
20
21
  handler: async (event, ctx) => {
@@ -49,6 +49,7 @@ export const setCustomFieldHandler = {
49
49
  access: { roles: DEFAULT_VALUE_WRITE_ROLES },
50
50
  description: "Stores one custom-field value on a single host entity row after validating it against the field definition's declared type and per-field write roles; use it to save what a user entered into a custom field.",
51
51
  escapeHatch: {
52
+ grants: ["unsafeRaw"],
52
53
  reason: SET_CUSTOM_FIELD_REASON,
53
54
  },
54
55
  handler: async (event, ctx) => {
@@ -88,16 +88,18 @@ export function createDeliveryService(options) {
88
88
  sink: escapeHatchAuditSink,
89
89
  log,
90
90
  });
91
- // Mirrors buildHandlerContext (pipeline/dispatch-shared.ts): a non-systemScope
92
- // tenantUserIdsQuery must get the same tenant-filtered db/no-systemDb context
93
- // the real dispatcher would give it — handing it "system" mode + a systemDb
94
- // view regardless would grant cross-tenant reach the dispatcher never would.
91
+ // ctx.db/dbOutsideTransaction are tenant-filtered even for a systemScope handler:
92
+ // the dispatcher closes them off entirely there, so "system" mode would hand this
93
+ // path a cross-tenant read the dispatcher never allows. Cross-tenant reach only
94
+ // exists through the grant-carrying systemDb view below.
95
95
  const isSystem = registry.isHandlerSystemScoped(tenantUserIdsQuery);
96
- const tenantDb = createTenantDb(db, tenantId, isSystem ? "system" : "tenant", undefined, undefined, undefined, { report });
96
+ const tenantDb = createTenantDb(db, tenantId, "tenant", undefined, undefined, undefined, {
97
+ report,
98
+ });
97
99
  // The dispatcher grants a systemScope handler's ctx.systemDb.unsafeRaw() ungated —
98
100
  // the grant comes from the handler's own r.systemScope() registration, not from this
99
- // caller. ctx.db/dbOutsideTransaction stay on the grant-less tenantDb above; only the
100
- // systemDb view's own source TenantDb carries the grant, so it can't leak onto ctx.db.
101
+ // caller. Only the systemDb view's own source TenantDb carries the grant, so it
102
+ // can't leak onto ctx.db.
101
103
  const systemDbSourceTenantDb = isSystem
102
104
  ? createTenantDb(db, tenantId, "system", undefined, undefined, undefined, {
103
105
  report,
@@ -114,8 +114,9 @@ export const documentIngestFoundationFeature = defineFeature(FEATURE_NAME, (r) =
114
114
  write: access.roles("TenantAdmin", "SystemAdmin"),
115
115
  read: access.roles("TenantAdmin", "SystemAdmin", "User"),
116
116
  }));
117
- // "fileName" can carry a real person's name; the payload has no user-subject
118
- // field to encrypt it under, so piiFields stays "none" for now.
117
+ // "fileName" can carry a real person's name, but it reaches both events already as the
118
+ // fileRef's own self-subject ciphertext (copied from fileRef.created/restored), which
119
+ // append() passes through; shredding the fileRef's key shreds it here too.
119
120
  r.defineEvent(DOCUMENT_INGEST_REQUESTED_EVENT_SHORT, documentIngestRequestedPayloadSchema, {
120
121
  piiFields: "none",
121
122
  version: 2,
@@ -29,6 +29,7 @@ export const discardDraftWrite = defineWriteHandler({
29
29
  description: "Deletes the calling user's draft for one draftKey and, when releaseFiles is set, hard-deletes the file references that draft alone uploaded; use it after a successful submit or when the user abandons the form.",
30
30
  agent: { risk: "high" },
31
31
  escapeHatch: {
32
+ grants: ["unsafeRaw"],
32
33
  reason: DISCARD_DRAFT_FILE_REFS_REASON,
33
34
  },
34
35
  handler: async (event, ctx) => {
@@ -27,6 +27,7 @@ export const saveDraftWrite = defineWriteHandler({
27
27
  access: FORM_DRAFT_ACCESS,
28
28
  description: "Upserts the calling user's draft for one draftKey with the given form values and step index, stamping savedAt server-side and refusing a brand-new draft once the per-owner draft cap is reached; use it to persist an in-progress form before the real entity exists.",
29
29
  escapeHatch: {
30
+ grants: ["unsafeRaw"],
30
31
  reason: SAVE_DRAFT_COUNT_REASON,
31
32
  },
32
33
  handler: async (event, ctx) => {
@@ -91,6 +91,7 @@ export const ingestMessageHandler = {
91
91
  access: { roles: ["SystemAdmin"] },
92
92
  agent: { expose: false },
93
93
  escapeHatch: {
94
+ grants: ["unsafeRaw"],
94
95
  reason: THREAD_ROLLUP_ADVISORY_LOCK_REASON,
95
96
  },
96
97
  handler: async (event, ctx) => {
@@ -38,6 +38,7 @@ export const setWrite = defineWriteHandler({
38
38
  access: { roles: ["TenantAdmin", "SystemAdmin"] },
39
39
  description: "Creates or overwrites one managed page addressed by slug and language, keeping the existing published flag, description and OG image when the payload omits them; use it for content edits and publish toggles, and as SystemAdmin to write another tenant's pages.",
40
40
  escapeHatch: {
41
+ grants: ["unsafeRaw"],
41
42
  reason: SET_PAGE_TENANT_OVERRIDE_REASON,
42
43
  },
43
44
  handler: async (event, ctx) => {
@@ -1,2 +1,4 @@
1
+ import { type EntityDefinition } from "@cosmicdrift/kumiko-framework/engine";
1
2
  export declare const noteEntryExecutor: import("@cosmicdrift/kumiko-types/event-store-executor-types").EventStoreExecutor, noteEntryTable: import("@cosmicdrift/kumiko-framework/db").EntityTable;
3
+ export declare function createNoteEntryExecutor(ownership: EntityDefinition["access"] | undefined): typeof noteEntryExecutor;
2
4
  export declare const noteMentionExecutor: import("@cosmicdrift/kumiko-types/event-store-executor-types").EventStoreExecutor, noteMentionTable: import("@cosmicdrift/kumiko-framework/db").EntityTable;
@@ -1,4 +1,12 @@
1
1
  import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
2
- import { noteEntryEntity, noteMentionEntity } from "./entity.js";
2
+ import { createNoteEntryEntity, noteEntryEntity, noteMentionEntity } from "./entity.js";
3
3
  export const { executor: noteEntryExecutor, table: noteEntryTable } = createEntityExecutor("note-entry", noteEntryEntity);
4
+ // Carries only `ownership.write`: the executor enforces entity.access.write on
5
+ // create, while read ownership is applied by the list handler.
6
+ export function createNoteEntryExecutor(ownership) {
7
+ if (ownership?.write === undefined)
8
+ return noteEntryExecutor;
9
+ return createEntityExecutor("note-entry", createNoteEntryEntity({ write: ownership.write }))
10
+ .executor;
11
+ }
4
12
  export const { executor: noteMentionExecutor, table: noteMentionTable } = createEntityExecutor("note-mention", noteMentionEntity);
@@ -19,9 +19,8 @@ export type NotesHistoryFeatureOptions = {
19
19
  * it is AND-ed with the host-visibility gate, never a replacement for it.
20
20
  *
21
21
  * `ownership.write` is separate and does NOT affect list/read. It's
22
- * consulted by the framework's generic delete/forget/restore paths (not
23
- * by this feature's own add-note handler — see createNotesHistoryFeature's
24
- * boot-guard comment). A `from()` rule there also gates GDPR erasure
22
+ * consulted on add-note (create) and the framework's generic
23
+ * forget/restore paths. A `from()` rule there also gates GDPR erasure
25
24
  * (`forget`): if the rule's role map doesn't cover whatever role the
26
25
  * erasure/retention pipeline runs as, `forget` denies instead of
27
26
  * crypto-shredding — a silent Art.17 failure, not a thrown error. Make
@@ -16,6 +16,7 @@ import { defineEntityListHandler, defineFeature, } from "@cosmicdrift/kumiko-fra
16
16
  import { hasWhereRule } from "../shared/index.js";
17
17
  import { DEFAULT_NOTES_HISTORY_ACCESS, NOTES_HISTORY_FEATURE_NAME } from "./constants.js";
18
18
  import { createNoteEntryEntity, noteMentionEntity } from "./entity.js";
19
+ import { createNoteEntryExecutor } from "./executor.js";
19
20
  import { createAddNoteHandler } from "./handlers/add-note.write.js";
20
21
  import { NOTES_HISTORY_FEATURE_I18N } from "./i18n.js";
21
22
  function registerNotesHistory(r, access, ownership, parents) {
@@ -33,7 +34,7 @@ function registerNotesHistory(r, access, ownership, parents) {
33
34
  // just building it inline in the handler) is what makes it visible to the
34
35
  // registry-wide GDPR boot guards and to executor.ts's table/projection setup.
35
36
  r.entity("note-mention", noteMentionEntity);
36
- r.writeHandler(createAddNoteHandler(access));
37
+ r.writeHandler(createAddNoteHandler(access, createNoteEntryExecutor(ownership)));
37
38
  r.queryHandler(defineEntityListHandler("note-entry", entity, {
38
39
  access,
39
40
  description: "Lists note-history entries of the caller's tenant with their text, author and timestamp; filter on entityId to read the full note trail of one record.",
@@ -1,3 +1,4 @@
1
1
  import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
- export declare function createAddNoteHandler(access?: AccessRule): WriteHandlerDef;
2
+ import { noteEntryExecutor } from "../executor.js";
3
+ export declare function createAddNoteHandler(access?: AccessRule, entryExecutor?: typeof noteEntryExecutor): WriteHandlerDef;
3
4
  export declare const addNoteHandler: WriteHandlerDef;
@@ -1,5 +1,7 @@
1
1
  import { fetchOne, runInSavepointIfSupported } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { ValidationError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
2
3
  import { decryptStoredPii, denyUnlessJoinRowParentVisible } from "../../shared/index.js";
4
+ import { tenantMembershipsTable } from "../../tenant/index.js";
3
5
  import { userTable } from "../../user/index.js";
4
6
  import { DEFAULT_NOTES_HISTORY_ACCESS } from "../constants.js";
5
7
  import { noteEntryExecutor, noteMentionExecutor } from "../executor.js";
@@ -17,13 +19,14 @@ const READ_AUTHOR_DISPLAY_NAME_REASON = "reads the author's displayName from the
17
19
  // Both the field names and the optional `parents` allowlist come from the
18
20
  // entity's own `parentRef` declaration, which the read gate reads too —
19
21
  // see shared/parent-visibility.ts.
20
- export function createAddNoteHandler(access = DEFAULT_NOTES_HISTORY_ACCESS) {
22
+ export function createAddNoteHandler(access = DEFAULT_NOTES_HISTORY_ACCESS, entryExecutor = noteEntryExecutor) {
21
23
  return {
22
24
  name: "add-note",
23
25
  schema: addNotePayloadSchema,
24
26
  access,
25
27
  description: "Appends a note to one host entity's history, stamping the author from the authenticated caller rather than the payload; use it for every remark and correction alike, because entries can never be edited or removed afterwards.",
26
28
  escapeHatch: {
29
+ grants: ["unsafeRaw"],
27
30
  reason: READ_AUTHOR_DISPLAY_NAME_REASON,
28
31
  },
29
32
  handler: async (event, ctx) => {
@@ -31,6 +34,20 @@ export function createAddNoteHandler(access = DEFAULT_NOTES_HISTORY_ACCESS) {
31
34
  const denied = await denyUnlessJoinRowParentVisible(ctx.registry, "note-entry", payload, event.user, ctx.db);
32
35
  if (denied)
33
36
  return denied;
37
+ // A mention of a non-member would leave a note body about that person out of reach of
38
+ // their forget run, which only visits the tenants they are a member of.
39
+ const mentionedUserIds = [...new Set(payload.mentions ?? [])];
40
+ if (mentionedUserIds.length > 0) {
41
+ const memberRows = await ctx.db.selectMany(tenantMembershipsTable, {
42
+ userId: { in: mentionedUserIds },
43
+ });
44
+ const memberIds = new Set(memberRows.map((row) => row.userId));
45
+ if (mentionedUserIds.some((userId) => !memberIds.has(userId))) {
46
+ return writeFailure(new ValidationError({
47
+ fields: [{ path: "mentions", code: "custom", i18nKey: "errors.validation.custom" }],
48
+ }));
49
+ }
50
+ }
34
51
  let authorName = null;
35
52
  try {
36
53
  // read_users is tenant-agnostic → ctx.db.unsafeRaw, not the tenant-scoped ctx.db.
@@ -54,13 +71,13 @@ export function createAddNoteHandler(access = DEFAULT_NOTES_HISTORY_ACCESS) {
54
71
  authorName = null;
55
72
  }
56
73
  const { mentions, ...notePayload } = payload;
57
- const created = await noteEntryExecutor.create({ ...notePayload, authorId: event.user.id, authorName }, event.user, ctx.db);
74
+ const created = await entryExecutor.create({ ...notePayload, authorId: event.user.id, authorName }, event.user, ctx.db);
58
75
  if (!created.isSuccess)
59
76
  return created;
60
77
  // Same tx as the note create above (ctx.db) — a mention-row failure
61
78
  // rolls the note back with it, same as the framework's own nested-write
62
79
  // parent+child pattern (dispatch-write.ts).
63
- for (const subjectId of new Set(mentions ?? [])) {
80
+ for (const subjectId of mentionedUserIds) {
64
81
  const mentionResult = await noteMentionExecutor.create({ noteId: created.data.id, subjectId }, event.user, ctx.db);
65
82
  if (!mentionResult.isSuccess)
66
83
  return mentionResult;
@@ -93,12 +93,30 @@ export const noteEntryDeleteHook = async (ctx) => {
93
93
  });
94
94
  }
95
95
  };
96
- // note-mention rows are a plain (noteId, subjectId) pointer — like authorId
97
- // on note-entry, `subjectId`'s `personal: "ref"` annotation exists for the
98
- // GDPR-hook-coverage boot guard, not because the row itself holds separately
99
- // exportable content or needs its own physical erasure: forgetting the
100
- // mentioned user's data is already handled by noteEntryDeleteHook shredding
101
- // the mentioned NOTE's row-subject key above. Registered here only so the V3
102
- // boot guard (validateGdprPiiHookCoverage) sees a hook for "note-mention".
103
- export const noteMentionExportHook = async () => null;
96
+ // Erasure reaches every note that structurally mentions the user (see
97
+ // noteEntryDeleteHook), so the Art. 15/20 export must list those notes too.
98
+ export const noteMentionExportHook = async (ctx) => {
99
+ const mentions = await ctx.db.selectMany(noteMentionTable, {
100
+ subjectId: ctx.userId,
101
+ });
102
+ if (mentions.length === 0)
103
+ return null;
104
+ const noteIds = [...new Set(mentions.map((m) => m.noteId))];
105
+ const notes = await ctx.db.selectMany(noteEntryTable, { id: { in: noteIds } });
106
+ if (notes.length === 0)
107
+ return null;
108
+ return {
109
+ entity: "note-mention",
110
+ rows: notes.map((note) => ({
111
+ noteId: note["id"],
112
+ entityType: note["entityType"],
113
+ entityId: note["entityId"],
114
+ body: note["body"],
115
+ insertedAt: note["insertedAt"],
116
+ })),
117
+ };
118
+ };
119
+ // note-mention rows are a plain (noteId, subjectId) pointer: forgetting the mentioned
120
+ // user's data is already handled by noteEntryDeleteHook shredding the mentioned NOTE's
121
+ // row-subject key, so this delete hook has nothing of its own to erase.
104
122
  export const noteMentionDeleteHook = async () => { };
@@ -37,6 +37,7 @@ export const revokeAllForUserWrite = defineWriteHandler({
37
37
  description: "Irreversibly signs a named user out of all their live sessions, across every tenant unless a tenantId narrows it, optionally sparing one session (exceptSessionId); use it for operator actions such as freezing or banning an account, or a role change that must not end the editor's own session.",
38
38
  agent: { risk: "high" },
39
39
  escapeHatch: {
40
+ grants: ["unsafeRaw"],
40
41
  reason: REVOKE_ALL_SESSIONS_REASON,
41
42
  },
42
43
  handler: async (event, ctx) => {
@@ -49,6 +49,7 @@ export function createMollieCheckoutSession(client, priceToConfig, appWebhookUrl
49
49
  metadata: {
50
50
  tenantId: options.tenantId,
51
51
  priceId: options.priceId,
52
+ ...(options.consentId && { consentId: options.consentId }),
52
53
  },
53
54
  })); // @cast-boundary engine-bridge
54
55
  const checkoutHref = payment.getCheckoutUrl();