@cosmicdrift/kumiko-bundled-features 0.334.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 (242) hide show
  1. package/dist/admin-shell/overview-panels.js +2 -2
  2. package/dist/agent-tools/agent-doc-lint.d.ts +1 -0
  3. package/dist/agent-tools/agent-doc-lint.js +33 -3
  4. package/dist/agent-tools/agent-manifest.js +47 -6
  5. package/dist/agent-tools/filter-ops.d.ts +2 -0
  6. package/dist/agent-tools/filter-ops.js +4 -0
  7. package/dist/agent-tools/tool-catalog.js +1 -4
  8. package/dist/agent-tools/tool-dispatch.js +1 -4
  9. package/dist/audit/constants.d.ts +1 -1
  10. package/dist/audit/constants.js +1 -1
  11. package/dist/auth-email-password/handlers/change-password.write.js +2 -0
  12. package/dist/auth-email-password/handlers/invite-create.write.js +12 -2
  13. package/dist/auth-email-password/handlers/login.write.js +2 -0
  14. package/dist/auth-email-password/handlers/self-registration-status.query.js +1 -1
  15. package/dist/auth-email-password/i18n.js +2 -0
  16. package/dist/auth-email-password/web/auth-client.d.ts +1 -0
  17. package/dist/auth-email-password/web/auth-client.js +1 -1
  18. package/dist/auth-email-password/web/auth-gate.js +6 -8
  19. package/dist/auth-email-password/web/index.d.ts +1 -1
  20. package/dist/auth-email-password/web/index.js +1 -1
  21. package/dist/auth-email-password/web/invite-accept-screen.js +16 -12
  22. package/dist/auth-email-password/web/login-screen.js +1 -1
  23. package/dist/auth-email-password/web/session-bootstrap-error.d.ts +5 -1
  24. package/dist/auth-email-password/web/session-bootstrap-error.js +2 -2
  25. package/dist/auth-email-password/web/session.d.ts +4 -0
  26. package/dist/auth-email-password/web/session.js +15 -0
  27. package/dist/auth-mfa/feature.js +3 -2
  28. package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +2 -2
  29. package/dist/auth-mfa/handlers/verify.write.js +2 -0
  30. package/dist/auth-mfa/hmac-token-codec.js +3 -0
  31. package/dist/auth-mfa/mfa-challenge-token.js +4 -0
  32. package/dist/auth-mfa/mfa-preauth-setup-token.js +3 -0
  33. package/dist/auth-mfa/web/mfa-client.d.ts +1 -1
  34. package/dist/auth-mfa/web/mfa-client.js +4 -10
  35. package/dist/billing-foundation/billing-info-query.js +1 -1
  36. package/dist/billing-foundation/checkout-core.d.ts +5 -5
  37. package/dist/billing-foundation/checkout-core.js +10 -6
  38. package/dist/billing-foundation/handlers/switch-plan.write.js +5 -8
  39. package/dist/cap-counter/book-cap-usage.js +15 -5
  40. package/dist/cap-counter/enforce-cap.js +9 -3
  41. package/dist/cap-counter/feature.js +3 -2
  42. package/dist/cap-counter/i18n.d.ts +1 -0
  43. package/dist/cap-counter/i18n.js +6 -5
  44. package/dist/cap-overview/handlers/caps-usage.query.js +9 -16
  45. package/dist/cap-overview/handlers/tenant-caps-list.query.js +41 -15
  46. package/dist/cap-overview/i18n.js +5 -0
  47. package/dist/cap-overview/web/cap-cards-panel.js +4 -1
  48. package/dist/cap-overview/web/cap-usage-bar.js +1 -1
  49. package/dist/channel-email/email-channel.js +12 -1
  50. package/dist/channel-in-app/tables.js +2 -6
  51. package/dist/compliance-profiles/handlers/sub-processors.query.js +1 -1
  52. package/dist/config/db/queries/resolver.js +3 -1
  53. package/dist/config/handlers/set.write.js +8 -4
  54. package/dist/config/web/i18n.js +1 -0
  55. package/dist/config/write-helpers.d.ts +1 -1
  56. package/dist/config/write-helpers.js +11 -5
  57. package/dist/crypto-shredding/handlers/forget-subject.write.js +15 -8
  58. package/dist/custom-fields/constants.js +1 -4
  59. package/dist/data-retention/resolve-for-tenant.d.ts +2 -3
  60. package/dist/data-retention/run-retention-cleanup.js +100 -44
  61. package/dist/delivery/types.d.ts +2 -1
  62. package/dist/delivery/types.js +7 -0
  63. package/dist/delivery/unsubscribe.js +5 -11
  64. package/dist/derivatives-sharp/render.js +27 -9
  65. package/dist/document-ingest-foundation/feature.js +5 -5
  66. package/dist/document-ingest-foundation/providers.d.ts +1 -0
  67. package/dist/document-ingest-foundation/providers.js +6 -0
  68. package/dist/document-ingest-foundation/write-document-extract.d.ts +2 -2
  69. package/dist/document-ingest-foundation/write-document-extract.js +10 -0
  70. package/dist/feature-toggles/global-feature-state-table.js +2 -7
  71. package/dist/feature-toggles/toggle-sync-signal.d.ts +2 -1
  72. package/dist/feature-toggles/toggle-sync-signal.js +3 -2
  73. package/dist/files-tenant-data/index.js +3 -2
  74. package/dist/folders/handlers/clear-folder.write.js +4 -11
  75. package/dist/folders/handlers/set-folder.write.js +4 -10
  76. package/dist/form-draft/feature.js +3 -2
  77. package/dist/form-draft/handlers/discard.write.js +7 -8
  78. package/dist/form-draft/handlers/get.query.js +7 -1
  79. package/dist/foundation-shared/host-allowlist.d.ts +4 -0
  80. package/dist/foundation-shared/host-allowlist.js +14 -0
  81. package/dist/foundation-shared/index.d.ts +1 -0
  82. package/dist/foundation-shared/index.js +1 -0
  83. package/dist/foundation-shared/mail-host-policy.d.ts +0 -2
  84. package/dist/foundation-shared/mail-host-policy.js +3 -13
  85. package/dist/inbound-mail-foundation/connect-routes.js +7 -5
  86. package/dist/inbound-mail-foundation/watch-supervisor.js +47 -43
  87. package/dist/jobs/handlers/list.query.d.ts +7 -2
  88. package/dist/jobs/handlers/list.query.js +9 -2
  89. package/dist/jobs/handlers/retry.write.js +3 -2
  90. package/dist/jobs/handlers/tenant-failures.query.js +3 -9
  91. package/dist/jobs/job-run-logger.js +18 -2
  92. package/dist/jobs/tenant-job-failure-table.js +2 -6
  93. package/dist/ledger/handlers/confirm-schedule-period.write.js +2 -2
  94. package/dist/ledger/handlers/reverse-transaction.write.js +2 -2
  95. package/dist/ledger/subject-reference.d.ts +4 -0
  96. package/dist/ledger/subject-reference.js +7 -0
  97. package/dist/legal-pages/feature.js +8 -1
  98. package/dist/managed-pages/handlers/branding.query.js +1 -1
  99. package/dist/managed-pages/handlers/by-slug.query.js +1 -1
  100. package/dist/managed-pages/handlers/by-tenant-published.query.js +1 -1
  101. package/dist/metrics/compute.js +7 -1
  102. package/dist/metrics/window.js +5 -0
  103. package/dist/notes-history/handlers/add-note.write.js +4 -9
  104. package/dist/notes-history-user-data/hooks.js +8 -1
  105. package/dist/personal-access-tokens/constants.d.ts +1 -0
  106. package/dist/personal-access-tokens/constants.js +1 -0
  107. package/dist/personal-access-tokens/feature.js +2 -2
  108. package/dist/personal-access-tokens/handlers/create.write.js +2 -2
  109. package/dist/personal-access-tokens/handlers/revoke.write.js +2 -2
  110. package/dist/personal-access-tokens/i18n.js +12 -13
  111. package/dist/personal-access-tokens/schema/api-token.js +2 -1
  112. package/dist/personal-access-tokens/screens.js +5 -1
  113. package/dist/rate-limiting/feature.js +2 -0
  114. package/dist/rate-limiting/handlers/bucket-access.js +15 -7
  115. package/dist/rate-limiting/i18n.d.ts +10 -0
  116. package/dist/rate-limiting/i18n.js +10 -0
  117. package/dist/secrets/feature.js +3 -2
  118. package/dist/seo/handlers/seo-config.query.js +1 -1
  119. package/dist/sessions/constants.d.ts +1 -0
  120. package/dist/sessions/constants.js +1 -0
  121. package/dist/sessions/feature.js +5 -3
  122. package/dist/sessions/handlers/revoke-all-for-user.write.d.ts +1 -0
  123. package/dist/sessions/handlers/revoke-all-for-user.write.js +12 -2
  124. package/dist/sessions/handlers/revoke.write.js +2 -2
  125. package/dist/sessions/schema/user-session.js +2 -1
  126. package/dist/sessions/screens.js +5 -1
  127. package/dist/sessions/session-callbacks.js +2 -2
  128. package/dist/shared/index.d.ts +1 -1
  129. package/dist/shared/index.js +1 -1
  130. package/dist/shared/lockout-counter.js +1 -1
  131. package/dist/shared/parent-visibility.d.ts +5 -0
  132. package/dist/shared/parent-visibility.js +17 -1
  133. package/dist/shared/row-bound-grant.js +20 -9
  134. package/dist/shared/single-use-token-store.js +5 -13
  135. package/dist/step-dispatcher/webhook-runner.d.ts +0 -2
  136. package/dist/step-dispatcher/webhook-runner.js +18 -17
  137. package/dist/subscription-mollie/plugin-methods.d.ts +3 -2
  138. package/dist/subscription-mollie/plugin-methods.js +7 -1
  139. package/dist/subscription-mollie/verify-webhook.js +6 -0
  140. package/dist/subscription-stripe/plugin-methods.js +25 -6
  141. package/dist/subscription-stripe/verify-webhook.js +13 -6
  142. package/dist/tags/handlers/assign-tag.write.js +4 -10
  143. package/dist/tags/handlers/remove-tag.write.js +4 -11
  144. package/dist/template-resolver/api.d.ts +2 -1
  145. package/dist/template-resolver/api.js +1 -1
  146. package/dist/template-resolver/handlers/by-slug.query.d.ts +1 -1
  147. package/dist/template-resolver/handlers/by-slug.query.js +2 -2
  148. package/dist/template-resolver/handlers/by-tenant.query.d.ts +3 -2
  149. package/dist/template-resolver/handlers/by-tenant.query.js +2 -2
  150. package/dist/template-resolver/handlers/collection-item.query.d.ts +1 -1
  151. package/dist/template-resolver/handlers/collection-list.query.d.ts +1 -1
  152. package/dist/template-resolver/handlers/collection-shared.d.ts +3 -2
  153. package/dist/template-resolver/handlers/collection-shared.js +1 -1
  154. package/dist/template-resolver/handlers/find-by-id.query.d.ts +1 -1
  155. package/dist/template-resolver/handlers/find-by-id.query.js +1 -1
  156. package/dist/template-resolver/handlers/list.query.d.ts +1 -1
  157. package/dist/template-resolver/handlers/list.query.js +1 -1
  158. package/dist/template-resolver/seeding.js +41 -39
  159. package/dist/template-resolver/table.d.ts +8 -5
  160. package/dist/template-resolver/testing.js +2 -2
  161. package/dist/template-resolver/user-content-table.d.ts +2 -5
  162. package/dist/template-resolver/web/client-plugin.d.ts +1 -1
  163. package/dist/template-resolver-user-data/hooks.js +1 -1
  164. package/dist/tenant/cancel-pending-invitation.d.ts +8 -0
  165. package/dist/tenant/cancel-pending-invitation.js +22 -0
  166. package/dist/tenant/feature.js +4 -2
  167. package/dist/tenant/handlers/cancel-invitation.write.js +6 -21
  168. package/dist/tenant/handlers/member-directory.query.d.ts +1 -0
  169. package/dist/tenant/handlers/member-directory.query.js +17 -4
  170. package/dist/tenant/handlers/remove-member.write.js +9 -14
  171. package/dist/tenant/handlers/team-list.query.js +2 -2
  172. package/dist/tenant/handlers/tenant-directory.query.d.ts +1 -0
  173. package/dist/tenant/handlers/tenant-directory.query.js +13 -3
  174. package/dist/tenant/handlers/update-member-roles.write.d.ts +1 -1
  175. package/dist/tenant/handlers/update-member-roles.write.js +28 -12
  176. package/dist/tenant/i18n.js +4 -0
  177. package/dist/tenant/index.d.ts +1 -1
  178. package/dist/tenant/index.js +1 -1
  179. package/dist/tenant/last-tenant-admin.d.ts +1 -1
  180. package/dist/tenant/last-tenant-admin.js +5 -3
  181. package/dist/tenant/seeding.d.ts +4 -0
  182. package/dist/tenant/seeding.js +9 -2
  183. package/dist/tenant-lifecycle/boot-checks.d.ts +1 -0
  184. package/dist/tenant-lifecycle/boot-checks.js +27 -1
  185. package/dist/tenant-lifecycle/feature.js +2 -1
  186. package/dist/tenant-lifecycle/stages.js +2 -2
  187. package/dist/tier-engine/feature.js +3 -2
  188. package/dist/user/command-schemas.d.ts +2 -2
  189. package/dist/user/handlers/create.write.d.ts +1 -1
  190. package/dist/user/handlers/create.write.js +8 -2
  191. package/dist/user/handlers/update.write.d.ts +1 -1
  192. package/dist/user/handlers/update.write.js +8 -2
  193. package/dist/user-data-rights/db/queries/export-jobs.d.ts +2 -0
  194. package/dist/user-data-rights/feature.js +22 -9
  195. package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +4 -7
  196. package/dist/user-data-rights/handlers/deletion-grace-period.d.ts +3 -2
  197. package/dist/user-data-rights/handlers/deletion-grace-period.js +30 -8
  198. package/dist/user-data-rights/handlers/download-by-token.query.js +1 -1
  199. package/dist/user-data-rights/handlers/request-deletion-by-email.write.js +1 -1
  200. package/dist/user-data-rights/handlers/request-deletion.write.js +3 -6
  201. package/dist/user-data-rights/lib/update-user-lifecycle.js +7 -14
  202. package/dist/user-data-rights/run-export-jobs.d.ts +1 -0
  203. package/dist/user-data-rights/run-export-jobs.js +74 -41
  204. package/dist/user-data-rights/run-forget-cleanup.js +2 -2
  205. package/dist/user-data-rights/run-user-export.js +2 -2
  206. package/dist/user-data-rights-defaults/hooks/file-ref.userdata-hook.js +4 -1
  207. package/dist/user-profile/feature.js +6 -1
  208. package/dist/user-profile/handlers/change-email.write.js +2 -0
  209. package/dist/workflow-runner/db/queries/due-runs.js +6 -9
  210. package/dist/workflow-runner/event-subscriber.js +29 -9
  211. package/dist/workflow-runner/handlers/resume-run.write.js +8 -5
  212. package/dist/workflow-runner/pending-projection.js +8 -1
  213. package/package.json +11 -9
  214. package/src/agent-tools/changes.json +12 -0
  215. package/src/auth-email-password/changes.json +18 -0
  216. package/src/auth-mfa/changes.json +6 -0
  217. package/src/billing-foundation/changes.json +7 -0
  218. package/src/cap-counter/changes.json +7 -0
  219. package/src/cap-overview/changes.json +12 -0
  220. package/src/channel-email/changes.json +6 -0
  221. package/src/config/changes.json +6 -0
  222. package/src/crypto-shredding/changes.json +6 -0
  223. package/src/data-retention/changes.json +12 -0
  224. package/src/delivery/changes.json +7 -0
  225. package/src/derivatives-sharp/changes.json +11 -0
  226. package/src/document-ingest-foundation/changes.json +12 -0
  227. package/src/inbound-mail-foundation/changes.json +12 -0
  228. package/src/jobs/changes.json +13 -0
  229. package/src/legal-pages/changes.json +8 -1
  230. package/src/managed-pages/changes.json +5 -0
  231. package/src/metrics/changes.json +6 -0
  232. package/src/rate-limiting/changes.json +5 -0
  233. package/src/secrets/changes.json +7 -0
  234. package/src/sessions/changes.json +5 -0
  235. package/src/shared/changes.json +6 -0
  236. package/src/step-dispatcher/changes.json +5 -0
  237. package/src/subscription-mollie/changes.json +7 -0
  238. package/src/subscription-stripe/changes.json +11 -0
  239. package/src/template-resolver/changes.json +7 -0
  240. package/src/tenant/changes.json +29 -0
  241. package/src/user-data-rights/changes.json +22 -0
  242. package/src/workflow-runner/changes.json +6 -0
