@cosmicdrift/kumiko-bundled-features 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 (212) hide show
  1. package/dist/agent-tools/tool-dispatch.d.ts +7 -7
  2. package/dist/agent-tools/tool-dispatch.js +9 -9
  3. package/dist/audit/handlers/audit-events.js +1 -1
  4. package/dist/auth-mfa/feature-exports.d.ts +2 -2
  5. package/dist/auth-mfa/feature.js +8 -0
  6. package/dist/auth-mfa/web/i18n.js +5 -1
  7. package/dist/auth-mfa/web/mfa-code-or-recovery-field.d.ts +11 -0
  8. package/dist/auth-mfa/web/mfa-code-or-recovery-field.js +12 -0
  9. package/dist/auth-mfa/web/mfa-disable-dialog.js +14 -3
  10. package/dist/auth-mfa/web/mfa-regenerate-recovery-dialog.js +14 -3
  11. package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +10 -5
  12. package/dist/auth-mfa/web/mfa-verify-screen.js +26 -5
  13. package/dist/billing-foundation/checkout-core.d.ts +9 -14
  14. package/dist/billing-foundation/checkout-core.js +24 -39
  15. package/dist/billing-foundation/consumer-protection/confirmation-mail.js +8 -2
  16. package/dist/billing-foundation/consumer-protection/consent-gate.d.ts +1 -0
  17. package/dist/billing-foundation/consumer-protection/consent-gate.js +1 -0
  18. package/dist/billing-foundation/consumer-protection/issue-confirmation.js +9 -3
  19. package/dist/billing-foundation/consumer-protection/mail-notifications.js +3 -1
  20. package/dist/billing-foundation/consumer-protection/termination-receipt-limit.d.ts +4 -2
  21. package/dist/billing-foundation/consumer-protection/termination-receipt-limit.js +6 -5
  22. package/dist/billing-foundation/consumer-protection/termination-record.d.ts +5 -1
  23. package/dist/billing-foundation/consumer-protection/termination-record.js +27 -17
  24. package/dist/billing-foundation/consumer-protection/termination-request.js +12 -7
  25. package/dist/billing-foundation/consumer-protection/termination-revoke.js +12 -7
  26. package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +1 -1
  27. package/dist/billing-foundation/consumer-protection/termination-texts.js +3 -0
  28. package/dist/billing-foundation/consumer-protection/withdrawal-period.d.ts +5 -2
  29. package/dist/billing-foundation/consumer-protection/withdrawal-period.js +8 -9
  30. package/dist/billing-foundation/events.d.ts +1 -0
  31. package/dist/billing-foundation/events.js +1 -0
  32. package/dist/billing-foundation/feature.js +3 -1
  33. package/dist/billing-foundation/handlers/billing-plans.query.js +35 -5
  34. package/dist/billing-foundation/handlers/create-checkout-session.write.js +1 -0
  35. package/dist/billing-foundation/handlers/plan-switch-preview.query.d.ts +13 -0
  36. package/dist/billing-foundation/handlers/plan-switch-preview.query.js +48 -0
  37. package/dist/billing-foundation/handlers/process-event.write.d.ts +6 -0
  38. package/dist/billing-foundation/handlers/process-event.write.js +8 -2
  39. package/dist/billing-foundation/handlers/start-plan-checkout.write.js +32 -5
  40. package/dist/billing-foundation/handlers/switch-plan.write.d.ts +2 -2
  41. package/dist/billing-foundation/handlers/switch-plan.write.js +56 -64
  42. package/dist/billing-foundation/handlers/sync-subscription.write.js +12 -1
  43. package/dist/billing-foundation/i18n.js +22 -0
  44. package/dist/billing-foundation/index.d.ts +1 -1
  45. package/dist/billing-foundation/plan-catalog.d.ts +21 -5
  46. package/dist/billing-foundation/plan-catalog.js +25 -16
  47. package/dist/billing-foundation/plan-switch.d.ts +12 -0
  48. package/dist/billing-foundation/plan-switch.js +60 -0
  49. package/dist/billing-foundation/types.d.ts +57 -1
  50. package/dist/billing-foundation/types.js +9 -0
  51. package/dist/billing-foundation/validate-options.js +17 -0
  52. package/dist/billing-foundation/web/billing-plans-panel.js +78 -32
  53. package/dist/billing-foundation/web/checkout-consent-dialog.d.ts +17 -3
  54. package/dist/billing-foundation/web/checkout-consent-dialog.js +14 -10
  55. package/dist/billing-foundation/web/constants.d.ts +4 -0
  56. package/dist/billing-foundation/web/constants.js +4 -0
  57. package/dist/billing-foundation/web/plan-price.d.ts +15 -0
  58. package/dist/billing-foundation/web/plan-price.js +32 -0
  59. package/dist/billing-foundation/web/plan-switch-confirm-dialog.d.ts +15 -0
  60. package/dist/billing-foundation/web/plan-switch-confirm-dialog.js +66 -0
  61. package/dist/cap-counter/cap-reservation.js +5 -1
  62. package/dist/cap-counter/enforce-cap.js +2 -1
  63. package/dist/cap-counter/stock-cap-guard.js +7 -1
  64. package/dist/channel-email/feature.js +1 -1
  65. package/dist/config/handlers/cascade.query.d.ts +5 -0
  66. package/dist/config/handlers/cascade.query.js +4 -3
  67. package/dist/config/handlers/values.query.d.ts +5 -0
  68. package/dist/config/handlers/values.query.js +4 -3
  69. package/dist/config/write-helpers.d.ts +1 -0
  70. package/dist/config/write-helpers.js +18 -9
  71. package/dist/crypto-shredding/handlers/forget-subject.write.js +13 -10
  72. package/dist/delivery/channel-context.js +6 -3
  73. package/dist/delivery/db/queries/preferences.js +2 -2
  74. package/dist/delivery/delivery-service.js +22 -7
  75. package/dist/delivery/handlers/preferences.query.d.ts +1 -1
  76. package/dist/delivery/handlers/preferences.query.js +20 -1
  77. package/dist/delivery/handlers/unsubscribe-page-chrome.query.js +1 -1
  78. package/dist/delivery/unsubscribe-link.d.ts +3 -1
  79. package/dist/delivery/unsubscribe-page-strings.d.ts +1 -0
  80. package/dist/delivery/unsubscribe-page-strings.js +1 -0
  81. package/dist/delivery/unsubscribe-page.d.ts +2 -0
  82. package/dist/delivery/unsubscribe-page.js +5 -0
  83. package/dist/delivery/unsubscribe.js +25 -7
  84. package/dist/file-foundation/index.d.ts +1 -1
  85. package/dist/file-foundation/index.js +1 -1
  86. package/dist/file-foundation/read-file-ref-content.d.ts +3 -0
  87. package/dist/file-foundation/read-file-ref-content.js +6 -1
  88. package/dist/file-provider-s3/env-named-feature.d.ts +0 -2
  89. package/dist/form-draft/db/queries/cleanup.js +2 -2
  90. package/dist/form-draft/decrypt-draft.js +5 -1
  91. package/dist/host-routing/dispatch.js +11 -20
  92. package/dist/inbound-mail-foundation/handlers/update-account.write.d.ts +2 -2
  93. package/dist/inbound-mail-foundation/handlers/update-account.write.js +12 -4
  94. package/dist/inbound-mail-foundation/i18n.js +0 -1
  95. package/dist/inbound-mail-foundation/screens.js +0 -3
  96. package/dist/inbound-mail-foundation/types.d.ts +4 -3
  97. package/dist/inbound-mail-foundation/watch-supervisor.js +28 -11
  98. package/dist/jobs/feature.d.ts +1 -0
  99. package/dist/jobs/feature.js +8 -4
  100. package/dist/jobs/handlers/tenant-failures.query.d.ts +1 -1
  101. package/dist/jobs/handlers/tenant-failures.query.js +45 -41
  102. package/dist/jobs/handlers/tenant-runs.query.d.ts +1 -1
  103. package/dist/jobs/handlers/tenant-runs.query.js +54 -50
  104. package/dist/jobs/job-run-logger.js +5 -4
  105. package/dist/mail-foundation/feature.d.ts +3 -3
  106. package/dist/mail-foundation/feature.js +3 -0
  107. package/dist/personal-access-tokens/feature.d.ts +1 -1
  108. package/dist/personal-access-tokens/feature.js +4 -0
  109. package/dist/renderer-pdf/render-table-pdf.d.ts +1 -1
  110. package/dist/renderer-pdf/render-table-pdf.js +22 -10
  111. package/dist/sessions/feature.d.ts +2 -2
  112. package/dist/sessions/feature.js +4 -0
  113. package/dist/shared/decrypt-stored-pii.d.ts +1 -0
  114. package/dist/shared/decrypt-stored-pii.js +6 -1
  115. package/dist/shared/index.d.ts +1 -1
  116. package/dist/shared/index.js +1 -1
  117. package/dist/shared/mail-template-body.d.ts +6 -2
  118. package/dist/shared/mail-template-body.js +7 -4
  119. package/dist/step-dispatcher/feature.js +1 -0
  120. package/dist/subscription-dodo/client.d.ts +17 -0
  121. package/dist/subscription-dodo/client.js +31 -0
  122. package/dist/subscription-dodo/constants.d.ts +12 -0
  123. package/dist/subscription-dodo/constants.js +19 -0
  124. package/dist/subscription-dodo/feature.d.ts +37 -0
  125. package/dist/subscription-dodo/feature.js +144 -0
  126. package/dist/subscription-dodo/index.d.ts +2 -0
  127. package/dist/subscription-dodo/index.js +4 -0
  128. package/dist/subscription-dodo/plugin-methods.d.ts +23 -0
  129. package/dist/subscription-dodo/plugin-methods.js +260 -0
  130. package/dist/subscription-dodo/runtime.d.ts +27 -0
  131. package/dist/subscription-dodo/runtime.js +89 -0
  132. package/dist/subscription-dodo/verify-webhook.d.ts +37 -0
  133. package/dist/subscription-dodo/verify-webhook.js +189 -0
  134. package/dist/subscription-stripe/feature.js +1 -0
  135. package/dist/subscription-stripe/runtime.js +7 -6
  136. package/dist/template-resolver/api.d.ts +3 -2
  137. package/dist/template-resolver/api.js +24 -2
  138. package/dist/template-resolver/compile-document-body.js +1 -0
  139. package/dist/template-resolver/feature.js +2 -2
  140. package/dist/template-resolver/handlers/bundle-import.js +3 -3
  141. package/dist/template-resolver/handlers/copy-locale.write.d.ts +5 -1
  142. package/dist/template-resolver/handlers/copy-locale.write.js +22 -7
  143. package/dist/template-resolver/handlers/diff-to-system.query.js +3 -2
  144. package/dist/template-resolver/handlers/document-base.js +3 -2
  145. package/dist/template-resolver/handlers/find-by-id.query.js +3 -2
  146. package/dist/template-resolver/handlers/list.query.js +5 -6
  147. package/dist/template-resolver/handlers/overview.query.d.ts +3 -0
  148. package/dist/template-resolver/handlers/overview.query.js +6 -3
  149. package/dist/template-resolver/handlers/tenant-override-counts.query.js +1 -3
  150. package/dist/template-resolver/handlers/tenant-override-selection.d.ts +2 -1
  151. package/dist/template-resolver/handlers/tenant-overrides.query.js +28 -5
  152. package/dist/template-resolver/handlers/usage.query.d.ts +2 -1
  153. package/dist/template-resolver/handlers/usage.query.js +26 -16
  154. package/dist/template-resolver/handlers/validate.query.js +7 -2
  155. package/dist/template-resolver/seeding.js +26 -10
  156. package/dist/template-resolver/store-template-resource.d.ts +1 -0
  157. package/dist/template-resolver/store-template-resource.js +19 -4
  158. package/dist/template-resolver/template-delivery-policy.d.ts +0 -5
  159. package/dist/template-resolver/template-delivery-policy.js +0 -6
  160. package/dist/template-resolver/template-meta.js +7 -4
  161. package/dist/template-resolver/template-preview.js +4 -3
  162. package/dist/template-resolver/web/constants.d.ts +2 -2
  163. package/dist/template-resolver/web/constants.js +3 -3
  164. package/dist/template-resolver/web/index.d.ts +1 -0
  165. package/dist/template-resolver/web/index.js +1 -0
  166. package/dist/template-resolver/web/mail-layout-constants.d.ts +5 -0
  167. package/dist/template-resolver/web/mail-layout-constants.js +7 -0
  168. package/dist/tenant/handlers/member-directory.query.d.ts +1 -0
  169. package/dist/tenant/handlers/member-directory.query.js +10 -3
  170. package/dist/tenant/handlers/tenant-directory.query.d.ts +1 -0
  171. package/dist/tenant/handlers/tenant-directory.query.js +8 -4
  172. package/dist/tenant-lifecycle/constants.d.ts +1 -0
  173. package/dist/tenant-lifecycle/constants.js +4 -0
  174. package/dist/tenant-lifecycle/run-tenant-destroy.js +2 -2
  175. package/dist/tier-engine/handlers/set-tenant-tier.write.d.ts +2 -3
  176. package/dist/tier-engine/handlers/set-tenant-tier.write.js +4 -1
  177. package/dist/tier-engine/web/constants.js +2 -2
  178. package/dist/user-data-rights/run-export-jobs.js +3 -4
  179. package/dist/user-data-rights/run-forget-cleanup.js +2 -1
  180. package/dist/waitlist/handlers/export-purge.write.js +2 -1
  181. package/dist/waitlist/handlers/submit.write.js +5 -5
  182. package/dist/waitlist/import.d.ts +1 -0
  183. package/dist/waitlist/import.js +4 -0
  184. package/dist/waitlist/legacy-migrate.js +17 -6
  185. package/dist/waitlist/link-on-signup.js +18 -6
  186. package/dist/waitlist/options.d.ts +3 -0
  187. package/package.json +19 -9
  188. package/src/agent-tools/changes.json +6 -0
  189. package/src/audit/changes.json +12 -0
  190. package/src/auth-mfa/changes.json +6 -0
  191. package/src/billing-foundation/changes.json +55 -0
  192. package/src/cap-counter/changes.json +18 -0
  193. package/src/channel-email/changes.json +6 -0
  194. package/src/config/changes.json +12 -0
  195. package/src/crypto-shredding/changes.json +6 -0
  196. package/src/delivery/changes.json +30 -0
  197. package/src/form-draft/changes.json +6 -0
  198. package/src/host-routing/changes.json +6 -0
  199. package/src/inbound-mail-foundation/changes.json +18 -0
  200. package/src/jobs/changes.json +13 -0
  201. package/src/mail-foundation/changes.json +6 -0
  202. package/src/renderer-pdf/changes.json +6 -0
  203. package/src/step-dispatcher/changes.json +6 -0
  204. package/src/subscription-dodo/changes.json +14 -0
  205. package/src/subscription-stripe/changes.json +12 -0
  206. package/src/template-resolver/changes.json +48 -0
  207. package/src/tenant/changes.json +6 -0
  208. package/src/tenant-lifecycle/changes.json +6 -0
  209. package/src/tier-engine/changes.json +6 -0
  210. package/src/waitlist/changes.json +18 -0
  211. package/dist/billing-foundation/consumer-protection/stored-user-email.d.ts +0 -1
  212. package/dist/billing-foundation/consumer-protection/stored-user-email.js +0 -8
