@cosmicdrift/kumiko-bundled-features 0.334.0 → 0.336.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 (258) hide show
  1. package/dist/admin-shell/overview-panels.js +2 -2
  2. package/dist/agent-tools/agent-doc-lint.d.ts +1 -0
  3. package/dist/agent-tools/agent-doc-lint.js +33 -3
  4. package/dist/agent-tools/agent-manifest.js +47 -6
  5. package/dist/agent-tools/filter-ops.d.ts +2 -0
  6. package/dist/agent-tools/filter-ops.js +4 -0
  7. package/dist/agent-tools/tool-catalog.js +1 -4
  8. package/dist/agent-tools/tool-dispatch.js +1 -4
  9. package/dist/audit/constants.d.ts +1 -1
  10. package/dist/audit/constants.js +1 -1
  11. package/dist/auth-email-password/handlers/change-password.write.js +2 -0
  12. package/dist/auth-email-password/handlers/invite-create.write.js +12 -2
  13. package/dist/auth-email-password/handlers/login.write.js +2 -0
  14. package/dist/auth-email-password/handlers/self-registration-status.query.js +1 -1
  15. package/dist/auth-email-password/i18n.js +2 -0
  16. package/dist/auth-email-password/web/auth-client.d.ts +1 -0
  17. package/dist/auth-email-password/web/auth-client.js +1 -1
  18. package/dist/auth-email-password/web/auth-gate.js +6 -8
  19. package/dist/auth-email-password/web/index.d.ts +1 -1
  20. package/dist/auth-email-password/web/index.js +1 -1
  21. package/dist/auth-email-password/web/invite-accept-screen.js +16 -12
  22. package/dist/auth-email-password/web/login-screen.js +1 -1
  23. package/dist/auth-email-password/web/session-bootstrap-error.d.ts +5 -1
  24. package/dist/auth-email-password/web/session-bootstrap-error.js +2 -2
  25. package/dist/auth-email-password/web/session.d.ts +4 -0
  26. package/dist/auth-email-password/web/session.js +15 -0
  27. package/dist/auth-mfa/feature.js +3 -2
  28. package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +2 -2
  29. package/dist/auth-mfa/handlers/status.query.js +1 -0
  30. package/dist/auth-mfa/handlers/verify.write.js +2 -0
  31. package/dist/auth-mfa/hmac-token-codec.js +3 -0
  32. package/dist/auth-mfa/mfa-challenge-token.js +4 -0
  33. package/dist/auth-mfa/mfa-preauth-setup-token.js +3 -0
  34. package/dist/auth-mfa/web/mfa-client.d.ts +1 -1
  35. package/dist/auth-mfa/web/mfa-client.js +4 -10
  36. package/dist/auth-mfa-user-data/hooks.js +4 -2
  37. package/dist/billing-foundation/billing-info-query.js +1 -1
  38. package/dist/billing-foundation/checkout-core.d.ts +5 -5
  39. package/dist/billing-foundation/checkout-core.js +10 -6
  40. package/dist/billing-foundation/handlers/switch-plan.write.js +5 -8
  41. package/dist/cap-counter/book-cap-usage.js +20 -5
  42. package/dist/cap-counter/enforce-cap.js +9 -3
  43. package/dist/cap-counter/feature.js +3 -2
  44. package/dist/cap-counter/i18n.d.ts +1 -0
  45. package/dist/cap-counter/i18n.js +6 -5
  46. package/dist/cap-overview/handlers/caps-usage.query.js +9 -16
  47. package/dist/cap-overview/handlers/tenant-caps-list.query.js +41 -15
  48. package/dist/cap-overview/i18n.js +5 -0
  49. package/dist/cap-overview/web/cap-cards-panel.js +4 -1
  50. package/dist/cap-overview/web/cap-usage-bar.js +1 -1
  51. package/dist/channel-email/email-channel.js +12 -1
  52. package/dist/channel-in-app/tables.js +2 -6
  53. package/dist/compliance-profiles/handlers/sub-processors.query.js +1 -1
  54. package/dist/config/db/queries/resolver.js +3 -1
  55. package/dist/config/handlers/set.write.js +8 -4
  56. package/dist/config/web/i18n.js +1 -0
  57. package/dist/config/write-helpers.d.ts +1 -1
  58. package/dist/config/write-helpers.js +11 -5
  59. package/dist/crypto-shredding/handlers/forget-subject.write.js +15 -8
  60. package/dist/custom-fields/constants.js +1 -4
  61. package/dist/data-retention/resolve-for-tenant.d.ts +2 -3
  62. package/dist/data-retention/run-retention-cleanup.js +100 -44
  63. package/dist/delivery/handlers/log.query.js +33 -24
  64. package/dist/delivery/types.d.ts +2 -1
  65. package/dist/delivery/types.js +7 -0
  66. package/dist/delivery/unsubscribe.js +5 -11
  67. package/dist/derivatives-sharp/render.js +27 -9
  68. package/dist/document-ingest-foundation/feature.js +5 -5
  69. package/dist/document-ingest-foundation/providers.d.ts +1 -0
  70. package/dist/document-ingest-foundation/providers.js +6 -0
  71. package/dist/document-ingest-foundation/write-document-extract.d.ts +2 -2
  72. package/dist/document-ingest-foundation/write-document-extract.js +10 -0
  73. package/dist/feature-toggles/global-feature-state-table.js +2 -7
  74. package/dist/feature-toggles/toggle-runtime.d.ts +1 -0
  75. package/dist/feature-toggles/toggle-runtime.js +16 -2
  76. package/dist/feature-toggles/toggle-sync-signal.d.ts +2 -1
  77. package/dist/feature-toggles/toggle-sync-signal.js +3 -2
  78. package/dist/files-tenant-data/index.js +3 -2
  79. package/dist/folders/handlers/clear-folder.write.js +4 -11
  80. package/dist/folders/handlers/set-folder.write.js +4 -10
  81. package/dist/form-draft/feature.js +3 -2
  82. package/dist/form-draft/handlers/discard.write.js +7 -8
  83. package/dist/form-draft/handlers/get.query.js +7 -1
  84. package/dist/foundation-shared/host-allowlist.d.ts +4 -0
  85. package/dist/foundation-shared/host-allowlist.js +14 -0
  86. package/dist/foundation-shared/index.d.ts +1 -0
  87. package/dist/foundation-shared/index.js +1 -0
  88. package/dist/foundation-shared/mail-host-policy.d.ts +0 -2
  89. package/dist/foundation-shared/mail-host-policy.js +3 -13
  90. package/dist/inbound-mail-foundation/connect-routes.js +7 -5
  91. package/dist/inbound-mail-foundation/watch-supervisor.js +47 -43
  92. package/dist/jobs/db/queries/stale-run-sweep.js +22 -15
  93. package/dist/jobs/handlers/list.query.d.ts +7 -2
  94. package/dist/jobs/handlers/list.query.js +9 -2
  95. package/dist/jobs/handlers/retry.write.js +3 -2
  96. package/dist/jobs/handlers/tenant-failures.query.js +3 -9
  97. package/dist/jobs/job-run-logger.js +18 -2
  98. package/dist/jobs/tenant-job-failure-table.js +2 -6
  99. package/dist/ledger/handlers/confirm-schedule-period.write.js +2 -2
  100. package/dist/ledger/handlers/reverse-transaction.write.js +2 -2
  101. package/dist/ledger/subject-reference.d.ts +4 -0
  102. package/dist/ledger/subject-reference.js +7 -0
  103. package/dist/legal-pages/feature.js +8 -1
  104. package/dist/managed-pages/handlers/branding.query.js +1 -1
  105. package/dist/managed-pages/handlers/by-slug.query.js +1 -1
  106. package/dist/managed-pages/handlers/by-tenant-published.query.js +1 -1
  107. package/dist/metrics/compute.js +7 -1
  108. package/dist/metrics/window.js +5 -0
  109. package/dist/notes-history/handlers/add-note.write.js +4 -9
  110. package/dist/notes-history-user-data/hooks.js +28 -14
  111. package/dist/personal-access-tokens/constants.d.ts +1 -0
  112. package/dist/personal-access-tokens/constants.js +1 -0
  113. package/dist/personal-access-tokens/feature.js +2 -2
  114. package/dist/personal-access-tokens/handlers/create.write.js +2 -2
  115. package/dist/personal-access-tokens/handlers/revoke.write.js +2 -2
  116. package/dist/personal-access-tokens/i18n.js +12 -13
  117. package/dist/personal-access-tokens/revoke-for-user.js +12 -1
  118. package/dist/personal-access-tokens/schema/api-token.js +2 -1
  119. package/dist/personal-access-tokens/screens.js +5 -1
  120. package/dist/rate-limiting/feature.js +2 -0
  121. package/dist/rate-limiting/handlers/bucket-access.js +15 -7
  122. package/dist/rate-limiting/i18n.d.ts +10 -0
  123. package/dist/rate-limiting/i18n.js +10 -0
  124. package/dist/secrets/feature.js +3 -2
  125. package/dist/seo/handlers/seo-config.query.js +1 -1
  126. package/dist/sessions/constants.d.ts +1 -0
  127. package/dist/sessions/constants.js +1 -0
  128. package/dist/sessions/feature.js +5 -3
  129. package/dist/sessions/handlers/revoke-all-for-user.write.d.ts +1 -0
  130. package/dist/sessions/handlers/revoke-all-for-user.write.js +12 -2
  131. package/dist/sessions/handlers/revoke.write.js +2 -2
  132. package/dist/sessions/schema/user-session.js +2 -1
  133. package/dist/sessions/screens.js +5 -1
  134. package/dist/sessions/session-callbacks.js +88 -77
  135. package/dist/shared/assert-erased.d.ts +2 -1
  136. package/dist/shared/assert-erased.js +12 -4
  137. package/dist/shared/index.d.ts +2 -2
  138. package/dist/shared/index.js +2 -2
  139. package/dist/shared/lockout-counter.js +1 -1
  140. package/dist/shared/parent-visibility.d.ts +5 -0
  141. package/dist/shared/parent-visibility.js +17 -1
  142. package/dist/shared/row-bound-grant.js +20 -9
  143. package/dist/shared/single-use-token-store.js +5 -13
  144. package/dist/step-dispatcher/webhook-runner.d.ts +0 -2
  145. package/dist/step-dispatcher/webhook-runner.js +18 -17
  146. package/dist/subscription-mollie/plugin-methods.d.ts +3 -2
  147. package/dist/subscription-mollie/plugin-methods.js +7 -1
  148. package/dist/subscription-mollie/verify-webhook.js +6 -0
  149. package/dist/subscription-stripe/plugin-methods.js +25 -6
  150. package/dist/subscription-stripe/verify-webhook.js +13 -6
  151. package/dist/tags/handlers/assign-tag.write.js +4 -10
  152. package/dist/tags/handlers/remove-tag.write.js +4 -11
  153. package/dist/template-resolver/api.d.ts +2 -1
  154. package/dist/template-resolver/api.js +1 -1
  155. package/dist/template-resolver/handlers/by-slug.query.d.ts +1 -1
  156. package/dist/template-resolver/handlers/by-slug.query.js +2 -2
  157. package/dist/template-resolver/handlers/by-tenant.query.d.ts +3 -2
  158. package/dist/template-resolver/handlers/by-tenant.query.js +2 -2
  159. package/dist/template-resolver/handlers/collection-item.query.d.ts +1 -1
  160. package/dist/template-resolver/handlers/collection-list.query.d.ts +1 -1
  161. package/dist/template-resolver/handlers/collection-shared.d.ts +3 -2
  162. package/dist/template-resolver/handlers/collection-shared.js +1 -1
  163. package/dist/template-resolver/handlers/find-by-id.query.d.ts +1 -1
  164. package/dist/template-resolver/handlers/find-by-id.query.js +1 -1
  165. package/dist/template-resolver/handlers/list.query.d.ts +1 -1
  166. package/dist/template-resolver/handlers/list.query.js +1 -1
  167. package/dist/template-resolver/seeding.js +41 -39
  168. package/dist/template-resolver/table.d.ts +8 -5
  169. package/dist/template-resolver/testing.js +2 -2
  170. package/dist/template-resolver/user-content-table.d.ts +2 -5
  171. package/dist/template-resolver/web/client-plugin.d.ts +1 -1
  172. package/dist/template-resolver-user-data/hooks.js +1 -1
  173. package/dist/tenant/cancel-pending-invitation.d.ts +8 -0
  174. package/dist/tenant/cancel-pending-invitation.js +22 -0
  175. package/dist/tenant/feature.js +4 -2
  176. package/dist/tenant/handlers/cancel-invitation.write.js +6 -21
  177. package/dist/tenant/handlers/member-directory.query.d.ts +1 -0
  178. package/dist/tenant/handlers/member-directory.query.js +17 -4
  179. package/dist/tenant/handlers/remove-member.write.js +9 -14
  180. package/dist/tenant/handlers/team-list.query.js +2 -2
  181. package/dist/tenant/handlers/tenant-directory.query.d.ts +1 -0
  182. package/dist/tenant/handlers/tenant-directory.query.js +13 -3
  183. package/dist/tenant/handlers/update-member-roles.write.d.ts +1 -1
  184. package/dist/tenant/handlers/update-member-roles.write.js +28 -12
  185. package/dist/tenant/i18n.js +4 -0
  186. package/dist/tenant/index.d.ts +1 -1
  187. package/dist/tenant/index.js +1 -1
  188. package/dist/tenant/last-tenant-admin.d.ts +1 -1
  189. package/dist/tenant/last-tenant-admin.js +5 -3
  190. package/dist/tenant/seeding.d.ts +4 -0
  191. package/dist/tenant/seeding.js +9 -2
  192. package/dist/tenant-lifecycle/boot-checks.d.ts +1 -0
  193. package/dist/tenant-lifecycle/boot-checks.js +27 -1
  194. package/dist/tenant-lifecycle/feature.js +2 -1
  195. package/dist/tenant-lifecycle/stages.js +2 -2
  196. package/dist/tier-engine/feature.js +3 -2
  197. package/dist/user/command-schemas.d.ts +2 -2
  198. package/dist/user/handlers/create.write.d.ts +1 -1
  199. package/dist/user/handlers/create.write.js +8 -2
  200. package/dist/user/handlers/detail.query.d.ts +1 -0
  201. package/dist/user/handlers/update.write.d.ts +1 -1
  202. package/dist/user/handlers/update.write.js +8 -2
  203. package/dist/user-data-rights/db/queries/export-jobs.d.ts +2 -0
  204. package/dist/user-data-rights/feature.js +22 -9
  205. package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +4 -7
  206. package/dist/user-data-rights/handlers/deletion-grace-period.d.ts +3 -2
  207. package/dist/user-data-rights/handlers/deletion-grace-period.js +30 -8
  208. package/dist/user-data-rights/handlers/download-by-token.query.js +1 -1
  209. package/dist/user-data-rights/handlers/request-deletion-by-email.write.js +1 -1
  210. package/dist/user-data-rights/handlers/request-deletion.write.js +3 -6
  211. package/dist/user-data-rights/lib/update-user-lifecycle.js +7 -14
  212. package/dist/user-data-rights/run-export-jobs.d.ts +1 -0
  213. package/dist/user-data-rights/run-export-jobs.js +74 -41
  214. package/dist/user-data-rights/run-forget-cleanup.js +2 -2
  215. package/dist/user-data-rights/run-user-export.js +2 -2
  216. package/dist/user-data-rights-defaults/hooks/config-value.userdata-hook.js +4 -2
  217. package/dist/user-data-rights-defaults/hooks/file-ref.userdata-hook.js +8 -3
  218. package/dist/user-data-rights-defaults/hooks/notification-preference.userdata-hook.js +4 -2
  219. package/dist/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.js +4 -2
  220. package/dist/user-profile/feature.js +6 -1
  221. package/dist/user-profile/handlers/change-email.write.js +2 -0
  222. package/dist/workflow-runner/db/queries/due-runs.js +6 -9
  223. package/dist/workflow-runner/event-subscriber.js +29 -9
  224. package/dist/workflow-runner/handlers/resume-run.write.js +8 -5
  225. package/dist/workflow-runner/pending-projection.js +8 -1
  226. package/package.json +11 -9
  227. package/src/agent-tools/changes.json +12 -0
  228. package/src/auth-email-password/changes.json +18 -0
  229. package/src/auth-mfa/changes.json +6 -0
  230. package/src/billing-foundation/changes.json +7 -0
  231. package/src/cap-counter/changes.json +13 -0
  232. package/src/cap-overview/changes.json +12 -0
  233. package/src/channel-email/changes.json +6 -0
  234. package/src/config/changes.json +6 -0
  235. package/src/crypto-shredding/changes.json +6 -0
  236. package/src/data-retention/changes.json +12 -0
  237. package/src/delivery/changes.json +13 -0
  238. package/src/derivatives-sharp/changes.json +11 -0
  239. package/src/document-ingest-foundation/changes.json +12 -0
  240. package/src/feature-toggles/changes.json +5 -0
  241. package/src/inbound-mail-foundation/changes.json +12 -0
  242. package/src/jobs/changes.json +18 -0
  243. package/src/legal-pages/changes.json +8 -1
  244. package/src/managed-pages/changes.json +5 -0
  245. package/src/metrics/changes.json +6 -0
  246. package/src/personal-access-tokens/changes.json +5 -0
  247. package/src/rate-limiting/changes.json +5 -0
  248. package/src/secrets/changes.json +7 -0
  249. package/src/sessions/changes.json +11 -0
  250. package/src/shared/changes.json +6 -0
  251. package/src/step-dispatcher/changes.json +5 -0
  252. package/src/subscription-mollie/changes.json +7 -0
  253. package/src/subscription-stripe/changes.json +11 -0
  254. package/src/template-resolver/changes.json +7 -0
  255. package/src/tenant/changes.json +29 -0
  256. package/src/user-data-rights/changes.json +22 -0
  257. package/src/user-data-rights-defaults/changes.json +5 -0
  258. package/src/workflow-runner/changes.json +6 -0
