@cosmicdrift/kumiko-bundled-features 0.387.0 → 0.388.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/dist/agent-tools/tool-dispatch.d.ts +7 -7
  2. package/dist/agent-tools/tool-dispatch.js +9 -9
  3. package/dist/audit/handlers/audit-events.js +1 -1
  4. package/dist/auth-mfa/feature-exports.d.ts +2 -2
  5. package/dist/auth-mfa/feature.js +8 -0
  6. package/dist/auth-mfa/web/i18n.js +5 -1
  7. package/dist/auth-mfa/web/mfa-code-or-recovery-field.d.ts +11 -0
  8. package/dist/auth-mfa/web/mfa-code-or-recovery-field.js +12 -0
  9. package/dist/auth-mfa/web/mfa-disable-dialog.js +14 -3
  10. package/dist/auth-mfa/web/mfa-regenerate-recovery-dialog.js +14 -3
  11. package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +10 -5
  12. package/dist/auth-mfa/web/mfa-verify-screen.js +26 -5
  13. package/dist/billing-foundation/checkout-core.d.ts +9 -14
  14. package/dist/billing-foundation/checkout-core.js +24 -39
  15. package/dist/billing-foundation/consumer-protection/confirmation-mail.js +8 -2
  16. package/dist/billing-foundation/consumer-protection/consent-gate.d.ts +1 -0
  17. package/dist/billing-foundation/consumer-protection/consent-gate.js +1 -0
  18. package/dist/billing-foundation/consumer-protection/issue-confirmation.js +9 -3
  19. package/dist/billing-foundation/consumer-protection/mail-notifications.js +3 -1
  20. package/dist/billing-foundation/consumer-protection/termination-receipt-limit.d.ts +4 -2
  21. package/dist/billing-foundation/consumer-protection/termination-receipt-limit.js +6 -5
  22. package/dist/billing-foundation/consumer-protection/termination-record.d.ts +5 -1
  23. package/dist/billing-foundation/consumer-protection/termination-record.js +27 -17
  24. package/dist/billing-foundation/consumer-protection/termination-request.js +12 -7
  25. package/dist/billing-foundation/consumer-protection/termination-revoke.js +12 -7
  26. package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +1 -1
  27. package/dist/billing-foundation/consumer-protection/termination-texts.js +3 -0
  28. package/dist/billing-foundation/consumer-protection/withdrawal-period.d.ts +5 -2
  29. package/dist/billing-foundation/consumer-protection/withdrawal-period.js +8 -9
  30. package/dist/billing-foundation/events.d.ts +1 -0
  31. package/dist/billing-foundation/events.js +1 -0
  32. package/dist/billing-foundation/feature.js +3 -1
  33. package/dist/billing-foundation/handlers/billing-plans.query.js +35 -5
  34. package/dist/billing-foundation/handlers/create-checkout-session.write.js +1 -0
  35. package/dist/billing-foundation/handlers/plan-switch-preview.query.d.ts +13 -0
  36. package/dist/billing-foundation/handlers/plan-switch-preview.query.js +48 -0
  37. package/dist/billing-foundation/handlers/process-event.write.d.ts +6 -0
  38. package/dist/billing-foundation/handlers/process-event.write.js +8 -2
  39. package/dist/billing-foundation/handlers/start-plan-checkout.write.js +32 -5
  40. package/dist/billing-foundation/handlers/switch-plan.write.d.ts +2 -2
  41. package/dist/billing-foundation/handlers/switch-plan.write.js +56 -64
  42. package/dist/billing-foundation/handlers/sync-subscription.write.js +12 -1
  43. package/dist/billing-foundation/i18n.js +22 -0
  44. package/dist/billing-foundation/index.d.ts +1 -1
  45. package/dist/billing-foundation/plan-catalog.d.ts +21 -5
  46. package/dist/billing-foundation/plan-catalog.js +25 -16
  47. package/dist/billing-foundation/plan-switch.d.ts +12 -0
  48. package/dist/billing-foundation/plan-switch.js +60 -0
  49. package/dist/billing-foundation/types.d.ts +57 -1
  50. package/dist/billing-foundation/types.js +9 -0
  51. package/dist/billing-foundation/validate-options.js +17 -0
  52. package/dist/billing-foundation/web/billing-plans-panel.js +78 -32
  53. package/dist/billing-foundation/web/checkout-consent-dialog.d.ts +17 -3
  54. package/dist/billing-foundation/web/checkout-consent-dialog.js +14 -10
  55. package/dist/billing-foundation/web/constants.d.ts +4 -0
  56. package/dist/billing-foundation/web/constants.js +4 -0
  57. package/dist/billing-foundation/web/plan-price.d.ts +15 -0
  58. package/dist/billing-foundation/web/plan-price.js +32 -0
  59. package/dist/billing-foundation/web/plan-switch-confirm-dialog.d.ts +15 -0
  60. package/dist/billing-foundation/web/plan-switch-confirm-dialog.js +66 -0
  61. package/dist/cap-counter/cap-reservation.js +5 -1
  62. package/dist/cap-counter/enforce-cap.js +2 -1
  63. package/dist/cap-counter/stock-cap-guard.js +7 -1
  64. package/dist/channel-email/feature.js +1 -1
  65. package/dist/config/handlers/cascade.query.d.ts +5 -0
  66. package/dist/config/handlers/cascade.query.js +4 -3
  67. package/dist/config/handlers/values.query.d.ts +5 -0
  68. package/dist/config/handlers/values.query.js +4 -3
  69. package/dist/config/write-helpers.d.ts +1 -0
  70. package/dist/config/write-helpers.js +18 -9
  71. package/dist/crypto-shredding/handlers/forget-subject.write.js +13 -10
  72. package/dist/delivery/channel-context.js +6 -3
  73. package/dist/delivery/db/queries/preferences.js +2 -2
  74. package/dist/delivery/delivery-service.js +22 -7
  75. package/dist/delivery/handlers/preferences.query.d.ts +1 -1
  76. package/dist/delivery/handlers/preferences.query.js +20 -1
  77. package/dist/delivery/handlers/unsubscribe-page-chrome.query.js +1 -1
  78. package/dist/delivery/unsubscribe-link.d.ts +3 -1
  79. package/dist/delivery/unsubscribe-page-strings.d.ts +1 -0
  80. package/dist/delivery/unsubscribe-page-strings.js +1 -0
  81. package/dist/delivery/unsubscribe-page.d.ts +2 -0
  82. package/dist/delivery/unsubscribe-page.js +5 -0
  83. package/dist/delivery/unsubscribe.js +25 -7
  84. package/dist/file-foundation/index.d.ts +1 -1
  85. package/dist/file-foundation/index.js +1 -1
  86. package/dist/file-foundation/read-file-ref-content.d.ts +3 -0
  87. package/dist/file-foundation/read-file-ref-content.js +6 -1
  88. package/dist/file-provider-s3/env-named-feature.d.ts +0 -2
  89. package/dist/form-draft/db/queries/cleanup.js +2 -2
  90. package/dist/form-draft/decrypt-draft.js +5 -1
  91. package/dist/host-routing/dispatch.js +11 -20
  92. package/dist/inbound-mail-foundation/handlers/update-account.write.d.ts +2 -2
  93. package/dist/inbound-mail-foundation/handlers/update-account.write.js +12 -4
  94. package/dist/inbound-mail-foundation/i18n.js +0 -1
  95. package/dist/inbound-mail-foundation/screens.js +0 -3
  96. package/dist/inbound-mail-foundation/types.d.ts +4 -3
  97. package/dist/inbound-mail-foundation/watch-supervisor.js +28 -11
  98. package/dist/jobs/feature.d.ts +1 -0
  99. package/dist/jobs/feature.js +8 -4
  100. package/dist/jobs/handlers/tenant-failures.query.d.ts +1 -1
  101. package/dist/jobs/handlers/tenant-failures.query.js +45 -41
  102. package/dist/jobs/handlers/tenant-runs.query.d.ts +1 -1
  103. package/dist/jobs/handlers/tenant-runs.query.js +54 -50
  104. package/dist/jobs/job-run-logger.js +5 -4
  105. package/dist/mail-foundation/feature.d.ts +3 -3
  106. package/dist/mail-foundation/feature.js +3 -0
  107. package/dist/personal-access-tokens/feature.d.ts +1 -1
  108. package/dist/personal-access-tokens/feature.js +4 -0
  109. package/dist/renderer-pdf/render-table-pdf.d.ts +1 -1
  110. package/dist/renderer-pdf/render-table-pdf.js +22 -10
  111. package/dist/sessions/feature.d.ts +2 -2
  112. package/dist/sessions/feature.js +4 -0
  113. package/dist/shared/decrypt-stored-pii.d.ts +1 -0
  114. package/dist/shared/decrypt-stored-pii.js +6 -1
  115. package/dist/shared/index.d.ts +1 -1
  116. package/dist/shared/index.js +1 -1
  117. package/dist/shared/mail-template-body.d.ts +6 -2
  118. package/dist/shared/mail-template-body.js +7 -4
  119. package/dist/step-dispatcher/feature.js +1 -0
  120. package/dist/subscription-dodo/client.d.ts +17 -0
  121. package/dist/subscription-dodo/client.js +31 -0
  122. package/dist/subscription-dodo/constants.d.ts +12 -0
  123. package/dist/subscription-dodo/constants.js +19 -0
  124. package/dist/subscription-dodo/feature.d.ts +37 -0
  125. package/dist/subscription-dodo/feature.js +144 -0
  126. package/dist/subscription-dodo/index.d.ts +2 -0
  127. package/dist/subscription-dodo/index.js +4 -0
  128. package/dist/subscription-dodo/plugin-methods.d.ts +23 -0
  129. package/dist/subscription-dodo/plugin-methods.js +260 -0
  130. package/dist/subscription-dodo/runtime.d.ts +27 -0
  131. package/dist/subscription-dodo/runtime.js +89 -0
  132. package/dist/subscription-dodo/verify-webhook.d.ts +37 -0
  133. package/dist/subscription-dodo/verify-webhook.js +189 -0
  134. package/dist/subscription-stripe/feature.js +1 -0
  135. package/dist/subscription-stripe/runtime.js +7 -6
  136. package/dist/template-resolver/api.d.ts +3 -2
  137. package/dist/template-resolver/api.js +24 -2
  138. package/dist/template-resolver/compile-document-body.js +1 -0
  139. package/dist/template-resolver/feature.js +2 -2
  140. package/dist/template-resolver/handlers/bundle-import.js +3 -3
  141. package/dist/template-resolver/handlers/copy-locale.write.d.ts +5 -1
  142. package/dist/template-resolver/handlers/copy-locale.write.js +22 -7
  143. package/dist/template-resolver/handlers/diff-to-system.query.js +3 -2
  144. package/dist/template-resolver/handlers/document-base.js +3 -2
  145. package/dist/template-resolver/handlers/find-by-id.query.js +3 -2
  146. package/dist/template-resolver/handlers/list.query.js +5 -6
  147. package/dist/template-resolver/handlers/overview.query.d.ts +3 -0
  148. package/dist/template-resolver/handlers/overview.query.js +6 -3
  149. package/dist/template-resolver/handlers/tenant-override-counts.query.js +1 -3
  150. package/dist/template-resolver/handlers/tenant-override-selection.d.ts +2 -1
  151. package/dist/template-resolver/handlers/tenant-overrides.query.js +28 -5
  152. package/dist/template-resolver/handlers/usage.query.d.ts +2 -1
  153. package/dist/template-resolver/handlers/usage.query.js +26 -16
  154. package/dist/template-resolver/handlers/validate.query.js +7 -2
  155. package/dist/template-resolver/seeding.js +26 -10
  156. package/dist/template-resolver/store-template-resource.d.ts +1 -0
  157. package/dist/template-resolver/store-template-resource.js +19 -4
  158. package/dist/template-resolver/template-delivery-policy.d.ts +0 -5
  159. package/dist/template-resolver/template-delivery-policy.js +0 -6
  160. package/dist/template-resolver/template-meta.js +7 -4
  161. package/dist/template-resolver/template-preview.js +4 -3
  162. package/dist/template-resolver/web/constants.d.ts +2 -2
  163. package/dist/template-resolver/web/constants.js +3 -3
  164. package/dist/template-resolver/web/index.d.ts +1 -0
  165. package/dist/template-resolver/web/index.js +1 -0
  166. package/dist/template-resolver/web/mail-layout-constants.d.ts +5 -0
  167. package/dist/template-resolver/web/mail-layout-constants.js +7 -0
  168. package/dist/tenant/handlers/member-directory.query.d.ts +1 -0
  169. package/dist/tenant/handlers/member-directory.query.js +10 -3
  170. package/dist/tenant/handlers/tenant-directory.query.d.ts +1 -0
  171. package/dist/tenant/handlers/tenant-directory.query.js +8 -4
  172. package/dist/tenant-lifecycle/constants.d.ts +1 -0
  173. package/dist/tenant-lifecycle/constants.js +4 -0
  174. package/dist/tenant-lifecycle/run-tenant-destroy.js +2 -2
  175. package/dist/tier-engine/handlers/set-tenant-tier.write.d.ts +2 -3
  176. package/dist/tier-engine/handlers/set-tenant-tier.write.js +4 -1
  177. package/dist/tier-engine/web/constants.js +2 -2
  178. package/dist/user-data-rights/run-export-jobs.js +3 -4
  179. package/dist/user-data-rights/run-forget-cleanup.js +2 -1
  180. package/dist/waitlist/handlers/export-purge.write.js +2 -1
  181. package/dist/waitlist/handlers/submit.write.js +5 -5
  182. package/dist/waitlist/import.d.ts +1 -0
  183. package/dist/waitlist/import.js +4 -0
  184. package/dist/waitlist/legacy-migrate.js +17 -6
  185. package/dist/waitlist/link-on-signup.js +18 -6
  186. package/dist/waitlist/options.d.ts +3 -0
  187. package/package.json +19 -9
  188. package/src/agent-tools/changes.json +6 -0
  189. package/src/audit/changes.json +12 -0
  190. package/src/auth-mfa/changes.json +6 -0
  191. package/src/billing-foundation/changes.json +55 -0
  192. package/src/cap-counter/changes.json +18 -0
  193. package/src/channel-email/changes.json +6 -0
  194. package/src/config/changes.json +12 -0
  195. package/src/crypto-shredding/changes.json +6 -0
  196. package/src/delivery/changes.json +30 -0
  197. package/src/form-draft/changes.json +6 -0
  198. package/src/host-routing/changes.json +6 -0
  199. package/src/inbound-mail-foundation/changes.json +18 -0
  200. package/src/jobs/changes.json +13 -0
  201. package/src/mail-foundation/changes.json +6 -0
  202. package/src/renderer-pdf/changes.json +6 -0
  203. package/src/step-dispatcher/changes.json +6 -0
  204. package/src/subscription-dodo/changes.json +14 -0
  205. package/src/subscription-stripe/changes.json +12 -0
  206. package/src/template-resolver/changes.json +48 -0
  207. package/src/tenant/changes.json +6 -0
  208. package/src/tenant-lifecycle/changes.json +6 -0
  209. package/src/tier-engine/changes.json +6 -0
  210. package/src/waitlist/changes.json +18 -0
  211. package/dist/billing-foundation/consumer-protection/stored-user-email.d.ts +0 -1
  212. package/dist/billing-foundation/consumer-protection/stored-user-email.js +0 -8
