@cosmicdrift/kumiko-samples 0.363.0 → 0.364.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 (140) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +7 -1
  3. package/packages/bundled-features/src/audit/changes.json +6 -0
  4. package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
  5. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +29 -8
  6. package/packages/bundled-features/src/auth-email-password/changes.json +37 -0
  7. package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
  8. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
  9. package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
  10. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
  11. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -1
  12. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
  13. package/packages/bundled-features/src/billing-foundation/changes.json +36 -0
  14. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +57 -5
  15. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
  16. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +4 -1
  17. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
  18. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +9 -3
  19. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
  20. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +10 -1
  21. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +35 -19
  22. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
  24. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
  25. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +10 -4
  26. package/packages/bundled-features/src/billing-foundation/i18n.ts +3 -0
  27. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +17 -5
  28. package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
  29. package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
  30. package/packages/bundled-features/src/cap-counter/changes.json +6 -0
  31. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
  32. package/packages/bundled-features/src/cap-counter/index.ts +1 -0
  33. package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
  34. package/packages/bundled-features/src/channel-email/email-channel.ts +17 -4
  35. package/packages/bundled-features/src/config/changes.json +6 -0
  36. package/packages/bundled-features/src/config/handlers/set.write.ts +2 -2
  37. package/packages/bundled-features/src/config/resolver.ts +15 -12
  38. package/packages/bundled-features/src/config/write-helpers.ts +23 -0
  39. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  40. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +48 -27
  41. package/packages/bundled-features/src/custom-fields/changes.json +6 -0
  42. package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
  43. package/packages/bundled-features/src/delivery/attachment-ref.ts +21 -0
  44. package/packages/bundled-features/src/delivery/changes.json +18 -0
  45. package/packages/bundled-features/src/delivery/channel-context.ts +31 -8
  46. package/packages/bundled-features/src/delivery/index.ts +2 -0
  47. package/packages/bundled-features/src/delivery/jobs.ts +51 -14
  48. package/packages/bundled-features/src/delivery/types.ts +7 -1
  49. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
  50. package/packages/bundled-features/src/file-foundation/changes.json +6 -0
  51. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +6 -3
  52. package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
  53. package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
  54. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
  55. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
  56. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
  57. package/packages/bundled-features/src/jobs/changes.json +19 -0
  58. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
  59. package/packages/bundled-features/src/jobs/feature.ts +7 -4
  60. package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
  61. package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
  62. package/packages/bundled-features/src/jobs/job-run-logger.ts +52 -17
  63. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
  64. package/packages/bundled-features/src/ledger/changes.json +7 -0
  65. package/packages/bundled-features/src/ledger/entity.ts +3 -1
  66. package/packages/bundled-features/src/renderer-foundation/types.ts +2 -0
  67. package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
  68. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
  69. package/packages/bundled-features/src/secrets/changes.json +6 -0
  70. package/packages/bundled-features/src/secrets/feature.ts +4 -3
  71. package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
  72. package/packages/bundled-features/src/shared/changes.json +6 -0
  73. package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
  74. package/packages/bundled-features/src/shared/mail-template-body.ts +17 -0
  75. package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
  76. package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
  77. package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
  78. package/packages/bundled-features/src/template-resolver/README.md +4 -2
  79. package/packages/bundled-features/src/template-resolver/api.ts +3 -2
  80. package/packages/bundled-features/src/template-resolver/changes.json +68 -0
  81. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +77 -13
  82. package/packages/bundled-features/src/template-resolver/constants.ts +12 -2
  83. package/packages/bundled-features/src/template-resolver/document-schema.ts +26 -4
  84. package/packages/bundled-features/src/template-resolver/feature.ts +4 -1
  85. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +31 -13
  86. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +5 -44
  87. package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
  88. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +56 -1
  89. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +6 -3
  90. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +73 -30
  91. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +14 -11
  92. package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
  93. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +90 -17
  94. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +1 -1
  95. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +39 -10
  96. package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +14 -2
  97. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +54 -4
  98. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +96 -14
  99. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +51 -24
  100. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +1 -1
  101. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +16 -2
  102. package/packages/bundled-features/src/template-resolver/index.ts +12 -1
  103. package/packages/bundled-features/src/template-resolver/layout-appearance.ts +40 -0
  104. package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
  105. package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
  106. package/packages/bundled-features/src/template-resolver/seeding.ts +25 -4
  107. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +2 -2
  108. package/packages/bundled-features/src/template-resolver/table.ts +13 -1
  109. package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
  110. package/packages/bundled-features/src/template-resolver/template-meta.ts +87 -24
  111. package/packages/bundled-features/src/template-resolver/template-preview.ts +159 -56
  112. package/packages/bundled-features/src/template-resolver/template-resources.ts +44 -4
  113. package/packages/bundled-features/src/template-resolver/template-usage.ts +31 -11
  114. package/packages/bundled-features/src/template-resolver/template-validation.ts +1 -0
  115. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +12 -8
  116. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +63 -10
  117. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +12 -3
  118. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +349 -134
  119. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +11 -0
  120. package/packages/bundled-features/src/template-resolver/web/i18n.ts +25 -4
  121. package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
  122. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +10 -3
  123. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  124. package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
  125. package/packages/bundled-features/src/user/changes.json +6 -0
  126. package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
  127. package/packages/bundled-features/src/user/seeding.ts +19 -5
  128. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  129. package/packages/bundled-features/src/user-profile/changes.json +1 -1
  130. package/packages/bundled-features/src/waitlist/options.ts +1 -1
  131. package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
  132. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +2 -1
  133. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
  134. package/samples/apps/ui-walkthrough/package.json +9 -0
  135. package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
  136. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +87 -2
  137. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +246 -30
  138. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +4 -0
  139. package/samples/apps/ui-walkthrough/src/run-config.ts +4 -0
  140. package/samples/apps/use-all-bundled/feature-manifest.json +5 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.363.0",
