@cosmicdrift/kumiko-samples 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 (173) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +8 -1
  3. package/packages/bundled-features/src/agent-tools/changes.json +6 -0
  4. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +9 -9
  5. package/packages/bundled-features/src/audit/changes.json +12 -0
  6. package/packages/bundled-features/src/audit/handlers/audit-events.ts +1 -1
  7. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  8. package/packages/bundled-features/src/auth-mfa/feature-exports.ts +2 -2
  9. package/packages/bundled-features/src/auth-mfa/feature.ts +6 -0
  10. package/packages/bundled-features/src/auth-mfa/web/i18n.ts +6 -1
  11. package/packages/bundled-features/src/auth-mfa/web/mfa-code-or-recovery-field.tsx +68 -0
  12. package/packages/bundled-features/src/auth-mfa/web/mfa-disable-dialog.tsx +23 -13
  13. package/packages/bundled-features/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +23 -13
  14. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +15 -6
  15. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +65 -18
  16. package/packages/bundled-features/src/billing-foundation/changes.json +55 -0
  17. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +32 -42
  18. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +8 -2
  19. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +2 -0
  20. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +14 -3
  21. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +3 -4
  22. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +12 -6
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +35 -19
  24. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +12 -8
  25. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +14 -8
  26. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +5 -0
  27. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +8 -10
  28. package/packages/bundled-features/src/billing-foundation/events.ts +1 -0
  29. package/packages/bundled-features/src/billing-foundation/feature.ts +3 -1
  30. package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +71 -6
  31. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +1 -0
  32. package/packages/bundled-features/src/billing-foundation/handlers/plan-switch-preview.query.ts +65 -0
  33. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +9 -2
  34. package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +44 -8
  35. package/packages/bundled-features/src/billing-foundation/handlers/switch-plan.write.ts +72 -82
  36. package/packages/bundled-features/src/billing-foundation/handlers/sync-subscription.write.ts +14 -1
  37. package/packages/bundled-features/src/billing-foundation/i18n.ts +22 -0
  38. package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
  39. package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +62 -27
  40. package/packages/bundled-features/src/billing-foundation/plan-switch.ts +84 -0
  41. package/packages/bundled-features/src/billing-foundation/types.ts +80 -1
  42. package/packages/bundled-features/src/billing-foundation/validate-options.ts +19 -0
  43. package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +119 -47
  44. package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +89 -42
  45. package/packages/bundled-features/src/billing-foundation/web/constants.ts +4 -0
  46. package/packages/bundled-features/src/billing-foundation/web/plan-price.ts +55 -0
  47. package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +224 -0
  48. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +5 -1
  49. package/packages/bundled-features/src/cap-counter/changes.json +18 -0
  50. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +2 -1
  51. package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +8 -1
  52. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  53. package/packages/bundled-features/src/channel-email/feature.ts +1 -1
  54. package/packages/bundled-features/src/config/changes.json +12 -0
  55. package/packages/bundled-features/src/config/handlers/cascade.query.ts +4 -2
  56. package/packages/bundled-features/src/config/handlers/values.query.ts +4 -2
  57. package/packages/bundled-features/src/config/write-helpers.ts +27 -8
  58. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  59. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +10 -9
  60. package/packages/bundled-features/src/delivery/changes.json +30 -0
  61. package/packages/bundled-features/src/delivery/channel-context.ts +9 -3
  62. package/packages/bundled-features/src/delivery/db/queries/preferences.ts +2 -2
  63. package/packages/bundled-features/src/delivery/delivery-service.ts +35 -5
  64. package/packages/bundled-features/src/delivery/handlers/preferences.query.ts +20 -1
  65. package/packages/bundled-features/src/delivery/handlers/unsubscribe-page-chrome.query.ts +1 -1
  66. package/packages/bundled-features/src/delivery/unsubscribe-link.ts +3 -1
  67. package/packages/bundled-features/src/delivery/unsubscribe-page-strings.ts +1 -0
  68. package/packages/bundled-features/src/delivery/unsubscribe-page.ts +6 -0
  69. package/packages/bundled-features/src/delivery/unsubscribe.ts +36 -15
  70. package/packages/bundled-features/src/file-foundation/index.ts +1 -0
  71. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +7 -1
  72. package/packages/bundled-features/src/file-provider-s3/env-named-feature.ts +0 -2
  73. package/packages/bundled-features/src/files/README.md +1 -1
  74. package/packages/bundled-features/src/form-draft/changes.json +6 -0
  75. package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +2 -2
  76. package/packages/bundled-features/src/form-draft/decrypt-draft.ts +4 -1
  77. package/packages/bundled-features/src/host-routing/README.md +1 -1
  78. package/packages/bundled-features/src/host-routing/changes.json +6 -0
  79. package/packages/bundled-features/src/host-routing/dispatch.ts +16 -17
  80. package/packages/bundled-features/src/host-routing/options.ts +2 -1
  81. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +18 -0
  82. package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +17 -5
  83. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +0 -1
  84. package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +0 -3
  85. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +4 -3
  86. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +32 -16
  87. package/packages/bundled-features/src/jobs/changes.json +13 -0
  88. package/packages/bundled-features/src/jobs/feature.ts +14 -4
  89. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +45 -40
  90. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +63 -59
  91. package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -4
  92. package/packages/bundled-features/src/legal-pages/README.md +1 -1
  93. package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
  94. package/packages/bundled-features/src/mail-foundation/feature.ts +5 -3
  95. package/packages/bundled-features/src/personal-access-tokens/feature.ts +4 -1
  96. package/packages/bundled-features/src/renderer-pdf/changes.json +6 -0
  97. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +27 -10
  98. package/packages/bundled-features/src/sessions/feature.ts +7 -2
  99. package/packages/bundled-features/src/shared/decrypt-stored-pii.ts +10 -0
  100. package/packages/bundled-features/src/shared/index.ts +1 -1
  101. package/packages/bundled-features/src/shared/mail-template-body.ts +10 -3
  102. package/packages/bundled-features/src/step-dispatcher/changes.json +6 -0
  103. package/packages/bundled-features/src/step-dispatcher/feature.ts +1 -0
  104. package/packages/bundled-features/src/subscription-dodo/changes.json +14 -0
  105. package/packages/bundled-features/src/subscription-dodo/client.ts +45 -0
  106. package/packages/bundled-features/src/subscription-dodo/constants.ts +24 -0
  107. package/packages/bundled-features/src/subscription-dodo/feature.ts +207 -0
  108. package/packages/bundled-features/src/subscription-dodo/index.ts +16 -0
  109. package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +345 -0
  110. package/packages/bundled-features/src/subscription-dodo/runtime.ts +126 -0
  111. package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +262 -0
  112. package/packages/bundled-features/src/subscription-stripe/changes.json +12 -0
  113. package/packages/bundled-features/src/subscription-stripe/feature.ts +1 -0
  114. package/packages/bundled-features/src/subscription-stripe/runtime.ts +7 -6
  115. package/packages/bundled-features/src/template-resolver/api.ts +31 -4
  116. package/packages/bundled-features/src/template-resolver/changes.json +48 -0
  117. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +1 -0
  118. package/packages/bundled-features/src/template-resolver/feature.ts +2 -2
  119. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +3 -2
  120. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +27 -5
  121. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +3 -2
  122. package/packages/bundled-features/src/template-resolver/handlers/document-base.ts +3 -2
  123. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +3 -2
  124. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +18 -19
  125. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +15 -5
  126. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-counts.query.ts +9 -5
  127. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-selection.ts +8 -6
  128. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +44 -5
  129. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +37 -26
  130. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +7 -2
  131. package/packages/bundled-features/src/template-resolver/seeding.ts +32 -14
  132. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +22 -4
  133. package/packages/bundled-features/src/template-resolver/template-delivery-policy.ts +0 -11
  134. package/packages/bundled-features/src/template-resolver/template-meta.ts +12 -4
  135. package/packages/bundled-features/src/template-resolver/template-preview.ts +11 -3
  136. package/packages/bundled-features/src/template-resolver/web/constants.ts +7 -5
  137. package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
  138. package/packages/bundled-features/src/template-resolver/web/mail-layout-constants.ts +8 -0
  139. package/packages/bundled-features/src/tenant/changes.json +6 -0
  140. package/packages/bundled-features/src/tenant/handlers/member-directory.query.ts +9 -3
  141. package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +7 -4
  142. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  143. package/packages/bundled-features/src/tenant-lifecycle/constants.ts +5 -0
  144. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +5 -1
  145. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  146. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +4 -1
  147. package/packages/bundled-features/src/tier-engine/web/constants.ts +3 -3
  148. package/packages/bundled-features/src/user-data-rights/run-export-jobs.ts +3 -4
  149. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -1
  150. package/packages/bundled-features/src/waitlist/changes.json +18 -0
  151. package/packages/bundled-features/src/waitlist/handlers/export-purge.write.ts +2 -1
  152. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +5 -5
  153. package/packages/bundled-features/src/waitlist/import.ts +5 -0
  154. package/packages/bundled-features/src/waitlist/legacy-migrate.ts +22 -3
  155. package/packages/bundled-features/src/waitlist/link-on-signup.ts +20 -10
  156. package/packages/bundled-features/src/waitlist/options.ts +3 -0
  157. package/samples/apps/styleguide/src/features/widgets/i18n.ts +11 -0
  158. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +24 -0
  159. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +5 -0
  160. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +90 -15
  161. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +16 -6
  162. package/samples/apps/use-all-bundled/feature-manifest.json +120 -3
  163. package/samples/apps/use-all-bundled/src/run-config.ts +9 -3
  164. package/samples/recipes/auth-mfa/README.md +5 -0
  165. package/samples/recipes/auth-mfa/package.json +5 -2
  166. package/samples/recipes/auth-mfa/src/web/confirmation-code-step.tsx +49 -0
  167. package/samples/recipes/auth-mfa/tsconfig.json +8 -0
  168. package/samples/recipes/env-composition/README.md +3 -1
  169. package/samples/recipes/env-composition/src/env.ts +2 -0
  170. package/samples/recipes/legal-pages/README.md +1 -1
  171. package/samples/recipes/rate-limiting/README.md +18 -0
  172. package/samples/recipes/rate-limiting/src/feature.ts +12 -0
  173. package/packages/bundled-features/src/billing-foundation/consumer-protection/stored-user-email.ts +0 -12
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "Undecryptable mail account no longer stops polling of other accounts",
6
+ "detail": "Any decryption failure of an account address (corrupt envelope, missing key, AAD mismatch) is logged by error class and the account is skipped; the other accounts keep polling."
7
+ },
8
+ {
9
+ "version": "0.388.0",
10
+ "type": "fix",
11
+ "title": "Rename audit reason comes from the caller role",
12
+ "detail": "update-account records tenant_admin or system_admin from the caller's roles and honors a client-supplied reason only for the system identity, so a form call can no longer write system reasons such as watch_supervisor. The rename form no longer carries a reason field and the reason payload field is optional."
13
+ },
14
+ {
15
+ "version": "0.388.0",
16
+ "type": "fix",
17
+ "title": "Seen-message lookup handles listings of any size",
18
+ "detail": "filterUnknownMessageIds queries the already seen provider message ids in chunks of 1000, so a full-mailbox listing no longer exceeds the Postgres bind parameter limit and aborts the account's poll."
19
+ },
2
20
  {
3
21
  "version": "0.378.0",
4
22
  "type": "fix",
@@ -3,7 +3,12 @@
3
3
  // the account after fixing its credentials. Full-snapshot event: loads the
4
4
  // latest state, merges the delta fields, and appends the new snapshot.
5
5
 
6
- import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
7
+ import {
8
+ type SessionUser,
9
+ SYSTEM_USER_ID,
10
+ type WriteHandlerDef,
11
+ } from "@cosmicdrift/kumiko-framework/engine";
7
12
  import { failNotFound, failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
8
13
  import * as z from "zod";
9
14
  import { InboundMailAccountStatuses } from "../constants.ts";
@@ -27,12 +32,19 @@ export const updateAccountSchema = z.object({
27
32
  /** Ops-Signal ("idle", "watching", "backoff:3", ...). */
28
33
  watchState: z.string().max(100).optional(),
29
34
  displayName: z.string().max(200).optional(),
30
- /** Audit reason for metadata.headers ("watch_supervisor",
31
- * "oauth_refresh", "tenant_admin"). */
32
- reason: z.string().min(1).max(100),
35
+ /** Audit reason for metadata.headers, honored for the system identity only
36
+ * ("watch_supervisor", "oauth_refresh"); user callers get a role-derived reason. */
37
+ reason: z.string().min(1).max(100).optional(),
33
38
  });
34
39
  type UpdateAccountPayload = z.infer<typeof updateAccountSchema>;
35
40
 
41
+ const SYSTEM_FALLBACK_REASON = "system";
42
+
43
+ function auditReasonFor(user: SessionUser, requestedReason: string | undefined): string {
44
+ if (user.id === SYSTEM_USER_ID) return requestedReason ?? SYSTEM_FALLBACK_REASON;
45
+ return user.roles.includes(ROLES.SystemAdmin) ? "system_admin" : "tenant_admin";
46
+ }
47
+
36
48
  export const updateAccountHandler: WriteHandlerDef = {
37
49
  name: "update-account",
38
50
  schema: updateAccountSchema,
@@ -65,7 +77,7 @@ export const updateAccountHandler: WriteHandlerDef = {
65
77
  };
66
78
  const headers: MailAccountEventHeaders = {
67
79
  providerName: current.provider,
68
- reason: payload.reason,
80
+ reason: auditReasonFor(event.user, payload.reason),
69
81
  };
70
82
  await ctx.unsafeAppendEvent({
71
83
  aggregateId: payload.accountId,
@@ -38,5 +38,4 @@ export const INBOUND_MAIL_FOUNDATION_I18N: Readonly<Record<string, { readonly en
38
38
  },
39
39
  [formFieldKey("accountId")]: { en: "Mailbox" },
40
40
  [formFieldKey("displayName")]: { en: "Name" },
41
- [formFieldKey("reason")]: { en: "Reason" },
42
41
  };
@@ -77,8 +77,6 @@ export const mailAccountListScreen: ProjectionListScreenDefinition = {
77
77
  dormant: true,
78
78
  };
79
79
 
80
- // The reason is the audit label update-account requires; it is not in the
81
- // layout, so the default is what gets written.
82
80
  export const mailAccountEditScreen: ActionFormScreenDefinition = {
83
81
  id: MAIL_ACCOUNT_EDIT_SCREEN_ID,
84
82
  type: "actionForm",
@@ -87,7 +85,6 @@ export const mailAccountEditScreen: ActionFormScreenDefinition = {
87
85
  fields: {
88
86
  accountId: { type: "text", required: true },
89
87
  displayName: { type: "text", required: true, maxLength: 200 },
90
- reason: { type: "text", default: "tenant_admin" },
91
88
  },
92
89
  layout: {
93
90
  sections: [{ fields: [{ field: "accountId", readOnly: true }, "displayName"] }],
@@ -21,8 +21,9 @@ import { INBOUND_MAIL_PROVIDER_EXTENSION, type InboundMailAccountStatus } from "
21
21
  * config — tenant config reads (host/port/... of the plugins)
22
22
  * registry — extension lookup in the factory (not plugin-internal)
23
23
  * secrets — tenant secret reads (IMAP password, OAuth refresh token)
24
- * auditUserId — audit identity for secret reads. Handler path: the dispatcher
25
- * sets the caller user ID; worker path: system identity.
24
+ * auditUserId — audit identity for secret reads. Handler path: unset,
25
+ * requireSecretsContext falls back to ctx.user.id; worker/provider
26
+ * path: system identity set by the resolver.
26
27
  * metricsFor — metrics handle of a feature by name, same behavior as
27
28
  * HandlerContext.metricsFor: unregistered metrics are a silent
28
29
  * no-op instead of a throw.
@@ -172,7 +173,7 @@ export type InboundMailProviderPlugin = {
172
173
  * for password accounts and providers without an oauth flow. */
173
174
  readonly accessToken?: string;
174
175
  /** Batch lookup of the provider message ids not yet ingested for this
175
- * account (committed seen anchors, one query per call). Call it after
176
+ * account (committed seen anchors; lists of any size are fine, queried in chunks). Call it after
176
177
  * the listing/delta page and download MIME only for the returned ids.
177
178
  * Ingest dedup stays the correctness anchor; this only saves downloads. */
178
179
  readonly filterUnknownMessageIds?: (
@@ -88,6 +88,8 @@ const DEFAULT_WATCH_LEASE_TTL_SECONDS = 90;
88
88
  * can add further scopes later without a schema change. */
89
89
  const CURSOR_SCOPE = "default";
90
90
 
91
+ const SEEN_LOOKUP_CHUNK_SIZE = 1000;
92
+
91
93
  export type InboundMailSupervisorDeps = {
92
94
  /** Slim context for provider calls (`registry` is required; `secrets` is
93
95
  * used for provider credential reads). */
@@ -223,11 +225,19 @@ export function createInboundMailSupervisor(
223
225
  const piiKms = configuredPiiSubjectKms();
224
226
  const records: MailAccountRecord[] = [];
225
227
  for (const row of rows) {
226
- const decrypted = piiKms
227
- ? await decryptPiiFieldValues(row, MAIL_ACCOUNT_PII_FIELDS, piiKms, {
228
- requestId: "inbound-mail-foundation:supervisor:list-accounts",
229
- })
230
- : row;
228
+ let decrypted: Record<string, unknown>;
229
+ try {
230
+ decrypted = piiKms
231
+ ? await decryptPiiFieldValues(row, MAIL_ACCOUNT_PII_FIELDS, piiKms, {
232
+ requestId: "inbound-mail-foundation:supervisor:list-accounts",
233
+ })
234
+ : row;
235
+ } catch (error) {
236
+ // Class name only: the message may carry ciphertext or key details.
237
+ const errorName = error instanceof Error ? error.name : "unknown error";
238
+ log(`inbound-mail: account ${row.id} skipped — address did not decrypt (${errorName})`);
239
+ continue;
240
+ }
231
241
  const decryptedAddress = decrypted["address"];
232
242
  // Falling back to row.address would hand the ciphertext to a provider.
233
243
  // One undecryptable account must not stop polling of every other tenant's accounts.
@@ -400,17 +410,23 @@ export function createInboundMailSupervisor(
400
410
  function unknownMessageIdsFilterFor(account: MailAccountRecord) {
401
411
  return async (providerMessageIds: readonly string[]): Promise<ReadonlySet<string>> => {
402
412
  if (providerMessageIds.length === 0) return new Set();
403
- const seen = await selectMany<{ providerMessageId: string }>(
404
- deps.db,
405
- seenMessageTable,
406
- {
407
- tenantId: account.tenantId,
408
- accountId: account.id,
409
- providerMessageId: { in: providerMessageIds },
410
- },
411
- { fields: ["providerMessageId"] },
412
- );
413
- const seenIds = new Set(seen.map((row) => row.providerMessageId));
413
+ // One bind parameter per id: a full-mailbox listing would exceed Postgres' 65535 limit.
414
+ const seenIds = new Set<string>();
415
+ for (let start = 0; start < providerMessageIds.length; start += SEEN_LOOKUP_CHUNK_SIZE) {
416
+ const seen = await selectMany<{ providerMessageId: string }>(
417
+ deps.db,
418
+ seenMessageTable,
419
+ {
420
+ tenantId: account.tenantId,
421
+ accountId: account.id,
422
+ providerMessageId: {
423
+ in: providerMessageIds.slice(start, start + SEEN_LOOKUP_CHUNK_SIZE),
424
+ },
425
+ },
426
+ { fields: ["providerMessageId"] },
427
+ );
428
+ for (const row of seen) seenIds.add(row.providerMessageId);
429
+ }
414
430
  return new Set(providerMessageIds.filter((id) => !seenIds.has(id)));
415
431
  };
416
432
  }
@@ -1,4 +1,17 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "improvement",
5
+ "title": "readRoles for tenant job status",
6
+ "detail": "`createJobsFeature({ readRoles: [\"PropertyManager\"] })` lets those app roles call `jobs:query:failures` and `jobs:query:tenant-runs` for their own tenant. They are added to the default `User`, `Editor` and admin roles, which stay. Before, a member who only holds app roles got access_denied.",
7
+ "migration": "Optional. Apps whose members hold only their own roles pass `createJobsFeature({ readRoles: [...] })`."
8
+ },
9
+ {
10
+ "version": "0.388.0",
11
+ "type": "fix",
12
+ "title": "Deduplicated job add keeps the finished tenant run row",
13
+ "detail": "JobQueuedMeta carries the BullMQ creation time of the job. The queued hook only removes finished rows queued before that time (leftovers of a Redis reset), so re-announcing an already finished job no longer turns its row back into queued."
14
+ },
2
15
  {
3
16
  "version": "0.384.0",
4
17
  "type": "improvement",
@@ -23,8 +23,8 @@ import {
23
23
  createStaleRunSweepJob,
24
24
  DEFAULT_JOB_RUN_STALE_TIMEOUT_HOURS,
25
25
  } from "./handlers/stale-run-sweep.job.ts";
26
- import { tenantFailuresQuery } from "./handlers/tenant-failures.query.ts";
27
- import { tenantRunsQuery } from "./handlers/tenant-runs.query.ts";
26
+ import { createTenantFailuresQuery } from "./handlers/tenant-failures.query.ts";
27
+ import { createTenantRunsQuery } from "./handlers/tenant-runs.query.ts";
28
28
  import { triggerWrite } from "./handlers/trigger.write.ts";
29
29
  import { jobRunLogsTableMeta, jobRunsTableMeta } from "./job-run-table.ts";
30
30
  import {
@@ -49,11 +49,21 @@ export type JobsFeatureOptions = {
49
49
  // it). Must clear any legitimately long-running job (reindexEntity,
50
50
  // projectionRebuild) — set high, not tight.
51
51
  readonly staleRunTimeoutHours?: number;
52
+ // Additional app roles that may read their own tenant's job status and
53
+ // failures (jobs:query:failures, jobs:query:tenant-runs). Added to the default
54
+ // User/Editor/admin roles, which stay; the scope stays the caller's own tenant.
55
+ readonly readRoles?: readonly string[];
52
56
  };
53
57
 
54
58
  export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefinition {
55
59
  const retentionDays = options.retentionDays ?? DEFAULT_JOB_RUN_RETENTION_DAYS;
56
60
  const staleRunTimeoutHours = options.staleRunTimeoutHours ?? DEFAULT_JOB_RUN_STALE_TIMEOUT_HOURS;
61
+ const readRoles = options.readRoles ?? [];
62
+ if (readRoles.includes("anonymous")) {
63
+ throw new Error(
64
+ `createJobsFeature: readRoles must not contain "anonymous" — job status and failures are only readable by members of the tenant.`,
65
+ );
66
+ }
57
67
  return defineFeature("jobs", (r) => {
58
68
  r.describe(
59
69
  "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`.",
@@ -137,8 +147,8 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
137
147
  list: r.queryHandler(listQuery),
138
148
  detail: r.queryHandler(detailQuery),
139
149
  catalog: r.queryHandler(catalogQuery),
140
- failures: r.queryHandler(tenantFailuresQuery),
141
- tenantRuns: r.queryHandler(tenantRunsQuery),
150
+ failures: r.queryHandler(createTenantFailuresQuery(readRoles)),
151
+ tenantRuns: r.queryHandler(createTenantRunsQuery(readRoles)),
142
152
  consumers: r.queryHandler(consumersQuery),
143
153
  consumerDetails: r.queryHandler(consumerDetailQuery),
144
154
  };
@@ -30,44 +30,49 @@ function parseSubject(stored: string | null): Record<string, unknown> | null {
30
30
  return Object.fromEntries(parsed.filter(isSubjectEntry));
31
31
  }
32
32
 
33
- export const tenantFailuresQuery = defineQueryHandler({
34
- name: "failures",
35
- description:
36
- "Lists the calling tenant's own failed jobs — one record per job and subject, newest first, each carrying a translation key for the reason, never the provider's own error message; the optional subject filter matches exactly: all declared subjectFields with the same JSON types as the job payload; use it to tell a tenant that their asynchronous job failed instead of leaving the screen waiting.",
37
- schema: z.object({
38
- jobName: z.string().optional(),
39
- subject: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
40
- limit: z.number().int().min(1).max(200).optional(),
41
- }),
42
- // Every membership rank: a failure record carries a job name and a
43
- // translation key, nothing a team member of the tenant may not see.
44
- access: { roles: access.roles("User", "Editor", ...access.admin) },
45
- handler: async (query, ctx) => {
46
- if (!ctx.systemDb) {
47
- throw new InternalError({
48
- message: "jobs:query:failures requires ctx.systemDb (feature must declare r.systemScope())",
33
+ export function createTenantFailuresQuery(readRoles: readonly string[]) {
34
+ return defineQueryHandler({
35
+ name: "failures",
36
+ description:
37
+ "Lists the calling tenant's own failed jobs — one record per job and subject, newest first, each carrying a translation key for the reason, never the provider's own error message; the optional subject filter matches exactly: all declared subjectFields with the same JSON types as the job payload; use it to tell a tenant that their asynchronous job failed instead of leaving the screen waiting.",
38
+ schema: z.object({
39
+ jobName: z.string().optional(),
40
+ subject: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
41
+ limit: z.number().int().min(1).max(200).optional(),
42
+ }),
43
+ // Every membership rank: a failure record carries a job name and a
44
+ // translation key, nothing a team member of the tenant may not see.
45
+ access: {
46
+ roles: [...new Set([...access.roles("User", "Editor", ...access.admin), ...readRoles])],
47
+ },
48
+ handler: async (query, ctx) => {
49
+ if (!ctx.systemDb) {
50
+ throw new InternalError({
51
+ message:
52
+ "jobs:query:failures requires ctx.systemDb (feature must declare r.systemScope())",
53
+ });
54
+ }
55
+ // assertTenantMatch is a self-check on the caller and returns the same
56
+ // unfiltered system-mode db (tenant-db.ts) — the explicit tenantId below
57
+ // is the filter, assertRowsTenant the second net. There is no
58
+ // cross-tenant mode here: SystemAdmin reads runs via jobs:query:list.
59
+ const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
60
+ const where: Record<string, unknown> = { tenantId: [query.user.tenantId] };
61
+ if (query.payload.jobName) where["jobName"] = query.payload.jobName;
62
+ if (query.payload.subject) where["subject"] = serializeJobSubject(query.payload.subject);
63
+ const rows = await selectMany<TenantJobFailureRow>(db, tenantJobFailuresTable, where, {
64
+ orderBy: { col: "failedAt", direction: "desc" },
65
+ limit: query.payload.limit ?? DEFAULT_LIMIT,
49
66
  });
50
- }
51
- // assertTenantMatch is a self-check on the caller and returns the same
52
- // unfiltered system-mode db (tenant-db.ts) — the explicit tenantId below
53
- // is the filter, assertRowsTenant the second net. There is no
54
- // cross-tenant mode here: SystemAdmin reads runs via jobs:query:list.
55
- const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
56
- const where: Record<string, unknown> = { tenantId: [query.user.tenantId] };
57
- if (query.payload.jobName) where["jobName"] = query.payload.jobName;
58
- if (query.payload.subject) where["subject"] = serializeJobSubject(query.payload.subject);
59
- const rows = await selectMany<TenantJobFailureRow>(db, tenantJobFailuresTable, where, {
60
- orderBy: { col: "failedAt", direction: "desc" },
61
- limit: query.payload.limit ?? DEFAULT_LIMIT,
62
- });
63
- return {
64
- rows: ctx.systemDb.assertRowsTenant(rows, "tenantId").map((row) => ({
65
- jobName: row.jobName,
66
- subject: parseSubject(row.subject),
67
- messageKey: row.messageKey,
68
- failedAt: row.failedAt,
69
- })),
70
- nextCursor: null,
71
- };
72
- },
73
- });
67
+ return {
68
+ rows: ctx.systemDb.assertRowsTenant(rows, "tenantId").map((row) => ({
69
+ jobName: row.jobName,
70
+ subject: parseSubject(row.subject),
71
+ messageKey: row.messageKey,
72
+ failedAt: row.failedAt,
73
+ })),
74
+ nextCursor: null,
75
+ };
76
+ },
77
+ });
78
+ }
@@ -32,63 +32,67 @@ function parseSubject(stored: string | null): Record<string, unknown> | null {
32
32
  return Object.fromEntries(parsed.filter(isSubjectEntry));
33
33
  }
34
34
 
35
- export const tenantRunsQuery = defineQueryHandler({
36
- name: "tenant-runs",
37
- description:
38
- "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.",
39
- schema: z.object({
40
- jobName: z.string().optional(),
41
- subject: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
42
- limit: z.number().int().min(1).max(200).optional(),
43
- }),
44
- // Every membership rank: a run state carries a job name, a status and
45
- // times, nothing a team member of the tenant may not see.
46
- access: { roles: access.roles("User", "Editor", ...access.admin) },
47
- handler: async (query, ctx) => {
48
- if (!ctx.systemDb) {
49
- throw new InternalError({
50
- message:
51
- "jobs:query:tenant-runs requires ctx.systemDb (feature must declare r.systemScope())",
35
+ export function createTenantRunsQuery(readRoles: readonly string[]) {
36
+ return defineQueryHandler({
37
+ name: "tenant-runs",
38
+ description:
39
+ "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.",
40
+ schema: z.object({
41
+ jobName: z.string().optional(),
42
+ subject: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
43
+ limit: z.number().int().min(1).max(200).optional(),
44
+ }),
45
+ // Every membership rank: a run state carries a job name, a status and
46
+ // times, nothing a team member of the tenant may not see.
47
+ access: {
48
+ roles: [...new Set([...access.roles("User", "Editor", ...access.admin), ...readRoles])],
49
+ },
50
+ handler: async (query, ctx) => {
51
+ if (!ctx.systemDb) {
52
+ throw new InternalError({
53
+ message:
54
+ "jobs:query:tenant-runs requires ctx.systemDb (feature must declare r.systemScope())",
55
+ });
56
+ }
57
+ // assertTenantMatch returns the same unfiltered system-mode db — the
58
+ // explicit tenantId below is the filter, assertRowsTenant the second net.
59
+ const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
60
+ const where: Record<string, unknown> = { tenantId: [query.user.tenantId] };
61
+ if (query.payload.jobName) where["jobName"] = query.payload.jobName;
62
+ if (query.payload.subject) where["subject"] = serializeJobSubject(query.payload.subject);
63
+ const limit = query.payload.limit ?? DEFAULT_LIMIT;
64
+ const newestFirst = { col: "updatedAt", direction: "desc" } as const;
65
+ const activeRows = await selectMany<TenantJobRunRow>(
66
+ db,
67
+ tenantJobRunsTable,
68
+ { ...where, status: { in: ["queued", "running"] } },
69
+ { orderBy: newestFirst, limit },
70
+ );
71
+ const finishedRows = await selectMany<TenantJobRunRow>(
72
+ db,
73
+ tenantJobRunsTable,
74
+ { ...where, status: { in: ["completed", "failed"] } },
75
+ { orderBy: newestFirst, limit },
76
+ );
77
+ const seenFinishedKeys = new Set<string>();
78
+ const finishedNewestPerKey = finishedRows.filter((row) => {
79
+ const key = JSON.stringify([row.jobName, row.subject]);
80
+ if (seenFinishedKeys.has(key)) return false;
81
+ seenFinishedKeys.add(key);
82
+ return true;
52
83
  });
53
- }
54
- // assertTenantMatch returns the same unfiltered system-mode db — the
55
- // explicit tenantId below is the filter, assertRowsTenant the second net.
56
- const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
57
- const where: Record<string, unknown> = { tenantId: [query.user.tenantId] };
58
- if (query.payload.jobName) where["jobName"] = query.payload.jobName;
59
- if (query.payload.subject) where["subject"] = serializeJobSubject(query.payload.subject);
60
- const limit = query.payload.limit ?? DEFAULT_LIMIT;
61
- const newestFirst = { col: "updatedAt", direction: "desc" } as const;
62
- const activeRows = await selectMany<TenantJobRunRow>(
63
- db,
64
- tenantJobRunsTable,
65
- { ...where, status: { in: ["queued", "running"] } },
66
- { orderBy: newestFirst, limit },
67
- );
68
- const finishedRows = await selectMany<TenantJobRunRow>(
69
- db,
70
- tenantJobRunsTable,
71
- { ...where, status: { in: ["completed", "failed"] } },
72
- { orderBy: newestFirst, limit },
73
- );
74
- const seenFinishedKeys = new Set<string>();
75
- const finishedNewestPerKey = finishedRows.filter((row) => {
76
- const key = JSON.stringify([row.jobName, row.subject]);
77
- if (seenFinishedKeys.has(key)) return false;
78
- seenFinishedKeys.add(key);
79
- return true;
80
- });
81
- const activeFirst = [...activeRows, ...finishedNewestPerKey];
82
- return {
83
- rows: ctx.systemDb.assertRowsTenant(activeFirst.slice(0, limit), "tenantId").map((row) => ({
84
- jobName: row.jobName,
85
- subject: parseSubject(row.subject),
86
- status: row.status,
87
- queuedAt: row.queuedAt,
88
- startedAt: row.startedAt,
89
- finishedAt: row.finishedAt,
90
- })),
91
- nextCursor: null,
92
- };
93
- },
94
- });
84
+ const activeFirst = [...activeRows, ...finishedNewestPerKey];
85
+ return {
86
+ rows: ctx.systemDb.assertRowsTenant(activeFirst.slice(0, limit), "tenantId").map((row) => ({
87
+ jobName: row.jobName,
88
+ subject: parseSubject(row.subject),
89
+ status: row.status,
90
+ queuedAt: row.queuedAt,
91
+ startedAt: row.startedAt,
92
+ finishedAt: row.finishedAt,
93
+ })),
94
+ nextCursor: null,
95
+ };
96
+ },
97
+ });
98
+ }
@@ -262,15 +262,16 @@ async function recordTenantRunQueued(
262
262
  const now = Temporal.Now.instant();
263
263
  await transaction(db, async (tx) => {
264
264
  // A finished row under the same id is left over from before a Redis reset
265
- // (ids restart at 1), not this job. Only rows that went through this hook
266
- // (queuedAt set) qualify: a fast worker can finish before the hook runs,
267
- // and that row is this job's own and must stay.
265
+ // (ids restart at 1), not this job. Only rows queued before this BullMQ job
266
+ // was created qualify: a fast worker can finish before the hook runs (no
267
+ // queuedAt), and a deduplicated add of an already finished job re-announces
268
+ // it with a creation time older than its own row. Both rows must stay.
268
269
  await deleteMany(tx, tenantJobRunsTable, {
269
270
  tenantId: key.tenantId,
270
271
  jobName,
271
272
  bullJobId,
272
273
  status: { in: ["completed", "failed"] },
273
- queuedAt: { gt: Temporal.Instant.fromEpochMilliseconds(0) },
274
+ queuedAt: { lt: Temporal.Instant.fromEpochMilliseconds(meta.jobCreatedAtMs) },
274
275
  });
275
276
  // DO NOTHING: a fast worker can report the start before this enqueue hook
276
277
  // returns, and a repeated jobId (debounce, fan-out dedup) is the same job.
@@ -35,7 +35,7 @@ runProdApp({
35
35
  // decoupling — legal-pages imports no code from template-resolver,
36
36
  // only uses the API via ctx)
37
37
  anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
38
- extraContext: ({ db }) => ({
38
+ extraContext: ({ db, registry }) => ({
39
39
  templateResolver: createTemplateResolverApi(db, registry),
40
40
  }),
41
41
  });
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "Tenant mail transport falls back to the app transport without tenant config",
6
+ "detail": "tenantMailTransport returns null when the channel context carries no tenant config, instead of throwing on every send, so the channel uses the app transport as documented."
7
+ },
2
8
  {
3
9
  "version": "0.378.0",
4
10
  "type": "improvement",
@@ -75,9 +75,9 @@ export const MAIL_TRANSPORT_EXTENSION = "mailTransport" as const;
75
75
  * config — tenant config reads (the plugin's host/port/from/...)
76
76
  * registry — extension lookup in the factory (not plugin-internal)
77
77
  * secrets — tenant secret reads (smtp.password)
78
- * auditUserId — audit identity for secret reads. Handler path: the
79
- * dispatcher sets the caller's user id; worker path: the
80
- * r.job wrap sets a system identity.
78
+ * auditUserId — audit identity for secret reads. Handler path: unset,
79
+ * requireSecretsContext falls back to ctx.user.id; worker path:
80
+ * the r.job wrap sets a system identity.
81
81
  */
82
82
  export type MailTransportContext = {
83
83
  readonly config?: ConfigAccessor;
@@ -276,6 +276,8 @@ export type TenantMailTransportScope = {
276
276
  export function tenantMailTransport(
277
277
  scope: TenantMailTransportScope,
278
278
  ): Promise<EmailTransport | null> {
279
+ // Without a tenant config the provider cannot be read: use the app transport.
280
+ if (scope.config === undefined) return Promise.resolve(null);
279
281
  return createTransportForTenantIfConfigured(
280
282
  {
281
283
  config: scope.config,
@@ -36,7 +36,7 @@ const PAT_REVOKE_TRIGGERING_FIELDS = ["passwordHash"] as const;
36
36
 
37
37
  export type BindPatAutoRevokeOnPasswordChange = (
38
38
  revoker: (userId: string) => Promise<number>,
39
- ) => void;
39
+ ) => () => void;
40
40
 
41
41
  // Reads the late-bind setter off a mounted personal-access-tokens feature's
42
42
  // exports — run{Prod,Dev}App call this once a concrete db is available,
@@ -169,6 +169,9 @@ export function createPersonalAccessTokensFeature(
169
169
  });
170
170
  const bindAutoRevokeOnPasswordChange: BindPatAutoRevokeOnPasswordChange = (revoker) => {
171
171
  boundAutoRevoke = revoker;
172
+ return () => {
173
+ if (boundAutoRevoke === revoker) boundAutoRevoke = undefined;
174
+ };
172
175
  };
173
176
 
174
177
  const handlers = {
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "renderTablePdf validates the document for direct callers",
6
+ "detail": "renderTablePdf parses the document against pdfTableDocumentSchema and throws RendererError invalid_payload above the row, column and cell caps. Title, subtitle, meta entries, headers, empty text, footer text and page label now have length caps and meta at most 500 entries."
7
+ },
2
8
  {
3
9
  "version": "0.364.0",
4
10
  "type": "fix",