@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
@@ -3,6 +3,7 @@
3
3
  // category in the kumiko isolation model (runtime may import client, client
4
4
  // may only import client) — otherwise the runtime isolation guard throws on
5
5
  // the client imports. Same pattern as text-content/constants.
6
+ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
6
7
  // Feature name
7
8
  export const TIER_ENGINE_FEATURE = "tier-engine";
8
9
  // Qualified write handler names (QN format: scope:type:name).
@@ -18,9 +19,8 @@ export const TierAssignmentSources = {
18
19
  billing: "billing",
19
20
  default: "default",
20
21
  };
21
- const TIER_ASSIGNMENT_SOURCE_VALUES = Object.values(TierAssignmentSources);
22
22
  export function isTierAssignmentSource(value) {
23
- return typeof value === "string" && TIER_ASSIGNMENT_SOURCE_VALUES.includes(value);
23
+ return isOneOf(Object.values(TierAssignmentSources), value);
24
24
  }
25
25
  // Qualified query handler names.
26
26
  export const TierEngineQueries = {
@@ -19,12 +19,11 @@
19
19
  // expiresAt + exportStorageCleanupGraceHours, so expired ZIPs do not stay in
20
20
  // storage indefinitely.
21
21
  import { addDurationSpec } from "@cosmicdrift/kumiko-framework/compliance";
22
- import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-framework/crypto";
23
22
  import { createEventStoreExecutor, createTenantDb, fetchOne, selectMany, } from "@cosmicdrift/kumiko-framework/db";
24
23
  import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
25
24
  import { createZipStream, tenantExportPrefix, } from "@cosmicdrift/kumiko-framework/files";
26
25
  import { resolveProfileForTenant } from "../compliance-profiles/index.js";
27
- import { decryptStoredPii } from "../shared/index.js";
26
+ import { decryptStoredUserEmail } from "../shared/index.js";
28
27
  import { userTable } from "../user/index.js";
29
28
  import { selectExportJobsForStorageCleanup } from "./db/queries/export-jobs.js";
30
29
  import { runUserExport } from "./run-user-export.js";
@@ -539,8 +538,8 @@ async function lookupUserContact(db, userId) {
539
538
  const row = (await fetchOne(db, userTable, { id: userId }));
540
539
  if (!row?.email)
541
540
  return null;
542
- const email = await decryptStoredPii(row.email, "email", "user-data-rights:export-ready");
543
- if (email === PII_ERASED_SENTINEL)
541
+ const email = await decryptStoredUserEmail(row.email, "user-data-rights:export-ready");
542
+ if (email === null)
544
543
  return null;
545
544
  return { email, locale: row.locale ?? null };
546
545
  }
@@ -274,7 +274,8 @@ async function processUser(args) {
274
274
  // is what makes this crash-safe: eraseKey-throw → tx rollback → user
275
275
  // stays DeletionRequested → next run retries; flip-throw after a
276
276
  // successful erase → retry re-runs the idempotent hooks and eraseKey
277
- // (contractually a no-op on an erased/unknown subject). Erasing after
277
+ // (idempotent: a repeat erase keeps the original tombstone, an unknown
278
+ // subject gets one). Erasing after
278
279
  // the flip instead would risk a permanent silent miss: user already
279
280
  // Deleted, no run ever picks him up again, key stays live.
280
281
  if (kms) {
@@ -54,8 +54,9 @@ export const purgeHandler = defineWriteHandler({
54
54
  details: { confirmCount: event.payload.confirmCount, actualCount: rows.length },
55
55
  }));
56
56
  }
57
- await purgeWaitlistRows(db, rows, event.user.id);
57
+ // Audit first: purgeWaitlistRows shreds KMS keys, which a later failure cannot roll back.
58
58
  await appendAudit(ctx, WAITLIST_PURGED_EVENT_QN, rows.length);
59
+ await purgeWaitlistRows(db, rows, event.user.id);
59
60
  return { isSuccess: true, data: { purged: rows.length } };
60
61
  },
61
62
  });
@@ -40,9 +40,10 @@ async function hasOpenEntryForEmail(email, ctx) {
40
40
  }, platformActor(), waitlistDb(ctx));
41
41
  return found.rows.length > 0;