3
+ "version": "0.364.0",
4
4
  "description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.363.0",
3
+ "version": "0.364.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -106,6 +106,7 @@
106
106
  "./shared/single-use-token-store": "./src/shared/single-use-token-store.ts",
107
107
  "./shared/signed-token": "./src/shared/signed-token.ts",
108
108
  "./shared/row-bound-grant": "./src/shared/row-bound-grant.ts",
109
+ "./shared/mail-template-body": "./src/shared/mail-template-body.ts",
109
110
  "./delivery": "./src/delivery/index.ts",
110
111
  "./delivery/web": "./src/delivery/web/index.ts",
111
112
  "./channel-in-app": "./src/channel-in-app/index.ts",
@@ -180,6 +181,7 @@
180
181
  "pdf-lib": "^1.17.1",
181
182
  "qrcode": "^1.5.4",
182
183
  "react": "^19.2.6",
184
+ "react-dom": "^19.2.6",
183
185
  "sharp": "^0.35.5",
184
186
  "stripe": "^22.6.2",
185
187
  "tailwind-merge": "^3.6.0",
@@ -527,6 +529,10 @@
527
529
  "types": "./dist/shared/row-bound-grant.d.ts",
528
530
  "default": "./dist/shared/row-bound-grant.js"
529
531
  },
