@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
@@ -0,0 +1,37 @@
1
+ import { type PaymentEvent, type SubscriptionEvent, type SubscriptionStatus } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
2
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
3
+ import * as z from "zod";
4
+ import type { DodoWebhookRuntime } from "./runtime.ts";
5
+ export declare const WEBHOOK_TOLERANCE_SECONDS = 300;
6
+ export type StandardWebhookHeaders = Readonly<Record<string, string>>;
7
+ /** Throws unless the Standard Webhooks signature over `rawBody` is valid and
8
+ * the timestamp is within the tolerance in both directions. */
9
+ export declare function verifyStandardWebhook(rawBody: string, headers: StandardWebhookHeaders, secret: string, nowSeconds: number): void;
10
+ export declare const dodoSubscriptionSchema: z.ZodObject<{
11
+ subscription_id: z.ZodString;
12
+ product_id: z.ZodString;
13
+ status: z.ZodString;
14
+ customer: z.ZodObject<{
15
+ customer_id: z.ZodString;
16
+ }, z.core.$strip>;
17
+ metadata: z.ZodOptional<z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
18
+ next_billing_date: z.ZodISODateTime;
19
+ cancel_at_next_billing_date: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
20
+ }, z.core.$strip>;
21
+ export type DodoSubscription = z.infer<typeof dodoSubscriptionSchema>;
22
+ export type MappedDodoSubscriptionState = {
23
+ readonly status: SubscriptionStatus;
24
+ readonly tier: string;
25
+ readonly currentPeriodEnd: string;
26
+ readonly cancelAt: string | null;
27
+ };
28
+ /** Shared by the webhook and retrieveSubscription so both map Dodo state
29
+ * identically. Null when the status is unknown or the product is not in
30
+ * `priceToTier` (SubscriptionEvent.tier is required). */
31
+ export declare function mapDodoSubscriptionState(sub: DodoSubscription, priceToTier: Readonly<Record<string, string>>): MappedDodoSubscriptionState | null;
32
+ export type DodoWebhookOptions = {
33
+ readonly priceToTier: Readonly<Record<string, string>>;
34
+ /** Test seam: Unix seconds. Defaults to the wall clock. */
35
+ readonly nowSeconds?: () => number;
36
+ };
37
+ export declare function verifyAndParseDodoWebhook(runtime: DodoWebhookRuntime, options: DodoWebhookOptions): (rawBody: string, headers: Record<string, string>, systemSecrets?: SecretsContext) => Promise<SubscriptionEvent | PaymentEvent | null>;
@@ -0,0 +1,189 @@
1
+ // Dodo Payments webhook: Standard Webhooks signature verification plus mapping
2
+ // of Dodo's event envelope onto the foundation's normalized events.
3
+ //
4
+ // Verification always runs against the raw body string; re-serialized JSON
5
+ // would change whitespace and break the HMAC. The signature check happens
6
+ // before any parsing, so no path bypasses it.
7
+ import { createHmac, timingSafeEqual } from "node:crypto";
8
+ import { BillingEventKinds, parseProviderConsentId, SubscriptionEventTypes, SubscriptionStatuses, } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
9
+ import { parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
10
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
11
+ import * as z from "zod";
12
+ import { DODO_PROVIDER_NAME } from "./constants.js";
13
+ export const WEBHOOK_TOLERANCE_SECONDS = 300;
14
+ const SECRET_PREFIX = "whsec_";
15
+ const SIGNATURE_VERSION = "v1";
16
+ const TIMESTAMP_PATTERN = /^\d+$/;
17
+ function signatureFor(secretKey, signedContent) {
18
+ return createHmac("sha256", secretKey).update(signedContent).digest();
19
+ }
20
+ function matchesAnyV1Signature(header, expected) {
21
+ return header.split(" ").some((entry) => {
22
+ const [version, encoded] = entry.split(",");
23
+ if (version !== SIGNATURE_VERSION || !encoded)
24
+ return false;
25
+ const candidate = Buffer.from(encoded, "base64");
26
+ return candidate.length === expected.length && timingSafeEqual(candidate, expected);
27
+ });
28
+ }
29
+ /** Throws unless the Standard Webhooks signature over `rawBody` is valid and
30
+ * the timestamp is within the tolerance in both directions. */
31
+ export function verifyStandardWebhook(rawBody, headers, secret, nowSeconds) {
32
+ const id = headers["webhook-id"];
33
+ const timestamp = headers["webhook-timestamp"];
34
+ const signatureHeader = headers["webhook-signature"];
35
+ if (!id || !timestamp || !signatureHeader) {
36
+ throw new Error("subscription-dodo: webhook-id, webhook-timestamp or webhook-signature missing");
37
+ }
38
+ if (!TIMESTAMP_PATTERN.test(timestamp)) {
39
+ throw new Error("subscription-dodo: webhook-timestamp is not a Unix timestamp in seconds");
40
+ }
41
+ if (Math.abs(nowSeconds - Number(timestamp)) > WEBHOOK_TOLERANCE_SECONDS) {
42
+ throw new Error("subscription-dodo: webhook timestamp outside the tolerance window");
43
+ }
44
+ const secretKey = Buffer.from(secret.startsWith(SECRET_PREFIX) ? secret.slice(SECRET_PREFIX.length) : secret, "base64");
45
+ const expected = signatureFor(secretKey, `${id}.${timestamp}.${rawBody}`);
46
+ if (!matchesAnyV1Signature(signatureHeader, expected)) {
47
+ throw new Error("subscription-dodo: webhook signature verify failed");
48
+ }
49
+ }
50
+ // =============================================================================
51
+ // Event mapping
52
+ // =============================================================================
53
+ const DODO_SUBSCRIPTION_EVENT_TYPES = {
54
+ "subscription.active": SubscriptionEventTypes.created,
55
+ "subscription.updated": SubscriptionEventTypes.updated,
56
+ "subscription.plan_changed": SubscriptionEventTypes.updated,
57
+ "subscription.paused": SubscriptionEventTypes.updated,
58
+ "subscription.unpaused": SubscriptionEventTypes.updated,
59
+ "subscription.update_payment_method": SubscriptionEventTypes.updated,
60
+ "subscription.renewed": SubscriptionEventTypes.invoicePaid,
61
+ "subscription.past_due": SubscriptionEventTypes.invoicePaymentFailed,
62
+ "subscription.on_hold": SubscriptionEventTypes.invoicePaymentFailed,
63
+ "subscription.cancelled": SubscriptionEventTypes.canceled,
64
+ "subscription.expired": SubscriptionEventTypes.canceled,
65
+ "subscription.failed": SubscriptionEventTypes.canceled,
66
+ };
67
+ const DODO_SUBSCRIPTION_STATUSES = {
68
+ pending: SubscriptionStatuses.incomplete,
69
+ active: SubscriptionStatuses.active,
70
+ on_hold: SubscriptionStatuses.pastDue,
71
+ past_due: SubscriptionStatuses.pastDue,
72
+ paused: SubscriptionStatuses.pastDue,
73
+ cancelled: SubscriptionStatuses.canceled,
74
+ failed: SubscriptionStatuses.canceled,
75
+ expired: SubscriptionStatuses.canceled,
76
+ };
77
+ const PAYMENT_SUCCEEDED = "payment.succeeded";
78
+ const metadataSchema = z.record(z.string(), z.unknown()).nullish();
79
+ const customerSchema = z.object({ customer_id: z.string().min(1) });
80
+ const envelopeSchema = z.object({
81
+ type: z.string(),
82
+ timestamp: z.iso.datetime({ offset: true }),
83
+ data: z.unknown(),
84
+ });
85
+ export const dodoSubscriptionSchema = z.object({
86
+ subscription_id: z.string().min(1),
87
+ product_id: z.string().min(1),
88
+ status: z.string(),
89
+ customer: customerSchema,
90
+ metadata: metadataSchema,
91
+ next_billing_date: z.iso.datetime({ offset: true }),
92
+ cancel_at_next_billing_date: z.boolean().nullish(),
93
+ });
94
+ const dodoPaymentSchema = z.object({
95
+ subscription_id: z.string().nullish(),
96
+ checkout_session_id: z.string().nullish(),
97
+ customer: customerSchema,
98
+ metadata: metadataSchema,
99
+ product_cart: z.array(z.object({ product_id: z.string().min(1) })).nullish(),
100
+ });
101
+ function metadataString(metadata, key) {
102
+ const value = metadata?.[key];
103
+ return typeof value === "string" && value.length > 0 ? value : undefined;
104
+ }
105
+ /** Shared by the webhook and retrieveSubscription so both map Dodo state
106
+ * identically. Null when the status is unknown or the product is not in
107
+ * `priceToTier` (SubscriptionEvent.tier is required). */
108
+ export function mapDodoSubscriptionState(sub, priceToTier) {
109
+ const status = DODO_SUBSCRIPTION_STATUSES[sub.status];
110
+ const tier = priceToTier[sub.product_id];
111
+ if (!status || !tier)
112
+ return null;
113
+ return {
114
+ status,
115
+ tier,
116
+ currentPeriodEnd: sub.next_billing_date,
117
+ cancelAt: sub.cancel_at_next_billing_date === true ? sub.next_billing_date : null,
118
+ };
119
+ }
120
+ function currentUnixSeconds() {
121
+ return Math.floor(Temporal.Now.instant().epochMilliseconds / 1000);
122
+ }
123
+ function parsePaymentEvent(providerEventId, data) {
124
+ const parsed = dodoPaymentSchema.safeParse(data);
125
+ if (!parsed.success)
126
+ return null;
127
+ const payment = parsed.data;
128
+ // A subscription's own payments are covered by its subscription.* events.
129
+ if (payment.subscription_id)
130
+ return null;
131
+ const tenantId = metadataString(payment.metadata, "tenantId");
132
+ const priceId = payment.product_cart?.[0]?.product_id;
133
+ if (!tenantId || !priceId)
134
+ return null;
135
+ const consentId = parseProviderConsentId(payment.metadata?.["consentId"]);
136
+ return {
137
+ kind: BillingEventKinds.payment,
138
+ providerEventId,
139
+ providerName: DODO_PROVIDER_NAME,
140
+ tenantId,
141
+ ...(consentId !== undefined && { consentId }),
142
+ ...(payment.checkout_session_id && { providerCheckoutId: payment.checkout_session_id }),
143
+ providerCustomerId: payment.customer.customer_id,
144
+ priceId,
145
+ };
146
+ }
147
+ function parseSubscriptionEvent(providerEventId, type, providerEventAt, data, priceToTier) {
148
+ const parsed = dodoSubscriptionSchema.safeParse(data);
149
+ if (!parsed.success)
150
+ return null;
151
+ const sub = parsed.data;
152
+ const tenantId = metadataString(sub.metadata, "tenantId");
153
+ const state = mapDodoSubscriptionState(sub, priceToTier);
154
+ if (!tenantId || !state)
155
+ return null;
156
+ const consentId = parseProviderConsentId(sub.metadata?.["consentId"]);
157
+ return {
158
+ providerEventId,
159
+ providerName: DODO_PROVIDER_NAME,
160
+ type,
161
+ tenantId,
162
+ ...(consentId !== undefined && { consentId }),
163
+ providerCustomerId: sub.customer.customer_id,
164
+ providerSubscriptionId: sub.subscription_id,
165
+ providerEventAt,
166
+ ...state,
167
+ };
168
+ }
169
+ export function verifyAndParseDodoWebhook(runtime, options) {
170
+ const nowSeconds = options.nowSeconds ?? currentUnixSeconds;
171
+ return async (rawBody, headers, systemSecrets) => {
172
+ const secret = await runtime.resolveWebhookSecret(systemSecrets);
173
+ verifyStandardWebhook(rawBody, headers, secret, nowSeconds());
174
+ // Past verification the body is trusted to be Dodo's; a malformed shape is
175
+ // schema drift, so it is dropped (200 ignored) instead of answered with a 401.
176
+ const envelope = envelopeSchema.safeParse(parseJsonOrThrow(rawBody, "subscription-dodo webhook body"));
177
+ // verifyStandardWebhook has checked webhook-id is present.
178
+ const providerEventId = headers["webhook-id"];
179
+ if (!envelope.success || !providerEventId)
180
+ return null;
181
+ const { type, timestamp, data } = envelope.data;
182
+ if (type === PAYMENT_SUCCEEDED)
183
+ return parsePaymentEvent(providerEventId, data);
184
+ const normalizedType = DODO_SUBSCRIPTION_EVENT_TYPES[type];
185
+ if (!normalizedType)
186
+ return null;
187
+ return parseSubscriptionEvent(providerEventId, normalizedType, timestamp, data, options.priceToTier);
188
+ };
189
+ }
@@ -172,6 +172,7 @@ export function createSubscriptionStripeFeature(options = {}) {
172
172
  const priceCache = createStripePriceCache();
173
173
  const portalConfigurationCache = new Map();
174
174
  const plugin = {
175
+ displayName: "Stripe",
175
176
  verifyAndParseWebhook: verifyAndParseStripeWebhook(runtimes.webhook, {
176
177
  priceToTier: options.priceToTier ?? {},
177
178
  }),
@@ -27,7 +27,7 @@
27
27
  import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
28
28
  import { SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
29
29
  import { FeatureDisabledError, UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
30
- import { parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
30
+ import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
31
31
  import Stripe from "stripe";
32
32
  import { SUBSCRIPTION_STRIPE_FEATURE } from "./constants.js";
33
33
  const API_KEY_HINT = "Set the Stripe API key via config:write:set on `subscription-stripe:config:api-key` (or seed it from STRIPE_API_KEY during the env bridge).";
@@ -52,14 +52,15 @@ export function createStripeClientCache() {
52
52
  // (the un-audited webhook path + the audited ctx read here) therefore returns
53
53
  // the JSON-quoted string and must parse it back. The factory fallback is a
54
54
  // plain env string and stays raw — so parsing is applied only to the store
55
- // read, never to the fallback.
55
+ // read, never to the fallback. The error must not carry the input: the JSON
56
+ // parser's own message would repeat the credential into logs and the 401 body.
56
57
  function parseStoredSecret(raw) {
57
58
  if (raw === undefined || raw.length === 0)
58
59
  return undefined;
59
- // parseJsonOrThrow is the sanctioned safe-parse (same helper the config
60
- // resolver's deserializeValue uses for this very value) — mirrors how the
61
- // store round-trips a backing:"secrets" text value.
62
- const parsed = parseJsonOrThrow(raw, "subscription-stripe credential (backing:secrets)");
60
+ const parsed = parseJsonSafe(raw, undefined);
61
+ if (parsed === undefined) {
62
+ throw new Error("subscription-stripe credential (backing:secrets) is not valid JSON");
63
+ }
63
64
  return typeof parsed === "string" && parsed.length > 0 ? parsed : undefined;
64
65
  }
65
66
  export function createStripeRuntimes(deps) {
@@ -80,10 +80,11 @@ export type TemplateResolverApi = {
80
80
  readonly resolveLayout: (args: ResolveLayoutRequest) => Promise<TemplateResource>;
81
81
  };
82
82
  export declare function createTemplateResolverApi(db: DbRunner | TenantDb, registry: Registry): TemplateResolverApi;
83
- export declare function isRowDelivered(db: DbRunner | TenantDb, policy: TemplateDeliveryPolicy, row: TemplateResourceRow): Promise<boolean>;
83
+ type DeliveredRowKey = Pick<TemplateResourceRow, "tenantId" | "kind" | "slug">;
84
+ export declare function isRowDelivered(db: DbRunner | TenantDb, policy: TemplateDeliveryPolicy, row: DeliveredRowKey): Promise<boolean>;
84
85
  export declare function isRowDeliveredTo(db: DbRunner | TenantDb, policy: TemplateDeliveryPolicy, user: {
85
86
  readonly roles: readonly string[];
86
- }, row: TemplateResourceRow): Promise<boolean>;
87
+ }, row: DeliveredRowKey): Promise<boolean>;
87
88
  export declare function filterDeliveredTo(db: DbRunner | TenantDb, policy: TemplateDeliveryPolicy, user: {
88
89
  readonly roles: readonly string[];
89
90
  }, rows: readonly TemplateResourceRow[]): Promise<TemplateResourceRow[]>;
@@ -69,9 +69,31 @@ export async function isRowDelivered(db, policy, row) {
69
69
  export async function isRowDeliveredTo(db, policy, user, row) {
70
70
  return isSystemAdmin(user) || (await isRowDelivered(db, policy, row));
71
71
  }
72
+ // One system-slug lookup per kind instead of one query per row, because by-tenant is anonymous.
72
73
  export async function filterDeliveredTo(db, policy, user, rows) {
73
- const flags = await Promise.all(rows.map((row) => isRowDeliveredTo(db, policy, user, row)));
74
- return rows.filter((_, index) => flags[index]);
74
+ if (isSystemAdmin(user))
75
+ return [...rows];
76
+ const served = rows.filter((row) => isRowServed(policy, row));
77
+ const shadowable = served.filter((row) => row.tenantId !== SYSTEM_TENANT_ID && isServedByCollectionOnly(policy, row));
78
+ const slugsByKind = new Map();
79
+ for (const row of shadowable) {
80
+ const slugs = slugsByKind.get(row.kind) ?? new Set();
81
+ slugs.add(row.slug);
82
+ slugsByKind.set(row.kind, slugs);
83
+ }
84
+ const systemKeys = new Set();
85
+ for (const [kind, slugs] of slugsByKind) {
86
+ const systemRows = await selectMany(db, templateResourcesTable, {
87
+ tenantId: SYSTEM_TENANT_ID,
88
+ kind,
89
+ slug: [...slugs],
90
+ });
91
+ for (const systemRow of systemRows)
92
+ systemKeys.add(`${kind}|${systemRow.slug}`);
93
+ }
94
+ return served.filter((row) => row.tenantId === SYSTEM_TENANT_ID ||
95
+ !isServedByCollectionOnly(policy, row) ||
96
+ !systemKeys.has(`${row.kind}|${row.slug}`));
75
97
  }
76
98
  export async function systemTemplateExists(db, kind, slug) {
77
99
  const rows = await selectMany(db, templateResourcesTable, { tenantId: SYSTEM_TENANT_ID, slug, kind }, { limit: 1 });
@@ -320,6 +320,7 @@ export function compileDocumentBodyToHandlebars(html, variables, options = {}) {
320
320
  for (const element of Array.from(root.querySelectorAll("div[data-images]"))) {
321
321
  rewriteImagesBlock(element, state, options);
322
322
  }
323
+ // Order matters: the conditional wraps first so the row's #each lands inside the #if (evaluated once per list).
323
324
  for (const block of Array.from(root.querySelectorAll(`[${DOCUMENT_CONDITION_ATTRIBUTE}]`))) {
324
325
  rewriteConditionalBlock(block, state);
325
326
  }
@@ -29,7 +29,7 @@ import { tenantOverridesQuery } from "./handlers/tenant-overrides.query.js";
29
29
  import { makeArchiveSystemWrite, makeArchiveWrite, makePublishSystemWrite, makePublishWrite, } from "./handlers/toggle-status.write.js";
30
30
  import { upsertSystemWrite } from "./handlers/upsert-system.write.js";
31
31
  import { makeUpsertTenantWrite } from "./handlers/upsert-tenant.write.js";
32
- import { usageQuery } from "./handlers/usage.query.js";
32
+ import { makeUsageQuery } from "./handlers/usage.query.js";
33
33
  import { validateQuery } from "./handlers/validate.query.js";
34
34
  import { TEMPLATE_RESOLVER_I18N } from "./i18n.js";
35
35
  import { templateResourceEntity } from "./table.js";
@@ -127,7 +127,7 @@ export function createTemplateResolverFeature(opts = {}) {
127
127
  list: r.queryHandler(listQuery),
128
128
  bySlug: r.queryHandler(bySlugQuery),
129
129
  byTenant: r.queryHandler(byTenantQuery),
130
- usage: r.queryHandler(usageQuery),
130
+ usage: r.queryHandler(makeUsageQuery(policy)),
131
131
  overview: r.queryHandler(makeOverviewQuery(policy)),
132
132
  layoutSource: r.queryHandler(layoutSourceQuery),
133
133
  tenantOverrides: r.queryHandler(tenantOverridesQuery),
@@ -1,7 +1,7 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { ValidationError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import { requireForTenant } from "../../shared/index.js";
4
- import { attachmentLimitFailure, attachmentNeedsMailTemplate, createPreparedBundleResourceEntry, prepareTemplateAttachmentBeforeTransaction, prepareTemplateResourceBeforeTransaction, readTemplateResourceMaxBytes, releaseAll, } from "../store-template-resource.js";
4
+ import { attachmentLimitFailure, attachmentNeedsMailTemplate, createPreparedBundleResourceEntry, prepareTemplateAttachmentBeforeTransaction, prepareTemplateResourceBeforeTransaction, readTemplateResourceMaxBytes, releaseAfterFailure, releaseAll, } from "../store-template-resource.js";
5
5
  import { templateResourcesTable } from "../table.js";
6
6
  import { attachTemplateResources } from "../template-resources.js";
7
7
  import { resolveTemplateVariables } from "../template-validation.js";
@@ -94,7 +94,7 @@ export async function prepareTemplateBundleBeforeTransaction(args) {
94
94
  })
95
95
  : await prepareTemplateResourceBeforeTransaction(common);
96
96
  if (!result.isSuccess) {
97
- await releaseAll(releases);
97
+ await releaseAfterFailure(args.ctx, releases);
98
98
  return result;
99
99
  }
100
100
  if (result.release)
@@ -103,7 +103,7 @@ export async function prepareTemplateBundleBeforeTransaction(args) {
103
103
  }
104
104
  }
105
105
  catch (cause) {
106
- await releaseAll(releases);
106
+ await releaseAfterFailure(args.ctx, releases);
107
107
  throw cause;
108
108
  }
109
109
  return { isSuccess: true, data: prepared, release: () => releaseAll(releases) };
@@ -17,4 +17,8 @@ export declare function makeCopyLocaleWrite(policy: TemplateEditPolicy): import(
17
17
  system: "system";
18
18
  tenant: "tenant";
19
19
  }>>;
20
- }, z.core.$strip>, import("./template-write.ts").WrittenTemplateRow, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap, readonly import("../store-template-resource.ts").PreparedLinkedResourceCopy[]>;
20
+ }, z.core.$strip>, import("./template-write.ts").WrittenTemplateRow, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap, {
21
+ sourceId: string | number;
22
+ sourceVersion: number;
23
+ copies: readonly import("../store-template-resource.ts").PreparedLinkedResourceCopy[];
24
+ }>;
@@ -1,6 +1,6 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { defineWriteHandler, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
3
- import { AccessDeniedError, NotFoundError, ValidationError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { AccessDeniedError, NotFoundError, ValidationError, VersionConflictError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
5
5
  import * as z from "zod";
6
6
  import { requireForTenant } from "../../shared/index.js";
@@ -111,14 +111,19 @@ export function makeCopyLocaleWrite(policy) {
111
111
  const request = await checkCopyRequest(ctx, policy, event);
112
112
  if (!request.isSuccess)
113
113
  return request;
114
- if (!request.data.sourceIsForeign)
115
- return { isSuccess: true, data: [] };
116
- return prepareLinkedResourceCopiesBeforeTransaction({
114
+ const { source, tenantId, sourceIsForeign, sourceResources } = request.data;
115
+ const sourceStamp = { sourceId: source.id, sourceVersion: source.version };
116
+ if (!sourceIsForeign)
117
+ return { isSuccess: true, data: { ...sourceStamp, copies: [] } };
118
+ const prepared = await prepareLinkedResourceCopiesBeforeTransaction({
117
119
  ctx,
118
120
  user: event.user,
119
- tenantId: request.data.tenantId,
120
- resources: request.data.sourceResources,
121
+ tenantId,
122
+ resources: sourceResources,
121
123
  });
124
+ if (!prepared.isSuccess)
125
+ return prepared;
126
+ return { ...prepared, data: { ...sourceStamp, copies: prepared.data } };
122
127
  },
123
128
  handler: async (event, ctx) => {
124
129
  const { slug, kind, toLocale } = event.payload;
@@ -126,6 +131,16 @@ export function makeCopyLocaleWrite(policy) {
126
131
  if (!request.isSuccess)
127
132
  return request;
128
133
  const { scope, tenantId, source, contentFormat, sourceResources, sourceIsForeign } = request.data;
134
+ // The uploads were prepared for the source as it was before the transaction; a source that
135
+ // changed since (own row created or deleted, edited) no longer matches them.
136
+ if (event.prepared.sourceId !== source.id ||
137
+ event.prepared.sourceVersion !== source.version) {
138
+ return writeFailure(new VersionConflictError({
139
+ entityId: source.id,
140
+ expectedVersion: event.prepared.sourceVersion,
141
+ currentVersion: source.version,
142
+ }));
143
+ }
129
144
  const variableSchema = storedRecord(source.variableSchema);
130
145
  const written = await writeTemplateRow({
131
146
  ctx,
@@ -149,7 +164,7 @@ export function makeCopyLocaleWrite(policy) {
149
164
  if (!written.isSuccess)
150
165
  return written;
151
166
  const resources = sourceIsForeign
152
- ? await createLinkedResourceCopies({ ctx, user: event.user, tenantId }, event.prepared)
167
+ ? await createLinkedResourceCopies({ ctx, user: event.user, tenantId }, event.prepared.copies)
153
168
  : { isSuccess: true, data: sourceResources };
154
169
  if (!resources.isSuccess)
155
170
  return resources;
@@ -2,8 +2,9 @@ import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { defineQueryHandler, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { NotFoundError } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import * as z from "zod";
5
+ import { isRowDeliveredTo } from "../api.js";
5
6
  import { templateResourcesTable } from "../table.js";
6
- import { deliveryPolicyOf, isRowServedTo } from "../template-delivery-policy.js";
7
+ import { deliveryPolicyOf } from "../template-delivery-policy.js";
7
8
  import { isHiddenSystemDraft } from "../template-draft.js";
8
9
  import { buildOverrideDiff } from "./override-diff.js";
9
10
  export const diffToSystemQuery = defineQueryHandler({
@@ -18,7 +19,7 @@ export const diffToSystemQuery = defineQueryHandler({
18
19
  if (!tenantRow ||
19
20
  tenantRow.tenantId !== query.user.tenantId ||
20
21
  tenantRow.scope !== "tenant" ||
21
- !isRowServedTo(deliveryPolicyOf(ctx.registry), query.user, tenantRow)) {
22
+ !(await isRowDeliveredTo(ctx.db, deliveryPolicyOf(ctx.registry), query.user, tenantRow))) {
22
23
  throw new NotFoundError("template-resource", query.payload.id);
23
24
  }
24
25
  const systemRow = await fetchOne(ctx.db, templateResourcesTable, {
@@ -1,8 +1,9 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
4
+ import { isRowDeliveredTo } from "../api.js";
4
5
  import { templateResourcesTable } from "../table.js";
5
- import { deliveryPolicyOf, isRowServedTo } from "../template-delivery-policy.js";
6
+ import { deliveryPolicyOf } from "../template-delivery-policy.js";
6
7
  import { isHiddenSystemDraft, toWorkingRow } from "../template-draft.js";
7
8
  import { isSystemOnlyTemplateHiddenFrom } from "../template-system-only.js";
8
9
  import { parseStoredJson } from "./template-gates.js";
@@ -20,7 +21,7 @@ export async function loadDocumentBase(ctx, user, where) {
20
21
  // A locked override is neither shown nor edited by a non-SystemAdmin; the served system default is.
21
22
  const ownIsVisible = own !== undefined &&
22
23
  !isSystemOnlyTemplateHiddenFrom(user, ctx.registry, own) &&
23
- isRowServedTo(deliveryPolicyOf(ctx.registry), user, own);
24
+ (await isRowDeliveredTo(ctx.db, deliveryPolicyOf(ctx.registry), user, own));
24
25
  if (own !== undefined && ownIsVisible) {
25
26
  const working = toWorkingRow(own, user.tenantId);
26
27
  return {
@@ -1,8 +1,9 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import * as z from "zod";
4
+ import { isRowDeliveredTo } from "../api.js";
4
5
  import { templateResourcesTable } from "../table.js";
5
- import { deliveryPolicyOf, isRowServedTo } from "../template-delivery-policy.js";
6
+ import { deliveryPolicyOf } from "../template-delivery-policy.js";
6
7
  import { draftViewerTenantId, isHiddenSystemDraft, toWorkingRow } from "../template-draft.js";
7
8
  import { isSystemOnlyTemplateHiddenFrom } from "../template-system-only.js";
8
9
  // Admin lookup for the UI editing flow. Returns the raw template, including draft/
@@ -22,7 +23,7 @@ export const findByIdQuery = defineQueryHandler({
22
23
  if (!stored ||
23
24
  isHiddenSystemDraft(query.user, stored) ||
24
25
  isSystemOnlyTemplateHiddenFrom(query.user, ctx.registry, stored) ||
25
- !isRowServedTo(deliveryPolicyOf(ctx.registry), query.user, stored)) {
26
+ !(await isRowDeliveredTo(ctx.db, deliveryPolicyOf(ctx.registry), query.user, stored))) {
26
27
  return null;
27
28
  }
28
29
  const row = toWorkingRow(stored, draftViewerTenantId(query.user, stored));
@@ -1,8 +1,9 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import * as z from "zod";
4
+ import { filterDeliveredTo } from "../api.js";
4
5
  import { templateResourcesTable } from "../table.js";
5
- import { deliveryPolicyOf, isRowServedTo } from "../template-delivery-policy.js";
6
+ import { deliveryPolicyOf } from "../template-delivery-policy.js";
6
7
  import { isHiddenSystemDraft } from "../template-draft.js";
7
8
  import { isSystemOnlyTemplateHiddenFrom } from "../template-system-only.js";
8
9
  import { TEMPLATE_KINDS, TEMPLATE_STATUSES } from "../web/constants.js";
@@ -41,11 +42,9 @@ export const listQuery = defineQueryHandler({
41
42
  limit: 500,
42
43
  }));
43
44
  const deliveryPolicy = deliveryPolicyOf(ctx.registry);
44
- return rows
45
- .filter((row) => !isHiddenSystemDraft(query.user, row) &&
46
- !isSystemOnlyTemplateHiddenFrom(query.user, ctx.registry, row) &&
47
- isRowServedTo(deliveryPolicy, query.user, row))
48
- .map((row) => ({
45
+ const candidates = rows.filter((row) => !isHiddenSystemDraft(query.user, row) &&
46
+ !isSystemOnlyTemplateHiddenFrom(query.user, ctx.registry, row));
47
+ return (await filterDeliveredTo(ctx.db, deliveryPolicy, query.user, candidates)).map((row) => ({
49
48
  id: String(row.id),
50
49
  tenantId: row.tenantId,
51
50
  slug: row.slug,
@@ -1,7 +1,9 @@
1
1
  import * as z from "zod";
2
2
  import { type TemplateResourceRow } from "../table.ts";
3
3
  import { type TemplateDeliveryPolicy } from "../template-delivery-policy.ts";
4
+ import { isHiddenSystemDraft } from "../template-draft.ts";
4
5
  import { type TemplateEditPolicy } from "../template-edit-policy.ts";
6
+ import { isSystemOnlyTemplateHiddenFrom } from "../template-system-only.ts";
5
7
  import { type EffectiveMailLayoutSlugs } from "../template-usage.ts";
6
8
  import { type TemplateStatus } from "../web/constants.ts";
7
9
  declare const OVERVIEW_KINDS: readonly ["mail", "document-pdf", "mail-layout", "pdf-layout"];
@@ -51,6 +53,7 @@ export type OverviewResponse = {
51
53
  /** True when the tenant has more template rows than the overview reads; rows and total are then incomplete. */
52
54
  readonly truncated: boolean;
53
55
  };
56
+ export declare function isRowVisibleToCaller(policy: TemplateEditPolicy, user: Parameters<typeof isHiddenSystemDraft>[0], registry: Parameters<typeof isSystemOnlyTemplateHiddenFrom>[1], row: TemplateResourceRow): boolean;
54
57
  export declare function effectiveMailLayoutSlugs(rows: readonly TemplateResourceRow[], policy: TemplateDeliveryPolicy): EffectiveMailLayoutSlugs;
55
58
  export declare function makeOverviewQuery(policy: TemplateEditPolicy): import("@cosmicdrift/kumiko-types/define-handler").QueryHandlerDefinition<"overview", z.ZodObject<{
56
59
  search: z.ZodOptional<z.ZodString>;
@@ -69,6 +69,11 @@ function effectiveRowsOf(rows, policy) {
69
69
  ? { origin: "tenant", rows: tenantRows, lockedOverride: false, lockedOverrideId: null }
70
70
  : null;
71
71
  }
72
+ export function isRowVisibleToCaller(policy, user, registry, row) {
73
+ return (!isHiddenSystemDraft(user, row) &&
74
+ !isSystemOnlyTemplateHiddenFrom(user, registry, row) &&
75
+ mayEditTemplate(policy, user, row));
76
+ }
72
77
  export function effectiveMailLayoutSlugs(rows, policy) {
73
78
  const byLocale = new Map();
74
79
  for (const row of rows) {
@@ -242,9 +247,7 @@ export function makeOverviewQuery(policy) {
242
247
  limit: OVERVIEW_ROW_LIMIT,
243
248
  });
244
249
  }
245
- const visibleRows = rows.filter((row) => !isHiddenSystemDraft(query.user, row) &&
246
- !isSystemOnlyTemplateHiddenFrom(query.user, ctx.registry, row) &&
247
- mayEditTemplate(policy, query.user, row));
250
+ const visibleRows = rows.filter((row) => isRowVisibleToCaller(policy, query.user, ctx.registry, row));
248
251
  const deliveryPolicy = deliveryPolicyOf(ctx.registry);
249
252
  const { drafts, layoutSlugByDocument } = buildDrafts(groupBySlugAndKind(visibleRows), query.user, deliveryPolicy);
250
253
  const effectiveLayouts = effectiveMailLayoutSlugs(visibleRows, deliveryPolicy);
@@ -14,9 +14,7 @@ export const tenantOverrideCountsQuery = defineQueryHandler({
14
14
  description: "Counts, per template slug and kind, the enabled tenants that override it with an active version, next to the number of enabled tenants; one query for the whole template overview instead of one tenant-overrides call per row.",
15
15
  escapeHatch: { grants: ["unsafeRaw"], reason: TENANT_OVERRIDES_REASON },
16
16
  handler: async (_query, ctx) => {
17
- const found = await selectMany(ctx.db.unsafeRaw(), templateResourcesTable, {
18
- scope: "tenant",
19
- });
17
+ const found = await selectMany(ctx.db.unsafeRaw(), templateResourcesTable, { scope: "tenant" }, { fields: ["tenantId", "slug", "kind", "status", "version"] });
20
18
  const enabledTenantIds = await loadEnabledTenantIds(ctx);
21
19
  const rowsBySlugAndKind = new Map();
22
20
  for (const row of found) {
@@ -8,5 +8,6 @@ type TenantIdQueryContext = {
8
8
  };
9
9
  /** Null when the tenant feature is not installed: every tenant row then counts. */
10
10
  export declare function loadEnabledTenantIds(ctx: TenantIdQueryContext): Promise<ReadonlySet<string> | null>;
11
- export declare function pickBestRowPerTenant(found: readonly TemplateResourceRow[], enabledTenantIds: ReadonlySet<string> | null): Map<string, TemplateResourceRow>;
11
+ export type OverrideCandidate = Pick<TemplateResourceRow, "tenantId" | "status" | "version">;
12
+ export declare function pickBestRowPerTenant<TRow extends OverrideCandidate>(found: readonly TRow[], enabledTenantIds: ReadonlySet<string> | null): Map<string, TRow>;
12
13
  export {};