@@ -1,10 +1,11 @@
1
1
  import { requestContext } from "@cosmicdrift/kumiko-framework/api";
2
2
  import { configuredPiiSubjectKms, encryptPiiValueForSubject, } from "@cosmicdrift/kumiko-framework/crypto";
3
3
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
4
- import { ConfigScopes, defineWriteHandler, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { ConfigScopes, defineWriteHandler, isEncryptedAtRest, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
5
5
  import { InternalError, UnprocessableError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
6
6
  import * as z from "zod";
7
7
  import { requireConfigEncryption, requireSystemDb } from "../feature.js";
8
+ import { MASKED } from "../read-redaction.js";
8
9
  import { configValueEntity, configValuesTable } from "../table.js";
9
10
  import { findConfigRow, prepareConfigWrite, resolvePiiSubject, validateBounds, validateExtensionPlugin, validatePattern, validateScope, validateType, } from "../write-helpers.js";
10
11
  const scopeEnum = z.enum([ConfigScopes.system, ConfigScopes.tenant, ConfigScopes.user]);
@@ -53,7 +54,7 @@ export const setWrite = defineWriteHandler({
53
54
  const typeError = validateType(event.payload.value, keyDef);
54
55
  if (typeError)
55
56
  return writeFailure(typeError);
56
- const pluginError = validateExtensionPlugin(ctx.registry, event.payload.key, event.payload.value);
57
+ const pluginError = validateExtensionPlugin(ctx.registry, event.payload.key, event.payload.value, keyDef);
57
58
  if (pluginError)
58
59
  return writeFailure(pluginError);
59
60
  // Bounds enforcement: hard-reject (not silent-clamp). A caller that
@@ -66,6 +67,9 @@ export const setWrite = defineWriteHandler({
66
67
  const patternError = validatePattern(event.payload.value, keyDef);
67
68
  if (patternError)
68
69
  return writeFailure(patternError);
70
+ // The batch idempotency cache stores this result and only masks entity
71
+ // writeOnly fields, so an at-rest-encrypted value must not be echoed.
72
+ const echoedValue = isEncryptedAtRest(keyDef) ? MASKED : event.payload.value;
69
73
  // backing="secrets": persist into the secrets store (system tenant, own
70
74
  // envelope encryption + audit) instead of config_values. Same JSON
71
75
  // serialization as a config row so the read path round-trips via
@@ -82,7 +86,7 @@ export const setWrite = defineWriteHandler({
82
86
  });
83
87
  return {
84
88
  isSuccess: true,
85
- data: { key: event.payload.key, value: event.payload.value, scope },
89
+ data: { key: event.payload.key, value: echoedValue, scope },
86
90
  };
87
91
  }
88
92
  let serialized = JSON.stringify(event.payload.value);
@@ -139,7 +143,7 @@ export const setWrite = defineWriteHandler({
139
143
  }
140
144
  return {
141
145
  isSuccess: true,
142
- data: { key: event.payload.key, value: event.payload.value, scope },
146
+ data: { key: event.payload.key, value: echoedValue, scope },
143
147
  };
144
148
  },
145
149
  });
@@ -9,6 +9,7 @@
9
9
  export const defaultTranslations = {
10
10
  en: {
11
11
  "config.secrets.delete": "Remove",
12
+ "config.secrets.deleteConfirm": "Remove this secret? Anything using it stops working, and it cannot be restored.",
12
13
  "config.secrets.notSet": "Not set",
13
14
  "config.secrets.placeholder": "Enter a value",
14
15
  "config.secrets.replacePlaceholder": "Enter a new value to replace it",
@@ -38,7 +38,7 @@ export declare function resolveScopeIds(scope: ConfigScope, tenantId: TenantId,
38
38
  userId: string | null;
39
39
  };
40
40
  export declare function resolvePiiSubject(scope: Exclude<ConfigScope, "system">, tenantId: TenantId, userId: string | null): SubjectId;
41
- export declare function validateExtensionPlugin(registry: Registry, key: string, value: string | number | boolean): KumikoError | null;
41
+ export declare function validateExtensionPlugin(registry: Pick<Registry, "getAllExtensionSelectors" | "getExtensionUsages">, key: string, value: string | number | boolean, keyDef: ConfigKeyDefinition): KumikoError | null;
42
42
  export declare function validateType(value: string | number | boolean, keyDef: ConfigKeyDefinition): KumikoError | null;
43
43
  export declare function validateBounds(value: string | number | boolean, keyDef: ConfigKeyDefinition): KumikoError | null;
44
44
  export declare function validatePattern(value: string | number | boolean, keyDef: ConfigKeyDefinition): KumikoError | null;
@@ -2,7 +2,7 @@
2
2
  // Extracted from set.write.ts so reset.write.ts + values.query.ts don't
3
3
  // have to cross-import from another handler file.
4
4
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
5
- import { ConfigScopes, extensionSelectorTargets, SYSTEM_ROLE, SYSTEM_TENANT_ID, selectablePluginIds, } from "@cosmicdrift/kumiko-framework/engine";
5
+ import { ConfigScopes, extensionSelectorTargets, isEncryptedAtRest, SYSTEM_ROLE, SYSTEM_TENANT_ID, selectablePluginIds, } from "@cosmicdrift/kumiko-framework/engine";
6
6
  import { AccessDeniedError, InternalError, NotFoundError, UnprocessableError, ValidationError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
7
7
  import { assertUnreachable } from "@cosmicdrift/kumiko-framework/utils";
8
8
  import { ConfigErrors } from "./constants.js";
@@ -134,9 +134,14 @@ export function resolvePiiSubject(scope, tenantId, userId) {
134
134
  }
135
135
  return { kind: "tenant", tenantId };
136
136
  }
137
+ // An encrypted value must not travel back in an error (logs, idempotency cache),
138
+ // so rejection details spread this instead of naming `value` directly.
139
+ function echoableValue(keyDef, value) {
140
+ return isEncryptedAtRest(keyDef) ? {} : { value };
141
+ }
137
142
  // An extension-selector key may only name a mounted plugin (or "" to clear) —
138
143
  // otherwise the settings screen would show no panel for a typo'd value.
139
- export function validateExtensionPlugin(registry, key, value) {
144
+ export function validateExtensionPlugin(registry, key, value, keyDef) {
140
145
  const extensionName = extensionSelectorTargets(registry).get(key);
141
146
  if (extensionName === undefined || value === "")
142
147
  return null;
@@ -145,7 +150,7 @@ export function validateExtensionPlugin(registry, key, value) {
145
150
  return null;
146
151
  return new UnprocessableError(ConfigErrors.unknownExtensionPlugin, {
147
152
  i18nKey: "config.errors.unknownExtensionPlugin",
148
- details: { key, value, allowed },
153
+ details: { key, ...echoableValue(keyDef, value), allowed },
149
154
  });
150
155
  }
151
156
  export function validateType(value, keyDef) {
@@ -172,7 +177,7 @@ export function validateType(value, keyDef) {
172
177
  path: "value",
173
178
  code: "invalid_option",
174
179
  i18nKey: "errors.validation.invalid_option",
175
- params: { value, options: keyDef.options },
180
+ params: { ...echoableValue(keyDef, value), options: keyDef.options },
176
181
  },
177
182
  ],
178
183
  });
@@ -254,13 +259,14 @@ export function validatePattern(value, keyDef) {
254
259
  }
255
260
  if (re.test(value))
256
261
  return null;
262
+ const echoedParams = { ...echoableValue(keyDef, value), pattern: keyDef.pattern.regex };
257
263
  return new ValidationError({
258
264
  fields: [
259
265
  {
260
266
  path: "value",
261
267
  code: "invalid_format",
262
268
  i18nKey: "errors.validation.invalid_format",
263
- params: { value, pattern: keyDef.pattern.regex },
269
+ params: echoedParams,
264
270
  },
265
271
  ],
266
272
  });
@@ -133,6 +133,17 @@ getRunner) {
133
133
  });
134
134
  return effective.policy?.strategy === "blockDelete" ? deny() : undefined;
135
135
  }
136
+ // AccessDeniedError.code is the generic "access_denied" for every branch;
137
+ // the specific branch lives in details.reason.
138
+ function denialReasonOf(failure) {
139
+ const details = failure.error.details;
140
+ if (typeof details === "object" && details !== null && "reason" in details) {
141
+ const { reason } = details;
142
+ if (typeof reason === "string")
143
+ return reason;
144
+ }
145
+ return failure.error.code;
146
+ }
136
147
  // Denied cross-tenant probes must still leave an audit trail (fw#2348).
137
148
  // Appended outside the handler tx (fw#2592): the caller's
138
149
  // `return tenantScopeDenial` rolls that tx back.
@@ -228,18 +239,14 @@ export const forgetSubjectWrite = defineWriteHandler({
228
239
  const subjectKey = subjectIdToKey(subject);
229
240
  const tenantScopeDenial = await resolveTenantScopeDenial(ctx.db.unsafeRaw("tenant-scope check runs against the subject's tenant, not necessarily the caller's"), ctx.registry.features, event.user, raw);
230
241
  if (tenantScopeDenial) {
231
- const auditFailure = await appendDenialAuditEvent(ctx, event, subjectKey, raw.kind, tenantScopeDenial.error.code, ctx.dbOutsideTransaction?.unsafeRaw("denial audit append: names the prober's own tenant stream on the outside-transaction db"));
242
+ const auditFailure = await appendDenialAuditEvent(ctx, event, subjectKey, raw.kind, denialReasonOf(tenantScopeDenial), ctx.dbOutsideTransaction?.unsafeRaw("denial audit append: names the prober's own tenant stream on the outside-transaction db"));
232
243
  return auditFailure ?? tenantScopeDenial;
233
244
  }
234
- // Runs AFTER the tenant gate (VORHER only means "before the shred"): a
235
- // retention check ahead of the tenant gate would leak a foreign
236
- // entity's retention posture to a cross-tenant prober.
245
+ // Runs after the tenant gate but before any key is touched: a retention
246
+ // check ahead of the tenant gate would leak a foreign entity's retention
247
+ // posture to a cross-tenant prober.
237
248
  const retentionDenial = await resolveRetentionDenial(ctx, raw, () => ctx.db.unsafeRaw("retention check reads the record row's owning tenant, not the caller's"));
238
249
  if (retentionDenial) {
239
- // Own reason constant, not tenantScopeDenial's `.error.code` pattern:
240
- // AccessDeniedError.code is the generic "access_denied" for every
241
- // branch, so re-deriving it here would make a blockDelete refusal
242
- // indistinguishable from a cross-tenant one in the audit trail.
243
250
  const auditFailure = await appendDenialAuditEvent(ctx, event, subjectKey, raw.kind, TARGET_RECORD_RETENTION_BLOCK_DELETE, ctx.dbOutsideTransaction?.unsafeRaw("denial audit append: names the prober's own tenant stream on the outside-transaction db"));
244
251
  return auditFailure ?? retentionDenial;
245
252
  }
@@ -19,10 +19,7 @@ export const CustomFieldsHandlers = {
19
19
  export const CustomFieldsQueries = {
20
20
  fieldDefinitionList: "custom-fields:query:field-definition:list",
21
21
  };
22
- // Name the web component registers under in ExtensionSectionsProvider —
23
- // apps reference it in the screen schema via:
24
- // { kind: "extension", title, contributesToFormSubmit: true,
25
- // component: { react: { __component: CUSTOM_FIELDS_FORM_EXTENSION_NAME } } }
22
+ // Name the web component registers under in ExtensionSectionsProvider.
26
23
  export const CUSTOM_FIELDS_FORM_EXTENSION_NAME = "CustomFieldsFormSection";
27
24
  // Entity-CRUD auto-events for the `field-definition` entity. registry.ts emits
28
25
  // these as `${entityName}.created`/`.updated` (dot form) — they do NOT run
@@ -10,9 +10,8 @@ export interface ResolveForTenantArgs {
10
10
  /**
11
11
  * Layer 2 — Tenant-Preset. `undefined` (the default) means this resolver
12
12
  * derives it itself via resolveTenantRetentionPreset — one compliance-
13
- * profile read per call, fine for single-lookup callers (`policy-for`, a
14
- * per-hook TenantDb caller like notes-history-user-data). A bulk caller
15
- * iterating N entities × M tenants (the forget-cleanup runner, the
13
+ * profile read per call, fine for single-lookup callers (`policy-for`). A
14
+ * bulk caller iterating N entities × M tenants (the forget-cleanup runner, the
16
15
  * retention-cleanup cron) MUST resolve it ONCE per tenant and pass it in
17
16
  * here instead, or it re-derives per entity. `null` means "resolved
18
17
  * already, no preset applies" — the resolver falls back to Entity-Default
@@ -41,7 +41,7 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
41
41
  import { collectPiiSubjectFields, configuredPiiSubjectKms, isSelfPiiField, resolveSubjectForField, subjectIdToKey, } from "@cosmicdrift/kumiko-framework/crypto";
42
42
  import { createEventStoreExecutor, createTenantDb, nullBlindIndexesForSubject, } from "@cosmicdrift/kumiko-framework/db";
43
43
  import { deleteStoredFileAndDerivatives } from "@cosmicdrift/kumiko-framework/derivatives";
44
- import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
44
+ import { createSystemUser, entityHasAnonymizableSubjectField, } from "@cosmicdrift/kumiko-framework/engine";
45
45
  import { fileRefEntity, fileRefsTable, } from "@cosmicdrift/kumiko-framework/files";
46
46
  import { runInSubTransaction } from "../shared/index.js";
47
47
  import { computeCutoff } from "./keep-for.js";
@@ -204,22 +204,62 @@ function isFileRefBoundElsewhere(fileRef, entityName, rowId) {
204
204
  return true;
205
205
  return false;
206
206
  }
207
- // A single-field fileRef-id can be shared by more than one row of the SAME
208
- // table (e.g. a stock-photo re-picked across records). Purging its bytes
209
- // would corrupt the row that isn't being deleted. Bounded by batchLimit rows
210
- // per run and singleFields.length queries per fileRef — the same per-row
211
- // event-cost tradeoff as the rest of this cron (see header).
212
- async function isFileRefSharedByOtherRow(db, table, tableHasTenantId, tenantId, rowId, singleFields, fileRefId) {
213
- for (const field of singleFields) {
214
- const where = { [field]: fileRefId, id: { ne: rowId } };
215
- if (tableHasTenantId)
216
- where["tenantId"] = tenantId;
217
- const others = await selectMany(db, table, where, { limit: 1 });
218
- if (others.length > 0)
219
- return true;
207
+ // Every implicit entity table with file/image fields: an unbound fileRef
208
+ // (POST /files without entityType/entityId) can sit in single fields of
209
+ // several entity types, so the sharing check must look beyond the purged
210
+ // row's own table.
211
+ function collectSingleFileHolders(registry) {
212
+ const holders = [];
213
+ for (const proj of registry.getAllProjections().values()) {
214
+ if (proj.isImplicit !== true || typeof proj.source !== "string" || !proj.table)
215
+ continue;
216
+ const entity = registry.getEntity(proj.source);
217
+ if (!entity)
218
+ continue;
219
+ const { singleFields } = planFileFields(entity.fields);
220
+ if (singleFields.length === 0)
221
+ continue;
222
+ const table = proj.table; // @cast-boundary column-presence probe
223
+ holders.push({
224
+ table: proj.table,
225
+ tableHasTenantId: table["tenantId"] !== undefined,
226
+ singleFields,
227
+ });
228
+ }
229
+ return holders;
230
+ }
231
+ // A single-field fileRef-id can be shared by more than one row, in the SAME
232
+ // table (e.g. a stock-photo re-picked across records) or in another entity's
233
+ // table. Purging its bytes would corrupt the row that isn't being deleted.
234
+ // Bounded by batchLimit rows per run and holderFields queries per fileRef —
235
+ // the same per-row event-cost tradeoff as the rest of this cron (see header).
236
+ async function isFileRefSharedByOtherRow(db, holders, ownTable, tenantId, rowId, fileRefId) {
237
+ for (const holder of holders) {
238
+ for (const field of holder.singleFields) {
239
+ const where = { [field]: fileRefId };
240
+ if (holder.table === ownTable)
241
+ where["id"] = { ne: rowId };
242
+ if (holder.tableHasTenantId)
243
+ where["tenantId"] = tenantId;
244
+ const others = await selectMany(db, holder.table, where, { limit: 1 });
245
+ if (others.length > 0)
246
+ return true;
247
+ }
220
248
  }
221
249
  return false;
222
250
  }
251
+ // Throws (unsafe key, missing list permission) must fail this row closed,
252
+ // not abort the whole tenant run.
253
+ async function deleteFileFailingClosed(key, store, logContext) {
254
+ try {
255
+ return await deleteStoredFileAndDerivatives(key, store, "data-retention:hardDelete");
256
+ }
257
+ catch (err) {
258
+ // biome-ignore lint/suspicious/noConsole: operator-visibility for storage-cleanup failures
259
+ console.warn(`[data-retention:hardDelete] ${logContext} storage cleanup threw: ${err instanceof Error ? err.message : String(err)}`);
260
+ return [key];
261
+ }
262
+ }
223
263
  // hardDelete for ONE row of an entity that has file/image/files/images
224
264
  // fields: delete the un-shared fileRefs' bytes (+ derivatives) first, then
225
265
  // their fileRef rows, then the entity row itself — bytes before rows so a
@@ -251,7 +291,7 @@ async function purgeHardDeleteRowWithFiles(args) {
251
291
  for (const fileRef of fileRefRows) {
252
292
  if (isFileRefBoundElsewhere(fileRef, args.entityName, rowId))
253
293
  continue;
254
- const shared = await isFileRefSharedByOtherRow(args.db, args.table, args.tableHasTenantId, args.tenantId, rowId, args.plan.singleFields, String(fileRef["id"]));
294
+ const shared = await isFileRefSharedByOtherRow(args.db, args.fileHolders, args.table, args.tenantId, rowId, String(fileRef["id"]));
255
295
  if (shared)
256
296
  continue;
257
297
  toDelete.push(fileRef);
@@ -270,7 +310,7 @@ async function purgeHardDeleteRowWithFiles(args) {
270
310
  const key = fileRef["storageKey"];
271
311
  if (typeof key !== "string" || key.length === 0)
272
312
  continue;
273
- const failedKeys = await deleteStoredFileAndDerivatives(key, store, "data-retention:hardDelete");
313
+ const failedKeys = await deleteFileFailingClosed(key, store, `tenant=${args.tenantId} entity=${args.entityName} row=${rowId}`);
274
314
  if (failedKeys.length > 0) {
275
315
  anyFailed = true;
276
316
  // biome-ignore lint/suspicious/noConsole: operator-visibility for storage-cleanup failures
@@ -295,9 +335,6 @@ async function purgeHardDeleteRowWithFiles(args) {
295
335
  // each skip reason at most once per entity per run (repeat skips would just
296
336
  // be noise; the per-row failure is already warned above).
297
337
  async function purgeHardDeleteRowsWithFiles(args) {
298
- const rows = await selectMany(args.db, args.table, args.where, {
299
- limit: args.batchLimit,
300
- });
301
338
  const reasonsSeen = new Set();
302
339
  const onSkip = (reason) => {
303
340
  // skip: reason already reported for this entity in this run
@@ -306,28 +343,44 @@ async function purgeHardDeleteRowsWithFiles(args) {
306
343
  reasonsSeen.add(reason);
307
344
  args.skipped.push({ entityName: args.entityName, reason });
308
345
  };
346
+ // id-cursor paging: rows skipped (missing storage, failed delete) stay in the
347
+ // table, so an unordered limit-page would re-fetch the same stuck rows every
348
+ // run and starve every row behind them. batchLimit bounds the DELETES.
309
349
  let count = 0;
310
- for (const row of rows) {
311
- const ok = await purgeHardDeleteRowWithFiles({
312
- db: args.db,
313
- table: args.table,
314
- tableHasTenantId: args.tableHasTenantId,
315
- tenantId: args.tenantId,
316
- entityName: args.entityName,
317
- entity: args.entity,
318
- row,
319
- plan: args.plan,
320
- files: args.files,
321
- entityExecutor: args.entityExecutor,
322
- fileRefExecutor: args.fileRefExecutor,
323
- systemUser: args.systemUser,
324
- tdb: args.tdb,
325
- kms: args.kms,
326
- registry: args.registry,
327
- onSkip,
350
+ let cursor = null;
351
+ while (count < args.batchLimit) {
352
+ const pageWhere = cursor === null ? args.where : { ...args.where, id: { gt: cursor } };
353
+ const rows = await selectMany(args.db, args.table, pageWhere, {
354
+ limit: args.batchLimit,
355
+ orderBy: { col: "id" },
328
356
  });
329
- if (ok)
330
- count++;
357
+ if (rows.length === 0)
358
+ break;
359
+ for (const row of rows) {
360
+ cursor = row["id"]; // @cast-boundary db-row
361
+ const ok = await purgeHardDeleteRowWithFiles({
362
+ db: args.db,
363
+ table: args.table,
364
+ fileHolders: args.fileHolders,
365
+ tenantId: args.tenantId,
366
+ entityName: args.entityName,
367
+ entity: args.entity,
368
+ row,
369
+ plan: args.plan,
370
+ files: args.files,
371
+ entityExecutor: args.entityExecutor,
372
+ fileRefExecutor: args.fileRefExecutor,
373
+ systemUser: args.systemUser,
374
+ tdb: args.tdb,
375
+ kms: args.kms,
376
+ registry: args.registry,
377
+ onSkip,
378
+ });
379
+ if (ok)
380
+ count++;
381
+ if (count >= args.batchLimit)
382
+ break;
383
+ }
331
384
  }
332
385
  return count;
333
386
  }
@@ -394,6 +447,7 @@ export async function runRetentionCleanup(args) {
394
447
  // filter shrinks the set). A single batched forget/delete event is a follow-up.
395
448
  const systemUser = createSystemUser(tenantId);
396
449
  const tdb = createTenantDb(db, tenantId, "system");
450
+ const fileHolders = collectSingleFileHolders(registry);
397
451
  // Pre-load every override row for this tenant ONCE — N entities × M
398
452
  // tenants would otherwise mean one fetchOne per entity per tenant, even
399
453
  // though the whole set for a tenant is a single small, indexed read.
@@ -458,7 +512,7 @@ export async function runRetentionCleanup(args) {
458
512
  hardDeleted += await purgeHardDeleteRowsWithFiles({
459
513
  db,
460
514
  table: proj.table,
461
- tableHasTenantId: table["tenantId"] !== undefined,
515
+ fileHolders,
462
516
  where,
463
517
  batchLimit,
464
518
  tenantId,
@@ -495,10 +549,12 @@ export async function runRetentionCleanup(args) {
495
549
  case "blockDelete": {
496
550
  const targets = await resolveAnonymizeTargets(entity.fields);
497
551
  if (!targets) {
498
- // blockDelete without anonymize fields is a pure hold: a subjectRef-only
499
- // person link outlives the hold and is cut only by the user-data
500
- // forget hook, so expiry has nothing to do here and is not reported.
501
- if (policy.strategy === "anonymize") {
552
+ // blockDelete without anonymize fields is a pure hold only while the
553
+ // entity has no field that CAN carry anonymize: a subjectRef-only
554
+ // person link outlives the hold and is cut by the user-data forget
555
+ // hook. A personal field lacking its anonymize function would stay
556
+ // stored past the hold, so that case is still reported.
557
+ if (policy.strategy === "anonymize" || entityHasAnonymizableSubjectField(entity.fields)) {
502
558
  skipped.push({ entityName, reason: "missing_anonymize_fields" });
503
559
  }
504
560
  break;
@@ -1,5 +1,5 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { decodeCursor, encodeCursor } from "@cosmicdrift/kumiko-framework/db";
2
+ import { decodeKeysetCursor, encodeKeysetCursor } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { access, definePagedQueryHandler, MAX_LIST_LIMIT, } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
5
5
  import { Temporal } from "temporal-polyfill";
@@ -28,15 +28,15 @@ function isDeliveryLogSortField(value) {
28
28
  // PHYSICAL column, not the display alias — row only has the DB field names
29
29
  // (e.g. notificationType), never the alias (e.g. "type").
30
30
  function encodeSortCursor(column, row) {
31
- if (column === "createdAt")
32
- return encodeCursor(row.createdAt.toString());
33
- return encodeCursor(String(row[column]));
31
+ const sortValue = column === "createdAt" ? row.createdAt.toString() : String(row[column]);
32
+ return encodeKeysetCursor(sortValue, row.id);
34
33
  }
34
+ // lastId is undefined for a legacy sort-value-only cursor still in flight.
35
35
  function decodeSortCursor(column, cursor) {
36
- const decoded = decodeCursor(cursor);
37
- if (column === "createdAt")
38
- return Temporal.Instant.from(decoded);
39
- return decoded;
36
+ const decoded = decodeKeysetCursor(cursor);
37
+ const raw = decoded.sortValue ?? decoded.id;
38
+ const sortValue = column === "createdAt" ? Temporal.Instant.from(raw) : raw;
39
+ return { sortValue, lastId: decoded.sortValue === undefined ? undefined : decoded.id };
40
40
  }
41
41
  export const logQuery = definePagedQueryHandler({
42
42
  name: "log",
@@ -70,23 +70,32 @@ export const logQuery = definePagedQueryHandler({
70
70
  const sortColumn = DELIVERY_LOG_SORT_COLUMNS[sortField];
71
71
  // TenantAdmin/Admin stay strictly tenant-scoped; SystemAdmin sees every
72
72
  // tenant's attempts (platform waitlist confirmations live on SYSTEM_TENANT_ID).
73
- const where = isSystemAdmin ? {} : { tenantId: query.user.tenantId };
74
- if (query.payload.cursor) {
75
- where[sortColumn] = {
76
- [sortDirection === "asc" ? "gt" : "lt"]: decodeSortCursor(sortColumn, query.payload.cursor),
77
- };
78
- }
79
- const rows = await selectMany(db, deliveryAttemptsTable, where, {
80
- // Tie-breaker on id keeps ordering stable across pages when the sort
81
- // column has duplicate values (e.g. many rows with the same status).
82
- orderBy: [
83
- { col: sortColumn, direction: sortDirection },
84
- { col: "id", direction: "asc" },
85
- ],
86
- limit: query.payload.limit,
87
- });
73
+ const baseWhere = isSystemAdmin ? {} : { tenantId: query.user.tenantId };
74
+ const limit = query.payload.limit;
75
+ const cursor = query.payload.cursor
76
+ ? decodeSortCursor(sortColumn, query.payload.cursor)
77
+ : undefined;
78
+ const beyondSortValue = sortDirection === "asc" ? "gt" : "lt";
79
+ // Keyset page = rows still tied on the cursor's sort value (id after the
80
+ // cursor's id), then rows strictly beyond it. Both halves are ordered the
81
+ // same way as the overall ORDER BY, so concatenation preserves it.
82
+ const tiedRows = cursor?.lastId !== undefined
83
+ ? await selectMany(db, deliveryAttemptsTable, { ...baseWhere, [sortColumn]: cursor.sortValue, id: { gt: cursor.lastId } }, { orderBy: [{ col: "id", direction: "asc" }], limit })
84
+ : [];
85
+ const beyondRows = tiedRows.length < limit
86
+ ? await selectMany(db, deliveryAttemptsTable, cursor
87
+ ? { ...baseWhere, [sortColumn]: { [beyondSortValue]: cursor.sortValue } }
88
+ : baseWhere, {
89
+ orderBy: [
90
+ { col: sortColumn, direction: sortDirection },
91
+ { col: "id", direction: "asc" },
92
+ ],
93
+ limit: limit - tiedRows.length,
94
+ })
95
+ : [];
96
+ const rows = [...tiedRows, ...beyondRows];
88
97
  const lastRow = rows[rows.length - 1];
89
- const nextCursor = rows.length === query.payload.limit && lastRow ? encodeSortCursor(sortColumn, lastRow) : null;
98
+ const nextCursor = rows.length === limit && lastRow ? encodeSortCursor(sortColumn, lastRow) : null;
90
99
  // recipientAddress is stored encrypted under the recipient's DEK (#799)
91
100
  // — decrypt for the admin log view; forgotten subjects show [[erased]].
92
101
  // The notificationType/recipientAddress → type/recipient rename happens
@@ -23,7 +23,8 @@ export type RenderedMessage = {
23
23
  readonly html: string;
24
24
  readonly subject: string;
25
25
  };
26
- export type DeliveryChannelMode = "inline" | "queued";
26
+ export declare const DELIVERY_CHANNEL_MODES: readonly ["inline", "queued"];
27
+ export type DeliveryChannelMode = (typeof DELIVERY_CHANNEL_MODES)[number];
27
28
  export type DeliveryChannel = {
28
29
  readonly name: string;
29
30
  readonly mode: DeliveryChannelMode;
@@ -1,8 +1,15 @@
1
1
  import { DELIVERY_CHANNEL_EXTENSION } from "./constants.js";
2
+ // `mode` decides how the delivery-service dispatches a channel:
3
+ // inline — sent synchronously inside notify() (inApp: DB insert + SSE).
4
+ // queued — sent asynchronously via the delivery.send job; channels with a
5
+ // render() additionally run through delivery.render first.
6
+ export const DELIVERY_CHANNEL_MODES = ["inline", "queued"];
2
7
  export function isDeliveryChannelPlugin(o) {
3
8
  return (typeof o === "object" &&
4
9
  o !== null &&
5
10
  "mode" in o &&
11
+ DELIVERY_CHANNEL_MODES.some((mode) => mode === o.mode) &&
12
+ (!("render" in o) || o.render === undefined || typeof o.render === "function") &&
6
13
  "resolve" in o &&
7
14
  typeof o.resolve === "function" &&
8
15
  "send" in o &&
@@ -213,13 +213,7 @@ function confirmationPage(token) {
213
213
  // One-click clients put the token in the query string; only the
214
214
  // confirmation-page form submits it in the body.
215
215
  function tokenFromPostRequest(request) {
216
- const contentType = request.headers["content-type"];
217
- if (contentType?.includes("application/x-www-form-urlencoded")) {
218
- const fromBody = new URLSearchParams(request.rawBody).get("token");
219
- if (fromBody)
220
- return fromBody;
221
- }
222
- return request.query["token"];
216
+ return tokenFromRequestBody(request) ?? request.query["token"];
223
217
  }
224
218
  // Resubscribe is deliberately NOT reachable via the query string — a mail
225
219
  // client's link prefetcher (or a scanner) following an unsubscribe link
@@ -227,10 +221,10 @@ function tokenFromPostRequest(request) {
227
221
  // convention is built to avoid; resubscribe carries the same risk in
228
222
  // reverse (a prefetch that undoes a real opt-out) and gets no query-param
229
223
  // fallback at all.
230
- function tokenFromResubscribeRequest(request) {
224
+ function tokenFromRequestBody(request) {
231
225
  const contentType = request.headers["content-type"];
232
226
  if (contentType?.includes("application/x-www-form-urlencoded")) {
233
- return new URLSearchParams(request.rawBody).get("token") ?? undefined;
227
+ return new URLSearchParams(request.rawBody).get("token") || undefined;
234
228
  }
235
229
  if (contentType?.includes("application/json")) {
236
230
  try {
@@ -278,13 +272,13 @@ export function createUnsubscribeRoutes(options) {
278
272
  },
279
273
  });
280
274
  // Undo direction: token only from the JSON/form body, never the query
281
- // (see tokenFromResubscribeRequest) — no GET confirmation-page variant,
275
+ // (see tokenFromRequestBody) — no GET confirmation-page variant,
282
276
  // this is a one-shot POST from the unsubscribe-page's "Undo" button.
283
277
  const resubscribeRoute = signatureRoute({
284
278
  method: "POST",
285
279
  path: DELIVERY_RESUBSCRIBE_PATH,
286
280
  entry: "signature",
287
- verify: async (request) => verifyUnsubscribeToken(tokenFromResubscribeRequest(request), encodedSecret),
281
+ verify: async (request) => verifyUnsubscribeToken(tokenFromRequestBody(request), encodedSecret),
288
282
  handler: async (c, verified, deps) => {
289
283
  const outcome = await dispatchResubscribeWrite(verified, deps);
290
284
  if (outcome === "limit_reached") {
@@ -124,24 +124,40 @@ function overlayPosition(gravity, marginPx, outputWidth, outputHeight, layerWidt
124
124
  // fraction of the output is a fraction of what the caller actually gets,
125
125
  // regardless of the source's own dimensions or `fit` crop.
126
126
  async function applyOverlays(pipeline, layers) {
127
- const { data, info } = await pipeline.toBuffer({ resolveWithObject: true });
127
+ // PNG intermediate: a bare toBuffer() re-encodes in the source format, so a
128
+ // JPEG source would gain an extra lossy generation before applyEncoder runs.
129
+ const { data, info } = await pipeline.png().toBuffer({ resolveWithObject: true });
128
130
  const composites = [];
129
131
  for (const layer of layers) {
132
+ const targetWidth = Math.max(1, Math.round(info.width * layer.widthPct));
133
+ // The QR is square, so the output height bounds its side just like the width does.
134
+ const qrSide = Math.min(targetWidth, info.height);
135
+ if (layer.kind === "qr" && qrSide < MIN_QR_PIXEL_WIDTH) {
136
+ throw new Error(`derivatives-sharp: qr overlay would render at ${qrSide}px wide, below the ${MIN_QR_PIXEL_WIDTH}px minimum a camera can reliably scan.`);
137
+ }
138
+ // Rendered at its final size so the modules stay hard-edged; resampling a
139
+ // small default-size QR to a non-integer module size blurs them.
130
140
  const layerBytes = layer.kind === "qr"
131
- ? await QRCode.toBuffer(layer.data, { margin: 1, errorCorrectionLevel: "M" })
141
+ ? await QRCode.toBuffer(layer.data, {
142
+ width: qrSide,
143
+ margin: 1,
144
+ errorCorrectionLevel: "M",
145
+ })
132
146
  : Buffer.from(layer.imageBase64, "base64");
133
147
  const layerMeta = await sharp(layerBytes, SHARP_INPUT_OPTIONS).metadata();
134
148
  assertNotSvg(layerMeta.format);
135
- const targetWidth = Math.max(1, Math.round(info.width * layer.widthPct));
136
- if (layer.kind === "qr" && targetWidth < MIN_QR_PIXEL_WIDTH) {
137
- throw new Error(`derivatives-sharp: qr overlay would render at ${targetWidth}px wide, below the ${MIN_QR_PIXEL_WIDTH}px minimum a camera can reliably scan.`);
138
- }
139
149
  // PNG (not the layer's own format) to keep any alpha through the resize.
140
150
  // height caps the other axis too — sharp's composite() rejects an input
141
151
  // larger than the base image, which a tall/square layer would otherwise
142
152
  // hit once widthPct alone drives its width past the output's height.
143
153
  const resized = await sharp(layerBytes, SHARP_INPUT_OPTIONS)
144
- .resize({ width: targetWidth, height: info.height, fit: "inside" })
154
+ .resize({
155
+ width: targetWidth,
156
+ height: info.height,
157
+ fit: "inside",
158
+ // Only ever a sub-pixel correction for a QR; keeps module edges crisp.
159
+ kernel: layer.kind === "qr" ? "nearest" : "lanczos3",
160
+ })
145
161
  .png()
146
162
  .toBuffer({ resolveWithObject: true });
147
163
  const marginPx = Math.round(info.width * (layer.marginPct ?? 0));
@@ -212,7 +228,8 @@ function assertOverlayLayerBounds(layer) {
212
228
  if (!Number.isFinite(layer.widthPct) || layer.widthPct <= 0 || layer.widthPct > 1) {
213
229
  throw new Error(`derivatives-sharp: overlay widthPct ${layer.widthPct} is out of range — must be a finite number in (0, 1].`);
214
230
  }
215
- if (layer.marginPct !== undefined && (layer.marginPct < 0 || layer.marginPct >= 0.5)) {
231
+ if (layer.marginPct !== undefined &&
232
+ (!Number.isFinite(layer.marginPct) || layer.marginPct < 0 || layer.marginPct >= 0.5)) {
216
233
  throw new Error(`derivatives-sharp: overlay marginPct ${layer.marginPct} is out of range — must be within [0, 0.5).`);
217
234
  }
218
235
  if (layer.kind === "qr") {
@@ -278,7 +295,8 @@ export const renderImage = async (input, spec, sourceMimeType) => {
278
295
  // the output.
279
296
  let pipeline = sharp(input, SHARP_INPUT_OPTIONS).rotate();
280
297
  if (spec.blurRegions && spec.blurRegions.length > 0) {
281
- const { data, info } = await pipeline.toBuffer({ resolveWithObject: true });
298
+ // PNG intermediate for the same reason as in applyOverlays (no extra lossy generation).
299
+ const { data, info } = await pipeline.png().toBuffer({ resolveWithObject: true });
282
300
  const blurred = await applyBlurRegions(data, info.width, info.height, spec.blurRegions);
283
301
  pipeline = sharp(blurred, SHARP_INPUT_OPTIONS);
284
302
  }