@cosmicdrift/kumiko-samples 0.387.0 → 0.388.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 (173) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +8 -1
  3. package/packages/bundled-features/src/agent-tools/changes.json +6 -0
  4. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +9 -9
  5. package/packages/bundled-features/src/audit/changes.json +12 -0
  6. package/packages/bundled-features/src/audit/handlers/audit-events.ts +1 -1
  7. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  8. package/packages/bundled-features/src/auth-mfa/feature-exports.ts +2 -2
  9. package/packages/bundled-features/src/auth-mfa/feature.ts +6 -0
  10. package/packages/bundled-features/src/auth-mfa/web/i18n.ts +6 -1
  11. package/packages/bundled-features/src/auth-mfa/web/mfa-code-or-recovery-field.tsx +68 -0
  12. package/packages/bundled-features/src/auth-mfa/web/mfa-disable-dialog.tsx +23 -13
  13. package/packages/bundled-features/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +23 -13
  14. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +15 -6
  15. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +65 -18
  16. package/packages/bundled-features/src/billing-foundation/changes.json +55 -0
  17. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +32 -42
  18. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +8 -2
  19. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +2 -0
  20. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +14 -3
  21. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +3 -4
  22. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +12 -6
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +35 -19
  24. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +12 -8
  25. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +14 -8
  26. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +5 -0
  27. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +8 -10
  28. package/packages/bundled-features/src/billing-foundation/events.ts +1 -0
  29. package/packages/bundled-features/src/billing-foundation/feature.ts +3 -1
  30. package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +71 -6
  31. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +1 -0
  32. package/packages/bundled-features/src/billing-foundation/handlers/plan-switch-preview.query.ts +65 -0
  33. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +9 -2
  34. package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +44 -8
  35. package/packages/bundled-features/src/billing-foundation/handlers/switch-plan.write.ts +72 -82
  36. package/packages/bundled-features/src/billing-foundation/handlers/sync-subscription.write.ts +14 -1
  37. package/packages/bundled-features/src/billing-foundation/i18n.ts +22 -0
  38. package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
  39. package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +62 -27
  40. package/packages/bundled-features/src/billing-foundation/plan-switch.ts +84 -0
  41. package/packages/bundled-features/src/billing-foundation/types.ts +80 -1
  42. package/packages/bundled-features/src/billing-foundation/validate-options.ts +19 -0
  43. package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +119 -47
  44. package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +89 -42
  45. package/packages/bundled-features/src/billing-foundation/web/constants.ts +4 -0
  46. package/packages/bundled-features/src/billing-foundation/web/plan-price.ts +55 -0
  47. package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +224 -0
  48. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +5 -1
  49. package/packages/bundled-features/src/cap-counter/changes.json +18 -0
  50. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +2 -1
  51. package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +8 -1
  52. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  53. package/packages/bundled-features/src/channel-email/feature.ts +1 -1
  54. package/packages/bundled-features/src/config/changes.json +12 -0
  55. package/packages/bundled-features/src/config/handlers/cascade.query.ts +4 -2
  56. package/packages/bundled-features/src/config/handlers/values.query.ts +4 -2
  57. package/packages/bundled-features/src/config/write-helpers.ts +27 -8
  58. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  59. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +10 -9
  60. package/packages/bundled-features/src/delivery/changes.json +30 -0
  61. package/packages/bundled-features/src/delivery/channel-context.ts +9 -3
  62. package/packages/bundled-features/src/delivery/db/queries/preferences.ts +2 -2
  63. package/packages/bundled-features/src/delivery/delivery-service.ts +35 -5
  64. package/packages/bundled-features/src/delivery/handlers/preferences.query.ts +20 -1
  65. package/packages/bundled-features/src/delivery/handlers/unsubscribe-page-chrome.query.ts +1 -1
  66. package/packages/bundled-features/src/delivery/unsubscribe-link.ts +3 -1
  67. package/packages/bundled-features/src/delivery/unsubscribe-page-strings.ts +1 -0
  68. package/packages/bundled-features/src/delivery/unsubscribe-page.ts +6 -0
  69. package/packages/bundled-features/src/delivery/unsubscribe.ts +36 -15
  70. package/packages/bundled-features/src/file-foundation/index.ts +1 -0
  71. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +7 -1
  72. package/packages/bundled-features/src/file-provider-s3/env-named-feature.ts +0 -2
  73. package/packages/bundled-features/src/files/README.md +1 -1
  74. package/packages/bundled-features/src/form-draft/changes.json +6 -0
  75. package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +2 -2
  76. package/packages/bundled-features/src/form-draft/decrypt-draft.ts +4 -1
  77. package/packages/bundled-features/src/host-routing/README.md +1 -1
  78. package/packages/bundled-features/src/host-routing/changes.json +6 -0
  79. package/packages/bundled-features/src/host-routing/dispatch.ts +16 -17
  80. package/packages/bundled-features/src/host-routing/options.ts +2 -1
  81. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +18 -0
  82. package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +17 -5
  83. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +0 -1
  84. package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +0 -3
  85. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +4 -3
  86. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +32 -16
  87. package/packages/bundled-features/src/jobs/changes.json +13 -0
  88. package/packages/bundled-features/src/jobs/feature.ts +14 -4
  89. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +45 -40
  90. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +63 -59
  91. package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -4
  92. package/packages/bundled-features/src/legal-pages/README.md +1 -1
  93. package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
  94. package/packages/bundled-features/src/mail-foundation/feature.ts +5 -3
  95. package/packages/bundled-features/src/personal-access-tokens/feature.ts +4 -1
  96. package/packages/bundled-features/src/renderer-pdf/changes.json +6 -0
  97. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +27 -10
  98. package/packages/bundled-features/src/sessions/feature.ts +7 -2
  99. package/packages/bundled-features/src/shared/decrypt-stored-pii.ts +10 -0
  100. package/packages/bundled-features/src/shared/index.ts +1 -1
  101. package/packages/bundled-features/src/shared/mail-template-body.ts +10 -3
  102. package/packages/bundled-features/src/step-dispatcher/changes.json +6 -0
  103. package/packages/bundled-features/src/step-dispatcher/feature.ts +1 -0
  104. package/packages/bundled-features/src/subscription-dodo/changes.json +14 -0
  105. package/packages/bundled-features/src/subscription-dodo/client.ts +45 -0
  106. package/packages/bundled-features/src/subscription-dodo/constants.ts +24 -0
  107. package/packages/bundled-features/src/subscription-dodo/feature.ts +207 -0
  108. package/packages/bundled-features/src/subscription-dodo/index.ts +16 -0
  109. package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +345 -0
  110. package/packages/bundled-features/src/subscription-dodo/runtime.ts +126 -0
  111. package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +262 -0
  112. package/packages/bundled-features/src/subscription-stripe/changes.json +12 -0
  113. package/packages/bundled-features/src/subscription-stripe/feature.ts +1 -0
  114. package/packages/bundled-features/src/subscription-stripe/runtime.ts +7 -6
  115. package/packages/bundled-features/src/template-resolver/api.ts +31 -4
  116. package/packages/bundled-features/src/template-resolver/changes.json +48 -0
  117. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +1 -0
  118. package/packages/bundled-features/src/template-resolver/feature.ts +2 -2
  119. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +3 -2
  120. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +27 -5
  121. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +3 -2
  122. package/packages/bundled-features/src/template-resolver/handlers/document-base.ts +3 -2
  123. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +3 -2
  124. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +18 -19
  125. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +15 -5
  126. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-counts.query.ts +9 -5
  127. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-selection.ts +8 -6
  128. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +44 -5
  129. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +37 -26
  130. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +7 -2
  131. package/packages/bundled-features/src/template-resolver/seeding.ts +32 -14
  132. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +22 -4
  133. package/packages/bundled-features/src/template-resolver/template-delivery-policy.ts +0 -11
  134. package/packages/bundled-features/src/template-resolver/template-meta.ts +12 -4
  135. package/packages/bundled-features/src/template-resolver/template-preview.ts +11 -3
  136. package/packages/bundled-features/src/template-resolver/web/constants.ts +7 -5
  137. package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
  138. package/packages/bundled-features/src/template-resolver/web/mail-layout-constants.ts +8 -0
  139. package/packages/bundled-features/src/tenant/changes.json +6 -0
  140. package/packages/bundled-features/src/tenant/handlers/member-directory.query.ts +9 -3
  141. package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +7 -4
  142. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  143. package/packages/bundled-features/src/tenant-lifecycle/constants.ts +5 -0
  144. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +5 -1
  145. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  146. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +4 -1
  147. package/packages/bundled-features/src/tier-engine/web/constants.ts +3 -3
  148. package/packages/bundled-features/src/user-data-rights/run-export-jobs.ts +3 -4
  149. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -1
  150. package/packages/bundled-features/src/waitlist/changes.json +18 -0
  151. package/packages/bundled-features/src/waitlist/handlers/export-purge.write.ts +2 -1
  152. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +5 -5
  153. package/packages/bundled-features/src/waitlist/import.ts +5 -0
  154. package/packages/bundled-features/src/waitlist/legacy-migrate.ts +22 -3
  155. package/packages/bundled-features/src/waitlist/link-on-signup.ts +20 -10
  156. package/packages/bundled-features/src/waitlist/options.ts +3 -0
  157. package/samples/apps/styleguide/src/features/widgets/i18n.ts +11 -0
  158. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +24 -0
  159. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +5 -0
  160. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +90 -15
  161. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +16 -6
  162. package/samples/apps/use-all-bundled/feature-manifest.json +120 -3
  163. package/samples/apps/use-all-bundled/src/run-config.ts +9 -3
  164. package/samples/recipes/auth-mfa/README.md +5 -0
  165. package/samples/recipes/auth-mfa/package.json +5 -2
  166. package/samples/recipes/auth-mfa/src/web/confirmation-code-step.tsx +49 -0
  167. package/samples/recipes/auth-mfa/tsconfig.json +8 -0
  168. package/samples/recipes/env-composition/README.md +3 -1
  169. package/samples/recipes/env-composition/src/env.ts +2 -0
  170. package/samples/recipes/legal-pages/README.md +1 -1
  171. package/samples/recipes/rate-limiting/README.md +18 -0
  172. package/samples/recipes/rate-limiting/src/feature.ts +12 -0
  173. package/packages/bundled-features/src/billing-foundation/consumer-protection/stored-user-email.ts +0 -12
