@cosmicdrift/kumiko-bundled-features 0.333.0 → 0.335.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 (277) 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/audit/handlers/list.query.js +4 -3
  12. package/dist/auth-email-password/handlers/change-password.write.js +2 -0
  13. package/dist/auth-email-password/handlers/invite-create.write.js +12 -2
  14. package/dist/auth-email-password/handlers/login.write.js +2 -0
  15. package/dist/auth-email-password/handlers/self-registration-status.query.js +1 -1
  16. package/dist/auth-email-password/i18n.js +2 -0
  17. package/dist/auth-email-password/web/auth-client.d.ts +1 -0
  18. package/dist/auth-email-password/web/auth-client.js +1 -1
  19. package/dist/auth-email-password/web/auth-gate.js +6 -8
  20. package/dist/auth-email-password/web/index.d.ts +1 -1
  21. package/dist/auth-email-password/web/index.js +1 -1
  22. package/dist/auth-email-password/web/invite-accept-screen.js +16 -12
  23. package/dist/auth-email-password/web/login-screen.js +1 -1
  24. package/dist/auth-email-password/web/session-bootstrap-error.d.ts +5 -1
  25. package/dist/auth-email-password/web/session-bootstrap-error.js +2 -2
  26. package/dist/auth-email-password/web/session.d.ts +4 -0
  27. package/dist/auth-email-password/web/session.js +15 -0
  28. package/dist/auth-mfa/feature.js +3 -2
  29. package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +2 -2
  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/schema/user-mfa.d.ts +6 -0
  35. package/dist/auth-mfa/web/mfa-client.d.ts +1 -1
  36. package/dist/auth-mfa/web/mfa-client.js +4 -10
  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/entities.d.ts +8 -0
  41. package/dist/billing-foundation/handlers/switch-plan.write.js +5 -8
  42. package/dist/billing-foundation/projection.d.ts +8 -0
  43. package/dist/cap-counter/book-cap-usage.js +15 -5
  44. package/dist/cap-counter/enforce-cap.js +9 -3
  45. package/dist/cap-counter/entity.d.ts +1 -0
  46. package/dist/cap-counter/feature.js +3 -2
  47. package/dist/cap-counter/i18n.d.ts +1 -0
  48. package/dist/cap-counter/i18n.js +6 -5
  49. package/dist/cap-overview/handlers/caps-usage.query.js +9 -16
  50. package/dist/cap-overview/handlers/tenant-caps-list.query.js +41 -15
  51. package/dist/cap-overview/i18n.js +5 -0
  52. package/dist/cap-overview/web/cap-cards-panel.js +4 -1
  53. package/dist/cap-overview/web/cap-usage-bar.js +1 -1
  54. package/dist/channel-email/email-channel.js +12 -1
  55. package/dist/channel-in-app/tables.js +2 -6
  56. package/dist/compliance-profiles/handlers/sub-processors.query.js +1 -1
  57. package/dist/compliance-profiles/schema/profile-selection.d.ts +2 -2
  58. package/dist/config/db/queries/resolver.js +3 -1
  59. package/dist/config/handlers/set.write.js +8 -4
  60. package/dist/config/table.d.ts +6 -0
  61. package/dist/config/web/i18n.js +13 -4
  62. package/dist/config/write-helpers.d.ts +1 -1
  63. package/dist/config/write-helpers.js +11 -5
  64. package/dist/crypto-shredding/handlers/forget-subject.write.js +15 -8
  65. package/dist/custom-fields/constants.js +1 -4
  66. package/dist/custom-fields/entity.d.ts +4 -0
  67. package/dist/data-retention/resolve-for-tenant.d.ts +2 -3
  68. package/dist/data-retention/run-retention-cleanup.js +100 -44
  69. package/dist/data-retention/schema/tenant-retention-override.d.ts +4 -0
  70. package/dist/delivery/tables.d.ts +6 -0
  71. package/dist/delivery/types.d.ts +2 -1
  72. package/dist/delivery/types.js +7 -0
  73. package/dist/delivery/unsubscribe.js +5 -11
  74. package/dist/derivatives-sharp/render.js +27 -9
  75. package/dist/document-ingest-foundation/entity.d.ts +4 -0
  76. package/dist/document-ingest-foundation/feature.js +5 -5
  77. package/dist/document-ingest-foundation/providers.d.ts +1 -0
  78. package/dist/document-ingest-foundation/providers.js +6 -0
  79. package/dist/document-ingest-foundation/write-document-extract.d.ts +2 -2
  80. package/dist/document-ingest-foundation/write-document-extract.js +10 -0
  81. package/dist/feature-toggles/global-feature-state-table.js +2 -7
  82. package/dist/feature-toggles/toggle-sync-signal.d.ts +2 -1
  83. package/dist/feature-toggles/toggle-sync-signal.js +3 -2
  84. package/dist/file-provider-s3/feature.js +1 -1
  85. package/dist/files-tenant-data/index.js +3 -2
  86. package/dist/folders/entity.d.ts +8 -0
  87. package/dist/folders/handlers/clear-folder.write.js +4 -11
  88. package/dist/folders/handlers/set-folder.write.js +4 -10
  89. package/dist/form-draft/entity.d.ts +2 -0
  90. package/dist/form-draft/feature.js +3 -2
  91. package/dist/form-draft/handlers/discard.write.js +7 -8
  92. package/dist/form-draft/handlers/get.query.js +7 -1
  93. package/dist/foundation-shared/host-allowlist.d.ts +4 -0
  94. package/dist/foundation-shared/host-allowlist.js +14 -0
  95. package/dist/foundation-shared/index.d.ts +1 -0
  96. package/dist/foundation-shared/index.js +1 -0
  97. package/dist/foundation-shared/mail-host-policy.d.ts +0 -2
  98. package/dist/foundation-shared/mail-host-policy.js +3 -13
  99. package/dist/inbound-mail-foundation/connect-routes.js +7 -5
  100. package/dist/inbound-mail-foundation/entities.d.ts +25 -0
  101. package/dist/inbound-mail-foundation/projection.d.ts +20 -0
  102. package/dist/inbound-mail-foundation/watch-supervisor.js +47 -43
  103. package/dist/jobs/handlers/list.query.d.ts +7 -2
  104. package/dist/jobs/handlers/list.query.js +9 -2
  105. package/dist/jobs/handlers/retry.write.js +3 -2
  106. package/dist/jobs/handlers/tenant-failures.query.js +3 -9
  107. package/dist/jobs/job-run-logger.js +18 -2
  108. package/dist/jobs/job-run-table.d.ts +6 -0
  109. package/dist/jobs/tenant-job-failure-table.js +2 -6
  110. package/dist/ledger/entity.d.ts +16 -3
  111. package/dist/ledger/handlers/confirm-schedule-period.write.js +2 -2
  112. package/dist/ledger/handlers/reverse-transaction.write.js +2 -2
  113. package/dist/ledger/subject-reference.d.ts +4 -0
  114. package/dist/ledger/subject-reference.js +7 -0
  115. package/dist/legal-pages/feature.js +8 -1
  116. package/dist/managed-pages/handlers/branding.query.js +1 -1
  117. package/dist/managed-pages/handlers/by-slug.query.js +1 -1
  118. package/dist/managed-pages/handlers/by-tenant-published.query.js +1 -1
  119. package/dist/managed-pages/table.d.ts +12 -0
  120. package/dist/metrics/compute.js +7 -1
  121. package/dist/metrics/window.js +5 -0
  122. package/dist/notes-history/entity.d.ts +12 -0
  123. package/dist/notes-history/handlers/add-note.write.js +4 -9
  124. package/dist/notes-history-user-data/hooks.js +8 -1
  125. package/dist/personal-access-tokens/constants.d.ts +1 -0
  126. package/dist/personal-access-tokens/constants.js +1 -0
  127. package/dist/personal-access-tokens/feature.js +2 -2
  128. package/dist/personal-access-tokens/handlers/create.write.js +2 -2
  129. package/dist/personal-access-tokens/handlers/revoke.write.js +2 -2
  130. package/dist/personal-access-tokens/i18n.js +12 -13
  131. package/dist/personal-access-tokens/schema/api-token.d.ts +6 -0
  132. package/dist/personal-access-tokens/schema/api-token.js +2 -1
  133. package/dist/personal-access-tokens/screens.js +5 -1
  134. package/dist/rate-limiting/feature.js +2 -0
  135. package/dist/rate-limiting/handlers/bucket-access.js +15 -7
  136. package/dist/rate-limiting/i18n.d.ts +10 -0
  137. package/dist/rate-limiting/i18n.js +10 -0
  138. package/dist/secrets/feature.js +3 -2
  139. package/dist/secrets/table.d.ts +1 -0
  140. package/dist/seo/handlers/seo-config.query.js +1 -1
  141. package/dist/sessions/constants.d.ts +1 -0
  142. package/dist/sessions/constants.js +1 -0
  143. package/dist/sessions/feature.js +5 -3
  144. package/dist/sessions/handlers/revoke-all-for-user.write.d.ts +1 -0
  145. package/dist/sessions/handlers/revoke-all-for-user.write.js +12 -2
  146. package/dist/sessions/handlers/revoke.write.js +2 -2
  147. package/dist/sessions/schema/user-session.d.ts +4 -0
  148. package/dist/sessions/schema/user-session.js +2 -1
  149. package/dist/sessions/screens.js +5 -1
  150. package/dist/sessions/session-callbacks.js +2 -2
  151. package/dist/shared/index.d.ts +1 -1
  152. package/dist/shared/index.js +1 -1
  153. package/dist/shared/lockout-counter.js +1 -1
  154. package/dist/shared/parent-visibility.d.ts +5 -0
  155. package/dist/shared/parent-visibility.js +17 -1
  156. package/dist/shared/row-bound-grant.js +20 -9
  157. package/dist/shared/single-use-token-store.js +5 -13
  158. package/dist/step-dispatcher/webhook-runner.d.ts +0 -2
  159. package/dist/step-dispatcher/webhook-runner.js +18 -17
  160. package/dist/subscription-mollie/plugin-methods.d.ts +3 -2
  161. package/dist/subscription-mollie/plugin-methods.js +7 -1
  162. package/dist/subscription-mollie/verify-webhook.js +6 -0
  163. package/dist/subscription-stripe/feature.js +3 -3
  164. package/dist/subscription-stripe/plugin-methods.js +25 -6
  165. package/dist/subscription-stripe/verify-webhook.js +13 -6
  166. package/dist/tags/entity.d.ts +9 -0
  167. package/dist/tags/handlers/assign-tag.write.js +4 -10
  168. package/dist/tags/handlers/remove-tag.write.js +4 -11
  169. package/dist/template-resolver/api.d.ts +2 -1
  170. package/dist/template-resolver/api.js +1 -1
  171. package/dist/template-resolver/handlers/by-slug.query.d.ts +1 -1
  172. package/dist/template-resolver/handlers/by-slug.query.js +2 -2
  173. package/dist/template-resolver/handlers/by-tenant.query.d.ts +3 -2
  174. package/dist/template-resolver/handlers/by-tenant.query.js +2 -2
  175. package/dist/template-resolver/handlers/collection-item.query.d.ts +1 -1
  176. package/dist/template-resolver/handlers/collection-list.query.d.ts +1 -1
  177. package/dist/template-resolver/handlers/collection-shared.d.ts +3 -2
  178. package/dist/template-resolver/handlers/collection-shared.js +1 -1
  179. package/dist/template-resolver/handlers/find-by-id.query.d.ts +1 -1
  180. package/dist/template-resolver/handlers/find-by-id.query.js +1 -1
  181. package/dist/template-resolver/handlers/list.query.d.ts +1 -1
  182. package/dist/template-resolver/handlers/list.query.js +1 -1
  183. package/dist/template-resolver/seeding.js +41 -39
  184. package/dist/template-resolver/table.d.ts +26 -13
  185. package/dist/template-resolver/testing.js +2 -2
  186. package/dist/template-resolver/user-content-table.d.ts +16 -9
  187. package/dist/template-resolver/web/client-plugin.d.ts +1 -1
  188. package/dist/template-resolver-user-data/hooks.js +1 -1
  189. package/dist/tenant/cancel-pending-invitation.d.ts +8 -0
  190. package/dist/tenant/cancel-pending-invitation.js +22 -0
  191. package/dist/tenant/feature.js +4 -2
  192. package/dist/tenant/handlers/cancel-invitation.write.js +6 -21
  193. package/dist/tenant/handlers/member-directory.query.d.ts +1 -0
  194. package/dist/tenant/handlers/member-directory.query.js +17 -4
  195. package/dist/tenant/handlers/remove-member.write.js +9 -14
  196. package/dist/tenant/handlers/team-list.query.js +2 -2
  197. package/dist/tenant/handlers/tenant-directory.query.d.ts +1 -0
  198. package/dist/tenant/handlers/tenant-directory.query.js +13 -3
  199. package/dist/tenant/handlers/update-member-roles.write.d.ts +1 -1
  200. package/dist/tenant/handlers/update-member-roles.write.js +28 -12
  201. package/dist/tenant/i18n.js +4 -0
  202. package/dist/tenant/index.d.ts +1 -1
  203. package/dist/tenant/index.js +1 -1
  204. package/dist/tenant/invitation-table.d.ts +8 -2
  205. package/dist/tenant/last-tenant-admin.d.ts +1 -1
  206. package/dist/tenant/last-tenant-admin.js +5 -3
  207. package/dist/tenant/membership-table.d.ts +4 -0
  208. package/dist/tenant/schema/tenant.d.ts +8 -2
  209. package/dist/tenant/seeding.d.ts +4 -0
  210. package/dist/tenant/seeding.js +9 -2
  211. package/dist/tenant-lifecycle/boot-checks.d.ts +1 -0
  212. package/dist/tenant-lifecycle/boot-checks.js +27 -1
  213. package/dist/tenant-lifecycle/feature.js +2 -1
  214. package/dist/tenant-lifecycle/stages.js +2 -2
  215. package/dist/tenant-settings/feature.js +9 -6
  216. package/dist/tier-engine/entity.d.ts +2 -0
  217. package/dist/tier-engine/feature.js +3 -2
  218. package/dist/tier-engine/tier-resolver.d.ts +2 -0
  219. package/dist/user/command-schemas.d.ts +2 -2
  220. package/dist/user/handlers/create.write.d.ts +1 -1
  221. package/dist/user/handlers/create.write.js +8 -2
  222. package/dist/user/handlers/update.write.d.ts +1 -1
  223. package/dist/user/handlers/update.write.js +8 -2
  224. package/dist/user/schema/user.d.ts +14 -2
  225. package/dist/user-data-rights/db/queries/export-jobs.d.ts +2 -0
  226. package/dist/user-data-rights/feature.js +22 -9
  227. package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +4 -7
  228. package/dist/user-data-rights/handlers/deletion-grace-period.d.ts +3 -2
  229. package/dist/user-data-rights/handlers/deletion-grace-period.js +30 -8
  230. package/dist/user-data-rights/handlers/download-by-token.query.js +1 -1
  231. package/dist/user-data-rights/handlers/request-deletion-by-email.write.js +1 -1
  232. package/dist/user-data-rights/handlers/request-deletion.write.js +3 -6
  233. package/dist/user-data-rights/lib/update-user-lifecycle.js +7 -14
  234. package/dist/user-data-rights/run-export-jobs.d.ts +1 -0
  235. package/dist/user-data-rights/run-export-jobs.js +74 -41
  236. package/dist/user-data-rights/run-forget-cleanup.js +2 -2
  237. package/dist/user-data-rights/run-user-export.js +8 -4
  238. package/dist/user-data-rights/schema/download-attempt.d.ts +14 -0
  239. package/dist/user-data-rights/schema/download-token.d.ts +8 -0
  240. package/dist/user-data-rights/schema/export-job.d.ts +8 -2
  241. package/dist/user-data-rights-defaults/hooks/file-ref.userdata-hook.js +4 -1
  242. package/dist/user-profile/feature.js +6 -1
  243. package/dist/user-profile/handlers/change-email.write.js +2 -0
  244. package/dist/workflow-runner/db/queries/due-runs.js +6 -9
  245. package/dist/workflow-runner/event-subscriber.js +29 -9
  246. package/dist/workflow-runner/handlers/resume-run.write.js +8 -5
  247. package/dist/workflow-runner/pending-projection.js +8 -1
  248. package/package.json +11 -9
  249. package/src/agent-tools/changes.json +12 -0
  250. package/src/auth-email-password/changes.json +18 -0
  251. package/src/auth-mfa/changes.json +6 -0
  252. package/src/billing-foundation/changes.json +7 -0
  253. package/src/cap-counter/changes.json +7 -0
  254. package/src/cap-overview/changes.json +12 -0
  255. package/src/channel-email/changes.json +6 -0
  256. package/src/config/changes.json +6 -0
  257. package/src/crypto-shredding/changes.json +6 -0
  258. package/src/data-retention/changes.json +12 -0
  259. package/src/delivery/changes.json +7 -0
  260. package/src/derivatives-sharp/changes.json +11 -0
  261. package/src/document-ingest-foundation/changes.json +12 -0
  262. package/src/inbound-mail-foundation/changes.json +12 -0
  263. package/src/jobs/changes.json +13 -0
  264. package/src/legal-pages/changes.json +8 -1
  265. package/src/managed-pages/changes.json +5 -0
  266. package/src/metrics/changes.json +6 -0
  267. package/src/rate-limiting/changes.json +5 -0
  268. package/src/secrets/changes.json +7 -0
  269. package/src/sessions/changes.json +5 -0
  270. package/src/shared/changes.json +6 -0
  271. package/src/step-dispatcher/changes.json +5 -0
  272. package/src/subscription-mollie/changes.json +7 -0
  273. package/src/subscription-stripe/changes.json +11 -0
  274. package/src/template-resolver/changes.json +7 -0
  275. package/src/tenant/changes.json +29 -0
  276. package/src/user-data-rights/changes.json +22 -0
  277. package/src/workflow-runner/changes.json +6 -0
