@cosmicdrift/kumiko-samples 0.387.0 → 0.389.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 (274) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +8 -1
  3. package/packages/bundled-features/src/agent-tools/changes.json +12 -0
  4. package/packages/bundled-features/src/agent-tools/index.ts +1 -1
  5. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +48 -32
  6. package/packages/bundled-features/src/audit/changes.json +12 -0
  7. package/packages/bundled-features/src/audit/feature.ts +1 -0
  8. package/packages/bundled-features/src/audit/handlers/audit-events.ts +1 -1
  9. package/packages/bundled-features/src/auth-email-password/changes.json +12 -0
  10. package/packages/bundled-features/src/auth-email-password/handlers/request-account-unlock.write.ts +1 -0
  11. package/packages/bundled-features/src/auth-email-password/handlers/request-email-verification.write.ts +1 -0
  12. package/packages/bundled-features/src/auth-email-password/handlers/request-password-reset.write.ts +1 -0
  13. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +4 -0
  14. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +8 -0
  15. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +16 -3
  16. package/packages/bundled-features/src/auth-email-password/signup-activation.ts +2 -0
  17. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +8 -2
  18. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +23 -21
  19. package/packages/bundled-features/src/auth-email-password/web/default-auth-pages-gate.tsx +6 -3
  20. package/packages/bundled-features/src/auth-email-password/web/forgot-password-screen.tsx +11 -1
  21. package/packages/bundled-features/src/auth-email-password/web/index.ts +5 -0
  22. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +16 -5
  23. package/packages/bundled-features/src/auth-email-password/web/reset-password-screen.tsx +7 -1
  24. package/packages/bundled-features/src/auth-email-password/web/return-path.ts +35 -0
  25. package/packages/bundled-features/src/auth-email-password/web/session.tsx +6 -1
  26. package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +11 -3
  27. package/packages/bundled-features/src/auth-email-password/web/signup-screen.tsx +12 -2
  28. package/packages/bundled-features/src/auth-mfa/changes.json +12 -0
  29. package/packages/bundled-features/src/auth-mfa/config.ts +3 -7
  30. package/packages/bundled-features/src/auth-mfa/feature-exports.ts +2 -2
  31. package/packages/bundled-features/src/auth-mfa/feature.ts +6 -0
  32. package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +8 -5
  33. package/packages/bundled-features/src/auth-mfa/web/i18n.ts +6 -1
  34. package/packages/bundled-features/src/auth-mfa/web/mfa-code-or-recovery-field.tsx +68 -0
  35. package/packages/bundled-features/src/auth-mfa/web/mfa-disable-dialog.tsx +23 -13
  36. package/packages/bundled-features/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +23 -13
  37. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +15 -6
  38. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +65 -18
  39. package/packages/bundled-features/src/billing-foundation/changes.json +55 -0
  40. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +32 -42
  41. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +8 -2
  42. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +2 -0
  43. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +14 -3
  44. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +3 -4
  45. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +12 -6
  46. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +35 -19
  47. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +12 -8
  48. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +14 -8
  49. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +5 -0
  50. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +8 -10
  51. package/packages/bundled-features/src/billing-foundation/events.ts +1 -0
  52. package/packages/bundled-features/src/billing-foundation/feature.ts +3 -1
  53. package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +71 -6
  54. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +1 -0
  55. package/packages/bundled-features/src/billing-foundation/handlers/plan-switch-preview.query.ts +65 -0
  56. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +9 -2
  57. package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +44 -8
  58. package/packages/bundled-features/src/billing-foundation/handlers/switch-plan.write.ts +72 -82
  59. package/packages/bundled-features/src/billing-foundation/handlers/sync-subscription.write.ts +14 -1
  60. package/packages/bundled-features/src/billing-foundation/i18n.ts +22 -0
  61. package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
  62. package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +62 -27
  63. package/packages/bundled-features/src/billing-foundation/plan-switch.ts +84 -0
  64. package/packages/bundled-features/src/billing-foundation/types.ts +80 -1
  65. package/packages/bundled-features/src/billing-foundation/validate-options.ts +19 -0
  66. package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +119 -47
  67. package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +89 -42
  68. package/packages/bundled-features/src/billing-foundation/web/constants.ts +4 -0
  69. package/packages/bundled-features/src/billing-foundation/web/plan-price.ts +55 -0
  70. package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +224 -0
  71. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +25 -8
  72. package/packages/bundled-features/src/cap-counter/changes.json +30 -0
  73. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +2 -1
  74. package/packages/bundled-features/src/cap-counter/feature.ts +1 -0
  75. package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +8 -1
  76. package/packages/bundled-features/src/cap-overview/feature.ts +1 -0
  77. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  78. package/packages/bundled-features/src/channel-email/feature.ts +1 -1
  79. package/packages/bundled-features/src/config/changes.json +18 -0
  80. package/packages/bundled-features/src/config/handlers/cascade.query.ts +4 -2
  81. package/packages/bundled-features/src/config/handlers/set.write.ts +19 -2
  82. package/packages/bundled-features/src/config/handlers/values.query.ts +4 -2
  83. package/packages/bundled-features/src/config/write-helpers.ts +27 -8
  84. package/packages/bundled-features/src/crypto-shredding/changes.json +18 -0
  85. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +11 -10
  86. package/packages/bundled-features/src/custom-fields/lib/parse-serialized-field.ts +1 -1
  87. package/packages/bundled-features/src/delivery/changes.json +42 -0
  88. package/packages/bundled-features/src/delivery/channel-context.ts +9 -3
  89. package/packages/bundled-features/src/delivery/db/queries/preferences.ts +2 -2
  90. package/packages/bundled-features/src/delivery/delivery-service.ts +35 -5
  91. package/packages/bundled-features/src/delivery/feature.ts +1 -0
  92. package/packages/bundled-features/src/delivery/handlers/preferences.query.ts +20 -1
  93. package/packages/bundled-features/src/delivery/handlers/unsubscribe-page-chrome.query.ts +1 -1
  94. package/packages/bundled-features/src/delivery/index.ts +5 -0
  95. package/packages/bundled-features/src/delivery/system-channel-context.ts +33 -0
  96. package/packages/bundled-features/src/delivery/unsubscribe-link.ts +3 -1
  97. package/packages/bundled-features/src/delivery/unsubscribe-page-strings.ts +1 -0
  98. package/packages/bundled-features/src/delivery/unsubscribe-page.ts +6 -0
  99. package/packages/bundled-features/src/delivery/unsubscribe.ts +36 -15
  100. package/packages/bundled-features/src/document-ingest-foundation/append-missing-extract-event.ts +84 -0
  101. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  102. package/packages/bundled-features/src/document-ingest-foundation/events.ts +15 -0
  103. package/packages/bundled-features/src/document-ingest-foundation/index.ts +11 -0
  104. package/packages/bundled-features/src/feature-toggles/feature.ts +1 -0
  105. package/packages/bundled-features/src/file-foundation/index.ts +1 -0
  106. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +7 -1
  107. package/packages/bundled-features/src/file-provider-s3/env-named-feature.ts +0 -2
  108. package/packages/bundled-features/src/files/README.md +1 -1
  109. package/packages/bundled-features/src/form-draft/changes.json +6 -0
  110. package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +2 -2
  111. package/packages/bundled-features/src/form-draft/decrypt-draft.ts +4 -1
  112. package/packages/bundled-features/src/host-routing/README.md +1 -1
  113. package/packages/bundled-features/src/host-routing/changes.json +12 -0
  114. package/packages/bundled-features/src/host-routing/dispatch.ts +16 -17
  115. package/packages/bundled-features/src/host-routing/options.ts +2 -1
  116. package/packages/bundled-features/src/host-routing/validate-options.ts +5 -0
  117. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +18 -0
  118. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -0
  119. package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +17 -5
  120. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +0 -1
  121. package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +0 -3
  122. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +4 -3
  123. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +32 -16
  124. package/packages/bundled-features/src/jobs/changes.json +13 -0
  125. package/packages/bundled-features/src/jobs/feature.ts +15 -4
  126. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +45 -40
  127. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +63 -59
  128. package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -4
  129. package/packages/bundled-features/src/ledger/web/index.ts +1 -1
  130. package/packages/bundled-features/src/legal-pages/README.md +1 -1
  131. package/packages/bundled-features/src/legal-pages/seed.ts +5 -2
  132. package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
  133. package/packages/bundled-features/src/mail-foundation/feature.ts +5 -3
  134. package/packages/bundled-features/src/mail-transport-smtp/changes.json +6 -0
  135. package/packages/bundled-features/src/mail-transport-smtp/config-keys.ts +1 -1
  136. package/packages/bundled-features/src/mail-transport-smtp/feature.ts +9 -11
  137. package/packages/bundled-features/src/managed-pages/changes.json +6 -0
  138. package/packages/bundled-features/src/managed-pages/constants.ts +1 -0
  139. package/packages/bundled-features/src/managed-pages/feature.ts +3 -1
  140. package/packages/bundled-features/src/managed-pages/index.ts +1 -0
  141. package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +1 -1
  142. package/packages/bundled-features/src/personal-access-tokens/feature.ts +5 -1
  143. package/packages/bundled-features/src/renderer-pdf/changes.json +6 -0
  144. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +27 -10
  145. package/packages/bundled-features/src/secrets/changes.json +6 -0
  146. package/packages/bundled-features/src/secrets/write-gate.ts +9 -2
  147. package/packages/bundled-features/src/sessions/changes.json +7 -0
  148. package/packages/bundled-features/src/sessions/feature.ts +8 -2
  149. package/packages/bundled-features/src/sessions/index.ts +1 -1
  150. package/packages/bundled-features/src/sessions/session-callbacks.ts +1 -9
  151. package/packages/bundled-features/src/shared/decrypt-stored-pii.ts +10 -0
  152. package/packages/bundled-features/src/shared/index.ts +1 -1
  153. package/packages/bundled-features/src/shared/mail-template-body.ts +10 -3
  154. package/packages/bundled-features/src/shared/purge-files-reaching-subject.ts +24 -2
  155. package/packages/bundled-features/src/shared/run-in-sub-transaction.ts +5 -5
  156. package/packages/bundled-features/src/step-dispatcher/changes.json +12 -0
  157. package/packages/bundled-features/src/step-dispatcher/deliver-dispatch-request.ts +17 -3
  158. package/packages/bundled-features/src/step-dispatcher/feature.ts +1 -0
  159. package/packages/bundled-features/src/step-dispatcher/mail-runner.ts +2 -1
  160. package/packages/bundled-features/src/subscription-dodo/changes.json +14 -0
  161. package/packages/bundled-features/src/subscription-dodo/client.ts +45 -0
  162. package/packages/bundled-features/src/subscription-dodo/constants.ts +24 -0
  163. package/packages/bundled-features/src/subscription-dodo/feature.ts +207 -0
  164. package/packages/bundled-features/src/subscription-dodo/index.ts +16 -0
  165. package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +345 -0
  166. package/packages/bundled-features/src/subscription-dodo/runtime.ts +126 -0
  167. package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +262 -0
  168. package/packages/bundled-features/src/subscription-stripe/changes.json +12 -0
  169. package/packages/bundled-features/src/subscription-stripe/feature.ts +1 -0
  170. package/packages/bundled-features/src/subscription-stripe/runtime.ts +7 -6
  171. package/packages/bundled-features/src/tags/feature.ts +1 -0
  172. package/packages/bundled-features/src/template-resolver/api.ts +31 -4
  173. package/packages/bundled-features/src/template-resolver/changes.json +84 -0
  174. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +1 -0
  175. package/packages/bundled-features/src/template-resolver/document-ops.ts +20 -1
  176. package/packages/bundled-features/src/template-resolver/feature.ts +4 -4
  177. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +3 -2
  178. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +27 -5
  179. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +3 -2
  180. package/packages/bundled-features/src/template-resolver/handlers/document-base.ts +3 -2
  181. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +3 -2
  182. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +36 -11
  183. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +18 -19
  184. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +45 -10
  185. package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +1 -1
  186. package/packages/bundled-features/src/template-resolver/handlers/send-test-status.query.ts +22 -19
  187. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-counts.query.ts +9 -5
  188. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-selection.ts +8 -6
  189. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +44 -5
  190. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +37 -26
  191. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +7 -2
  192. package/packages/bundled-features/src/template-resolver/seeding.ts +42 -17
  193. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +74 -12
  194. package/packages/bundled-features/src/template-resolver/template-delivery-policy.ts +0 -11
  195. package/packages/bundled-features/src/template-resolver/template-meta.ts +12 -4
  196. package/packages/bundled-features/src/template-resolver/template-preview.ts +11 -3
  197. package/packages/bundled-features/src/template-resolver/web/constants.ts +7 -5
  198. package/packages/bundled-features/src/template-resolver/web/i18n.ts +6 -0
  199. package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
  200. package/packages/bundled-features/src/template-resolver/web/mail-layout-constants.ts +8 -0
  201. package/packages/bundled-features/src/template-resolver/web/qualified-names.ts +1 -0
  202. package/packages/bundled-features/src/tenant/changes.json +6 -0
  203. package/packages/bundled-features/src/tenant/feature.ts +1 -0
  204. package/packages/bundled-features/src/tenant/handlers/member-directory.query.ts +9 -3
  205. package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +3 -3
  206. package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +7 -4
  207. package/packages/bundled-features/src/tenant-lifecycle/changes.json +12 -0
  208. package/packages/bundled-features/src/tenant-lifecycle/constants.ts +5 -0
  209. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +5 -1
  210. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +35 -1
  211. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  212. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +4 -1
  213. package/packages/bundled-features/src/tier-engine/web/constants.ts +3 -3
  214. package/packages/bundled-features/src/user/feature.ts +1 -0
  215. package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
  216. package/packages/bundled-features/src/user-data-rights/feature.ts +1 -0
  217. package/packages/bundled-features/src/user-data-rights/index.ts +6 -1
  218. package/packages/bundled-features/src/user-data-rights/run-export-jobs.ts +3 -4
  219. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -1
  220. package/packages/bundled-features/src/user-data-rights/web/constants.ts +6 -0
  221. package/packages/bundled-features/src/user-data-rights/web/index.ts +1 -0
  222. package/packages/bundled-features/src/waitlist/changes.json +36 -0
  223. package/packages/bundled-features/src/waitlist/feature.ts +1 -0
  224. package/packages/bundled-features/src/waitlist/handlers/export-purge.write.ts +2 -1
  225. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +28 -9
  226. package/packages/bundled-features/src/waitlist/import.ts +28 -3
  227. package/packages/bundled-features/src/waitlist/legacy-migrate.ts +24 -4
  228. package/packages/bundled-features/src/waitlist/link-on-signup.ts +20 -10
  229. package/packages/bundled-features/src/waitlist/options.ts +5 -1
  230. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +1 -1
  231. package/samples/apps/styleguide/src/app/client.tsx +2 -0
  232. package/samples/apps/styleguide/src/app/seed.ts +6 -4
  233. package/samples/apps/styleguide/src/app/server.ts +4 -1
  234. package/samples/apps/styleguide/src/features/widgets/feature.ts +15 -4
  235. package/samples/apps/styleguide/src/features/widgets/i18n.ts +11 -0
  236. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +24 -0
  237. package/samples/apps/styleguide/src/features/workboard/feature.ts +185 -0
  238. package/samples/apps/styleguide/src/features/workboard/i18n.ts +27 -0
  239. package/samples/apps/styleguide/src/features/workboard/seed.ts +121 -0
  240. package/samples/apps/styleguide/src/features/workboard/web.ts +13 -0
  241. package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +2 -0
  242. package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +9 -0
  243. package/samples/apps/use-all-bundled/bin/main.ts +2 -0
  244. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +5 -0
  245. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +90 -15
  246. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +16 -6
  247. package/samples/apps/use-all-bundled/feature-manifest.json +136 -3
  248. package/samples/apps/use-all-bundled/kumiko/schema.ts +2 -0
  249. package/samples/apps/use-all-bundled/kumiko.config.ts +2 -0
  250. package/samples/apps/use-all-bundled/package.json +2 -1
  251. package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +2 -0
  252. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +2 -0
  253. package/samples/apps/use-all-bundled/src/bundled-lists.ts +15 -0
  254. package/samples/apps/use-all-bundled/src/run-config.ts +9 -3
  255. package/samples/recipes/auth-mfa/README.md +5 -0
  256. package/samples/recipes/auth-mfa/package.json +5 -2
  257. package/samples/recipes/auth-mfa/src/web/confirmation-code-step.tsx +49 -0
  258. package/samples/recipes/auth-mfa/tsconfig.json +8 -0
  259. package/samples/recipes/env-composition/README.md +3 -1
  260. package/samples/recipes/env-composition/src/env.ts +2 -0
  261. package/samples/recipes/golden-path/src/feature.ts +2 -1
  262. package/samples/recipes/legal-pages/README.md +1 -1
  263. package/samples/recipes/lifecycle-hooks/README.md +4 -0
  264. package/samples/recipes/lifecycle-hooks/src/feature.ts +26 -1
  265. package/samples/recipes/list-table-options/README.md +50 -0
  266. package/samples/recipes/list-table-options/package.json +11 -0
  267. package/samples/recipes/list-table-options/src/feature.ts +221 -0
  268. package/samples/recipes/pdf-audit-export/src/feature.ts +2 -1
  269. package/samples/recipes/rate-limiting/README.md +18 -0
  270. package/samples/recipes/rate-limiting/src/feature.ts +12 -0
  271. package/samples/recipes/state-machine/src/events.ts +3 -2
  272. package/samples/recipes/tenant-isolation/README.md +1 -0
  273. package/samples/recipes/tenant-isolation/src/feature.ts +36 -0
  274. package/packages/bundled-features/src/billing-foundation/consumer-protection/stored-user-email.ts +0 -12