42
42
  }
43
- // The resolver usually reads config; run it outside the handler transaction's
44
- // critical section so a failing statement cannot abort the transaction that holds
45
- // the entry, and a slow resolver does not extend the per-email advisory lock.
43
+ // The resolver usually reads config. It runs before the per-email advisory lock so a
44
+ // slow resolver does not extend the lock. It still shares the handler transaction: a
45
+ // thrown JS error is swallowed here, but a failed SQL statement inside it would abort
46
+ // the transaction, so resolvers must not issue statements that can fail.
46
47
  async function resolveAdminRecipient(opts, ctx) {
47
48
  try {
48
49
  return (await opts.notifyRecipient?.(ctx)) || null;
@@ -124,8 +125,7 @@ export function createSubmitHandler(opts) {
124
125
  message: payload.message,
125
126
  }),
126
127
  priority: "normal",
127
- // Operator-facing: the request locale, not the one the entrant picked in the form.
128
- locale: resolveWaitlistMailLocale(ctx.locale),
128
+ locale: resolveWaitlistMailLocale(opts.adminLocale),
129
129
  });
130
130
  });
131
131
  return SUBMITTED;
@@ -19,6 +19,7 @@ export type WaitlistImportEntry = {
19
19
  };
20
20
  export type WaitlistImportOutcome = "created" | "skipped";
21
21
  export declare function waitlistImportId(sourceKey: string): string;
22
+ export declare function isWaitlistImportId(id: string): boolean;
22
23
  export declare function importWaitlistEntry(db: TenantDb, entry: WaitlistImportEntry, options?: {
23
24
  readonly dryRun?: boolean;
24
25
  }): Promise<WaitlistImportOutcome>;
@@ -13,6 +13,10 @@ export function waitlistImportId(sourceKey) {
13
13
  const hex = bytes.subarray(0, 16).toString("hex");
14
14
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
15
15
  }
16
+ // The version nibble marks ids minted by waitlistImportId; legacy row ids are random UUIDs.
17
+ export function isWaitlistImportId(id) {
18
+ return id[14] === "5";
19
+ }
16
20
  function presentOnly(fields) {
17
21
  return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
18
22
  }
@@ -1,10 +1,11 @@
1
1
  import { configuredPiiSubjectKms, decryptPiiFieldValues, isPiiCiphertext, PII_ERASED_SENTINEL, } from "@cosmicdrift/kumiko-framework/crypto";
2
- import { countWhere, createTenantDb, selectMany, table, text as textColumn, timestamp, transaction, uuid, } from "@cosmicdrift/kumiko-framework/db";
2
+ import { countWhere, createTenantDb, selectMany, table, tableExists, text as textColumn, timestamp, transaction, uuid, } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { archiveStream, createArchivedStreamsTable, eventsTable, isStreamArchived, } from "@cosmicdrift/kumiko-framework/event-store";
5
5
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
6
- import { importWaitlistEntry, waitlistImportId } from "./import.js";
6
+ import { importWaitlistEntry, isWaitlistImportId, waitlistImportId, } from "./import.js";
7
7
  import { WAITLIST_STATUS } from "./web/constants.js";
8
+ const ARCHIVED_STREAMS_TABLE = "public.kumiko_archived_streams";
8
9
  export const LEGACY_WAITLIST_SOURCES = ["offlot", "publicstatus"];