@@ -121,7 +121,8 @@ export function createInboundMailSupervisor(deps) {
121
121
  return null; // korrupter Cursor = wie kein Cursor → Backfill, Dedup fängt Dubletten
122
122
  }
123
123
  }
124
- async function saveCursor(accountId, cursor) {
124
+ async function saveCursor(account, cursor) {
125
+ const accountId = account.id;
125
126
  const now = Temporal.Now.instant().toString();
126
127
  const serialized = JSON.stringify(cursor);
127
128
  const existing = await fetchOne(deps.db, syncCursorTable, {
@@ -135,6 +136,8 @@ export function createInboundMailSupervisor(deps) {
135
136
  }
136
137
  await insertOne(deps.db, syncCursorTable, {
137
138
  id: crypto.randomUUID(),
139
+ // Direct-write store: no executor stamps the NOT NULL tenant_id for us.
140
+ tenantId: account.tenantId,
138
141
  accountId,
139
142
  scope: CURSOR_SCOPE,
140
143
  cursor: serialized,
@@ -227,7 +230,7 @@ export function createInboundMailSupervisor(deps) {
227
230
  maxMessages: budget,
228
231
  });
229
232
  await ingestBatch(account, result.messages, JSON.stringify(result.nextCursor));
230
- await saveCursor(account.id, result.nextCursor);
233
+ await saveCursor(account, result.nextCursor);
231
234
  cursor = result.nextCursor;
232
235
  budget -= result.messages.length;
233
236
  if (!result.hasMore || budget <= 0)
@@ -267,51 +270,52 @@ export function createInboundMailSupervisor(deps) {
267
270
  // lifecycle — it keeps renewing across reconnect backoff, not just while
268
271
  // `plugin.watch()` is actually connected, so a flaky IMAP link doesn't
269
272
  // make this worker lose the account to a peer mid-backoff.
270
- // kumiko-lint-ignore complexity-budget watch lease renew backoff/re-acquire loop
271
273
  function scheduleRenew(account, state, generation) {
272
274
  state.renewTimer = setTimeout(() => {
273
- // kumiko-lint-ignore complexity-budget renew timer callback (renew/backoff/re-acquire)
274
- void (async () => {
275
- state.renewTimer = null;
276
- // skip: supervisor stopped, generation superseded, or lease already lost — stale timer fire, nothing to renew.
277
- if (!running || state.generation !== generation || !state.lockToken || !deps.lock)
278
- return;
279
- let renewed;
280
- try {
281
- renewed = await deps.lock.renew(account.id, state.lockToken, leaseTtlSeconds);
282
- }
283
- catch (err) {
284
- log(`inbound-mail: watch lease renew for account ${account.id} failed: ${err instanceof Error ? err.message : String(err)}`);
285
- state.renewFailures += 1;
286
- // After failures spanning a full lease TTL, tear down — otherwise a
287
- // peer can acquire the expired key while we still hold an IDLE conn.
288
- if (state.renewFailures * renewIntervalMs >= leaseTtlSeconds * 1000) {
289
- log(`inbound-mail: watch lease renew for account ${account.id} failed across TTL — tearing down local watcher`);
290
- state.lockToken = null;
291
- await stopWatcher(account.id);
292
- // skip: local watcher torn down after renew failures spanning a full lease TTL.
293
- return;
294
- }
295
- if (running && state.generation === generation && state.lockToken) {
296
- scheduleRenew(account, state, generation);
297
- }
298
- // skip: renew already rescheduled above (or conditions no longer hold) — nothing left to do this tick.
299
- return;
300
- }
301
- state.renewFailures = 0;
302
- if (!renewed) {
303
- log(`inbound-mail: watch lease for account ${account.id} lost — tearing down local watcher`);
304
- state.lockToken = null;
305
- await stopWatcher(account.id);
306
- // skip: watcher already torn down by stopWatcher() above — nothing left to do.
307
- return;
308
- }
309
- if (running && state.generation === generation && state.lockToken) {
310
- scheduleRenew(account, state, generation);
311
- }
312
- })();
275
+ void renewLeaseOnce(account, state, generation);
313
276
  }, renewIntervalMs);
314
277
  }
278
+ async function tearDownAfterRenewFailures(account, state, reason) {
279
+ log(`inbound-mail: watch lease ${reason} for account ${account.id} — tearing down local watcher`);
280
+ state.lockToken = null;
281
+ await stopWatcher(account.id);
282
+ }
283
+ async function renewLeaseOnce(account, state, generation) {
284
+ state.renewTimer = null;
285
+ // skip: supervisor stopped, generation superseded, or lease already lost — stale timer fire, nothing to renew.
286
+ if (!running || state.generation !== generation || !state.lockToken || !deps.lock)
287
+ return;
288
+ let renewed;
289
+ try {
290
+ renewed = await deps.lock.renew(account.id, state.lockToken, leaseTtlSeconds);
291
+ }
292
+ catch (err) {
293
+ log(`inbound-mail: watch lease renew for account ${account.id} failed: ${err instanceof Error ? err.message : String(err)}`);
294
+ state.renewFailures += 1;
295
+ // After failures spanning a full lease TTL, tear down — otherwise a
296
+ // peer can acquire the expired key while we still hold an IDLE conn.
297
+ if (state.renewFailures * renewIntervalMs >= leaseTtlSeconds * 1000) {
298
+ await tearDownAfterRenewFailures(account, state, "renew failed across TTL");
299
+ // skip: connection torn down, no reschedule
300
+ return;
301
+ }
302
+ rescheduleIfStillHeld(account, state, generation);
303
+ // skip: renew failed but TTL not yet exhausted, retry already scheduled
304
+ return;
305
+ }
306
+ state.renewFailures = 0;
307
+ if (!renewed) {
308
+ await tearDownAfterRenewFailures(account, state, "lost");
309
+ // skip: lease lost and torn down, nothing to reschedule
310
+ return;
311
+ }
312
+ rescheduleIfStillHeld(account, state, generation);
313
+ }
314
+ function rescheduleIfStillHeld(account, state, generation) {
315
+ if (running && state.generation === generation && state.lockToken) {
316
+ scheduleRenew(account, state, generation);
317
+ }
318
+ }
315
319
  // kumiko-lint-ignore complexity-budget yield-point stale-map check after acquire is intentional (#2460)
316
320
  async function acquireWatchLease(account, state) {
317
321
  if (!deps.lock || state.lockToken)
@@ -8,9 +8,14 @@ export declare const listQuery: import("@cosmicdrift/kumiko-types/define-handler
8
8
  running: "running";
9
9
  }>>;
10
10
  filters: z.ZodOptional<z.ZodArray<z.ZodObject<{
11
- field: z.ZodString;
11
+ field: z.ZodLiteral<"status">;
12
12
  op: z.ZodLiteral<"in">;
13
- value: z.ZodArray<z.ZodString>;
13
+ value: z.ZodArray<z.ZodEnum<{
14
+ completed: "completed";
15
+ failed: "failed";
16
+ queued: "queued";
17
+ running: "running";
18
+ }>>;
14
19
  }, z.core.$strip>>>;
15
20
  sort: z.ZodOptional<z.ZodEnum<{
16
21
  duration: "duration";
@@ -5,6 +5,7 @@ import * as z from "zod";
5
5
  import { decryptStoredPii, mapWithConcurrency } from "../../shared/index.js";
6
6
  import { jobRunsTable } from "../job-run-table.js";
7
7
  const KMS_POOL_CONCURRENCY = 4;
8
+ const jobRunStatusSchema = z.enum(["queued", "running", "completed", "failed"]);
8
9
  async function decryptRunRow(row) {
9
10
  let result = row;
10
11
  if (typeof result["payload"] === "string") {
@@ -23,9 +24,13 @@ export const listQuery = defineQueryHandler({
23
24
  description: "Lists job runs across all tenants newest-first, optionally filtered by job name and status; use it to check whether a job ran and whether it succeeded.",
24
25
  schema: z.object({
25
26
  jobName: z.string().optional(),
26
- status: z.enum(["queued", "running", "completed", "failed"]).optional(),
27
+ status: jobRunStatusSchema.optional(),
27
28
  filters: z
28
- .array(z.object({ field: z.string(), op: z.literal("in"), value: z.array(z.string()) }))
29
+ .array(z.object({
30
+ field: z.literal("status"),
31
+ op: z.literal("in"),
32
+ value: z.array(jobRunStatusSchema),
33
+ }))
29
34
  .optional(),
30
35
  sort: z.enum(["jobName", "status", "startedAt", "duration"]).optional(),
31
36
  sortDirection: z.enum(["asc", "desc"]).optional(),
@@ -47,6 +52,8 @@ export const listQuery = defineQueryHandler({
47
52
  const statuses = statusFilter?.value ?? (query.payload.status ? [query.payload.status] : []);
48
53
  if (statuses.length === 1)
49
54
  where["status"] = statuses[0];
55
+ else if (statuses.length > 1)
56
+ where["status"] = { in: statuses };
50
57
  const sortColumn = query.payload.sort ?? "startedAt";
51
58
  const rows = await selectMany(db, jobRunsTable, where, {
52
59
  orderBy: { col: sortColumn, direction: query.payload.sortDirection ?? "desc" },
@@ -1,5 +1,5 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-framework/crypto";
2
+ import { isPiiCiphertext, PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-framework/crypto";
3
3
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { InternalError, NotFoundError, UnprocessableError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
5
5
  import { parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
@@ -44,7 +44,8 @@ export const retryWrite = defineWriteHandler({
44
44
  let decryptedPayloadJson = null;
45
45
  if (run.payload) {
46
46
  const decryptedPayload = await decryptStoredPii(run.payload, "payload", `job run ${event.payload.runId} payload`);
47
- if (decryptedPayload === PII_ERASED_SENTINEL) {
47
+ // Still-ciphertext means no KMS could decrypt it (rollout mode); not JSON either.
48
+ if (decryptedPayload === PII_ERASED_SENTINEL || isPiiCiphertext(decryptedPayload)) {
48
49
  return writeFailure(new UnprocessableError(JobErrors.payloadErased, {
49
50
  i18nKey: "jobs.errors.payloadErased",
50
51
  }));
@@ -1,17 +1,11 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { serializeJobSubject } from "@cosmicdrift/kumiko-framework/jobs";
4
5
  import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
5
6
  import * as z from "zod";
6
7
  import { tenantJobFailuresTable } from "../tenant-job-failure-table.js";
7
8
  const DEFAULT_LIMIT = 50;
8
- // Mirrors job-runner.ts's jobSubjectKey: sorted field names, so a caller that
9
- // passes the same subject values gets the same string the writer stored.
10
- function subjectKey(subject) {
11
- return JSON.stringify(Object.keys(subject)
12
- .sort()
13
- .map((field) => [field, subject[field] ?? null]));
14
- }
15
9
  function isSubjectEntry(value) {
16
10
  return Array.isArray(value) && typeof value[0] === "string";
17
11
  }
@@ -28,7 +22,7 @@ function parseSubject(stored) {
28
22
  }
29
23
  export const tenantFailuresQuery = defineQueryHandler({
30
24
  name: "failures",
31
- description: "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; use it to tell a tenant that their asynchronous job failed instead of leaving the screen waiting.",
25
+ description: "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.",
32
26
  schema: z.object({
33
27
  jobName: z.string().optional(),
34
28
  subject: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
@@ -52,7 +46,7 @@ export const tenantFailuresQuery = defineQueryHandler({
52
46
  if (query.payload.jobName)
53
47
  where["jobName"] = query.payload.jobName;
54
48
  if (query.payload.subject)
55
- where["subject"] = subjectKey(query.payload.subject);
49
+ where["subject"] = serializeJobSubject(query.payload.subject);
56
50
  const rows = await selectMany(db, tenantJobFailuresTable, where, {
57
51
  orderBy: { col: "failedAt", direction: "desc" },
58
52
  limit: query.payload.limit ?? DEFAULT_LIMIT,
@@ -1,6 +1,7 @@
1
1
  import { deleteMany, fetchOne, insertMany, insertOne, updateMany, } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { configuredPiiSubjectKms, encryptPiiValueForSubject, KeyErasedError, PII_ERASED_SENTINEL, } from "@cosmicdrift/kumiko-framework/crypto";
3
3
  import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
4
5
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
5
6
  import { mapWithConcurrency } from "../shared/index.js";
6
7
  import { runCompletedSchema, runFailedSchema, runStartedSchema } from "./events.js";
@@ -128,6 +129,21 @@ async function clearTenantJobFailure(db, jobName, outcome) {
128
129
  return;
129
130
  await deleteMany(db, tenantJobFailuresTable, where);
130
131
  }
132
+ const log = createFallbackLogger("job-run-logger");
133
+ // The tenant failure record is an optional side write: a throw here must not
134
+ // skip the run-row update or turn a completed run into a BullMQ retry.
135
+ async function isolateTenantFailureWrite(write, jobName, bullJobId) {
136
+ try {
137
+ await write();
138
+ }
139
+ catch (e) {
140
+ log.error("tenant job failure record write failed", {
141
+ jobName,
142
+ bullJobId,
143
+ error: e instanceof Error ? e.message : String(e),
144
+ });
145
+ }
146
+ }
131
147
  export function createJobRunLogger(opts) {
132
148
  const { db } = opts;
133
149
  const runIdByBullJobId = new Map();
@@ -210,7 +226,7 @@ export function createJobRunLogger(opts) {
210
226
  // Before the run-row write and independent of it: a successful run
211
227
  // clears the tenant's failure record even when the run row itself is
212
228
  // unreachable (the state-loss return below).
213
- await clearTenantJobFailure(db, jobName, outcome);
229
+ await isolateTenantFailureWrite(() => clearTenantJobFailure(db, jobName, outcome), jobName, bullJobId);
214
230
  const resolved = await resolveRun(bullJobId);
215
231
  // skip: state loss between start + complete (worker restart, cache
216
232
  // evicted AND DB has no matching bull_job_id). Rare edge case; we
@@ -255,7 +271,7 @@ export function createJobRunLogger(opts) {
255
271
  onJobFailed: async (jobName, bullJobId, error, logs, outcome) => {
256
272
  // Mirror of onJobComplete: recorded independently of the run row, so a
257
273
  // tenant still learns their job failed if the row is unreachable.
258
- await recordTenantJobFailure(db, jobName, outcome);
274
+ await isolateTenantFailureWrite(() => recordTenantJobFailure(db, jobName, outcome), jobName, bullJobId);
259
275
  const resolved = await resolveRun(bullJobId);
260
276
  // skip: same rare state-loss case as in onJobComplete — drop the
261
277
  // failure write rather than forge a run row from scratch.
@@ -12,6 +12,7 @@ export declare const jobRunEntity: import("@cosmicdrift/kumiko-types/fields").En
12
12
  readonly filterable?: boolean;
13
13
  readonly encrypted?: boolean;
14
14
  readonly sensitive?: boolean;
15
+ readonly writeOnly?: boolean;
15
16
  readonly readAsInstruction?: boolean;
16
17
  readonly format?: "email" | "url" | "phone" | "password";
17
18
  readonly default?: string;
@@ -32,6 +33,7 @@ export declare const jobRunEntity: import("@cosmicdrift/kumiko-types/fields").En
32
33
  readonly filterable?: boolean;
33
34
  readonly encrypted?: boolean;
34
35
  readonly sensitive?: boolean;
36
+ readonly writeOnly?: boolean;
35
37
  readonly readAsInstruction?: boolean;
36
38
  readonly format?: "email" | "url" | "phone" | "password";
37
39
  readonly default?: string;
@@ -52,6 +54,7 @@ export declare const jobRunEntity: import("@cosmicdrift/kumiko-types/fields").En
52
54
  readonly filterable?: boolean;
53
55
  readonly encrypted?: boolean;
54
56
  readonly sensitive?: boolean;
57
+ readonly writeOnly?: boolean;
55
58
  readonly readAsInstruction?: boolean;
56
59
  readonly format?: "email" | "url" | "phone" | "password";
57
60
  readonly default?: string;
@@ -72,6 +75,7 @@ export declare const jobRunEntity: import("@cosmicdrift/kumiko-types/fields").En
72
75
  readonly filterable?: boolean;
73
76
  readonly encrypted?: boolean;
74
77
  readonly sensitive?: boolean;
78
+ readonly writeOnly?: boolean;
75
79
  readonly readAsInstruction?: boolean;
76
80
  readonly format?: "email" | "url" | "phone" | "password";
77
81
  readonly default?: string;
@@ -92,6 +96,7 @@ export declare const jobRunEntity: import("@cosmicdrift/kumiko-types/fields").En
92
96
  readonly filterable?: boolean;
93
97
  readonly encrypted?: boolean;
94
98
  readonly sensitive?: boolean;
99
+ readonly writeOnly?: boolean;
95
100
  readonly readAsInstruction?: boolean;
96
101
  readonly format?: "email" | "url" | "phone" | "password";
97
102
  readonly default?: string;
@@ -180,6 +185,7 @@ export declare const jobRunEntity: import("@cosmicdrift/kumiko-types/fields").En
180
185
  readonly filterable?: boolean;
181
186
  readonly encrypted?: boolean;
182
187
  readonly sensitive?: boolean;
188
+ readonly writeOnly?: boolean;
183
189
  readonly readAsInstruction?: boolean;
184
190
  readonly format?: "email" | "url" | "phone" | "password";
185
191
  readonly default?: string;
@@ -1,4 +1,4 @@
1
- import { asEntityTableMeta } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { requireEntityTableMeta } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { instant, table as pgTable, serial, sql, text, uuid, } from "@cosmicdrift/kumiko-framework/db";
3
3
  // The tenant's own view of a failed job (fw#3079). Direct-write store like
4
4
  // store_job_runs: job-run-logger.ts writes it from the BullMQ callbacks,
@@ -22,8 +22,4 @@ export const tenantJobFailuresTable = pgTable("store_tenant_job_failures", {
22
22
  messageKey: text("message_key").notNull(),
23
23
  failedAt: instant("failed_at").default(sql `now()`).notNull(),
24
24
  });
25
- const derivedTenantJobFailuresTableMeta = asEntityTableMeta(tenantJobFailuresTable);
26
- if (!derivedTenantJobFailuresTableMeta) {
27
- throw new Error("tenantJobFailuresTable: table carries no EntityTableMeta — built via table()?");
28
- }
29
- export const tenantJobFailuresTableMeta = derivedTenantJobFailuresTableMeta;
25
+ export const tenantJobFailuresTableMeta = requireEntityTableMeta(tenantJobFailuresTable, "tenantJobFailuresTable");
@@ -9,6 +9,7 @@ export declare const accountEntity: import("@cosmicdrift/kumiko-types/fields").E
9
9
  readonly filterable?: boolean;
10
10
  readonly encrypted?: boolean;
11
11
  readonly sensitive?: boolean;
12
+ readonly writeOnly?: boolean;
12
13
  readonly readAsInstruction?: boolean;
13
14
  readonly format?: "email" | "url" | "phone" | "password";
14
15
  readonly default?: string;
@@ -38,7 +39,7 @@ export declare const accountEntity: import("@cosmicdrift/kumiko-types/fields").E
38
39
  readonly liability?: import("@cosmicdrift/kumiko-types/fields").SelectOptionTone | undefined;
39
40
  } | undefined;
40
41
  readonly optionsQuery?: string;
41
- readonly optionsQueryPayload?: Readonly<Record<string, string | number | boolean>>;
42
+ readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
42
43
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
43
44
  required: true;
44
45
  };
@@ -52,6 +53,7 @@ export declare const accountEntity: import("@cosmicdrift/kumiko-types/fields").E
52
53
  readonly filterable?: boolean;
53
54
  readonly encrypted?: boolean;
54
55
  readonly sensitive?: boolean;
56
+ readonly writeOnly?: boolean;
55
57
  readonly readAsInstruction?: boolean;
56
58
  readonly format?: "email" | "url" | "phone" | "password";
57
59
  readonly default?: string;
@@ -72,6 +74,7 @@ export declare const accountEntity: import("@cosmicdrift/kumiko-types/fields").E
72
74
  readonly filterable?: boolean;
73
75
  readonly encrypted?: boolean;
74
76
  readonly sensitive?: boolean;
77
+ readonly writeOnly?: boolean;
75
78
  readonly readAsInstruction?: boolean;
76
79
  readonly format?: "email" | "url" | "phone" | "password";
77
80
  readonly default?: string;
@@ -108,6 +111,7 @@ export declare const transactionEntity: import("@cosmicdrift/kumiko-types/fields
108
111
  readonly filterable?: boolean;
109
112
  readonly encrypted?: boolean;
110
113
  readonly sensitive?: boolean;
114
+ readonly writeOnly?: boolean;
111
115
  readonly readAsInstruction?: boolean;
112
116
  readonly format?: "email" | "url" | "phone" | "password";
113
117
  readonly default?: string;
@@ -128,6 +132,7 @@ export declare const transactionEntity: import("@cosmicdrift/kumiko-types/fields
128
132
  readonly filterable?: boolean;
129
133
  readonly encrypted?: boolean;
130
134
  readonly sensitive?: boolean;
135
+ readonly writeOnly?: boolean;
131
136
  readonly readAsInstruction?: boolean;
132
137
  readonly format?: "email" | "url" | "phone" | "password";
133
138
  readonly default?: string;
@@ -154,7 +159,7 @@ export declare const transactionEntity: import("@cosmicdrift/kumiko-types/fields
154
159
  readonly posted?: import("@cosmicdrift/kumiko-types/fields").SelectOptionTone | undefined;
155
160
  } | undefined;
156
161
  readonly optionsQuery?: string;
157
- readonly optionsQueryPayload?: Readonly<Record<string, string | number | boolean>>;
162
+ readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
158
163
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
159
164
  required: true;
160
165
  };
@@ -184,6 +189,7 @@ export declare const transactionEntity: import("@cosmicdrift/kumiko-types/fields
184
189
  readonly filterable?: boolean;
185
190
  readonly encrypted?: boolean;
186
191
  readonly sensitive?: boolean;
192
+ readonly writeOnly?: boolean;
187
193
  readonly readAsInstruction?: boolean;
188
194
  readonly format?: "email" | "url" | "phone" | "password";
189
195
  readonly default?: string;
@@ -204,6 +210,7 @@ export declare const transactionEntity: import("@cosmicdrift/kumiko-types/fields
204
210
  readonly filterable?: boolean;
205
211
  readonly encrypted?: boolean;
206
212
  readonly sensitive?: boolean;
213
+ readonly writeOnly?: boolean;
207
214
  readonly readAsInstruction?: boolean;
208
215
  readonly format?: "email" | "url" | "phone" | "password";
209
216
  readonly default?: string;
@@ -226,6 +233,7 @@ export declare const scheduleEntity: import("@cosmicdrift/kumiko-types/fields").
226
233
  readonly filterable?: boolean;
227
234
  readonly encrypted?: boolean;
228
235
  readonly sensitive?: boolean;
236
+ readonly writeOnly?: boolean;
229
237
  readonly readAsInstruction?: boolean;
230
238
  readonly format?: "email" | "url" | "phone" | "password";
231
239
  readonly default?: string;
@@ -279,7 +287,7 @@ export declare const scheduleEntity: import("@cosmicdrift/kumiko-types/fields").
279
287
  readonly monthly?: import("@cosmicdrift/kumiko-types/fields").SelectOptionTone | undefined;
280
288
  } | undefined;
281
289
  readonly optionsQuery?: string;
282
- readonly optionsQueryPayload?: Readonly<Record<string, string | number | boolean>>;
290
+ readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
283
291
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
284
292
  required: true;
285
293
  };
@@ -312,6 +320,7 @@ export declare const scheduleEntity: import("@cosmicdrift/kumiko-types/fields").
312
320
  readonly filterable?: boolean;
313
321
  readonly encrypted?: boolean;
314
322
  readonly sensitive?: boolean;
323
+ readonly writeOnly?: boolean;
315
324
  readonly readAsInstruction?: boolean;
316
325
  readonly format?: "email" | "url" | "phone" | "password";
317
326
  readonly default?: string;
@@ -332,6 +341,7 @@ export declare const scheduleEntity: import("@cosmicdrift/kumiko-types/fields").
332
341
  readonly filterable?: boolean;
333
342
  readonly encrypted?: boolean;
334
343
  readonly sensitive?: boolean;
344
+ readonly writeOnly?: boolean;
335
345
  readonly readAsInstruction?: boolean;
336
346
  readonly format?: "email" | "url" | "phone" | "password";
337
347
  readonly default?: string;
@@ -352,6 +362,7 @@ export declare const scheduleEntity: import("@cosmicdrift/kumiko-types/fields").
352
362
  readonly filterable?: boolean;
353
363
  readonly encrypted?: boolean;
354
364
  readonly sensitive?: boolean;
365
+ readonly writeOnly?: boolean;
355
366
  readonly readAsInstruction?: boolean;
356
367
  readonly format?: "email" | "url" | "phone" | "password";
357
368
  readonly default?: string;
@@ -372,6 +383,7 @@ export declare const scheduleEntity: import("@cosmicdrift/kumiko-types/fields").
372
383
  readonly filterable?: boolean;
373
384
  readonly encrypted?: boolean;
374
385
  readonly sensitive?: boolean;
386
+ readonly writeOnly?: boolean;
375
387
  readonly readAsInstruction?: boolean;
376
388
  readonly format?: "email" | "url" | "phone" | "password";
377
389
  readonly default?: string;
@@ -392,6 +404,7 @@ export declare const scheduleEntity: import("@cosmicdrift/kumiko-types/fields").
392
404
  readonly filterable?: boolean;
393
405
  readonly encrypted?: boolean;
394
406
  readonly sensitive?: boolean;
407
+ readonly writeOnly?: boolean;
395
408
  readonly readAsInstruction?: boolean;
396
409
  readonly format?: "email" | "url" | "phone" | "password";
397
410
  readonly default?: string;
@@ -4,6 +4,7 @@ import { DEFAULT_LEDGER_ACCESS } from "../constants.js";
4
4
  import { accountExecutor, scheduleExecutor, transactionExecutor, transactionTable, } from "../executor.js";
5
5
  import { findReversedIds, isoMonth, scheduleReference } from "../recurring.js";
6
6
  import { confirmSchedulePeriodPayloadSchema, } from "../schemas.js";
7
+ import { readSubjectReference } from "../subject-reference.js";
7
8
  // confirm-schedule-period — turn ONE projected period of a schedule into a posted,
8
9
  // balanced transaction (debit +amount / credit −amount), tagged with
9
10
  // scheduleReference(scheduleId, period) so the host can merge Soll vs. Ist.
@@ -70,8 +71,7 @@ export function createConfirmSchedulePeriodHandler(access = DEFAULT_LEDGER_ACCES
70
71
  const amount = payload.amount ?? Number(schedule["amount"]);
71
72
  // The subject is a property of the schedule, not the confirm call — a
72
73
  // period without one stays without one, no backfill/error.
73
- const subjectType = typeof schedule["subjectType"] === "string" ? schedule["subjectType"] : null;
74
- const subjectId = typeof schedule["subjectId"] === "string" ? schedule["subjectId"] : null;
74
+ const { subjectType, subjectId } = readSubjectReference(schedule);
75
75
  const created = await transactionExecutor.create({
76
76
  id: generateId(),
77
77
  date: payload.date ?? `${payload.period}-01`,
@@ -4,6 +4,7 @@ import { DEFAULT_LEDGER_ACCESS } from "../constants.js";
4
4
  import { transactionExecutor, transactionTable } from "../executor.js";
5
5
  import { normalizeLines } from "../reports.js";
6
6
  import { reverseTransactionPayloadSchema } from "../schemas.js";
7
+ import { readSubjectReference } from "../subject-reference.js";
7
8
  // reverse-transaction (Storno) — the ONLY correction path for a posted entry.
8
9
  // Books the mirror image (every amount negated) as a new posted entry that
9
10
  // references the original, leaving the original untouched. This is why
@@ -51,8 +52,7 @@ export function createReverseTransactionHandler(access = DEFAULT_LEDGER_ACCESS)
51
52
  }));
52
53
  // The reversal shares its subject with the original so filtering by
53
54
  // subjectId still finds the Storno; unset on the original stays unset.
54
- const subjectType = typeof original["subjectType"] === "string" ? original["subjectType"] : null;
55
- const subjectId = typeof original["subjectId"] === "string" ? original["subjectId"] : null;
55
+ const { subjectType, subjectId } = readSubjectReference(original);
56
56
  return transactionExecutor.create({
57
57
  id: generateId(),
58
58
  // Pass the original's date through untyped — create takes Record<string,
@@ -0,0 +1,4 @@
1
+ export declare function readSubjectReference(row: Record<string, unknown>): {
2
+ subjectType: string | null;
3
+ subjectId: string | null;
4
+ };
@@ -0,0 +1,7 @@
1
+ // Subject columns are nullable; anything that is not a string reads as unset.
2
+ export function readSubjectReference(row) {
3
+ return {
4
+ subjectType: typeof row["subjectType"] === "string" ? row["subjectType"] : null,
5
+ subjectId: typeof row["subjectId"] === "string" ? row["subjectId"] : null,
6
+ };
7
+ }
@@ -36,6 +36,12 @@ export function createLegalPagesFeature(opts = {}) {
36
36
  const routes = opts.routes ?? LEGAL_ROUTES;
37
37
  const requiredBlocks = opts.requiredBlocks ?? LEGAL_REQUIRED_BLOCKS;
38
38
  validateRoutes(routes);
39
+ if (opts.routes !== undefined && opts.routes.length > 0 && opts.requiredBlocks === undefined) {
40
+ const coversDefaultBlock = LEGAL_REQUIRED_BLOCKS.some((block) => opts.routes?.some((route) => route.slug === block.slug && route.lang === block.lang));
41
+ if (!coversDefaultBlock) {
42
+ throw new Error("legal-pages: custom `routes` without `requiredBlocks` — pass `requiredBlocks` explicitly (or `[]`)");
43
+ }
44
+ }
39
45
  return defineFeature("legal-pages", (r) => {
40
46
  r.describe("Opt-in wrapper around `template-resolver` text-blocks that registers public HTML routes (default: `/legal/impressum`, `/legal/datenschutz`, `/legal/imprint`, `/legal/privacy`) with Markdown-to-HTML rendering and a boot-time job that hard-fails in production when the required blocks (default: `imprint/de`, `privacy/de`) are not seeded in `SYSTEM_TENANT`. Both are configurable via `routes`/`requiredBlocks` for apps with a different default language or additional pages. Requires `anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID }` and `extraContext.templateResolver` to be wired at app bootstrap; for per-tenant imprints or a custom layout call `template-resolver:query:by-slug` directly.");
41
47
  r.uiHints({
@@ -75,9 +81,10 @@ export function createLegalPagesFeature(opts = {}) {
75
81
  }
76
82
  const etag = computeRevisionEtag([
77
83
  SYSTEM_TENANT_ID,
84
+ route.path,
78
85
  route.slug,
79
86
  route.lang,
80
- data.updatedAt,
87
+ String(data.modifiedAt ?? ""),
81
88
  ]);
82
89
  const extra = { "content-type": "text/html; charset=utf-8" };
83
90
  // 304 (Revision unverändert) und HEAD überspringen beide das
@@ -18,7 +18,7 @@ export function createBrandingQuery(opts) {
18
18
  name: "branding",
19
19
  schema: z.object({}),
20
20
  access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
21
- rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
21
+ rateLimit: { per: "ip+handler", limit: 60, windowSeconds: 60 },
22
22
  description: "Reads the tenant's public branding values for the rendered page shell, adding the raw custom CSS only when the app opted in and the per-tenant CSS toggle is on; anonymous callers may read it because it dresses public pages.",
23
23
  handler: async (_query, ctx) => {
24
24
  const base = await readBranding(ctx.config);
@@ -15,7 +15,7 @@ export const bySlugQuery = defineQueryHandler({
15
15
  lang: z.string().min(2).max(8),
16
16
  }),
17
17
  access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
18
- rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
18
+ rateLimit: { per: "ip+handler", limit: 60, windowSeconds: 60 },
19
19
  description: "Reads the body and metadata of one published managed page of the caller's tenant by slug and language, returning null for drafts and bodyless pages; use it to render a public page, not to load one for editing.",
20
20
  handler: async (query, ctx) => {
21
21
  const row = await fetchOne(ctx.db, pagesTable, {
@@ -11,7 +11,7 @@ export const byTenantPublishedQuery = defineQueryHandler({
11
11
  tenantIdOverride: z.string().min(1).optional(),
12
12
  }),
13
13
  access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
14
- rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
14
+ rateLimit: { per: "ip+handler", limit: 60, windowSeconds: 60 },
15
15
  description: "Lists every published page of a tenant with slug, language, title and last-change time but no body; use it to enumerate the public pages for sitemap.xml or llms.txt, rather than by-slug which fetches one page's content.",
16
16
  handler: async (query, ctx) => {
17
17
  const override = query.payload.tenantIdOverride;