532
+ "./shared/mail-template-body": {
533
+ "types": "./dist/shared/mail-template-body.d.ts",
534
+ "default": "./dist/shared/mail-template-body.js"
535
+ },
530
536
  "./delivery": {
531
537
  "types": "./dist/delivery/index.d.ts",
532
538
  "default": "./dist/delivery/index.js"
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.364.0",
4
+ "type": "fix",
5
+ "title": "Audit list pages by id unless sorted by type",
6
+ "detail": "An explicit createdAt sort pages in id order so keyset pagination no longer skips rows."
7
+ },
2
8
  {
3
9
  "version": "0.361.0",
4
10
  "type": "fix",
@@ -117,11 +117,16 @@ export const listQuery = defineQueryHandler({
117
117
  createdAt: unknown;
118
118
  createdBy: string;
119
119
  }>(ctx.db, eventsTable, where, {
120
- // `id` as tie-breaker keeps the order total, so pages never overlap.
121
- orderBy: [
122
- { col: p.sort === "type" ? "type" : "createdAt", direction },
123
- { col: "id", direction },
124
- ],
120
+ // The id cursor only resumes an id-ordered listing. created_at comes from the
121
+ // transaction start and can run against insert order, so ordering by it would let
122
+ // a page boundary skip rows; only the type sort leaves id order (and is one page).
123
+ orderBy:
124
+ p.sort === "type"
125
+ ? [
126
+ { col: "type", direction },
127
+ { col: "id", direction },
128
+ ]
129
+ : [{ col: "id", direction }],
125
130
  limit: p.limit,
126
131
  });
127
132
 
@@ -7,23 +7,26 @@
7
7
 
8
8
  import {
9
9
  makeDispatchSystemWrite,
10
- type SystemDispatchArgs,
10
+ makeDispatchWriteAs,
11
+ type RoleDispatchArgs,
11
12
  } from "@cosmicdrift/kumiko-framework/api";
12
13
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
13
14
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
14
15
  import {
15
16
  ConfigScopes,
16
17
  createSystemUser,
18
+ type Registry,
17
19
  type TenantId,
18
20
  type WriteResult,
19
21
  } from "@cosmicdrift/kumiko-framework/engine";
20
22
  import type { Dispatcher } from "@cosmicdrift/kumiko-framework/pipeline";
23
+ import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
21
24
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
22
25
  import type { Redis } from "ioredis";
23
26
  // kumiko-lint-ignore cross-feature-import bootstrap sets per-tenant config through the config feature's write handler
24
27
  import { ConfigHandlers } from "../config/constants.js";
25
28
  // kumiko-lint-ignore cross-feature-import bootstrap compares against the stored config row to stay idempotent
26
- import { findConfigRow } from "../config/write-helpers.js";
29
+ import { configRowHoldsValue, findConfigRow } from "../config/write-helpers.js";
27
30
  // kumiko-lint-ignore cross-feature-import bootstrap provisions tenant + invitation rows owned by the tenant feature
28
31
  import { TenantHandlers } from "../tenant/constants.js";
29
32
  // kumiko-lint-ignore cross-feature-import bootstrap provisions tenant + invitation rows owned by the tenant feature
@@ -55,9 +58,7 @@ export type BootstrapSeedDeps = {
55
58
  /** Writes as the system user with the given tenant roles in the seeded
56
59
  * tenant; use it when the app gates tenant data on its own roles rather
57
60
  * than SystemAdmin. */
58
- readonly dispatchWriteAs: (
59
- args: Pick<SystemDispatchArgs, "handlerQn" | "payload"> & { readonly roles: readonly string[] },
60
- ) => Promise<WriteResult>;
61
+ readonly dispatchWriteAs: (args: Omit<RoleDispatchArgs, "tenantId">) => Promise<WriteResult>;
61
62
  };
62
63
 
63
64
  export type BootstrapConfigValue = string | number | boolean;
@@ -96,6 +97,10 @@ export type BootstrapDeps = {
96
97
  readonly db: DbConnection;
97
98
  readonly redis: Redis;
98
99
  readonly dispatcher: Dispatcher;
100
+ /** With these, a rerun can compare `encrypted`/`piiEncrypted` config keys by
101
+ * their decrypted value; without them such keys are rewritten on every run. */
102
+ readonly registry?: Registry;
103
+ readonly configCipher?: EnvelopeCipher;
99
104
  };
100
105
 
101
106
  export type BootstrapTenantOutcome = "created" | "exists";
@@ -216,7 +221,18 @@ async function applyTenantConfig(
216
221
  const applied: string[] = [];
217
222
  for (const [key, value] of Object.entries(tenant.config ?? {})) {
218
223
  const existing = await findConfigRow(deps.db, key, tenant.id, null);
219
- if (existing?.value === JSON.stringify(value)) continue;
224
+ const serialized = JSON.stringify(value);
225
+ if (
226
+ await configRowHoldsValue(
227
+ existing,
228
+ deps.registry?.getConfigKey(key),
229
+ deps.configCipher,
230
+ key,
231
+ serialized,
232
+ )
233
+ ) {
234
+ continue;
235
+ }
220
236
  const result = await deps.dispatcher.write(
221
237
  ConfigHandlers.set,
222
238
  { key, value, scope: ConfigScopes.tenant },
@@ -266,6 +282,11 @@ async function decideInvite(
266
282
  return "role-mismatch";
267
283
  }
268
284
  if (invitation.status === INVITATION_STATUS.accepted) return "active";
285
+ // The plan dropped a global role (e.g. an admin moved to tenants[].invites):
286
+ // the stored invitation would still grant it on accept, so it is re-issued.
287
+ if (storedGlobalRoles.some((role) => !invite.globalRoles.some((planned) => planned === role))) {
288
+ return "resent";
289
+ }
269
290
  if (await hasLiveInviteToken(deps.redis, invitation.id)) return "pending";
270
291
  return "resent";
271
292
  }
@@ -280,6 +301,7 @@ export async function bootstrapTenants(
280
301
  ): Promise<BootstrapReport> {
281
302
  const plannedInvites = planInvites(plan);
282
303
  const dispatchSystemWrite = makeDispatchSystemWrite(deps.dispatcher);
304
+ const dispatchWriteAsRoles = makeDispatchWriteAs(deps.dispatcher);
283
305
 
284
306
  const tenants: Array<BootstrapReport["tenants"][number]> = [];
285
307
  for (const tenant of plan.tenants) {
@@ -290,8 +312,7 @@ export async function bootstrapTenants(
290
312
  tenantId: tenant.id,
291
313
  db: deps.db,
292
314
  dispatchSystemWrite,
293
- dispatchWriteAs: ({ handlerQn, payload, roles }) =>
294
- deps.dispatcher.write(handlerQn, payload, createSystemUser(tenant.id, roles)),
315
+ dispatchWriteAs: (args) => dispatchWriteAsRoles({ ...args, tenantId: tenant.id }),
295
316
  });
296
317
  }
297
318
  const configApplied = await applyTenantConfig(deps, tenant);
@@ -1,4 +1,41 @@
1
1
  [
2
+ {
3
+ "version": "0.364.0",
4
+ "type": "fix",
5
+ "title": "A loginUrl builder that throws no longer crashes the app tree",
6
+ "detail": "The auth gate resolves the login redirect once per location change and, when the builder returns an invalid URL or throws, logs the error and shows the built-in login screen instead of throwing during render."
7
+ },
8
+ {
9
+ "version": "0.364.0",
10
+ "type": "fix",
11
+ "title": "How to send an auth mail after the render*Email helpers were removed in 0.363.0",
12
+ "detail": "No runtime change. The 0.363.0 breaking entry lists renderActivationEmail, renderResetPasswordEmail, renderUnlockAccountEmail, renderVerifyEmail and renderInviteEmail as removed but does not say how app code that rendered one of these mails itself now sends it. This entry adds that recipe.",
13
+ "migration": "App code that rendered an auth mail itself and passed the result as `data`, for example `notify(\"auth-email-password:signup-activation\", { route: { email }, data: renderActivationEmail({ url, expiresAt, locale, appName }), priority: \"critical\" })`, breaks twice since 0.363.0: the helper is gone and the notification type is now `auth-email-password:notify:signup-activation`. The stored template renders subject and body; the app only passes template variables.\nFor a signup activation link, call `issueSignupActivation({ redis, notify: ctx.notify, email, appUrl, appName, locale, timeZone, tokenTtlMinutes, onTokenStored })` from `@cosmicdrift/kumiko-bundled-features/auth-email-password` (only redis, notify, email and appUrl are required). It drops any live signup token for the email, stores a new one under the keys signup-confirm reads and sends the mail. `appUrl` is the bare activation page; a function `(locale) => url` picks the page per locale.\nIf the app stores the token itself and only needs the mail, call `ctx.notify(AuthMailNotificationTypes.signupActivation, { route: { email }, data: { appName, actionUrl, validFor, validUntil }, locale, priority: \"critical\" })`. `AuthMailNotificationTypes` comes from `@cosmicdrift/kumiko-bundled-features/auth-email-password`. All four variables are required strings: ctx.notify checks them against the declared template variables and throws an InternalError on a missing or invalid one, which fails the write. `actionUrl` is the page URL with `token=<encodeURIComponent(token)>` appended as a query parameter and must be an http, https or mailto URL. `validFor` and `validUntil` come from `tokenMailExpiry({ expiresAt, issuedAt, timeZone }, locale)` in `@cosmicdrift/kumiko-bundled-features/auth-email-password/email-templates`. Only send a `locale` that has a template for the type, because the renderer has no locale fallback and a locale without a template sends nothing: use `resolveMailLocale(locale)` when `hasMailTranslationKey(resolveMailLocale(locale), \"auth.mail.activation.subject\")` is true (both from `@cosmicdrift/kumiko-framework/i18n`), otherwise `\"en\"`. The other mails use the subject keys `auth.mail.reset.subject`, `auth.mail.unlock.subject`, `auth.mail.verify.subject` and `auth.mail.invite.subject`.\nThe other auth mails work the same way with `AuthMailNotificationTypes.passwordReset`, `.accountUnlock`, `.emailVerification` and `.invite`; the invite mail also needs a `role` variable."
14
+ },
15
+ {
16
+ "version": "0.364.0",
17
+ "type": "fix",
18
+ "title": "seedAdminGuarded rewrites the admin email only when its blind index is missing or stale",
19
+ "detail": "rewriteSeededUserEmail returns early when the stored email blind index already matches the expected value, so repeated boots no longer re-encrypt the email and grow the user event stream. The row version is now checked with a type guard instead of a cast."
20
+ },
21
+ {
22
+ "version": "0.364.0",
23
+ "type": "fix",
24
+ "title": "Bootstrap reruns no longer rewrite encrypted config or restore dropped global roles",
25
+ "detail": "Declarative bootstrap compares encrypted and piiEncrypted config keys by their decrypted value, so a matching rerun writes nothing, and re-issues an invitation whose stored global roles are no longer in the plan. makeDispatchWriteAs is exported from the framework api."
26
+ },
27
+ {
28
+ "version": "0.364.0",
29
+ "type": "fix",
30
+ "title": "Email payload buckets ignore plus-tags and case; auth requests are capped at 3 per 15 minutes",
31
+ "detail": "additionalRateLimits accepts normalize: email, which lowercases and strips the plus-tag before hashing, and bucket keys are scoped by tenant and handler. Signup and token requests (reset, unlock, verification) and the waitlist default use it with a 3-request, 900-second window."
32
+ },
33
+ {
34
+ "version": "0.364.0",
35
+ "type": "fix",
36
+ "title": "Password change and logout work for members with only an app role or TenantAdmin",
37
+ "detail": "Both handlers required User, Admin or SystemAdmin and answered 403 access_denied for invited members holding a single custom role. They are now open to every signed-in user, like the other self-service handlers; anonymous callers are still rejected."
38
+ },
2
39
  {
3
40
  "version": "0.363.0",
4
41
  "type": "breaking",
@@ -0,0 +1,16 @@
1
+ import type { PayloadRateLimitOption } from "@cosmicdrift/kumiko-types/handlers";
2
+
3
+ // The bucket counts across all IPs, so a long window would let an attacker who
4
+ // knows an address lock its owner out of recovery/signup for the whole window.
5
+ // A short one still throttles mail bombing but clears within minutes.
6
+ export const AUTH_EMAIL_RATE_LIMIT_WINDOW_SECONDS = 15 * 60;
7
+ export const AUTH_EMAIL_RATE_LIMIT_MAX_REQUESTS = 3;
8
+
9
+ export const AUTH_EMAIL_PAYLOAD_RATE_LIMITS: readonly PayloadRateLimitOption[] = [
10
+ {
11
+ per: { payloadField: "email" },
12
+ limit: AUTH_EMAIL_RATE_LIMIT_MAX_REQUESTS,
13
+ windowSeconds: AUTH_EMAIL_RATE_LIMIT_WINDOW_SECONDS,
14
+ normalize: "email",
15
+ },
16
+ ];
@@ -1,5 +1,4 @@
1
1
  import {
2
- access,
3
2
  createSystemUser,
4
3
  defineWriteHandler,
5
4
  SYSTEM_TENANT_ID,
@@ -20,7 +19,12 @@ export const changePasswordWrite = defineWriteHandler({
20
19
  oldPassword: z.string().min(1),
21
20
  newPassword: passwordSchema,
22
21
  }),
23
- access: { roles: access.authenticated },
22
+ access: {
23
+ openToAll: {
24
+ reason:
25
+ "Self-service: acts only on event.user.id and re-checks the caller's current password; invited members hold only an app role, so a fixed role list would lock them out.",
26
+ },
27
+ },
24
28
  escapeHatch: {
25
29
  grants: ["systemIdentity"],
26
30
  reason:
@@ -1,4 +1,4 @@
1
- import { access, defineWriteHandler, stepsPipeline } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { defineWriteHandler, stepsPipeline } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import * as z from "zod";
3
3
 
4
4
  // Logout — JWT is stateless, so server-side we only return OK. A future
@@ -7,7 +7,12 @@ import * as z from "zod";
7
7
  export const logoutWrite = defineWriteHandler({
8
8
  name: "logout",
9
9
  schema: z.object({}),
10
- access: { roles: access.authenticated },
10
+ access: {
11
+ openToAll: {
12
+ reason:
13
+ "Self-service: touches no data, any signed-in member must be able to sign out regardless of which role they hold.",
14
+ },
15
+ },
11
16
  description:
12
17
  "Acknowledges a sign-out for the authenticated caller so the client can discard its token; the JWT is stateless, so no server-side session state changes here.",
13
18
  perform: stepsPipeline(({ r }) => [
@@ -25,6 +25,7 @@ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
25
25
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
26
26
  import * as z from "zod";
27
27
  import { findSignupHandoverProvider, type SignupHandoverBinding } from "../../shared/index.js";
28
+ import { AUTH_EMAIL_PAYLOAD_RATE_LIMITS } from "../email-rate-limits.js";
28
29
  import type { AuthMailLocale } from "../email-templates.js";
29
30
  import { resolveHandlerMailLocale } from "../magic-link-mail.js";
30
31
  import { AUTH_SELF_REGISTRATION_FEATURE } from "../self-registration-toggle.js";
@@ -79,7 +80,7 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
79
80
  schema: SignupRequestSchema,
80
81
  access: { roles: ["anonymous"] },
81
82
  rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
82
- additionalRateLimits: [{ per: { payloadField: "email" }, limit: 3, windowSeconds: 86400 }],
83
+ additionalRateLimits: AUTH_EMAIL_PAYLOAD_RATE_LIMITS,
83
84
  description:
84
85
  "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.",
85
86
  escapeHatch: { reason: HANDOVER_GRANT_VERIFY_REASON, grants: ["unsafeRaw"] },
@@ -19,6 +19,7 @@ import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
19
19
  import * as z from "zod";
20
20
  import { UserQueries } from "../../user/index.js";
21
21
  import { type AuthUserRow, parseAuthUserRow } from "../auth-user-row.js";
22
+ import { AUTH_EMAIL_PAYLOAD_RATE_LIMITS } from "../email-rate-limits.js";
22
23
  import type { AuthMailLocale } from "../email-templates.js";
23
24
  import { dispatchMagicLinkMail, resolveHandlerMailLocale } from "../magic-link-mail.js";
24
25
 
@@ -99,7 +100,7 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
99
100
  schema: RequestTokenSchema,
100
101
  access: { roles: ["anonymous"] },
101
102
  rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
102
- additionalRateLimits: [{ per: { payloadField: "email" }, limit: 5, windowSeconds: 86400 }],
103
+ additionalRateLimits: AUTH_EMAIL_PAYLOAD_RATE_LIMITS,
103
104
  escapeHatch: {
104
105
  grants: ["systemIdentity"],
105
106
  reason:
@@ -10,7 +10,7 @@
10
10
  // Screen rein konfigurieren, ohne den Gate selbst ersetzen zu müssen.
11
11
 
12
12
  import { useLocale } from "@cosmicdrift/kumiko-renderer";
13
- import { type ComponentType, type ReactNode, useEffect, useState } from "react";
13
+ import { type ComponentType, type ReactNode, useEffect, useMemo, useState } from "react";
14
14
  import {
15
15
  assertNavigableUrl,
16
16
  buildLoginRedirectUrl,
@@ -204,6 +204,21 @@ export function resolveLoginRedirect(
204
204
  };
205
205
  }
206
206
 
207
+ // A throwing loginUrl builder must not take the whole app tree down in render;
208
+ // null falls through to the built-in login screen.
209
+ function resolveLoginRedirectOrNull(
210
+ loginUrl: string | LoginUrlBuilder,
211
+ locale: string,
212
+ location: Pick<Location, "origin" | "pathname" | "search" | "hash">,
213
+ ): LoginRedirect | null {
214
+ try {
215
+ return resolveLoginRedirect(loginUrl, locale, location);
216
+ } catch (error) {
217
+ console.error("Auth gate: loginUrl could not be resolved, showing the built-in login", error);
218
+ return null;
219
+ }
220
+ }
221
+
207
222
  export function makeAuthGate(opts: AuthGateOptions = {}): ComponentType<{
208
223
  children: ReactNode;
209
224
  }> {
@@ -213,10 +228,14 @@ export function makeAuthGate(opts: AuthGateOptions = {}): ComponentType<{
213
228
  function AuthGate({ children }: { readonly children: ReactNode }): ReactNode {
214
229
  const { status } = useSession();
215
230
  const locale = useLocale().locale();
216
- const loginRedirect =
217
- loginUrl !== undefined && status === "unauthenticated"
218
- ? resolveLoginRedirect(loginUrl, locale, window.location)
219
- : null;
231
+ const { origin, pathname, search, hash } = window.location;
232
+ const loginRedirect = useMemo(
233
+ () =>
234
+ loginUrl !== undefined && status === "unauthenticated"
235
+ ? resolveLoginRedirectOrNull(loginUrl, locale, { origin, pathname, search, hash })
236
+ : null,
237
+ [status, locale, origin, pathname, search, hash],
238
+ );
220
239
  const redirectUrl = loginRedirect?.kind === "redirect" ? loginRedirect.url : null;
221
240
  // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
222
241
  useEffect(() => {
@@ -1,4 +1,40 @@
1
1
  [
2
+ {
3
+ "version": "0.364.0",
4
+ "type": "fix",
5
+ "title": "Stale subscription created events are ignored and manual-grant skips are logged",
6
+ "detail": "A created event for a subscription that is no longer current no longer replaces the active one while the current row blocks checkout, and the tier sync logs its own code when a manual grant keeps the tier."
7
+ },
8
+ {
9
+ "version": "0.364.0",
10
+ "type": "fix",
11
+ "title": "Contract confirmation mail renders the terms' Markdown as formatting",
12
+ "detail": "The terms rows of the contract confirmation mail were plain text, so `**Right to cancel.**` reached the buyer with its asterisks (0.363.0) or lost its emphasis. Each row of `termsParagraphs` is now a richText value: bold, italic and http(s)/mailto/tel links render as HTML, headings become bold rows. Author HTML and images in the terms stay escaped text, and links outside those schemes stay \"label (url)\". Tenant overrides of the template keep working, the variable name and row structure are unchanged."
13
+ },
14
+ {
15
+ "version": "0.364.0",
16
+ "type": "fix",
17
+ "title": "Terms in the contract confirmation mail no longer show raw Markdown",
18
+ "detail": "The terms Markdown is reduced to plain text rows: bold and italic markers are dropped, links become \"label (url)\", list items get their own row, and single line breaks inside a paragraph become spaces."
19
+ },
20
+ {
21
+ "version": "0.364.0",
22
+ "type": "fix",
23
+ "title": "The termination receipt rate limit keys Redis with a keyed, handler-bound digest instead of an unkeyed SHA-256 of the email",
24
+ "detail": "HandlerContext gains an optional rateLimitDigest(value), an HMAC bound to the running handler, so a handler can build rate-limit keys without plaintext identifiers and cannot reproduce the dispatcher's payload-bucket digests. normalizePayloadBucketValue is exported from the pipeline entry point. The receipt limit reuses it, and withholds the receipt mail (the declaration is still recorded) when no digest is wired. Payload-bucket normalization now applies Unicode NFKC first, so fullwidth lookalikes share a bucket. Existing receipt buckets restart empty; they expire after the 24 hour window."
25
+ },
26
+ {
27
+ "version": "0.364.0",
28
+ "type": "fix",
29
+ "title": "Checkout validates the one-off order label and price lookup",
30
+ "detail": "An invalid oneOffItemLabel or a provider without retrievePrices fails with a configuration error before the provider is called."
31
+ },
32
+ {
33
+ "version": "0.364.0",
34
+ "type": "fix",
35
+ "title": "Contract termination downgrades late withdrawals and caps receipt mails",
36
+ "detail": "A withdrawal declared after the 14-day period ends at period end (operator notice withdrawal_period_expired); the declaration is always accepted, receipt mails are capped at 3 per recipient per day and carry only enum fields, request id and time; purchaser roles come from the catalog."
37
+ },
2
38
  {
3
39
  "version": "0.363.0",
4
40
  "type": "breaking",
@@ -1,4 +1,10 @@
1
+ import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
1
2
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
3
+ import { Marked } from "marked";
4
+ import {
5
+ isAllowedDocumentLinkHref,
6
+ isDocumentPlaceholderHref,
7
+ } from "../../template-resolver/index.js";
2
8
  import type { CheckoutConsentRecordedPayload } from "../events.js";
3
9
  import { type ConsentLocale, resolveConsentLocale } from "./consent-text.js";
4
10
 
@@ -138,13 +144,59 @@ function formatDate(iso: string, locale: ConsentLocale, timeZone: string): strin
138
144
  }).format(epochMilliseconds);
139
145
  }
140
146
 
141
- // The mail template only knows plain text variables, so the Markdown terms
142
- // become one row per paragraph; heading markers are dropped.
147
+ // One richText row of inline HTML per Markdown block. The sanitizer shows a rejected
148
+ // row as escaped text, so author HTML, images and refused links stay plain text.
149
+ const ROW_END = "\n\n";
150
+
151
+ function isMailLinkHref(href: string): boolean {
152
+ return isAllowedDocumentLinkHref(href) && !isDocumentPlaceholderHref(href);
153
+ }
154
+
155
+ const inlineHtmlMarkdown = new Marked({ gfm: false, breaks: false });
156
+ inlineHtmlMarkdown.use({
157
+ renderer: {
158
+ heading({ tokens }) {
159
+ const headingHtml = this.parser.parseInline(tokens);
160
+ return `<strong>${headingHtml}</strong>${ROW_END}`;
161
+ },
162
+ paragraph({ tokens }) {
163
+ return `${this.parser.parseInline(tokens)}${ROW_END}`;
164
+ },
165
+ code: ({ text }) => `${escapeHtml(text)}${ROW_END}`,
166
+ blockquote({ tokens }) {
167
+ return this.parser.parse(tokens);
168
+ },
169
+ list({ items }) {
170
+ return items.map((item) => this.parser.parse(item.tokens)).join("");
171
+ },
172
+ hr: () => "",
173
+ space: () => "",
174
+ del({ tokens }) {
175
+ return this.parser.parseInline(tokens);
176
+ },
177
+ br: () => " ",
178
+ html: ({ text }) => escapeHtml(text),
179
+ image: ({ text }) => escapeHtml(text),
180
+ text(token) {
181
+ // Only block-level text (a list item's own text) carries inline child tokens.
182
+ return "tokens" in token && token.tokens
183
+ ? `${this.parser.parseInline(token.tokens)}${ROW_END}`
184
+ : escapeHtml(token.text);
185
+ },
186
+ link({ href, tokens }) {
187
+ const labelHtml = this.parser.parseInline(tokens);
188
+ if (isMailLinkHref(href)) return `<a href="${escapeHtmlAttr(href)}">${labelHtml}</a>`;
189
+ return labelHtml === escapeHtml(href) ? labelHtml : `${labelHtml} (${escapeHtml(href)})`;
190
+ },
191
+ },
192
+ });
193
+
143
194
  function termsParagraphsOf(markdown: string): readonly TextRow[] {
144
- return markdown
195
+ return inlineHtmlMarkdown
196
+ .parse(markdown, { async: false })
145
197
  .split(/\n\s*\n/)
146
- .map((paragraph) => paragraph.replace(/^#{1,6}\s+/gm, "").trim())
147
- .filter((paragraph) => paragraph.length > 0)
198
+ .map((row) => row.replace(/\s+/g, " ").trim())
199
+ .filter((row) => row.length > 0)
148
200
  .map((text) => ({ text }));
149
201
  }
150
202
 
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { createHash } from "node:crypto";
5
5
  import { type HandlerContext, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
6
- import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
6
+ import { InternalError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
7
7
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
8
8
  import * as z from "zod";
9
9
  import {
@@ -15,6 +15,7 @@ import { paymentAggregateId, subscriptionAggregateId } from "../aggregate-id.js"
15
15
  import {
16
16
  CHECKOUT_CONSENT_RECORDED_EVENT_QN,
17
17
  type CheckoutConsentRecordedPayload,
18
+ checkoutConsentRecordedPayloadSchema,
18
19
  PAYMENT_AGGREGATE_TYPE,
19
20
  SUBSCRIPTION_AGGREGATE_TYPE,
20
21
  } from "../events.js";
@@ -50,6 +51,26 @@ export type OrderItem = {
50
51
  readonly params?: Readonly<Record<string, string | number>>;
51
52
  };
52
53
 
54
+ // Bounds come from the event schema the label ends up in, so an app label that
55
+ // passes here cannot fail the append after the provider session exists.
56
+ const orderItemSchema = z.object({
57
+ labelKey: checkoutConsentRecordedPayloadSchema.shape.itemLabelKey.unwrap(),
58
+ params: checkoutConsentRecordedPayloadSchema.shape.itemLabelParams,
59
+ });
60
+
61
+ /** Throws an InternalError (an app configuration fault, not a buyer error) for a
62
+ * label the consent event could not store. */
63
+ export function assertValidOrderItem(item: OrderItem | undefined, priceId: string): void {
64
+ // skip: subscription checkouts have no one-off order item
65
+ if (item === undefined) return;
66
+ const parsed = orderItemSchema.safeParse(item);
67
+ if (!parsed.success) {
68
+ throw new InternalError({
69
+ message: `billing-foundation: consumerProtection.oneOffItemLabel returned an invalid label for priceId "${priceId}": ${parsed.error.message}`,
70
+ });
71
+ }
72
+ }
73
+
53
74
  export type ConsentPriceDetails = {
54
75
  readonly tier: string | null;
55
76
  readonly priceId: string;
@@ -32,7 +32,10 @@ export const OPERATOR_NOTICE_VARIABLES: TemplateVariables = {
32
32
  export const CONTRACT_CONFIRMATION_VARIABLES: TemplateVariables = {
33
33
  contractLines: textRows(),
34
34
  consentGivenAt: templateVariable.text({ required: true }),
35
- termsParagraphs: textRows(),
35
+ termsParagraphs: templateVariable.list(
36
+ { text: templateVariable.richText({ required: true }) },
37
+ { required: true },
38
+ ),
36
39
  operatorEmail: templateVariable.text({ required: true }),
37
40
  };
38
41
 
@@ -52,9 +52,19 @@ export function registerContractTermination(
52
52
  // request: matched and unmatched requests then do the same work and the
53
53
  // response time does not tell whether the email belongs to a customer.
54
54
  // The tenant comes from the stored trigger event (the job's system user).
55
+ // Every declaration appends to the same subscription stream, so parallel runs
56
+ // and a declaration arriving mid-run hit version conflicts. Sequential runs
57
+ // rule out the first; a retry re-reads the stream and the requestId check in
58
+ // record-contract-termination keeps it from recording twice.
55
59
  r.job(
56
60
  "cancel-on-public-termination-declared",
57
- { trigger: { on: CONTRACT_TERMINATION_DECLARED_EVENT_QN }, runIn: "worker" },
61
+ {
62
+ trigger: { on: CONTRACT_TERMINATION_DECLARED_EVENT_QN },
63
+ runIn: "worker",
64
+ concurrency: "sequential",
65
+ retries: 3,
66
+ backoff: "exponential",
67
+ },
58
68
  async (payload, ctx) => {
59
69
  const declared = contractTerminationDeclaredPayloadSchema.safeParse(payload);
60
70
  // skip: trigger event carries no usable declaration
@@ -79,16 +79,22 @@ function declarationLines(
79
79
  return lines.map((text) => ({ text }));
80
80
  }
81
81
 
82
+ /** The receipt carries enum fields only: free text from an anonymous caller
83
+ * must not be echoed to an arbitrary address. */
84
+ export type ReceiptDeclaration = Pick<
85
+ TerminationDeclaration,
86
+ "declarationType" | "terminationKind"
87
+ >;
88
+
82
89
  export type TerminationReceiptArgs = {
83
90
  readonly locale: ConsentLocale;
84
91
  readonly requestId: string;
85
92
  readonly receivedAtIso: string;
86
- readonly declaration: TerminationDeclaration;
93
+ readonly declaration: ReceiptDeclaration;
87
94
  readonly operatorEmail: string;
88
95
  };
89
96
 
90
- /** Built from the declarant's own input only: the content must not depend on
91
- * whether (or how) the declaration matched a contract. */
97
+ /** The content must not depend on whether (or how) the declaration matched a contract. */
92
98
  export function terminationReceiptVariables(
93
99
  args: TerminationReceiptArgs,
94
100
  ): TerminationReceiptVariables {
@@ -0,0 +1,41 @@
1
+ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { normalizePayloadBucketValue } from "@cosmicdrift/kumiko-framework/pipeline";
3
+
4
+ // The declaration itself is a statutory duty and is never refused. Only the
5
+ // receipt mail is capped, so the endpoint cannot be used to mail one inbox
6
+ // from the operator's sender domain.
7
+ export const RECEIPT_MAILS_PER_RECIPIENT = 3;
8
+ export const RECEIPT_WINDOW_SECONDS = 24 * 60 * 60;
9
+
10
+ const RECEIPT_BUCKET_PREFIX = "termination-receipt";
11
+
12
+ function receiptBucketKey(ctx: HandlerContext, email: string): string | undefined {
13
+ const digest = ctx.rateLimitDigest?.(normalizePayloadBucketValue(email, "email"));
14
+ return digest === undefined ? undefined : `${RECEIPT_BUCKET_PREFIX}:${digest}`;
15
+ }
16
+
17
+ /** Consumes one receipt token for the recipient. Fails closed when the limiter
18
+ * errors or no digest key is wired: the declaration is already recorded, a missing receipt is the lesser harm. */
19
+ export async function mayMailReceipt(ctx: HandlerContext, email: string): Promise<boolean> {
20
+ // skip: no rate limiter wired, nothing to count against
21
+ if (!ctx.rateLimit) return true;
22
+ try {
23
+ const bucketKey = receiptBucketKey(ctx, email);
24
+ if (bucketKey === undefined) {
25
+ ctx.log?.warn(
26
+ "[billing-foundation:termination] no rate-limit digest available, receipt mail withheld",
27
+ );
28
+ return false;
29
+ }
30
+ const decision = await ctx.rateLimit.check(bucketKey, {
31
+ limit: RECEIPT_MAILS_PER_RECIPIENT,
32
+ windowSeconds: RECEIPT_WINDOW_SECONDS,
33
+ });
34
+ return decision.allowed;
35
+ } catch (error) {
36
+ ctx.log?.warn(
37
+ `[billing-foundation:termination] receipt rate limiter failed: ${error instanceof Error ? error.message : String(error)}`,
38
+ );
39
+ return false;
40
+ }
41
+ }