@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,4 +1,4 @@
1
- import { hasAccess } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { hasAccess, normalizeAccessEntry } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { FILTER_OPS, isRecord } from "./filter-ops.js";
3
3
  const MAX_TOOL_NAME_LENGTH = 64;
4
4
  /** Field types that declare `filterable` (per `packages/framework/src/engine/types/fields.ts`).
@@ -74,14 +74,28 @@ function isDetailHandlerQn(qn, entityName) {
74
74
  function isExplicitlyAgentHidden(def) {
75
75
  return def.agent?.expose === false;
76
76
  }
77
- function addToolsForListHandler(registry, qn, entityName, entity, sink) {
77
+ /** The model's filters feed the WHERE clause, and only the output is field-filtered — a field
78
+ * whose read access is role- or row-restricted would leak through `totalCount` and hit/miss
79
+ * probing. Only fields every one of these roles may read without a row condition are offered. */
80
+ function isReadableWithoutRowCondition(field, roles) {
81
+ const accessMap = normalizeAccessEntry(field.access?.read);
82
+ if (!accessMap || Object.keys(accessMap).length === 0)
83
+ return true;
84
+ return roles.every((role) => accessMap[role] === "all");
85
+ }
86
+ /** Entity CRUD tools are registry-derived, so an explicit `agent.risk` on the handler is read
87
+ * here; without a hint the read tools stay "low". */
88
+ function entityHandlerRisk(registry, qn) {
89
+ return registry.getAllQueryHandlers().get(qn)?.agent?.risk ?? "low";
90
+ }
91
+ function addToolsForListHandler(registry, qn, entityName, entity, roles, sink) {
78
92
  const searchableFields = registry.getSearchableFields(entityName);
79
93
  if (searchableFields.length > 0) {
80
94
  const tool = buildSearchTool(entityName, searchableFields);
81
95
  addTool(sink, tool, { kind: "search", entityName, qn });
82
96
  }
83
97
  for (const [fieldName, field] of Object.entries(entity.fields)) {
84
- if (!isFilterable(field))
98
+ if (!isFilterable(field) || !isReadableWithoutRowCondition(field, roles))
85
99
  continue;
86
100
  const fieldSchema = jsonSchemaTypeForField(field);
87
101
  if (!fieldSchema)
@@ -98,7 +112,7 @@ function entityDisplayLabel(entity, entityName, locale) {
98
112
  return label;
99
113
  return entityName;
100
114
  }
101
- function buildGetTool(entityName, qn, label) {
115
+ function buildGetTool(entityName, qn, label, risk) {
102
116
  return {
103
117
  tool: {
104
118
  name: `get_${entityName}`,
@@ -110,7 +124,7 @@ function buildGetTool(entityName, qn, label) {
110
124
  additionalProperties: false,
111
125
  },
112
126
  },
113
- descriptor: { kind: "server", op: "query", qn, risk: "low", entity: entityName, detail: true },
127
+ descriptor: { kind: "server", op: "query", qn, risk, entity: entityName, detail: true },
114
128
  };
115
129
  }
116
130
  function buildListInputSchema(searchableFields, filterableFields) {
@@ -140,7 +154,7 @@ function buildListInputSchema(searchableFields, filterableFields) {
140
154
  properties["limit"] = { type: "integer", minimum: 1, maximum: 200 };
141
155
  return { type: "object", properties, required: [], additionalProperties: false };
142
156
  }
143
- function buildListTool(entityName, qn, label, searchableFields, filterableFields) {
157
+ function buildListTool(entityName, qn, label, searchableFields, filterableFields, risk) {
144
158
  const descriptionParts = [`List ${label} records.`];
145
159
  if (searchableFields.length > 0) {
146
160
  descriptionParts.push(`Free-text search over: ${searchableFields.join(", ")}.`);
@@ -159,16 +173,22 @@ function buildListTool(entityName, qn, label, searchableFields, filterableFields
159
173
  kind: "server",
160
174
  op: "query",
161
175
  qn,
162
- risk: "low",
176
+ risk,
163
177
  entity: entityName,
164
178
  list: { searchableFields, filterableFields },
165
179
  },
166
180
  };
167
181
  }
168
- function filterableFieldsOf(entity) {
182
+ function filterableFieldsOf(entity, definition, roles) {
169
183
  if (!entity)
170
184
  return [];
171
- return entity.fields.filter((field) => field.filterable === true).map((field) => field.name);
185
+ return entity.fields
186
+ .filter((field) => field.filterable === true)
187
+ .map((field) => field.name)
188
+ .filter((name) => {
189
+ const fieldDefinition = definition?.fields[name];
190
+ return fieldDefinition !== undefined && isReadableWithoutRowCondition(fieldDefinition, roles);
191
+ });
172
192
  }
173
193
  function compareByCodePoint(a, b) {
174
194
  return a < b ? -1 : a > b ? 1 : 0;
@@ -321,7 +341,7 @@ function addRegistrySearchTools(registry, roleFilter, denyQns, sink) {
321
341
  const entity = registry.getEntity(entityName);
322
342
  if (!entity)
323
343
  continue;
324
- addToolsForListHandler(registry, qn, entityName, entity, sink);
344
+ addToolsForListHandler(registry, qn, entityName, entity, roleFilter.roles, sink);
325
345
  }
326
346
  }
327
347
  function collectEntityHandlerQns(registry, roleFilter, denyQns) {
@@ -352,21 +372,21 @@ function collectEntityHandlerQns(registry, roleFilter, denyQns) {
352
372
  }
353
373
  return { detailQnByEntity, listQnByEntity, entityListDetailQns };
354
374
  }
355
- function addGetTools(detailQnByEntity, entityByName, locale, sink) {
375
+ function addGetTools(registry, detailQnByEntity, entityByName, locale, sink) {
356
376
  const getEntries = [...detailQnByEntity.entries()].sort((a, b) => compareByCodePoint(a[0], b[0]));
357
377
  for (const [entityName, qn] of getEntries) {
358
378
  const label = entityDisplayLabel(entityByName.get(entityName), entityName, locale);
359
- const { tool, descriptor } = buildGetTool(entityName, qn, label);
379
+ const { tool, descriptor } = buildGetTool(entityName, qn, label, entityHandlerRisk(registry, qn));
360
380
  addTool(sink, tool, descriptor);
361
381
  }
362
382
  }
363
- function addListTools(registry, listQnByEntity, entityByName, locale, sink) {
383
+ function addListTools(registry, listQnByEntity, entityByName, roles, locale, sink) {
364
384
  const listEntries = [...listQnByEntity.entries()].sort((a, b) => compareByCodePoint(a[0], b[0]));
365
385
  for (const [entityName, qn] of listEntries) {
366
386
  const label = entityDisplayLabel(entityByName.get(entityName), entityName, locale);
367
387
  const searchableFields = registry.getSearchableFields(entityName);
368
- const filterableFields = filterableFieldsOf(entityByName.get(entityName));
369
- const { tool, descriptor } = buildListTool(entityName, qn, label, searchableFields, filterableFields);
388
+ const filterableFields = filterableFieldsOf(entityByName.get(entityName), registry.getEntity(entityName), roles);
389
+ const { tool, descriptor } = buildListTool(entityName, qn, label, searchableFields, filterableFields, entityHandlerRisk(registry, qn));
370
390
  addTool(sink, tool, descriptor);
371
391
  }
372
392
  }
@@ -455,8 +475,8 @@ export function buildToolCatalog(registry, manifest, options) {
455
475
  const denyQns = new Set(options.denyQns ?? []);
456
476
  addRegistrySearchTools(registry, roleFilter, denyQns, sink);
457
477
  const { detailQnByEntity, listQnByEntity, entityListDetailQns } = collectEntityHandlerQns(registry, roleFilter, denyQns);
458
- addGetTools(detailQnByEntity, entityByName, locale, sink);
459
- addListTools(registry, listQnByEntity, entityByName, locale, sink);
478
+ addGetTools(registry, detailQnByEntity, entityByName, locale, sink);
479
+ addListTools(registry, listQnByEntity, entityByName, roleFilter.roles, locale, sink);
460
480
  addQueryHandlerTools(manifest, entityListDetailQns, denyQns, sink);
461
481
  if (options.mode !== "read-only") {
462
482
  addWriteHandlerTools(manifest, detailQnByEntity, denyQns, sink);
@@ -53,6 +53,7 @@ export function createInviteAcceptWithLoginHandler(opts = {}) {
53
53
  access: { roles: ["anonymous"] },
54
54
  rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
55
55
  escapeHatch: {
56
+ grants: ["unsafeRaw"],
56
57
  reason: "Anonymous invite-accept has no session in the invited tenant yet — checks existing " +
57
58
  "membership via ctx.queryAs(SYSTEM, tenant:query:memberships) of the invitation's tenant. " +
58
59
  "Also reads the pending invitation by id (the invitee is not yet a member of the " +
@@ -51,6 +51,7 @@ export function createInviteAcceptHandler() {
51
51
  },
52
52
  agent: { expose: false },
53
53
  escapeHatch: {
54
+ grants: ["unsafeRaw"],
54
55
  reason: INVITE_ACCEPT_ESCAPE_HATCH_REASON,
55
56
  },
56
57
  // kumiko-lint-ignore complexity-budget invite branches (auth/anon/burn) stay in one handler
@@ -34,6 +34,7 @@ export const inviteInfoQuery = defineQueryHandler({
34
34
  rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
35
35
  agent: { expose: false },
36
36
  escapeHatch: {
37
+ grants: ["unsafeRaw"],
37
38
  reason: READ_PENDING_INVITATION_REASON,
38
39
  },
39
40
  description: "Anonymous, read-only invite lookup for the invite-acceptance page: reveals the invited " +
@@ -51,6 +51,7 @@ export function createInviteSignupCompleteHandler() {
51
51
  rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
52
52
  agent: { expose: false },
53
53
  escapeHatch: {
54
+ grants: ["unsafeRaw", "globalWrites"],
54
55
  reason: INVITE_SIGNUP_COMPLETE_ESCAPE_HATCH_REASON,
55
56
  },
56
57
  handler: async (event, ctx) => {
@@ -78,6 +78,7 @@ export function createSignupConfirmHandler() {
78
78
  rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
79
79
  agent: { expose: false },
80
80
  escapeHatch: {
81
+ grants: ["unsafeRaw", "systemIdentity"],
81
82
  reason: SIGNUP_CONFIRM_PROVISION_REASON,
82
83
  },
83
84
  handler: async (event, ctx) => {
@@ -55,7 +55,7 @@ export function createSignupRequestHandler(opts) {
55
55
  access: { roles: ["anonymous"] },
56
56
  rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
57
57
  description: "Starts magic-link self-registration by mailing a fresh activation link to an address and invalidating any link still outstanding for it; the answer looks the same whether or not the address is already registered.",
58
- escapeHatch: { reason: HANDOVER_GRANT_VERIFY_REASON },
58
+ escapeHatch: { reason: HANDOVER_GRANT_VERIFY_REASON, grants: ["unsafeRaw"] },
59
59
  handler: async (event, ctx) => {
60
60
  // Silent no-op when off, matching the route's own always-200
61
61
  // anti-enumeration contract (registerTokenRequestRoute swallows every
@@ -30,6 +30,7 @@ export function createEnableStartPreauthHandler(opts) {
30
30
  // Same secret-bearing result as enable-start.
31
31
  agent: { expose: false },
32
32
  escapeHatch: {
33
+ grants: ["unsafeRaw"],
33
34
  reason: ENABLE_START_PREAUTH_TENANT_REASON,
34
35
  },
35
36
  handler: async (event, ctx) => {
@@ -18,3 +18,5 @@ export declare function paymentAggregateId(tenantId: string): string;
18
18
  * providerEventId)` rather than the event-store's bigserial `event.id` (not
19
19
  * a UUID) or the shared per-tenant aggregateId (one row per payment). */
20
20
  export declare function paymentRowId(tenantId: string, providerName: string, providerEventId: string): string;
21
+ /** Stream id of one unmatched termination declaration, derived from its request id. */
22
+ export declare function terminationUnmatchedAggregateId(requestId: string): string;
@@ -16,6 +16,9 @@ const PAYMENT_NAMESPACE = "dec0b897-646d-4da3-be3c-e9a35a83f4e0";
16
16
  * UUID, so it can't back a uuid-typed PK. Generated 2026-09-11, set in
17
17
  * stone (same rationale as the two namespaces above). */
18
18
  const PAYMENT_ROW_NAMESPACE = "3a1c9f4e-6b2d-4c8a-9e5f-7d4b1a2c8e6f";
19
+ /** Namespace for the system-tenant stream of an unmatched termination
20
+ * declaration (one stream per request). Generated 2026-10-03, set in stone. */
21
+ const TERMINATION_UNMATCHED_NAMESPACE = "7e2f4b9a-3c1d-4a68-8d05-b6c9e1f27a34";
19
22
  /**
20
23
  * Deterministic aggregate-id für die subscription eines Plattform-
21
24
  * Tenants. EINE Subscription pro Tenant (Add-Ons sind line-items in
@@ -51,3 +54,8 @@ export function paymentRowId(tenantId, providerName, providerEventId) {
51
54
  // the payment with no error anywhere.
52
55
  return uuidv5(`${tenantId}:${providerName}:${providerEventId}`, PAYMENT_ROW_NAMESPACE);
53
56
  }
57
+ /** Stream id of one unmatched termination declaration, derived from its request id. */
58
+ // @wrapper-known uuid-domain
59
+ export function terminationUnmatchedAggregateId(requestId) {
60
+ return uuidv5(requestId, TERMINATION_UNMATCHED_NAMESPACE);
61
+ }
@@ -88,6 +88,9 @@ export type OpenCheckoutInput = {
88
88
  readonly cancelUrl: string;
89
89
  readonly providerCustomerId?: string;
90
90
  readonly mode?: "subscription" | "payment";
91
+ readonly consentId?: string;
92
+ readonly locale?: string;
93
+ readonly submitMessage?: string;
91
94
  };
92
95
  /** The one checkout entry-point create-checkout-session and
93
96
  * start-plan-checkout both funnel through. `mode: "payment"` (one-off
@@ -282,6 +282,9 @@ export async function openCheckout(ctx, options, input) {
282
282
  // pre-hardening wire-contract for callers that never sent `mode`
283
283
  // (create-checkout-session's own regression pin).
284
284
  ...(input.mode && { mode: input.mode }),
285
+ ...(input.consentId && { consentId: input.consentId }),
286
+ ...(input.locale && { locale: input.locale }),
287
+ ...(input.submitMessage && { submitMessage: input.submitMessage }),
285
288
  });
286
289
  return { url: result.url, providerName: input.providerName };
287
290
  }
@@ -2,6 +2,12 @@
2
2
  import { Temporal as TemporalPolyfill } from "temporal-polyfill";
3
3
  export declare const BILLING_FOUNDATION_FEATURE: "billing-foundation";
4
4
  export declare const SUBSCRIPTION_PROVIDER_EXTENSION: "subscriptionProvider";
5
+ /** delivery notification type of the § 312f contract confirmation mail. */
6
+ export declare const CONTRACT_CONFIRMATION_NOTIFICATION_TYPE: "billing-foundation:contract-confirmation";
7
+ /** delivery notification types of the § 312k termination receipt and the
8
+ * operator notice. */
9
+ export declare const CONTRACT_TERMINATION_RECEIPT_NOTIFICATION_TYPE: "billing-foundation:contract-termination-receipt";
10
+ export declare const CONTRACT_TERMINATION_OPERATOR_NOTIFICATION_TYPE: "billing-foundation:contract-termination-operator-notice";
5
11
  export declare const SubscriptionFoundationHandlers: {
6
12
  /** Programmatic entry-point für den webhook-handler. Receives the
7
13
  * parsed SubscriptionEvent (vom Plugin schon verifiziert) + macht
@@ -22,6 +28,22 @@ export declare const SubscriptionFoundationHandlers: {
22
28
  * (payment-aggregate), separate from the subscription-aggregate — a
23
29
  * payment is not a subscription-state transition. */
24
30
  readonly processPaymentEvent: "billing-foundation:write:process-payment-event";
31
+ /** System-only: sends the § 312f contract confirmation mail for a recorded
32
+ * checkout consent (consumerProtection only). */
33
+ readonly issueContractConfirmation: "billing-foundation:write:issue-contract-confirmation";
34
+ /** System-only: records a termination/withdrawal on the tenant's
35
+ * subscription stream and asks the provider to cancel (consumerProtection
36
+ * only). */
37
+ readonly recordContractTermination: "billing-foundation:write:record-contract-termination";
38
+ /** System-only: records a public declaration no single tenant could be
39
+ * matched to, on a system-tenant stream (consumerProtection only). */
40
+ readonly recordUnmatchedContractTermination: "billing-foundation:write:record-unmatched-contract-termination";
41
+ /** Anonymous § 312k cancellation/withdrawal declaration (consumerProtection
42
+ * only). */
43
+ readonly requestContractTermination: "billing-foundation:write:request-contract-termination";
44
+ /** Tenant-Admin terminates or withdraws from the contract of their own
45
+ * tenant (consumerProtection only). */
46
+ readonly terminateContract: "billing-foundation:write:terminate-contract";
25
47
  /** Tenant-Admin/purchase-role picks a plan tier from the catalog with no
26
48
  * existing non-terminal subscription — starts a hosted checkout for the
27
49
  * matching price. Only registered when `createBillingFoundationFeature`
@@ -47,6 +69,9 @@ export declare const SubscriptionFoundationQueries: {
47
69
  * current tier and per-plan action. Only registered when a `catalog` is
48
70
  * configured. */
49
71
  readonly billingPlans: "billing-foundation:query:billing-plans";
72
+ /** System-only probe: does the caller tenant hold a non-terminal
73
+ * subscription (consumerProtection only). */
74
+ readonly terminableSubscription: "billing-foundation:query:terminable-subscription";
50
75
  };
51
76
  export declare const SubscriptionEventTypes: {
52
77
  readonly created: "subscription.created";
@@ -69,6 +94,12 @@ export declare const BillingEventKinds: {
69
94
  readonly payment: "payment";
70
95
  };
71
96
  export type BillingEventKind = (typeof BillingEventKinds)[keyof typeof BillingEventKinds];
97
+ export declare const SubscriptionCancelTimings: {
98
+ readonly periodEnd: "period-end";
99
+ readonly immediately: "immediately";
100
+ };
101
+ export type SubscriptionCancelTiming = (typeof SubscriptionCancelTimings)[keyof typeof SubscriptionCancelTimings];
102
+ export declare function parseProviderConsentId(raw: unknown): string | undefined;
72
103
  export declare const BILLING_PLANS_SCREEN_ID: "billing-plans";
73
104
  export declare const BILLING_PLANS_PANEL_COMPONENT: "BillingPlansPanel";
74
105
  export declare const BillingPlanActions: {
@@ -12,6 +12,12 @@ export const BILLING_FOUNDATION_FEATURE = "billing-foundation";
12
12
  // Extension-point name fuer Provider-Plugins (subscription-stripe,
13
13
  // subscription-mollie, ...).
14
14
  export const SUBSCRIPTION_PROVIDER_EXTENSION = "subscriptionProvider";
15
+ /** delivery notification type of the § 312f contract confirmation mail. */
16
+ export const CONTRACT_CONFIRMATION_NOTIFICATION_TYPE = "billing-foundation:contract-confirmation";
17
+ /** delivery notification types of the § 312k termination receipt and the
18
+ * operator notice. */
19
+ export const CONTRACT_TERMINATION_RECEIPT_NOTIFICATION_TYPE = "billing-foundation:contract-termination-receipt";
20
+ export const CONTRACT_TERMINATION_OPERATOR_NOTIFICATION_TYPE = "billing-foundation:contract-termination-operator-notice";
15
21
  // Qualified write handler names (QN format: scope:type:name).
16
22
  export const SubscriptionFoundationHandlers = {
17
23
  /** Programmatic entry-point für den webhook-handler. Receives the
@@ -33,6 +39,22 @@ export const SubscriptionFoundationHandlers = {
33
39
  * (payment-aggregate), separate from the subscription-aggregate — a
34
40
  * payment is not a subscription-state transition. */
35
41
  processPaymentEvent: "billing-foundation:write:process-payment-event",
42
+ /** System-only: sends the § 312f contract confirmation mail for a recorded
43
+ * checkout consent (consumerProtection only). */
44
+ issueContractConfirmation: "billing-foundation:write:issue-contract-confirmation",
45
+ /** System-only: records a termination/withdrawal on the tenant's
46
+ * subscription stream and asks the provider to cancel (consumerProtection
47
+ * only). */
48
+ recordContractTermination: "billing-foundation:write:record-contract-termination",
49
+ /** System-only: records a public declaration no single tenant could be
50
+ * matched to, on a system-tenant stream (consumerProtection only). */
51
+ recordUnmatchedContractTermination: "billing-foundation:write:record-unmatched-contract-termination",
52
+ /** Anonymous § 312k cancellation/withdrawal declaration (consumerProtection
53
+ * only). */
54
+ requestContractTermination: "billing-foundation:write:request-contract-termination",
55
+ /** Tenant-Admin terminates or withdraws from the contract of their own
56
+ * tenant (consumerProtection only). */
57
+ terminateContract: "billing-foundation:write:terminate-contract",
36
58
  /** Tenant-Admin/purchase-role picks a plan tier from the catalog with no
37
59
  * existing non-terminal subscription — starts a hosted checkout for the
38
60
  * matching price. Only registered when `createBillingFoundationFeature`
@@ -59,6 +81,9 @@ export const SubscriptionFoundationQueries = {
59
81
  * current tier and per-plan action. Only registered when a `catalog` is
60
82
  * configured. */
61
83
  billingPlans: "billing-foundation:query:billing-plans",
84
+ /** System-only probe: does the caller tenant hold a non-terminal
85
+ * subscription (consumerProtection only). */
86
+ terminableSubscription: "billing-foundation:query:terminable-subscription",
62
87
  };
63
88
  // Normalized subscription-event types — provider-agnostic.
64
89
  // Alle Provider-Plugins normalisieren ihre eigenen event-types auf einen
@@ -90,6 +115,16 @@ export const BillingEventKinds = {
90
115
  subscription: "subscription",
91
116
  payment: "payment",
92
117
  };
118
+ export const SubscriptionCancelTimings = {
119
+ periodEnd: "period-end",
120
+ immediately: "immediately",
121
+ };
122
+ // consentId is echoed back by the provider (checkout metadata), so it is
123
+ // untrusted input: bounded length, id-ish charset only.
124
+ const CONSENT_ID_PATTERN = /^[A-Za-z0-9_-]{1,100}$/;
125
+ export function parseProviderConsentId(raw) {
126
+ return typeof raw === "string" && CONSENT_ID_PATTERN.test(raw) ? raw : undefined;
127
+ }
93
128
  // Billing-plans screen/panel identifiers — the dormant dashboard app-builders
94
129
  // mount their own catalog-derived nav entry onto.
95
130
  export const BILLING_PLANS_SCREEN_ID = "billing-plans";
@@ -0,0 +1,21 @@
1
+ import type { CheckoutConsentRecordedPayload } from "../events.js";
2
+ export type ContractConfirmationContent = {
3
+ readonly subject: string;
4
+ readonly header: string;
5
+ readonly sections: readonly {
6
+ readonly text: string;
7
+ }[];
8
+ readonly footer: string;
9
+ };
10
+ export type RenderContractConfirmationArgs = {
11
+ readonly consent: CheckoutConsentRecordedPayload;
12
+ readonly consentGivenAtIso: string;
13
+ readonly contractStartIso: string;
14
+ readonly currentPeriodEndIso?: string;
15
+ readonly vatNote: Readonly<Record<string, string>>;
16
+ readonly operatorEmail: string;
17
+ readonly termsContent: string;
18
+ };
19
+ /** Structured mail content (header/sections/footer) for the email channel's
20
+ * renderer; the renderer owns HTML escaping, this only builds plain text. */
21
+ export declare function renderContractConfirmation(args: RenderContractConfirmationArgs): ContractConfirmationContent;
@@ -0,0 +1,125 @@
1
+ import { Temporal } from "temporal-polyfill";
2
+ import { CONSENT_TEXTS, resolveConsentLocale } from "./consent-text.js";
3
+ const LABELS = {
4
+ de: {
5
+ subject: "Vertragsbestätigung",
6
+ intro: "vielen Dank für Ihre Bestellung. Hiermit bestätigen wir den Vertragsschluss mit folgendem Inhalt.",
7
+ plan: "Tarif",
8
+ oneOffPayment: "Einmalzahlung",
9
+ price: "Preis",
10
+ oneOff: "einmalig",
11
+ contractStart: "Vertragsbeginn",
12
+ currentPeriodEnd: "Aktueller Abrechnungszeitraum bis",
13
+ consentHeading: "Ihre Zustimmungen",
14
+ consentGivenAt: "Erteilt am",
15
+ termsHeading: "Allgemeine Geschäftsbedingungen",
16
+ contact: "Fragen zu Ihrem Vertrag richten Sie bitte an",
17
+ every: "alle",
18
+ intervals: {
19
+ day: { one: "Tag", many: "Tage" },
20
+ week: { one: "Woche", many: "Wochen" },
21
+ month: { one: "Monat", many: "Monate" },
22
+ year: { one: "Jahr", many: "Jahre" },
23
+ },
24
+ },
25
+ en: {
26
+ subject: "Contract confirmation",
27
+ intro: "thank you for your order. We hereby confirm the conclusion of the contract with the following content.",
28
+ plan: "Plan",
29
+ oneOffPayment: "One-off payment",
30
+ price: "Price",
31
+ oneOff: "one-off",
32
+ contractStart: "Contract start",
33
+ currentPeriodEnd: "Current billing period ends",
34
+ consentHeading: "Your consents",
35
+ consentGivenAt: "Given on",
36
+ termsHeading: "Terms and conditions",
37
+ contact: "Questions about your contract go to",
38
+ every: "every",
39
+ intervals: {
40
+ day: { one: "day", many: "days" },
41
+ week: { one: "week", many: "weeks" },
42
+ month: { one: "month", many: "months" },
43
+ year: { one: "year", many: "years" },
44
+ },
45
+ },
46
+ };
47
+ function formatPrice(consent, locale) {
48
+ const labels = LABELS[locale];
49
+ if (consent.unitAmount === null || consent.currency === null)
50
+ return "-";
51
+ let amount;
52
+ try {
53
+ const format = new Intl.NumberFormat(locale, { style: "currency", currency: consent.currency });
54
+ const fractionDigits = format.resolvedOptions().maximumFractionDigits ?? 2;
55
+ amount = format.format(consent.unitAmount / 10 ** fractionDigits);
56
+ }
57
+ catch {
58
+ // Currency codes come from the provider; an unknown one must not block the mail.
59
+ amount = `${consent.unitAmount} ${consent.currency.toUpperCase()}`;
60
+ }
61
+ if (consent.interval === null)
62
+ return `${amount} (${labels.oneOff})`;
63
+ const unit = labels.intervals[consent.interval];
64
+ const count = consent.intervalCount ?? 1;
65
+ const intervalText = unit === undefined
66
+ ? consent.interval
67
+ : count === 1
68
+ ? unit.one
69
+ : `${labels.every} ${count} ${unit.many}`;
70
+ return count === 1 ? `${amount} / ${intervalText}` : `${amount}, ${intervalText}`;
71
+ }
72
+ function formatDate(iso, locale) {
73
+ let epochMilliseconds;
74
+ try {
75
+ epochMilliseconds = Temporal.Instant.from(iso).epochMilliseconds;
76
+ }
77
+ catch {
78
+ return iso;
79
+ }
80
+ return new Intl.DateTimeFormat(locale, {
81
+ dateStyle: "long",
82
+ timeStyle: "short",
83
+ timeZone: "UTC",
84
+ }).format(epochMilliseconds);
85
+ }
86
+ function paragraphs(text) {
87
+ return text
88
+ .split(/\n{2,}/)
89
+ .map((part) => part.trim())
90
+ .filter((part) => part.length > 0)
91
+ .map((part) => ({ text: part }));
92
+ }
93
+ /** Structured mail content (header/sections/footer) for the email channel's
94
+ * renderer; the renderer owns HTML escaping, this only builds plain text. */
95
+ export function renderContractConfirmation(args) {
96
+ const locale = resolveConsentLocale(args.consent.locale);
97
+ const labels = LABELS[locale];
98
+ const texts = CONSENT_TEXTS[locale];
99
+ const { consent } = args;
100
+ const contractLines = [
101
+ `${labels.plan}: ${consent.tier ?? labels.oneOffPayment}`,
102
+ `${labels.price}: ${formatPrice(consent, locale)}`,
103
+ `${labels.contractStart}: ${formatDate(args.contractStartIso, locale)}`,
104
+ ...(args.currentPeriodEndIso !== undefined
105
+ ? [`${labels.currentPeriodEnd}: ${formatDate(args.currentPeriodEndIso, locale)}`]
106
+ : []),
107
+ ];
108
+ const vatNote = args.vatNote[locale] ?? args.vatNote[resolveConsentLocale()];
109
+ return {
110
+ subject: labels.subject,
111
+ header: labels.subject,
112
+ sections: [
113
+ { text: labels.intro },
114
+ ...contractLines.map((text) => ({ text })),
115
+ ...(vatNote !== undefined ? [{ text: vatNote }] : []),
116
+ { text: labels.consentHeading },
117
+ { text: texts.earlyPerformance },
118
+ { text: texts.withdrawalLoss },
119
+ { text: `${labels.consentGivenAt}: ${formatDate(args.consentGivenAtIso, locale)}` },
120
+ { text: labels.termsHeading },
121
+ ...paragraphs(args.termsContent),
122
+ ],
123
+ footer: `${labels.contact} ${args.operatorEmail}`,
124
+ };
125
+ }
@@ -0,0 +1,32 @@
1
+ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
2
+ import * as z from "zod";
3
+ import type { ConsumerProtectionOptions, ProviderPrice } from "../types.js";
4
+ export type CheckoutMode = "subscription" | "payment";
5
+ export declare const consentPayloadSchema: z.ZodObject<{
6
+ earlyPerformanceRequested: z.ZodLiteral<true>;
7
+ withdrawalLossAcknowledged: z.ZodLiteral<true>;
8
+ consentTextVersion: z.ZodString;
9
+ locale: z.ZodString;
10
+ }, z.core.$strict>;
11
+ export type ConsentPayload = z.infer<typeof consentPayloadSchema>;
12
+ export type PreparedConsent = {
13
+ readonly consentId: string;
14
+ readonly mode: CheckoutMode;
15
+ readonly locale: string;
16
+ readonly submitMessage: string;
17
+ readonly consentTextVersion: string;
18
+ readonly termsHash: string;
19
+ readonly termsTemplateVersion: number;
20
+ };
21
+ export type ConsentPriceDetails = {
22
+ readonly tier: string | null;
23
+ readonly priceId: string;
24
+ readonly price: Pick<ProviderPrice, "unitAmount" | "currency" | "interval" | "intervalCount"> | null;
25
+ };
26
+ export declare function prepareConsent(ctx: HandlerContext, options: ConsumerProtectionOptions, consent: ConsentPayload, mode: CheckoutMode): Promise<PreparedConsent>;
27
+ export declare function consentCheckoutFields(prepared: PreparedConsent): {
28
+ readonly consentId: string;
29
+ readonly locale: string;
30
+ readonly submitMessage: string;
31
+ };
32
+ export declare function recordConsent(ctx: HandlerContext, prepared: PreparedConsent, details: ConsentPriceDetails): Promise<void>;
@@ -0,0 +1,88 @@
1
+ // Consent gate shared by start-plan-checkout and create-checkout-session:
2
+ // validate + prepare before the provider call, record only after it succeeded.
3
+ import { createHash } from "node:crypto";
4
+ import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
5
+ import { generateId } from "@cosmicdrift/kumiko-framework/utils";
6
+ import * as z from "zod";
7
+ import { requireTemplateResolver, TEXT_BLOCK_KIND, TemplateNotFoundError, } from "../../template-resolver/index.js";
8
+ import { paymentAggregateId, subscriptionAggregateId } from "../aggregate-id.js";
9
+ import { CHECKOUT_CONSENT_RECORDED_EVENT_QN, PAYMENT_AGGREGATE_TYPE, SUBSCRIPTION_AGGREGATE_TYPE, } from "../events.js";
10
+ import { consentTextVersion, resolveConsentLocale, submitMessageFor } from "./consent-text.js";
11
+ export const consentPayloadSchema = z
12
+ .object({
13
+ earlyPerformanceRequested: z.literal(true),
14
+ withdrawalLossAcknowledged: z.literal(true),
15
+ consentTextVersion: z.string().min(1).max(64),
16
+ locale: z.string().min(2).max(35),
17
+ })
18
+ .strict();
19
+ export async function prepareConsent(ctx, options, consent, mode) {
20
+ const locale = resolveConsentLocale(consent.locale);
21
+ if (consent.consentTextVersion !== consentTextVersion(locale)) {
22
+ throw new UnprocessableError("consent_text_outdated", {
23
+ i18nKey: "billing-foundation.errors.consentTextOutdated",
24
+ message: `billing-foundation: consent text version "${consent.consentTextVersion}" is not current for locale "${locale}"`,
25
+ });
26
+ }
27
+ const resolver = requireTemplateResolver(ctx, "billing-foundation");
28
+ let terms;
29
+ try {
30
+ terms = await resolver.resolveTemplate({
31
+ tenantId: ctx.user.tenantId,
32
+ slug: options.termsTextBlock,
33
+ kind: TEXT_BLOCK_KIND,
34
+ locale,
35
+ });
36
+ }
37
+ catch (error) {
38
+ if (error instanceof TemplateNotFoundError) {
39
+ throw new UnprocessableError("terms_unavailable", {
40
+ i18nKey: "billing-foundation.errors.termsUnavailable",
41
+ message: `billing-foundation: terms text block "${options.termsTextBlock}" could not be resolved`,
42
+ cause: error,
43
+ });
44
+ }
45
+ throw error;
46
+ }
47
+ return {
48
+ consentId: generateId(),
49
+ mode,
50
+ locale,
51
+ submitMessage: submitMessageFor(locale, mode),
52
+ consentTextVersion: consent.consentTextVersion,
53
+ termsHash: createHash("sha256").update(terms.content).digest("hex"),
54
+ termsTemplateVersion: terms.version,
55
+ };
56
+ }
57
+ export function consentCheckoutFields(prepared) {
58
+ return {
59
+ consentId: prepared.consentId,
60
+ locale: prepared.locale,
61
+ submitMessage: prepared.submitMessage,
62
+ };
63
+ }
64
+ export async function recordConsent(ctx, prepared, details) {
65
+ const payload = {
66
+ consentId: prepared.consentId,
67
+ mode: prepared.mode,
68
+ tier: details.tier,
69
+ priceId: details.priceId,
70
+ unitAmount: details.price?.unitAmount ?? null,
71
+ currency: details.price?.currency ?? null,
72
+ interval: details.price?.interval ?? null,
73
+ intervalCount: details.price?.intervalCount ?? null,
74
+ consentTextVersion: prepared.consentTextVersion,
75
+ termsHash: prepared.termsHash,
76
+ termsTemplateVersion: prepared.termsTemplateVersion,
77
+ locale: prepared.locale,
78
+ actorUserId: String(ctx.user.id),
79
+ };
80
+ const tenantId = ctx.user.tenantId;
81
+ const isSubscription = prepared.mode === "subscription";
82
+ await ctx.unsafeAppendEvent({
83
+ aggregateId: isSubscription ? subscriptionAggregateId(tenantId) : paymentAggregateId(tenantId),
84
+ aggregateType: isSubscription ? SUBSCRIPTION_AGGREGATE_TYPE : PAYMENT_AGGREGATE_TYPE,
85
+ type: CHECKOUT_CONSENT_RECORDED_EVENT_QN,
86
+ payload,
87
+ });
88
+ }
@@ -0,0 +1,5 @@
1
+ export declare const CONSENT_LOCALES: readonly ["de", "en"];
2
+ export type ConsentLocale = (typeof CONSENT_LOCALES)[number];
3
+ export declare const FALLBACK_CONSENT_LOCALE: ConsentLocale;
4
+ /** Exact match, else the language part ("de-AT" → "de"), else the fallback. */
5
+ export declare function resolveConsentLocale(input?: string): ConsentLocale;