9
10
  export function isLegacyWaitlistSource(value) {
10
11
  return LEGACY_WAITLIST_SOURCES.some((known) => known === value);
@@ -152,10 +153,12 @@ async function readLegacyRows(db, from, sourceTable) {
152
153
  // new id, so a rebuild would replay the old stream next to it and bring every migrated entry back
153
154
  // twice; archived streams are excluded from replay. The offlot default source table is the entity
154
155
  // table itself, so its row ids are live entries by design and no id check can tell a wrong table.
155
- async function archiveLegacyStream(db, legacyId, importedId) {
156
+ async function archiveLegacyStream(db, legacyId, importedId, dryRun) {
156
157
  if (legacyId === importedId)
157
158
  return 0;
158
- if (await isStreamArchived(db, SYSTEM_TENANT_ID, legacyId))
159
+ // A dry run never creates the archive table, so a missing one means nothing is archived yet.
160
+ const archiveTableReady = !dryRun || (await tableExists(db, ARCHIVED_STREAMS_TABLE));
161
+ if (archiveTableReady && (await isStreamArchived(db, SYSTEM_TENANT_ID, legacyId)))
159
162
  return 0;
160
163
  const legacyEvents = await countWhere(db, eventsTable, {
161
164
  tenantId: SYSTEM_TENANT_ID,
@@ -164,6 +167,8 @@ async function archiveLegacyStream(db, legacyId, importedId) {
164
167
  });
165
168
  if (legacyEvents === 0)
166
169
  return 0;
170
+ if (dryRun)
171
+ return 1;
167
172
  await archiveStream(db, {
168
173
  tenantId: SYSTEM_TENANT_ID,
169
174
  aggregateId: legacyId,
@@ -185,6 +190,12 @@ export async function migrateLegacyWaitlist(db, options) {
185
190
  if (!options.dryRun && options.from === "offlot")
186
191
  await createArchivedStreamsTable(db);
187
192
  for (const row of rows) {
193
+ // The offlot default source is the entity table: rows a previous run imported
194
+ // are already migrated entries and must neither be re-imported nor archived.
195
+ if (options.from === "offlot" && isWaitlistImportId(String(row["id"]))) {
196
+ skipped += 1;
197
+ continue;
198
+ }
188
199
  const entry = options.from === "offlot"
189
200
  ? await mapOfflotRow(row)
190
201
  : mapPublicstatusRow(row, options.defaultLocale);
@@ -200,8 +211,8 @@ export async function migrateLegacyWaitlist(db, options) {
200
211
  else
201
212
  skipped += 1;
202
213
  });
203
- if (!options.dryRun && options.from === "offlot") {
204
- archivedStreams += await archiveLegacyStream(db, String(row["id"]), waitlistImportId(entry.sourceKey));
214
+ if (options.from === "offlot") {
215
+ archivedStreams += await archiveLegacyStream(db, String(row["id"]), waitlistImportId(entry.sourceKey), options.dryRun);
205
216
  }
206
217
  }
207
218
  return { read: rows.length, created, skipped, unreadable, archivedStreams };
@@ -59,11 +59,14 @@ export const linkWaitlistOnSignup = async (event, tx) => {
59
59
  if (typeof user?.email !== "string")
60
60
  return;
61
61
  const email = normalizeEmail(await decryptStoredPii(user.email, "email", PII_DECRYPT_REASON));
62
- const found = await waitlistExecutor.list({
63
- filter: { field: "email", op: "eq", value: email },
64
- filters: [{ field: "status", op: "in", value: [WAITLIST_STATUS.Invited] }],
65
- }, platformActor(), db);
66
- const invited = [...found.rows].sort(newestSubmittedFirst)[0];
62
+ const findInvited = async () => {
63
+ const found = await waitlistExecutor.list({
64
+ filter: { field: "email", op: "eq", value: email },
65
+ filters: [{ field: "status", op: "in", value: [WAITLIST_STATUS.Invited] }],
66
+ }, platformActor(), db);
67
+ return [...found.rows].sort(newestSubmittedFirst)[0];
68
+ };
69
+ const invited = await findInvited();
67
70
  // skip: the signup email has no invited waitlist entry (open signup or stale invite).
68
71
  if (!invited)
69
72
  return;
@@ -71,7 +74,7 @@ export const linkWaitlistOnSignup = async (event, tx) => {
71
74
  // skip: defensive guard on the row shape.
72
75
  if (typeof id !== "string" || typeof version !== "number")
73
76
  return;
74
- await waitlistExecutor.update({
77
+ const updated = await waitlistExecutor.update({
75
78
  id,
76
79
  version,
77
80
  changes: {
@@ -80,4 +83,13 @@ export const linkWaitlistOnSignup = async (event, tx) => {
80
83
  provisionedAt: Temporal.Now.instant(),
81
84
  },
82
85
  }, platformActor(), db);
86
+ // skip: linked.
87
+ if (updated.isSuccess)
88
+ return;
89
+ // A concurrent admin change that already moved the entry out of `invited` makes the link
90
+ // moot; otherwise throw so the at-least-once consumer redelivers.
91
+ // skip: the entry left `invited` concurrently.
92
+ if ((await findInvited())?.id !== id)
93
+ return;
94
+ throw new Error(`waitlist:link-on-signup: could not link waitlist entry ${id}`);
83
95
  };
@@ -16,6 +16,9 @@ export type WaitlistOptions = {
16
16
  * string or an absent option sends none. Best effort, never fails the submit,
17
17
  * also when the resolver throws or rejects. */
18
18
  readonly notifyRecipient?: (ctx: HandlerContext) => string | null | Promise<string | null>;
19
+ /** Language of the admin notice mail (en, de or es; anything else falls back to en).
20
+ * Not taken from the request: a submit comes from the entrant, not the operator. */
21
+ readonly adminLocale?: string;
19
22
  /** Default `{ mode: "own-tenant", role: "TenantAdmin" }`. */
20
23
  readonly invite?: WaitlistInviteOptions;
21
24
  /** Default: 5 submits per IP per 10 minutes. Pass `{ disabled: true, reason }`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.387.0",
3
+ "version": "0.388.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -186,6 +186,10 @@
186
186
  "types": "./dist/subscription-stripe/index.d.ts",
187
187
  "default": "./dist/subscription-stripe/index.js"
188
188
  },
189
+ "./subscription-dodo": {
190
+ "types": "./dist/subscription-dodo/index.d.ts",
191
+ "default": "./dist/subscription-dodo/index.js"
192
+ },
189
193
  "./subscription-mollie": {
190
194
  "types": "./dist/subscription-mollie/index.d.ts",
191
195
  "default": "./dist/subscription-mollie/index.js"
@@ -544,9 +548,9 @@
544
548
  }
545
549
  },
546
550
  "dependencies": {
547
- "@cosmicdrift/kumiko-framework": "0.387.0",
548
- "@cosmicdrift/kumiko-renderer": "0.387.0",
549
- "@cosmicdrift/kumiko-types": "0.387.0",
551
+ "@cosmicdrift/kumiko-framework": "0.388.0",
552
+ "@cosmicdrift/kumiko-renderer": "0.388.0",
553
+ "@cosmicdrift/kumiko-types": "0.388.0",
550
554
  "@mollie/api-client": "^4.5.0",
551
555
  "@node-rs/argon2": "^2.0.2",
552
556
  "@tiptap/core": "3.31.3",
@@ -749,6 +753,10 @@
749
753
  "types": "./dist/subscription-stripe/index.d.ts",
750
754
  "default": "./dist/subscription-stripe/index.js"
751
755
  },
756
+ "./subscription-dodo": {
757
+ "types": "./dist/subscription-dodo/index.d.ts",
758
+ "default": "./dist/subscription-dodo/index.js"
759
+ },
752
760
  "./subscription-mollie": {
753
761
  "types": "./dist/subscription-mollie/index.d.ts",
754
762
  "default": "./dist/subscription-mollie/index.js"
@@ -1116,15 +1124,17 @@
1116
1124
  ],
1117
1125
  "devDependencies": {
1118
1126
  "@testing-library/user-event": "^14.6.1",
1119
- "@cosmicdrift/kumiko-locale-de": "0.387.0",
1120
- "@cosmicdrift/kumiko-locale-es": "0.387.0",
1121
- "@cosmicdrift/kumiko-renderer-mail-html": "0.387.0",
1127
+ "@cosmicdrift/kumiko-locale-de": "0.388.0",
1128
+ "@cosmicdrift/kumiko-locale-es": "0.388.0",
1129
+ "@cosmicdrift/kumiko-locale-fr": "0.388.0",
1130
+ "@cosmicdrift/kumiko-locale-it": "0.388.0",
1131
+ "@cosmicdrift/kumiko-renderer-mail-html": "0.388.0",
1122
1132
  "jsqr": "^1.4.0",
1123
1133
  "unpdf": "^1.8.1",
1124
- "@cosmicdrift/kumiko-testing": "0.387.0"
1134
+ "@cosmicdrift/kumiko-testing": "0.388.0"
1125
1135
  },
1126
1136
  "peerDependencies": {
1127
- "@cosmicdrift/kumiko-testing": "0.387.0"
1137
+ "@cosmicdrift/kumiko-testing": "0.388.0"
1128
1138
  },
1129
1139
  "peerDependenciesMeta": {
1130
1140
  "@cosmicdrift/kumiko-testing": {
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "dispatchToolCall documents which failures carry a code",
6
+ "detail": "Only handler and dispatcher failures carry a code; local validation errors such as an unknown tool or bad arguments come back without one, and the JSDoc now says so."
7
+ },
2
8
  {
3
9
  "version": "0.366.0",
4
10
  "type": "improvement",
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "Oversized audit cursor is documented as a 400 validation error",
6
+ "detail": "The cursor pattern comment now names the validation error (400) the API actually returns instead of 422."
7
+ },
8
+ {
9
+ "version": "0.388.0",
10
+ "type": "fix",
11
+ "title": "Tests cover nested agent grants, erased subjects and anonymous change-password",
12
+ "detail": "Integration tests now check that a delegating tool handler keeps the cross-tenant-read marker closed, that an erased subject gets no consumer-protection mail, and that the anonymous change-password check runs once; the audit seed helper uses withEnv."
13
+ },
2
14
  {
3
15
  "version": "0.384.0",
4
16
  "type": "improvement",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "improvement",
5
+ "title": "Six-cell code input, autofocus, auto-submit and recovery switch",
6
+ "detail": "The MFA login, setup, disable and regenerate screens use the six-cell code input with autofocus. Login and setup submit automatically on the sixth digit; disable and regenerate keep the explicit confirm button. Verify, disable and regenerate can switch to a recovery-code field. New keys auth.mfa.verify.useRecoveryCode, useAuthenticatorCode, recoveryCode and recoverySubtitle in all locale packages; auth.mfa.disable.code now names the authenticator code only."
7
+ },
2
8
  {
3
9
  "version": "0.379.0",
4
10
  "type": "fix",
@@ -1,4 +1,59 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "Concurrent termination runs, revocation resume and sync ordering",
6
+ "detail": "record-contract-termination re-checks the requestId after the provider call so a concurrent run appends nothing. Revocation resumes a period-end cancel that was recorded between its two stream reads. sync-subscription stamps the snapshot time as providerEventAtIso so a late older webhook cannot reactivate a synced cancellation."
7
+ },
8
+ {
9
+ "version": "0.388.0",
10
+ "type": "fix",
11
+ "title": "Ordered terms lists keep their numbers in the confirmation mail",
12
+ "detail": "The contract confirmation mail prefixes each item of an ordered Markdown list with its number, so references such as \"see no. 3\" in terms and cancellation policies stay intact."
13
+ },
14
+ {
15
+ "version": "0.388.0",
16
+ "type": "fix",
17
+ "title": "Operator notice tells an unavailable receipt limiter from a reached limit",
18
+ "detail": "When the termination receipt mail is withheld because the rate limiter failed or has no digest key, the operator notice reports \"receipt_not_sent\" instead of the per-recipient limit reason."
19
+ },
20
+ {
21
+ "version": "0.388.0",
22
+ "type": "fix",
23
+ "title": "An abandoned plan-change checkout no longer restarts the withdrawal period",
24
+ "detail": "The withdrawal period now runs from the latest subscription start only; a checkout consent without a following subscription start does not reopen it, so a later withdrawal of an old contract is cancelled at period end."
25
+ },
26
+ {
27
+ "version": "0.388.0",
28
+ "type": "fix",
29
+ "title": "Same-second provider update cannot revive a canceled subscription",
30
+ "detail": "When a non-terminal state event carries the same provider timestamp as a stored cancellation, it is treated as late and discarded."
31
+ },
32
+ {
33
+ "version": "0.388.0",
34
+ "type": "improvement",
35
+ "title": "Several subscription providers in one catalog",
36
+ "detail": "The billing-plans query returns providers (name, displayName, plans, note) when more than one provider is offered, and start-plan-checkout takes providerName. consumerProtection.providerTexts overrides checkoutNote and vatNote per provider, and the consent record carries providerName. switch-plan always uses the provider of the existing subscription."
37
+ },
38
+ {
39
+ "version": "0.388.0",
40
+ "type": "improvement",
41
+ "title": "In-app plan switch with a preview and consent",
42
+ "detail": "A provider plugin that implements previewPlanSwitch and applyPlanSwitch switches plans without a hosted portal. The new plan-switch-preview query returns the proration, the billing-plans result carries inAppPlanSwitch, and the panel shows PlanSwitchConfirmDialog with the withdrawal consent checkboxes. The confirmation mail is sent when the plan_changed webhook arrives with the consent id."
43
+ },
44
+ {
45
+ "version": "0.388.0",
46
+ "type": "breaking",
47
+ "title": "A catalog with several providers no longer throws an ambiguous-provider error",
48
+ "detail": "With more than one subscription provider mounted, billing-plans and start-plan-checkout used to throw. They now list the providers; start-plan-checkout answers with errors.providerRequired when several are offered and no providerName is given, and errors.providerNotOffered for an unknown one. Plugins have to set displayName.",
49
+ "migration": "Set displayName on custom subscriptionProvider plugins. Callers that send start-plan-checkout with several providers mounted must pass providerName."
50
+ },
51
+ {
52
+ "version": "0.388.0",
53
+ "type": "improvement",
54
+ "title": "Provider choice moved into the checkout dialog",
55
+ "detail": "The plan panel no longer shows a provider switch. Cards show the first provider's price, or the cheapest one with a \"from\" prefix when providers disagree. With several providers, or with consumerProtection, choosing a plan opens CheckoutConsentDialog, which now has an optional providerChoice (ChoiceCards with the checkout note and, when prices differ, the price), an optional note, and an optional consumerProtection (without it there are no checkboxes and the order carries no consent). The plan switch dialog shows the recurring price with its interval."
56
+ },
2
57
  {
3
58
  "version": "0.379.0",
4
59
  "type": "fix",
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "Consume on a released reservation extends its tombstone",
6
+ "detail": "Every consume against a released reservation tombstone now pushes its expiry out, so a long-running job that consumes across the sweep can no longer lose the tombstone and have its earlier units counted twice at settle."
7
+ },
8
+ {
9
+ "version": "0.388.0",
10
+ "type": "fix",
11
+ "title": "Misconfigured cap period fails on every enforcement",
12
+ "detail": "A periodEndIso that is unparsable or not after periodStartIso now throws on each enforceCap or reservation call instead of only once the hard cap is reached, so a bad period no longer turns into a 500 at the limit instead of cap_exceeded."
13
+ },
14
+ {
15
+ "version": "0.388.0",
16
+ "type": "fix",
17
+ "title": "Stock cap lock covers the counted rows, not the error code",
18
+ "detail": "withStockCap serializes on the table and where filter that are counted, so two handlers capping the same rows under different codes can no longer both pass the check in parallel and exceed the cap together."
19
+ },
2
20
  {
3
21
  "version": "0.378.0",
4
22
  "type": "fix",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "Corrected description of an email channel without app transport",
6
+ "detail": "Without an app transport only notifications declared with tenantTransport true are delivered; all others fail with missing_credentials, so such apps must mark every outgoing notification or keep an app transport."
7
+ },
2
8
  {
3
9
  "version": "0.376.0",
4
10
  "type": "improvement",
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "improvement",
5
+ "title": "config:query:values and config:query:cascade accept a read scope",
6
+ "detail": "Both queries take an optional scope (system, tenant, user). With scope system the cascade of a tenant or user key is evaluated at system level, so the system row is returned even when the caller's own tenant has a row for the key. Only a SystemAdmin gets the narrowed read; other callers keep the normal cascade. Inherited-value redaction still applies. Without scope the behavior is unchanged."
7
+ },
8
+ {
9
+ "version": "0.388.0",
10
+ "type": "fix",
11
+ "title": "Remove unreachable branch in configRowHoldsValue",
12
+ "detail": "The undefined key definition is now handled together with the plaintext comparison, dropping a dead return without changing behavior."
13
+ },
2
14
  {
3
15
  "version": "0.364.0",
4
16
  "type": "fix",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "forget-subject audit event survives a failing record owner lookup",
6
+ "detail": "The lookup of the record row's owning tenant now runs inside the file purge step, so a database error there is reported as cleanupError on the subject-forgotten event instead of aborting the handler after the key was erased."
7
+ },
2
8
  {
3
9
  "version": "0.377.0",
4
10
  "type": "fix",
@@ -1,4 +1,34 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "Preferences query reports renamed notification types under the current name",
6
+ "detail": "Rows stored under a former notification name are returned with the current name and collapsed per channel with the delivery precedence (current-name row wins, otherwise any disabled row disables), so settings screens show the effective state."
7
+ },
8
+ {
9
+ "version": "0.388.0",
10
+ "type": "fix",
11
+ "title": "Inline mail image retries on transient storage errors",
12
+ "detail": "Only a missing file storage for the file's tenant sends the mail without the inline image; any other read error (timeout, provider failure, DB) is rethrown so the send job retries instead of mailing without the logo."
13
+ },
14
+ {
15
+ "version": "0.388.0",
16
+ "type": "fix",
17
+ "title": "Unsubscribe tenant origin resolved once per notify call",
18
+ "detail": "The app's tenantOrigin callback runs once per notify call and tenant instead of once per recipient and channel, so a newsletter no longer causes one lookup and one fallback warning per address. The docs state the callback must only return verified, active domains."
19
+ },
20
+ {
21
+ "version": "0.388.0",
22
+ "type": "fix",
23
+ "title": "One-click unsubscribe POST keeps the JSON rejection",
24
+ "detail": "RFC 8058 one-click POSTs (form body List-Unsubscribe=One-Click) answer an invalid or expired token with the JSON 400 again and skip the branding lookup; only browser form submits get the HTML page."
25
+ },
26
+ {
27
+ "version": "0.388.0",
28
+ "type": "fix",
29
+ "title": "Resubscribe limit page has its own text",
30
+ "detail": "A browser that hits the resubscribe limit sees that the address cannot be subscribed again here, instead of the generic \"try again later\" error (new key mail.unsubscribe.resubscribeLimitReached in en, de, es, fr, it)."
31
+ },
2
32
  {
3
33
  "version": "0.383.0",
4
34
  "type": "fix",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "Encrypted drafts without a PII key service raise an error",
6
+ "detail": "Reading a ciphertext draft with no PII subject key service configured throws instead of returning an empty draft, so the stale-draft cleanup defers it and no longer releases its file refs as if it were empty."
7
+ },
2
8
  {
3
9
  "version": "0.379.0",
4
10
  "type": "breaking",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "Redirects documented as hostDispatch-only",
6
+ "detail": "The redirects option and README state that redirects run inside hostDispatch, so only for requests that reach the HTML fallback; a from matching a static file or an API route never fires. The shared existence lookup of the host and resolver modes is one helper."
7
+ },
2
8
  {
3
9
  "version": "0.376.0",
4
10
  "type": "improvement",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "Deliver job validates its payload at the trigger",
6
+ "detail": "The deliver job now declares its payload schema, so a manual trigger with an invalid payload is rejected up front instead of throwing inside the handler and consuming all retries."
7
+ },
2
8
  {
3
9
  "version": "0.365.1",
4
10
  "type": "fix",
@@ -0,0 +1,14 @@
1
+ [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "improvement",
5
+ "title": "New subscription provider for Dodo Payments",
6
+ "detail": "createSubscriptionDodoFeature() registers the \"dodo\" subscription provider (Merchant of Record). It verifies Standard Webhooks signatures, maps Dodo events to the foundation events, creates hosted checkouts and portal sessions, and switches plans in the app through previewPlanSwitch and applyPlanSwitch. Config keys are api-key, webhook-secret, billing-live and environment. Plan switches need a Product Collection in Dodo."
7
+ },
8
+ {
9
+ "version": "0.388.0",
10
+ "type": "improvement",
11
+ "title": "dodoEnvSchema declares the Dodo product id slots",
12
+ "detail": "dodoEnvSchema({ prices: [\"PRO\"] }) adds one optional DODO_PRODUCT_PRO slot per plan next to the API key and webhook key, and checks the pdt_ prefix. It is the counterpart to stripeEnvSchema."
13
+ }
14
+ ]