@cosmicdrift/kumiko-bundled-features 0.336.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 (186) hide show
  1. package/dist/agent-tools/agent-manifest.js +34 -7
  2. package/dist/agent-tools/tool-catalog.js +58 -19
  3. package/dist/audit/handlers/list.query.js +7 -3
  4. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +1 -0
  5. package/dist/auth-email-password/handlers/invite-accept.write.js +1 -0
  6. package/dist/auth-email-password/handlers/invite-info.query.js +1 -0
  7. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +1 -0
  8. package/dist/auth-email-password/handlers/signup-confirm.write.js +1 -0
  9. package/dist/auth-email-password/handlers/signup-request.write.js +1 -1
  10. package/dist/auth-email-password/seeding.js +6 -0
  11. package/dist/auth-email-password/web/invite-accept-screen.js +4 -1
  12. package/dist/auth-mfa/handlers/enable-start-preauth.write.js +1 -0
  13. package/dist/auth-mfa/handlers/enable-start.write.js +13 -2
  14. package/dist/auth-mfa/hmac-token-codec.js +8 -9
  15. package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +2 -0
  16. package/dist/billing-foundation/aggregate-id.d.ts +2 -0
  17. package/dist/billing-foundation/aggregate-id.js +8 -0
  18. package/dist/billing-foundation/checkout-core.d.ts +3 -0
  19. package/dist/billing-foundation/checkout-core.js +3 -0
  20. package/dist/billing-foundation/constants.d.ts +31 -0
  21. package/dist/billing-foundation/constants.js +35 -0
  22. package/dist/billing-foundation/consumer-protection/confirmation-mail.d.ts +21 -0
  23. package/dist/billing-foundation/consumer-protection/confirmation-mail.js +125 -0
  24. package/dist/billing-foundation/consumer-protection/consent-gate.d.ts +32 -0
  25. package/dist/billing-foundation/consumer-protection/consent-gate.js +88 -0
  26. package/dist/billing-foundation/consumer-protection/consent-locale.d.ts +5 -0
  27. package/dist/billing-foundation/consumer-protection/consent-locale.js +17 -0
  28. package/dist/billing-foundation/consumer-protection/consent-text.d.ts +11 -0
  29. package/dist/billing-foundation/consumer-protection/consent-text.js +28 -0
  30. package/dist/billing-foundation/consumer-protection/issue-confirmation.d.ts +15 -0
  31. package/dist/billing-foundation/consumer-protection/issue-confirmation.js +149 -0
  32. package/dist/billing-foundation/consumer-protection/register-consent.d.ts +3 -0
  33. package/dist/billing-foundation/consumer-protection/register-consent.js +63 -0
  34. package/dist/billing-foundation/consumer-protection/register-termination.d.ts +3 -0
  35. package/dist/billing-foundation/consumer-protection/register-termination.js +12 -0
  36. package/dist/billing-foundation/consumer-protection/termination-mail.d.ts +43 -0
  37. package/dist/billing-foundation/consumer-protection/termination-mail.js +76 -0
  38. package/dist/billing-foundation/consumer-protection/termination-pages.d.ts +8 -0
  39. package/dist/billing-foundation/consumer-protection/termination-pages.js +239 -0
  40. package/dist/billing-foundation/consumer-protection/termination-record.d.ts +51 -0
  41. package/dist/billing-foundation/consumer-protection/termination-record.js +162 -0
  42. package/dist/billing-foundation/consumer-protection/termination-request.d.ts +45 -0
  43. package/dist/billing-foundation/consumer-protection/termination-request.js +263 -0
  44. package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +62 -0
  45. package/dist/billing-foundation/consumer-protection/termination-texts.js +168 -0
  46. package/dist/billing-foundation/events.d.ts +86 -2
  47. package/dist/billing-foundation/events.js +76 -2
  48. package/dist/billing-foundation/feature.js +6 -2
  49. package/dist/billing-foundation/handlers/billing-plans.query.js +19 -1
  50. package/dist/billing-foundation/handlers/create-checkout-session.write.js +33 -2
  51. package/dist/billing-foundation/handlers/process-event.write.d.ts +2 -2
  52. package/dist/billing-foundation/handlers/process-event.write.js +43 -15
  53. package/dist/billing-foundation/handlers/process-payment-event.write.d.ts +2 -1
  54. package/dist/billing-foundation/handlers/process-payment-event.write.js +6 -2
  55. package/dist/billing-foundation/handlers/start-plan-checkout.write.js +15 -1
  56. package/dist/billing-foundation/handlers/sync-subscription.write.js +0 -1
  57. package/dist/billing-foundation/i18n.js +58 -0
  58. package/dist/billing-foundation/index.d.ts +3 -2
  59. package/dist/billing-foundation/index.js +2 -1
  60. package/dist/billing-foundation/types.d.ts +49 -9
  61. package/dist/billing-foundation/validate-options.js +47 -10
  62. package/dist/billing-foundation/web/billing-plans-panel.js +40 -3
  63. package/dist/billing-foundation/web/cancel-contract-dialog.d.ts +7 -0
  64. package/dist/billing-foundation/web/cancel-contract-dialog.js +57 -0
  65. package/dist/billing-foundation/web/checkout-consent-dialog.d.ts +28 -0
  66. package/dist/billing-foundation/web/checkout-consent-dialog.js +38 -0
  67. package/dist/billing-foundation/webhook-handler.js +5 -2
  68. package/dist/cap-counter/with-cap-enforcement.js +7 -3
  69. package/dist/cap-overview/access.d.ts +2 -1
  70. package/dist/cap-overview/access.js +26 -5
  71. package/dist/cap-overview/feature.d.ts +4 -0
  72. package/dist/cap-overview/feature.js +8 -6
  73. package/dist/cap-overview/handlers/caps-usage.query.d.ts +1 -1
  74. package/dist/cap-overview/handlers/caps-usage.query.js +6 -4
  75. package/dist/cap-overview/handlers/tenant-caps-list.query.js +13 -13
  76. package/dist/cap-overview/index.d.ts +2 -2
  77. package/dist/cap-overview/index.js +2 -2
  78. package/dist/cap-overview/screens.d.ts +1 -1
  79. package/dist/cap-overview/screens.js +17 -16
  80. package/dist/config/handlers/set.write.js +18 -2
  81. package/dist/config/write-helpers.js +2 -2
  82. package/dist/crypto-shredding/feature.js +3 -2
  83. package/dist/crypto-shredding/handlers/forget-subject.write.d.ts +1 -1
  84. package/dist/crypto-shredding/handlers/forget-subject.write.js +24 -13
  85. package/dist/custom-fields/feature.js +9 -6
  86. package/dist/custom-fields/handlers/clear-custom-field.write.js +1 -0
  87. package/dist/custom-fields/handlers/define-tenant-field.write.js +1 -0
  88. package/dist/custom-fields/handlers/set-custom-field.write.js +1 -0
  89. package/dist/custom-fields/lib/field-access.d.ts +3 -2
  90. package/dist/custom-fields/lib/field-access.js +1 -0
  91. package/dist/delivery/delivery-service.js +9 -7
  92. package/dist/delivery/unsubscribe.js +1 -0
  93. package/dist/document-ingest-foundation/feature.js +3 -2
  94. package/dist/files-tenant-data/hooks.js +11 -3
  95. package/dist/folders/web/folder-section.js +8 -1
  96. package/dist/form-draft/handlers/discard.write.js +1 -0
  97. package/dist/form-draft/handlers/save.write.js +1 -0
  98. package/dist/inbound-mail-foundation/handlers/ingest-message.write.js +1 -0
  99. package/dist/jobs/constants.d.ts +1 -0
  100. package/dist/jobs/constants.js +1 -0
  101. package/dist/jobs/handlers/trigger.write.js +7 -2
  102. package/dist/jobs/i18n.js +3 -0
  103. package/dist/managed-pages/handlers/set.write.js +1 -0
  104. package/dist/metrics/compute.js +16 -5
  105. package/dist/notes-history/executor.d.ts +2 -0
  106. package/dist/notes-history/executor.js +9 -1
  107. package/dist/notes-history/feature.d.ts +2 -3
  108. package/dist/notes-history/feature.js +2 -1
  109. package/dist/notes-history/handlers/add-note.write.d.ts +2 -1
  110. package/dist/notes-history/handlers/add-note.write.js +20 -3
  111. package/dist/notes-history/web/notes-section.js +6 -1
  112. package/dist/notes-history-user-data/hooks.js +26 -8
  113. package/dist/rate-limiting/handlers/bucket-access.d.ts +2 -5
  114. package/dist/secrets/feature.js +2 -0
  115. package/dist/secrets/handlers/delete.write.js +4 -0
  116. package/dist/secrets/handlers/set.write.js +5 -2
  117. package/dist/secrets/i18n.d.ts +10 -0
  118. package/dist/secrets/i18n.js +10 -0
  119. package/dist/secrets/write-gate.d.ts +10 -0
  120. package/dist/secrets/write-gate.js +36 -0
  121. package/dist/sessions/handlers/revoke-all-for-user.write.js +1 -0
  122. package/dist/shared/index.d.ts +0 -1
  123. package/dist/shared/index.js +0 -1
  124. package/dist/shared/session-locale-field.js +1 -2
  125. package/dist/shared/session-timezone-field.js +3 -2
  126. package/dist/step-dispatcher/feature.js +4 -3
  127. package/dist/step-dispatcher/mail-runner.d.ts +1 -0
  128. package/dist/step-dispatcher/mail-runner.js +5 -0
  129. package/dist/step-dispatcher/webhook-runner.d.ts +7 -0
  130. package/dist/step-dispatcher/webhook-runner.js +9 -0
  131. package/dist/subscription-mollie/plugin-methods.js +1 -0
  132. package/dist/subscription-mollie/verify-webhook.js +5 -3
  133. package/dist/subscription-stripe/plugin-methods.d.ts +3 -1
  134. package/dist/subscription-stripe/plugin-methods.js +85 -13
  135. package/dist/subscription-stripe/verify-webhook.js +7 -3
  136. package/dist/tags/executor.d.ts +2 -0
  137. package/dist/tags/executor.js +9 -1
  138. package/dist/tags/feature.d.ts +4 -3
  139. package/dist/tags/feature.js +4 -2
  140. package/dist/tags/handlers/assign-tag.write.d.ts +2 -1
  141. package/dist/tags/handlers/assign-tag.write.js +4 -4
  142. package/dist/tags/handlers/remove-tag.write.d.ts +2 -1
  143. package/dist/tags/handlers/remove-tag.write.js +3 -3
  144. package/dist/tenant-handover/handlers/claim.write.js +1 -0
  145. package/dist/tenant-handover/move-entity-graph.js +11 -3
  146. package/dist/tenant-lifecycle/handlers/cancel-destruction.write.js +1 -0
  147. package/dist/tenant-lifecycle/handlers/request-destruction.write.js +1 -0
  148. package/dist/tenant-lifecycle/lifecycle-gate.d.ts +4 -0
  149. package/dist/tenant-lifecycle/lifecycle-gate.js +26 -4
  150. package/dist/tier-engine/handlers/get-tenant-tier.query.js +1 -0
  151. package/dist/tier-engine/handlers/set-tenant-tier.write.js +1 -0
  152. package/dist/user/handlers/detail.query.js +1 -0
  153. package/dist/user/handlers/list.query.js +1 -0
  154. package/dist/user-data-rights/handlers/cancel-deletion.write.js +1 -0
  155. package/dist/user-data-rights/handlers/deletion-grace-period.js +3 -1
  156. package/dist/user-data-rights/handlers/download-attempt-list.query.js +1 -0
  157. package/dist/user-data-rights/handlers/download-by-job.query.js +1 -0
  158. package/dist/user-data-rights/handlers/download-by-token.query.js +1 -0
  159. package/dist/user-data-rights/handlers/export-status.query.js +1 -0
  160. package/dist/user-data-rights/handlers/lift-restriction.write.js +1 -0
  161. package/dist/user-data-rights/handlers/my-audit-log.query.js +1 -0
  162. package/dist/user-data-rights/handlers/request-deletion-by-email.write.js +1 -0
  163. package/dist/user-data-rights/handlers/request-export.write.js +1 -0
  164. package/dist/user-data-rights/handlers/run-forget-cleanup.write.js +1 -0
  165. package/dist/user-data-rights/run-forget-cleanup.js +30 -15
  166. package/dist/workflow-runner/db/queries/due-runs.d.ts +1 -0
  167. package/dist/workflow-runner/db/queries/due-runs.js +4 -1
  168. package/package.json +9 -9
  169. package/src/agent-tools/changes.json +20 -0
  170. package/src/billing-foundation/changes.json +49 -0
  171. package/src/cap-counter/changes.json +7 -0
  172. package/src/cap-overview/changes.json +14 -0
  173. package/src/config/changes.json +7 -0
  174. package/src/crypto-shredding/changes.json +13 -0
  175. package/src/custom-fields/changes.json +7 -0
  176. package/src/delivery/changes.json +7 -0
  177. package/src/notes-history/changes.json +7 -0
  178. package/src/secrets/changes.json +7 -0
  179. package/src/subscription-mollie/changes.json +13 -0
  180. package/src/subscription-stripe/changes.json +19 -0
  181. package/src/tags/changes.json +7 -0
  182. package/src/tenant-lifecycle/changes.json +8 -1
  183. package/src/user-data-rights/changes.json +6 -0
  184. package/src/workflow-runner/changes.json +6 -0
  185. package/dist/shared/session-field.d.ts +0 -3
  186. package/dist/shared/session-field.js +0 -6