@@ -66,6 +66,7 @@ const DEFAULT_WATCH_LEASE_TTL_SECONDS = 90;
66
66
  /** One cursor per account (one mailbox inbox) for now; multi-folder support
67
67
  * can add further scopes later without a schema change. */
68
68
  const CURSOR_SCOPE = "default";
69
+ const SEEN_LOOKUP_CHUNK_SIZE = 1000;
69
70
  export function createInboundMailSupervisor(deps) {
70
71
  const pollIntervalMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
71
72
  const backfillWindowDays = deps.backfillWindowDays ?? DEFAULT_BACKFILL_WINDOW_DAYS;
@@ -112,11 +113,20 @@ export function createInboundMailSupervisor(deps) {
112
113
  const piiKms = configuredPiiSubjectKms();
113
114
  const records = [];
114
115
  for (const row of rows) {
115
- const decrypted = piiKms
116
- ? await decryptPiiFieldValues(row, MAIL_ACCOUNT_PII_FIELDS, piiKms, {
117
- requestId: "inbound-mail-foundation:supervisor:list-accounts",
118
- })
119
- : row;
116
+ let decrypted;
117
+ try {
118
+ decrypted = piiKms
119
+ ? await decryptPiiFieldValues(row, MAIL_ACCOUNT_PII_FIELDS, piiKms, {
120
+ requestId: "inbound-mail-foundation:supervisor:list-accounts",
121
+ })
122
+ : row;
123
+ }
124
+ catch (error) {
125
+ // Class name only: the message may carry ciphertext or key details.
126
+ const errorName = error instanceof Error ? error.name : "unknown error";
127
+ log(`inbound-mail: account ${row.id} skipped — address did not decrypt (${errorName})`);
128
+ continue;
129
+ }
120
130
  const decryptedAddress = decrypted["address"];
121
131
  // Falling back to row.address would hand the ciphertext to a provider.
122
132
  // One undecryptable account must not stop polling of every other tenant's accounts.
@@ -258,12 +268,19 @@ export function createInboundMailSupervisor(deps) {
258
268
  return async (providerMessageIds) => {
259
269
  if (providerMessageIds.length === 0)
260
270
  return new Set();
261
- const seen = await selectMany(deps.db, seenMessageTable, {
262
- tenantId: account.tenantId,
263
- accountId: account.id,
264
- providerMessageId: { in: providerMessageIds },
265
- }, { fields: ["providerMessageId"] });
266
- const seenIds = new Set(seen.map((row) => row.providerMessageId));
271
+ // One bind parameter per id: a full-mailbox listing would exceed Postgres' 65535 limit.
272
+ const seenIds = new Set();
273
+ for (let start = 0; start < providerMessageIds.length; start += SEEN_LOOKUP_CHUNK_SIZE) {
274
+ const seen = await selectMany(deps.db, seenMessageTable, {
275
+ tenantId: account.tenantId,
276
+ accountId: account.id,
277
+ providerMessageId: {
278
+ in: providerMessageIds.slice(start, start + SEEN_LOOKUP_CHUNK_SIZE),
279
+ },
280
+ }, { fields: ["providerMessageId"] });
281
+ for (const row of seen)
282
+ seenIds.add(row.providerMessageId);
283
+ }
267
284
  return new Set(providerMessageIds.filter((id) => !seenIds.has(id)));
268
285
  };
269
286
  }
@@ -2,5 +2,6 @@ import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
2
  export type JobsFeatureOptions = {
3
3
  readonly retentionDays?: number;
4
4
  readonly staleRunTimeoutHours?: number;
5
+ readonly readRoles?: readonly string[];
5
6
  };
6
7
  export declare function createJobsFeature(options?: JobsFeatureOptions): FeatureDefinition;
@@ -9,8 +9,8 @@ import { reindexEntityJob, reindexEntityPayloadSchema } from "./handlers/reindex
9
9
  import { createRetentionCleanupJob, DEFAULT_JOB_RUN_RETENTION_DAYS, } from "./handlers/retention-cleanup.job.js";
10
10
  import { retryWrite } from "./handlers/retry.write.js";
11
11
  import { createStaleRunSweepJob, DEFAULT_JOB_RUN_STALE_TIMEOUT_HOURS, } from "./handlers/stale-run-sweep.job.js";
12
- import { tenantFailuresQuery } from "./handlers/tenant-failures.query.js";
13
- import { tenantRunsQuery } from "./handlers/tenant-runs.query.js";
12
+ import { createTenantFailuresQuery } from "./handlers/tenant-failures.query.js";
13
+ import { createTenantRunsQuery } from "./handlers/tenant-runs.query.js";
14
14
  import { triggerWrite } from "./handlers/trigger.write.js";
15
15
  import { jobRunLogsTableMeta, jobRunsTableMeta } from "./job-run-table.js";
16
16
  import { eventConsumerDetailScreen, eventConsumersScreen, jobRunDetailScreen, jobRunsScreen, jobTriggerScreen, } from "./screens.js";
@@ -20,6 +20,10 @@ import { JOBS_I18N } from "./web/i18n.js";
20
20
  export function createJobsFeature(options = {}) {
21
21
  const retentionDays = options.retentionDays ?? DEFAULT_JOB_RUN_RETENTION_DAYS;
22
22
  const staleRunTimeoutHours = options.staleRunTimeoutHours ?? DEFAULT_JOB_RUN_STALE_TIMEOUT_HOURS;
23
+ const readRoles = options.readRoles ?? [];
24
+ if (readRoles.includes("anonymous")) {
25
+ throw new Error(`createJobsFeature: readRoles must not contain "anonymous" — job status and failures are only readable by members of the tenant.`);
26
+ }
23
27
  return defineFeature("jobs", (r) => {
24
28
  r.describe("Persistence and operator tooling for background jobs registered via `r.job(...)`. Every job execution writes directly into `store_job_runs` (current status + duration) and `store_job_run_logs` (per-line log rows) from the BullMQ callbacks — no event stream in between (#2243). A daily `retention-cleanup` job deletes runs (and their logs) older than `retentionDays`; an hourly `stale-run-sweep` job marks runs stuck at status `running` past `staleRunTimeoutHours` as `failed` (#2246 — a crashed worker never fires the completion callback, so nothing else ever revisits the row). Exposes `jobs:write:trigger` (manual run) and `jobs:write:retry` (operator retry of a failed run), plus `jobs:query:list`, `jobs:query:details`, and `jobs:query:catalog` (manual jobs) for the operator UI. A job that declares `tenantVisibleFailure` also records its last failed attempt per tenant and subject in `store_tenant_job_failures`, which the tenant itself reads through `jobs:query:failures` — a translation key only, never the provider's message (fw#3079). A job that declares `tenantVisibleRun` also keeps its run state per tenant and subject in `store_tenant_job_runs` (queued, running, plus the latest completed or failed run, no payload, error text or logs), which the tenant reads through `jobs:query:tenant-runs` (fw#3616); the stale-run sweep fails its stuck `running` rows and drops `queued` rows older than `retentionDays`.");
25
29
  r.uiHints({
@@ -79,8 +83,8 @@ export function createJobsFeature(options = {}) {
79
83
  list: r.queryHandler(listQuery),
80
84
  detail: r.queryHandler(detailQuery),
81
85
  catalog: r.queryHandler(catalogQuery),
82
- failures: r.queryHandler(tenantFailuresQuery),
83
- tenantRuns: r.queryHandler(tenantRunsQuery),
86
+ failures: r.queryHandler(createTenantFailuresQuery(readRoles)),
87
+ tenantRuns: r.queryHandler(createTenantRunsQuery(readRoles)),
84
88
  consumers: r.queryHandler(consumersQuery),
85
89
  consumerDetails: r.queryHandler(consumerDetailQuery),
86
90
  };
@@ -1,5 +1,5 @@
1
1
  import * as z from "zod";
2
- export declare const tenantFailuresQuery: import("@cosmicdrift/kumiko-types/define-handler").QueryHandlerDefinition<"failures", z.ZodObject<{
2
+ export declare function createTenantFailuresQuery(readRoles: readonly string[]): import("@cosmicdrift/kumiko-types/define-handler").QueryHandlerDefinition<"failures", z.ZodObject<{
3
3
  jobName: z.ZodOptional<z.ZodString>;
4
4
  subject: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>>;
5
5
  limit: z.ZodOptional<z.ZodNumber>;
@@ -20,45 +20,49 @@ function parseSubject(stored) {
20
20
  return null;
21
21
  return Object.fromEntries(parsed.filter(isSubjectEntry));
22
22
  }
23
- export const tenantFailuresQuery = defineQueryHandler({
24
- name: "failures",
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.",
26
- schema: z.object({
27
- jobName: z.string().optional(),
28
- subject: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
29
- limit: z.number().int().min(1).max(200).optional(),
30
- }),
31
- // Every membership rank: a failure record carries a job name and a
32
- // translation key, nothing a team member of the tenant may not see.
33
- access: { roles: access.roles("User", "Editor", ...access.admin) },
34
- handler: async (query, ctx) => {
35
- if (!ctx.systemDb) {
36
- throw new InternalError({
37
- message: "jobs:query:failures requires ctx.systemDb (feature must declare r.systemScope())",
23
+ export function createTenantFailuresQuery(readRoles) {
24
+ return defineQueryHandler({
25
+ name: "failures",
26
+ 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.",
27
+ schema: z.object({
28
+ jobName: z.string().optional(),
29
+ subject: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
30
+ limit: z.number().int().min(1).max(200).optional(),
31
+ }),
32
+ // Every membership rank: a failure record carries a job name and a
33
+ // translation key, nothing a team member of the tenant may not see.
34
+ access: {
35
+ roles: [...new Set([...access.roles("User", "Editor", ...access.admin), ...readRoles])],
36
+ },
37
+ handler: async (query, ctx) => {
38
+ if (!ctx.systemDb) {
39
+ throw new InternalError({
40
+ message: "jobs:query:failures requires ctx.systemDb (feature must declare r.systemScope())",
41
+ });
42
+ }
43
+ // assertTenantMatch is a self-check on the caller and returns the same
44
+ // unfiltered system-mode db (tenant-db.ts) — the explicit tenantId below
45
+ // is the filter, assertRowsTenant the second net. There is no
46
+ // cross-tenant mode here: SystemAdmin reads runs via jobs:query:list.
47
+ const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
48
+ const where = { tenantId: [query.user.tenantId] };
49
+ if (query.payload.jobName)
50
+ where["jobName"] = query.payload.jobName;
51
+ if (query.payload.subject)
52
+ where["subject"] = serializeJobSubject(query.payload.subject);
53
+ const rows = await selectMany(db, tenantJobFailuresTable, where, {
54
+ orderBy: { col: "failedAt", direction: "desc" },
55
+ limit: query.payload.limit ?? DEFAULT_LIMIT,
38
56
  });
39
- }
40
- // assertTenantMatch is a self-check on the caller and returns the same
41
- // unfiltered system-mode db (tenant-db.ts) — the explicit tenantId below
42
- // is the filter, assertRowsTenant the second net. There is no
43
- // cross-tenant mode here: SystemAdmin reads runs via jobs:query:list.
44
- const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
45
- const where = { tenantId: [query.user.tenantId] };
46
- if (query.payload.jobName)
47
- where["jobName"] = query.payload.jobName;
48
- if (query.payload.subject)
49
- where["subject"] = serializeJobSubject(query.payload.subject);
50
- const rows = await selectMany(db, tenantJobFailuresTable, where, {
51
- orderBy: { col: "failedAt", direction: "desc" },
52
- limit: query.payload.limit ?? DEFAULT_LIMIT,
53
- });
54
- return {
55
- rows: ctx.systemDb.assertRowsTenant(rows, "tenantId").map((row) => ({
56
- jobName: row.jobName,
57
- subject: parseSubject(row.subject),
58
- messageKey: row.messageKey,
59
- failedAt: row.failedAt,
60
- })),
61
- nextCursor: null,
62
- };
63
- },
64
- });
57
+ return {
58
+ rows: ctx.systemDb.assertRowsTenant(rows, "tenantId").map((row) => ({
59
+ jobName: row.jobName,
60
+ subject: parseSubject(row.subject),
61
+ messageKey: row.messageKey,
62
+ failedAt: row.failedAt,
63
+ })),
64
+ nextCursor: null,
65
+ };
66
+ },
67
+ });
68
+ }
@@ -1,6 +1,6 @@
1
1
  import * as z from "zod";
2
2
  import { type TenantJobRunStatus } from "../tenant-job-run-table.ts";
3
- export declare const tenantRunsQuery: import("@cosmicdrift/kumiko-types/define-handler").QueryHandlerDefinition<"tenant-runs", z.ZodObject<{
3
+ export declare function createTenantRunsQuery(readRoles: readonly string[]): import("@cosmicdrift/kumiko-types/define-handler").QueryHandlerDefinition<"tenant-runs", z.ZodObject<{
4
4
  jobName: z.ZodOptional<z.ZodString>;
5
5
  subject: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>>;
6
6
  limit: z.ZodOptional<z.ZodNumber>;
@@ -19,54 +19,58 @@ function parseSubject(stored) {
19
19
  return null;
20
20
  return Object.fromEntries(parsed.filter(isSubjectEntry));
21
21
  }
22
- export const tenantRunsQuery = defineQueryHandler({
23
- name: "tenant-runs",
24
- description: "Lists the calling tenant's own job runs that opted in via tenantVisibleRun — per job and subject every queued or running run plus the latest completed or failed one, active runs first, then newest first; each row carries only status and queuedAt/startedAt/finishedAt, never payloads, error text or logs; the optional subject filter matches exactly: all declared subjectFields with the same JSON types as the job payload; use it to show whether work for a subject is waiting, running or done, and to tell a failed run from a finished one.",
25
- schema: z.object({
26
- jobName: z.string().optional(),
27
- subject: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
28
- limit: z.number().int().min(1).max(200).optional(),
29
- }),
30
- // Every membership rank: a run state carries a job name, a status and
31
- // times, nothing a team member of the tenant may not see.
32
- access: { roles: access.roles("User", "Editor", ...access.admin) },
33
- handler: async (query, ctx) => {
34
- if (!ctx.systemDb) {
35
- throw new InternalError({
36
- message: "jobs:query:tenant-runs requires ctx.systemDb (feature must declare r.systemScope())",
22
+ export function createTenantRunsQuery(readRoles) {
23
+ return defineQueryHandler({
24
+ name: "tenant-runs",
25
+ description: "Lists the calling tenant's own job runs that opted in via tenantVisibleRun — per job and subject every queued or running run plus the latest completed or failed one, active runs first, then newest first; each row carries only status and queuedAt/startedAt/finishedAt, never payloads, error text or logs; the optional subject filter matches exactly: all declared subjectFields with the same JSON types as the job payload; use it to show whether work for a subject is waiting, running or done, and to tell a failed run from a finished one.",
26
+ schema: z.object({
27
+ jobName: z.string().optional(),
28
+ subject: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
29
+ limit: z.number().int().min(1).max(200).optional(),
30
+ }),
31
+ // Every membership rank: a run state carries a job name, a status and
32
+ // times, nothing a team member of the tenant may not see.
33
+ access: {
34
+ roles: [...new Set([...access.roles("User", "Editor", ...access.admin), ...readRoles])],
35
+ },
36
+ handler: async (query, ctx) => {
37
+ if (!ctx.systemDb) {
38
+ throw new InternalError({
39
+ message: "jobs:query:tenant-runs requires ctx.systemDb (feature must declare r.systemScope())",
40
+ });
41
+ }
42
+ // assertTenantMatch returns the same unfiltered system-mode db — the
43
+ // explicit tenantId below is the filter, assertRowsTenant the second net.
44
+ const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
45
+ const where = { tenantId: [query.user.tenantId] };
46
+ if (query.payload.jobName)
47
+ where["jobName"] = query.payload.jobName;
48
+ if (query.payload.subject)
49
+ where["subject"] = serializeJobSubject(query.payload.subject);
50
+ const limit = query.payload.limit ?? DEFAULT_LIMIT;
51
+ const newestFirst = { col: "updatedAt", direction: "desc" };
52
+ const activeRows = await selectMany(db, tenantJobRunsTable, { ...where, status: { in: ["queued", "running"] } }, { orderBy: newestFirst, limit });
53
+ const finishedRows = await selectMany(db, tenantJobRunsTable, { ...where, status: { in: ["completed", "failed"] } }, { orderBy: newestFirst, limit });
54
+ const seenFinishedKeys = new Set();
55
+ const finishedNewestPerKey = finishedRows.filter((row) => {
56
+ const key = JSON.stringify([row.jobName, row.subject]);
57
+ if (seenFinishedKeys.has(key))
58
+ return false;
59
+ seenFinishedKeys.add(key);
60
+ return true;
37
61
  });
38
- }
39
- // assertTenantMatch returns the same unfiltered system-mode db — the
40
- // explicit tenantId below is the filter, assertRowsTenant the second net.
41
- const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
42
- const where = { tenantId: [query.user.tenantId] };
43
- if (query.payload.jobName)
44
- where["jobName"] = query.payload.jobName;
45
- if (query.payload.subject)
46
- where["subject"] = serializeJobSubject(query.payload.subject);
47
- const limit = query.payload.limit ?? DEFAULT_LIMIT;
48
- const newestFirst = { col: "updatedAt", direction: "desc" };
49
- const activeRows = await selectMany(db, tenantJobRunsTable, { ...where, status: { in: ["queued", "running"] } }, { orderBy: newestFirst, limit });
50
- const finishedRows = await selectMany(db, tenantJobRunsTable, { ...where, status: { in: ["completed", "failed"] } }, { orderBy: newestFirst, limit });
51
- const seenFinishedKeys = new Set();
52
- const finishedNewestPerKey = finishedRows.filter((row) => {
53
- const key = JSON.stringify([row.jobName, row.subject]);
54
- if (seenFinishedKeys.has(key))
55
- return false;
56
- seenFinishedKeys.add(key);
57
- return true;
58
- });
59
- const activeFirst = [...activeRows, ...finishedNewestPerKey];
60
- return {
61
- rows: ctx.systemDb.assertRowsTenant(activeFirst.slice(0, limit), "tenantId").map((row) => ({
62
- jobName: row.jobName,
63
- subject: parseSubject(row.subject),
64
- status: row.status,
65
- queuedAt: row.queuedAt,
66
- startedAt: row.startedAt,
67
- finishedAt: row.finishedAt,
68
- })),
69
- nextCursor: null,
70
- };
71
- },
72
- });
62
+ const activeFirst = [...activeRows, ...finishedNewestPerKey];
63
+ return {
64
+ rows: ctx.systemDb.assertRowsTenant(activeFirst.slice(0, limit), "tenantId").map((row) => ({
65
+ jobName: row.jobName,
66
+ subject: parseSubject(row.subject),
67
+ status: row.status,
68
+ queuedAt: row.queuedAt,
69
+ startedAt: row.startedAt,
70
+ finishedAt: row.finishedAt,
71
+ })),
72
+ nextCursor: null,
73
+ };
74
+ },
75
+ });
76
+ }
@@ -157,15 +157,16 @@ async function recordTenantRunQueued(db, jobName, bullJobId, meta) {
157
157
  const now = Temporal.Now.instant();
158
158
  await transaction(db, async (tx) => {
159
159
  // A finished row under the same id is left over from before a Redis reset
160
- // (ids restart at 1), not this job. Only rows that went through this hook
161
- // (queuedAt set) qualify: a fast worker can finish before the hook runs,
162
- // and that row is this job's own and must stay.
160
+ // (ids restart at 1), not this job. Only rows queued before this BullMQ job
161
+ // was created qualify: a fast worker can finish before the hook runs (no
162
+ // queuedAt), and a deduplicated add of an already finished job re-announces
163
+ // it with a creation time older than its own row. Both rows must stay.
163
164
  await deleteMany(tx, tenantJobRunsTable, {
164
165
  tenantId: key.tenantId,
165
166
  jobName,
166
167
  bullJobId,
167
168
  status: { in: ["completed", "failed"] },
168
- queuedAt: { gt: Temporal.Instant.fromEpochMilliseconds(0) },
169
+ queuedAt: { lt: Temporal.Instant.fromEpochMilliseconds(meta.jobCreatedAtMs) },
169
170
  });
170
171
  // DO NOTHING: a fast worker can report the start before this enqueue hook
171
172
  // returns, and a repeated jobId (debounce, fan-out dedup) is the same job.
@@ -20,9 +20,9 @@ export declare const MAIL_TRANSPORT_EXTENSION: "mailTransport";
20
20
  * config — tenant config reads (the plugin's host/port/from/...)
21
21
  * registry — extension lookup in the factory (not plugin-internal)
22
22
  * secrets — tenant secret reads (smtp.password)
23
- * auditUserId — audit identity for secret reads. Handler path: the
24
- * dispatcher sets the caller's user id; worker path: the
25
- * r.job wrap sets a system identity.
23
+ * auditUserId — audit identity for secret reads. Handler path: unset,
24
+ * requireSecretsContext falls back to ctx.user.id; worker path:
25
+ * the r.job wrap sets a system identity.
26
26
  */
27
27
  export type MailTransportContext = {
28
28
  readonly config?: ConfigAccessor;
@@ -147,6 +147,9 @@ export async function createTransportForTenantIfConfigured(ctx, tenantId, handle
147
147
  /** The `tenantTransport` resolver for `createChannelEmailFeature`: the tenant's configured
148
148
  * provider, or null so the channel falls back to its app transport. */
149
149
  export function tenantMailTransport(scope) {
150
+ // Without a tenant config the provider cannot be read: use the app transport.
151
+ if (scope.config === undefined)
152
+ return Promise.resolve(null);
150
153
  return createTransportForTenantIfConfigured({
151
154
  config: scope.config,
152
155
  registry: scope.registry,
@@ -2,7 +2,7 @@ import { type FeatureDefinition, type ProjectionListScreenDefinition } from "@co
2
2
  import { type CreatePatOptions } from "./handlers/create.write.ts";
3
3
  import { type PatRateLimit } from "./web/constants.ts";
4
4
  import type { PatScopeConfig } from "./web/scopes.ts";
5
- export type BindPatAutoRevokeOnPasswordChange = (revoker: (userId: string) => Promise<number>) => void;
5
+ export type BindPatAutoRevokeOnPasswordChange = (revoker: (userId: string) => Promise<number>) => () => void;
6
6
  export declare function bindPatAutoRevokeOnPasswordChangeFromFeature(feature: FeatureDefinition): BindPatAutoRevokeOnPasswordChange | undefined;
7
7
  export type PersonalAccessTokensOptions = {
8
8
  readonly scopes: PatScopeConfig;
@@ -105,6 +105,10 @@ export function createPersonalAccessTokensFeature(options) {
105
105
  });
106
106
  const bindAutoRevokeOnPasswordChange = (revoker) => {
107
107
  boundAutoRevoke = revoker;
108
+ return () => {
109
+ if (boundAutoRevoke === revoker)
110
+ boundAutoRevoke = undefined;
111
+ };
108
112
  };
109
113
  const handlers = {
110
114
  create: r.writeHandler(createPatCreateHandler({ mfaVerifier: options.mfaVerifier })),
@@ -44,5 +44,5 @@ export type PdfTableResult = {
44
44
  readonly pdfBytes: Uint8Array;
45
45
  readonly pageCount: number;
46
46
  };
47
- export declare function renderTablePdf(doc: PdfTableDocument, options?: PdfTableOptions): Promise<PdfTableResult>;
47
+ export declare function renderTablePdf(untrustedDoc: PdfTableDocument, options?: PdfTableOptions): Promise<PdfTableResult>;
48
48
  export {};
@@ -5,19 +5,28 @@ import { RendererError } from "../renderer-foundation/index.js";
5
5
  const PDF_TABLE_MAX_ROWS = 10_000;
6
6
  const PDF_TABLE_MAX_COLUMNS = 30;
7
7
  const PDF_TABLE_MAX_CELL_LENGTH = 5_000;
8
+ const PDF_TABLE_MAX_META_ENTRIES = 500;
9
+ const PDF_TABLE_MAX_TEXT_LENGTH = 20_000;
10
+ const PDF_TABLE_MAX_PAGE_LABEL_LENGTH = 100;
8
11
  const pdfTableColumnSchema = z
9
12
  .object({
10
- header: z.string(),
13
+ header: z.string().max(PDF_TABLE_MAX_TEXT_LENGTH),
11
14
  weight: z.number().positive().optional(),
12
15
  align: z.enum(["left", "right"]).optional(),
13
16
  })
14
17
  .readonly();
15
18
  export const pdfTableDocumentSchema = z
16
19
  .object({
17
- title: z.string(),
18
- subtitle: z.string().optional(),
20
+ title: z.string().max(PDF_TABLE_MAX_TEXT_LENGTH),
21
+ subtitle: z.string().max(PDF_TABLE_MAX_TEXT_LENGTH).optional(),
19
22
  meta: z
20
- .array(z.object({ label: z.string(), value: z.string() }).readonly())
23
+ .array(z
24
+ .object({
25
+ label: z.string().max(PDF_TABLE_MAX_TEXT_LENGTH),
26
+ value: z.string().max(PDF_TABLE_MAX_TEXT_LENGTH),
27
+ })
28
+ .readonly())
29
+ .max(PDF_TABLE_MAX_META_ENTRIES)
21
30
  .readonly()
22
31
  .optional(),
23
32
  columns: z.array(pdfTableColumnSchema).min(1).max(PDF_TABLE_MAX_COLUMNS).readonly(),
@@ -25,10 +34,10 @@ export const pdfTableDocumentSchema = z
25
34
  .array(z.array(z.string().max(PDF_TABLE_MAX_CELL_LENGTH)).readonly())
26
35
  .max(PDF_TABLE_MAX_ROWS)
27
36
  .readonly(),
28
- emptyText: z.string().optional(),
29
- footerText: z.string().optional(),
37
+ emptyText: z.string().max(PDF_TABLE_MAX_TEXT_LENGTH).optional(),
38
+ footerText: z.string().max(PDF_TABLE_MAX_TEXT_LENGTH).optional(),
30
39
  // Template with `{page}` and `{pages}`; default `"{page} / {pages}"`.
31
- pageLabel: z.string().optional(),
40
+ pageLabel: z.string().max(PDF_TABLE_MAX_PAGE_LABEL_LENGTH).optional(),
32
41
  })
33
42
  .readonly();
34
43
  const marginMmSchema = z
@@ -174,10 +183,13 @@ function resolveTableLayout(doc, options, fonts, clean) {
174
183
  headerHeight,
175
184
  };
176
185
  }
177
- export async function renderTablePdf(doc, options) {
178
- if (doc.columns.length === 0) {
179
- throw new RendererError("table needs at least one column", "invalid_payload");
186
+ export async function renderTablePdf(untrustedDoc, options) {
187
+ // Direct callers bypass renderTablePdfRequest, so the size caps are enforced here too.
188
+ const parsed = pdfTableDocumentSchema.safeParse(untrustedDoc);
189
+ if (!parsed.success) {
190
+ throw new RendererError(`table document is invalid: ${parsed.error.message}`, "invalid_payload");
180
191
  }
192
+ const doc = parsed.data;
181
193
  assertRowShape(doc);
182
194
  const pdf = await PDFDocument.create({ updateMetadata: false });
183
195
  const fonts = {
@@ -5,7 +5,7 @@ export type SessionsFeatureOptions = {
5
5
  readonly expiresInMs?: number;
6
6
  readonly adminAccess?: "admin" | "systemAdmin";
7
7
  };
8
- export type BindAutoRevokeOnPasswordChange = (revoker: SessionMassRevoker) => void;
9
- export type BindRevokeAllOtherSessions = (revoker: SessionAllOthersRevoker) => void;
8
+ export type BindAutoRevokeOnPasswordChange = (revoker: SessionMassRevoker) => () => void;
9
+ export type BindRevokeAllOtherSessions = (revoker: SessionAllOthersRevoker) => () => void;
10
10
  export declare function bindAutoRevokeFromFeature(feature: FeatureDefinition): BindAutoRevokeOnPasswordChange | undefined;
11
11
  export declare function createSessionsFeature(options?: SessionsFeatureOptions): FeatureDefinition;
@@ -170,6 +170,10 @@ export function createSessionsFeature(options) {
170
170
  });
171
171
  const bindAutoRevokeOnPasswordChange = (revoker) => {
172
172
  boundAutoRevoke = revoker;
173
+ return () => {
174
+ if (boundAutoRevoke === revoker)
175
+ boundAutoRevoke = undefined;
176
+ };
173
177
  };
174
178
  r.translations({ keys: SESSIONS_I18N });
175
179
  r.screen(createSessionListScreen(adminRoles));
@@ -1 +1,2 @@
1
1
  export declare function decryptStoredPii(value: string, field: string, fallbackRequestId: string): Promise<string>;
2
+ export declare function decryptStoredUserEmail(stored: string, fallbackRequestId: string): Promise<string | null>;
@@ -1,5 +1,5 @@
1
1
  import { requestContext } from "@cosmicdrift/kumiko-framework/api";
2
- import { configuredPiiSubjectKms, decryptPiiFieldValues, } from "@cosmicdrift/kumiko-framework/crypto";
2
+ import { configuredPiiSubjectKms, decryptPiiFieldValues, PII_ERASED_SENTINEL, } from "@cosmicdrift/kumiko-framework/crypto";
3
3
  // Raw reads (fetchOne/selectMany) return the stored column value — with an
4
4
  // active KMS that is ciphertext (self-describing: it names its own subject,
5
5
  // so no entity/field context is needed for key selection). `field` must
@@ -17,3 +17,8 @@ export async function decryptStoredPii(value, field, fallbackRequestId) {
17
17
  const decrypted = out[field];
18
18
  return typeof decrypted === "string" ? decrypted : value;
19
19
  }
20
+ // Null for an erased subject: the sentinel must never be mailed or shown as an address.
21
+ export async function decryptStoredUserEmail(stored, fallbackRequestId) {
22
+ const email = await decryptStoredPii(stored, "email", fallbackRequestId);
23
+ return email === PII_ERASED_SENTINEL ? null : email;
24
+ }
@@ -3,7 +3,7 @@ export { assertNoWhereRuleInOwnershipWrite } from "./assert-no-where-rule-in-own
3
3
  export { attachmentFileNameSchema, sanitizePdfAttachmentFileName, } from "./attachment-file-name.ts";
4
4
  export { type ChunkedMigrationOptions, type ChunkedMigrationResult, type ChunkedMigrationStopReason, type MigrationRowOutcome, runChunkedMigration, } from "./chunked-entity-migration.ts";
5
5
  export { classifyStoredEnvelope, type StoredEnvelopeClassification, } from "./classify-stored-envelope.ts";
6
- export { decryptStoredPii } from "./decrypt-stored-pii.ts";
6
+ export { decryptStoredPii, decryptStoredUserEmail } from "./decrypt-stored-pii.ts";
7
7
  export { encryptForDirectWrite } from "./encrypt-for-direct-write.ts";
8
8
  export { entitiesOf } from "./entities-of.ts";
9
9
  export { isWithinGracePeriod } from "./grace-period.ts";
@@ -3,7 +3,7 @@ export { assertNoWhereRuleInOwnershipWrite } from "./assert-no-where-rule-in-own
3
3
  export { attachmentFileNameSchema, sanitizePdfAttachmentFileName, } from "./attachment-file-name.js";
4
4
  export { runChunkedMigration, } from "./chunked-entity-migration.js";
5
5
  export { classifyStoredEnvelope, } from "./classify-stored-envelope.js";
6
- export { decryptStoredPii } from "./decrypt-stored-pii.js";
6
+ export { decryptStoredPii, decryptStoredUserEmail } from "./decrypt-stored-pii.js";
7
7
  export { encryptForDirectWrite } from "./encrypt-for-direct-write.js";
8
8
  export { entitiesOf } from "./entities-of.js";
9
9
  export { isWithinGracePeriod } from "./grace-period.js";
@@ -1,7 +1,11 @@
1
1
  export declare function mailTextWithSlotsHtml(text: string): string;
2
- export declare function mailParagraph(text: string): string;
2
+ type MailParagraphOptions = {
3
+ readonly showIf?: string;
4
+ };
5
+ export declare function mailParagraph(text: string, options?: MailParagraphOptions): string;
3
6
  export declare function mailHeading(text: string): string;
4
7
  export declare function mailButton(label: string, linkVariable: string): string;
5
- export declare function mailParagraphWithLink(text: string, linkLabel: string, linkVariable: string): string;
8
+ export declare function mailParagraphWithLink(text: string, linkLabel: string, linkVariable: string, options?: MailParagraphOptions): string;
6
9
  export declare function mailRepeatedRows(listName: string, field: string): string;
7
10
  export declare function mailSubject(text: string): string;
11
+ export {};
@@ -5,8 +5,11 @@ const SLOT_PATTERN = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
5
5
  export function mailTextWithSlotsHtml(text) {
6
6
  return escapeHtml(text).replace(SLOT_PATTERN, (_, name) => `<span data-variable="${escapeHtmlAttr(name)}"></span>`);
7
7
  }
8
- export function mailParagraph(text) {
9
- return `<p>${mailTextWithSlotsHtml(text)}</p>`;
8
+ function paragraphOpenTag(options) {
9
+ return options?.showIf === undefined ? "<p>" : `<p data-if="${escapeHtmlAttr(options.showIf)}">`;
10
+ }
11
+ export function mailParagraph(text, options) {
12
+ return `${paragraphOpenTag(options)}${mailTextWithSlotsHtml(text)}</p>`;
10
13
  }
11
14
  export function mailHeading(text) {
12
15
  return `<h2>${mailTextWithSlotsHtml(text)}</h2>`;
@@ -14,8 +17,8 @@ export function mailHeading(text) {
14
17
  export function mailButton(label, linkVariable) {
15
18
  return `<p><a data-button href="{{${escapeHtmlAttr(linkVariable)}}}">${escapeHtml(label)}</a></p>`;
16
19
  }
17
- export function mailParagraphWithLink(text, linkLabel, linkVariable) {
18
- return `<p>${mailTextWithSlotsHtml(text)} <a href="{{${escapeHtmlAttr(linkVariable)}}}">${escapeHtml(linkLabel)}</a></p>`;
20
+ export function mailParagraphWithLink(text, linkLabel, linkVariable, options) {
21
+ return `${paragraphOpenTag(options)}${mailTextWithSlotsHtml(text)} <a href="{{${escapeHtmlAttr(linkVariable)}}}">${escapeHtml(linkLabel)}</a></p>`;
19
22
  }
20
23
  // One table row per list entry; the cell shows the entry's `field`.
21
24
  export function mailRepeatedRows(listName, field) {
@@ -42,6 +42,7 @@ export function createStepDispatcherFeature() {
42
42
  retries: DELIVER_RETRIES,
43
43
  backoff: "exponential",
44
44
  maxPerTenant: { limit: DELIVER_MAX_PER_TENANT, onLimit: "defer" },
45
+ schema: deliverJobPayloadSchema,
45
46
  escapeHatch: { reason: DELIVER_ESCAPE_HATCH_REASON },
46
47
  }, async (payload, ctx) => {
47
48
  const { aggregateId } = deliverJobPayloadSchema.parse(payload);
@@ -0,0 +1,17 @@
1
+ export type DodoSend = (url: string, init?: RequestInit) => Promise<Response>;
2
+ export type DodoRequest = {
3
+ readonly method: "GET" | "POST" | "PATCH";
4
+ /** Path segments interpolated into it must already be encodeURIComponent'ed. */
5
+ readonly path: string;
6
+ readonly query?: Readonly<Record<string, string>>;
7
+ readonly body?: unknown;
8
+ };
9
+ export type DodoTarget = {
10
+ readonly baseUrl: string;
11
+ readonly apiKey: string;
12
+ };
13
+ export declare class DodoHttpError extends Error {
14
+ readonly status: number;
15
+ constructor(request: DodoRequest, status: number);
16
+ }
17
+ export declare function createDodoHttp(send: DodoSend): (target: DodoTarget, request: DodoRequest) => Promise<unknown>;