@@ -75,8 +75,9 @@ export async function configRowHoldsValue(
75
75
  serializedValue: string,
76
76
  ): Promise<boolean> {
77
77
  if (row?.value === null || row?.value === undefined) return false;
78
- if (!keyDef?.encrypted && !keyDef?.piiEncrypted) return row.value === serializedValue;
79
- if (!keyDef) return false;
78
+ if (keyDef === undefined || (!keyDef.encrypted && !keyDef.piiEncrypted)) {
79
+ return row.value === serializedValue;
80
+ }
80
81
  try {
81
82
  return (await decryptStoredValue(row.value, keyDef, cipher, qualifiedKey)) === serializedValue;
82
83
  } catch {
@@ -194,12 +195,7 @@ export function validateScope(
194
195
  definedScope: ConfigScope,
195
196
  key: string,
196
197
  ): KumikoError | null {
197
- const levels: Record<ConfigScope, number> = {
198
- [ConfigScopes.system]: 0,
199
- [ConfigScopes.tenant]: 1,
200
- [ConfigScopes.user]: 2,
201
- };
202
- if (levels[requestedScope] > levels[definedScope]) {
198
+ if (SCOPE_LEVEL[requestedScope] > SCOPE_LEVEL[definedScope]) {
203
199
  return new UnprocessableError("invalid_scope", {
204
200
  i18nKey: "config.errors.invalidScope",
205
201
  details: { key, definedScope, requestedScope },
@@ -208,6 +204,29 @@ export function validateScope(
208
204
  return null;
209
205
  }
210
206
 
207
+ const SCOPE_LEVEL: Record<ConfigScope, number> = {
208
+ [ConfigScopes.system]: 0,
209
+ [ConfigScopes.tenant]: 1,
210
+ [ConfigScopes.user]: 2,
211
+ };
212
+
213
+ // Reads a tenant/user key at a narrower scope, e.g. system for a system-level screen.
214
+ // Only a SystemAdmin may widen the read to the platform level; everyone else keeps the normal cascade.
215
+ export function keyDefAtScope(
216
+ keyDef: ConfigKeyDefinition,
217
+ readScope: ConfigScope | undefined,
218
+ roles: readonly string[],
219
+ ): ConfigKeyDefinition {
220
+ if (
221
+ readScope === undefined ||
222
+ !roles.includes("SystemAdmin") ||
223
+ SCOPE_LEVEL[readScope] >= SCOPE_LEVEL[keyDef.scope]
224
+ ) {
225
+ return keyDef;
226
+ }
227
+ return { ...keyDef, scope: readScope };
228
+ }
229
+
211
230
  export function resolveScopeIds(
212
231
  scope: ConfigScope,
213
232
  tenantId: TenantId,
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "forget-subject audit event survives a failing record owner lookup",
6
+ "detail": "The lookup of the record row's owning tenant now runs inside the file purge step, so a database error there is reported as cleanupError on the subject-forgotten event instead of aborting the handler after the key was erased."
7
+ },
2
8
  {
3
9
  "version": "0.377.0",
4
10
  "type": "fix",
@@ -324,11 +324,6 @@ async function sweepDerivedSubjectData(
324
324
  audit: { readonly requestId: string; readonly erasedBy: string },
325
325
  rawSubject: SubjectIdInput,
326
326
  ): Promise<string | undefined> {
327
- const recordTenantId = await owningTenantOfRecordSubject(
328
- crossTenantRunner,
329
- ctx.registry.features,
330
- rawSubject,
331
- );
332
327
  const failureLabels: string[] = [];
333
328
  const runStep = async (step: (runner: DbRunner) => Promise<void>): Promise<void> => {
334
329
  try {
@@ -367,8 +362,14 @@ async function sweepDerivedSubjectData(
367
362
  });
368
363
 
369
364
  // File columns hold only the fileRef id, so the key erase never reaches the bytes.
370
- await runStep((crossTenantSubjectRunner) =>
371
- purgeFilesReachingSubject({
365
+ await runStep(async (crossTenantSubjectRunner) => {
366
+ // Inside the step: a failing owner lookup must land in the audit label, not abort the handler.
367
+ const recordTenantId = await owningTenantOfRecordSubject(
368
+ crossTenantSubjectRunner,
369
+ ctx.registry.features,
370
+ rawSubject,
371
+ );
372
+ await purgeFilesReachingSubject({
372
373
  registry: ctx.registry,
373
374
  db: crossTenantSubjectRunner,
374
375
  subject,
@@ -376,8 +377,8 @@ async function sweepDerivedSubjectData(
376
377
  ...(recordTenantId !== undefined && { recordTenantId }),
377
378
  resolveDeleteBytes: resolveFileBytesDeleter(ctx),
378
379
  missingStorage: { mode: "fail" },
379
- }),
380
- );
380
+ });
381
+ });
381
382
 
382
383
  return failureLabels[0];
383
384
  }
@@ -1,4 +1,34 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "Preferences query reports renamed notification types under the current name",
6
+ "detail": "Rows stored under a former notification name are returned with the current name and collapsed per channel with the delivery precedence (current-name row wins, otherwise any disabled row disables), so settings screens show the effective state."
7
+ },
8
+ {
9
+ "version": "0.388.0",
10
+ "type": "fix",
11
+ "title": "Inline mail image retries on transient storage errors",
12
+ "detail": "Only a missing file storage for the file's tenant sends the mail without the inline image; any other read error (timeout, provider failure, DB) is rethrown so the send job retries instead of mailing without the logo."
13
+ },
14
+ {
15
+ "version": "0.388.0",
16
+ "type": "fix",
17
+ "title": "Unsubscribe tenant origin resolved once per notify call",
18
+ "detail": "The app's tenantOrigin callback runs once per notify call and tenant instead of once per recipient and channel, so a newsletter no longer causes one lookup and one fallback warning per address. The docs state the callback must only return verified, active domains."
19
+ },
20
+ {
21
+ "version": "0.388.0",
22
+ "type": "fix",
23
+ "title": "One-click unsubscribe POST keeps the JSON rejection",
24
+ "detail": "RFC 8058 one-click POSTs (form body List-Unsubscribe=One-Click) answer an invalid or expired token with the JSON 400 again and skip the branding lookup; only browser form submits get the HTML page."
25
+ },
26
+ {
27
+ "version": "0.388.0",
28
+ "type": "fix",
29
+ "title": "Resubscribe limit page has its own text",
30
+ "detail": "A browser that hits the resubscribe limit sees that the address cannot be subscribed again here, instead of the generic \"try again later\" error (new key mail.unsubscribe.resubscribeLimitReached in en, de, es, fr, it)."
31
+ },
2
32
  {
3
33
  "version": "0.383.0",
4
34
  "type": "fix",
@@ -5,7 +5,11 @@ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
5
5
  import type { FileContext } from "@cosmicdrift/kumiko-framework/files";
6
6
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
7
7
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
8
- import { type FileRefContent, readFileRefContent } from "../file-foundation/index.ts";
8
+ import {
9
+ type FileRefContent,
10
+ FileStorageUnavailableError,
11
+ readFileRefContent,
12
+ } from "../file-foundation/index.ts";
9
13
  import {
10
14
  collectRendererPlugins,
11
15
  createRendererFoundationApi,
@@ -86,7 +90,8 @@ function createPdfRenderer(
86
90
  };
87
91
  }
88
92
 
89
- // A decorative inline image (logo) that cannot be read must not fail the whole mail.
93
+ // A decorative inline image (logo) of a tenant without file storage must not fail the whole mail;
94
+ // transient read errors rethrow so the send job retries.
90
95
  async function readInlineImageOrNull(
91
96
  fileRefId: string,
92
97
  read: () => Promise<FileRefContent | null>,
@@ -94,9 +99,10 @@ async function readInlineImageOrNull(
94
99
  try {
95
100
  return await read();
96
101
  } catch (err) {
102
+ if (!(err instanceof FileStorageUnavailableError)) throw err;
97
103
  createFallbackLogger("delivery").warn("inline image could not be read, sending without it", {
98
104
  fileRefId,
99
- errorName: err instanceof Error ? err.name : typeof err,
105
+ errorName: err.name,
100
106
  });
101
107
  return null;
102
108
  }
@@ -1,5 +1,5 @@
1
1
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
- import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/db";
2
+ import { toPostgresArrayLiteral, unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
4
 
5
5
  export type NotificationPreferenceRow = {
@@ -27,6 +27,6 @@ export async function selectNotificationPreferences(
27
27
  OR (notification_type = '*' AND channel = $4)
28
28
  OR (notification_type = ANY($3::text[]) AND channel = '*')
29
29
  )`,
30
- [tenantId, userId, notificationTypeNames, channelName],
30
+ [tenantId, userId, toPostgresArrayLiteral(notificationTypeNames), channelName],
31
31
  );
32
32
  }
@@ -484,9 +484,37 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
484
484
  }
485
485
  }
486
486
 
487
+ // The app's tenantOrigin callback typically hits the DB: once per run and tenant, not per recipient.
488
+ const unsubscribeOriginsByRun = new WeakMap<NotifyRun, Map<TenantId, Promise<string>>>();
489
+
490
+ function unsubscribeOriginOnce(
491
+ run: NotifyRun,
492
+ links: UnsubscribeLinkConfig,
493
+ channelCtx: ChannelContext,
494
+ notificationType: string,
495
+ ): Promise<string> {
496
+ let originsByTenant = unsubscribeOriginsByRun.get(run);
497
+ if (originsByTenant === undefined) {
498
+ originsByTenant = new Map();
499
+ unsubscribeOriginsByRun.set(run, originsByTenant);
500
+ }
501
+ let origin = originsByTenant.get(channelCtx.tenantId);
502
+ if (origin === undefined) {
503
+ origin = unsubscribeLinkOrigin(links, channelCtx, (reason) =>
504
+ logError.warn(`unsubscribe link falls back to the app origin: ${reason}`, {
505
+ tenantId: channelCtx.tenantId,
506
+ notificationType,
507
+ }),
508
+ );
509
+ originsByTenant.set(channelCtx.tenantId, origin);
510
+ }
511
+ return origin;
512
+ }
513
+
487
514
  // A caller-supplied unsubscribeUrl wins; the recipient comes from the server-side notify
488
515
  // context, never from the notification data.
489
516
  async function withUnsubscribeUrl(
517
+ run: NotifyRun,
490
518
  message: ChannelMessage,
491
519
  channelName: string,
492
520
  recipient: UnsubscribeLinkRecipient,
@@ -498,11 +526,11 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
498
526
  }
499
527
  const supplied = message.data?.["unsubscribeUrl"];
500
528
  if (typeof supplied === "string" && supplied !== "") return message;
501
- const origin = await unsubscribeLinkOrigin(unsubscribeLinks, channelCtx, (reason) =>
502
- logError.warn(`unsubscribe link falls back to the app origin: ${reason}`, {
503
- tenantId: channelCtx.tenantId,
504
- notificationType: message.notificationType,
505
- }),
529
+ const origin = await unsubscribeOriginOnce(
530
+ run,
531
+ unsubscribeLinks,
532
+ channelCtx,
533
+ message.notificationType,
506
534
  );
507
535
  const unsubscribeUrl = await buildUnsubscribeUrl(unsubscribeLinks, origin, recipient, {
508
536
  tenantId: channelCtx.tenantId,
@@ -811,6 +839,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
811
839
  channel,
812
840
  address,
813
841
  message: await withUnsubscribeUrl(
842
+ run,
814
843
  message,
815
844
  channel.name,
816
845
  { kind: "user", userId },
@@ -914,6 +943,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
914
943
  channel,
915
944
  address,
916
945
  message: await withUnsubscribeUrl(
946
+ run,
917
947
  message,
918
948
  channel.name,
919
949
  { kind: "address", address },
@@ -2,6 +2,7 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import * as z from "zod";
5
+ import { currentNotificationType } from "../notification-type-aliases.ts";
5
6
  import { notificationPreferencesTable } from "../tables.ts";
6
7
 
7
8
  export const preferencesQuery = defineQueryHandler({
@@ -37,6 +38,24 @@ export const preferencesQuery = defineQueryHandler({
37
38
  userId: query.user.id,
38
39
  });
39
40
 
40
- return { rows };
41
+ // Rows stored under a former notification name are reported under the current
42
+ // one, collapsed with the same precedence as delivery: an exact row wins,
43
+ // otherwise any disabled former-name row disables.
44
+ const collapsed = new Map<string, { row: (typeof rows)[number]; isExact: boolean }>();
45
+ for (const row of rows) {
46
+ const notificationType = currentNotificationType(ctx.registry, row.notificationType);
47
+ const key = `${notificationType}\u0000${row.channel}`;
48
+ const isExact = row.notificationType === notificationType;
49
+ const existing = collapsed.get(key);
50
+ if (existing?.isExact) continue;
51
+ const formerNameRowDisablesAlready =
52
+ existing !== undefined &&
53
+ !isExact &&
54
+ (existing.row.enabled === false || row.enabled !== false);
55
+ if (formerNameRowDisablesAlready) continue;
56
+ collapsed.set(key, { row: { ...row, notificationType }, isExact });
57
+ }
58
+
59
+ return { rows: [...collapsed.values()].map((entry) => entry.row) };
41
60
  },
42
61
  });
@@ -33,7 +33,7 @@ export const unsubscribePageChromeQuery = defineQueryHandler({
33
33
  // or createUnsubscribePageChromeReader for a tenant the app resolved server-side.
34
34
  agent: { expose: false },
35
35
  description:
36
- "Returns the tenant's mail brand row and footer resolved for one locale, or null without a mail renderer; dispatched by the signed unsubscribe route to dress its pages like the mail, not meant for UI callers.",
36
+ "Returns the tenant's mail brand row and footer resolved for one locale, or null without a mail renderer; dispatched by the signed unsubscribe route or by createUnsubscribePageChromeReader for a tenant the app resolved server-side, to dress its pages like the mail; not meant for UI callers.",
37
37
  handler: async (query, ctx): Promise<MailChrome | null> => {
38
38
  const { tenantId } = query.user;
39
39
  if (!ctx.systemDb) {
@@ -10,7 +10,9 @@ export type UnsubscribeLinkConfig = {
10
10
  readonly secret: string;
11
11
  readonly baseUrl: string;
12
12
  /** The sending tenant's own public origin (subdomain or custom domain), null when it has
13
- * none. The host must serve this app's `/api` routes. Null, a throw or an unusable value
13
+ * none. The host must serve this app's `/api` routes, and the callback must only return a
14
+ * verified, active domain of the tenant (the check that lets the app serve under it), else null:
15
+ * the link carries a bearer token that unsubscribes and yields a resubscribe token. Null, a throw or an unusable value
14
16
  * fall back to `baseUrl`, so a missing tenant origin never fails a send. */
15
17
  readonly tenantOrigin?: (ctx: ChannelContext) => Promise<string | null | undefined>;
16
18
  };
@@ -14,6 +14,7 @@ export const UNSUBSCRIBE_PAGE_EN = {
14
14
  "mail.unsubscribe.resubscribedMessage": "You have been subscribed again.",
15
15
  "mail.unsubscribe.failed": "That did not work. Please try again later.",
16
16
  "mail.unsubscribe.failedTitle": "Something went wrong",
17
+ "mail.unsubscribe.resubscribeLimitReached": "This address cannot be subscribed again here.",
17
18
  "mail.unsubscribe.invalidTitle": "Link no longer valid",
18
19
  "mail.unsubscribe.invalidMessage":
19
20
  "This link is invalid or has expired. Please use the unsubscribe link in a more recent email.",
@@ -11,6 +11,7 @@ export type UnsubscribePageState =
11
11
  | { readonly kind: "unsubscribed"; readonly resubscribeToken?: string }
12
12
  | { readonly kind: "resubscribed" }
13
13
  | { readonly kind: "failed" }
14
+ | { readonly kind: "resubscribeLimitReached" }
14
15
  | { readonly kind: "invalid" };
15
16
 
16
17
  export type UnsubscribePageInput = {
@@ -162,6 +163,11 @@ ${tokenForm(DELIVERY_RESUBSCRIBE_PATH, state.resubscribeToken, mailT(lang, "mail
162
163
  title: mailT(lang, "mail.unsubscribe.failedTitle"),
163
164
  bodyHtml: paragraph("mail.unsubscribe.failed"),
164
165
  };
166
+ case "resubscribeLimitReached":
167
+ return {
168
+ title: mailT(lang, "mail.unsubscribe.failedTitle"),
169
+ bodyHtml: paragraph("mail.unsubscribe.resubscribeLimitReached"),
170
+ };
165
171
  case "invalid":
166
172
  return {
167
173
  title: mailT(lang, "mail.unsubscribe.invalidTitle"),
@@ -569,11 +569,20 @@ async function verifyPost(
569
569
  encodedSecret: Uint8Array,
570
570
  purpose: TokenPurpose,
571
571
  ): Promise<VerifiedUnsubscribe | RejectedForPage> {
572
- return isFormRequest(request.headers["content-type"])
572
+ return isFormRequest(request.headers["content-type"]) && !isOneClickPost(request)
573
573
  ? verifyForPage(token, encodedSecret, purpose, request.headers)
574
574
  : verifyUnsubscribeToken(token, encodedSecret, purpose);
575
575
  }
576
576
 
577
+ // RFC 8058 mail-provider POSTs are form-encoded too, but nobody looks at the answer: they keep
578
+ // the JSON rejection and get no branded page.
579
+ function isOneClickPost(request: SignatureExtraRouteVerifyRequest): boolean {
580
+ return (
581
+ isFormRequest(request.headers["content-type"]) &&
582
+ request.rawBody.trim() === DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE
583
+ );
584
+ }
585
+
577
586
  function isFormRequest(contentType: string | undefined): boolean {
578
587
  return contentType?.includes("application/x-www-form-urlencoded") === true;
579
588
  }
@@ -635,22 +644,37 @@ export function createUnsubscribeRoutes(
635
644
  : pageResponse(c, deps, { kind: "confirm", token: verified.token }, verified, 200),
636
645
  });
637
646
 
638
- const writeRoute = signatureRoute<VerifiedUnsubscribe | RejectedForPage>({
647
+ const writeRoute = signatureRoute<
648
+ (VerifiedUnsubscribe & { readonly oneClick: boolean }) | RejectedForPage
649
+ >({
639
650
  method: "POST",
640
651
  path: DELIVERY_UNSUBSCRIBE_PATH,
641
652
  entry: "signature",
642
- verify: async (request) =>
643
- verifyPost(request, tokenFromPostRequest(request), encodedSecret, "unsubscribe"),
653
+ verify: async (request) => {
654
+ const verified = await verifyPost(
655
+ request,
656
+ tokenFromPostRequest(request),
657
+ encodedSecret,
658
+ "unsubscribe",
659
+ );
660
+ return verified.kind === "rejected"
661
+ ? verified
662
+ : { ...verified, oneClick: isOneClickPost(request) };
663
+ },
644
664
  handler: async (c, verified, deps) => {
645
665
  if (verified.kind === "rejected") {
646
666
  return pageResponse(c, deps, { kind: "invalid" }, verified, 400);
647
667
  }
668
+ // No chrome lookup for a one-click client: it never renders the page.
669
+ const pageScope: PageScope = verified.oneClick
670
+ ? { tenantId: undefined, locale: verified.locale }
671
+ : verified;
648
672
  // Token-verify passed — everything below is a legitimate write. Don't
649
673
  // swallow write-errors as "invalid token", that would mask real bugs
650
674
  // (e.g. events-table missing, DB down) behind a misleading 400.
651
675
  const succeeded = await dispatchUnsubscribeWrite(verified, deps);
652
676
  if (!succeeded) {
653
- return pageResponse(c, deps, { kind: "failed" }, verified, 500);
677
+ return pageResponse(c, deps, { kind: "failed" }, pageScope, 500);
654
678
  }
655
679
  return pageResponse(
656
680
  c,
@@ -659,7 +683,7 @@ export function createUnsubscribeRoutes(
659
683
  kind: "unsubscribed",
660
684
  resubscribeToken: await signResubscribeToken(verified, encodedSecret),
661
685
  },
662
- verified,
686
+ pageScope,
663
687
  200,
664
688
  );
665
689
  },
@@ -681,15 +705,12 @@ export function createUnsubscribeRoutes(
681
705
  const outcome = await dispatchResubscribeWrite(verified, deps);
682
706
  // The undo button on the unsubscribed page posts a form; a browser should land on a page.
683
707
  if (isFormRequest(c.req.header("content-type"))) {
684
- return outcome === "success"
685
- ? pageResponse(c, deps, { kind: "resubscribed" }, verified, 200)
686
- : pageResponse(
687
- c,
688
- deps,
689
- { kind: "failed" },
690
- verified,
691
- outcome === "limit_reached" ? 409 : 500,
692
- );
708
+ if (outcome === "success") {
709
+ return pageResponse(c, deps, { kind: "resubscribed" }, verified, 200);
710
+ }
711
+ return outcome === "limit_reached"
712
+ ? pageResponse(c, deps, { kind: "resubscribeLimitReached" }, verified, 409)
713
+ : pageResponse(c, deps, { kind: "failed" }, verified, 500);
693
714
  }
694
715
  if (outcome === "limit_reached") {
695
716
  return c.json(
@@ -10,6 +10,7 @@ export {
10
10
  export {
11
11
  type FileRefContent,
12
12
  FileRefTooLargeError,
13
+ FileStorageUnavailableError,
13
14
  fileContextForTenant,
14
15
  fileContextFromResolver,
15
16
  readFileRefContent,
@@ -53,6 +53,12 @@ export class FileRefTooLargeError extends Error {
53
53
  }
54
54
  }
55
55
 
56
+ export class FileStorageUnavailableError extends Error {
57
+ constructor() {
58
+ super("readFileRefContent: no file storage is available for the file's tenant");
59
+ }
60
+ }
61
+
56
62
  // fileName of the row is never used: it is personal data and may be a PII ciphertext.
57
63
  export async function readFileRefContent(args: {
58
64
  readonly db: DbRunner | TenantDb;
@@ -82,7 +88,7 @@ export async function readFileRefContent(args: {
82
88
  }
83
89
  const files = args.filesForTenant(row.tenantId);
84
90
  if (files === undefined) {
85
- throw new Error("readFileRefContent: no file storage is available for the file's tenant");
91
+ throw new FileStorageUnavailableError();
86
92
  }
87
93
  const content = await files.refFromFileRef(row).read();
88
94
  return { content, contentType: row.mimeType, tenantId: row.tenantId, size };
@@ -13,8 +13,6 @@ export type NamedS3EnvProviderOptions = {
13
13
  readonly name: string;
14
14
  /** Env prefix of the credential set, e.g. `RELEASES_S3_`. */
15
15
  readonly envPrefix: string;
16
- /** Tolerate missing credentials at boot; the provider then fails on its first file operation. */
17
- readonly lazy?: boolean;
18
16
  };
19
17
 
20
18
  /** An additional S3 provider next to `"s3-env"`, reading its credentials from `<envPrefix>BUCKET` etc. */
@@ -15,7 +15,7 @@ bewirtschaftet. Das öffnet die Tür für Cross-Feature-Hooks:
15
15
  ## Was es NICHT macht
16
16
 
17
17
  - **Keine Upload-/Download-Routes** — die bleiben in
18
- `framework/src/api/server.ts` via `options.files`-Bootstrap.
18
+ `framework/src/api/server-routes.ts` (`mountFileRoutes`).
19
19
  Multipart-Form-Body und Binary-Streaming passen nicht ins Write/Query-
20
20
  Handler-Pattern; ein Refactor zu `r.httpRoute` wäre orthogonal zu
21
21
  diesem Sprint.
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "Encrypted drafts without a PII key service raise an error",
6
+ "detail": "Reading a ciphertext draft with no PII subject key service configured throws instead of returning an empty draft, so the stale-draft cleanup defers it and no longer releases its file refs as if it were empty."
7
+ },
2
8
  {
3
9
  "version": "0.379.0",
4
10
  "type": "breaking",
@@ -1,5 +1,5 @@
1
1
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
- import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/db";
2
+ import { toPostgresArrayLiteral, unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
5
5
  import { readStoredDraft } from "../../decrypt-draft.ts";
@@ -48,7 +48,7 @@ export async function selectStaleDraftsBatch(
48
48
  AND NOT ("id"::text = ANY($3::text[]))
49
49
  ORDER BY COALESCE("modified_at", "inserted_at") ASC
50
50
  LIMIT $2`,
51
- [olderThanDays, batchSize, [...excludeIds]],
51
+ [olderThanDays, batchSize, toPostgresArrayLiteral([...excludeIds])],
52
52
  );
53
53
  return Promise.all(
54
54
  rows.map(async (row) => {
@@ -4,6 +4,7 @@ import {
4
4
  decryptPiiFieldValues,
5
5
  isPiiCiphertext,
6
6
  } from "@cosmicdrift/kumiko-framework/crypto";
7
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
7
8
  import { type FormDraftBlob, formDraftBlobSchema } from "./schemas.ts";
8
9
 
9
10
  // Raw reads (fetchOne/selectMany/SQL) return the stored column, which is
@@ -13,7 +14,9 @@ import { type FormDraftBlob, formDraftBlobSchema } from "./schemas.ts";
13
14
  export async function readStoredDraft(stored: unknown): Promise<FormDraftBlob | null> {
14
15
  let plain = stored;
15
16
  const kms = configuredPiiSubjectKms();
16
- if (isPiiCiphertext(stored) && kms) {
17
+ if (isPiiCiphertext(stored)) {
18
+ // A silent null would make the stale-draft cleanup treat the draft as empty and leak its file refs.
19
+ if (!kms) throw new InternalError({ message: "form-draft: pii kms not configured" });
17
20
  const out = await decryptPiiFieldValues({ draft: stored }, ["draft"], kms, {
18
21
  requestId: requestContext.get()?.requestId ?? "form-draft:read",
19
22
  });
@@ -92,5 +92,5 @@ allowlists stay in the app: wrap the dispatch function and fall through to it.
92
92
 
93
93
  - `tenantBy: (host, db) => TenantId | null` resolves every host that is neither the apex nor a fixed host (profile subdomains, custom domains). The tenant must be enabled and active; null means 404 for pages and `tenant_required` for the API. `createHostTenantIdResolver(options, db)` exposes the same mapping for managed-pages' `resolveApexTenant`.
94
94
  - `tenantPathPrefix` is optional in host and resolver mode and serves `<prefix>/<key>` on the apex. The API half still takes the apex anchor there; an app that needs a path-named tenant on API calls resolves it itself.
95
- - `redirects: [{ from, to, host?, status? }]` run before host classification. `to` is configured, never derived from the request.
95
+ - `redirects: [{ from, to, host?, status? }]` run before host classification inside hostDispatch, i.e. only for requests that reach the HTML fallback (no static file, no API route); a `from` that matches a static file or `/api/...` never fires. `to` is configured, never derived from the request.
96
96
  - `apexAnonymousQueryAllowlist: { "example.com": ["feature:query:x"] }` limits the anonymous apex anchor to the listed query types on that host, for `/api/query` and `/api/stream`; other paths keep the anchor. `example.com` and `www.example.com` share one entry (a host without its own uses the other's). Combined with `tenantBy: "path"` it throws at boot, because the client names its tenant there (X-Tenant wins over the resolver) and would bypass the allowlist.
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "Redirects documented as hostDispatch-only",
6
+ "detail": "The redirects option and README state that redirects run inside hostDispatch, so only for requests that reach the HTML fallback; a from matching a static file or an API route never fires. The shared existence lookup of the host and resolver modes is one helper."
7
+ },
2
8
  {
3
9
  "version": "0.376.0",
4
10
  "type": "improvement",
@@ -30,33 +30,32 @@ export type HostRoutingDispatchFn = (
30
30
 
31
31
  const tenantKeyExistsResultSchema = z.object({ exists: z.boolean() });
32
32
 
33
- async function isTenantKeyKnown(systemQuery: PageHeadSystemQuery, key: string): Promise<boolean> {
33
+ async function existsViaSystemQuery(
34
+ systemQuery: PageHeadSystemQuery,
35
+ queryType: string,
36
+ payload: Record<string, string>,
37
+ ): Promise<boolean> {
34
38
  let result: unknown;
35
39
  try {
36
- result = await systemQuery(HostRoutingQueries.tenantKeyExists, { key }, SYSTEM_TENANT_ID);
40
+ result = await systemQuery(queryType, payload, SYSTEM_TENANT_ID);
37
41
  } catch (error) {
38
42
  if (error instanceof ValidationError) return false;
39
- // Over the rate limit every key looks the same: serve the tenant shell,
40
- // which leaks nothing; the API behind it still resolves the tenant itself.
43
+ // Over the rate limit every key looks the same: serve the shell, which
44
+ // leaks nothing. The API resolver is authoritative and answers
45
+ // tenant_required for unknown hosts, while a 404 would hit visitors
46
+ // behind a shared NAT under load.
41
47
  if (error instanceof RateLimitError) return true;
42
48
  throw error;
43
49
  }
44
50
  return tenantKeyExistsResultSchema.parse(result).exists;
45
51
  }
46
52
 
47
- async function isHostKnown(systemQuery: PageHeadSystemQuery, host: string): Promise<boolean> {
48
- let result: unknown;
49
- try {
50
- result = await systemQuery(HostRoutingQueries.hostTenantExists, { host }, SYSTEM_TENANT_ID);
51
- } catch (error) {
52
- if (error instanceof ValidationError) return false;
53
- // The shell is harmless: the API resolver is authoritative and answers
54
- // tenant_required for unknown hosts, while a 404 would hit visitors behind
55
- // a shared NAT under load.
56
- if (error instanceof RateLimitError) return true;
57
- throw error;
58
- }
59
- return tenantKeyExistsResultSchema.parse(result).exists;
53
+ function isTenantKeyKnown(systemQuery: PageHeadSystemQuery, key: string): Promise<boolean> {
54
+ return existsViaSystemQuery(systemQuery, HostRoutingQueries.tenantKeyExists, { key });
55
+ }
56
+
57
+ function isHostKnown(systemQuery: PageHeadSystemQuery, host: string): Promise<boolean> {
58
+ return existsViaSystemQuery(systemQuery, HostRoutingQueries.hostTenantExists, { host });
60
59
  }
61
60
 
62
61
  function serve(
@@ -81,6 +81,7 @@ export type HostRoutingOptions = HostRoutingTenantMode & {
81
81
  // Not allowed with tenantBy "path" (boot error): there the client's X-Tenant
82
82
  // wins over the resolver, so the allowlist would be ineffective.
83
83
  readonly apexAnonymousQueryAllowlist?: Readonly<Record<string, readonly string[]>>;
84
- // Checked first, before any host classification.
84
+ // Checked first within hostDispatch, i.e. only for requests that reach the HTML
85
+ // fallback (no static file, no API route): a `from` matching either never fires.
85
86
  readonly redirects?: readonly HostRoutingRedirect[];
86
87
  };