@@ -53,6 +53,12 @@ export class FileRefTooLargeError extends Error {
53
53
  }
54
54
  }
55
55
 
56
+ export class FileStorageUnavailableError extends Error {
57
+ constructor() {
58
+ super("readFileRefContent: no file storage is available for the file's tenant");
59
+ }
60
+ }
61
+
56
62
  // fileName of the row is never used: it is personal data and may be a PII ciphertext.
57
63
  export async function readFileRefContent(args: {
58
64
  readonly db: DbRunner | TenantDb;
@@ -82,7 +88,7 @@ export async function readFileRefContent(args: {
82
88
  }
83
89
  const files = args.filesForTenant(row.tenantId);
84
90
  if (files === undefined) {
85
- throw new Error("readFileRefContent: no file storage is available for the file's tenant");
91
+ throw new FileStorageUnavailableError();
86
92
  }
87
93
  const content = await files.refFromFileRef(row).read();
88
94
  return { content, contentType: row.mimeType, tenantId: row.tenantId, size };
@@ -13,8 +13,6 @@ export type NamedS3EnvProviderOptions = {
13
13
  readonly name: string;
14
14
  /** Env prefix of the credential set, e.g. `RELEASES_S3_`. */
15
15
  readonly envPrefix: string;
16
- /** Tolerate missing credentials at boot; the provider then fails on its first file operation. */
17
- readonly lazy?: boolean;
18
16
  };
19
17
 
20
18
  /** An additional S3 provider next to `"s3-env"`, reading its credentials from `<envPrefix>BUCKET` etc. */
@@ -15,7 +15,7 @@ bewirtschaftet. Das öffnet die Tür für Cross-Feature-Hooks:
15
15
  ## Was es NICHT macht
16
16
 
17
17
  - **Keine Upload-/Download-Routes** — die bleiben in
18
- `framework/src/api/server.ts` via `options.files`-Bootstrap.
18
+ `framework/src/api/server-routes.ts` (`mountFileRoutes`).
19
19
  Multipart-Form-Body und Binary-Streaming passen nicht ins Write/Query-
20
20
  Handler-Pattern; ein Refactor zu `r.httpRoute` wäre orthogonal zu
21
21
  diesem Sprint.
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "Encrypted drafts without a PII key service raise an error",
6
+ "detail": "Reading a ciphertext draft with no PII subject key service configured throws instead of returning an empty draft, so the stale-draft cleanup defers it and no longer releases its file refs as if it were empty."
7
+ },
2
8
  {
3
9
  "version": "0.379.0",
4
10
  "type": "breaking",
@@ -1,5 +1,5 @@
1
1
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
- import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/db";
2
+ import { toPostgresArrayLiteral, unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
5
5
  import { readStoredDraft } from "../../decrypt-draft.ts";
@@ -48,7 +48,7 @@ export async function selectStaleDraftsBatch(
48
48
  AND NOT ("id"::text = ANY($3::text[]))
49
49
  ORDER BY COALESCE("modified_at", "inserted_at") ASC
50
50
  LIMIT $2`,
51
- [olderThanDays, batchSize, [...excludeIds]],
51
+ [olderThanDays, batchSize, toPostgresArrayLiteral([...excludeIds])],
52
52
  );
53
53
  return Promise.all(
54
54
  rows.map(async (row) => {
@@ -4,6 +4,7 @@ import {
4
4
  decryptPiiFieldValues,
5
5
  isPiiCiphertext,
6
6
  } from "@cosmicdrift/kumiko-framework/crypto";
7
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
7
8
  import { type FormDraftBlob, formDraftBlobSchema } from "./schemas.ts";
8
9
 
9
10
  // Raw reads (fetchOne/selectMany/SQL) return the stored column, which is
@@ -13,7 +14,9 @@ import { type FormDraftBlob, formDraftBlobSchema } from "./schemas.ts";
13
14
  export async function readStoredDraft(stored: unknown): Promise<FormDraftBlob | null> {
14
15
  let plain = stored;
15
16
  const kms = configuredPiiSubjectKms();
16
- if (isPiiCiphertext(stored) && kms) {
17
+ if (isPiiCiphertext(stored)) {
18
+ // A silent null would make the stale-draft cleanup treat the draft as empty and leak its file refs.
19
+ if (!kms) throw new InternalError({ message: "form-draft: pii kms not configured" });
17
20
  const out = await decryptPiiFieldValues({ draft: stored }, ["draft"], kms, {
18
21
  requestId: requestContext.get()?.requestId ?? "form-draft:read",
19
22
  });
@@ -92,5 +92,5 @@ allowlists stay in the app: wrap the dispatch function and fall through to it.
92
92
 
93
93
  - `tenantBy: (host, db) => TenantId | null` resolves every host that is neither the apex nor a fixed host (profile subdomains, custom domains). The tenant must be enabled and active; null means 404 for pages and `tenant_required` for the API. `createHostTenantIdResolver(options, db)` exposes the same mapping for managed-pages' `resolveApexTenant`.
94
94
  - `tenantPathPrefix` is optional in host and resolver mode and serves `<prefix>/<key>` on the apex. The API half still takes the apex anchor there; an app that needs a path-named tenant on API calls resolves it itself.
95
- - `redirects: [{ from, to, host?, status? }]` run before host classification. `to` is configured, never derived from the request.
95
+ - `redirects: [{ from, to, host?, status? }]` run before host classification inside hostDispatch, i.e. only for requests that reach the HTML fallback (no static file, no API route); a `from` that matches a static file or `/api/...` never fires. `to` is configured, never derived from the request.
96
96
  - `apexAnonymousQueryAllowlist: { "example.com": ["feature:query:x"] }` limits the anonymous apex anchor to the listed query types on that host, for `/api/query` and `/api/stream`; other paths keep the anchor. `example.com` and `www.example.com` share one entry (a host without its own uses the other's). Combined with `tenantBy: "path"` it throws at boot, because the client names its tenant there (X-Tenant wins over the resolver) and would bypass the allowlist.
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.389.0",
4
+ "type": "fix",
5
+ "title": "Redirects from /api paths fail at boot",
6
+ "detail": "Redirects run inside hostDispatch after static files and API routes, so a from of /api or /api/... was silently dead config. Boot validation now throws; the options and README already describe the HTML-fallback scope."
7
+ },
8
+ {
9
+ "version": "0.388.0",
10
+ "type": "fix",
11
+ "title": "Redirects documented as hostDispatch-only",
12
+ "detail": "The redirects option and README state that redirects run inside hostDispatch, so only for requests that reach the HTML fallback; a from matching a static file or an API route never fires. The shared existence lookup of the host and resolver modes is one helper."
13
+ },
2
14
  {
3
15
  "version": "0.376.0",
4
16
  "type": "improvement",
@@ -30,33 +30,32 @@ export type HostRoutingDispatchFn = (
30
30
 
31
31
  const tenantKeyExistsResultSchema = z.object({ exists: z.boolean() });
32
32
 
33
- async function isTenantKeyKnown(systemQuery: PageHeadSystemQuery, key: string): Promise<boolean> {
33
+ async function existsViaSystemQuery(
34
+ systemQuery: PageHeadSystemQuery,
35
+ queryType: string,
36
+ payload: Record<string, string>,
37
+ ): Promise<boolean> {
34
38
  let result: unknown;
35
39
  try {
36
- result = await systemQuery(HostRoutingQueries.tenantKeyExists, { key }, SYSTEM_TENANT_ID);
40
+ result = await systemQuery(queryType, payload, SYSTEM_TENANT_ID);
37
41
  } catch (error) {
38
42
  if (error instanceof ValidationError) return false;
39
- // Over the rate limit every key looks the same: serve the tenant shell,
40
- // which leaks nothing; the API behind it still resolves the tenant itself.
43
+ // Over the rate limit every key looks the same: serve the shell, which
44
+ // leaks nothing. The API resolver is authoritative and answers
45
+ // tenant_required for unknown hosts, while a 404 would hit visitors
46
+ // behind a shared NAT under load.
41
47
  if (error instanceof RateLimitError) return true;
42
48
  throw error;
43
49
  }
44
50
  return tenantKeyExistsResultSchema.parse(result).exists;
45
51
  }
46
52
 
47
- async function isHostKnown(systemQuery: PageHeadSystemQuery, host: string): Promise<boolean> {
48
- let result: unknown;
49
- try {
50
- result = await systemQuery(HostRoutingQueries.hostTenantExists, { host }, SYSTEM_TENANT_ID);
51
- } catch (error) {
52
- if (error instanceof ValidationError) return false;
53
- // The shell is harmless: the API resolver is authoritative and answers
54
- // tenant_required for unknown hosts, while a 404 would hit visitors behind
55
- // a shared NAT under load.
56
- if (error instanceof RateLimitError) return true;
57
- throw error;
58
- }
59
- return tenantKeyExistsResultSchema.parse(result).exists;
53
+ function isTenantKeyKnown(systemQuery: PageHeadSystemQuery, key: string): Promise<boolean> {
54
+ return existsViaSystemQuery(systemQuery, HostRoutingQueries.tenantKeyExists, { key });
55
+ }
56
+
57
+ function isHostKnown(systemQuery: PageHeadSystemQuery, host: string): Promise<boolean> {
58
+ return existsViaSystemQuery(systemQuery, HostRoutingQueries.hostTenantExists, { host });
60
59
  }
61
60
 
62
61
  function serve(
@@ -81,6 +81,7 @@ export type HostRoutingOptions = HostRoutingTenantMode & {
81
81
  // Not allowed with tenantBy "path" (boot error): there the client's X-Tenant
82
82
  // wins over the resolver, so the allowlist would be ineffective.
83
83
  readonly apexAnonymousQueryAllowlist?: Readonly<Record<string, readonly string[]>>;
84
- // Checked first, before any host classification.
84
+ // Checked first within hostDispatch, i.e. only for requests that reach the HTML
85
+ // fallback (no static file, no API route): a `from` matching either never fires.
85
86
  readonly redirects?: readonly HostRoutingRedirect[];
86
87
  };
@@ -16,6 +16,11 @@ function validateRedirects(options: HostRoutingOptions): void {
16
16
  if (!REDIRECT_PATH_PATTERN.test(rule.from)) {
17
17
  throw new Error(`host-routing: redirect from must be a path like "/old", got "${rule.from}"`);
18
18
  }
19
+ if (rule.from === "/api" || rule.from.startsWith("/api/")) {
20
+ throw new Error(
21
+ `host-routing: redirect from must not be an /api path, it would never fire, got "${rule.from}"`,
22
+ );
23
+ }
19
24
  if (!REDIRECT_TARGET_PATTERN.test(rule.to)) {
20
25
  throw new Error(
21
26
  `host-routing: redirect to must be a same-origin path or an https URL without query, got "${rule.to}"`,
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "Undecryptable mail account no longer stops polling of other accounts",
6
+ "detail": "Any decryption failure of an account address (corrupt envelope, missing key, AAD mismatch) is logged by error class and the account is skipped; the other accounts keep polling."
7
+ },
8
+ {
9
+ "version": "0.388.0",
10
+ "type": "fix",
11
+ "title": "Rename audit reason comes from the caller role",
12
+ "detail": "update-account records tenant_admin or system_admin from the caller's roles and honors a client-supplied reason only for the system identity, so a form call can no longer write system reasons such as watch_supervisor. The rename form no longer carries a reason field and the reason payload field is optional."
13
+ },
14
+ {
15
+ "version": "0.388.0",
16
+ "type": "fix",
17
+ "title": "Seen-message lookup handles listings of any size",
18
+ "detail": "filterUnknownMessageIds queries the already seen provider message ids in chunks of 1000, so a full-mailbox listing no longer exceeds the Postgres bind parameter limit and aborts the account's poll."
19
+ },
2
20
  {
3
21
  "version": "0.378.0",
4
22
  "type": "fix",
@@ -103,6 +103,7 @@ export function createInboundMailFoundationFeature() {
103
103
  return defineFeature(
104
104
  INBOUND_MAIL_FOUNDATION_FEATURE,
105
105
  (r) => {
106
+ r.adoptsBundledLists();
106
107
  r.describe(
107
108
  "Plugin host for inbound e-mail (inbox federation) — provider plugins (inbound-provider-imap, later M365 Graph / Gmail REST) register at the `inboundMailProvider` extension point with verify/fetch and optional oauth/watch (live push). The foundation owns three event-sourced streams (mail-account lifecycle, inbound-message with exactly-once ingest via deterministic aggregate ids, mail-thread rollup) and their projections `read_mail_accounts`/`read_inbound_messages`/`read_mail_threads`; PII fields are envelope-encrypted per tenant subject key before every append (crypto-shredding on tenant destroy). Wire `createInboundMailConnectRoutes` (OAuth connect + anonymous callback outside /api) and `createInboundMailSupervisor` (IDLE watch with backoff + reconciliation poll) in bin/server.ts. Consume `inbound-mail-foundation:event:inbound-message-received` from app features to attach business processes.",
108
109
  );
@@ -3,7 +3,12 @@
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
5
 
6
- import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
7
+ import {
8
+ type SessionUser,
9
+ SYSTEM_USER_ID,
10
+ type WriteHandlerDef,
11
+ } from "@cosmicdrift/kumiko-framework/engine";
7
12
  import { failNotFound, failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
8
13
  import * as z from "zod";
9
14
  import { InboundMailAccountStatuses } from "../constants.ts";
@@ -27,12 +32,19 @@ export const updateAccountSchema = z.object({
27
32
  /** Ops-Signal ("idle", "watching", "backoff:3", ...). */
28
33
  watchState: z.string().max(100).optional(),
29
34
  displayName: z.string().max(200).optional(),
30
- /** Audit reason for metadata.headers ("watch_supervisor",
31
- * "oauth_refresh", "tenant_admin"). */
32
- reason: z.string().min(1).max(100),
35
+ /** Audit reason for metadata.headers, honored for the system identity only
36
+ * ("watch_supervisor", "oauth_refresh"); user callers get a role-derived reason. */
37
+ reason: z.string().min(1).max(100).optional(),
33
38
  });
34
39
  type UpdateAccountPayload = z.infer<typeof updateAccountSchema>;
35
40
 
41
+ const SYSTEM_FALLBACK_REASON = "system";
42
+
43
+ function auditReasonFor(user: SessionUser, requestedReason: string | undefined): string {
44
+ if (user.id === SYSTEM_USER_ID) return requestedReason ?? SYSTEM_FALLBACK_REASON;
45
+ return user.roles.includes(ROLES.SystemAdmin) ? "system_admin" : "tenant_admin";
46
+ }
47
+
36
48
  export const updateAccountHandler: WriteHandlerDef = {
37
49
  name: "update-account",
38
50
  schema: updateAccountSchema,
@@ -65,7 +77,7 @@ export const updateAccountHandler: WriteHandlerDef = {
65
77
  };
66
78
  const headers: MailAccountEventHeaders = {
67
79
  providerName: current.provider,
68
- reason: payload.reason,
80
+ reason: auditReasonFor(event.user, payload.reason),
69
81
  };
70
82
  await ctx.unsafeAppendEvent({
71
83
  aggregateId: payload.accountId,
@@ -38,5 +38,4 @@ export const INBOUND_MAIL_FOUNDATION_I18N: Readonly<Record<string, { readonly en
38
38
  },
39
39
  [formFieldKey("accountId")]: { en: "Mailbox" },
40
40
  [formFieldKey("displayName")]: { en: "Name" },
41
- [formFieldKey("reason")]: { en: "Reason" },
42
41
  };
@@ -77,8 +77,6 @@ export const mailAccountListScreen: ProjectionListScreenDefinition = {
77
77
  dormant: true,
78
78
  };
79
79
 
80
- // The reason is the audit label update-account requires; it is not in the
81
- // layout, so the default is what gets written.
82
80
  export const mailAccountEditScreen: ActionFormScreenDefinition = {
83
81
  id: MAIL_ACCOUNT_EDIT_SCREEN_ID,
84
82
  type: "actionForm",
@@ -87,7 +85,6 @@ export const mailAccountEditScreen: ActionFormScreenDefinition = {
87
85
  fields: {
88
86
  accountId: { type: "text", required: true },
89
87
  displayName: { type: "text", required: true, maxLength: 200 },
90
- reason: { type: "text", default: "tenant_admin" },
91
88
  },
92
89
  layout: {
93
90
  sections: [{ fields: [{ field: "accountId", readOnly: true }, "displayName"] }],
@@ -21,8 +21,9 @@ import { INBOUND_MAIL_PROVIDER_EXTENSION, type InboundMailAccountStatus } from "
21
21
  * config — tenant config reads (host/port/... of the plugins)
22
22
  * registry — extension lookup in the factory (not plugin-internal)
23
23
  * secrets — tenant secret reads (IMAP password, OAuth refresh token)
24
- * auditUserId — audit identity for secret reads. Handler path: the dispatcher
25
- * sets the caller user ID; worker path: system identity.
24
+ * auditUserId — audit identity for secret reads. Handler path: unset,
25
+ * requireSecretsContext falls back to ctx.user.id; worker/provider
26
+ * path: system identity set by the resolver.
26
27
  * metricsFor — metrics handle of a feature by name, same behavior as
27
28
  * HandlerContext.metricsFor: unregistered metrics are a silent
28
29
  * no-op instead of a throw.
@@ -172,7 +173,7 @@ export type InboundMailProviderPlugin = {
172
173
  * for password accounts and providers without an oauth flow. */
173
174
  readonly accessToken?: string;
174
175
  /** Batch lookup of the provider message ids not yet ingested for this
175
- * account (committed seen anchors, one query per call). Call it after
176
+ * account (committed seen anchors; lists of any size are fine, queried in chunks). Call it after
176
177
  * the listing/delta page and download MIME only for the returned ids.
177
178
  * Ingest dedup stays the correctness anchor; this only saves downloads. */
178
179
  readonly filterUnknownMessageIds?: (
@@ -88,6 +88,8 @@ const DEFAULT_WATCH_LEASE_TTL_SECONDS = 90;
88
88
  * can add further scopes later without a schema change. */
89
89
  const CURSOR_SCOPE = "default";
90
90
 
91
+ const SEEN_LOOKUP_CHUNK_SIZE = 1000;
92
+
91
93
  export type InboundMailSupervisorDeps = {
92
94
  /** Slim context for provider calls (`registry` is required; `secrets` is
93
95
  * used for provider credential reads). */
@@ -223,11 +225,19 @@ export function createInboundMailSupervisor(
223
225
  const piiKms = configuredPiiSubjectKms();
224
226
  const records: MailAccountRecord[] = [];
225
227
  for (const row of rows) {
226
- const decrypted = piiKms
227
- ? await decryptPiiFieldValues(row, MAIL_ACCOUNT_PII_FIELDS, piiKms, {
228
- requestId: "inbound-mail-foundation:supervisor:list-accounts",
229
- })
230
- : row;
228
+ let decrypted: Record<string, unknown>;
229
+ try {
230
+ decrypted = piiKms
231
+ ? await decryptPiiFieldValues(row, MAIL_ACCOUNT_PII_FIELDS, piiKms, {
232
+ requestId: "inbound-mail-foundation:supervisor:list-accounts",
233
+ })
234
+ : row;
235
+ } catch (error) {
236
+ // Class name only: the message may carry ciphertext or key details.
237
+ const errorName = error instanceof Error ? error.name : "unknown error";
238
+ log(`inbound-mail: account ${row.id} skipped — address did not decrypt (${errorName})`);
239
+ continue;
240
+ }
231
241
  const decryptedAddress = decrypted["address"];
232
242
  // Falling back to row.address would hand the ciphertext to a provider.
233
243
  // One undecryptable account must not stop polling of every other tenant's accounts.
@@ -400,17 +410,23 @@ export function createInboundMailSupervisor(
400
410
  function unknownMessageIdsFilterFor(account: MailAccountRecord) {
401
411
  return async (providerMessageIds: readonly string[]): Promise<ReadonlySet<string>> => {
402
412
  if (providerMessageIds.length === 0) return new Set();
403
- const seen = await selectMany<{ providerMessageId: string }>(
404
- deps.db,
405
- seenMessageTable,
406
- {
407
- tenantId: account.tenantId,
408
- accountId: account.id,
409
- providerMessageId: { in: providerMessageIds },
410
- },
411
- { fields: ["providerMessageId"] },
412
- );
413
- const seenIds = new Set(seen.map((row) => row.providerMessageId));
413
+ // One bind parameter per id: a full-mailbox listing would exceed Postgres' 65535 limit.
414
+ const seenIds = new Set<string>();
415
+ for (let start = 0; start < providerMessageIds.length; start += SEEN_LOOKUP_CHUNK_SIZE) {
416
+ const seen = await selectMany<{ providerMessageId: string }>(
417
+ deps.db,
418
+ seenMessageTable,
419
+ {
420
+ tenantId: account.tenantId,
421
+ accountId: account.id,
422
+ providerMessageId: {
423
+ in: providerMessageIds.slice(start, start + SEEN_LOOKUP_CHUNK_SIZE),
424
+ },
425
+ },
426
+ { fields: ["providerMessageId"] },
427
+ );
428
+ for (const row of seen) seenIds.add(row.providerMessageId);
429
+ }
414
430
  return new Set(providerMessageIds.filter((id) => !seenIds.has(id)));
415
431
  };
416
432
  }
@@ -1,4 +1,17 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "improvement",
5
+ "title": "readRoles for tenant job status",
6
+ "detail": "`createJobsFeature({ readRoles: [\"PropertyManager\"] })` lets those app roles call `jobs:query:failures` and `jobs:query:tenant-runs` for their own tenant. They are added to the default `User`, `Editor` and admin roles, which stay. Before, a member who only holds app roles got access_denied.",
7
+ "migration": "Optional. Apps whose members hold only their own roles pass `createJobsFeature({ readRoles: [...] })`."
8
+ },
9
+ {
10
+ "version": "0.388.0",
11
+ "type": "fix",
12
+ "title": "Deduplicated job add keeps the finished tenant run row",
13
+ "detail": "JobQueuedMeta carries the BullMQ creation time of the job. The queued hook only removes finished rows queued before that time (leftovers of a Redis reset), so re-announcing an already finished job no longer turns its row back into queued."
14
+ },
2
15
  {
3
16
  "version": "0.384.0",
4
17
  "type": "improvement",
@@ -23,8 +23,8 @@ import {
23
23
  createStaleRunSweepJob,
24
24
  DEFAULT_JOB_RUN_STALE_TIMEOUT_HOURS,
25
25
  } from "./handlers/stale-run-sweep.job.ts";
26
- import { tenantFailuresQuery } from "./handlers/tenant-failures.query.ts";
27
- import { tenantRunsQuery } from "./handlers/tenant-runs.query.ts";
26
+ import { createTenantFailuresQuery } from "./handlers/tenant-failures.query.ts";
27
+ import { createTenantRunsQuery } from "./handlers/tenant-runs.query.ts";
28
28
  import { triggerWrite } from "./handlers/trigger.write.ts";
29
29
  import { jobRunLogsTableMeta, jobRunsTableMeta } from "./job-run-table.ts";
30
30
  import {
@@ -49,12 +49,23 @@ export type JobsFeatureOptions = {
49
49
  // it). Must clear any legitimately long-running job (reindexEntity,
50
50
  // projectionRebuild) — set high, not tight.
51
51
  readonly staleRunTimeoutHours?: number;
52
+ // Additional app roles that may read their own tenant's job status and
53
+ // failures (jobs:query:failures, jobs:query:tenant-runs). Added to the default
54
+ // User/Editor/admin roles, which stay; the scope stays the caller's own tenant.
55
+ readonly readRoles?: readonly string[];
52
56
  };
53
57
 
54
58
  export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefinition {
55
59
  const retentionDays = options.retentionDays ?? DEFAULT_JOB_RUN_RETENTION_DAYS;
56
60
  const staleRunTimeoutHours = options.staleRunTimeoutHours ?? DEFAULT_JOB_RUN_STALE_TIMEOUT_HOURS;
61
+ const readRoles = options.readRoles ?? [];
62
+ if (readRoles.includes("anonymous")) {
63
+ throw new Error(
64
+ `createJobsFeature: readRoles must not contain "anonymous" — job status and failures are only readable by members of the tenant.`,
65
+ );
66
+ }
57
67
  return defineFeature("jobs", (r) => {
68
+ r.adoptsBundledLists();
58
69
  r.describe(
59
70
  "Persistence and operator tooling for background jobs registered via `r.job(...)`. Every job execution writes directly into `store_job_runs` (current status + duration) and `store_job_run_logs` (per-line log rows) from the BullMQ callbacks — no event stream in between (#2243). A daily `retention-cleanup` job deletes runs (and their logs) older than `retentionDays`; an hourly `stale-run-sweep` job marks runs stuck at status `running` past `staleRunTimeoutHours` as `failed` (#2246 — a crashed worker never fires the completion callback, so nothing else ever revisits the row). Exposes `jobs:write:trigger` (manual run) and `jobs:write:retry` (operator retry of a failed run), plus `jobs:query:list`, `jobs:query:details`, and `jobs:query:catalog` (manual jobs) for the operator UI. A job that declares `tenantVisibleFailure` also records its last failed attempt per tenant and subject in `store_tenant_job_failures`, which the tenant itself reads through `jobs:query:failures` — a translation key only, never the provider's message (fw#3079). A job that declares `tenantVisibleRun` also keeps its run state per tenant and subject in `store_tenant_job_runs` (queued, running, plus the latest completed or failed run, no payload, error text or logs), which the tenant reads through `jobs:query:tenant-runs` (fw#3616); the stale-run sweep fails its stuck `running` rows and drops `queued` rows older than `retentionDays`.",
60
71
  );
@@ -137,8 +148,8 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
137
148
  list: r.queryHandler(listQuery),
138
149
  detail: r.queryHandler(detailQuery),
139
150
  catalog: r.queryHandler(catalogQuery),
140
- failures: r.queryHandler(tenantFailuresQuery),
141
- tenantRuns: r.queryHandler(tenantRunsQuery),
151
+ failures: r.queryHandler(createTenantFailuresQuery(readRoles)),
152
+ tenantRuns: r.queryHandler(createTenantRunsQuery(readRoles)),
142
153
  consumers: r.queryHandler(consumersQuery),
143
154
  consumerDetails: r.queryHandler(consumerDetailQuery),
144
155
  };
@@ -30,44 +30,49 @@ function parseSubject(stored: string | null): Record<string, unknown> | null {
30
30
  return Object.fromEntries(parsed.filter(isSubjectEntry));
31
31
  }
32
32
 
33
- export const tenantFailuresQuery = defineQueryHandler({
34
- name: "failures",
35
- description:
36
- "Lists the calling tenant's own failed jobs — one record per job and subject, newest first, each carrying a translation key for the reason, never the provider's own error message; the optional subject filter matches exactly: all declared subjectFields with the same JSON types as the job payload; use it to tell a tenant that their asynchronous job failed instead of leaving the screen waiting.",
37
- schema: z.object({
38
- jobName: z.string().optional(),
39
- subject: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
40
- limit: z.number().int().min(1).max(200).optional(),
41
- }),
42
- // Every membership rank: a failure record carries a job name and a
43
- // translation key, nothing a team member of the tenant may not see.
44
- access: { roles: access.roles("User", "Editor", ...access.admin) },
45
- handler: async (query, ctx) => {
46
- if (!ctx.systemDb) {
47
- throw new InternalError({
48
- message: "jobs:query:failures requires ctx.systemDb (feature must declare r.systemScope())",
33
+ export function createTenantFailuresQuery(readRoles: readonly string[]) {
34
+ return defineQueryHandler({
35
+ name: "failures",
36
+ description:
37
+ "Lists the calling tenant's own failed jobs — one record per job and subject, newest first, each carrying a translation key for the reason, never the provider's own error message; the optional subject filter matches exactly: all declared subjectFields with the same JSON types as the job payload; use it to tell a tenant that their asynchronous job failed instead of leaving the screen waiting.",
38
+ schema: z.object({
39
+ jobName: z.string().optional(),
40
+ subject: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
41
+ limit: z.number().int().min(1).max(200).optional(),
42
+ }),
43
+ // Every membership rank: a failure record carries a job name and a
44
+ // translation key, nothing a team member of the tenant may not see.
45
+ access: {
46
+ roles: [...new Set([...access.roles("User", "Editor", ...access.admin), ...readRoles])],
47
+ },
48
+ handler: async (query, ctx) => {
49
+ if (!ctx.systemDb) {
50
+ throw new InternalError({
51
+ message:
52
+ "jobs:query:failures requires ctx.systemDb (feature must declare r.systemScope())",
53
+ });
54
+ }
55
+ // assertTenantMatch is a self-check on the caller and returns the same
56
+ // unfiltered system-mode db (tenant-db.ts) — the explicit tenantId below
57
+ // is the filter, assertRowsTenant the second net. There is no
58
+ // cross-tenant mode here: SystemAdmin reads runs via jobs:query:list.
59
+ const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
60
+ const where: Record<string, unknown> = { tenantId: [query.user.tenantId] };
61
+ if (query.payload.jobName) where["jobName"] = query.payload.jobName;
62
+ if (query.payload.subject) where["subject"] = serializeJobSubject(query.payload.subject);
63
+ const rows = await selectMany<TenantJobFailureRow>(db, tenantJobFailuresTable, where, {
64
+ orderBy: { col: "failedAt", direction: "desc" },
65
+ limit: query.payload.limit ?? DEFAULT_LIMIT,
49
66
  });
50
- }
51
- // assertTenantMatch is a self-check on the caller and returns the same
52
- // unfiltered system-mode db (tenant-db.ts) — the explicit tenantId below
53
- // is the filter, assertRowsTenant the second net. There is no
54
- // cross-tenant mode here: SystemAdmin reads runs via jobs:query:list.
55
- const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
56
- const where: Record<string, unknown> = { tenantId: [query.user.tenantId] };
57
- if (query.payload.jobName) where["jobName"] = query.payload.jobName;
58
- if (query.payload.subject) where["subject"] = serializeJobSubject(query.payload.subject);
59
- const rows = await selectMany<TenantJobFailureRow>(db, tenantJobFailuresTable, where, {
60
- orderBy: { col: "failedAt", direction: "desc" },
61
- limit: query.payload.limit ?? DEFAULT_LIMIT,
62
- });
63
- return {
64
- rows: ctx.systemDb.assertRowsTenant(rows, "tenantId").map((row) => ({
65
- jobName: row.jobName,
66
- subject: parseSubject(row.subject),
67
- messageKey: row.messageKey,
68
- failedAt: row.failedAt,
69
- })),
70
- nextCursor: null,
71
- };
72
- },
73
- });
67
+ return {
68
+ rows: ctx.systemDb.assertRowsTenant(rows, "tenantId").map((row) => ({
69
+ jobName: row.jobName,
70
+ subject: parseSubject(row.subject),
71
+ messageKey: row.messageKey,
72
+ failedAt: row.failedAt,
73
+ })),
74
+ nextCursor: null,
75
+ };
76
+ },
77
+ });
78
+ }