@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
@@ -0,0 +1,262 @@
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
+
8
+ import { createHmac, timingSafeEqual } from "node:crypto";
9
+ import {
10
+ BillingEventKinds,
11
+ type PaymentEvent,
12
+ parseProviderConsentId,
13
+ type SubscriptionEvent,
14
+ type SubscriptionEventType,
15
+ SubscriptionEventTypes,
16
+ type SubscriptionStatus,
17
+ SubscriptionStatuses,
18
+ } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
19
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
20
+ import { parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
21
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
22
+ import * as z from "zod";
23
+ import { DODO_PROVIDER_NAME } from "./constants.ts";
24
+ import type { DodoWebhookRuntime } from "./runtime.ts";
25
+
26
+ export const WEBHOOK_TOLERANCE_SECONDS = 300;
27
+
28
+ const SECRET_PREFIX = "whsec_";
29
+ const SIGNATURE_VERSION = "v1";
30
+ const TIMESTAMP_PATTERN = /^\d+$/;
31
+
32
+ export type StandardWebhookHeaders = Readonly<Record<string, string>>;
33
+
34
+ function signatureFor(secretKey: Buffer, signedContent: string): Buffer {
35
+ return createHmac("sha256", secretKey).update(signedContent).digest();
36
+ }
37
+
38
+ function matchesAnyV1Signature(header: string, expected: Buffer): boolean {
39
+ return header.split(" ").some((entry) => {
40
+ const [version, encoded] = entry.split(",");
41
+ if (version !== SIGNATURE_VERSION || !encoded) return false;
42
+ const candidate = Buffer.from(encoded, "base64");
43
+ return candidate.length === expected.length && timingSafeEqual(candidate, expected);
44
+ });
45
+ }
46
+
47
+ /** Throws unless the Standard Webhooks signature over `rawBody` is valid and
48
+ * the timestamp is within the tolerance in both directions. */
49
+ export function verifyStandardWebhook(
50
+ rawBody: string,
51
+ headers: StandardWebhookHeaders,
52
+ secret: string,
53
+ nowSeconds: number,
54
+ ): void {
55
+ const id = headers["webhook-id"];
56
+ const timestamp = headers["webhook-timestamp"];
57
+ const signatureHeader = headers["webhook-signature"];
58
+ if (!id || !timestamp || !signatureHeader) {
59
+ throw new Error(
60
+ "subscription-dodo: webhook-id, webhook-timestamp or webhook-signature missing",
61
+ );
62
+ }
63
+ if (!TIMESTAMP_PATTERN.test(timestamp)) {
64
+ throw new Error("subscription-dodo: webhook-timestamp is not a Unix timestamp in seconds");
65
+ }
66
+ if (Math.abs(nowSeconds - Number(timestamp)) > WEBHOOK_TOLERANCE_SECONDS) {
67
+ throw new Error("subscription-dodo: webhook timestamp outside the tolerance window");
68
+ }
69
+ const secretKey = Buffer.from(
70
+ secret.startsWith(SECRET_PREFIX) ? secret.slice(SECRET_PREFIX.length) : secret,
71
+ "base64",
72
+ );
73
+ const expected = signatureFor(secretKey, `${id}.${timestamp}.${rawBody}`);
74
+ if (!matchesAnyV1Signature(signatureHeader, expected)) {
75
+ throw new Error("subscription-dodo: webhook signature verify failed");
76
+ }
77
+ }
78
+
79
+ // =============================================================================
80
+ // Event mapping
81
+ // =============================================================================
82
+
83
+ const DODO_SUBSCRIPTION_EVENT_TYPES: Readonly<Record<string, SubscriptionEventType>> = {
84
+ "subscription.active": SubscriptionEventTypes.created,
85
+ "subscription.updated": SubscriptionEventTypes.updated,
86
+ "subscription.plan_changed": SubscriptionEventTypes.updated,
87
+ "subscription.paused": SubscriptionEventTypes.updated,
88
+ "subscription.unpaused": SubscriptionEventTypes.updated,
89
+ "subscription.update_payment_method": SubscriptionEventTypes.updated,
90
+ "subscription.renewed": SubscriptionEventTypes.invoicePaid,
91
+ "subscription.past_due": SubscriptionEventTypes.invoicePaymentFailed,
92
+ "subscription.on_hold": SubscriptionEventTypes.invoicePaymentFailed,
93
+ "subscription.cancelled": SubscriptionEventTypes.canceled,
94
+ "subscription.expired": SubscriptionEventTypes.canceled,
95
+ "subscription.failed": SubscriptionEventTypes.canceled,
96
+ };
97
+
98
+ const DODO_SUBSCRIPTION_STATUSES: Readonly<Record<string, SubscriptionStatus>> = {
99
+ pending: SubscriptionStatuses.incomplete,
100
+ active: SubscriptionStatuses.active,
101
+ on_hold: SubscriptionStatuses.pastDue,
102
+ past_due: SubscriptionStatuses.pastDue,
103
+ paused: SubscriptionStatuses.pastDue,
104
+ cancelled: SubscriptionStatuses.canceled,
105
+ failed: SubscriptionStatuses.canceled,
106
+ expired: SubscriptionStatuses.canceled,
107
+ };
108
+
109
+ const PAYMENT_SUCCEEDED = "payment.succeeded";
110
+
111
+ const metadataSchema = z.record(z.string(), z.unknown()).nullish();
112
+ const customerSchema = z.object({ customer_id: z.string().min(1) });
113
+
114
+ const envelopeSchema = z.object({
115
+ type: z.string(),
116
+ timestamp: z.iso.datetime({ offset: true }),
117
+ data: z.unknown(),
118
+ });
119
+
120
+ export const dodoSubscriptionSchema = z.object({
121
+ subscription_id: z.string().min(1),
122
+ product_id: z.string().min(1),
123
+ status: z.string(),
124
+ customer: customerSchema,
125
+ metadata: metadataSchema,
126
+ next_billing_date: z.iso.datetime({ offset: true }),
127
+ cancel_at_next_billing_date: z.boolean().nullish(),
128
+ });
129
+ export type DodoSubscription = z.infer<typeof dodoSubscriptionSchema>;
130
+
131
+ const dodoPaymentSchema = z.object({
132
+ subscription_id: z.string().nullish(),
133
+ checkout_session_id: z.string().nullish(),
134
+ customer: customerSchema,
135
+ metadata: metadataSchema,
136
+ product_cart: z.array(z.object({ product_id: z.string().min(1) })).nullish(),
137
+ });
138
+
139
+ function metadataString(metadata: DodoSubscription["metadata"], key: string): string | undefined {
140
+ const value = metadata?.[key];
141
+ return typeof value === "string" && value.length > 0 ? value : undefined;
142
+ }
143
+
144
+ export type MappedDodoSubscriptionState = {
145
+ readonly status: SubscriptionStatus;
146
+ readonly tier: string;
147
+ readonly currentPeriodEnd: string;
148
+ readonly cancelAt: string | null;
149
+ };
150
+
151
+ /** Shared by the webhook and retrieveSubscription so both map Dodo state
152
+ * identically. Null when the status is unknown or the product is not in
153
+ * `priceToTier` (SubscriptionEvent.tier is required). */
154
+ export function mapDodoSubscriptionState(
155
+ sub: DodoSubscription,
156
+ priceToTier: Readonly<Record<string, string>>,
157
+ ): MappedDodoSubscriptionState | null {
158
+ const status = DODO_SUBSCRIPTION_STATUSES[sub.status];
159
+ const tier = priceToTier[sub.product_id];
160
+ if (!status || !tier) return null;
161
+ return {
162
+ status,
163
+ tier,
164
+ currentPeriodEnd: sub.next_billing_date,
165
+ cancelAt: sub.cancel_at_next_billing_date === true ? sub.next_billing_date : null,
166
+ };
167
+ }
168
+
169
+ export type DodoWebhookOptions = {
170
+ readonly priceToTier: Readonly<Record<string, string>>;
171
+ /** Test seam: Unix seconds. Defaults to the wall clock. */
172
+ readonly nowSeconds?: () => number;
173
+ };
174
+
175
+ function currentUnixSeconds(): number {
176
+ return Math.floor(Temporal.Now.instant().epochMilliseconds / 1000);
177
+ }
178
+
179
+ function parsePaymentEvent(providerEventId: string, data: unknown): PaymentEvent | null {
180
+ const parsed = dodoPaymentSchema.safeParse(data);
181
+ if (!parsed.success) return null;
182
+ const payment = parsed.data;
183
+ // A subscription's own payments are covered by its subscription.* events.
184
+ if (payment.subscription_id) return null;
185
+ const tenantId = metadataString(payment.metadata, "tenantId");
186
+ const priceId = payment.product_cart?.[0]?.product_id;
187
+ if (!tenantId || !priceId) return null;
188
+ const consentId = parseProviderConsentId(payment.metadata?.["consentId"]);
189
+ return {
190
+ kind: BillingEventKinds.payment,
191
+ providerEventId,
192
+ providerName: DODO_PROVIDER_NAME,
193
+ tenantId,
194
+ ...(consentId !== undefined && { consentId }),
195
+ ...(payment.checkout_session_id && { providerCheckoutId: payment.checkout_session_id }),
196
+ providerCustomerId: payment.customer.customer_id,
197
+ priceId,
198
+ };
199
+ }
200
+
201
+ function parseSubscriptionEvent(
202
+ providerEventId: string,
203
+ type: SubscriptionEventType,
204
+ providerEventAt: string,
205
+ data: unknown,
206
+ priceToTier: Readonly<Record<string, string>>,
207
+ ): SubscriptionEvent | null {
208
+ const parsed = dodoSubscriptionSchema.safeParse(data);
209
+ if (!parsed.success) return null;
210
+ const sub = parsed.data;
211
+ const tenantId = metadataString(sub.metadata, "tenantId");
212
+ const state = mapDodoSubscriptionState(sub, priceToTier);
213
+ if (!tenantId || !state) return null;
214
+ const consentId = parseProviderConsentId(sub.metadata?.["consentId"]);
215
+ return {
216
+ providerEventId,
217
+ providerName: DODO_PROVIDER_NAME,
218
+ type,
219
+ tenantId,
220
+ ...(consentId !== undefined && { consentId }),
221
+ providerCustomerId: sub.customer.customer_id,
222
+ providerSubscriptionId: sub.subscription_id,
223
+ providerEventAt,
224
+ ...state,
225
+ };
226
+ }
227
+
228
+ export function verifyAndParseDodoWebhook(
229
+ runtime: DodoWebhookRuntime,
230
+ options: DodoWebhookOptions,
231
+ ): (
232
+ rawBody: string,
233
+ headers: Record<string, string>,
234
+ systemSecrets?: SecretsContext,
235
+ ) => Promise<SubscriptionEvent | PaymentEvent | null> {
236
+ const nowSeconds = options.nowSeconds ?? currentUnixSeconds;
237
+ return async (rawBody, headers, systemSecrets) => {
238
+ const secret = await runtime.resolveWebhookSecret(systemSecrets);
239
+ verifyStandardWebhook(rawBody, headers, secret, nowSeconds());
240
+
241
+ // Past verification the body is trusted to be Dodo's; a malformed shape is
242
+ // schema drift, so it is dropped (200 ignored) instead of answered with a 401.
243
+ const envelope = envelopeSchema.safeParse(
244
+ parseJsonOrThrow<unknown>(rawBody, "subscription-dodo webhook body"),
245
+ );
246
+ // verifyStandardWebhook has checked webhook-id is present.
247
+ const providerEventId = headers["webhook-id"];
248
+ if (!envelope.success || !providerEventId) return null;
249
+
250
+ const { type, timestamp, data } = envelope.data;
251
+ if (type === PAYMENT_SUCCEEDED) return parsePaymentEvent(providerEventId, data);
252
+ const normalizedType = DODO_SUBSCRIPTION_EVENT_TYPES[type];
253
+ if (!normalizedType) return null;
254
+ return parseSubscriptionEvent(
255
+ providerEventId,
256
+ normalizedType,
257
+ timestamp,
258
+ data,
259
+ options.priceToTier,
260
+ );
261
+ };
262
+ }
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "improvement",
5
+ "title": "Stripe plugin has a displayName",
6
+ "detail": "The Stripe plugin sets displayName \"Stripe\", which the provider choice in the billing panel shows."
7
+ },
8
+ {
9
+ "version": "0.388.0",
10
+ "type": "fix",
11
+ "title": "A malformed stored credential no longer leaks into the error",
12
+ "detail": "A stored api-key or webhook-secret that is not valid JSON used to raise the JSON parser's own message, which repeats the value into logs and the webhook 401 body. The error now has a fixed text and names no input. subscription-dodo behaves the same."
13
+ },
2
14
  {
3
15
  "version": "0.370.0",
4
16
  "type": "improvement",
@@ -250,6 +250,7 @@ export function createSubscriptionStripeFeature(
250
250
  const portalConfigurationCache = new Map<string, string>();
251
251
 
252
252
  const plugin: SubscriptionProviderPlugin = {
253
+ displayName: "Stripe",
253
254
  verifyAndParseWebhook: verifyAndParseStripeWebhook(runtimes.webhook, {
254
255
  priceToTier: options.priceToTier ?? {},
255
256
  }),
@@ -33,7 +33,7 @@ import {
33
33
  } from "@cosmicdrift/kumiko-framework/engine";
34
34
  import { FeatureDisabledError, UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
35
35
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
36
- import { parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
36
+ import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
37
37
  import Stripe from "stripe";
38
38
  import { SUBSCRIPTION_STRIPE_FEATURE } from "./constants.ts";
39
39
 
@@ -68,13 +68,14 @@ export type StripeRuntimeDeps = {
68
68
  // (the un-audited webhook path + the audited ctx read here) therefore returns
69
69
  // the JSON-quoted string and must parse it back. The factory fallback is a
70
70
  // plain env string and stays raw — so parsing is applied only to the store
71
- // read, never to the fallback.
71
+ // read, never to the fallback. The error must not carry the input: the JSON
72
+ // parser's own message would repeat the credential into logs and the 401 body.
72
73
  function parseStoredSecret(raw: string | undefined): string | undefined {
73
74
  if (raw === undefined || raw.length === 0) return undefined;
74
- // parseJsonOrThrow is the sanctioned safe-parse (same helper the config
75
- // resolver's deserializeValue uses for this very value) — mirrors how the
76
- // store round-trips a backing:"secrets" text value.
77
- const parsed = parseJsonOrThrow<unknown>(raw, "subscription-stripe credential (backing:secrets)");
75
+ const parsed = parseJsonSafe<unknown>(raw, undefined);
76
+ if (parsed === undefined) {
77
+ throw new Error("subscription-stripe credential (backing:secrets) is not valid JSON");
78
+ }
78
79
  return typeof parsed === "string" && parsed.length > 0 ? parsed : undefined;
79
80
  }
80
81
 
@@ -158,12 +158,14 @@ export function createTemplateResolverApi(
158
158
  };
159
159
  }
160
160
 
161
+ type DeliveredRowKey = Pick<TemplateResourceRow, "tenantId" | "kind" | "slug">;
162
+
161
163
  // The one delivery rule for a stored row: unlocked, and a collection entry never shadows a system
162
164
  // template of the same slug that the app did not unlock.
163
165
  export async function isRowDelivered(
164
166
  db: DbRunner | TenantDb,
165
167
  policy: TemplateDeliveryPolicy,
166
- row: TemplateResourceRow,
168
+ row: DeliveredRowKey,
167
169
  ): Promise<boolean> {
168
170
  if (!isRowServed(policy, row)) return false;
169
171
  if (row.tenantId === SYSTEM_TENANT_ID || !isServedByCollectionOnly(policy, row)) return true;
@@ -174,19 +176,44 @@ export async function isRowDeliveredTo(
174
176
  db: DbRunner | TenantDb,
175
177
  policy: TemplateDeliveryPolicy,
176
178
  user: { readonly roles: readonly string[] },
177
- row: TemplateResourceRow,
179
+ row: DeliveredRowKey,
178
180
  ): Promise<boolean> {
179
181
  return isSystemAdmin(user) || (await isRowDelivered(db, policy, row));
180
182
  }
181
183
 
184
+ // One system-slug lookup per kind instead of one query per row, because by-tenant is anonymous.
182
185
  export async function filterDeliveredTo(
183
186
  db: DbRunner | TenantDb,
184
187
  policy: TemplateDeliveryPolicy,
185
188
  user: { readonly roles: readonly string[] },
186
189
  rows: readonly TemplateResourceRow[],
187
190
  ): Promise<TemplateResourceRow[]> {
188
- const flags = await Promise.all(rows.map((row) => isRowDeliveredTo(db, policy, user, row)));
189
- return rows.filter((_, index) => flags[index]);
191
+ if (isSystemAdmin(user)) return [...rows];
192
+ const served = rows.filter((row) => isRowServed(policy, row));
193
+ const shadowable = served.filter(
194
+ (row) => row.tenantId !== SYSTEM_TENANT_ID && isServedByCollectionOnly(policy, row),
195
+ );
196
+ const slugsByKind = new Map<string, Set<string>>();
197
+ for (const row of shadowable) {
198
+ const slugs = slugsByKind.get(row.kind) ?? new Set<string>();
199
+ slugs.add(row.slug);
200
+ slugsByKind.set(row.kind, slugs);
201
+ }
202
+ const systemKeys = new Set<string>();
203
+ for (const [kind, slugs] of slugsByKind) {
204
+ const systemRows = await selectMany<TemplateResourceRow>(db, templateResourcesTable, {
205
+ tenantId: SYSTEM_TENANT_ID,
206
+ kind,
207
+ slug: [...slugs],
208
+ });
209
+ for (const systemRow of systemRows) systemKeys.add(`${kind}|${systemRow.slug}`);
210
+ }
211
+ return served.filter(
212
+ (row) =>
213
+ row.tenantId === SYSTEM_TENANT_ID ||
214
+ !isServedByCollectionOnly(policy, row) ||
215
+ !systemKeys.has(`${row.kind}|${row.slug}`),
216
+ );
190
217
  }
191
218
 
192
219
  export async function systemTemplateExists(
@@ -1,4 +1,52 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "Template overrides, key collisions and by-tenant queries",
6
+ "detail": "link-pdf refuses a key that already holds an inline image (key_in_use). by-tenant no longer runs a query per row, tenant-override-counts selects only the needed columns, and tenant-overrides resolves names beyond the first 200 tenants or users through the new ids filter of the tenant and member directories. Notification preference and form-draft cleanup queries bind array parameters as Postgres array literals."
7
+ },
8
+ {
9
+ "version": "0.388.0",
10
+ "type": "fix",
11
+ "title": "Re-seeding keeps archived system templates, drops stale drafts and adds only missing bundle locales",
12
+ "detail": "A boot-time re-seed no longer reactivates an archived system template and clears an open system draft when the seeded content changes. A bundle with resources seeded with ifExists skip imports only the missing locales instead of overwriting existing system rows. copy-locale fails with version_conflict when its source changed between the upload and the transaction, and releases the uploaded files."
13
+ },
14
+ {
15
+ "version": "0.388.0",
16
+ "type": "fix",
17
+ "title": "isCollectionRefusedKind is a type predicate",
18
+ "detail": "isCollectionRefusedKind accepts unknown and narrows to the refused kinds; tier-engine's isTierAssignmentSource shares the isOneOf implementation. Behavior is unchanged."
19
+ },
20
+ {
21
+ "version": "0.388.0",
22
+ "type": "fix",
23
+ "title": "Layout usage list matches the overview",
24
+ "detail": "The usage query of a mail layout filters the mail rows by the caller's visibility like the overview and logs a warning when the 5000-row limit cuts the result."
25
+ },
26
+ {
27
+ "version": "0.388.0",
28
+ "type": "fix",
29
+ "title": "Admin views follow the delivery rule of rendering",
30
+ "detail": "List, find-by-id, diff-to-system, validate, preview and the document base hide a collection row that a locked system template of the same slug shadows, as mail, PDF and the resolver already do."
31
+ },
32
+ {
33
+ "version": "0.388.0",
34
+ "type": "fix",
35
+ "title": "Seeded title key must look like a namespace:key",
36
+ "detail": "seedSystemTemplate rejects a titleI18nKey without a namespace prefix or with whitespace, so free text cannot pass as a key and show up raw in the UI."
37
+ },
38
+ {
39
+ "version": "0.388.0",
40
+ "type": "fix",
41
+ "title": "Failing release keeps the upload error",
42
+ "detail": "When releasing already uploaded files fails after a refused upload in copy-locale or bundle import, the release error is logged and the original error is returned, and the releases run once."
43
+ },
44
+ {
45
+ "version": "0.388.0",
46
+ "type": "fix",
47
+ "title": "Mail layout enums and hex color pattern are exported client-side",
48
+ "detail": "The layout editor imports the width, font, accent line and button style lists plus the hex pattern from the template-resolver web barrel instead of keeping copies, so editor and server validation cannot drift."
49
+ },
2
50
  {
3
51
  "version": "0.384.0",
4
52
  "type": "improvement",
@@ -432,6 +432,7 @@ export function compileDocumentBodyToHandlebars(
432
432
  for (const element of Array.from(root.querySelectorAll("div[data-images]"))) {
433
433
  rewriteImagesBlock(element, state, options);
434
434
  }
435
+ // Order matters: the conditional wraps first so the row's #each lands inside the #if (evaluated once per list).
435
436
  for (const block of Array.from(root.querySelectorAll(`[${DOCUMENT_CONDITION_ATTRIBUTE}]`))) {
436
437
  rewriteConditionalBlock(block, state);
437
438
  }
@@ -41,7 +41,7 @@ import {
41
41
  } from "./handlers/toggle-status.write.ts";
42
42
  import { upsertSystemWrite } from "./handlers/upsert-system.write.ts";
43
43
  import { makeUpsertTenantWrite } from "./handlers/upsert-tenant.write.ts";
44
- import { usageQuery } from "./handlers/usage.query.ts";
44
+ import { makeUsageQuery } from "./handlers/usage.query.ts";
45
45
  import { validateQuery } from "./handlers/validate.query.ts";
46
46
  import { TEMPLATE_RESOLVER_I18N } from "./i18n.ts";
47
47
  import { templateResourceEntity } from "./table.ts";
@@ -181,7 +181,7 @@ export function createTemplateResolverFeature(opts: TemplateResolverOptions = {}
181
181
  list: r.queryHandler(listQuery),
182
182
  bySlug: r.queryHandler(bySlugQuery),
183
183
  byTenant: r.queryHandler(byTenantQuery),
184
- usage: r.queryHandler(usageQuery),
184
+ usage: r.queryHandler(makeUsageQuery(policy)),
185
185
  overview: r.queryHandler(makeOverviewQuery(policy)),
186
186
  layoutSource: r.queryHandler(layoutSourceQuery),
187
187
  tenantOverrides: r.queryHandler(tenantOverridesQuery),
@@ -23,6 +23,7 @@ import {
23
23
  prepareTemplateResourceBeforeTransaction,
24
24
  type Release,
25
25
  readTemplateResourceMaxBytes,
26
+ releaseAfterFailure,
26
27
  releaseAll,
27
28
  } from "../store-template-resource.ts";
28
29
  import { type TemplateResourceRow, templateResourcesTable } from "../table.ts";
@@ -152,14 +153,14 @@ export async function prepareTemplateBundleBeforeTransaction(
152
153
  })
153
154
  : await prepareTemplateResourceBeforeTransaction(common);
154
155
  if (!result.isSuccess) {
155
- await releaseAll(releases);
156
+ await releaseAfterFailure(args.ctx, releases);
156
157
  return result;
157
158
  }
158
159
  if (result.release) releases.push(result.release);
159
160
  prepared.set(key, result.data);
160
161
  }
161
162
  } catch (cause) {
162
- await releaseAll(releases);
163
+ await releaseAfterFailure(args.ctx, releases);
163
164
  throw cause;
164
165
  }
165
166
  return { isSuccess: true, data: prepared, release: () => releaseAll(releases) };
@@ -11,6 +11,7 @@ import {
11
11
  AccessDeniedError,
12
12
  NotFoundError,
13
13
  ValidationError,
14
+ VersionConflictError,
14
15
  writeFailure,
15
16
  } from "@cosmicdrift/kumiko-framework/errors";
16
17
  import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
@@ -172,13 +173,17 @@ export function makeCopyLocaleWrite(policy: TemplateEditPolicy) {
172
173
  beforeTransaction: async (event, ctx) => {
173
174
  const request = await checkCopyRequest(ctx, policy, event);
174
175
  if (!request.isSuccess) return request;
175
- if (!request.data.sourceIsForeign) return { isSuccess: true, data: [] };
176
- return prepareLinkedResourceCopiesBeforeTransaction({
176
+ const { source, tenantId, sourceIsForeign, sourceResources } = request.data;
177
+ const sourceStamp = { sourceId: source.id, sourceVersion: source.version };
178
+ if (!sourceIsForeign) return { isSuccess: true, data: { ...sourceStamp, copies: [] } };
179
+ const prepared = await prepareLinkedResourceCopiesBeforeTransaction({
177
180
  ctx,
178
181
  user: event.user,
179
- tenantId: request.data.tenantId,
180
- resources: request.data.sourceResources,
182
+ tenantId,
183
+ resources: sourceResources,
181
184
  });
185
+ if (!prepared.isSuccess) return prepared;
186
+ return { ...prepared, data: { ...sourceStamp, copies: prepared.data } };
182
187
  },
183
188
  handler: async (event, ctx) => {
184
189
  const { slug, kind, toLocale } = event.payload;
@@ -186,6 +191,20 @@ export function makeCopyLocaleWrite(policy: TemplateEditPolicy) {
186
191
  if (!request.isSuccess) return request;
187
192
  const { scope, tenantId, source, contentFormat, sourceResources, sourceIsForeign } =
188
193
  request.data;
194
+ // The uploads were prepared for the source as it was before the transaction; a source that
195
+ // changed since (own row created or deleted, edited) no longer matches them.
196
+ if (
197
+ event.prepared.sourceId !== source.id ||
198
+ event.prepared.sourceVersion !== source.version
199
+ ) {
200
+ return writeFailure(
201
+ new VersionConflictError({
202
+ entityId: source.id,
203
+ expectedVersion: event.prepared.sourceVersion,
204
+ currentVersion: source.version,
205
+ }),
206
+ );
207
+ }
189
208
 
190
209
  const variableSchema = storedRecord(source.variableSchema);
191
210
  const written = await writeTemplateRow({
@@ -209,7 +228,10 @@ export function makeCopyLocaleWrite(policy: TemplateEditPolicy) {
209
228
  });
210
229
  if (!written.isSuccess) return written;
211
230
  const resources = sourceIsForeign
212
- ? await createLinkedResourceCopies({ ctx, user: event.user, tenantId }, event.prepared)
231
+ ? await createLinkedResourceCopies(
232
+ { ctx, user: event.user, tenantId },
233
+ event.prepared.copies,
234
+ )
213
235
  : { isSuccess: true as const, data: sourceResources };
214
236
  if (!resources.isSuccess) return resources;
215
237
  const attached = await attachTemplateResources(ctx, {
@@ -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.ts";
5
6
  import { type TemplateResourceRow, templateResourcesTable } from "../table.ts";
6
- import { deliveryPolicyOf, isRowServedTo } from "../template-delivery-policy.ts";
7
+ import { deliveryPolicyOf } from "../template-delivery-policy.ts";
7
8
  import { isHiddenSystemDraft } from "../template-draft.ts";
8
9
  import { buildOverrideDiff } from "./override-diff.ts";
9
10
 
@@ -21,7 +22,7 @@ export const diffToSystemQuery = defineQueryHandler({
21
22
  !tenantRow ||
22
23
  tenantRow.tenantId !== query.user.tenantId ||
23
24
  tenantRow.scope !== "tenant" ||
24
- !isRowServedTo(deliveryPolicyOf(ctx.registry), query.user, tenantRow)
25
+ !(await isRowDeliveredTo(ctx.db, deliveryPolicyOf(ctx.registry), query.user, tenantRow))
25
26
  ) {
26
27
  throw new NotFoundError("template-resource", query.payload.id);
27
28
  }
@@ -5,8 +5,9 @@ import {
5
5
  SYSTEM_TENANT_ID,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
8
+ import { isRowDeliveredTo } from "../api.ts";
8
9
  import { type TemplateResourceRow, templateResourcesTable } from "../table.ts";
9
- import { deliveryPolicyOf, isRowServedTo } from "../template-delivery-policy.ts";
10
+ import { deliveryPolicyOf } from "../template-delivery-policy.ts";
10
11
  import { isHiddenSystemDraft, toWorkingRow } from "../template-draft.ts";
11
12
  import { isSystemOnlyTemplateHiddenFrom } from "../template-system-only.ts";
12
13
  import type { DocumentHtmlKind } from "../web/constants.ts";
@@ -50,7 +51,7 @@ export async function loadDocumentBase(
50
51
  const ownIsVisible =
51
52
  own !== undefined &&
52
53
  !isSystemOnlyTemplateHiddenFrom(user, ctx.registry, own) &&
53
- isRowServedTo(deliveryPolicyOf(ctx.registry), user, own);
54
+ (await isRowDeliveredTo(ctx.db, deliveryPolicyOf(ctx.registry), user, own));
54
55
  if (own !== undefined && ownIsVisible) {
55
56
  const working = toWorkingRow(own, user.tenantId);
56
57
  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.ts";
4
5
  import { type TemplateResourceRow, templateResourcesTable } from "../table.ts";
5
- import { deliveryPolicyOf, isRowServedTo } from "../template-delivery-policy.ts";
6
+ import { deliveryPolicyOf } from "../template-delivery-policy.ts";
6
7
  import { draftViewerTenantId, isHiddenSystemDraft, toWorkingRow } from "../template-draft.ts";
7
8
  import { isSystemOnlyTemplateHiddenFrom } from "../template-system-only.ts";
8
9
 
@@ -25,7 +26,7 @@ export const findByIdQuery = defineQueryHandler({
25
26
  !stored ||
26
27
  isHiddenSystemDraft(query.user, stored) ||
27
28
  isSystemOnlyTemplateHiddenFrom(query.user, ctx.registry, stored) ||
28
- !isRowServedTo(deliveryPolicyOf(ctx.registry), query.user, stored)
29
+ !(await isRowDeliveredTo(ctx.db, deliveryPolicyOf(ctx.registry), query.user, stored))
29
30
  ) {
30
31
  return null;
31
32
  }