@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
@@ -13,7 +13,7 @@
13
13
  // muss `customerSubscriptions.create` selbst aufrufen). Wir machen
14
14
  // das im Plugin (idempotent via list-check), damit Foundation einen
15
15
  // Created-Event mit subscription-id bekommt.
16
- import { SubscriptionEventTypes, SubscriptionStatuses, } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
16
+ import { parseProviderConsentId, SubscriptionEventTypes, SubscriptionStatuses, } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
17
17
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
18
18
  import { Temporal } from "temporal-polyfill";
19
19
  import { MOLLIE_PROVIDER_NAME } from "./constants.js";
@@ -85,17 +85,18 @@ export function verifyAndParseMollieWebhook(client, options) {
85
85
  throw new Error(`subscription-mollie: subscription ${subscription.id} has neither nextPaymentDate nor startDate`);
86
86
  }
87
87
  const currentPeriodEnd = mollieDateStringToInstantIso(periodEndSource);
88
+ const consentId = parseProviderConsentId(metadata["consentId"]);
88
89
  return {
89
90
  providerEventId: id,
90
91
  providerName: MOLLIE_PROVIDER_NAME,
91
92
  type,
92
93
  tenantId,
94
+ ...(consentId !== undefined && { consentId }),
93
95
  providerCustomerId: subscription.customerId,
94
96
  providerSubscriptionId: subscription.id,
95
97
  status,
96
98
  tier,
97
99
  currentPeriodEnd,
98
- rawPayload: JSON.stringify({ webhookId: id, subscription, triggerPayment }),
99
100
  };
100
101
  };
101
102
  }
@@ -112,6 +113,7 @@ async function ensureSubscriptionForMandate(client, options, payment) {
112
113
  const paymentMetadata = payment.metadata ?? {}; // @cast-boundary engine-bridge
113
114
  const tenantId = paymentMetadata["tenantId"];
114
115
  const priceId = paymentMetadata["priceId"];
116
+ const consentId = parseProviderConsentId(paymentMetadata["consentId"]);
115
117
  if (!tenantId || !priceId)
116
118
  return null;
117
119
  const priceCfg = options.priceToConfig[priceId];
@@ -130,7 +132,7 @@ async function ensureSubscriptionForMandate(client, options, payment) {
130
132
  amount: { currency: priceCfg.amountCurrency, value: priceCfg.amountValue },
131
133
  interval: priceCfg.interval,
132
134
  description: priceCfg.description,
133
- metadata: { tenantId, priceId },
135
+ metadata: { tenantId, priceId, ...(consentId && { consentId }) },
134
136
  });
135
137
  }
136
138
  // =============================================================================
@@ -8,6 +8,7 @@ export type StripeCheckoutSessionRuntimeOptions = {
8
8
  * Never applied in mode:"subscription" — Stripe rejects invoice_creation there. */
9
9
  readonly paymentInvoiceCreation?: boolean;
10
10
  };
11
+ export declare function mapAppLocaleToStripeLocale(locale: string): Stripe.Checkout.SessionCreateParams.Locale;
11
12
  export declare function createStripeCheckoutSession(runtime: StripeCtxRuntime, { paymentInvoiceCreation }?: StripeCheckoutSessionRuntimeOptions): (ctx: HandlerContext, options: StripeCheckoutOptions) => Promise<{
12
13
  url: string;
13
14
  }>;
@@ -15,7 +16,8 @@ export type StripePortalOptions = Parameters<NonNullable<SubscriptionProviderPlu
15
16
  export declare function createStripePortalSession(runtime: StripeCtxRuntime): (ctx: HandlerContext, options: StripePortalOptions) => Promise<{
16
17
  url: string;
17
18
  }>;
18
- export declare function createStripeCancelSubscription(runtime: StripeCtxRuntime): (ctx: HandlerContext, providerSubscriptionId: string) => Promise<void>;
19
+ export type StripeCancelOptions = Parameters<NonNullable<SubscriptionProviderPlugin["cancelSubscription"]>>[1];
20
+ export declare function createStripeCancelSubscription(runtime: StripeCtxRuntime): (ctx: HandlerContext, options: StripeCancelOptions) => Promise<void>;
19
21
  export type StripeRetrieveSubscriptionOptions = {
20
22
  readonly priceToTier: Readonly<Record<string, string>>;
21
23
  };
@@ -15,10 +15,65 @@
15
15
  // Contract erweitert (z.B. neuer optionaler Field), bemerkt der
16
16
  // Stripe-Plugin das beim TS-Compile, nicht erst zur Laufzeit.
17
17
  import { createHash } from "node:crypto";