@@ -13,6 +13,7 @@
13
13
  import { defineFeature, EXT_STORAGE_PROVIDER, EXT_TENANT_DATA, } from "@cosmicdrift/kumiko-framework/engine";
14
14
  import { sweepOrphanedDerivativesJob, sweepOrphanedDerivativesPayloadSchema, } from "./handlers/sweep-orphaned-derivatives.job.js";
15
15
  import { fileRefStorageDestroyHook, fileRefTenantDestroyHook } from "./hooks.js";
16
+ const SWEEP_ORPHANED_DERIVATIVES_ESCAPE_HATCH_REASON = "iterates every tenant and checks fileRef owners per tenant";
16
17
  export function createFilesTenantDataFeature() {
17
18
  return defineFeature("files-tenant-data", (r) => {
18
19
  r.describe("GDPR coverage for the `files` feature's `fileRef` entity: tenant-destroy row purge (EXT_TENANT_DATA) and full storage-prefix binary wipe (EXT_STORAGE_PROVIDER), plus a manual backfill/GC job that sweeps derivatives orphaned by a forget/tenant-destroy that ran before #2461 wired binary cleanup into those flows. Requires `tenant-lifecycle`, optionalRequires `files`.");
@@ -31,9 +32,9 @@ export function createFilesTenantDataFeature() {
31
32
  concurrency: "skip",
32
33
  schema: sweepOrphanedDerivativesPayloadSchema,
33
34
  escapeHatch: {
34
- reason: "iterates every tenant and checks fileRef owners per tenant",
35
+ reason: SWEEP_ORPHANED_DERIVATIVES_ESCAPE_HATCH_REASON,
35
36
  },
36
- handler: (payload, ctx) => sweepOrphanedDerivativesJob(payload, ctx, ctx.db.unsafeRaw("iterates every tenant and checks fileRef owners per tenant")),
37
+ handler: (payload, ctx) => sweepOrphanedDerivativesJob(payload, ctx, ctx.db.unsafeRaw(SWEEP_ORPHANED_DERIVATIVES_ESCAPE_HATCH_REASON)),
37
38
  });
38
39
  });
