@cosmicdrift/kumiko-samples 0.362.0 → 0.364.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.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +23 -6
- package/packages/bundled-features/src/admin-shell/changes.json +6 -0
- package/packages/bundled-features/src/admin-shell/feature.ts +8 -1
- package/packages/bundled-features/src/agent-tools/agent-manifest.ts +3 -4
- package/packages/bundled-features/src/agent-tools/filter-ops.ts +1 -3
- package/packages/bundled-features/src/agent-tools/tool-catalog.ts +1 -3
- package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +2 -1
- package/packages/bundled-features/src/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
- package/packages/bundled-features/src/auth-email-password/bootstrap.ts +29 -8
- package/packages/bundled-features/src/auth-email-password/changes.json +51 -0
- package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
- package/packages/bundled-features/src/auth-email-password/email-templates.ts +12 -138
- package/packages/bundled-features/src/auth-email-password/feature.ts +30 -9
- package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +4 -5
- package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
- package/packages/bundled-features/src/auth-email-password/handlers/request-account-unlock.write.ts +2 -5
- package/packages/bundled-features/src/auth-email-password/handlers/request-email-verification.write.ts +2 -5
- package/packages/bundled-features/src/auth-email-password/handlers/request-password-reset.write.ts +2 -5
- package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +3 -4
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +5 -13
- package/packages/bundled-features/src/auth-email-password/index.ts +2 -17
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +30 -22
- package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +158 -0
- package/packages/bundled-features/src/auth-email-password/signup-activation.ts +2 -3
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
- package/packages/bundled-features/src/auth-foundation/types.ts +8 -18
- package/packages/bundled-features/src/auth-mfa/feature.ts +8 -10
- package/packages/bundled-features/src/auth-mfa/hmac-token-codec.ts +4 -4
- package/packages/bundled-features/src/auth-mfa/mfa-challenge-token.ts +3 -3
- package/packages/bundled-features/src/auth-mfa/mfa-preauth-setup-token.ts +3 -3
- package/packages/bundled-features/src/auth-mfa/mfa-setup-token.ts +8 -8
- package/packages/bundled-features/src/billing-foundation/changes.json +43 -0
- package/packages/bundled-features/src/billing-foundation/constants.ts +5 -4
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +90 -44
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +3 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +118 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/register-consent.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +39 -33
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +2 -2
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +1 -4
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +10 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +36 -20
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
- package/packages/bundled-features/src/billing-foundation/get-subscription-for-tenant.ts +2 -2
- package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
- package/packages/bundled-features/src/billing-foundation/handlers/create-portal-session.write.ts +4 -9
- package/packages/bundled-features/src/billing-foundation/handlers/list-subscriptions.query.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +12 -6
- package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/i18n.ts +3 -0
- package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +20 -11
- package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
- package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
- package/packages/bundled-features/src/cap-counter/changes.json +6 -0
- package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
- package/packages/bundled-features/src/cap-counter/index.ts +1 -0
- package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
- package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
- package/packages/bundled-features/src/cap-overview/web/cap-usage-cell.tsx +6 -4
- package/packages/bundled-features/src/channel-email/changes.json +19 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +135 -29
- package/packages/bundled-features/src/channel-email/feature.ts +6 -3
- package/packages/bundled-features/src/channel-email/index.ts +7 -1
- package/packages/bundled-features/src/channel-email/pii-guard.ts +13 -0
- package/packages/bundled-features/src/channel-email/smtp-transport.ts +15 -1
- package/packages/bundled-features/src/channel-email/types.ts +23 -0
- package/packages/bundled-features/src/channel-in-app/handlers/mark-read.write.ts +2 -2
- package/packages/bundled-features/src/config/changes.json +6 -0
- package/packages/bundled-features/src/config/handlers/set.write.ts +3 -7
- package/packages/bundled-features/src/config/resolver.ts +15 -12
- package/packages/bundled-features/src/config/write-helpers.ts +23 -0
- package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +48 -27
- package/packages/bundled-features/src/custom-fields/changes.json +6 -0
- package/packages/bundled-features/src/custom-fields/db/queries/user-data-rights.ts +2 -2
- package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
- package/packages/bundled-features/src/custom-fields/lib/value-schema.ts +10 -11
- package/packages/bundled-features/src/custom-fields/wire-user-data-rights.ts +7 -6
- package/packages/bundled-features/src/data-retention/changes.json +6 -0
- package/packages/bundled-features/src/data-retention/index.ts +1 -0
- package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +8 -6
- package/packages/bundled-features/src/delivery/attachment-ref.ts +105 -0
- package/packages/bundled-features/src/delivery/changes.json +66 -0
- package/packages/bundled-features/src/delivery/channel-context.ts +152 -4
- package/packages/bundled-features/src/delivery/constants.ts +2 -0
- package/packages/bundled-features/src/delivery/delivery-service.ts +104 -26
- package/packages/bundled-features/src/delivery/feature.ts +1 -1
- package/packages/bundled-features/src/delivery/index.ts +19 -2
- package/packages/bundled-features/src/delivery/jobs.ts +97 -17
- package/packages/bundled-features/src/delivery/notify-factory.ts +14 -9
- package/packages/bundled-features/src/delivery/testing.ts +9 -1
- package/packages/bundled-features/src/delivery/types.ts +34 -18
- package/packages/bundled-features/src/delivery/unsubscribe.ts +5 -2
- package/packages/bundled-features/src/derivatives-sharp/render.ts +1 -22
- package/packages/bundled-features/src/document-ingest-foundation/pages.ts +6 -3
- package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +4 -4
- package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
- package/packages/bundled-features/src/file-foundation/changes.json +6 -0
- package/packages/bundled-features/src/file-foundation/index.ts +7 -0
- package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +89 -0
- package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
- package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
- package/packages/bundled-features/src/folders/web/folder-manager.tsx +5 -2
- package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +11 -11
- package/packages/bundled-features/src/form-draft/db/queries/draft-count.ts +2 -2
- package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +2 -2
- package/packages/bundled-features/src/form-draft/release-file-refs.ts +3 -2
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/connect-account.write.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +6 -6
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-messages.query.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/scope-visibility.ts +2 -2
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-state.ts +7 -7
- package/packages/bundled-features/src/inbound-mail-foundation/retention-sweep.ts +2 -6
- package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +5 -13
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +4 -6
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +42 -25
- package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +4 -3
- package/packages/bundled-features/src/jobs/changes.json +19 -0
- package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
- package/packages/bundled-features/src/jobs/feature.ts +7 -4
- package/packages/bundled-features/src/jobs/handlers/catalog.query.ts +1 -1
- package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
- package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
- package/packages/bundled-features/src/jobs/job-run-logger.ts +52 -17
- package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
- package/packages/bundled-features/src/ledger/changes.json +7 -0
- package/packages/bundled-features/src/ledger/entity.ts +3 -1
- package/packages/bundled-features/src/ledger/recurring.ts +1 -3
- package/packages/bundled-features/src/managed-pages/branding.ts +33 -9
- package/packages/bundled-features/src/managed-pages/changes.json +6 -0
- package/packages/bundled-features/src/managed-pages/feature.ts +1 -0
- package/packages/bundled-features/src/managed-pages/i18n.ts +1 -0
- package/packages/bundled-features/src/managed-pages/screens/branding-screen.ts +8 -0
- package/packages/bundled-features/src/page-render/branding.ts +3 -0
- package/packages/bundled-features/src/page-render/markdown.ts +1 -1
- package/packages/bundled-features/src/personal-access-tokens/feature.ts +2 -4
- package/packages/bundled-features/src/personal-access-tokens/handlers/list.query.ts +2 -1
- package/packages/bundled-features/src/personal-access-tokens/index.ts +1 -1
- package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +2 -1
- package/packages/bundled-features/src/renderer-foundation/README.md +11 -10
- package/packages/bundled-features/src/renderer-foundation/api.ts +14 -14
- package/packages/bundled-features/src/renderer-foundation/changes.json +39 -0
- package/packages/bundled-features/src/renderer-foundation/constants.ts +6 -8
- package/packages/bundled-features/src/renderer-foundation/feature.ts +7 -7
- package/packages/bundled-features/src/renderer-foundation/index.ts +13 -2
- package/packages/bundled-features/src/renderer-foundation/types.ts +134 -35
- package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
- package/packages/bundled-features/src/renderer-pdf/feature.ts +7 -1
- package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
- package/packages/bundled-features/src/secrets/changes.json +6 -0
- package/packages/bundled-features/src/secrets/feature.ts +4 -3
- package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
- package/packages/bundled-features/src/sessions/db/queries/cleanup.ts +2 -2
- package/packages/bundled-features/src/sessions/feature.ts +2 -4
- package/packages/bundled-features/src/sessions/handlers/list.query.ts +2 -1
- package/packages/bundled-features/src/shared/attachment-file-name.ts +39 -0
- package/packages/bundled-features/src/shared/changes.json +6 -0
- package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
- package/packages/bundled-features/src/shared/index.ts +6 -0
- package/packages/bundled-features/src/shared/is-tenant-db.ts +1 -1
- package/packages/bundled-features/src/shared/mail-template-body.ts +42 -0
- package/packages/bundled-features/src/shared/sharp-input-limits.ts +26 -0
- package/packages/bundled-features/src/shared/signup-handover.ts +4 -4
- package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
- package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
- package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
- package/packages/bundled-features/src/tags/handlers/assign-tag.write.ts +5 -1
- package/packages/bundled-features/src/tags/web/tag-chip.tsx +2 -1
- package/packages/bundled-features/src/template-resolver/README.md +66 -8
- package/packages/bundled-features/src/template-resolver/api.ts +85 -9
- package/packages/bundled-features/src/template-resolver/changes.json +186 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +278 -0
- package/packages/bundled-features/src/template-resolver/constants.ts +49 -11
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +335 -0
- package/packages/bundled-features/src/template-resolver/document-schema.ts +345 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +59 -6
- package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +188 -0
- package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +30 -2
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +160 -0
- package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +33 -0
- package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
- package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +138 -0
- package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +9 -6
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +42 -0
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +22 -0
- package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +151 -0
- package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +19 -18
- package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +323 -0
- package/packages/bundled-features/src/template-resolver/handlers/preview.query.ts +25 -0
- package/packages/bundled-features/src/template-resolver/handlers/reset-to-system.write.ts +58 -0
- package/packages/bundled-features/src/template-resolver/handlers/resource-link-pdf.write.ts +34 -0
- package/packages/bundled-features/src/template-resolver/handlers/resource-remove.write.ts +50 -0
- package/packages/bundled-features/src/template-resolver/handlers/resource-shared.ts +24 -0
- package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +135 -0
- package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +135 -0
- package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +32 -3
- package/packages/bundled-features/src/template-resolver/handlers/shared.ts +7 -2
- package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +196 -0
- package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +233 -0
- package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +137 -0
- package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +91 -17
- package/packages/bundled-features/src/template-resolver/handlers/upsert-system.write.ts +19 -71
- package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +16 -77
- package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +31 -0
- package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +116 -0
- package/packages/bundled-features/src/template-resolver/index.ts +111 -0
- package/packages/bundled-features/src/template-resolver/layout-appearance.ts +40 -0
- package/packages/bundled-features/src/template-resolver/layout-regions.ts +206 -0
- package/packages/bundled-features/src/template-resolver/linked-resources.ts +102 -0
- package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
- package/packages/bundled-features/src/template-resolver/preview-display-html.ts +36 -0
- package/packages/bundled-features/src/template-resolver/print-document.ts +58 -0
- package/packages/bundled-features/src/template-resolver/qualified-names.ts +34 -0
- package/packages/bundled-features/src/template-resolver/record-mapping.ts +26 -0
- package/packages/bundled-features/src/template-resolver/resource-image.ts +47 -0
- package/packages/bundled-features/src/template-resolver/seeding.ts +171 -20
- package/packages/bundled-features/src/template-resolver/store-template-resource.ts +416 -0
- package/packages/bundled-features/src/template-resolver/table.ts +27 -1
- package/packages/bundled-features/src/template-resolver/template-bundle.ts +79 -0
- package/packages/bundled-features/src/template-resolver/template-diff.ts +78 -0
- package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
- package/packages/bundled-features/src/template-resolver/template-meta.ts +192 -0
- package/packages/bundled-features/src/template-resolver/template-preview.ts +676 -0
- package/packages/bundled-features/src/template-resolver/template-resources.ts +114 -0
- package/packages/bundled-features/src/template-resolver/template-usage.ts +68 -0
- package/packages/bundled-features/src/template-resolver/template-validation.ts +230 -0
- package/packages/bundled-features/src/template-resolver/testing.ts +5 -5
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +20 -13
- package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +215 -0
- package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +37 -0
- package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +1065 -0
- package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +72 -0
- package/packages/bundled-features/src/template-resolver/web/editor-handle.ts +26 -0
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +121 -0
- package/packages/bundled-features/src/template-resolver/web/index.ts +26 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor-lazy.tsx +36 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +176 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-text.ts +37 -0
- package/packages/bundled-features/src/template-resolver/web/use-stable-by-key.ts +14 -0
- package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +3 -2
- package/packages/bundled-features/src/tenant/seeding.ts +41 -7
- package/packages/bundled-features/src/tenant-lifecycle/boot-checks.ts +1 -1
- package/packages/bundled-features/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +2 -1
- package/packages/bundled-features/src/tier-engine/changes.json +6 -0
- package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
- package/packages/bundled-features/src/user/changes.json +6 -0
- package/packages/bundled-features/src/user/db/queries/stream-tenant-backfill.ts +4 -4
- package/packages/bundled-features/src/user/handlers/detail.query.ts +3 -5
- package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
- package/packages/bundled-features/src/user/roles-input-schema.ts +2 -2
- package/packages/bundled-features/src/user/seeding.ts +19 -5
- package/packages/bundled-features/src/user-data-rights/changes.json +13 -0
- package/packages/bundled-features/src/user-data-rights/db/queries/forget-cleanup.ts +2 -2
- package/packages/bundled-features/src/user-data-rights/email-templates.ts +5 -149
- package/packages/bundled-features/src/user-data-rights/feature.ts +72 -35
- package/packages/bundled-features/src/user-data-rights/handlers/cancel-deletion.write.ts +2 -1
- package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +5 -11
- package/packages/bundled-features/src/user-data-rights/lib/default-mailers.ts +134 -53
- package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +2 -2
- package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +157 -0
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +2 -1
- package/packages/bundled-features/src/user-profile/changes.json +1 -1
- package/packages/bundled-features/src/user-profile/web/profile-screen.tsx +2 -1
- package/packages/bundled-features/src/waitlist/changes.json +7 -0
- package/packages/bundled-features/src/waitlist/feature.ts +17 -0
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +4 -5
- package/packages/bundled-features/src/waitlist/mail.ts +69 -18
- package/packages/bundled-features/src/waitlist/options.ts +1 -1
- package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
- package/packages/bundled-features/src/workflow-runner/event-trigger.ts +3 -3
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
- package/samples/apps/ui-walkthrough/package.json +11 -0
- package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
- package/samples/apps/ui-walkthrough/src/app/auth-constants.ts +9 -0
- package/samples/apps/ui-walkthrough/src/app/client.tsx +6 -0
- package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +239 -0
- package/samples/apps/ui-walkthrough/src/app/server.ts +5 -1
- package/samples/apps/ui-walkthrough/src/app/shell.tsx +12 -1
- package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +570 -0
- package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +72 -0
- package/samples/apps/ui-walkthrough/src/features/billing/i18n.ts +18 -0
- package/samples/apps/ui-walkthrough/src/features/billing/index.ts +2 -0
- package/samples/apps/ui-walkthrough/src/features/billing/web/index.ts +7 -0
- package/samples/apps/ui-walkthrough/src/run-config.ts +41 -1
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +0 -2
- package/samples/apps/use-all-bundled/feature-manifest.json +118 -21
- package/samples/apps/use-all-bundled/kumiko/migrations/0045_template-resource-meta.rebuild.json +4 -0
- package/samples/apps/use-all-bundled/kumiko/migrations/0046_pdf-rendered.rebuild.json +4 -0
- package/samples/apps/use-all-bundled/package.json +2 -0
- package/samples/apps/use-all-bundled/playwright.config.ts +38 -3
- package/samples/apps/use-all-bundled/schema/generate.ts +13 -3
- package/samples/apps/use-all-bundled/schema/schema.generated.ts +3 -0
- package/samples/apps/use-all-bundled/scripts/check-coverage.ts +3 -0
- package/samples/apps/use-all-bundled/src/app/auth-constants.ts +3 -0
- package/samples/apps/use-all-bundled/src/app/client.tsx +4 -0
- package/samples/apps/use-all-bundled/src/app/seed-documents.ts +128 -0
- package/samples/apps/use-all-bundled/src/app/seed.ts +4 -0
- package/samples/apps/use-all-bundled/src/run-config.ts +11 -11
- package/samples/recipes/delivery-notifications/README.md +26 -25
- package/samples/recipes/delivery-notifications/package.json +2 -1
- package/samples/recipes/delivery-notifications/src/feature.ts +64 -58
- package/samples/recipes/seed-migration/README.md +1 -1
- package/packages/bundled-features/src/renderer-simple/changes.json +0 -27
- package/packages/bundled-features/src/renderer-simple/feature.ts +0 -66
- package/packages/bundled-features/src/renderer-simple/index.ts +0 -8
- package/packages/bundled-features/src/renderer-simple/mail-blocks.ts +0 -138
- package/packages/bundled-features/src/renderer-simple/resolve-variables.ts +0 -57
- package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +0 -304
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
SignatureExtraRouteDeps,
|
|
15
15
|
} from "@cosmicdrift/kumiko-framework/api";
|
|
16
16
|
import type { TenantId, WriteResult } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
17
18
|
import { subscriptionAggregateId } from "./aggregate-id.js";
|
|
18
19
|
import { SubscriptionFoundationQueries, SubscriptionStatuses } from "./constants.js";
|
|
19
20
|
import { createSubscriptionWebhookRoute } from "./webhook-handler.js";
|
|
@@ -37,6 +38,10 @@ export type SubscriptionTierSyncDeps<TTier extends string> = {
|
|
|
37
38
|
readonly onSyncError?: "log" | "fail-webhook";
|
|
38
39
|
};
|
|
39
40
|
|
|
41
|
+
// A manual grant that differs from what billing would assign is reported with this
|
|
42
|
+
// code but never fails the webhook: a provider retry cannot resolve it.
|
|
43
|
+
export const TIER_SYNC_MANUAL_GRANT_SKIPPED = "tier_sync_manual_grant_skipped";
|
|
44
|
+
|
|
40
45
|
export function effectiveTierFromSubscription<TTier extends string>(
|
|
41
46
|
status: string | undefined,
|
|
42
47
|
tier: string | undefined,
|
|
@@ -52,12 +57,8 @@ export function effectiveTierFromSubscription<TTier extends string>(
|
|
|
52
57
|
// (handler bug, or the handlerQn resolving to a non-list query) fails
|
|
53
58
|
// loudly instead of crashing on `.find` against `undefined`.
|
|
54
59
|
function asRows(result: unknown): ReadonlyArray<Record<string, unknown>> {
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
result !== null &&
|
|
58
|
-
Array.isArray((result as { rows?: unknown }).rows)
|
|
59
|
-
) {
|
|
60
|
-
return (result as { rows: ReadonlyArray<Record<string, unknown>> }).rows;
|
|
60
|
+
if (isPlainObject(result) && Array.isArray(result["rows"])) {
|
|
61
|
+
return result["rows"];
|
|
61
62
|
}
|
|
62
63
|
throw new Error("expected a { rows: [...] } list-query result");
|
|
63
64
|
}
|
|
@@ -85,7 +86,7 @@ export function createSubscriptionTierSync<TTier extends string>(
|
|
|
85
86
|
) {
|
|
86
87
|
async function syncTierFromSubscription(
|
|
87
88
|
tenantId: TenantId,
|
|
88
|
-
routeDeps: SignatureExtraRouteDeps,
|
|
89
|
+
routeDeps: Pick<SignatureExtraRouteDeps, "dispatchSystemQuery" | "dispatchSystemWrite">,
|
|
89
90
|
): Promise<{ code: string; message: string } | null> {
|
|
90
91
|
try {
|
|
91
92
|
const subscriptionId = subscriptionAggregateId(tenantId);
|
|
@@ -132,8 +133,13 @@ export function createSubscriptionTierSync<TTier extends string>(
|
|
|
132
133
|
}
|
|
133
134
|
return null;
|
|
134
135
|
}
|
|
135
|
-
if (assignment.source === TierAssignmentSources.manual) return null;
|
|
136
136
|
if (assignment.tier === effective) return null;
|
|
137
|
+
if (assignment.source === TierAssignmentSources.manual) {
|
|
138
|
+
return {
|
|
139
|
+
code: TIER_SYNC_MANUAL_GRANT_SKIPPED,
|
|
140
|
+
message: `manual tier grant "${String(assignment.tier)}" kept although the subscription yields "${effective}"`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
137
143
|
|
|
138
144
|
const result = await routeDeps.dispatchSystemWrite({
|
|
139
145
|
handlerQn: TierEngineHandlers.update,
|
|
@@ -172,9 +178,12 @@ export function createSubscriptionTierSync<TTier extends string>(
|
|
|
172
178
|
if (syncError) {
|
|
173
179
|
// biome-ignore lint/suspicious/noConsole: operator visibility for a post-commit sync failure
|
|
174
180
|
console.warn(
|
|
175
|
-
`[subscription-tier-sync] tier sync
|
|
181
|
+
`[subscription-tier-sync] tier sync for tenant ${tenantId} after successful webhook write: ${syncError.code} ${syncError.message}`,
|
|
176
182
|
);
|
|
177
|
-
if (
|
|
183
|
+
if (
|
|
184
|
+
deps.onSyncError === "fail-webhook" &&
|
|
185
|
+
syncError.code !== TIER_SYNC_MANUAL_GRANT_SKIPPED
|
|
186
|
+
) {
|
|
178
187
|
return {
|
|
179
188
|
isSuccess: false,
|
|
180
189
|
error: {
|
|
@@ -191,5 +200,5 @@ export function createSubscriptionTierSync<TTier extends string>(
|
|
|
191
200
|
});
|
|
192
201
|
}
|
|
193
202
|
|
|
194
|
-
return { createWebhookRoute };
|
|
203
|
+
return { createWebhookRoute, syncTierFromSubscription };
|
|
195
204
|
}
|
|
@@ -396,7 +396,10 @@ export function isLegalLinkSet(
|
|
|
396
396
|
|
|
397
397
|
/** Consumer-protection consent gate (§ 312j / § 356 BGB style). When set,
|
|
398
398
|
* `start-plan-checkout` and `create-checkout-session` require a `consent`
|
|
399
|
-
* payload and record it as an event before the buyer reaches the provider.
|
|
399
|
+
* payload and record it as an event before the buyer reaches the provider.
|
|
400
|
+
* Every provider used with `create-checkout-session` must implement
|
|
401
|
+
* `retrievePrices`: the live price is part of the consent record, and a
|
|
402
|
+
* provider without it fails the checkout with an InternalError. */
|
|
400
403
|
export type ConsumerProtectionOptions = {
|
|
401
404
|
/** Slug of the template-resolver `text-block` holding the terms text; its
|
|
402
405
|
* content hash is stored with every recorded consent. */
|
|
@@ -411,7 +414,9 @@ export type ConsumerProtectionOptions = {
|
|
|
411
414
|
readonly terminationScope?: TerminationScope;
|
|
412
415
|
/** Names the item of a one-off payment (mode "payment") for the consent record
|
|
413
416
|
* and the confirmation mail; the label key is resolved through the registry
|
|
414
|
-
* i18n, so it must be qualified with the registering feature.
|
|
417
|
+
* i18n, so it must be qualified with the registering feature. The label key
|
|
418
|
+
* is 1-200 characters, string params at most 100; a violation fails the
|
|
419
|
+
* checkout with an InternalError before the provider session is created. */
|
|
415
420
|
readonly oneOffItemLabel?: (
|
|
416
421
|
ctx: HandlerContext,
|
|
417
422
|
priceId: string,
|
|
@@ -44,6 +44,8 @@ export async function reserveEnforcedCalendarCap(
|
|
|
44
44
|
profile: cap.profile,
|
|
45
45
|
notify: cap.notify,
|
|
46
46
|
amount: cap.amount,
|
|
47
|
+
...(cap.periodEndIso !== undefined && { periodEndIso: cap.periodEndIso }),
|
|
48
|
+
...(cap.i18nKey !== undefined && { i18nKey: cap.i18nKey }),
|
|
47
49
|
markSoftWarnedOutsideTransaction: outsideTransaction,
|
|
48
50
|
});
|
|
49
51
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.364.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "CapExceededError carries resetAt and retryAfterSeconds, API sets Retry-After",
|
|
6
|
+
"migration": "Periodic caps no longer need RateLimitError({ windowSeconds: 0, ... }). Where you catch CapExceededError from enforceCap and rethrow it as RateLimitError, drop the catch and let CapExceededError pass: the response stays 429 with a correct Retry-After, error.code becomes cap_exceeded instead of rate_limited, and error.details carries resetAt and retryAfterSeconds. Keep a feature-specific message with enforceCap({ ..., i18nKey }) and pass a period end other than the calendar month with periodEndIso (e.g. a budget's resetsAtIso). Clients matching on rate_limited for these caps must also accept cap_exceeded. Any KumikoError with retryAfterSeconds (including RateLimitError thrown in a handler) now gets a Retry-After header from /api/write, /api/batch and /api/query."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.361.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -66,14 +66,38 @@ export function assertBelowHardCap(
|
|
|
66
66
|
readonly capName: string;
|
|
67
67
|
readonly limit: number;
|
|
68
68
|
readonly profile: CapToleranceProfileName;
|
|
69
|
+
// Rolling caps have no fixed period, so they pass neither period field.
|
|
70
|
+
readonly periodStartIso?: string;
|
|
71
|
+
readonly periodEndIso?: string;
|
|
72
|
+
readonly i18nKey?: string;
|
|
69
73
|
},
|
|
70
74
|
): void {
|
|
71
75
|
const tolerance = CAP_TOLERANCES[cap.profile];
|
|
72
76
|
if (valueSeenByLastUnit >= cap.limit * tolerance.hard) {
|
|
73
|
-
|
|
77
|
+
const resetAt = resolvePeriodEndIso(cap.periodStartIso, cap.periodEndIso);
|
|
78
|
+
throw new CapExceededError(cap.capName, cap.limit, valueSeenByLastUnit, tolerance, {
|
|
79
|
+
...(resetAt !== undefined && { resetAt }),
|
|
80
|
+
...(cap.i18nKey !== undefined && { i18nKey: cap.i18nKey }),
|
|
81
|
+
});
|
|
74
82
|
}
|
|
75
83
|
}
|
|
76
84
|
|
|
85
|
+
function resolvePeriodEndIso(
|
|
86
|
+
periodStartIso: string | undefined,
|
|
87
|
+
periodEndIso: string | undefined,
|
|
88
|
+
): string | undefined {
|
|
89
|
+
if (periodStartIso === undefined) return periodEndIso;
|
|
90
|
+
if (periodEndIso === undefined) return calendarMonthPeriodEndIso(periodStartIso);
|
|
91
|
+
const start = Temporal.Instant.from(periodStartIso);
|
|
92
|
+
const end = Temporal.Instant.from(periodEndIso);
|
|
93
|
+
if (Temporal.Instant.compare(end, start) <= 0) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`cap-counter: periodEndIso (${periodEndIso}) must be after periodStartIso (${periodStartIso})`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return end.toString();
|
|
99
|
+
}
|
|
100
|
+
|
|
77
101
|
// =============================================================================
|
|
78
102
|
// Enforce-Cap helper
|
|
79
103
|
// =============================================================================
|
|
@@ -109,6 +133,9 @@ export async function enforceCap(
|
|
|
109
133
|
// Units the caller is about to book in one go (default 1), checked with
|
|
110
134
|
// the same outcome as booking them one by one.
|
|
111
135
|
readonly amount?: number;
|
|
136
|
+
// Default: end of the calendar month that starts at periodStartIso.
|
|
137
|
+
readonly periodEndIso?: string;
|
|
138
|
+
readonly i18nKey?: string;
|
|
112
139
|
},
|
|
113
140
|
): Promise<EnforceCapResult> {
|
|
114
141
|
const { user, readDb } = resolveCapCounterScope(ctx, false);
|
|
@@ -211,23 +238,54 @@ export async function enforceRollingCap(
|
|
|
211
238
|
* Thrown by enforceCap/enforceRollingCap when value ≥ hard-threshold.
|
|
212
239
|
* Extends `KumikoError` — the dispatcher auto-maps this to HTTP 429
|
|
213
240
|
* with `code: "cap_exceeded"`, no caller-side catch needed.
|
|
241
|
+
*
|
|
242
|
+
* Calendar caps carry `resetAt` (end of the period) and `retryAfterSeconds`, which the API
|
|
243
|
+
* answers as Retry-After. Rolling caps have no fixed reset instant, so both stay undefined.
|
|
214
244
|
*/
|
|
215
245
|
export class CapExceededError extends KumikoError {
|
|
216
246
|
readonly code = "cap_exceeded" as const;
|
|
217
247
|
readonly httpStatus = 429;
|
|
248
|
+
readonly resetAt: string | undefined;
|
|
218
249
|
|
|
219
250
|
constructor(
|
|
220
251
|
readonly capName: string,
|
|
221
252
|
readonly limit: number,
|
|
222
253
|
readonly currentValue: number,
|
|
223
254
|
readonly tolerance: CapToleranceProfile,
|
|
255
|
+
options?: {
|
|
256
|
+
readonly resetAt?: string;
|
|
257
|
+
readonly i18nKey?: string;
|
|
258
|
+
readonly cause?: Error;
|
|
259
|
+
// Deterministic tests only.
|
|
260
|
+
readonly now?: Temporal.Instant;
|
|
261
|
+
},
|
|
224
262
|
) {
|
|
263
|
+
const resetAt = options?.resetAt;
|
|
264
|
+
const retryAfterSeconds =
|
|
265
|
+
resetAt === undefined
|
|
266
|
+
? undefined
|
|
267
|
+
: Math.max(
|
|
268
|
+
0,
|
|
269
|
+
Math.ceil(
|
|
270
|
+
Temporal.Instant.from(resetAt).epochMilliseconds / 1000 -
|
|
271
|
+
(options?.now ?? Temporal.Now.instant()).epochMilliseconds / 1000,
|
|
272
|
+
),
|
|
273
|
+
);
|
|
225
274
|
super({
|
|
226
|
-
message: `Cap "${capName}" exceeded: current=${currentValue}, limit=${limit}, hard-threshold=${limit * tolerance.hard}. Upgrade tier or wait for next period reset
|
|
227
|
-
i18nKey: "errors.cap.exceeded",
|
|
228
|
-
i18nParams: { capName, limit, currentValue },
|
|
229
|
-
details: {
|
|
275
|
+
message: `Cap "${capName}" exceeded: current=${currentValue}, limit=${limit}, hard-threshold=${limit * tolerance.hard}. Upgrade tier or wait for next period reset.${resetAt === undefined ? "" : ` Resets at ${resetAt}.`}`,
|
|
276
|
+
i18nKey: options?.i18nKey ?? "errors.cap.exceeded",
|
|
277
|
+
i18nParams: { capName, limit, currentValue, ...(resetAt !== undefined && { resetAt }) },
|
|
278
|
+
details: {
|
|
279
|
+
capName,
|
|
280
|
+
limit,
|
|
281
|
+
currentValue,
|
|
282
|
+
tolerance,
|
|
283
|
+
...(resetAt !== undefined && { resetAt, retryAfterSeconds }),
|
|
284
|
+
},
|
|
285
|
+
...(retryAfterSeconds !== undefined && { retryAfterSeconds }),
|
|
286
|
+
...(options?.cause && { cause: options.cause }),
|
|
230
287
|
});
|
|
288
|
+
this.resetAt = resetAt;
|
|
231
289
|
}
|
|
232
290
|
}
|
|
233
291
|
|
|
@@ -257,6 +315,14 @@ export function currentCalendarMonthStartIso(
|
|
|
257
315
|
return start.toInstant().toString();
|
|
258
316
|
}
|
|
259
317
|
|
|
318
|
+
export function calendarMonthPeriodEndIso(periodStartIso: string): string {
|
|
319
|
+
return Temporal.Instant.from(periodStartIso)
|
|
320
|
+
.toZonedDateTimeISO("UTC")
|
|
321
|
+
.add({ months: 1 })
|
|
322
|
+
.toInstant()
|
|
323
|
+
.toString();
|
|
324
|
+
}
|
|
325
|
+
|
|
260
326
|
// =============================================================================
|
|
261
327
|
// Notification-Wiring helpers — convenience-wrapper für enforceCap +
|
|
262
328
|
// enforceRollingCap, die einen Caller-supplied delivery-emit beim
|
|
@@ -299,6 +365,8 @@ export async function enforceCapAndMaybeNotify(
|
|
|
299
365
|
readonly profile: CapToleranceProfileName;
|
|
300
366
|
readonly notify: SoftHitNotifier;
|
|
301
367
|
readonly amount?: number;
|
|
368
|
+
readonly periodEndIso?: string;
|
|
369
|
+
readonly i18nKey?: string;
|
|
302
370
|
readonly markSoftWarnedOutsideTransaction?: boolean;
|
|
303
371
|
},
|
|
304
372
|
): Promise<EnforceCapResult> {
|
|
@@ -308,6 +376,8 @@ export async function enforceCapAndMaybeNotify(
|
|
|
308
376
|
limit: options.limit,
|
|
309
377
|
profile: options.profile,
|
|
310
378
|
...(options.amount !== undefined && { amount: options.amount }),
|
|
379
|
+
...(options.periodEndIso !== undefined && { periodEndIso: options.periodEndIso }),
|
|
380
|
+
...(options.i18nKey !== undefined && { i18nKey: options.i18nKey }),
|
|
311
381
|
});
|
|
312
382
|
|
|
313
383
|
if (result.state === "soft-hit" && result.crossed) {
|
|
@@ -53,6 +53,9 @@ import { chainReservations } from "./reservation-chain.js";
|
|
|
53
53
|
export type CalendarCapDef = {
|
|
54
54
|
readonly capName: string;
|
|
55
55
|
readonly periodStartIso: string;
|
|
56
|
+
// Default: end of the calendar month that starts at periodStartIso.
|
|
57
|
+
readonly periodEndIso?: string;
|
|
58
|
+
readonly i18nKey?: string;
|
|
56
59
|
readonly limit: number;
|
|
57
60
|
readonly profile: CapToleranceProfileName;
|
|
58
61
|
/** Increment-amount post-success. Default 1. */
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type TenantId,
|
|
8
8
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
9
9
|
import { InternalError, ValidationError } from "@cosmicdrift/kumiko-framework/errors";
|
|
10
|
+
import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
10
11
|
import * as z from "zod";
|
|
11
12
|
import { subscriptionsProjectionTable } from "../../billing-foundation/index.js";
|
|
12
13
|
import type { CapLimitContext } from "../../cap-counter/index.js";
|
|
@@ -79,7 +80,7 @@ async function resolveLimitsByTenantAndCap(
|
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
function isSortableField(field: string): field is SortableField {
|
|
82
|
-
return (SORTABLE_FIELDS
|
|
83
|
+
return isOneOf(SORTABLE_FIELDS, field);
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
// tier is a plain string with no defined ordering — lt/gt have no meaning
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
// @runtime client
|
|
2
|
+
|
|
2
3
|
import type { ColumnRendererProps } from "@cosmicdrift/kumiko-renderer";
|
|
4
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
3
5
|
import type { ReactNode } from "react";
|
|
4
6
|
import type { CapUsage } from "../types.js";
|
|
5
7
|
import { CapUsageBar } from "./cap-usage-bar.js";
|
|
6
8
|
|
|
7
9
|
function isCapUsage(value: unknown): value is CapUsage {
|
|
8
|
-
if (
|
|
9
|
-
const used =
|
|
10
|
-
const limit =
|
|
10
|
+
if (!isPlainObject(value)) return false;
|
|
11
|
+
const used = value["used"];
|
|
12
|
+
const limit = value["limit"];
|
|
11
13
|
return (
|
|
12
14
|
(typeof used === "number" || used === null) &&
|
|
13
15
|
(typeof limit === "number" || limit === null) &&
|
|
14
|
-
typeof
|
|
16
|
+
typeof value["fraction"] === "number"
|
|
15
17
|
);
|
|
16
18
|
}
|
|
17
19
|
|
|
@@ -1,4 +1,23 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.363.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "A notification without a mail template fails on the email channel",
|
|
6
|
+
"detail": "The renderer option of createChannelEmailFeature is removed. A notification routed to the email channel without template + meta fails its delivery permanently with \"has no mail template, declare template + meta on r.notification\". r.notification declares no channels, so this is not checked at boot; an app's own mail tests over the inmemory transport catch it.",
|
|
7
|
+
"migration": "Drop the renderer option. Declare template + meta (or localizedTemplate) and variables on every r.notification that reaches the email channel, or accept that its email delivery fails. Run the app's notification tests after the bump: every email notification without a template now fails there."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.363.0",
|
|
11
|
+
"type": "improvement",
|
|
12
|
+
"title": "Email channel sends rendered-pdf attachments",
|
|
13
|
+
"detail": "The channel resolves rendered-pdf entries from notify data next to file entries; without a locale the message locale is used. A render failure fails the send job, so the retry runs it again."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"version": "0.363.0",
|
|
17
|
+
"type": "improvement",
|
|
18
|
+
"title": "Email channel sends file attachments and inline cid images",
|
|
19
|
+
"detail": "EmailMessage.attachments is sent through the SMTP transport (inline images with Content-ID). notify data.attachments with kind file entries are resolved through the file store; the PII guard rejects ciphertext file names."
|
|
20
|
+
},
|
|
2
21
|
{
|
|
3
22
|
"version": "0.350.0",
|
|
4
23
|
"type": "improvement",
|
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-framework/crypto";
|
|
2
|
-
import type { DbRow } from "@cosmicdrift/kumiko-framework/db";
|
|
3
2
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import * as z from "zod";
|
|
4
4
|
import {
|
|
5
|
+
type AttachmentOrigin,
|
|
6
|
+
type ChannelContext,
|
|
5
7
|
type ChannelMessage,
|
|
6
8
|
DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE,
|
|
7
9
|
DELIVERY_UNSUBSCRIBE_PATH,
|
|
8
10
|
type DeliveryChannel,
|
|
9
|
-
|
|
11
|
+
EMAIL_CHANNEL_NAME,
|
|
12
|
+
type EmailAttachmentRef,
|
|
13
|
+
fileAttachmentRefSchema,
|
|
14
|
+
MAX_EMAIL_ATTACHMENTS,
|
|
10
15
|
type RenderedMessage,
|
|
16
|
+
renderedPdfAttachmentRefSchema,
|
|
11
17
|
} from "../delivery/index.js";
|
|
12
18
|
import { decryptStoredPii } from "../shared/index.js";
|
|
13
19
|
import { guardEmailMessage } from "./pii-guard.js";
|
|
14
|
-
import type { EmailTransport } from "./types.js";
|
|
20
|
+
import type { EmailAttachment, EmailTransport } from "./types.js";
|
|
15
21
|
|
|
16
22
|
// Envelope (From / Reply-To / threading headers) rides on the channel data —
|
|
17
23
|
// the notification's email template echoes it out of the notify() call, since
|
|
@@ -83,9 +89,80 @@ function emailEnvelopeFrom(data: Readonly<Record<string, unknown>> | undefined):
|
|
|
83
89
|
};
|
|
84
90
|
}
|
|
85
91
|
|
|
92
|
+
const fileAttachmentsSchema = z
|
|
93
|
+
.array(z.discriminatedUnion("kind", [fileAttachmentRefSchema, renderedPdfAttachmentRefSchema]))
|
|
94
|
+
.max(MAX_EMAIL_ATTACHMENTS);
|
|
95
|
+
|
|
96
|
+
// Only kinds "file" and "rendered-pdf" may come from notify data; inline images are created by the renderer alone.
|
|
97
|
+
// Invalid input throws instead of dropping the attachment silently.
|
|
98
|
+
export function fileAttachmentsFrom(
|
|
99
|
+
data: Readonly<Record<string, unknown>> | undefined,
|
|
100
|
+
messageLocale?: string,
|
|
101
|
+
): readonly EmailAttachmentRef[] {
|
|
102
|
+
const raw = data?.["attachments"];
|
|
103
|
+
if (raw === undefined) return [];
|
|
104
|
+
return fileAttachmentsSchema
|
|
105
|
+
.parse(raw)
|
|
106
|
+
.map((ref) =>
|
|
107
|
+
ref.kind === "rendered-pdf" && ref.locale === undefined && messageLocale !== undefined
|
|
108
|
+
? { ...ref, locale: messageLocale }
|
|
109
|
+
: ref,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const INLINE_EXTENSION_BY_CONTENT_TYPE: Readonly<Record<string, string>> = {
|
|
114
|
+
"image/png": "png",
|
|
115
|
+
"image/jpeg": "jpg",
|
|
116
|
+
"image/gif": "gif",
|
|
117
|
+
"image/webp": "webp",
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
type AttachmentSource = { readonly ref: EmailAttachmentRef; readonly origin: AttachmentOrigin };
|
|
121
|
+
|
|
122
|
+
async function resolveAttachments(
|
|
123
|
+
sources: readonly AttachmentSource[],
|
|
124
|
+
ctx: ChannelContext,
|
|
125
|
+
): Promise<readonly EmailAttachment[]> {
|
|
126
|
+
if (sources.length === 0) return [];
|
|
127
|
+
if (ctx.readAttachment === undefined) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
"email channel: the message has attachments but the channel context cannot read files",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
const resolved: EmailAttachment[] = [];
|
|
133
|
+
for (const { ref, origin } of sources) {
|
|
134
|
+
const found = await ctx.readAttachment(ref, origin);
|
|
135
|
+
if (ref.kind === "rendered-pdf") {
|
|
136
|
+
if (found === null) {
|
|
137
|
+
throw new Error(`email channel: rendering attachment "${ref.fileName}" returned nothing`);
|
|
138
|
+
}
|
|
139
|
+
resolved.push({
|
|
140
|
+
kind: "rendered-pdf",
|
|
141
|
+
templateSlug: ref.templateSlug,
|
|
142
|
+
fileName: ref.fileName,
|
|
143
|
+
content: found.content,
|
|
144
|
+
contentType: found.contentType,
|
|
145
|
+
});
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (found === null) {
|
|
149
|
+
// A missing inline image only degrades the look; a missing file is content the recipient expects.
|
|
150
|
+
if (ref.kind === "inline") continue;
|
|
151
|
+
throw new Error(`email channel: attachment file ${ref.fileRefId} was not found`);
|
|
152
|
+
}
|
|
153
|
+
const fileName =
|
|
154
|
+
ref.kind === "file"
|
|
155
|
+
? ref.fileName
|
|
156
|
+
: INLINE_EXTENSION_BY_CONTENT_TYPE[found.contentType]
|
|
157
|
+
? `${ref.cid}.${INLINE_EXTENSION_BY_CONTENT_TYPE[found.contentType]}`
|
|
158
|
+
: ref.cid;
|
|
159
|
+
resolved.push({ ...ref, fileName, content: found.content, contentType: found.contentType });
|
|
160
|
+
}
|
|
161
|
+
return resolved;
|
|
162
|
+
}
|
|
163
|
+
|
|
86
164
|
export type EmailChannelOptions = {
|
|
87
165
|
readonly transport: EmailTransport;
|
|
88
|
-
readonly renderer: NotificationRenderer;
|
|
89
166
|
readonly resolveEmail: (
|
|
90
167
|
userId: string,
|
|
91
168
|
ctx: { db: unknown; tenantId: TenantId },
|
|
@@ -93,32 +170,45 @@ export type EmailChannelOptions = {
|
|
|
93
170
|
};
|
|
94
171
|
|
|
95
172
|
export function createEmailChannel(options: EmailChannelOptions): DeliveryChannel {
|
|
96
|
-
const { transport,
|
|
173
|
+
const { transport, resolveEmail } = options;
|
|
97
174
|
|
|
98
|
-
// Render
|
|
99
|
-
//
|
|
100
|
-
// can retry independently. Extracted so the inline fallback (no job runner)
|
|
175
|
+
// Render runs in the delivery.render job, decoupled from the SMTP send so
|
|
176
|
+
// each can retry independently. Extracted so the inline path (no job runner)
|
|
101
177
|
// can reuse it without going through the channel's own render() indirection.
|
|
102
|
-
async function renderMessage(
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
178
|
+
async function renderMessage(
|
|
179
|
+
message: ChannelMessage,
|
|
180
|
+
ctx: ChannelContext,
|
|
181
|
+
): Promise<RenderedMessage> {
|
|
182
|
+
const notificationType = message.notificationType;
|
|
183
|
+
if (ctx.registry.getAllNotifications().get(notificationType)?.template === undefined) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`email channel: notification "${notificationType}" has no mail template, declare template + meta on r.notification`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
if (ctx.renderMail === undefined) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`email channel: notification "${notificationType}" has a mail template but the channel context carries no renderMail`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
const unsubscribeUrl = message.data?.["unsubscribeUrl"];
|
|
194
|
+
const { subject, html, text, attachments, fromName, replyTo } = await ctx.renderMail({
|
|
195
|
+
slug: notificationType,
|
|
196
|
+
locale: message.locale ?? "en",
|
|
197
|
+
variables: message.data ?? {},
|
|
198
|
+
...(typeof unsubscribeUrl === "string" && { unsubscribeUrl }),
|
|
199
|
+
});
|
|
200
|
+
return {
|
|
201
|
+
html,
|
|
202
|
+
subject,
|
|
203
|
+
text,
|
|
204
|
+
...(fromName !== undefined && { fromName }),
|
|
205
|
+
...(replyTo !== undefined && { replyTo }),
|
|
206
|
+
...(attachments !== undefined && { attachments }),
|
|
113
207
|
};
|
|
114
|
-
const html = await renderer.render(rendererInput);
|
|
115
|
-
const text = renderer.renderText ? await renderer.renderText(rendererInput) : undefined;
|
|
116
|
-
const subject = (variables["subject"] as string) ?? message.title; // @cast-boundary dynamic-key
|
|
117
|
-
return { html, subject, ...(text !== undefined && { text }) };
|
|
118
208
|
}
|
|
119
209
|
|
|
120
210
|
return {
|
|
121
|
-
name:
|
|
211
|
+
name: EMAIL_CHANNEL_NAME,
|
|
122
212
|
mode: "queued",
|
|
123
213
|
|
|
124
214
|
// resolveEmail typically returns the raw stored user.email, which is ciphertext under an active KMS.
|
|
@@ -129,13 +219,28 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
|
|
|
129
219
|
return address === PII_ERASED_SENTINEL ? null : address;
|
|
130
220
|
},
|
|
131
221
|
|
|
132
|
-
render(message,
|
|
133
|
-
return renderMessage(message);
|
|
222
|
+
render(message, ctx) {
|
|
223
|
+
return renderMessage(message, ctx);
|
|
134
224
|
},
|
|
135
225
|
|
|
136
|
-
async send(address, message,
|
|
137
|
-
const { html, subject, text
|
|
138
|
-
|
|
226
|
+
async send(address, message, ctx, rendered) {
|
|
227
|
+
const { html, subject, text, attachments, fromName, replyTo } =
|
|
228
|
+
rendered ?? (await renderMessage(message, ctx));
|
|
229
|
+
// Template-level sender defaults; notify data wins.
|
|
230
|
+
const envelope = {
|
|
231
|
+
...(fromName && { fromName }),
|
|
232
|
+
...(replyTo && { replyTo }),
|
|
233
|
+
...emailEnvelopeFrom(message.data),
|
|
234
|
+
};
|
|
235
|
+
const resolvedAttachments = await resolveAttachments(
|
|
236
|
+
[
|
|
237
|
+
...(attachments ?? []).map((ref): AttachmentSource => ({ ref, origin: "template" })),
|
|
238
|
+
...fileAttachmentsFrom(message.data, message.locale).map(
|
|
239
|
+
(ref): AttachmentSource => ({ ref, origin: "message" }),
|
|
240
|
+
),
|
|
241
|
+
],
|
|
242
|
+
ctx,
|
|
243
|
+
);
|
|
139
244
|
await transport.send(
|
|
140
245
|
guardEmailMessage({
|
|
141
246
|
to: address,
|
|
@@ -143,6 +248,7 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
|
|
|
143
248
|
html,
|
|
144
249
|
...(text !== undefined && { text }),
|
|
145
250
|
...envelope,
|
|
251
|
+
...(resolvedAttachments.length > 0 && { attachments: resolvedAttachments }),
|
|
146
252
|
}),
|
|
147
253
|
);
|
|
148
254
|
return { status: "sent", address };
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
DELIVERY_CHANNEL_EXTENSION,
|
|
3
|
+
EMAIL_CHANNEL_NAME,
|
|
4
|
+
} from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
2
5
|
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
6
|
import { createEmailChannel, type EmailChannelOptions } from "./email-channel.js";
|
|
4
7
|
|
|
@@ -7,7 +10,7 @@ export function createChannelEmailFeature(options: EmailChannelOptions): Feature
|
|
|
7
10
|
|
|
8
11
|
return defineFeature("channel-email", (r) => {
|
|
9
12
|
r.describe(
|
|
10
|
-
"Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport
|
|
13
|
+
"Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails.",
|
|
11
14
|
);
|
|
12
15
|
r.uiHints({
|
|
13
16
|
displayLabel: "Email Channel",
|
|
@@ -17,7 +20,7 @@ export function createChannelEmailFeature(options: EmailChannelOptions): Feature
|
|
|
17
20
|
r.requires("delivery");
|
|
18
21
|
|
|
19
22
|
r.translations({ keys: { "delivery.channel.email": { en: "Email" } } });
|
|
20
|
-
r.useExtension(DELIVERY_CHANNEL_EXTENSION,
|
|
23
|
+
r.useExtension(DELIVERY_CHANNEL_EXTENSION, EMAIL_CHANNEL_NAME, {
|
|
21
24
|
mode: channel.mode,
|
|
22
25
|
resolve: channel.resolve,
|
|
23
26
|
render: channel.render,
|
|
@@ -7,4 +7,10 @@ export {
|
|
|
7
7
|
type SmtpEnv,
|
|
8
8
|
type SmtpTransportOptions,
|
|
9
9
|
} from "./smtp-transport.js";
|
|
10
|
-
export {
|
|
10
|
+
export {
|
|
11
|
+
createInMemoryTransport,
|
|
12
|
+
type EmailAttachment,
|
|
13
|
+
type EmailAttachmentRef,
|
|
14
|
+
type EmailMessage,
|
|
15
|
+
type EmailTransport,
|
|
16
|
+
} from "./types.js";
|
|
@@ -11,6 +11,18 @@ const CIPHERTEXT_RE = /kumiko-pii:v\d+:[^"\s<>\\]*/g;
|
|
|
11
11
|
// is always refused (the address is garbage — better no mail than a leaked
|
|
12
12
|
// blob); ciphertext in subject/body fails loud in dev and is redacted+logged
|
|
13
13
|
// in prod.
|
|
14
|
+
function assertAttachmentNamesArePlain(message: EmailMessage): void {
|
|
15
|
+
for (const attachment of message.attachments ?? []) {
|
|
16
|
+
const fields = [attachment.fileName, attachment.kind === "inline" ? attachment.cid : undefined];
|
|
17
|
+
if (fields.some((field) => field?.includes(CIPHERTEXT_MARKER))) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`[channel-email] refusing to send: attachment file name is a PII ciphertext ` +
|
|
20
|
+
`("${PII_CIPHERTEXT_PREFIX}…") — decrypt the stored value before mailing (decryptStoredPii).`,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
14
26
|
export function guardEmailMessage(message: EmailMessage): EmailMessage {
|
|
15
27
|
// Every envelope address, not just `to`: a ciphertext From, From-Name or Reply-To is
|
|
16
28
|
// the same garbage as a ciphertext recipient — refuse rather than mail it.
|
|
@@ -35,6 +47,7 @@ export function guardEmailMessage(message: EmailMessage): EmailMessage {
|
|
|
35
47
|
);
|
|
36
48
|
}
|
|
37
49
|
}
|
|
50
|
+
assertAttachmentNamesArePlain(message);
|
|
38
51
|
const leaking =
|
|
39
52
|
message.subject.includes(CIPHERTEXT_MARKER) ||
|
|
40
53
|
message.html.includes(CIPHERTEXT_MARKER) ||
|