@@ -6,14 +6,38 @@ import * as z from "zod";
6
6
  function entryAsLabels(entry) {
7
7
  return Object.fromEntries(Object.entries(entry).map(([locale, value]) => [locale, translationValueOtherText(value)]));
8
8
  }
9
+ const labelsIndexByTranslations = new WeakMap();
10
+ /** Every tail starting at a key boundary (`:` or `.`, with and without the
11
+ * separator) maps to the labels of the first key that ends with it. */
12
+ function buildSuffixIndex(translations) {
13
+ const index = new Map();
14
+ for (const [key, entry] of Object.entries(translations)) {
15
+ const labels = entryAsLabels(entry);
16
+ const addTail = (start) => {
17
+ const tail = key.slice(start);
18
+ if (!index.has(tail))
19
+ index.set(tail, labels);
20
+ };
21
+ addTail(0);
22
+ for (let position = 0; position < key.length; position++) {
23
+ if (key[position] === ":" || key[position] === ".") {
24
+ addTail(position);
25
+ addTail(position + 1);
26
+ }
27
+ }
28
+ }
29
+ return index;
30
+ }
9
31
  /** Translation keys carry a feature prefix the manifest can't reconstruct
10
- * (`showcase:entity:item:field:title`), so match on the suffix instead. */
32
+ * (`showcase:entity:item:field:title`), so match on the suffix instead. The
33
+ * suffix must start at a `:` or `.` boundary of the key. */
11
34
  function labelsForSuffix(translations, suffix) {
12
- for (const [key, labels] of Object.entries(translations)) {
13
- if (key === suffix || key.endsWith(suffix))
14
- return entryAsLabels(labels);
35
+ let index = labelsIndexByTranslations.get(translations);
36
+ if (index === undefined) {
37
+ index = buildSuffixIndex(translations);
38
+ labelsIndexByTranslations.set(translations, index);
15
39
  }
16
- return {};
40
+ return index.get(suffix) ?? {};
17
41
  }
18
42
  /** Nav/screen/workspace gating is opt-in: no rule means visible to everyone
19
43
  * (NavDefinition.access). Handlers are the opposite — `hasAccess` is
@@ -205,6 +229,9 @@ function lastSegment(qualifiedId) {
205
229
  const idx = qualifiedId.lastIndexOf(":");
206
230
  return idx === -1 ? qualifiedId : qualifiedId.slice(idx + 1);
207
231
  }
232
+ function detailIdParam(screen) {
233
+ return screen.type === "projectionDetail" ? (screen.idParam ?? "id") : "id";
234
+ }
208
235
  function buildScreens(screens, navs, workspaces, translations, roles, visibleNavIds) {
209
236
  const allNavs = [...navs.values()];
210
237
  const result = [];
@@ -239,8 +266,8 @@ function buildScreens(screens, navs, workspaces, translations, roles, visibleNav
239
266
  description: labelsForSuffix(translations, screen.description)["en"] ?? screen.description,
240
267
  }),
241
268
  ...("entity" in screen && screen.entity !== undefined && { entity: screen.entity }),
242
- // A detail screen takes the row id; everything else is parameterless.
243
- params: screen.detailFor !== undefined ? ["id"] : [],
269
+ // A detail screen takes the row id under its idParam; everything else is parameterless.
270
+ params: screen.detailFor !== undefined ? [detailIdParam(screen)] : [],
244
271
  workspaces: screenWorkspaces,
245
272
  ...(screen.detailFor !== undefined && { detailFor: screen.detailFor }),
246
273
  ...("handler" in screen && typeof screen.handler === "string" && { handler: screen.handler }),
@@ -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;
@@ -206,6 +226,25 @@ export function toolNameForQn(qn) {
206
226
  const sanitized = joined.replace(/[^A-Za-z0-9_]/g, "_").replace(/_+/g, "_");
207
227
  return sanitized.slice(0, MAX_TOOL_NAME_LENGTH);
208
228
  }
229
+ // FNV-1a: only needs to be stable and well-spread, not collision-proof — a hash clash
230
+ // on top of a name clash falls through to the sink's drop-later-tool guard.
231
+ function shortQnHash(qn) {
232
+ let hash = 0x811c9dc5;
233
+ for (let index = 0; index < qn.length; index++) {
234
+ hash = Math.imul(hash ^ qn.charCodeAt(index), 0x01000193) >>> 0;
235
+ }
236
+ return hash.toString(16).padStart(8, "0");
237
+ }
238
+ /** Sanitizing, verb-dropping and truncation make distinct QNs share a tool name; the
239
+ * first QN in manifest order keeps the plain name, later ones get a QN-hash suffix so
240
+ * no handler silently disappears from the catalog. */
241
+ function uniqueToolNameForQn(qn, usedNames) {
242
+ const plainName = toolNameForQn(qn);
243
+ if (!usedNames.has(plainName))
244
+ return plainName;
245
+ const hashSuffix = `_${shortQnHash(qn)}`;
246
+ return `${plainName.slice(0, MAX_TOOL_NAME_LENGTH - hashSuffix.length)}${hashSuffix}`;
247
+ }
209
248
  export const OPEN_FORM_TOOL_NAME = "open_form";
210
249
  function buildNavigateTool(manifest) {
211
250
  const entityScreens = new Map();
@@ -302,7 +341,7 @@ function addRegistrySearchTools(registry, roleFilter, denyQns, sink) {
302
341
  const entity = registry.getEntity(entityName);
303
342
  if (!entity)
304
343
  continue;
305
- addToolsForListHandler(registry, qn, entityName, entity, sink);
344
+ addToolsForListHandler(registry, qn, entityName, entity, roleFilter.roles, sink);
306
345
  }
307
346
  }
308
347
  function collectEntityHandlerQns(registry, roleFilter, denyQns) {
@@ -333,21 +372,21 @@ function collectEntityHandlerQns(registry, roleFilter, denyQns) {
333
372
  }
334
373
  return { detailQnByEntity, listQnByEntity, entityListDetailQns };
335
374
  }
336
- function addGetTools(detailQnByEntity, entityByName, locale, sink) {
375
+ function addGetTools(registry, detailQnByEntity, entityByName, locale, sink) {
337
376
  const getEntries = [...detailQnByEntity.entries()].sort((a, b) => compareByCodePoint(a[0], b[0]));
338
377
  for (const [entityName, qn] of getEntries) {
339
378
  const label = entityDisplayLabel(entityByName.get(entityName), entityName, locale);
340
- const { tool, descriptor } = buildGetTool(entityName, qn, label);
379
+ const { tool, descriptor } = buildGetTool(entityName, qn, label, entityHandlerRisk(registry, qn));
341
380
  addTool(sink, tool, descriptor);
342
381
  }
343
382
  }
344
- function addListTools(registry, listQnByEntity, entityByName, locale, sink) {
383
+ function addListTools(registry, listQnByEntity, entityByName, roles, locale, sink) {
345
384
  const listEntries = [...listQnByEntity.entries()].sort((a, b) => compareByCodePoint(a[0], b[0]));
346
385
  for (const [entityName, qn] of listEntries) {
347
386
  const label = entityDisplayLabel(entityByName.get(entityName), entityName, locale);
348
387
  const searchableFields = registry.getSearchableFields(entityName);
349
- const filterableFields = filterableFieldsOf(entityByName.get(entityName));
350
- 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));
351
390
  addTool(sink, tool, descriptor);
352
391
  }
353
392
  }
@@ -359,7 +398,7 @@ function addQueryHandlerTools(manifest, entityListDetailQns, denyQns, sink) {
359
398
  continue;
360
399
  if (entityListDetailQns.has(handler.qn))
361
400
  continue;
362
- const name = toolNameForQn(handler.qn);
401
+ const name = uniqueToolNameForQn(handler.qn, sink.usedNames);
363
402
  addTool(sink, { name, description: handlerDescription(handler), inputSchema: handler.inputSchema }, {
364
403
  kind: "server",
365
404
  op: "query",
@@ -375,7 +414,7 @@ function addWriteHandlerTools(manifest, detailQnByEntity, denyQns, sink) {
375
414
  continue;
376
415
  if (denyQns.has(handler.qn))
377
416
  continue;
378
- const name = toolNameForQn(handler.qn);
417
+ const name = uniqueToolNameForQn(handler.qn, sink.usedNames);
379
418
  const detailQn = handler.entity !== undefined ? detailQnByEntity.get(handler.entity) : undefined;
380
419
  const injectsVersion = detailQn !== undefined && schemaRequiresField(handler.inputSchema, "version");
381
420
  const inputSchema = injectsVersion
@@ -436,8 +475,8 @@ export function buildToolCatalog(registry, manifest, options) {
436
475
  const denyQns = new Set(options.denyQns ?? []);
437
476
  addRegistrySearchTools(registry, roleFilter, denyQns, sink);
438
477
  const { detailQnByEntity, listQnByEntity, entityListDetailQns } = collectEntityHandlerQns(registry, roleFilter, denyQns);
439
- addGetTools(detailQnByEntity, entityByName, locale, sink);
440
- addListTools(registry, listQnByEntity, entityByName, locale, sink);
478
+ addGetTools(registry, detailQnByEntity, entityByName, locale, sink);
479
+ addListTools(registry, listQnByEntity, entityByName, roleFilter.roles, locale, sink);
441
480
  addQueryHandlerTools(manifest, entityListDetailQns, denyQns, sink);
442
481
  if (options.mode !== "read-only") {
443
482
  addWriteHandlerTools(manifest, detailQnByEntity, denyQns, sink);
@@ -27,15 +27,19 @@ function buildDateRange(from, to) {
27
27
  range.lte = Temporal.Instant.from(to);
28
28
  return range;
29
29
  }
30
+ function escapeLikePattern(value) {
31
+ return value.replace(/[\\%_]/g, (m) => `\\${m}`);
32
+ }
30
33
  function buildAuditWhere(tenantId, p) {
31
34
  const where = { tenantId };
32
35
  if (p.aggregateType)
33
36
  where["aggregateType"] = p.aggregateType;
34
37
  if (p.aggregateId)
35
38
  where["aggregateId"] = p.aggregateId;
36
- const type = p.eventType ?? p.search;
37
- if (type)
38
- where["type"] = type;
39
+ if (p.eventType)
40
+ where["type"] = p.eventType;
41
+ else if (p.search)
42
+ where["type"] = { like: `%${escapeLikePattern(p.search)}%` };
39
43
  if (p.userId)
40
44
  where["createdBy"] = p.userId;
41
45
  const range = buildDateRange(p.from, p.to);
@@ -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
@@ -151,6 +151,12 @@ export async function seedAdmin(db, options) {
151
151
  return { id: userId };
152
152
  }
153
153
  function compareInsertedAt(a, b) {
154
+ // selectMany<ActiveUserRow> is an unchecked row-shape claim. A codec that
155
+ // yields another type would make both comparisons false and silently fall
156
+ // back to id order, which can crown the younger duplicate as survivor.
157
+ if (typeof a.epochNanoseconds !== "bigint" || typeof b.epochNanoseconds !== "bigint") {
158
+ throw new Error("compareInsertedAt: user.insertedAt is not a Temporal.Instant");
159
+ }
154
160
  if (a.epochNanoseconds < b.epochNanoseconds)
155
161
  return -1;
156
162
  if (a.epochNanoseconds > b.epochNanoseconds)
@@ -22,6 +22,9 @@ import { csrfHeader } from "./auth-client.js";
22
22
  import { resolvePostAuthHref } from "./auth-form-logic.js";
23
23
  import { AuthCard, useUrlToken } from "./auth-form-primitives.js";
24
24
  import { SessionContext, UNAUTHENTICATED } from "./session.js";
25
+ function isMfaPending(data) {
26
+ return data.tenantId === undefined;
27
+ }
25
28
  function grantedRoles(data) {
26
29
  if (data.user?.roles !== undefined)
27
30
  return data.user.roles;
@@ -46,7 +49,7 @@ export function InviteAcceptScreen({ title, token: tokenProp, loggedInHref = "/"
46
49
  // challenge / setup requirement, but no session cookie was minted: send the
47
50
  // user to the login screen (now a member) instead of a tenant URL.
48
51
  const finishAccept = (data) => {
49
- if (data.mfaRequired === true || data.mfaSetupRequired === true) {
52
+ if (isMfaPending(data)) {
50
53
  window.location.assign(loginHref);
51
54
  return;
52
55
  }
@@ -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) => {
@@ -9,6 +9,18 @@ import { signMfaSetupToken } from "../mfa-setup-token.js";
9
9
  import { buildOtpauthUri } from "../otpauth-uri.js";
10
10
  import { generateRecoveryCodes, hashRecoveryCodes } from "../recovery-codes.js";
11
11
  import { generateTotpSecret } from "../totp.js";
12
+ const FALLBACK_ACCOUNT_LABEL = "account";
13
+ // The authenticator app shows this label, so an internal user id must never leak into it.
14
+ function readableAccountLabel(me) {
15
+ if (typeof me !== "object" || me === null)
16
+ return FALLBACK_ACCOUNT_LABEL;
17
+ for (const field of ["email", "displayName"]) {
18
+ const value = Reflect.get(me, field);
19
+ if (typeof value === "string" && value.length > 0)
20
+ return value;
21
+ }
22
+ return FALLBACK_ACCOUNT_LABEL;
23
+ }
12
24
  // Stateless setup: no `userMfa` row is created here. The generated secret +
13
25
  // recovery-code hashes are signed into a short-lived `setupToken` (see
14
26
  // mfa-setup-token.ts) that `enable-confirm` verifies. An abandoned setup
@@ -41,8 +53,7 @@ export function createEnableStartHandler(opts) {
41
53
  if (!accountLabel) {
42
54
  // The declarative secretMint screen has no client component left to
43
55
  // read the session email off — derive the otpauth label server-side.
44
- const me = (await ctx.queryAs(event.user, UserQueries.me, {})); // @cast-boundary engine-payload
45
- accountLabel = me?.email ?? event.user.id;
56
+ accountLabel = readableAccountLabel(await ctx.queryAs(event.user, UserQueries.me, {}));
46
57
  }
47
58
  const secret = generateTotpSecret();
48
59
  const recoveryCodes = generateRecoveryCodes();
@@ -7,15 +7,15 @@ function hmacSign(input, secret) {
7
7
  // minted for one MFA step from verifying as another (e.g. a preauth-setup token
8
8
  // replayed as a challenge token), since both share the same secret and payload shape.
9
9
  export function createHmacTokenCodec(domain, isPayload) {
10
- function isEncodedBody(value) {
10
+ function splitEncodedBody(value) {
11
11
  if (typeof value !== "object" || value === null)
12
- return false;
12
+ return null;
13
13
  const v = value;
14
14
  const expiresAtMs = v["expiresAtMs"];
15
15
  if (typeof expiresAtMs !== "number")
16
- return false;
16
+ return null;
17
17
  const { expiresAtMs: _drop, ...rest } = v;
18
- return isPayload(rest);
18
+ return isPayload(rest) ? { payload: rest, expiresAtMs } : null;
19
19
  }
20
20
  return {
21
21
  sign(payload, ttlMinutes, secret, now = Temporal.Now.instant()) {
@@ -46,14 +46,13 @@ export function createHmacTokenCodec(domain, isPayload) {
46
46
  catch {
47
47
  return { ok: false, reason: "malformed" };
48
48
  }
49
- if (!isEncodedBody(parsed))
49
+ const body = splitEncodedBody(parsed);
50
+ if (!body)
50
51
  return { ok: false, reason: "malformed" };
51
- if (Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(parsed.expiresAtMs)) >
52
- 0) {
52
+ if (Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(body.expiresAtMs)) > 0) {
53
53
  return { ok: false, reason: "expired" };
54
54
  }
55
- const { expiresAtMs, ...payload } = parsed;
56
- return { ok: true, payload: payload, expiresAtMs };
55
+ return { ok: true, payload: body.payload, expiresAtMs: body.expiresAtMs };
57
56
  },
58
57
  };
59
58
  }
@@ -1,5 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // @runtime client
3
+ // Consumers that type-check this source never include the ambient declaration on their own.
4
+ /// <reference path="./qrcode-browser.d.ts" />
3
5
  // Pre-auth twin of the declarative auth-mfa-enable secretMint screen: a user
4
6
  // blocked at login by MFA enforcement and not yet enrolled lands here
5
7
  // straight out of the login flow — there is no session yet. Identity comes
@@ -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;