39
40
  }
@@ -1,5 +1,4 @@
1
- import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
2
- import { joinRowParentIsVisible } from "../../shared/index.js";
1
+ import { denyUnlessJoinRowParentVisible } from "../../shared/index.js";
3
2
  import { folderAssignmentAggregateId } from "../aggregate-id.js";
4
3
  import { DEFAULT_FOLDER_ACCESS } from "../constants.js";
5
4
  import { folderAssignmentExecutor } from "../executor.js";
@@ -19,15 +18,9 @@ export function createClearFolderHandler(access = DEFAULT_FOLDER_ACCESS) {
19
18
  description: "Unfiles a host entity so it sits in no folder at all, reporting success when it was already unfiled; use it to take an entity out of its folder without touching the folder itself.",
20
19
  handler: async (event, ctx) => {
21
20
  const payload = event.payload; // @cast-boundary engine-payload
22
- // entityType/entityId are client input: the caller must be able to see
23
- // the host row through that entity's own read path (tenant scope plus its
24
- // `access.read` ownership) before its assignment row may be written.
25
- // Checked FIRST, ahead of every lookup below, so a denied caller can't tell
26
- // an invisible parent apart from a missing assignment or an unknown folder —
27
- // every path answers with the same NotFoundError.
28
- if (!(await joinRowParentIsVisible(ctx.registry, "folder-assignment", payload, event.user, ctx.db))) {
29
- return writeFailure(new NotFoundError(payload.entityType, payload.entityId));
30
- }
21
+ const denied = await denyUnlessJoinRowParentVisible(ctx.registry, "folder-assignment", payload, event.user, ctx.db);
22
+ if (denied)
23
+ return denied;
31
24
  const id = folderAssignmentAggregateId(event.user.tenantId, payload.entityType, payload.entityId);
32
25
  const existing = await folderAssignmentExecutor.detail({ id }, event.user, ctx.db);
33
26
  if (!existing) {
@@ -1,5 +1,5 @@
1
1
  import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
2
- import { joinRowParentIsVisible } from "../../shared/index.js";
2
+ import { denyUnlessJoinRowParentVisible } from "../../shared/index.js";
3
3
  import { folderAssignmentAggregateId } from "../aggregate-id.js";
4
4
  import { DEFAULT_FOLDER_ACCESS } from "../constants.js";
5
5
  import { folderAssignmentExecutor, folderExecutor } from "../executor.js";
@@ -35,15 +35,9 @@ export function createSetFolderHandler(access = DEFAULT_FOLDER_ACCESS) {
35
35
  description: "Files a host entity into one existing folder and moves it out of whatever folder it was in, since an entity belongs to at most one folder; use it for both the first filing and every later move.",
36
36
  handler: async (event, ctx) => {
37
37
  const payload = event.payload; // @cast-boundary engine-payload
38
- // entityType/entityId are client input: the caller must be able to see
39
- // the host row through that entity's own read path (tenant scope plus its
40
- // `access.read` ownership) before its assignment row may be written.
41
- // Checked FIRST, ahead of every lookup below, so a denied caller can't tell
42
- // an invisible parent apart from a missing assignment or an unknown folder —
43
- // every path answers with the same NotFoundError.
44
- if (!(await joinRowParentIsVisible(ctx.registry, "folder-assignment", payload, event.user, ctx.db))) {
45
- return writeFailure(new NotFoundError(payload.entityType, payload.entityId));
46
- }
38
+ const denied = await denyUnlessJoinRowParentVisible(ctx.registry, "folder-assignment", payload, event.user, ctx.db);
39
+ if (denied)
40
+ return denied;
47
41
  const id = folderAssignmentAggregateId(event.user.tenantId, payload.entityType, payload.entityId);
48
42
  const folder = await folderExecutor.detail({ id: payload.folderId }, event.user, ctx.db);
49
43
  if (!folder)
@@ -12,6 +12,7 @@ import { discardDraftWrite } from "./handlers/discard.write.js";
12
12
  import { getDraftQuery } from "./handlers/get.query.js";
13
13
  import { listDraftsQuery } from "./handlers/list.query.js";
14
14
  import { saveDraftWrite } from "./handlers/save.write.js";
15
+ const CLEANUP_ESCAPE_HATCH_REASON = "purges stale drafts of every tenant";
15
16
  function registerFormDraft(r) {
16
17
  r.describe("Per-user, per-tenant working copy of an in-progress form, saved BEFORE the real domain entity exists. Owns one event-sourced entity, `form-draft` (`read_form_drafts`), keyed by a caller-assigned draftKey (typically screenId + optional hostEntityId), unique per (tenant, owner, draftKey). `save` upserts the draft blob ({ values, stepIndex, savedAt } — savedAt stamped server-side), `discard` deletes it (called once the real submit succeeds), `get` resumes it, `list` finds a user's open drafts for a given screenId (draftKey prefix match) — the fallback when a client-generated draftId is lost. Ownership is enforced by a per-row owner filter in every handler, not by roles — a foreign user's save/discard/get/list for someone else's draftKey never sees or touches that row. Never holds anything that already lives in a domain stream; the consuming app is responsible for discarding once the domain write succeeds. A daily cron job hard-deletes drafts past a configurable retention window (`form-draft:config:retention-days`, SystemAdmin-writable, default 30 days).");
17
18
  r.uiHints({
@@ -36,8 +37,8 @@ function registerFormDraft(r) {
36
37
  name: "cleanup",
37
38
  trigger: { cron: "0 3 * * *" },
38
39
  concurrency: "skip",
39
- escapeHatch: { reason: "purges stale drafts of every tenant" },
40
- handler: (payload, ctx) => cleanupDraftsJob(payload, ctx, ctx.db.unsafeRaw("purges stale drafts of every tenant")),
40
+ escapeHatch: { reason: CLEANUP_ESCAPE_HATCH_REASON },
41
+ handler: (payload, ctx) => cleanupDraftsJob(payload, ctx, ctx.db.unsafeRaw(CLEANUP_ESCAPE_HATCH_REASON)),
41
42
  });
42
43
  }
43
44
  export const formDraftFeature = defineFeature(FORM_DRAFT_FEATURE_NAME, registerFormDraft);
@@ -73,18 +73,17 @@ export const discardDraftWrite = defineWriteHandler({
73
73
  const keys = ownedRefs.map((ref) => ref.storageKey);
74
74
  const log = ctx.log;
75
75
  const schedule = ctx.scheduleAfterCommit;
76
+ const release = async () => {
77
+ await releaseDraftFileRefs(keys, async (key) => {
78
+ await files.ref(key).delete();
79
+ }, log);
80
+ };
76
81
  if (schedule) {
77
- schedule(async () => {
78
- await releaseDraftFileRefs(keys, async (key) => {
79
- await files.ref(key).delete();
80
- }, log);
81
- });
82
+ schedule(release);
82
83
  }
83
84
  else {
84
85
  // Unit/harness paths without a commit sink — best-effort inline.
85
- await releaseDraftFileRefs(keys, async (key) => {
86
- await files.ref(key).delete();
87
- }, log);
86
+ await release();
88
87
  }
89
88
  }
90
89
  return { isSuccess: true, data: { discarded: true } };
@@ -16,6 +16,12 @@ export const getDraftQuery = defineQueryHandler({
16
16
  if (!row)
17
17
  return { draft: null };
18
18
  const parsed = formDraftBlobSchema.safeParse(row.draft);
19
- return { draft: parsed.success ? parsed.data : null };
19
+ if (!parsed.success) {
20
+ // Schema evolution or a corrupt jsonb row: surface it to operators
21
+ // instead of silently making the user's draft vanish.
22
+ ctx.log?.warn?.(`[form-draft:get] stored blob for draftKey=${query.payload.draftKey} failed schema, treating as absent: ${parsed.error.message}`);
23
+ return { draft: null };
24
+ }
25
+ return { draft: parsed.data };
20
26
  },
21
27
  });
@@ -0,0 +1,4 @@
1
+ /** Parses a comma-separated operator host allowlist env var. Never throws —
2
+ * an unset or empty value just means no bypass. */
3
+ export declare function readHostAllowlistFromEnv(envVar: string, env?: Readonly<Record<string, string | undefined>>): readonly string[];
4
+ export declare function isHostAllowlisted(host: string, allowlist: readonly string[]): boolean;
@@ -0,0 +1,14 @@
1
+ /** Parses a comma-separated operator host allowlist env var. Never throws —
2
+ * an unset or empty value just means no bypass. */
3
+ export function readHostAllowlistFromEnv(envVar, env = process.env) {
4
+ const raw = env[envVar];
5
+ if (!raw)
6
+ return [];
7
+ return raw
8
+ .split(",")
9
+ .map((host) => host.trim())
10
+ .filter((host) => host.length > 0);
11
+ }
12
+ export function isHostAllowlisted(host, allowlist) {
13
+ return allowlist.some((candidate) => candidate.toLowerCase() === host.toLowerCase());
14
+ }
@@ -1,2 +1,3 @@
1
1
  export { requireDefined, requireNonEmpty, requireSecretSet } from "./config-helpers.js";
2
+ export { isHostAllowlisted, readHostAllowlistFromEnv } from "./host-allowlist.js";
2
3
  export { BlockedHostError, HostResolutionError, MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR, type MailConnectTarget, type MailHostGuardOptions, readAllowedPrivateMailHostsFromEnv, resolveMailConnectTarget, } from "./mail-host-policy.js";
@@ -1,4 +1,5 @@
1
1
  // Public API of foundation-shared — utilities consumed by the per-tenant
2
2
  // Foundation packages (ai-foundation, mail-foundation, file-foundation).
3
3
  export { requireDefined, requireNonEmpty, requireSecretSet } from "./config-helpers.js";
4
+ export { isHostAllowlisted, readHostAllowlistFromEnv } from "./host-allowlist.js";
4
5
  export { BlockedHostError, HostResolutionError, MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR, readAllowedPrivateMailHostsFromEnv, resolveMailConnectTarget, } from "./mail-host-policy.js";
@@ -1,7 +1,5 @@
1
1
  import type { lookup } from "node:dns/promises";
2
2
  export declare const MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR = "KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS";
3
- /** Parses the comma-separated operator allowlist env var. Never throws —
4
- * an unset or empty value just means no bypass. */
5
3
  export declare function readAllowedPrivateMailHostsFromEnv(env?: Readonly<Record<string, string | undefined>>): readonly string[];
6
4
  export type MailHostGuardOptions = {
7
5
  readonly allowedPrivateMailHosts?: readonly string[];
@@ -16,23 +16,13 @@
16
16
  // conflict check tripping when both features are mounted together.
17
17
  import { isIP } from "node:net";
18
18
  import { resolvePublicHostname } from "@cosmicdrift/kumiko-framework/http";
19
+ import { isHostAllowlisted, readHostAllowlistFromEnv } from "./host-allowlist.js";
19
20
  export const MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR = "KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS";
20
- /** Parses the comma-separated operator allowlist env var. Never throws —
21
- * an unset or empty value just means no bypass. */
22
21
  export function readAllowedPrivateMailHostsFromEnv(env = process.env) {
23
- const raw = env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR];
24
- if (!raw)
25
- return [];
26
- return raw
27
- .split(",")
28
- .map((host) => host.trim())
29
- .filter((host) => host.length > 0);
30
- }
31
- function isAllowedPrivateMailHost(host, allowed) {
32
- return allowed.some((candidate) => candidate.toLowerCase() === host.toLowerCase());
22
+ return readHostAllowlistFromEnv(MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR, env);
33
23
  }
34
24
  export async function resolveMailConnectTarget(host, options = {}) {
35
- if (isAllowedPrivateMailHost(host, options.allowedPrivateMailHosts ?? [])) {
25
+ if (isHostAllowlisted(host, options.allowedPrivateMailHosts ?? [])) {
36
26
  return { host };
37
27
  }
38
28
  const resolved = await resolvePublicHostname(host, options.lookupFn);
@@ -73,6 +73,12 @@ export function createInboundMailConnectRoutes(options) {
73
73
  verify: async (request) => verifyCallbackRequest(request, options.stateSecret),
74
74
  handler: async (c, verified, deps) => {
75
75
  const { code, state } = verified;
76
+ // Checked before exchangeCode/connectAccount: the code is single-use and
77
+ // an account created without a persistable refresh token is an orphan.
78
+ // `deps.secrets` is optional on SignatureExtraRouteDeps.
79
+ if (!deps.secrets) {
80
+ return errorJson(c, 500, "secrets_context_missing", `${INBOUND_MAIL_FOUNDATION_FEATURE}: no secrets context wired for the oauth callback route — the refresh token cannot be persisted`);
81
+ }
76
82
  const providerCtx = { registry: deps.registry, secrets: deps.secrets, config: undefined };
77
83
  let plugin;
78
84
  try {
@@ -123,11 +129,7 @@ export function createInboundMailConnectRoutes(options) {
123
129
  }
124
130
  // Refresh-token into the per-account secret slot (Slot = accountId).
125
131
  // Access tokens (~1h) are NEVER persisted — refresh-before-poll in the
126
- // sync path. `deps.secrets` is optional on SignatureExtraRouteDeps —
127
- // without it the token would be silently lost, so fail loud instead.
128
- if (!deps.secrets) {
129
- return errorJson(c, 500, "secrets_context_missing", `${INBOUND_MAIL_FOUNDATION_FEATURE}: no secrets context wired for the oauth callback route — the refresh token cannot be persisted`);
130
- }
132
+ // sync path.
131
133
  await deps.secrets.set(state.tenantId, inboundCredentialSecretKey(accountId), tokens.refreshToken, {
132
134
  redact: (plaintext) => `${plaintext.slice(0, 4)}…`,
133
135
  hint: `OAuth refresh token for inbound mail account ${accountId}`,
@@ -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.
@@ -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");
@@ -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);