18
- import { KNOWN_RECURRING_INTERVALS, } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
18
+ import { KNOWN_RECURRING_INTERVALS, SubscriptionCancelTimings, } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
19
19
  import { ConflictError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
20
20
  import Stripe from "stripe";
21
21
  import { isResourceMissingStripeError, mapStripeSubscriptionState } from "./verify-webhook.js";
22
+ // Stripe's own Locale union also accepts arbitrary strings, so the known set
23
+ // is spelled out here to pick a value Stripe actually renders.
24
+ const STRIPE_CHECKOUT_LOCALES = [
25
+ "bg",
26
+ "cs",
27
+ "da",
28
+ "de",
29
+ "el",
30
+ "en",
31
+ "en-GB",
32
+ "es",
33
+ "es-419",
34
+ "et",
35
+ "fi",
36
+ "fil",
37
+ "fr",
38
+ "fr-CA",
39
+ "hr",
40
+ "hu",
41
+ "id",
42
+ "it",
43
+ "ja",
44
+ "ko",
45
+ "lt",
46
+ "lv",
47
+ "ms",
48
+ "mt",
49
+ "nb",
50
+ "nl",
51
+ "pl",
52
+ "pt",
53
+ "pt-BR",
54
+ "ro",
55
+ "ru",
56
+ "sk",
57
+ "sl",
58
+ "sv",
59
+ "th",
60
+ "tr",
61
+ "vi",
62
+ "zh",
63
+ "zh-HK",
64
+ "zh-TW",
65
+ ];
66
+ // Stripe rejects custom_text.submit.message above 1200 characters.
67
+ const STRIPE_CUSTOM_TEXT_MAX_LENGTH = 1200;
68
+ export function mapAppLocaleToStripeLocale(locale) {
69
+ const normalized = locale.replace("_", "-");
70
+ const knownLocales = STRIPE_CHECKOUT_LOCALES;
71
+ const exact = knownLocales.find((known) => known.toLowerCase() === normalized.toLowerCase());
72
+ if (exact)
73
+ return exact;
74
+ const language = normalized.split("-")[0]?.toLowerCase();
75
+ return knownLocales.find((known) => known === language) ?? "auto";
76
+ }
22
77
  function isUnknownCustomerStripeError(error) {
23
78
  return (isResourceMissingStripeError(error) &&
24
79
  error instanceof Stripe.errors.StripeInvalidRequestError &&
@@ -34,11 +89,22 @@ export function createStripeCheckoutSession(runtime, { paymentInvoiceCreation =
34
89
  await runtime.assertBillingLive(ctx);
35
90
  const stripe = await runtime.clientForCtx(ctx);
36
91
  const mode = options.mode ?? "subscription";
92
+ const metadata = {
93
+ tenantId: options.tenantId,
94
+ ...(options.consentId && { consentId: options.consentId }),
95
+ };
37
96
  const sessionParams = (providerCustomerId) => ({
38
97
  mode,
39
98
  line_items: [{ price: options.priceId, quantity: 1 }],
40
99
  success_url: options.successUrl,
41
100
  cancel_url: options.cancelUrl,
101
+ submit_type: "pay",
102
+ ...(options.locale && { locale: mapAppLocaleToStripeLocale(options.locale) }),
103
+ ...(options.submitMessage && {
104
+ custom_text: {
105
+ submit: { message: options.submitMessage.slice(0, STRIPE_CUSTOM_TEXT_MAX_LENGTH) },
106
+ },
107
+ }),
42
108
  // subscription_data is subscription-mode-only (Stripe rejects it in
43
109
  // payment-mode) — the subsequent webhook reads metadata.tenantId off
44
110
  // the subscription it creates. payment-mode has no subscription, so
@@ -47,9 +113,9 @@ export function createStripeCheckoutSession(runtime, { paymentInvoiceCreation =
47
113
  // rejects it in subscription-mode, which invoices via the subscription
48
114
  // itself.
49
115
  ...(mode === "subscription"
50
- ? { subscription_data: { metadata: { tenantId: options.tenantId } } }
116
+ ? { subscription_data: { metadata } }
51
117
  : {
52
- payment_intent_data: { metadata: { tenantId: options.tenantId } },
118
+ payment_intent_data: { metadata },
53
119
  invoice_creation: { enabled: paymentInvoiceCreation },
54
120
  // Without a customer, Stripe's default "if_required" makes a guest
55
121
  // checkout when invoice_creation is off; the webhook then has no
@@ -88,17 +154,17 @@ export function createStripePortalSession(runtime) {
88
154
  return { url: session.url };
89
155
  };
90
156
  }
91
- // =============================================================================
92
- // cancelSubscription
93
- // =============================================================================
94
- //
95
- // Stripe sendet danach `customer.subscription.deleted`-webhook → der
96
- // state-update läuft über den normalen webhook-pfad. Diese function
97
- // triggert nur die API-Cancellation.
98
157
  export function createStripeCancelSubscription(runtime) {
99
- return async (ctx, providerSubscriptionId) => {
158
+ return async (ctx, options) => {
100
159
  const stripe = await runtime.clientForCtx(ctx);
101
- await stripe.subscriptions.cancel(providerSubscriptionId);
160
+ if (options.when === SubscriptionCancelTimings.periodEnd) {
161
+ await stripe.subscriptions.update(options.providerSubscriptionId, {
162
+ cancel_at_period_end: true,
163
+ });
164
+ }
165
+ else {
166
+ await stripe.subscriptions.cancel(options.providerSubscriptionId);
167
+ }
102
168
  };
103
169
  }
104
170
  export function createStripeRetrieveSubscription(runtime, { priceToTier }) {
@@ -122,7 +188,6 @@ export function createStripeRetrieveSubscription(runtime, { priceToTier }) {
122
188
  : subscription.customer.id,
123
189
  providerSubscriptionId: subscription.id,
124
190
  ...state,
125
- rawPayload: JSON.stringify(subscription),
126
191
  };
127
192
  };
128
193
  }
@@ -31,7 +31,7 @@
31
31
  // status/tier/period-end mapping. resource_missing (deleted between webhook
32
32
  // and retrieve) returns null; any other Stripe error becomes 503 so Stripe
33
33
  // redelivers.
34
- import { BillingEventKinds, SubscriptionEventTypes, SubscriptionStatuses, } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
34
+ import { BillingEventKinds, parseProviderConsentId, SubscriptionEventTypes, SubscriptionStatuses, } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
35
35
  import { ExtraRouteRejection } from "@cosmicdrift/kumiko-framework/api";
36
36
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
37
37
  import Stripe from "stripe";
@@ -116,15 +116,16 @@ export function verifyAndParseStripeWebhook(runtime, options) {
116
116
  if (!state) {
117
117
  return null;
118
118
  }
119
+ const consentId = parseProviderConsentId(sub.metadata?.["consentId"]);
119
120
  return {
120
121
  providerEventId: event.id,
121
122
  providerName: STRIPE_PROVIDER_NAME,
122
123
  type: normalizedType,
123
124
  tenantId,
125
+ ...(consentId !== undefined && { consentId }),
124
126
  providerCustomerId: typeof sub.customer === "string" ? sub.customer : sub.customer.id,
125
127
  providerSubscriptionId: sub.id,
126
128
  ...state,
127
- rawPayload: JSON.stringify(event),
128
129
  };
129
130
  };
130
131
  }
@@ -269,6 +270,7 @@ function dropPaidSession(event, session, reason) {
269
270
  }
270
271
  /** Parses a checkout.session.completed / .async_payment_succeeded event into
271
272
  * a PaymentEvent, or null if it isn't a paid one-off-payment session. */
273
+ // kumiko-lint-ignore complexity-budget optional-field spreads over the session, one branch per optional provider field
272
274
  async function parsePaymentEvent(event, stripe) {
273
275
  const session = event.data.object; // @cast-boundary engine-bridge
274
276
  // mode !== "payment" excludes subscription-checkout sessions (same event types);
@@ -326,13 +328,15 @@ async function parsePaymentEvent(event, stripe) {
326
328
  if (!providerCustomerId) {
327
329
  return dropPaidSession(event, session, "missing_customer");
328
330
  }
331
+ const consentId = parseProviderConsentId(paymentIntent.metadata?.["consentId"]);
329
332
  return {
330
333
  kind: BillingEventKinds.payment,
331
334
  providerEventId: event.id,
332
335
  providerName: STRIPE_PROVIDER_NAME,
333
336
  tenantId,
337
+ ...(consentId !== undefined && { consentId }),
338
+ providerCheckoutId: session.id,
334
339
  providerCustomerId,
335
340
  priceId,
336
- rawPayload: JSON.stringify(event),
337
341
  };
338
342
  }
@@ -1,2 +1,4 @@
1
+ import { type EntityDefinition } from "@cosmicdrift/kumiko-framework/engine";
1
2
  export declare const tagExecutor: import("@cosmicdrift/kumiko-types/event-store-executor-types").EventStoreExecutor;
2
3
  export declare const tagAssignmentExecutor: import("@cosmicdrift/kumiko-types/event-store-executor-types").EventStoreExecutor;
4
+ export declare function createTagAssignmentExecutor(ownership: EntityDefinition["access"] | undefined): typeof tagAssignmentExecutor;
@@ -1,7 +1,15 @@
1
1
  import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
2
- import { tagAssignmentEntity, tagEntity } from "./entity.js";
2
+ import { createTagAssignmentEntity, tagAssignmentEntity, tagEntity } from "./entity.js";
3
3
  // Shared executors for the tag + tag-assignment write-handlers.
4
4
  // createEntityExecutor is side-effect-free; instantiating once keeps the
5
5
  // table+executor pair in one place instead of rebuilding it per handler module.
6
6
  export const { executor: tagExecutor } = createEntityExecutor("tag", tagEntity);
7
7
  export const { executor: tagAssignmentExecutor } = createEntityExecutor("tag-assignment", tagAssignmentEntity);
8
+ // Carries only `ownership.write`: the executor enforces entity.access.write on
9
+ // create/restore/delete, while a read rule would hide rows from the handlers'
10
+ // own idempotency reads and the delete-tag cascade.
11
+ export function createTagAssignmentExecutor(ownership) {
12
+ if (ownership?.write === undefined)
13
+ return tagAssignmentExecutor;
14
+ return createEntityExecutor("tag-assignment", createTagAssignmentEntity({ write: ownership.write })).executor;
15
+ }
@@ -28,9 +28,10 @@ export type TagsFeatureOptions = {
28
28
  * `tag-assignment`; the `tag` catalog stays tenant-wide by design.
29
29
  *
30
30
  * `ownership.write` is separate and does NOT affect list/read. It's
31
- * consulted by the framework's generic delete/forget/restore paths (not by
32
- * this feature's own assign-tag handler — see createTagsFeature's
33
- * boot-guard comment). A `from()` rule there also gates GDPR erasure
31
+ * consulted on every assignment write: assign-tag (create/restore),
32
+ * remove-tag (delete) and the framework's generic forget path. The
33
+ * delete-tag cascade stays ungated, since deleting a catalog tag detaches
34
+ * it everywhere. A `from()` rule there also gates GDPR erasure
34
35
  * (`forget`): if the rule's role map doesn't cover whatever role the
35
36
  * erasure/retention pipeline runs as, `forget` denies instead of
36
37
  * crypto-shredding — a silent Art.17 failure, not a thrown error. Make
@@ -21,6 +21,7 @@ import { defineEntityDetailHandler, defineEntityListHandler, defineEntityUpdateH
21
21
  import { hasWhereRule } from "../shared/index.js";
22
22
  import { DEFAULT_TAG_ACCESS, TAGS_FEATURE_NAME } from "./constants.js";
23
23
  import { createTagAssignmentEntity, tagEntity } from "./entity.js";
24
+ import { createTagAssignmentExecutor } from "./executor.js";
24
25
  import { createAssignTagHandler } from "./handlers/assign-tag.write.js";
25
26
  import { createCreateTagHandler } from "./handlers/create-tag.write.js";
26
27
  import { createDeleteTagHandler } from "./handlers/delete-tag.write.js";
@@ -45,8 +46,9 @@ function registerTags(r, access, toggleable, ownership, parents) {
45
46
  r.writeHandler(createCreateTagHandler(access));
46
47
  r.writeHandler(createUpdateTagHandler(access));
47
48
  r.writeHandler(createDeleteTagHandler(access));
48
- r.writeHandler(createAssignTagHandler(access));
49
- r.writeHandler(createRemoveTagHandler(access));
49
+ const assignmentExecutor = createTagAssignmentExecutor(ownership);
50
+ r.writeHandler(createAssignTagHandler(access, assignmentExecutor));
51
+ r.writeHandler(createRemoveTagHandler(access, assignmentExecutor));
50
52
  // Convention aliases for entityList/entityEdit — create stays flat-payload
51
53
  // (payloadMode=values); update must accept the {id,version,changes} envelope
52
54
  // entityEdit sends (legacy update-tag stays for TagManager). delete stays
@@ -1,3 +1,4 @@
1
1
  import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
- export declare function createAssignTagHandler(access?: AccessRule): WriteHandlerDef;
2
+ import { tagAssignmentExecutor } from "../executor.js";
3
+ export declare function createAssignTagHandler(access?: AccessRule, assignmentExecutor?: typeof tagAssignmentExecutor): WriteHandlerDef;
3
4
  export declare const assignTagHandler: WriteHandlerDef;
@@ -25,7 +25,7 @@ import { assignTagPayloadSchema } from "../schemas.js";
25
25
  // the aggregate-id is tenant-scoped, so this is not a cross-tenant hole, but
26
26
  // without the check any dispatch-eligible tenant user could tag an object they
27
27
  // are not allowed to read. See shared/parent-visibility.ts.
28
- export function createAssignTagHandler(access = DEFAULT_TAG_ACCESS) {
28
+ export function createAssignTagHandler(access = DEFAULT_TAG_ACCESS, assignmentExecutor = tagAssignmentExecutor) {
29
29
  return {
30
30
  name: "assign-tag",
31
31
  schema: assignTagPayloadSchema,
@@ -37,11 +37,11 @@ export function createAssignTagHandler(access = DEFAULT_TAG_ACCESS) {
37
37
  if (denied)
38
38
  return denied;
39
39
  const id = tagAssignmentAggregateId(event.user.tenantId, payload.tagId, payload.entityType, payload.entityId);
40
- const existing = await tagAssignmentExecutor.detail({ id }, event.user, ctx.db);
40
+ const existing = await assignmentExecutor.detail({ id }, event.user, ctx.db);
41
41
  if (existing) {
42
42
  return { isSuccess: true, data: { id } };
43
43
  }
44
- const restored = await tagAssignmentExecutor.restore({ id }, event.user, ctx.db);
44
+ const restored = await assignmentExecutor.restore({ id }, event.user, ctx.db);
45
45
  if (restored.isSuccess)
46
46
  return { isSuccess: true, data: { id } };
47
47
  // A concurrent first-time assign can also land here: the other caller's
@@ -57,7 +57,7 @@ export function createAssignTagHandler(access = DEFAULT_TAG_ACCESS) {
57
57
  const tag = await tagExecutor.detail({ id: payload.tagId }, event.user, ctx.db);
58
58
  if (!tag)
59
59
  return writeFailure(new NotFoundError("tag", payload.tagId));
60
- const created = await tagAssignmentExecutor.create({
60
+ const created = await assignmentExecutor.create({
61
61
  id,
62
62
  tagId: payload.tagId,
63
63
  entityType: payload.entityType,
@@ -1,3 +1,4 @@
1
1
  import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
- export declare function createRemoveTagHandler(access?: AccessRule): WriteHandlerDef;
2
+ import { tagAssignmentExecutor } from "../executor.js";
3
+ export declare function createRemoveTagHandler(access?: AccessRule, assignmentExecutor?: typeof tagAssignmentExecutor): WriteHandlerDef;
3
4
  export declare const removeTagHandler: WriteHandlerDef;
@@ -10,7 +10,7 @@ import { removeTagPayloadSchema } from "../schemas.js";
10
10
  // Host reference: entityType/entityId are client input, so the caller must be
11
11
  // able to see the host row before detaching anything from it — see
12
12
  // shared/parent-visibility.ts.
13
- export function createRemoveTagHandler(access = DEFAULT_TAG_ACCESS) {
13
+ export function createRemoveTagHandler(access = DEFAULT_TAG_ACCESS, assignmentExecutor = tagAssignmentExecutor) {
14
14
  return {
15
15
  name: "remove-tag",
16
16
  schema: removeTagPayloadSchema,
@@ -22,11 +22,11 @@ export function createRemoveTagHandler(access = DEFAULT_TAG_ACCESS) {
22
22
  if (denied)
23
23
  return denied;
24
24
  const id = tagAssignmentAggregateId(event.user.tenantId, payload.tagId, payload.entityType, payload.entityId);
25
- const existing = await tagAssignmentExecutor.detail({ id }, event.user, ctx.db);
25
+ const existing = await assignmentExecutor.detail({ id }, event.user, ctx.db);
26
26
  if (!existing) {
27
27
  return { isSuccess: true, data: { id } };
28
28
  }
29
- return tagAssignmentExecutor.delete({ id }, event.user, ctx.db);
29
+ return assignmentExecutor.delete({ id }, event.user, ctx.db);
30
30
  },
31
31
  };
32
32
  }
@@ -28,6 +28,7 @@ export function createClaimTenantHandoverHandler(opts = {}) {
28
28
  schema: z.object({ token: z.string().min(1), entityType: z.string().min(1) }),
29
29
  access: { roles: ["Member", "User", "TenantAdmin", "SystemAdmin"] },
30
30
  escapeHatch: {
31
+ grants: ["unsafeRaw"],
31
32
  reason: "the ownership change moves rows OUT of a source tenant this caller has no membership " +
32
33
  "in — the framework's declared cross-tenant operation for try-before-signup, not a " +
33
34
  "per-consumer acknowledgeCrossTenant workaround",
@@ -15,6 +15,7 @@ export const cancelDestructionWrite = defineWriteHandler({
15
15
  access: { roles: ["TenantOwner", "Admin"] },
16
16
  description: "Returns a tenant whose destruction was requested back to active and clears the grace period; use it to undo a close-account request while the grace period is still running.",
17
17
  escapeHatch: {
18
+ grants: ["unsafeRaw"],
18
19
  reason: CANCEL_DESTRUCTION_TENANT_ROW_REASON,
19
20
  },
20
21
  handler: async (event, ctx) => {
@@ -19,6 +19,7 @@ export const requestDestructionWrite = defineWriteHandler({
19
19
  description: "Puts the caller's own tenant into destroyRequested, starts the compliance-profile grace period after which its data is erased, and revokes every session in the tenant; use it when an account owner asks to close their account.",
20
20
  agent: { risk: "high" },
21
21
  escapeHatch: {
22
+ grants: ["unsafeRaw"],
22
23
  reason: REQUEST_DESTRUCTION_TENANT_ROW_REASON,
23
24
  },
24
25
  handler: async (event, ctx) => {
@@ -15,6 +15,7 @@ export const getTenantTierQuery = defineQueryHandler({
15
15
  schema: z.object({ tenantId: z.string().min(1) }),
16
16
  access: { roles: ["SystemAdmin"] },
17
17
  escapeHatch: {
18
+ grants: ["unsafeRaw"],
18
19
  reason: GET_TENANT_TIER_REASON,
19
20
  },
20
21
  handler: async (query, ctx) => {
@@ -46,6 +46,7 @@ export function createSetTenantTierWrite(opts = {}) {
46
46
  }),
47
47
  access: { roles: ["SystemAdmin"] },
48
48
  escapeHatch: {
49
+ grants: ["unsafeRaw"],
49
50
  reason: SET_TENANT_TIER_REASON,
50
51
  },
51
52
  handler: async (event, ctx) => {
@@ -6,6 +6,7 @@ import { attachTenantLabels, dbForList } from "./list.query.js";
6
6
  // bound to their own tenant's memberships (served by the tenant feature).
7
7
  const baseDetail = defineEntityDetailHandler("user", userEntity, {
8
8
  access: { roles: access.systemAdmin },
9
+ agent: { risk: "high" },
9
10
  description: "Loads one identity record by id for a SystemAdmin, enriched with the same derived `tenants` membership label as the user list; tenant-level admins do not reach it.",
10
11
  });
11
12
  // Same tenants enrichment as user:list — derived field placeholder is "".
@@ -6,6 +6,7 @@ import { tenantMembershipsTable, tenantTable } from "../../tenant/index.js";
6
6
  import { userEntity } from "../schema/user.js";
7
7
  const baseList = defineEntityListHandler("user", userEntity, {
8
8
  access: { roles: access.systemAdmin },
9
+ agent: { risk: "high" },
9
10
  description: "Lists identity records across every tenant for a SystemAdmin roster, each row carrying a derived `tenants` label naming the tenants the user belongs to and their roles there.",
10
11
  });
11
12
  async function loadMemberships(db, userIds) {
@@ -27,6 +27,7 @@ export const cancelDeletionWrite = defineWriteHandler({
27
27
  },
28
28
  description: "Withdraws the calling user's own pending account-deletion request and puts the account back to active, accepted only while the grace period is still running.",
29
29
  escapeHatch: {
30
+ grants: ["unsafeRaw", "globalWrites"],
30
31
  reason: APPEND_LIFECYCLE_EVENT_REASON,
31
32
  },
32
33
  handler: async (event, ctx) => {
@@ -5,6 +5,7 @@ import { downloadAttemptEntity } from "../schema/download-attempt.js";
5
5
  // non-convention QN; entityList needs the convention `download-attempt:list`.
6
6
  export const downloadAttemptListQuery = defineEntityListHandler("download-attempt", downloadAttemptEntity, {
7
7
  access: { roles: access.systemAdmin },
8
+ agent: { risk: "high" },
8
9
  escapeHatch: {
9
10
  reason: "SystemAdmin DPO triage of invalid export-download attempts needs every tenant's rows to spot brute-force patterns across tenants",
10
11
  },
@@ -47,6 +47,7 @@ export const downloadByJobQuery = defineQueryHandler({
47
47
  }, // openToAll = auth-required, kein anonymous
48
48
  description: "Returns a short-lived signed download URL for the calling user's own finished data-export job named by job id, backing the download button in the privacy center once export-status reports the job done.",
49
49
  escapeHatch: {
50
+ grants: ["unsafeRaw"],
50
51
  reason: DOWNLOAD_BY_JOB_REASON,
51
52
  },
52
53
  handler: async (query, ctx) => {
@@ -58,6 +58,7 @@ export const downloadByTokenQuery = defineQueryHandler({
58
58
  // Memory `feedback_security_default_on`.
59
59
  rateLimit: { per: "ip+handler", limit: 30, windowSeconds: 60 },
60
60
  escapeHatch: {
61
+ grants: ["unsafeRaw"],
61
62
  reason: DOWNLOAD_BY_TOKEN_REASON,
62
63
  },
63
64
  handler: async (query, ctx) => {
@@ -26,6 +26,7 @@ export const exportStatusQuery = defineQueryHandler({
26
26
  },
27
27
  description: "Returns the calling user's own most recent data-export job with its status, expiry and error, or hasJob false, for polling after a request-export while the job is still running.",
28
28
  escapeHatch: {
29
+ grants: ["unsafeRaw"],
29
30
  reason: EXPORT_STATUS_REASON,
30
31
  },
31
32
  handler: async (query, ctx) => {
@@ -33,6 +33,7 @@ export const liftRestrictionWrite = defineWriteHandler({
33
33
  access: { roles: access.admin },
34
34
  description: "Lifts a GDPR Art. 18 processing restriction on the named user and returns the account to active; operator-only, because a restricted user's own session is rejected and cannot reach this endpoint.",
35
35
  escapeHatch: {
36
+ grants: ["unsafeRaw", "globalWrites"],
36
37
  reason: LIFT_RESTRICTION_ESCAPE_HATCH_REASON,
37
38
  },
38
39
  handler: async (event, ctx) => {
@@ -33,6 +33,7 @@ export const myAuditLogQuery = defineQueryHandler({
33
33
  },
34
34
  description: "Returns the calling user's own event-store entries across all their tenant memberships, paged and filterable by aggregate type, event type and time range, for the GDPR Art. 15 self-disclosure; it can never read another user's history.",
35
35
  escapeHatch: {
36
+ grants: ["unsafeRaw"],
36
37
  reason: MY_AUDIT_LOG_REASON,
37
38
  },
38
39
  handler: async (query, ctx) => {
@@ -36,6 +36,7 @@ export function createRequestDeletionByEmailHandler(opts = {}) {
36
36
  // Defense-in-depth gegen Email-Probing auf dem anonymen Endpoint.
37
37
  rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
38
38
  escapeHatch: {
39
+ grants: ["unsafeRaw", "globalWrites"],
39
40
  reason: APPEND_LIFECYCLE_EVENT_REASON,
40
41
  },
41
42
  handler: async (event, ctx) => {
@@ -51,6 +51,7 @@ export const requestExportWrite = defineWriteHandler({
51
51
  },
52
52
  description: "Queues a GDPR Art. 15 and 20 data export for the calling user and returns its job id, handing back the running job with isExisting true instead of a second one when an export is already pending.",
53
53
  escapeHatch: {
54
+ grants: ["unsafeRaw"],
54
55
  reason: REQUEST_EXPORT_REASON,
55
56
  },
56
57
  handler: async (event, ctx) => {
@@ -26,6 +26,7 @@ export function createRunForgetCleanupHandler(opts = {}) {
26
26
  // records without softDelete — an agent must never be able to trigger it.
27
27
  agent: { expose: false, risk: "high" },
28
28
  escapeHatch: {
29
+ grants: ["unsafeRaw"],
29
30
  reason: RUN_FORGET_CLEANUP_REASON,
30
31
  },
31
32
  handler: async (_event, ctx) => {
@@ -1,11 +1,13 @@
1
1
  // Forget-Cleanup-Runner (S2.U5b) — pure-Function Pipeline.
2
2
  //
3
- // Nach abgelaufener Grace-Period (S2.U5a setzt gracePeriodEnd) iteriert
4
- // dieser Runner ueber alle User in DeletionRequested-State und triggert
5
- // die EXT_USER_DATA-delete-Hooks pro Membership-Tenant.
3
+ // After the grace period (S2.U5a sets gracePeriodEnd) this runner iterates
4
+ // over all users in DeletionRequested state and triggers the EXT_USER_DATA
5
+ // delete hooks in every tenant the user is or was a member of (membership
6
+ // history from kumiko_events).
6
7
  //
7
- // **Cross-Tenant-Iteration:** Ein User-Forget-Antrag in Tenant A muss
8
- // die Daten des Users in ALLEN seinen Tenants entfernen — siehe
8
+ // **Cross-tenant iteration:** a forget request in tenant A must remove the
9
+ // user's data in ALL of their tenants, including tenants they left before
10
+ // the request — see
9
11
  // docs/plans/architecture/user-data-rights.md "Cross-Tenant-Semantik".
10
12
  //
11
13
  // **Strategy-Dispatch:** Pro Entity entscheidet die data-retention-
@@ -177,15 +179,16 @@ async function processUser(args) {
177
179
  // memberships in der Tx loeschen. Orphan-User (0 memberships) liefert
178
180
  // [] in Email-args; App-Author-Template kann das case-handlen.
179
181
  const tenantIdsBeforeDelete = memberships.map((m) => m.tenantId);
180
- // Edge-Case "0 Memberships": User hat alle Tenants schon verlassen
181
- // bevor Forget triggerte. Wir laufen den Hook-Loop trotzdem mit einem
182
- // Pseudo-Tenant — der user-Hook (user-data-rights-defaults) ist
183
- // tenant-agnostisch und MUSS laufen damit email/displayName/passwordHash
184
- // anonymisiert werden. Tenant-scoped Hooks (z.B. fileRefDeleteHook)
185
- // finden im Pseudo-Tenant nichts und sind no-op. Ohne diesen Pfad
186
- // wuerde status=Deleted gesetzt waehrend Original-PII liegen bleibt
187
- // — sieht compliant aus, ist es nicht (advisor-Finding S2.U5b.fix1).
188
- const tenantList = memberships.length > 0 ? memberships.map((m) => m.tenantId) : [SYSTEM_TENANT_ID_FOR_ORPHANS];
182
+ // Departed tenants: removeMember deletes only the projection row, so data
183
+ // the user left behind there (e.g. note mentions) must still be reached.
184
+ const historicalTenantIds = await readHistoricalMembershipTenantIds(db, userId);
185
+ const hookTenantIds = [...new Set([...tenantIdsBeforeDelete, ...historicalTenantIds])];
186
+ // Edge case "0 tenants" (neither membership nor history): the hook loop still
187
+ // runs with a pseudo tenant. The user hook is tenant-agnostic and MUST run so
188
+ // email/displayName/passwordHash get anonymized; tenant-scoped hooks find
189
+ // nothing there and no-op. Without this path status=Deleted would be set
190
+ // while the original PII stays — compliant-looking, but not compliant.
191
+ const tenantList = hookTenantIds.length > 0 ? hookTenantIds : [SYSTEM_TENANT_ID_FOR_ORPHANS];
189
192
  // Per-User-Sub-Tx: hooks + status-flip atomar. Bei Hook-Throw rollt
190
193
  // nur dieser User zurueck, andere User bleiben commit-fest. Die Sub-Tx
191
194
  // nestet korrekt: eine Top-Level-Connection oeffnet sie via `.begin`
@@ -342,7 +345,7 @@ async function resolveEffectiveTenantModel(db, tenantId, appTenantModel) {
342
345
  // rows.
343
346
  //
344
347
  // Filter by payload->>'tenantId' (not the event column tenant_id): cross-
345
- // tenant SystemAdmin adds land under the actor's tenant_id (#2347).
348
+ // tenant SystemAdmin adds land under the actor's tenant_id.
346
349
  //
347
350
  // Counting created-ROWS against distinct userIds latched a tenant to
348
351
  // multi-user as soon as its sole member was removed and re-added (#2608) —
@@ -369,6 +372,18 @@ async function everHadMultipleMembers(db, tenantId) {
369
372
  // could name a second member.
370
373
  return row.event_count > row.readable_users;
371
374
  }
375
+ // Filter by payload (not the event column tenant_id) for the same reason as
376
+ // everHadMultipleMembers: cross-tenant SystemAdmin adds are billed to the
377
+ // actor's tenant.
378
+ async function readHistoricalMembershipTenantIds(db, userId) {
379
+ const rows = await executeRawQuery(db, `SELECT DISTINCT payload->>'tenantId' AS tenant_id
380
+ FROM kumiko_events
381
+ WHERE aggregate_type = 'tenant-membership'
382
+ AND type = $1
383
+ AND payload->>'userId' = $2
384
+ AND NULLIF(payload->>'tenantId', '') IS NOT NULL`, [entityEventName("tenant-membership", "created"), userId]);
385
+ return rows.map((row) => row.tenant_id); // @cast-boundary db-row
386
+ }
372
387
  // Mapping retention.strategy → user-data-rights.UserDataDeleteStrategy.
373
388
  // - "anonymize" / "blockDelete" → "anonymize" (Aufbewahrungs-Pflicht
374
389
  // blockDelete: Daten muessen physisch bleiben, nur Personen-Bezug raus)