@@ -33,10 +33,9 @@ export async function findConfigRow(db, key, tenantId, userId) {
33
33
  export async function configRowHoldsValue(row, keyDef, cipher, qualifiedKey, serializedValue) {
34
34
  if (row?.value === null || row?.value === undefined)
35
35
  return false;
36
- if (!keyDef?.encrypted && !keyDef?.piiEncrypted)
36
+ if (keyDef === undefined || (!keyDef.encrypted && !keyDef.piiEncrypted)) {
37
37
  return row.value === serializedValue;
38
- if (!keyDef)
39
- return false;
38
+ }
40
39
  try {
41
40
  return (await decryptStoredValue(row.value, keyDef, cipher, qualifiedKey)) === serializedValue;
42
41
  }
@@ -112,12 +111,7 @@ export function checkScopeWriteAccess(scope, userRoles) {
112
111
  });
113
112
  }
114
113
  export function validateScope(requestedScope, definedScope, key) {
115
- const levels = {
116
- [ConfigScopes.system]: 0,
117
- [ConfigScopes.tenant]: 1,
118
- [ConfigScopes.user]: 2,
119
- };
120
- if (levels[requestedScope] > levels[definedScope]) {
114
+ if (SCOPE_LEVEL[requestedScope] > SCOPE_LEVEL[definedScope]) {
121
115
  return new UnprocessableError("invalid_scope", {
122
116
  i18nKey: "config.errors.invalidScope",
123
117
  details: { key, definedScope, requestedScope },
@@ -125,6 +119,21 @@ export function validateScope(requestedScope, definedScope, key) {
125
119
  }
126
120
  return null;
127
121
  }
122
+ const SCOPE_LEVEL = {
123
+ [ConfigScopes.system]: 0,
124
+ [ConfigScopes.tenant]: 1,
125
+ [ConfigScopes.user]: 2,
126
+ };
127
+ // Reads a tenant/user key at a narrower scope, e.g. system for a system-level screen.
128
+ // Only a SystemAdmin may widen the read to the platform level; everyone else keeps the normal cascade.
129
+ export function keyDefAtScope(keyDef, readScope, roles) {
130
+ if (readScope === undefined ||
131
+ !roles.includes("SystemAdmin") ||
132
+ SCOPE_LEVEL[readScope] >= SCOPE_LEVEL[keyDef.scope]) {
133
+ return keyDef;
134
+ }
135
+ return { ...keyDef, scope: readScope };
136
+ }
128
137
  export function resolveScopeIds(scope, tenantId, userId) {
129
138
  switch (scope) {
130
139
  case ConfigScopes.system:
@@ -224,7 +224,6 @@ function sweepErrorLabel(err) {
224
224
  // Each step runs in its own savepoint: a database error must not abort the handler
225
225
  // transaction, and one failing step must not skip or roll back the others.
226
226
  async function sweepDerivedSubjectData(ctx, subject, subjectKey, crossTenantRunner, audit, rawSubject) {
227
- const recordTenantId = await owningTenantOfRecordSubject(crossTenantRunner, ctx.registry.features, rawSubject);
228
227
  const failureLabels = [];
229
228
  const runStep = async (step) => {
230
229
  try {
@@ -254,15 +253,19 @@ async function sweepDerivedSubjectData(ctx, subject, subjectKey, crossTenantRunn
254
253
  }
255
254
  });
256
255
  // File columns hold only the fileRef id, so the key erase never reaches the bytes.
257
- await runStep((crossTenantSubjectRunner) => purgeFilesReachingSubject({
258
- registry: ctx.registry,
259
- db: crossTenantSubjectRunner,
260
- subject,
261
- subjectKey,
262
- ...(recordTenantId !== undefined && { recordTenantId }),
263
- resolveDeleteBytes: resolveFileBytesDeleter(ctx),
264
- missingStorage: { mode: "fail" },
265
- }));
256
+ await runStep(async (crossTenantSubjectRunner) => {
257
+ // Inside the step: a failing owner lookup must land in the audit label, not abort the handler.
258
+ const recordTenantId = await owningTenantOfRecordSubject(crossTenantSubjectRunner, ctx.registry.features, rawSubject);
259
+ await purgeFilesReachingSubject({
260
+ registry: ctx.registry,
261
+ db: crossTenantSubjectRunner,
262
+ subject,
263
+ subjectKey,
264
+ ...(recordTenantId !== undefined && { recordTenantId }),
265
+ resolveDeleteBytes: resolveFileBytesDeleter(ctx),
266
+ missingStorage: { mode: "fail" },
267
+ });
268
+ });
266
269
  return failureLabels[0];
267
270
  }
268
271
  // Email subscribers and other non-user entities use user-style subject keys
@@ -1,7 +1,7 @@
1
1
  import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
4
- import { readFileRefContent } from "../file-foundation/index.js";
4
+ import { FileStorageUnavailableError, readFileRefContent, } from "../file-foundation/index.js";
5
5
  import { collectRendererPlugins, createRendererFoundationApi, RendererError, } from "../renderer-foundation/index.js";
6
6
  import { FALLBACK_LOCALE } from "../template-resolver/index.js";
7
7
  export function createTemplateMailRenderer(db, registry, tenantId, config) {
@@ -44,15 +44,18 @@ function createPdfRenderer(db, registry, tenantId, config, filesForTenant) {
44
44
  return { content: response.pdfBytes, contentType: PDF_CONTENT_TYPE };
45
45
  };
46
46
  }
47
- // A decorative inline image (logo) that cannot be read must not fail the whole mail.
47
+ // A decorative inline image (logo) of a tenant without file storage must not fail the whole mail;
48
+ // transient read errors rethrow so the send job retries.
48
49
  async function readInlineImageOrNull(fileRefId, read) {
49
50
  try {
50
51
  return await read();
51
52
  }
52
53
  catch (err) {
54
+ if (!(err instanceof FileStorageUnavailableError))
55
+ throw err;
53
56
  createFallbackLogger("delivery").warn("inline image could not be read, sending without it", {
54
57
  fileRefId,
55
- errorName: err instanceof Error ? err.name : typeof err,
58
+ errorName: err.name,
56
59
  });
57
60
  return null;
58
61
  }
@@ -1,4 +1,4 @@
1
- import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/db";
1
+ import { toPostgresArrayLiteral, unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/db";
2
2
  // @wrapper-known semantic-alias
3
3
  export async function selectNotificationPreferences(db, tenantId, userId, notificationTypeNames, channelName) {
4
4
  return unsafeReadRetrying(db, `SELECT notification_type AS "notificationType", channel, enabled
@@ -9,5 +9,5 @@ export async function selectNotificationPreferences(db, tenantId, userId, notifi
9
9
  (notification_type = ANY($3::text[]) AND channel = $4)
10
10
  OR (notification_type = '*' AND channel = $4)
11
11
  OR (notification_type = ANY($3::text[]) AND channel = '*')
12
- )`, [tenantId, userId, notificationTypeNames, channelName]);
12
+ )`, [tenantId, userId, toPostgresArrayLiteral(notificationTypeNames), channelName]);
13
13
  }
@@ -266,9 +266,27 @@ export function createDeliveryService(options) {
266
266
  });
267
267
  }
268
268
  }
269
+ // The app's tenantOrigin callback typically hits the DB: once per run and tenant, not per recipient.
270
+ const unsubscribeOriginsByRun = new WeakMap();
271
+ function unsubscribeOriginOnce(run, links, channelCtx, notificationType) {
272
+ let originsByTenant = unsubscribeOriginsByRun.get(run);
273
+ if (originsByTenant === undefined) {
274
+ originsByTenant = new Map();
275
+ unsubscribeOriginsByRun.set(run, originsByTenant);
276
+ }
277
+ let origin = originsByTenant.get(channelCtx.tenantId);
278
+ if (origin === undefined) {
279
+ origin = unsubscribeLinkOrigin(links, channelCtx, (reason) => logError.warn(`unsubscribe link falls back to the app origin: ${reason}`, {
280
+ tenantId: channelCtx.tenantId,
281
+ notificationType,
282
+ }));
283
+ originsByTenant.set(channelCtx.tenantId, origin);
284
+ }
285
+ return origin;
286
+ }
269
287
  // A caller-supplied unsubscribeUrl wins; the recipient comes from the server-side notify
270
288
  // context, never from the notification data.
271
- async function withUnsubscribeUrl(message, channelName, recipient, channelCtx) {
289
+ async function withUnsubscribeUrl(run, message, channelName, recipient, channelCtx) {
272
290
  if (!unsubscribeLinks || channelName !== EMAIL_CHANNEL_NAME)
273
291
  return message;
274
292
  if (registry.getAllNotifications().get(message.notificationType)?.unsubscribe !== true) {
@@ -277,10 +295,7 @@ export function createDeliveryService(options) {
277
295
  const supplied = message.data?.["unsubscribeUrl"];
278
296
  if (typeof supplied === "string" && supplied !== "")
279
297
  return message;
280
- const origin = await unsubscribeLinkOrigin(unsubscribeLinks, channelCtx, (reason) => logError.warn(`unsubscribe link falls back to the app origin: ${reason}`, {
281
- tenantId: channelCtx.tenantId,
282
- notificationType: message.notificationType,
283
- }));
298
+ const origin = await unsubscribeOriginOnce(run, unsubscribeLinks, channelCtx, message.notificationType);
284
299
  const unsubscribeUrl = await buildUnsubscribeUrl(unsubscribeLinks, origin, recipient, {
285
300
  tenantId: channelCtx.tenantId,
286
301
  notificationType: message.notificationType,
@@ -476,7 +491,7 @@ export function createDeliveryService(options) {
476
491
  run,
477
492
  channel,
478
493
  address,
479
- message: await withUnsubscribeUrl(message, channel.name, { kind: "user", userId }, channelCtx),
494
+ message: await withUnsubscribeUrl(run, message, channel.name, { kind: "user", userId }, channelCtx),
480
495
  channelCtx,
481
496
  tenantId,
482
497
  recipientId: userId,
@@ -549,7 +564,7 @@ export function createDeliveryService(options) {
549
564
  run,
550
565
  channel,
551
566
  address,
552
- message: await withUnsubscribeUrl(message, channel.name, { kind: "address", address }, channelCtx),
567
+ message: await withUnsubscribeUrl(run, message, channel.name, { kind: "address", address }, channelCtx),
553
568
  channelCtx,
554
569
  tenantId,
555
570
  recipientId,
@@ -1,4 +1,4 @@
1
1
  import * as z from "zod";
2
2
  export declare const preferencesQuery: import("@cosmicdrift/kumiko-types/define-handler").QueryHandlerDefinition<"preferences", z.ZodObject<{}, z.core.$strip>, {
3
- rows: readonly any[];
3
+ rows: any[];
4
4
  }, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
@@ -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.js";
5
6
  import { notificationPreferencesTable } from "../tables.js";
6
7
  export const preferencesQuery = defineQueryHandler({
7
8
  name: "preferences",
@@ -31,6 +32,24 @@ export const preferencesQuery = defineQueryHandler({
31
32
  tenantId: query.user.tenantId,
32
33
  userId: query.user.id,
33
34
  });
34
- return { rows };
35
+ // Rows stored under a former notification name are reported under the current
36
+ // one, collapsed with the same precedence as delivery: an exact row wins,
37
+ // otherwise any disabled former-name row disables.
38
+ const collapsed = new Map();
39
+ for (const row of rows) {
40
+ const notificationType = currentNotificationType(ctx.registry, row.notificationType);
41
+ const key = `${notificationType}\u0000${row.channel}`;
42
+ const isExact = row.notificationType === notificationType;
43
+ const existing = collapsed.get(key);
44
+ if (existing?.isExact)
45
+ continue;
46
+ const formerNameRowDisablesAlready = existing !== undefined &&
47
+ !isExact &&
48
+ (existing.row.enabled === false || row.enabled !== false);
49
+ if (formerNameRowDisablesAlready)
50
+ continue;
51
+ collapsed.set(key, { row: { ...row, notificationType }, isExact });
52
+ }
53
+ return { rows: [...collapsed.values()].map((entry) => entry.row) };
35
54
  },
36
55
  });
@@ -23,7 +23,7 @@ export const unsubscribePageChromeQuery = defineQueryHandler({
23
23
  // Only reachable via a system dispatch: the signed unsubscribe routes after verify(),
24
24
  // or createUnsubscribePageChromeReader for a tenant the app resolved server-side.
25
25
  agent: { expose: false },
26
- description: "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.",
26
+ description: "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.",
27
27
  handler: async (query, ctx) => {
28
28
  const { tenantId } = query.user;
29
29
  if (!ctx.systemDb) {
@@ -5,7 +5,9 @@ export type UnsubscribeLinkConfig = {
5
5
  readonly secret: string;
6
6
  readonly baseUrl: string;
7
7
  /** The sending tenant's own public origin (subdomain or custom domain), null when it has
8
- * none. The host must serve this app's `/api` routes. Null, a throw or an unusable value
8
+ * none. The host must serve this app's `/api` routes, and the callback must only return a
9
+ * verified, active domain of the tenant (the check that lets the app serve under it), else null:
10
+ * the link carries a bearer token that unsubscribes and yields a resubscribe token. Null, a throw or an unusable value
9
11
  * fall back to `baseUrl`, so a missing tenant origin never fails a send. */
10
12
  readonly tenantOrigin?: (ctx: ChannelContext) => Promise<string | null | undefined>;
11
13
  };
@@ -10,6 +10,7 @@ export declare const UNSUBSCRIBE_PAGE_EN: {
10
10
  readonly "mail.unsubscribe.resubscribedMessage": "You have been subscribed again.";
11
11
  readonly "mail.unsubscribe.failed": "That did not work. Please try again later.";
12
12
  readonly "mail.unsubscribe.failedTitle": "Something went wrong";
13
+ readonly "mail.unsubscribe.resubscribeLimitReached": "This address cannot be subscribed again here.";
13
14
  readonly "mail.unsubscribe.invalidTitle": "Link no longer valid";
14
15
  readonly "mail.unsubscribe.invalidMessage": "This link is invalid or has expired. Please use the unsubscribe link in a more recent email.";
15
16
  };
@@ -13,6 +13,7 @@ export const UNSUBSCRIBE_PAGE_EN = {
13
13
  "mail.unsubscribe.resubscribedMessage": "You have been subscribed again.",
14
14
  "mail.unsubscribe.failed": "That did not work. Please try again later.",
15
15
  "mail.unsubscribe.failedTitle": "Something went wrong",
16
+ "mail.unsubscribe.resubscribeLimitReached": "This address cannot be subscribed again here.",
16
17
  "mail.unsubscribe.invalidTitle": "Link no longer valid",
17
18
  "mail.unsubscribe.invalidMessage": "This link is invalid or has expired. Please use the unsubscribe link in a more recent email.",
18
19
  };
@@ -10,6 +10,8 @@ export type UnsubscribePageState = {
10
10
  readonly kind: "resubscribed";
11
11
  } | {
12
12
  readonly kind: "failed";
13
+ } | {
14
+ readonly kind: "resubscribeLimitReached";
13
15
  } | {
14
16
  readonly kind: "invalid";
15
17
  };
@@ -135,6 +135,11 @@ ${tokenForm(DELIVERY_RESUBSCRIBE_PATH, state.resubscribeToken, mailT(lang, "mail
135
135
  title: mailT(lang, "mail.unsubscribe.failedTitle"),
136
136
  bodyHtml: paragraph("mail.unsubscribe.failed"),
137
137
  };
138
+ case "resubscribeLimitReached":
139
+ return {
140
+ title: mailT(lang, "mail.unsubscribe.failedTitle"),
141
+ bodyHtml: paragraph("mail.unsubscribe.resubscribeLimitReached"),
142
+ };
138
143
  case "invalid":
139
144
  return {
140
145
  title: mailT(lang, "mail.unsubscribe.invalidTitle"),
@@ -373,10 +373,16 @@ async function verifyForPage(token, encodedSecret, purpose, headers) {
373
373
  }
374
374
  }
375
375
  async function verifyPost(request, token, encodedSecret, purpose) {
376
- return isFormRequest(request.headers["content-type"])
376
+ return isFormRequest(request.headers["content-type"]) && !isOneClickPost(request)
377
377
  ? verifyForPage(token, encodedSecret, purpose, request.headers)
378
378
  : verifyUnsubscribeToken(token, encodedSecret, purpose);
379
379
  }
380
+ // RFC 8058 mail-provider POSTs are form-encoded too, but nobody looks at the answer: they keep
381
+ // the JSON rejection and get no branded page.
382
+ function isOneClickPost(request) {
383
+ return (isFormRequest(request.headers["content-type"]) &&
384
+ request.rawBody.trim() === DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE);
385
+ }
380
386
  function isFormRequest(contentType) {
381
387
  return contentType?.includes("application/x-www-form-urlencoded") === true;
382
388
  }
@@ -435,22 +441,31 @@ export function createUnsubscribeRoutes(options) {
435
441
  method: "POST",
436
442
  path: DELIVERY_UNSUBSCRIBE_PATH,
437
443
  entry: "signature",
438
- verify: async (request) => verifyPost(request, tokenFromPostRequest(request), encodedSecret, "unsubscribe"),
444
+ verify: async (request) => {
445
+ const verified = await verifyPost(request, tokenFromPostRequest(request), encodedSecret, "unsubscribe");
446
+ return verified.kind === "rejected"
447
+ ? verified
448
+ : { ...verified, oneClick: isOneClickPost(request) };
449
+ },
439
450
  handler: async (c, verified, deps) => {
440
451
  if (verified.kind === "rejected") {
441
452
  return pageResponse(c, deps, { kind: "invalid" }, verified, 400);
442
453
  }
454
+ // No chrome lookup for a one-click client: it never renders the page.
455
+ const pageScope = verified.oneClick
456
+ ? { tenantId: undefined, locale: verified.locale }
457
+ : verified;
443
458
  // Token-verify passed — everything below is a legitimate write. Don't
444
459
  // swallow write-errors as "invalid token", that would mask real bugs
445
460
  // (e.g. events-table missing, DB down) behind a misleading 400.
446
461
  const succeeded = await dispatchUnsubscribeWrite(verified, deps);
447
462
  if (!succeeded) {
448
- return pageResponse(c, deps, { kind: "failed" }, verified, 500);
463
+ return pageResponse(c, deps, { kind: "failed" }, pageScope, 500);
449
464
  }
450
465
  return pageResponse(c, deps, {
451
466
  kind: "unsubscribed",
452
467
  resubscribeToken: await signResubscribeToken(verified, encodedSecret),
453
- }, verified, 200);
468
+ }, pageScope, 200);
454
469
  },
455
470
  });
456
471
  // Undo direction: token only from the JSON/form body, never the query
@@ -468,9 +483,12 @@ export function createUnsubscribeRoutes(options) {
468
483
  const outcome = await dispatchResubscribeWrite(verified, deps);
469
484
  // The undo button on the unsubscribed page posts a form; a browser should land on a page.
470
485
  if (isFormRequest(c.req.header("content-type"))) {
471
- return outcome === "success"
472
- ? pageResponse(c, deps, { kind: "resubscribed" }, verified, 200)
473
- : pageResponse(c, deps, { kind: "failed" }, verified, outcome === "limit_reached" ? 409 : 500);
486
+ if (outcome === "success") {
487
+ return pageResponse(c, deps, { kind: "resubscribed" }, verified, 200);
488
+ }
489
+ return outcome === "limit_reached"
490
+ ? pageResponse(c, deps, { kind: "resubscribeLimitReached" }, verified, 409)
491
+ : pageResponse(c, deps, { kind: "failed" }, verified, 500);
474
492
  }
475
493
  if (outcome === "limit_reached") {
476
494
  return c.json({ error: { code: DeliveryErrors.resubscribeLimitReached } }, 409, NO_STORE_HEADERS);
@@ -1,2 +1,2 @@
1
1
  export { createFileFoundationFeature, createFileProviderForTenant, type FileProviderContext, type FileProviderPlugin, isFileProviderPlugin, } from "./feature.ts";
2
- export { type FileRefContent, FileRefTooLargeError, fileContextForTenant, fileContextFromResolver, readFileRefContent, } from "./read-file-ref-content.ts";
2
+ export { type FileRefContent, FileRefTooLargeError, FileStorageUnavailableError, fileContextForTenant, fileContextFromResolver, readFileRefContent, } from "./read-file-ref-content.ts";
@@ -1,3 +1,3 @@
1
1
  // Public API of the file-foundation bundled-feature.
2
2
  export { createFileFoundationFeature, createFileProviderForTenant, isFileProviderPlugin, } from "./feature.js";
3
- export { FileRefTooLargeError, fileContextForTenant, fileContextFromResolver, readFileRefContent, } from "./read-file-ref-content.js";
3
+ export { FileRefTooLargeError, FileStorageUnavailableError, fileContextForTenant, fileContextFromResolver, readFileRefContent, } from "./read-file-ref-content.js";
@@ -15,6 +15,9 @@ export declare class FileRefTooLargeError extends Error {
15
15
  readonly size: number;
16
16
  constructor(size: number);
17
17
  }
18
+ export declare class FileStorageUnavailableError extends Error {
19
+ constructor();
20
+ }
18
21
  export declare function readFileRefContent(args: {
19
22
  readonly db: DbRunner | TenantDb;
20
23
  readonly filesForTenant: (tenantId: string) => FileContext | undefined;
@@ -22,6 +22,11 @@ export class FileRefTooLargeError extends Error {
22
22
  this.size = size;
23
23
  }
24
24
  }
25
+ export class FileStorageUnavailableError extends Error {
26
+ constructor() {
27
+ super("readFileRefContent: no file storage is available for the file's tenant");
28
+ }
29
+ }
25
30
  // fileName of the row is never used: it is personal data and may be a PII ciphertext.
26
31
  export async function readFileRefContent(args) {
27
32
  if (!isUuid(args.fileRefId))
@@ -44,7 +49,7 @@ export async function readFileRefContent(args) {
44
49
  }
45
50
  const files = args.filesForTenant(row.tenantId);
46
51
  if (files === undefined) {
47
- throw new Error("readFileRefContent: no file storage is available for the file's tenant");
52
+ throw new FileStorageUnavailableError();
48
53
  }
49
54
  const content = await files.refFromFileRef(row).read();
50
55
  return { content, contentType: row.mimeType, tenantId: row.tenantId, size };
@@ -3,8 +3,6 @@ export type NamedS3EnvProviderOptions = {
3
3
  readonly name: string;
4
4
  /** Env prefix of the credential set, e.g. `RELEASES_S3_`. */
5
5
  readonly envPrefix: string;
6
- /** Tolerate missing credentials at boot; the provider then fails on its first file operation. */
7
- readonly lazy?: boolean;
8
6
  };
9
7
  /** An additional S3 provider next to `"s3-env"`, reading its credentials from `<envPrefix>BUCKET` etc. */
10
8
  export declare function createFileProviderS3NamedEnvFeature(options: NamedS3EnvProviderOptions): import("@cosmicdrift/kumiko-framework/env").FeatureWithEnvSchema<import("@cosmicdrift/kumiko-types/feature").FeatureDefinition & {
@@ -1,4 +1,4 @@
1
- import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/db";
1
+ import { toPostgresArrayLiteral, unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
3
3
  import { readStoredDraft } from "../../decrypt-draft.js";
4
4
  // COALESCE(modified_at, inserted_at) — a draft's "last touched" timestamp is
@@ -17,7 +17,7 @@ export async function selectStaleDraftsBatch(db, olderThanDays, batchSize, exclu
17
17
  WHERE COALESCE("modified_at", "inserted_at") < now() - ($1::int * interval '1 day')
18
18
  AND NOT ("id"::text = ANY($3::text[]))
19
19
  ORDER BY COALESCE("modified_at", "inserted_at") ASC
20
- LIMIT $2`, [olderThanDays, batchSize, [...excludeIds]]);
20
+ LIMIT $2`, [olderThanDays, batchSize, toPostgresArrayLiteral([...excludeIds])]);
21
21
  return Promise.all(rows.map(async (row) => {
22
22
  const base = {
23
23
  id: row.id,
@@ -1,5 +1,6 @@
1
1
  import { requestContext } from "@cosmicdrift/kumiko-framework/api";
2
2
  import { configuredPiiSubjectKms, decryptPiiFieldValues, isPiiCiphertext, } from "@cosmicdrift/kumiko-framework/crypto";
3
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
3
4
  import { formDraftBlobSchema } from "./schemas.js";
4
5
  // Raw reads (fetchOne/selectMany/SQL) return the stored column, which is
5
6
  // ciphertext once the owner's key exists. The field name is part of the AAD,
@@ -8,7 +9,10 @@ import { formDraftBlobSchema } from "./schemas.js";
8
9
  export async function readStoredDraft(stored) {
9
10
  let plain = stored;
10
11
  const kms = configuredPiiSubjectKms();
11
- if (isPiiCiphertext(stored) && kms) {
12
+ if (isPiiCiphertext(stored)) {
13
+ // A silent null would make the stale-draft cleanup treat the draft as empty and leak its file refs.
14
+ if (!kms)
15
+ throw new InternalError({ message: "form-draft: pii kms not configured" });
12
16
  const out = await decryptPiiFieldValues({ draft: stored }, ["draft"], kms, {
13
17
  requestId: requestContext.get()?.requestId ?? "form-draft:read",
14
18
  });
@@ -6,38 +6,29 @@ import { classifyHost, splitFirstSegment, tenantKeyFromPath } from "./host.js";
6
6
  import { matchRedirect, sameOriginPath } from "./redirects.js";
7
7
  import { validateHostRoutingOptions } from "./validate-options.js";
8
8
  const tenantKeyExistsResultSchema = z.object({ exists: z.boolean() });
9
- async function isTenantKeyKnown(systemQuery, key) {
9
+ async function existsViaSystemQuery(systemQuery, queryType, payload) {
10
10
  let result;
11
11
  try {
12
- result = await systemQuery(HostRoutingQueries.tenantKeyExists, { key }, SYSTEM_TENANT_ID);
12
+ result = await systemQuery(queryType, payload, SYSTEM_TENANT_ID);
13
13
  }
14
14
  catch (error) {
15
15
  if (error instanceof ValidationError)
16
16
  return false;
17
- // Over the rate limit every key looks the same: serve the tenant shell,
18
- // which leaks nothing; the API behind it still resolves the tenant itself.
17
+ // Over the rate limit every key looks the same: serve the shell, which
18
+ // leaks nothing. The API resolver is authoritative and answers
19
+ // tenant_required for unknown hosts, while a 404 would hit visitors
20
+ // behind a shared NAT under load.
19
21
  if (error instanceof RateLimitError)
20
22
  return true;
21
23
  throw error;
22
24
  }
23
25
  return tenantKeyExistsResultSchema.parse(result).exists;
24
26
  }
25
- async function isHostKnown(systemQuery, host) {
26
- let result;
27
- try {
28
- result = await systemQuery(HostRoutingQueries.hostTenantExists, { host }, SYSTEM_TENANT_ID);
29
- }
30
- catch (error) {
31
- if (error instanceof ValidationError)
32
- return false;
33
- // The shell is harmless: the API resolver is authoritative and answers
34
- // tenant_required for unknown hosts, while a 404 would hit visitors behind
35
- // a shared NAT under load.
36
- if (error instanceof RateLimitError)
37
- return true;
38
- throw error;
39
- }
40
- return tenantKeyExistsResultSchema.parse(result).exists;
27
+ function isTenantKeyKnown(systemQuery, key) {
28
+ return existsViaSystemQuery(systemQuery, HostRoutingQueries.tenantKeyExists, { key });
29
+ }
30
+ function isHostKnown(systemQuery, host) {
31
+ return existsViaSystemQuery(systemQuery, HostRoutingQueries.hostTenantExists, { host });
41
32
  }
42
33
  function serve(surface, file = surface.file) {
43
34
  return surface.csp === undefined
@@ -1,4 +1,4 @@
1
- import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import * as z from "zod";
3
3
  export declare const updateAccountSchema: z.ZodObject<{
4
4
  accountId: z.ZodUUID;
@@ -9,6 +9,6 @@ export declare const updateAccountSchema: z.ZodObject<{
9
9
  }>>;
10
10
  watchState: z.ZodOptional<z.ZodString>;
11
11
  displayName: z.ZodOptional<z.ZodString>;
12
- reason: z.ZodString;
12
+ reason: z.ZodOptional<z.ZodString>;
13
13
  }, z.core.$strip>;
14
14
  export declare const updateAccountHandler: WriteHandlerDef;
@@ -2,6 +2,8 @@
2
2
  // error/backoff, OAuth refresh fails (auth_error), or a tenant admin re-enables
3
3
  // the account after fixing its credentials. Full-snapshot event: loads the
4
4
  // latest state, merges the delta fields, and appends the new snapshot.
5
+ import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
6
+ import { SYSTEM_USER_ID, } from "@cosmicdrift/kumiko-framework/engine";
5
7
  import { failNotFound, failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
6
8
  import * as z from "zod";
7
9
  import { InboundMailAccountStatuses } from "../constants.js";
@@ -19,10 +21,16 @@ export const updateAccountSchema = z.object({
19
21
  /** Ops-Signal ("idle", "watching", "backoff:3", ...). */
20
22
  watchState: z.string().max(100).optional(),
21
23
  displayName: z.string().max(200).optional(),
22
- /** Audit reason for metadata.headers ("watch_supervisor",
23
- * "oauth_refresh", "tenant_admin"). */
24
- reason: z.string().min(1).max(100),
24
+ /** Audit reason for metadata.headers, honored for the system identity only
25
+ * ("watch_supervisor", "oauth_refresh"); user callers get a role-derived reason. */
26
+ reason: z.string().min(1).max(100).optional(),
25
27
  });
28
+ const SYSTEM_FALLBACK_REASON = "system";
29
+ function auditReasonFor(user, requestedReason) {
30
+ if (user.id === SYSTEM_USER_ID)
31
+ return requestedReason ?? SYSTEM_FALLBACK_REASON;
32
+ return user.roles.includes(ROLES.SystemAdmin) ? "system_admin" : "tenant_admin";
33
+ }
26
34
  export const updateAccountHandler = {
27
35
  name: "update-account",
28
36
  schema: updateAccountSchema,
@@ -52,7 +60,7 @@ export const updateAccountHandler = {
52
60
  };
53
61
  const headers = {
54
62
  providerName: current.provider,
55
- reason: payload.reason,
63
+ reason: auditReasonFor(event.user, payload.reason),
56
64
  };
57
65
  await ctx.unsafeAppendEvent({
58
66
  aggregateId: payload.accountId,
@@ -31,5 +31,4 @@ export const INBOUND_MAIL_FOUNDATION_I18N = {
31
31
  },
32
32
  [formFieldKey("accountId")]: { en: "Mailbox" },
33
33
  [formFieldKey("displayName")]: { en: "Name" },
34
- [formFieldKey("reason")]: { en: "Reason" },
35
34
  };
@@ -63,8 +63,6 @@ export const mailAccountListScreen = {
63
63
  access: MAIL_ACCOUNT_ADMIN_ACCESS,
64
64
  dormant: true,
65
65
  };
66
- // The reason is the audit label update-account requires; it is not in the
67
- // layout, so the default is what gets written.
68
66
  export const mailAccountEditScreen = {
69
67
  id: MAIL_ACCOUNT_EDIT_SCREEN_ID,
70
68
  type: "actionForm",
@@ -73,7 +71,6 @@ export const mailAccountEditScreen = {
73
71
  fields: {
74
72
  accountId: { type: "text", required: true },
75
73
  displayName: { type: "text", required: true, maxLength: 200 },
76
- reason: { type: "text", default: "tenant_admin" },
77
74
  },
78
75
  layout: {
79
76
  sections: [{ fields: [{ field: "accountId", readOnly: true }, "displayName"] }],
@@ -8,8 +8,9 @@ import { INBOUND_MAIL_PROVIDER_EXTENSION, type InboundMailAccountStatus } from "
8
8
  * config — tenant config reads (host/port/... of the plugins)
9
9
  * registry — extension lookup in the factory (not plugin-internal)
10
10
  * secrets — tenant secret reads (IMAP password, OAuth refresh token)
11
- * auditUserId — audit identity for secret reads. Handler path: the dispatcher
12
- * sets the caller user ID; worker path: system identity.
11
+ * auditUserId — audit identity for secret reads. Handler path: unset,
12
+ * requireSecretsContext falls back to ctx.user.id; worker/provider
13
+ * path: system identity set by the resolver.
13
14
  * metricsFor — metrics handle of a feature by name, same behavior as
14
15
  * HandlerContext.metricsFor: unregistered metrics are a silent
15
16
  * no-op instead of a throw.
@@ -146,7 +147,7 @@ export type InboundMailProviderPlugin = {
146
147
  * for password accounts and providers without an oauth flow. */
147
148
  readonly accessToken?: string;
148
149
  /** Batch lookup of the provider message ids not yet ingested for this
149
- * account (committed seen anchors, one query per call). Call it after
150
+ * account (committed seen anchors; lists of any size are fine, queried in chunks). Call it after
150
151
  * the listing/delta page and download MIME only for the returned ids.
151
152
  * Ingest dedup stays the correctness anchor; this only saves downloads. */
152
153
  readonly filterUnknownMessageIds?: (providerMessageIds: readonly string[]) => Promise<ReadonlySet<string>>;