@cosmicdrift/kumiko-bundled-features 0.366.0 → 0.368.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/dist/audit/handlers/details.query.js +1 -1
- package/dist/audit/handlers/list.query.js +1 -1
- package/dist/audit/run-escape-hatch-retention.js +1 -1
- package/dist/auth-email-password/bootstrap.js +1 -1
- package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +6 -3
- package/dist/auth-email-password/handlers/invite-accept.write.js +6 -3
- package/dist/auth-email-password/handlers/invite-info.query.js +1 -1
- package/dist/auth-email-password/handlers/invite-signup-complete.write.js +6 -3
- package/dist/auth-email-password/handlers/signup-confirm.write.js +1 -1
- package/dist/auth-email-password/seeding.js +2 -2
- package/dist/auth-foundation/feature.d.ts +4 -0
- package/dist/auth-foundation/feature.js +37 -33
- package/dist/auth-foundation/index.d.ts +1 -1
- package/dist/auth-foundation/index.js +1 -1
- package/dist/auth-mfa/db/queries.js +1 -2
- package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +1 -1
- package/dist/auth-mfa/handlers/enable-start-preauth.write.js +1 -1
- package/dist/auth-mfa/handlers/reencrypt.job.js +2 -3
- package/dist/auth-mfa/handlers/status.query.js +1 -1
- package/dist/auth-mfa/handlers/verify.write.js +1 -1
- package/dist/auth-mfa/mfa-code-verifier.js +1 -1
- package/dist/auth-mfa/mfa-status-checker.js +1 -1
- package/dist/auth-mfa-user-data/index.d.ts +4 -0
- package/dist/auth-mfa-user-data/index.js +13 -9
- package/dist/billing-foundation/billing-info-query.js +1 -1
- package/dist/billing-foundation/feature.d.ts +1 -0
- package/dist/billing-foundation/feature.js +5 -6
- package/dist/billing-foundation/handlers/create-portal-session.write.js +1 -1
- package/dist/billing-foundation/projection.js +1 -2
- package/dist/cap-counter/calendar-cap-usage-reservation.d.ts +7 -0
- package/dist/cap-counter/calendar-cap-usage-reservation.js +28 -2
- package/dist/cap-counter/cap-reservation.d.ts +16 -0
- package/dist/cap-counter/cap-reservation.js +144 -14
- package/dist/cap-counter/feature.d.ts +4 -0
- package/dist/cap-counter/feature.js +74 -68
- package/dist/cap-counter/handlers/increment-rolling.write.js +3 -4
- package/dist/cap-counter/index.d.ts +3 -2
- package/dist/cap-counter/index.js +3 -2
- package/dist/cap-counter/tables.js +10 -3
- package/dist/cap-overview/handlers/caps-usage.query.js +1 -2
- package/dist/cap-overview/handlers/tenant-caps-list.query.js +1 -2
- package/dist/cap-overview/handlers/tenant-options.query.js +1 -1
- package/dist/channel-email/email-channel.d.ts +5 -1
- package/dist/channel-email/email-channel.js +8 -2
- package/dist/channel-email/feature.js +1 -1
- package/dist/channel-in-app/handlers/inbox.query.js +1 -1
- package/dist/channel-in-app/handlers/mark-all-read.write.js +1 -1
- package/dist/channel-in-app/handlers/mark-read.write.js +1 -1
- package/dist/channel-in-app/handlers/unread-count.query.js +1 -1
- package/dist/channel-in-app/tables.js +1 -2
- package/dist/compliance-profiles/handlers/for-tenant.query.js +1 -1
- package/dist/compliance-profiles/handlers/needs-profile.query.js +1 -1
- package/dist/compliance-profiles/handlers/set-profile.write.js +1 -2
- package/dist/compliance-profiles/resolve-for-tenant.js +1 -1
- package/dist/compliance-profiles/seeding.js +3 -5
- package/dist/compliance-profiles-ops/index.d.ts +8 -0
- package/dist/compliance-profiles-ops/index.js +19 -15
- package/dist/config/db/queries/resolver.js +1 -1
- package/dist/config/handlers/reencrypt.job.js +2 -2
- package/dist/config/resolver.js +1 -1
- package/dist/config/write-helpers.js +1 -1
- package/dist/crypto-shredding/handlers/forget-subject.write.js +6 -3
- package/dist/custom-fields/db/queries/field-access.js +1 -1
- package/dist/custom-fields/db/queries/projection.js +1 -1
- package/dist/custom-fields/db/queries/quota.js +1 -1
- package/dist/custom-fields/db/queries/retention.js +1 -2
- package/dist/custom-fields/db/queries/user-data-rights.js +1 -1
- package/dist/custom-fields/feature.d.ts +20 -2
- package/dist/custom-fields/feature.js +9 -13
- package/dist/custom-fields/handlers/clear-custom-field.write.js +2 -2
- package/dist/custom-fields/handlers/delete-system-field.write.js +2 -2
- package/dist/custom-fields/handlers/delete-tenant-field.write.js +2 -2
- package/dist/custom-fields/handlers/set-custom-field.write.js +2 -2
- package/dist/custom-fields/wire-for-entity.js +4 -4
- package/dist/data-retention/resolve-for-tenant.js +1 -1
- package/dist/data-retention/run-retention-cleanup.js +17 -13
- package/dist/delivery/address-opt-out.js +1 -2
- package/dist/delivery/attempt-log-retention.js +1 -1
- package/dist/delivery/channel-context.js +2 -1
- package/dist/delivery/db/queries/address-opt-outs.js +1 -1
- package/dist/delivery/db/queries/preferences.js +1 -1
- package/dist/delivery/delivery-service.d.ts +2 -0
- package/dist/delivery/delivery-service.js +46 -11
- package/dist/delivery/feature.js +2 -2
- package/dist/delivery/handlers/log.query.js +2 -3
- package/dist/delivery/handlers/preferences.query.js +1 -1
- package/dist/delivery/handlers/unsubscribe-address.write.d.ts +6 -0
- package/dist/delivery/handlers/unsubscribe-address.write.js +8 -5
- package/dist/delivery/index.d.ts +3 -0
- package/dist/delivery/index.js +2 -0
- package/dist/delivery/testing.d.ts +2 -0
- package/dist/delivery/types.d.ts +2 -1
- package/dist/delivery/unsubscribe-link.d.ts +25 -0
- package/dist/delivery/unsubscribe-link.js +40 -0
- package/dist/delivery/unsubscribe-page-strings.d.ts +11 -0
- package/dist/delivery/unsubscribe-page-strings.js +15 -0
- package/dist/delivery/unsubscribe.d.ts +3 -0
- package/dist/delivery/unsubscribe.js +65 -29
- package/dist/delivery/upsert-preference.js +1 -2
- package/dist/derivatives-sharp/feature.d.ts +4 -0
- package/dist/derivatives-sharp/feature.js +15 -11
- package/dist/derivatives-sharp/index.d.ts +1 -1
- package/dist/derivatives-sharp/index.js +1 -1
- package/dist/document-ingest-foundation/feature.d.ts +7 -0
- package/dist/document-ingest-foundation/feature.js +94 -90
- package/dist/document-ingest-foundation/forget-extract-with-file-ref.js +3 -3
- package/dist/document-ingest-foundation/index.d.ts +1 -1
- package/dist/document-ingest-foundation/index.js +1 -1
- package/dist/feature-toggles/global-feature-state-table.js +1 -2
- package/dist/feature-toggles/handlers/set.write.js +3 -5
- package/dist/feature-toggles/toggle-runtime.js +1 -1
- package/dist/file-derivatives/feature.d.ts +1 -0
- package/dist/file-derivatives/feature.js +2 -1
- package/dist/file-derivatives/handlers/public-variant-by-file-ref.query.js +1 -1
- package/dist/file-derivatives/handlers/public-variant.query.js +1 -1
- package/dist/file-foundation/feature.d.ts +6 -0
- package/dist/file-foundation/feature.js +29 -25
- package/dist/file-foundation/index.d.ts +1 -1
- package/dist/file-foundation/index.js +1 -1
- package/dist/file-foundation/read-file-ref-content.js +1 -1
- package/dist/file-provider-inmemory/feature.d.ts +4 -0
- package/dist/file-provider-inmemory/feature.js +24 -20
- package/dist/file-provider-inmemory/index.d.ts +1 -1
- package/dist/file-provider-inmemory/index.js +1 -1
- package/dist/file-provider-s3/env-feature.d.ts +3 -0
- package/dist/file-provider-s3/env-feature.js +46 -0
- package/dist/file-provider-s3/feature.d.ts +26 -0
- package/dist/file-provider-s3/feature.js +84 -72
- package/dist/file-provider-s3/index.d.ts +1 -1
- package/dist/file-provider-s3/index.js +1 -1
- package/dist/file-provider-s3/lib/index.d.ts +3 -0
- package/dist/file-provider-s3/lib/index.js +2 -0
- package/dist/{files-provider-s3 → file-provider-s3/lib}/s3-provider.js +19 -19
- package/dist/file-provider-s3-env/index.d.ts +8 -1
- package/dist/file-provider-s3-env/index.js +7 -2
- package/dist/files-provider-s3/index.d.ts +5 -3
- package/dist/files-provider-s3/index.js +5 -2
- package/dist/files-tenant-data/handlers/sweep-orphaned-derivatives.job.js +1 -1
- package/dist/files-tenant-data/hooks.js +1 -2
- package/dist/folders/feature.d.ts +6 -2
- package/dist/folders/feature.js +4 -2
- package/dist/folders-user-data/index.d.ts +4 -0
- package/dist/folders-user-data/index.js +21 -17
- package/dist/form-draft/db/queries/cleanup.js +1 -1
- package/dist/form-draft/db/queries/draft-count.js +1 -1
- package/dist/form-draft/db/queries/owned-file-refs.js +1 -2
- package/dist/form-draft/feature.d.ts +4 -0
- package/dist/form-draft/feature.js +5 -1
- package/dist/form-draft/handlers/cleanup.job.js +2 -2
- package/dist/form-draft/index.d.ts +1 -1
- package/dist/form-draft/index.js +1 -1
- package/dist/form-draft/lookup.js +1 -1
- package/dist/form-draft-user-data/index.d.ts +4 -0
- package/dist/form-draft-user-data/index.js +13 -9
- package/dist/inbound-mail-foundation/feature.d.ts +4 -0
- package/dist/inbound-mail-foundation/feature.js +151 -147
- package/dist/inbound-mail-foundation/handlers/ingest-message.write.js +1 -2
- package/dist/inbound-mail-foundation/index.d.ts +1 -1
- package/dist/inbound-mail-foundation/index.js +1 -1
- package/dist/inbound-mail-foundation/oauth-access-token.js +1 -2
- package/dist/inbound-mail-foundation/projection.js +1 -2
- package/dist/inbound-mail-foundation/watch-supervisor.js +1 -1
- package/dist/inbound-provider-imap/feature.d.ts +4 -0
- package/dist/inbound-provider-imap/feature.js +16 -12
- package/dist/inbound-provider-imap/index.d.ts +1 -1
- package/dist/inbound-provider-imap/index.js +1 -1
- package/dist/inbound-provider-inmemory/feature.d.ts +4 -0
- package/dist/inbound-provider-inmemory/feature.js +14 -10
- package/dist/inbound-provider-inmemory/index.d.ts +1 -1
- package/dist/inbound-provider-inmemory/index.js +1 -1
- package/dist/jobs/db/queries/retention.js +1 -1
- package/dist/jobs/db/queries/stale-run-sweep.js +1 -1
- package/dist/jobs/handlers/detail.query.js +1 -1
- package/dist/jobs/handlers/list.query.js +1 -2
- package/dist/jobs/handlers/retry.write.js +1 -1
- package/dist/jobs/handlers/tenant-failures.query.js +1 -1
- package/dist/jobs/handlers/tenant-runs.query.js +1 -1
- package/dist/jobs/job-run-logger.js +1 -2
- package/dist/jobs/tenant-job-failure-table.js +1 -2
- package/dist/jobs/tenant-job-run-table.js +1 -2
- package/dist/ledger/feature.d.ts +6 -2
- package/dist/ledger/feature.js +4 -2
- package/dist/mail-foundation/feature.d.ts +23 -1
- package/dist/mail-foundation/feature.js +76 -49
- package/dist/mail-foundation/index.d.ts +1 -1
- package/dist/mail-foundation/index.js +1 -1
- package/dist/mail-transport-inmemory/feature.d.ts +4 -0
- package/dist/mail-transport-inmemory/feature.js +20 -16
- package/dist/mail-transport-inmemory/index.d.ts +1 -1
- package/dist/mail-transport-inmemory/index.js +1 -1
- package/dist/mail-transport-smtp/feature.d.ts +16 -0
- package/dist/mail-transport-smtp/feature.js +85 -79
- package/dist/mail-transport-smtp/index.d.ts +1 -1
- package/dist/mail-transport-smtp/index.js +1 -1
- package/dist/managed-pages/handlers/by-slug.query.js +1 -1
- package/dist/managed-pages/handlers/by-tenant-published.query.js +1 -2
- package/dist/managed-pages/handlers/set.write.js +1 -2
- package/dist/managed-pages/seeding.js +3 -5
- package/dist/metrics/compute.d.ts +1 -1
- package/dist/metrics/filters.d.ts +1 -1
- package/dist/metrics/filters.js +1 -1
- package/dist/metrics/validate.js +1 -1
- package/dist/notes-history/feature.d.ts +7 -2
- package/dist/notes-history/feature.js +4 -2
- package/dist/notes-history/handlers/add-note.write.js +1 -1
- package/dist/notes-history-user-data/hooks.js +1 -1
- package/dist/notes-history-user-data/index.d.ts +4 -0
- package/dist/notes-history-user-data/index.js +21 -17
- package/dist/personal-access-tokens/feature.d.ts +4 -1
- package/dist/personal-access-tokens/feature.js +10 -2
- package/dist/personal-access-tokens/handlers/create.write.js +1 -1
- package/dist/personal-access-tokens/handlers/list.query.js +2 -2
- package/dist/personal-access-tokens/handlers/revoke.write.js +1 -1
- package/dist/personal-access-tokens/resolver.js +1 -1
- package/dist/personal-access-tokens/revoke-for-user.js +1 -1
- package/dist/personal-access-tokens/roles.js +3 -5
- package/dist/personal-access-tokens/screens.js +2 -0
- package/dist/readiness/feature.d.ts +8 -0
- package/dist/readiness/feature.js +18 -14
- package/dist/readiness/index.d.ts +1 -1
- package/dist/readiness/index.js +1 -1
- package/dist/secrets/db/queries/read.js +1 -1
- package/dist/secrets/feature.d.ts +3 -0
- package/dist/secrets/feature.js +3 -2
- package/dist/secrets/handlers/rotate.job.js +2 -3
- package/dist/secrets/secrets-context.js +3 -4
- package/dist/sessions/db/queries/cleanup.js +1 -1
- package/dist/sessions/handlers/detail.query.js +1 -1
- package/dist/sessions/handlers/list.query.js +2 -2
- package/dist/sessions/handlers/mine.query.js +1 -1
- package/dist/sessions/handlers/revoke-all-for-user.write.js +1 -1
- package/dist/sessions/handlers/revoke-all-others.write.js +1 -1
- package/dist/sessions/handlers/revoke.write.js +1 -1
- package/dist/sessions/session-callbacks.js +4 -6
- package/dist/sessions/testing.d.ts +1 -1
- package/dist/tags/feature.d.ts +6 -2
- package/dist/tags/feature.js +4 -2
- package/dist/template-resolver/api.js +1 -1
- package/dist/template-resolver/compile-document-body.js +47 -11
- package/dist/template-resolver/document-sanitizer.d.ts +2 -0
- package/dist/template-resolver/document-sanitizer.js +16 -1
- package/dist/template-resolver/document-schema.d.ts +2 -0
- package/dist/template-resolver/document-schema.js +31 -0
- package/dist/template-resolver/handlers/bundle-import.js +4 -3
- package/dist/template-resolver/handlers/by-slug.query.js +1 -1
- package/dist/template-resolver/handlers/by-tenant.query.js +1 -2
- package/dist/template-resolver/handlers/collection-item.query.js +1 -1
- package/dist/template-resolver/handlers/collection-list.query.js +1 -2
- package/dist/template-resolver/handlers/collection-set.write.js +1 -1
- package/dist/template-resolver/handlers/copy-locale.write.js +4 -3
- package/dist/template-resolver/handlers/diff-to-system.query.js +1 -1
- package/dist/template-resolver/handlers/export-bundle.query.js +1 -1
- package/dist/template-resolver/handlers/find-by-id.query.js +6 -2
- package/dist/template-resolver/handlers/layout-source.query.js +1 -1
- package/dist/template-resolver/handlers/list.query.js +4 -2
- package/dist/template-resolver/handlers/overview.query.js +5 -3
- package/dist/template-resolver/handlers/reset-to-system.write.js +1 -1
- package/dist/template-resolver/handlers/resource-remove.write.js +1 -1
- package/dist/template-resolver/handlers/set.write.js +1 -1
- package/dist/template-resolver/handlers/template-write.d.ts +2 -1
- package/dist/template-resolver/handlers/template-write.js +13 -2
- package/dist/template-resolver/handlers/tenant-overrides.query.js +1 -1
- package/dist/template-resolver/handlers/toggle-status.write.js +1 -1
- package/dist/template-resolver/handlers/usage.query.js +1 -1
- package/dist/template-resolver/handlers/validate.query.js +1 -1
- package/dist/template-resolver/index.d.ts +3 -2
- package/dist/template-resolver/index.js +3 -2
- package/dist/template-resolver/qualified-names.d.ts +1 -0
- package/dist/template-resolver/qualified-names.js +1 -0
- package/dist/template-resolver/seeding.d.ts +4 -0
- package/dist/template-resolver/seeding.js +43 -9
- package/dist/template-resolver/store-template-resource.js +1 -1
- package/dist/template-resolver/template-preview.js +3 -1
- package/dist/template-resolver/template-resources.js +1 -1
- package/dist/template-resolver/template-system-only.d.ts +8 -0
- package/dist/template-resolver/template-system-only.js +11 -0
- package/dist/template-resolver/template-validation.js +42 -28
- package/dist/template-resolver/testing.js +1 -1
- package/dist/template-resolver/web/i18n.js +1 -0
- package/dist/template-resolver-user-data/index.d.ts +4 -0
- package/dist/template-resolver-user-data/index.js +17 -13
- package/dist/tenant/handlers/active-tenant-ids.query.js +1 -1
- package/dist/tenant/handlers/add-member.write.js +1 -2
- package/dist/tenant/handlers/cancel-invitation.write.js +1 -1
- package/dist/tenant/handlers/invitations.query.js +1 -1
- package/dist/tenant/handlers/me.query.js +1 -1
- package/dist/tenant/handlers/member-directory.query.js +1 -1
- package/dist/tenant/handlers/members.query.js +1 -1
- package/dist/tenant/handlers/memberships.query.js +1 -1
- package/dist/tenant/handlers/remove-member.write.js +1 -2
- package/dist/tenant/handlers/resolve-user-ids.query.js +1 -1
- package/dist/tenant/handlers/team-list.query.js +1 -1
- package/dist/tenant/handlers/tenant-directory.query.js +1 -1
- package/dist/tenant/handlers/update-member-roles.write.js +1 -2
- package/dist/tenant/invited-membership.js +2 -3
- package/dist/tenant/last-tenant-admin.js +1 -2
- package/dist/tenant/seeding.js +12 -6
- package/dist/tenant-lifecycle/handlers/cancel-destruction.write.js +1 -2
- package/dist/tenant-lifecycle/handlers/request-destruction.write.js +1 -2
- package/dist/tenant-lifecycle/lib/revoke-tenant-sessions.js +1 -1
- package/dist/tenant-lifecycle/lifecycle-gate.js +1 -1
- package/dist/tenant-lifecycle/run-tenant-destroy.js +3 -5
- package/dist/tenant-lifecycle/stages.js +10 -6
- package/dist/tenant-lifecycle/testing.d.ts +1 -1
- package/dist/tenant-lifecycle/testing.js +2 -2
- package/dist/tenant-settings/feature.d.ts +1 -0
- package/dist/tenant-settings/feature.js +2 -1
- package/dist/tier-engine/feature.d.ts +1 -8
- package/dist/tier-engine/feature.js +71 -78
- package/dist/tier-engine/handlers/get-tenant-tier.query.js +2 -3
- package/dist/tier-engine/handlers/set-tenant-tier.write.js +1 -2
- package/dist/user/db/queries/stream-tenant-backfill.js +1 -1
- package/dist/user/handlers/create.write.js +1 -2
- package/dist/user/handlers/find-for-auth.query.js +1 -1
- package/dist/user/handlers/list.query.js +1 -1
- package/dist/user/handlers/update-roles.js +1 -2
- package/dist/user/index.d.ts +1 -1
- package/dist/user/index.js +1 -1
- package/dist/user/invited-global-roles.js +2 -3
- package/dist/user/principal-status.d.ts +4 -0
- package/dist/user/principal-status.js +9 -3
- package/dist/user/resolve-display-names.js +1 -1
- package/dist/user/seeding.js +5 -6
- package/dist/user-data-rights/audit-download.js +2 -2
- package/dist/user-data-rights/db/queries/export-jobs.js +1 -1
- package/dist/user-data-rights/db/queries/forget-cleanup.js +1 -1
- package/dist/user-data-rights/handlers/download-by-job.query.js +1 -1
- package/dist/user-data-rights/handlers/download-by-token.query.js +1 -1
- package/dist/user-data-rights/handlers/export-status.query.js +1 -1
- package/dist/user-data-rights/handlers/list-download-attempts.query.js +1 -1
- package/dist/user-data-rights/handlers/my-audit-log.query.js +1 -1
- package/dist/user-data-rights/handlers/request-export.write.js +1 -2
- package/dist/user-data-rights/handlers/run-forget-cleanup.write.js +3 -3
- package/dist/user-data-rights/lib/deny-if-target-outside-admin-tenant.d.ts +1 -1
- package/dist/user-data-rights/lib/deny-if-target-outside-admin-tenant.js +1 -1
- package/dist/user-data-rights/lib/update-user-lifecycle.d.ts +1 -0
- package/dist/user-data-rights/lib/update-user-lifecycle.js +3 -3
- package/dist/user-data-rights/run-export-jobs.js +2 -3
- package/dist/user-data-rights/run-forget-cleanup.js +9 -5
- package/dist/user-data-rights/run-user-export.js +9 -5
- package/dist/waitlist/handlers/invite.write.js +1 -2
- package/dist/waitlist/handlers/reject.write.js +1 -1
- package/dist/waitlist/lib.js +1 -2
- package/dist/workflow-runner/event-subscriber.js +1 -1
- package/dist/workflow-runner/feature.d.ts +4 -0
- package/dist/workflow-runner/feature.js +44 -40
- package/dist/workflow-runner/handlers/resume-run.write.js +1 -1
- package/dist/workflow-runner/index.d.ts +1 -1
- package/dist/workflow-runner/index.js +1 -1
- package/dist/workflow-runner/pending-projection.js +1 -1
- package/package.json +28 -11
- package/src/cap-counter/changes.json +13 -0
- package/src/channel-email/changes.json +6 -0
- package/src/crypto-shredding/changes.json +6 -0
- package/src/delivery/changes.json +12 -0
- package/src/file-provider-s3/changes.json +10 -1
- package/src/personal-access-tokens/changes.json +6 -0
- package/src/template-resolver/changes.json +25 -0
- package/src/user/changes.json +6 -0
- package/src/user-data-rights/changes.json +6 -0
- package/dist/file-provider-s3-env/feature.d.ts +0 -3
- package/dist/file-provider-s3-env/feature.js +0 -46
- /package/dist/{files-provider-s3 → file-provider-s3/lib}/env-helper.d.ts +0 -0
- /package/dist/{files-provider-s3 → file-provider-s3/lib}/env-helper.js +0 -0
- /package/dist/{files-provider-s3 → file-provider-s3/lib}/s3-provider.d.ts +0 -0
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import { createSystemDbView, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
-
import { assertNotificationVariables, createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { assertNotificationVariables, bridgeStub, createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
4
4
|
import { assertNotifyTargetsOwnTenant, createEscapeHatchReporter, } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
5
|
-
import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing/handler-context";
|
|
6
5
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
7
6
|
import { fileContextFromResolver } from "../file-foundation/index.js";
|
|
8
7
|
import { redactErrorText } from "../shared/index.js";
|
|
9
8
|
import { hashUnsubscribeAddress } from "./address-opt-out.js";
|
|
10
9
|
import { appendAttemptEvent, logAttempt } from "./attempt-log.js";
|
|
11
10
|
import { buildChannelContext } from "./channel-context.js";
|
|
12
|
-
import { DELIVERY_CHANNEL_EXTENSION, DeliveryJobs, deliveryIdempotencyRedisKey, deliveryPriorityRank, } from "./constants.js";
|
|
11
|
+
import { DELIVERY_CHANNEL_EXTENSION, DeliveryJobs, deliveryIdempotencyRedisKey, deliveryPriorityRank, EMAIL_CHANNEL_NAME, } from "./constants.js";
|
|
13
12
|
import { addressOptOutKey, selectOptedOutAddresses, } from "./db/queries/address-opt-outs.js";
|
|
14
13
|
import { selectNotificationPreferences } from "./db/queries/preferences.js";
|
|
15
14
|
import { notificationTypeNames } from "./notification-type-aliases.js";
|
|
16
15
|
import { isDeliveryChannelPlugin, } from "./types.js";
|
|
16
|
+
import { buildUnsubscribeUrl, unsubscribeLinkOrigin, } from "./unsubscribe-link.js";
|
|
17
17
|
export function redactedMessageOf(err) {
|
|
18
18
|
return redactErrorText(err instanceof Error ? err.message : String(err));
|
|
19
19
|
}
|
|
@@ -28,7 +28,7 @@ export function collectChannels(registry) {
|
|
|
28
28
|
});
|
|
29
29
|
}
|
|
30
30
|
export function createDeliveryService(options) {
|
|
31
|
-
const { db, registry, sseBroker, channels, tenantUserIdsQuery, rateLimit, isChannelKilled, idempotencyRedis, jobRunner, secrets, filesForTenant, tenantConfigFor, escapeHatchAuditSink, log, } = options;
|
|
31
|
+
const { db, registry, sseBroker, channels, tenantUserIdsQuery, rateLimit, isChannelKilled, idempotencyRedis, jobRunner, secrets, filesForTenant, tenantConfigFor, escapeHatchAuditSink, log, unsubscribeLinks, } = options;
|
|
32
32
|
const idemRedis = idempotencyRedis ?? rateLimit?.redis;
|
|
33
33
|
const logError = createFallbackLogger("delivery", log);
|
|
34
34
|
// Rate limit check: atomic INCR + TTL + over-limit rollback via server-side
|
|
@@ -124,17 +124,27 @@ export function createDeliveryService(options) {
|
|
|
124
124
|
// path a cross-tenant read the dispatcher never allows. Cross-tenant reach only
|
|
125
125
|
// exists through the grant-carrying systemDb view below.
|
|
126
126
|
const isSystem = registry.isHandlerSystemScoped(tenantUserIdsQuery);
|
|
127
|
-
const tenantDb = createTenantDb(
|
|
128
|
-
|
|
127
|
+
const tenantDb = createTenantDb({
|
|
128
|
+
db: run.db,
|
|
129
|
+
tenantId,
|
|
130
|
+
mode: "tenant",
|
|
131
|
+
grants: {
|
|
132
|
+
report,
|
|
133
|
+
},
|
|
129
134
|
});
|
|
130
135
|
// The dispatcher grants a systemScope handler's ctx.systemDb.unsafeRaw() ungated —
|
|
131
136
|
// the grant comes from the handler's own r.systemScope() registration, not from this
|
|
132
137
|
// caller. Only the systemDb view's own source TenantDb carries the grant, so it
|
|
133
138
|
// can't leak onto ctx.db.
|
|
134
139
|
const systemDbSourceTenantDb = isSystem
|
|
135
|
-
? createTenantDb(
|
|
136
|
-
|
|
137
|
-
|
|
140
|
+
? createTenantDb({
|
|
141
|
+
db: run.db,
|
|
142
|
+
tenantId,
|
|
143
|
+
mode: "system",
|
|
144
|
+
grants: {
|
|
145
|
+
report,
|
|
146
|
+
unsafeRaw: { reason: `r.systemScope() handler "${tenantUserIdsQuery}"` },
|
|
147
|
+
},
|
|
138
148
|
})
|
|
139
149
|
: undefined;
|
|
140
150
|
// Hand-built context, not routed through the dispatcher — this needs both
|
|
@@ -255,6 +265,31 @@ export function createDeliveryService(options) {
|
|
|
255
265
|
});
|
|
256
266
|
}
|
|
257
267
|
}
|
|
268
|
+
// A caller-supplied unsubscribeUrl wins; the recipient comes from the server-side notify
|
|
269
|
+
// context, never from the notification data.
|
|
270
|
+
async function withUnsubscribeUrl(message, channelName, recipient, channelCtx) {
|
|
271
|
+
if (!unsubscribeLinks || channelName !== EMAIL_CHANNEL_NAME)
|
|
272
|
+
return message;
|
|
273
|
+
if (registry.getAllNotifications().get(message.notificationType)?.unsubscribe !== true) {
|
|
274
|
+
return message;
|
|
275
|
+
}
|
|
276
|
+
const supplied = message.data?.["unsubscribeUrl"];
|
|
277
|
+
if (typeof supplied === "string" && supplied !== "")
|
|
278
|
+
return message;
|
|
279
|
+
const origin = await unsubscribeLinkOrigin(unsubscribeLinks, channelCtx, (reason) => logError.warn(`unsubscribe link falls back to the app origin: ${reason}`, {
|
|
280
|
+
tenantId: channelCtx.tenantId,
|
|
281
|
+
notificationType: message.notificationType,
|
|
282
|
+
}));
|
|
283
|
+
const unsubscribeUrl = await buildUnsubscribeUrl(unsubscribeLinks, origin, recipient, {
|
|
284
|
+
tenantId: channelCtx.tenantId,
|
|
285
|
+
notificationType: message.notificationType,
|
|
286
|
+
channel: channelName,
|
|
287
|
+
locale: message.locale,
|
|
288
|
+
});
|
|
289
|
+
return unsubscribeUrl === undefined
|
|
290
|
+
? message
|
|
291
|
+
: { ...message, data: { ...message.data, unsubscribeUrl } };
|
|
292
|
+
}
|
|
258
293
|
function buildMessage(notificationType, data, channelName, locale) {
|
|
259
294
|
// Look up per-channel template from notification definition
|
|
260
295
|
const notifDef = registry.getAllNotifications().get(notificationType);
|
|
@@ -435,7 +470,7 @@ export function createDeliveryService(options) {
|
|
|
435
470
|
run,
|
|
436
471
|
channel,
|
|
437
472
|
address,
|
|
438
|
-
message,
|
|
473
|
+
message: await withUnsubscribeUrl(message, channel.name, { kind: "user", userId }, channelCtx),
|
|
439
474
|
channelCtx,
|
|
440
475
|
tenantId,
|
|
441
476
|
recipientId: userId,
|
|
@@ -508,7 +543,7 @@ export function createDeliveryService(options) {
|
|
|
508
543
|
run,
|
|
509
544
|
channel,
|
|
510
545
|
address,
|
|
511
|
-
message,
|
|
546
|
+
message: await withUnsubscribeUrl(message, channel.name, { kind: "address", address }, channelCtx),
|
|
512
547
|
channelCtx,
|
|
513
548
|
tenantId,
|
|
514
549
|
recipientId,
|
package/dist/delivery/feature.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { updateMany, upsertByPk } from "@cosmicdrift/kumiko-framework/
|
|
1
|
+
import { updateMany, upsertByPk } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { access, defineFeature, i18nKey, } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { ConsumerLagError } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
4
4
|
import { resolveAttemptLogRetentionDays, runAttemptLogRetention } from "./attempt-log-retention.js";
|
|
@@ -19,7 +19,7 @@ export function createDeliveryFeature(options) {
|
|
|
19
19
|
const resolvedAccess = options?.access ?? access.admin;
|
|
20
20
|
const attemptLogRetentionDays = resolveAttemptLogRetentionDays(options?.attemptLogRetentionDays);
|
|
21
21
|
return defineFeature("delivery", (r) => {
|
|
22
|
-
r.describe(
|
|
22
|
+
r.describe("The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). Inside a write handler the delivery runs after the write has committed (the call returns `{ kind: \"deferred\" }`, a rolled-back write sends nothing); `immediate: true` delivers synchronously and returns `{ kind: \"dispatched\", deliveries }`. It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting \u2014 add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route. A notification declared with `unsubscribe: true` gets that link from delivery itself when the service has `unsubscribeLinks` (`runProdApp` option `unsubscribe: { secret }` mounts the routes and wires it; base URL from `auth.publicOrigin`, `auth.mail.baseUrl` or `APP_BASE_URL`): user recipients get a user token, `route` recipients an address token (needs a blind-index key, otherwise no link); a `data.unsubscribeUrl` from the caller wins. `unsubscribe.tenantOrigin: (ctx) => origin | null` points the link at the tenant's own host (subdomain or custom domain, must serve this app's `/api`); null, a throw or a non-https value fall back to the app origin. The token carries the mail's locale, so the confirmation and result pages render in the mail's language. To react to a no-account opt-out (e.g. remove the app's own subscriber row), declare `r.job(name, { trigger: { on: DeliveryHandlers.unsubscribeAddress } }, handler)` on a lane the app consumes: it runs after each committed opt-out, also an idempotent repeat, with `unsubscribeAddressPayloadSchema` (`addressHash`, `notificationType`, `channel`) as payload and the tenant in `ctx.triggeredBy.tenantId`; match the subscriber via `hashUnsubscribeAddress(email)`. Declare `unsubscribeUrl` as an optional template variable if the mail body shows it.");
|
|
23
23
|
r.uiHints({
|
|
24
24
|
displayLabel: "Notifications \u00b7 Dispatch Core",
|
|
25
25
|
category: "notifications",
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/
|
|
2
|
-
import { decodeKeysetCursor, encodeKeysetCursor, } from "@cosmicdrift/kumiko-framework/db";
|
|
1
|
+
import { decodeKeysetCursor, encodeKeysetCursor, selectMany, } from "@cosmicdrift/kumiko-framework/db";
|
|
3
2
|
import { access, definePagedQueryHandler, isDeliveryErrorCode, isUuid, MAX_LIST_LIMIT, SYSTEM_USER_ID, } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
3
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
5
4
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
@@ -30,7 +29,7 @@ function isDeliveryLogSortField(value) {
|
|
|
30
29
|
return value in DELIVERY_LOG_SORT_COLUMNS;
|
|
31
30
|
}
|
|
32
31
|
// createdAt round-trips through Temporal.Instant (matches how the WHERE
|
|
33
|
-
// builder coerces timestamptz values — see
|
|
32
|
+
// builder coerces timestamptz values — see db/query.ts prepareValue);
|
|
34
33
|
// every other whitelisted column is a plain text column. Keyed by the
|
|
35
34
|
// PHYSICAL column, not the display alias — row only has the DB field names
|
|
36
35
|
// (e.g. notificationType), never the alias (e.g. "type").
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
4
|
import * as z from "zod";
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
|
+
export declare const unsubscribeAddressPayloadSchema: z.ZodObject<{
|
|
3
|
+
addressHash: z.ZodString;
|
|
4
|
+
notificationType: z.ZodString;
|
|
5
|
+
channel: z.ZodString;
|
|
6
|
+
}, z.core.$strip>;
|
|
7
|
+
export type UnsubscribeAddressPayload = z.infer<typeof unsubscribeAddressPayloadSchema>;
|
|
2
8
|
export declare const unsubscribeAddressWrite: import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<"unsubscribeAddress", z.ZodObject<{
|
|
3
9
|
addressHash: z.ZodString;
|
|
4
10
|
notificationType: z.ZodString;
|
|
@@ -3,13 +3,16 @@ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
|
3
3
|
import * as z from "zod";
|
|
4
4
|
import { upsertAddressOptOut } from "../address-opt-out.js";
|
|
5
5
|
import { currentNotificationType } from "../notification-type-aliases.js";
|
|
6
|
+
// Also the payload an app job receives with `trigger: { on: DeliveryHandlers.unsubscribeAddress }`
|
|
7
|
+
// after a committed address opt-out (the tenant is the job's `ctx.triggeredBy.tenantId`).
|
|
8
|
+
export const unsubscribeAddressPayloadSchema = z.object({
|
|
9
|
+
addressHash: z.string().min(1),
|
|
10
|
+
notificationType: z.string().min(1),
|
|
11
|
+
channel: z.string().min(1),
|
|
12
|
+
});
|
|
6
13
|
export const unsubscribeAddressWrite = defineWriteHandler({
|
|
7
14
|
name: "unsubscribeAddress",
|
|
8
|
-
schema:
|
|
9
|
-
addressHash: z.string().min(1),
|
|
10
|
-
notificationType: z.string().min(1),
|
|
11
|
-
channel: z.string().min(1),
|
|
12
|
-
}),
|
|
15
|
+
schema: unsubscribeAddressPayloadSchema,
|
|
13
16
|
access: { roles: access.systemAdmin },
|
|
14
17
|
// Only reachable via createUnsubscribeRoutes' POST dispatchSystemWrite, once
|
|
15
18
|
// verify() has already proven the address-token signature — not a surface
|
package/dist/delivery/index.d.ts
CHANGED
|
@@ -9,8 +9,11 @@ export type { DeliveryStatusValue } from "./constants.js";
|
|
|
9
9
|
export { DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT, DELIVERY_CHANNEL_EXTENSION, DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, DeliveryJobs, DeliveryQueries, DeliveryStatus, EMAIL_CHANNEL_NAME, } from "./constants.js";
|
|
10
10
|
export { collectChannels, createDeliveryService, type DeliveryServiceOptions, type KillSwitchResolver, type RateLimitConfig, } from "./delivery-service.js";
|
|
11
11
|
export { createDeliveryFeature, type DeliveryFeatureOptions } from "./feature.js";
|
|
12
|
+
export { type UnsubscribeAddressPayload, unsubscribeAddressPayloadSchema, } from "./handlers/unsubscribe-address.write.js";
|
|
12
13
|
export { createDeliveryNotifyFactory } from "./notify-factory.js";
|
|
13
14
|
export { deliveryAttemptsTable, notificationAddressOptOutEntity, notificationAddressOptOutsTable, notificationPreferenceEntity, notificationPreferencesTable, } from "./tables.js";
|
|
14
15
|
export { type CreateDeliveryTestContextOptions, createDeliveryTestContext } from "./testing.js";
|
|
15
16
|
export { type AttachmentOrigin, type ChannelContext, type ChannelMessage, type ChannelResult, type DeliveryChannel, type DeliveryChannelMode, type DeliveryChannelPlugin, type DeliveryLogEntry, type DeliveryService, isDeliveryChannelPlugin, type RenderedMessage, } from "./types.js";
|
|
16
17
|
export { type AddressUnsubscribeTokenPayload, createUnsubscribeRoutes, DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE, signAddressUnsubscribeToken, signUnsubscribeToken, type UnsubscribeRouteOptions, type UnsubscribeTokenPayload, } from "./unsubscribe.js";
|
|
18
|
+
export type { UnsubscribeLinkConfig } from "./unsubscribe-link.js";
|
|
19
|
+
export { UNSUBSCRIBE_PAGE_EN } from "./unsubscribe-page-strings.js";
|
package/dist/delivery/index.js
CHANGED
|
@@ -8,8 +8,10 @@ export { chatConnectionNameSchema, chatWebhookUrlSchema, checkChatWebhookTarget,
|
|
|
8
8
|
export { DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT, DELIVERY_CHANNEL_EXTENSION, DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, DeliveryJobs, DeliveryQueries, DeliveryStatus, EMAIL_CHANNEL_NAME, } from "./constants.js";
|
|
9
9
|
export { collectChannels, createDeliveryService, } from "./delivery-service.js";
|
|
10
10
|
export { createDeliveryFeature } from "./feature.js";
|
|
11
|
+
export { unsubscribeAddressPayloadSchema, } from "./handlers/unsubscribe-address.write.js";
|
|
11
12
|
export { createDeliveryNotifyFactory } from "./notify-factory.js";
|
|
12
13
|
export { deliveryAttemptsTable, notificationAddressOptOutEntity, notificationAddressOptOutsTable, notificationPreferenceEntity, notificationPreferencesTable, } from "./tables.js";
|
|
13
14
|
export { createDeliveryTestContext } from "./testing.js";
|
|
14
15
|
export { isDeliveryChannelPlugin, } from "./types.js";
|
|
15
16
|
export { createUnsubscribeRoutes, DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE, signAddressUnsubscribeToken, signUnsubscribeToken, } from "./unsubscribe.js";
|
|
17
|
+
export { UNSUBSCRIBE_PAGE_EN } from "./unsubscribe-page-strings.js";
|
|
@@ -8,6 +8,7 @@ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
|
8
8
|
import type { Redis } from "ioredis";
|
|
9
9
|
import type { KillSwitchResolver, RateLimitConfig } from "./delivery-service.js";
|
|
10
10
|
import type { DeliveryService } from "./types.js";
|
|
11
|
+
import type { UnsubscribeLinkConfig } from "./unsubscribe-link.js";
|
|
11
12
|
export type CreateDeliveryTestContextOptions = {
|
|
12
13
|
readonly tenantUserIdsQuery?: string;
|
|
13
14
|
readonly rateLimit?: RateLimitConfig;
|
|
@@ -19,6 +20,7 @@ export type CreateDeliveryTestContextOptions = {
|
|
|
19
20
|
readonly idempotencyRedis?: Redis;
|
|
20
21
|
readonly filesForTenant?: (tenantId: string) => FileContext | undefined;
|
|
21
22
|
readonly tenantConfigFor?: (tenantId: TenantId) => ConfigAccessor;
|
|
23
|
+
readonly unsubscribeLinks?: UnsubscribeLinkConfig;
|
|
22
24
|
};
|
|
23
25
|
/**
|
|
24
26
|
* Helper for setupTestStack: creates a DeliveryService + _notifyFactory for the test context.
|
package/dist/delivery/types.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
import type { DbConnection, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
-
import type { DeliveryErrorCode, NotifyDeliveryStatus, NotifyDispatched, NotifyJobDispatcher, NotifyOptions, NotifyPriority, Registry, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import type { ConfigAccessor, DeliveryErrorCode, NotifyDeliveryStatus, NotifyDispatched, NotifyJobDispatcher, NotifyOptions, NotifyPriority, Registry, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
import type { FileProviderResolver } from "@cosmicdrift/kumiko-framework/files";
|
|
5
5
|
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
6
6
|
import type { MailPayload, RenderedTemplateRef } from "../renderer-foundation/index.js";
|
|
@@ -23,6 +23,7 @@ export type ChannelContext = {
|
|
|
23
23
|
readonly sseBroker: SseBroker | undefined;
|
|
24
24
|
readonly tenantId: TenantId;
|
|
25
25
|
readonly secrets?: SecretsContext | undefined;
|
|
26
|
+
readonly config?: ConfigAccessor | undefined;
|
|
26
27
|
readonly renderMail?: (payload: MailPayload) => Promise<RenderedMail>;
|
|
27
28
|
readonly readAttachment?: (ref: EmailAttachmentRef, origin?: AttachmentOrigin) => Promise<{
|
|
28
29
|
readonly content: Uint8Array;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import type { ChannelContext } from "./types.js";
|
|
3
|
+
/** Secret shared with `createUnsubscribeRoutes` plus the public origin the links point at. */
|
|
4
|
+
export type UnsubscribeLinkConfig = {
|
|
5
|
+
readonly secret: string;
|
|
6
|
+
readonly baseUrl: string;
|
|
7
|
+
/** The sending tenant's own public origin (subdomain or custom domain), null when it has
|
|
8
|
+
* none. The host must serve this app's `/api` routes. Null, a throw or an unusable value
|
|
9
|
+
* fall back to `baseUrl`, so a missing tenant origin never fails a send. */
|
|
10
|
+
readonly tenantOrigin?: (ctx: ChannelContext) => Promise<string | null | undefined>;
|
|
11
|
+
};
|
|
12
|
+
export type UnsubscribeLinkRecipient = {
|
|
13
|
+
readonly kind: "user";
|
|
14
|
+
readonly userId: string;
|
|
15
|
+
} | {
|
|
16
|
+
readonly kind: "address";
|
|
17
|
+
readonly address: string;
|
|
18
|
+
};
|
|
19
|
+
export declare function unsubscribeLinkOrigin(config: UnsubscribeLinkConfig, ctx: ChannelContext, onFallback: (reason: string) => void): Promise<string>;
|
|
20
|
+
export declare function buildUnsubscribeUrl(config: UnsubscribeLinkConfig, origin: string, recipient: UnsubscribeLinkRecipient, scope: {
|
|
21
|
+
tenantId: TenantId;
|
|
22
|
+
notificationType: string;
|
|
23
|
+
channel: string;
|
|
24
|
+
locale: string | undefined;
|
|
25
|
+
}): Promise<string | undefined>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { publicOriginOf } from "@cosmicdrift/kumiko-framework/api";
|
|
2
|
+
import { hashUnsubscribeAddress } from "./address-opt-out.js";
|
|
3
|
+
import { DELIVERY_UNSUBSCRIBE_PATH } from "./constants.js";
|
|
4
|
+
import { signAddressUnsubscribeToken, signUnsubscribeToken } from "./unsubscribe.js";
|
|
5
|
+
// https only, unless the app itself runs on plain http (local dev): a tenant origin must not
|
|
6
|
+
// downgrade the link that carries the token.
|
|
7
|
+
function isUsableTenantOrigin(origin, baseUrl) {
|
|
8
|
+
return (origin.startsWith("https://") || (baseUrl.startsWith("http://") && origin.startsWith("http://")));
|
|
9
|
+
}
|
|
10
|
+
export async function unsubscribeLinkOrigin(config, ctx, onFallback) {
|
|
11
|
+
if (config.tenantOrigin === undefined)
|
|
12
|
+
return config.baseUrl;
|
|
13
|
+
let resolved;
|
|
14
|
+
try {
|
|
15
|
+
resolved = await config.tenantOrigin(ctx);
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
onFallback(`tenantOrigin threw (${err instanceof Error ? err.name : typeof err})`);
|
|
19
|
+
return config.baseUrl;
|
|
20
|
+
}
|
|
21
|
+
if (resolved === null || resolved === undefined)
|
|
22
|
+
return config.baseUrl;
|
|
23
|
+
const origin = publicOriginOf(resolved);
|
|
24
|
+
if (origin === undefined || !isUsableTenantOrigin(origin, config.baseUrl)) {
|
|
25
|
+
onFallback("tenantOrigin returned no usable https origin");
|
|
26
|
+
return config.baseUrl;
|
|
27
|
+
}
|
|
28
|
+
return origin;
|
|
29
|
+
}
|
|
30
|
+
// Undefined means "no link": without a blind-index key an address opt-out could never be
|
|
31
|
+
// matched again, and a mail without a link is better than a failed send.
|
|
32
|
+
export async function buildUnsubscribeUrl(config, origin, recipient, scope) {
|
|
33
|
+
if (recipient.kind === "address" && hashUnsubscribeAddress(recipient.address) === undefined) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
const token = recipient.kind === "user"
|
|
37
|
+
? await signUnsubscribeToken({ ...scope, userId: recipient.userId }, config.secret)
|
|
38
|
+
: await signAddressUnsubscribeToken({ ...scope, address: recipient.address }, config.secret);
|
|
39
|
+
return `${origin}${DELIVERY_UNSUBSCRIBE_PATH}?token=${encodeURIComponent(token)}`;
|
|
40
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const UNSUBSCRIBE_PAGE_EN: {
|
|
2
|
+
readonly "mail.unsubscribe.confirmTitle": "Unsubscribe";
|
|
3
|
+
readonly "mail.unsubscribe.confirmQuestion": "Unsubscribe from these emails?";
|
|
4
|
+
readonly "mail.unsubscribe.confirmButton": "Unsubscribe";
|
|
5
|
+
readonly "mail.unsubscribe.doneTitle": "Unsubscribed";
|
|
6
|
+
readonly "mail.unsubscribe.doneMessage": "You have been unsubscribed.";
|
|
7
|
+
readonly "mail.unsubscribe.undoButton": "Undo";
|
|
8
|
+
readonly "mail.unsubscribe.resubscribedTitle": "Subscribed again";
|
|
9
|
+
readonly "mail.unsubscribe.resubscribedMessage": "You have been subscribed again.";
|
|
10
|
+
readonly "mail.unsubscribe.failed": "That did not work. Please try again later.";
|
|
11
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { registerMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
|
|
2
|
+
// The pages behind a mailed unsubscribe link speak the mail's language, so their texts live in
|
|
3
|
+
// the mail translations: English here, other languages in the locale packages.
|
|
4
|
+
export const UNSUBSCRIBE_PAGE_EN = {
|
|
5
|
+
"mail.unsubscribe.confirmTitle": "Unsubscribe",
|
|
6
|
+
"mail.unsubscribe.confirmQuestion": "Unsubscribe from these emails?",
|
|
7
|
+
"mail.unsubscribe.confirmButton": "Unsubscribe",
|
|
8
|
+
"mail.unsubscribe.doneTitle": "Unsubscribed",
|
|
9
|
+
"mail.unsubscribe.doneMessage": "You have been unsubscribed.",
|
|
10
|
+
"mail.unsubscribe.undoButton": "Undo",
|
|
11
|
+
"mail.unsubscribe.resubscribedTitle": "Subscribed again",
|
|
12
|
+
"mail.unsubscribe.resubscribedMessage": "You have been subscribed again.",
|
|
13
|
+
"mail.unsubscribe.failed": "That did not work. Please try again later.",
|
|
14
|
+
};
|
|
15
|
+
registerMailTranslations("en", UNSUBSCRIBE_PAGE_EN);
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import { type ExtraRouteDefinition } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import "./unsubscribe-page-strings.js";
|
|
3
4
|
export type UnsubscribeTokenPayload = {
|
|
4
5
|
readonly userId: string;
|
|
5
6
|
readonly tenantId: TenantId;
|
|
6
7
|
readonly notificationType: string;
|
|
7
8
|
readonly channel: string;
|
|
9
|
+
readonly locale?: string | undefined;
|
|
8
10
|
};
|
|
9
11
|
export type AddressUnsubscribeTokenPayload = {
|
|
10
12
|
readonly tenantId: TenantId;
|
|
11
13
|
readonly address: string;
|
|
12
14
|
readonly notificationType: string;
|
|
13
15
|
readonly channel: string;
|
|
16
|
+
readonly locale?: string | undefined;
|
|
14
17
|
};
|
|
15
18
|
/**
|
|
16
19
|
* `secret` must be a value dedicated to unsubscribe-token signing — do NOT
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import { ExtraRouteRejection, signatureRoute, } from "@cosmicdrift/kumiko-framework/api";
|
|
2
|
+
import { mailT, resolveMailLocale } from "@cosmicdrift/kumiko-framework/i18n";
|
|
2
3
|
import { escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
|
|
3
4
|
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
4
5
|
import * as jose from "jose";
|
|
5
6
|
import * as z from "zod";
|
|
6
7
|
import { hashUnsubscribeAddress } from "./address-opt-out.js";
|
|
7
8
|
import { DELIVERY_RESUBSCRIBE_PATH, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, } from "./constants.js";
|
|
9
|
+
import "./unsubscribe-page-strings.js";
|
|
10
|
+
// The mail's locale, so the pages behind the link speak the mail's language. Optional: links
|
|
11
|
+
// signed before the claim existed keep verifying and fall back to English.
|
|
12
|
+
const localeClaimSchema = z
|
|
13
|
+
.string()
|
|
14
|
+
.regex(/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{1,8})*$/)
|
|
15
|
+
.optional();
|
|
8
16
|
// Shape des verified-JWT-payloads. tenantId kommt als string aus jose und
|
|
9
17
|
// wird NACH erfolgreichem parse() zur Branded TenantId — kein blind-cast.
|
|
10
18
|
//
|
|
@@ -19,6 +27,7 @@ const unsubscribeJwtPayloadSchema = z.object({
|
|
|
19
27
|
tenantId: z.string().min(1),
|
|
20
28
|
notificationType: z.string().min(1),
|
|
21
29
|
channel: z.string().min(1),
|
|
30
|
+
locale: localeClaimSchema,
|
|
22
31
|
});
|
|
23
32
|
// Address-token payload. `sub` and `addressHash` are the same value (the
|
|
24
33
|
// subject claim IS the blind-index hash) — kept as two fields because the
|
|
@@ -31,6 +40,7 @@ const addressUnsubscribeJwtPayloadSchema = z.object({
|
|
|
31
40
|
notificationType: z.string().min(1),
|
|
32
41
|
channel: z.string().min(1),
|
|
33
42
|
addressHash: z.string().min(1),
|
|
43
|
+
locale: localeClaimSchema,
|
|
34
44
|
});
|
|
35
45
|
const UNSUBSCRIBE_EXPIRY = "7d";
|
|
36
46
|
const RESUBSCRIBE_EXPIRY = "1h";
|
|
@@ -42,6 +52,10 @@ function assertUnsubscribeSecret(secret, caller) {
|
|
|
42
52
|
throw new Error(`${caller}: secret must be ≥${MIN_UNSUBSCRIBE_SECRET_LENGTH} chars (HMAC-SHA256 token signing)`);
|
|
43
53
|
}
|
|
44
54
|
}
|
|
55
|
+
// An unusable locale is left out instead of failing the send; the pages then fall back to English.
|
|
56
|
+
function localeClaim(locale) {
|
|
57
|
+
return locale !== undefined && localeClaimSchema.safeParse(locale).success ? { locale } : {};
|
|
58
|
+
}
|
|
45
59
|
const UNSUBSCRIBE_TOKEN_INVALID_BODY = {
|
|
46
60
|
error: { code: "unsubscribe_token_invalid", message: "Invalid or expired token" },
|
|
47
61
|
};
|
|
@@ -52,6 +66,7 @@ export async function signUnsubscribeToken(payload, secret) {
|
|
|
52
66
|
tenantId: payload.tenantId,
|
|
53
67
|
notificationType: payload.notificationType,
|
|
54
68
|
channel: payload.channel,
|
|
69
|
+
...localeClaim(payload.locale),
|
|
55
70
|
})
|
|
56
71
|
.setProtectedHeader({ alg: "HS256" })
|
|
57
72
|
.setSubject(String(payload.userId))
|
|
@@ -80,6 +95,7 @@ export async function signAddressUnsubscribeToken(payload, secret) {
|
|
|
80
95
|
channel: payload.channel,
|
|
81
96
|
addressHash,
|
|
82
97
|
kind: "address",
|
|
98
|
+
...localeClaim(payload.locale),
|
|
83
99
|
})
|
|
84
100
|
.setProtectedHeader({ alg: "HS256" })
|
|
85
101
|
.setSubject(addressHash)
|
|
@@ -104,6 +120,7 @@ async function signResubscribeToken(verified, encodedSecret) {
|
|
|
104
120
|
tenantId: verified.tenantId,
|
|
105
121
|
notificationType: verified.notificationType,
|
|
106
122
|
channel: verified.channel,
|
|
123
|
+
...localeClaim(verified.locale),
|
|
107
124
|
};
|
|
108
125
|
const jwt = verified.kind === "address"
|
|
109
126
|
? new jose.SignJWT({
|
|
@@ -154,6 +171,7 @@ async function verifyUnsubscribeToken(token, encodedSecret, purpose) {
|
|
|
154
171
|
addressHash: parsed.addressHash,
|
|
155
172
|
notificationType: parsed.notificationType,
|
|
156
173
|
channel: parsed.channel,
|
|
174
|
+
locale: parsed.locale,
|
|
157
175
|
};
|
|
158
176
|
}
|
|
159
177
|
const userAttempt = unsubscribeJwtPayloadSchema.safeParse(verifiedPayload);
|
|
@@ -169,6 +187,7 @@ async function verifyUnsubscribeToken(token, encodedSecret, purpose) {
|
|
|
169
187
|
userId: parsed.sub,
|
|
170
188
|
notificationType: parsed.notificationType,
|
|
171
189
|
channel: parsed.channel,
|
|
190
|
+
locale: parsed.locale,
|
|
172
191
|
};
|
|
173
192
|
}
|
|
174
193
|
catch (err) {
|
|
@@ -228,39 +247,50 @@ async function dispatchResubscribeWrite(verified, deps) {
|
|
|
228
247
|
return "success";
|
|
229
248
|
return isResubscribeLimitReached(dispatched.error) ? "limit_reached" : "failed";
|
|
230
249
|
}
|
|
231
|
-
function
|
|
250
|
+
function pageLocale(locale) {
|
|
251
|
+
return resolveMailLocale(locale ?? "en");
|
|
252
|
+
}
|
|
253
|
+
function htmlPage(locale, title, bodyHtml) {
|
|
232
254
|
return `<!doctype html>
|
|
233
|
-
<html>
|
|
255
|
+
<html lang="${escapeHtmlAttr(locale)}">
|
|
234
256
|
<head>
|
|
235
257
|
<meta charset="utf-8">
|
|
236
258
|
<meta name="robots" content="noindex">
|
|
237
|
-
<
|
|
259
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
260
|
+
<title>${escapeHtmlAttr(title)}</title>
|
|
238
261
|
</head>
|
|
239
262
|
<body>
|
|
240
|
-
|
|
241
|
-
<form method="post" action="${DELIVERY_UNSUBSCRIBE_PATH}">
|
|
242
|
-
<input type="hidden" name="token" value="${escapeHtmlAttr(token)}">
|
|
243
|
-
<button type="submit">Unsubscribe</button>
|
|
244
|
-
</form>
|
|
263
|
+
${bodyHtml}
|
|
245
264
|
</body>
|
|
246
265
|
</html>`;
|
|
247
266
|
}
|
|
248
|
-
function
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
<
|
|
252
|
-
<
|
|
253
|
-
<
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
267
|
+
function confirmationPage(token, locale) {
|
|
268
|
+
const lang = pageLocale(locale);
|
|
269
|
+
return htmlPage(lang, mailT(lang, "mail.unsubscribe.confirmTitle"), `<p>${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.confirmQuestion"))}</p>
|
|
270
|
+
<form method="post" action="${DELIVERY_UNSUBSCRIBE_PATH}">
|
|
271
|
+
<input type="hidden" name="token" value="${escapeHtmlAttr(token)}">
|
|
272
|
+
<button type="submit">${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.confirmButton"))}</button>
|
|
273
|
+
</form>`);
|
|
274
|
+
}
|
|
275
|
+
function unsubscribedPage(resubscribeToken, locale) {
|
|
276
|
+
const lang = pageLocale(locale);
|
|
277
|
+
return htmlPage(lang, mailT(lang, "mail.unsubscribe.doneTitle"), `<p>${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.doneMessage"))}</p>
|
|
258
278
|
<form method="post" action="${DELIVERY_RESUBSCRIBE_PATH}">
|
|
259
279
|
<input type="hidden" name="token" value="${escapeHtmlAttr(resubscribeToken)}">
|
|
260
|
-
<button type="submit"
|
|
261
|
-
</form
|
|
262
|
-
|
|
263
|
-
|
|
280
|
+
<button type="submit">${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.undoButton"))}</button>
|
|
281
|
+
</form>`);
|
|
282
|
+
}
|
|
283
|
+
function resubscribedPage(locale) {
|
|
284
|
+
const lang = pageLocale(locale);
|
|
285
|
+
return htmlPage(lang, mailT(lang, "mail.unsubscribe.resubscribedTitle"), `<p>${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.resubscribedMessage"))}</p>`);
|
|
286
|
+
}
|
|
287
|
+
function failedPage(locale) {
|
|
288
|
+
const lang = pageLocale(locale);
|
|
289
|
+
const message = mailT(lang, "mail.unsubscribe.failed");
|
|
290
|
+
return htmlPage(lang, message, `<p>${escapeHtmlAttr(message)}</p>`);
|
|
291
|
+
}
|
|
292
|
+
function isFormRequest(contentType) {
|
|
293
|
+
return contentType?.includes("application/x-www-form-urlencoded") === true;
|
|
264
294
|
}
|
|
265
295
|
// One-click clients put the token in the query string; only the
|
|
266
296
|
// confirmation-page form submits it in the body.
|
|
@@ -304,10 +334,10 @@ export function createUnsubscribeRoutes(options) {
|
|
|
304
334
|
if (!token) {
|
|
305
335
|
throw new ExtraRouteRejection(400, UNSUBSCRIBE_TOKEN_INVALID_BODY, "missing unsubscribe token");
|
|
306
336
|
}
|
|
307
|
-
await verifyUnsubscribeToken(token, encodedSecret, "unsubscribe");
|
|
308
|
-
return { token };
|
|
337
|
+
const verified = await verifyUnsubscribeToken(token, encodedSecret, "unsubscribe");
|
|
338
|
+
return { token, locale: verified.locale };
|
|
309
339
|
},
|
|
310
|
-
handler: async (c, { token }) => c.html(confirmationPage(token), 200, CONFIRMATION_PAGE_HEADERS),
|
|
340
|
+
handler: async (c, { token, locale }) => c.html(confirmationPage(token, locale), 200, CONFIRMATION_PAGE_HEADERS),
|
|
311
341
|
});
|
|
312
342
|
const writeRoute = signatureRoute({
|
|
313
343
|
method: "POST",
|
|
@@ -320,11 +350,9 @@ export function createUnsubscribeRoutes(options) {
|
|
|
320
350
|
// (e.g. events-table missing, DB down) behind a misleading 400.
|
|
321
351
|
const succeeded = await dispatchUnsubscribeWrite(verified, deps);
|
|
322
352
|
if (!succeeded) {
|
|
323
|
-
return c.html(
|
|
353
|
+
return c.html(failedPage(verified.locale), 500, { "Cache-Control": "no-store" });
|
|
324
354
|
}
|
|
325
|
-
return c.html(unsubscribedPage(await signResubscribeToken(verified, encodedSecret)), 200, {
|
|
326
|
-
"Cache-Control": "no-store",
|
|
327
|
-
});
|
|
355
|
+
return c.html(unsubscribedPage(await signResubscribeToken(verified, encodedSecret), verified.locale), 200, { "Cache-Control": "no-store" });
|
|
328
356
|
},
|
|
329
357
|
});
|
|
330
358
|
// Undo direction: token only from the JSON/form body, never the query
|
|
@@ -337,6 +365,14 @@ export function createUnsubscribeRoutes(options) {
|
|
|
337
365
|
verify: async (request) => verifyUnsubscribeToken(tokenFromRequestBody(request), encodedSecret, "resubscribe"),
|
|
338
366
|
handler: async (c, verified, deps) => {
|
|
339
367
|
const outcome = await dispatchResubscribeWrite(verified, deps);
|
|
368
|
+
// The undo button on the unsubscribed page posts a form; a browser should land on a page.
|
|
369
|
+
if (isFormRequest(c.req.header("content-type"))) {
|
|
370
|
+
return outcome === "success"
|
|
371
|
+
? c.html(resubscribedPage(verified.locale), 200, { "Cache-Control": "no-store" })
|
|
372
|
+
: c.html(failedPage(verified.locale), outcome === "limit_reached" ? 409 : 500, {
|
|
373
|
+
"Cache-Control": "no-store",
|
|
374
|
+
});
|
|
375
|
+
}
|
|
340
376
|
if (outcome === "limit_reached") {
|
|
341
377
|
return c.json({ error: { code: DeliveryErrors.resubscribeLimitReached } }, 409, {
|
|
342
378
|
"Cache-Control": "no-store",
|
|
@@ -12,8 +12,7 @@
|
|
|
12
12
|
// loser's create() comes back as a `version_conflict` failure; re-lookup and
|
|
13
13
|
// fall through to update, or no-op if the winner already set the target
|
|
14
14
|
// `enabled` state.
|
|
15
|
-
import { fetchOne } from "@cosmicdrift/kumiko-framework/
|
|
16
|
-
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
15
|
+
import { createEventStoreExecutor, fetchOne, } from "@cosmicdrift/kumiko-framework/db";
|
|
17
16
|
import { generateDeterministicId } from "@cosmicdrift/kumiko-framework/utils";
|
|
18
17
|
import { notificationPreferenceEntity, notificationPreferencesTable } from "./tables.js";
|
|
19
18
|
const executor = createEventStoreExecutor(notificationPreferencesTable, notificationPreferenceEntity, { entityName: "notification-preference" });
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
export declare function createDerivativesSharpFeature(): import("@cosmicdrift/kumiko-types/feature").FeatureDefinition & {
|
|
2
|
+
readonly exports: void;
|
|
3
|
+
};
|
|
4
|
+
/** @deprecated Use createDerivativesSharpFeature(). Run `bun kumiko-upgrade --apply`. Removed in the first release after 2026-10-29. */
|
|
1
5
|
export declare const derivativesSharpFeature: import("@cosmicdrift/kumiko-types/feature").FeatureDefinition & {
|
|
2
6
|
readonly exports: void;
|
|
3
7
|
};
|
|
@@ -2,14 +2,18 @@
|
|
|
2
2
|
import { defineFeature, EXT_DERIVATIVE_RENDERER } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { renderImage } from "./render.js";
|
|
4
4
|
const FEATURE_NAME = "derivatives-sharp";
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
5
|
+
export function createDerivativesSharpFeature() {
|
|
6
|
+
return defineFeature(FEATURE_NAME, (r) => {
|
|
7
|
+
r.describe("Registers an `image/*` renderer against the `derivativeRenderer` extension point declared by `file-derivatives`, backed by sharp. Supports resize/fit (cover, inside, contain), format conversion with quality (webp, avif, jpeg), whole-image blur, and blurring individual regions burned in before resize (see BlurRegion). EXIF orientation is applied and then normalized away, and all other EXIF (including GPS) is dropped. Server-only: sharp is a native binding — never import this from client code.");
|
|
8
|
+
r.uiHints({
|
|
9
|
+
displayLabel: "File Derivatives · Sharp",
|
|
10
|
+
category: "storage",
|
|
11
|
+
recommended: false,
|
|
12
|
+
});
|
|
13
|
+
r.requires({ features: ["file-derivatives"] });
|
|
14
|
+
const plugin = { render: renderImage };
|
|
15
|
+
r.useExtension(EXT_DERIVATIVE_RENDERER, "image/*", plugin);
|
|
16
|
+
}, { dedupeOptions: {} });
|
|
17
|
+
}
|
|
18
|
+
/** @deprecated Use createDerivativesSharpFeature(). Run `bun kumiko-upgrade --apply`. Removed in the first release after 2026-10-29. */
|
|
19
|
+
export const derivativesSharpFeature = createDerivativesSharpFeature();
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { derivativesSharpFeature } from "./feature.js";
|
|
1
|
+
export { createDerivativesSharpFeature, derivativesSharpFeature } from "./feature.js";
|
|
2
2
|
export { imageMetadata } from "./render.js";
|