@cosmicdrift/kumiko-samples 0.387.0 → 0.389.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 +8 -1
- package/packages/bundled-features/src/agent-tools/changes.json +12 -0
- package/packages/bundled-features/src/agent-tools/index.ts +1 -1
- package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +48 -32
- package/packages/bundled-features/src/audit/changes.json +12 -0
- package/packages/bundled-features/src/audit/feature.ts +1 -0
- package/packages/bundled-features/src/audit/handlers/audit-events.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/changes.json +12 -0
- package/packages/bundled-features/src/auth-email-password/handlers/request-account-unlock.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/request-email-verification.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/request-password-reset.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +4 -0
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +8 -0
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +16 -3
- package/packages/bundled-features/src/auth-email-password/signup-activation.ts +2 -0
- package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +8 -2
- package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +23 -21
- package/packages/bundled-features/src/auth-email-password/web/default-auth-pages-gate.tsx +6 -3
- package/packages/bundled-features/src/auth-email-password/web/forgot-password-screen.tsx +11 -1
- package/packages/bundled-features/src/auth-email-password/web/index.ts +5 -0
- package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +16 -5
- package/packages/bundled-features/src/auth-email-password/web/reset-password-screen.tsx +7 -1
- package/packages/bundled-features/src/auth-email-password/web/return-path.ts +35 -0
- package/packages/bundled-features/src/auth-email-password/web/session.tsx +6 -1
- package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +11 -3
- package/packages/bundled-features/src/auth-email-password/web/signup-screen.tsx +12 -2
- package/packages/bundled-features/src/auth-mfa/changes.json +12 -0
- package/packages/bundled-features/src/auth-mfa/config.ts +3 -7
- package/packages/bundled-features/src/auth-mfa/feature-exports.ts +2 -2
- package/packages/bundled-features/src/auth-mfa/feature.ts +6 -0
- package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +8 -5
- package/packages/bundled-features/src/auth-mfa/web/i18n.ts +6 -1
- package/packages/bundled-features/src/auth-mfa/web/mfa-code-or-recovery-field.tsx +68 -0
- package/packages/bundled-features/src/auth-mfa/web/mfa-disable-dialog.tsx +23 -13
- package/packages/bundled-features/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +23 -13
- package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +15 -6
- package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +65 -18
- package/packages/bundled-features/src/billing-foundation/changes.json +55 -0
- package/packages/bundled-features/src/billing-foundation/checkout-core.ts +32 -42
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +8 -2
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +14 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +3 -4
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +12 -6
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +35 -19
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +12 -8
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +14 -8
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +5 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +8 -10
- package/packages/bundled-features/src/billing-foundation/events.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/feature.ts +3 -1
- package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +71 -6
- package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/handlers/plan-switch-preview.query.ts +65 -0
- package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +9 -2
- package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +44 -8
- package/packages/bundled-features/src/billing-foundation/handlers/switch-plan.write.ts +72 -82
- package/packages/bundled-features/src/billing-foundation/handlers/sync-subscription.write.ts +14 -1
- package/packages/bundled-features/src/billing-foundation/i18n.ts +22 -0
- package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +62 -27
- package/packages/bundled-features/src/billing-foundation/plan-switch.ts +84 -0
- package/packages/bundled-features/src/billing-foundation/types.ts +80 -1
- package/packages/bundled-features/src/billing-foundation/validate-options.ts +19 -0
- package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +119 -47
- package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +89 -42
- package/packages/bundled-features/src/billing-foundation/web/constants.ts +4 -0
- package/packages/bundled-features/src/billing-foundation/web/plan-price.ts +55 -0
- package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +224 -0
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +25 -8
- package/packages/bundled-features/src/cap-counter/changes.json +30 -0
- package/packages/bundled-features/src/cap-counter/enforce-cap.ts +2 -1
- package/packages/bundled-features/src/cap-counter/feature.ts +1 -0
- package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +8 -1
- package/packages/bundled-features/src/cap-overview/feature.ts +1 -0
- package/packages/bundled-features/src/channel-email/changes.json +6 -0
- package/packages/bundled-features/src/channel-email/feature.ts +1 -1
- package/packages/bundled-features/src/config/changes.json +18 -0
- package/packages/bundled-features/src/config/handlers/cascade.query.ts +4 -2
- package/packages/bundled-features/src/config/handlers/set.write.ts +19 -2
- package/packages/bundled-features/src/config/handlers/values.query.ts +4 -2
- package/packages/bundled-features/src/config/write-helpers.ts +27 -8
- package/packages/bundled-features/src/crypto-shredding/changes.json +18 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +11 -10
- package/packages/bundled-features/src/custom-fields/lib/parse-serialized-field.ts +1 -1
- package/packages/bundled-features/src/delivery/changes.json +42 -0
- package/packages/bundled-features/src/delivery/channel-context.ts +9 -3
- package/packages/bundled-features/src/delivery/db/queries/preferences.ts +2 -2
- package/packages/bundled-features/src/delivery/delivery-service.ts +35 -5
- package/packages/bundled-features/src/delivery/feature.ts +1 -0
- package/packages/bundled-features/src/delivery/handlers/preferences.query.ts +20 -1
- package/packages/bundled-features/src/delivery/handlers/unsubscribe-page-chrome.query.ts +1 -1
- package/packages/bundled-features/src/delivery/index.ts +5 -0
- package/packages/bundled-features/src/delivery/system-channel-context.ts +33 -0
- package/packages/bundled-features/src/delivery/unsubscribe-link.ts +3 -1
- package/packages/bundled-features/src/delivery/unsubscribe-page-strings.ts +1 -0
- package/packages/bundled-features/src/delivery/unsubscribe-page.ts +6 -0
- package/packages/bundled-features/src/delivery/unsubscribe.ts +36 -15
- package/packages/bundled-features/src/document-ingest-foundation/append-missing-extract-event.ts +84 -0
- package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
- package/packages/bundled-features/src/document-ingest-foundation/events.ts +15 -0
- package/packages/bundled-features/src/document-ingest-foundation/index.ts +11 -0
- package/packages/bundled-features/src/feature-toggles/feature.ts +1 -0
- package/packages/bundled-features/src/file-foundation/index.ts +1 -0
- package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +7 -1
- package/packages/bundled-features/src/file-provider-s3/env-named-feature.ts +0 -2
- package/packages/bundled-features/src/files/README.md +1 -1
- package/packages/bundled-features/src/form-draft/changes.json +6 -0
- package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +2 -2
- package/packages/bundled-features/src/form-draft/decrypt-draft.ts +4 -1
- package/packages/bundled-features/src/host-routing/README.md +1 -1
- package/packages/bundled-features/src/host-routing/changes.json +12 -0
- package/packages/bundled-features/src/host-routing/dispatch.ts +16 -17
- package/packages/bundled-features/src/host-routing/options.ts +2 -1
- package/packages/bundled-features/src/host-routing/validate-options.ts +5 -0
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +18 -0
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +17 -5
- package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +0 -1
- package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +0 -3
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +4 -3
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +32 -16
- package/packages/bundled-features/src/jobs/changes.json +13 -0
- package/packages/bundled-features/src/jobs/feature.ts +15 -4
- package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +45 -40
- package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +63 -59
- package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -4
- package/packages/bundled-features/src/ledger/web/index.ts +1 -1
- package/packages/bundled-features/src/legal-pages/README.md +1 -1
- package/packages/bundled-features/src/legal-pages/seed.ts +5 -2
- package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/mail-foundation/feature.ts +5 -3
- package/packages/bundled-features/src/mail-transport-smtp/changes.json +6 -0
- package/packages/bundled-features/src/mail-transport-smtp/config-keys.ts +1 -1
- package/packages/bundled-features/src/mail-transport-smtp/feature.ts +9 -11
- package/packages/bundled-features/src/managed-pages/changes.json +6 -0
- package/packages/bundled-features/src/managed-pages/constants.ts +1 -0
- package/packages/bundled-features/src/managed-pages/feature.ts +3 -1
- package/packages/bundled-features/src/managed-pages/index.ts +1 -0
- package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +1 -1
- package/packages/bundled-features/src/personal-access-tokens/feature.ts +5 -1
- package/packages/bundled-features/src/renderer-pdf/changes.json +6 -0
- package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +27 -10
- package/packages/bundled-features/src/secrets/changes.json +6 -0
- package/packages/bundled-features/src/secrets/write-gate.ts +9 -2
- package/packages/bundled-features/src/sessions/changes.json +7 -0
- package/packages/bundled-features/src/sessions/feature.ts +8 -2
- package/packages/bundled-features/src/sessions/index.ts +1 -1
- package/packages/bundled-features/src/sessions/session-callbacks.ts +1 -9
- package/packages/bundled-features/src/shared/decrypt-stored-pii.ts +10 -0
- package/packages/bundled-features/src/shared/index.ts +1 -1
- package/packages/bundled-features/src/shared/mail-template-body.ts +10 -3
- package/packages/bundled-features/src/shared/purge-files-reaching-subject.ts +24 -2
- package/packages/bundled-features/src/shared/run-in-sub-transaction.ts +5 -5
- package/packages/bundled-features/src/step-dispatcher/changes.json +12 -0
- package/packages/bundled-features/src/step-dispatcher/deliver-dispatch-request.ts +17 -3
- package/packages/bundled-features/src/step-dispatcher/feature.ts +1 -0
- package/packages/bundled-features/src/step-dispatcher/mail-runner.ts +2 -1
- package/packages/bundled-features/src/subscription-dodo/changes.json +14 -0
- package/packages/bundled-features/src/subscription-dodo/client.ts +45 -0
- package/packages/bundled-features/src/subscription-dodo/constants.ts +24 -0
- package/packages/bundled-features/src/subscription-dodo/feature.ts +207 -0
- package/packages/bundled-features/src/subscription-dodo/index.ts +16 -0
- package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +345 -0
- package/packages/bundled-features/src/subscription-dodo/runtime.ts +126 -0
- package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +262 -0
- package/packages/bundled-features/src/subscription-stripe/changes.json +12 -0
- package/packages/bundled-features/src/subscription-stripe/feature.ts +1 -0
- package/packages/bundled-features/src/subscription-stripe/runtime.ts +7 -6
- package/packages/bundled-features/src/tags/feature.ts +1 -0
- package/packages/bundled-features/src/template-resolver/api.ts +31 -4
- package/packages/bundled-features/src/template-resolver/changes.json +84 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +1 -0
- package/packages/bundled-features/src/template-resolver/document-ops.ts +20 -1
- package/packages/bundled-features/src/template-resolver/feature.ts +4 -4
- package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +3 -2
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +27 -5
- package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +3 -2
- package/packages/bundled-features/src/template-resolver/handlers/document-base.ts +3 -2
- package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +3 -2
- package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +36 -11
- package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +18 -19
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +45 -10
- package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/send-test-status.query.ts +22 -19
- package/packages/bundled-features/src/template-resolver/handlers/tenant-override-counts.query.ts +9 -5
- package/packages/bundled-features/src/template-resolver/handlers/tenant-override-selection.ts +8 -6
- package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +44 -5
- package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +37 -26
- package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +7 -2
- package/packages/bundled-features/src/template-resolver/seeding.ts +42 -17
- package/packages/bundled-features/src/template-resolver/store-template-resource.ts +74 -12
- package/packages/bundled-features/src/template-resolver/template-delivery-policy.ts +0 -11
- package/packages/bundled-features/src/template-resolver/template-meta.ts +12 -4
- package/packages/bundled-features/src/template-resolver/template-preview.ts +11 -3
- package/packages/bundled-features/src/template-resolver/web/constants.ts +7 -5
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +6 -0
- package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
- package/packages/bundled-features/src/template-resolver/web/mail-layout-constants.ts +8 -0
- package/packages/bundled-features/src/template-resolver/web/qualified-names.ts +1 -0
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/feature.ts +1 -0
- package/packages/bundled-features/src/tenant/handlers/member-directory.query.ts +9 -3
- package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +3 -3
- package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +7 -4
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +12 -0
- package/packages/bundled-features/src/tenant-lifecycle/constants.ts +5 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +5 -1
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +35 -1
- package/packages/bundled-features/src/tier-engine/changes.json +6 -0
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +4 -1
- package/packages/bundled-features/src/tier-engine/web/constants.ts +3 -3
- package/packages/bundled-features/src/user/feature.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
- package/packages/bundled-features/src/user-data-rights/feature.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/index.ts +6 -1
- package/packages/bundled-features/src/user-data-rights/run-export-jobs.ts +3 -4
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -1
- package/packages/bundled-features/src/user-data-rights/web/constants.ts +6 -0
- package/packages/bundled-features/src/user-data-rights/web/index.ts +1 -0
- package/packages/bundled-features/src/waitlist/changes.json +36 -0
- package/packages/bundled-features/src/waitlist/feature.ts +1 -0
- package/packages/bundled-features/src/waitlist/handlers/export-purge.write.ts +2 -1
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +28 -9
- package/packages/bundled-features/src/waitlist/import.ts +28 -3
- package/packages/bundled-features/src/waitlist/legacy-migrate.ts +24 -4
- package/packages/bundled-features/src/waitlist/link-on-signup.ts +20 -10
- package/packages/bundled-features/src/waitlist/options.ts +5 -1
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +1 -1
- package/samples/apps/styleguide/src/app/client.tsx +2 -0
- package/samples/apps/styleguide/src/app/seed.ts +6 -4
- package/samples/apps/styleguide/src/app/server.ts +4 -1
- package/samples/apps/styleguide/src/features/widgets/feature.ts +15 -4
- package/samples/apps/styleguide/src/features/widgets/i18n.ts +11 -0
- package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +24 -0
- package/samples/apps/styleguide/src/features/workboard/feature.ts +185 -0
- package/samples/apps/styleguide/src/features/workboard/i18n.ts +27 -0
- package/samples/apps/styleguide/src/features/workboard/seed.ts +121 -0
- package/samples/apps/styleguide/src/features/workboard/web.ts +13 -0
- package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +2 -0
- package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +9 -0
- package/samples/apps/use-all-bundled/bin/main.ts +2 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +5 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +90 -15
- package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +16 -6
- package/samples/apps/use-all-bundled/feature-manifest.json +136 -3
- package/samples/apps/use-all-bundled/kumiko/schema.ts +2 -0
- package/samples/apps/use-all-bundled/kumiko.config.ts +2 -0
- package/samples/apps/use-all-bundled/package.json +2 -1
- package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +2 -0
- package/samples/apps/use-all-bundled/src/app/notes-feature.ts +2 -0
- package/samples/apps/use-all-bundled/src/bundled-lists.ts +15 -0
- package/samples/apps/use-all-bundled/src/run-config.ts +9 -3
- package/samples/recipes/auth-mfa/README.md +5 -0
- package/samples/recipes/auth-mfa/package.json +5 -2
- package/samples/recipes/auth-mfa/src/web/confirmation-code-step.tsx +49 -0
- package/samples/recipes/auth-mfa/tsconfig.json +8 -0
- package/samples/recipes/env-composition/README.md +3 -1
- package/samples/recipes/env-composition/src/env.ts +2 -0
- package/samples/recipes/golden-path/src/feature.ts +2 -1
- package/samples/recipes/legal-pages/README.md +1 -1
- package/samples/recipes/lifecycle-hooks/README.md +4 -0
- package/samples/recipes/lifecycle-hooks/src/feature.ts +26 -1
- package/samples/recipes/list-table-options/README.md +50 -0
- package/samples/recipes/list-table-options/package.json +11 -0
- package/samples/recipes/list-table-options/src/feature.ts +221 -0
- package/samples/recipes/pdf-audit-export/src/feature.ts +2 -1
- package/samples/recipes/rate-limiting/README.md +18 -0
- package/samples/recipes/rate-limiting/src/feature.ts +12 -0
- package/samples/recipes/state-machine/src/events.ts +3 -2
- package/samples/recipes/tenant-isolation/README.md +1 -0
- package/samples/recipes/tenant-isolation/src/feature.ts +36 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/stored-user-email.ts +0 -12
|
@@ -324,11 +324,6 @@ async function sweepDerivedSubjectData(
|
|
|
324
324
|
audit: { readonly requestId: string; readonly erasedBy: string },
|
|
325
325
|
rawSubject: SubjectIdInput,
|
|
326
326
|
): Promise<string | undefined> {
|
|
327
|
-
const recordTenantId = await owningTenantOfRecordSubject(
|
|
328
|
-
crossTenantRunner,
|
|
329
|
-
ctx.registry.features,
|
|
330
|
-
rawSubject,
|
|
331
|
-
);
|
|
332
327
|
const failureLabels: string[] = [];
|
|
333
328
|
const runStep = async (step: (runner: DbRunner) => Promise<void>): Promise<void> => {
|
|
334
329
|
try {
|
|
@@ -367,8 +362,14 @@ async function sweepDerivedSubjectData(
|
|
|
367
362
|
});
|
|
368
363
|
|
|
369
364
|
// File columns hold only the fileRef id, so the key erase never reaches the bytes.
|
|
370
|
-
await runStep((crossTenantSubjectRunner) =>
|
|
371
|
-
|
|
365
|
+
await runStep(async (crossTenantSubjectRunner) => {
|
|
366
|
+
// Inside the step: a failing owner lookup must land in the audit label, not abort the handler.
|
|
367
|
+
const recordTenantId = await owningTenantOfRecordSubject(
|
|
368
|
+
crossTenantSubjectRunner,
|
|
369
|
+
ctx.registry.features,
|
|
370
|
+
rawSubject,
|
|
371
|
+
);
|
|
372
|
+
await purgeFilesReachingSubject({
|
|
372
373
|
registry: ctx.registry,
|
|
373
374
|
db: crossTenantSubjectRunner,
|
|
374
375
|
subject,
|
|
@@ -376,8 +377,8 @@ async function sweepDerivedSubjectData(
|
|
|
376
377
|
...(recordTenantId !== undefined && { recordTenantId }),
|
|
377
378
|
resolveDeleteBytes: resolveFileBytesDeleter(ctx),
|
|
378
379
|
missingStorage: { mode: "fail" },
|
|
379
|
-
})
|
|
380
|
-
);
|
|
380
|
+
});
|
|
381
|
+
});
|
|
381
382
|
|
|
382
383
|
return failureLabels[0];
|
|
383
384
|
}
|
|
@@ -561,7 +562,7 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
561
562
|
// - user.updated as lifecycle event (updateUserLifecycle), so a
|
|
562
563
|
// read_users rebuild doesn't wipe the flip
|
|
563
564
|
// - sessions don't need active revocation — session-callbacks
|
|
564
|
-
// re-validate user
|
|
565
|
+
// re-validate the user row on every request (isPrincipalRowBlocked
|
|
565
566
|
// blocks Deleted).
|
|
566
567
|
// Both idempotent (status set / revokedAt IS NULL filter), retry after
|
|
567
568
|
// crash recovery is safe. User-feature guard: without the user feature
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Centralised parser for `fieldDefinition.serializedField`.
|
|
2
2
|
//
|
|
3
3
|
// The column is stored as `text` in B1 (entity.ts) — what the db row hands
|
|
4
|
-
// back depends on the driver:
|
|
4
|
+
// back depends on the driver: text normally arrives as a `string`, but
|
|
5
5
|
// jsonb-tolerant drivers and middleware can deliver an already-parsed
|
|
6
6
|
// object. This helper normalises both, validates the shape, and centralises
|
|
7
7
|
// the single boundary cast so callers don't sprinkle `as { … }`-narrowings
|
|
@@ -1,4 +1,46 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.389.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Unsubscribe link origin is looked up once per notify call",
|
|
6
|
+
"detail": "The tenantOrigin callback runs once per tenant for a notify call with many recipients, and a fallback warning is logged once. A callback that hangs is still not guarded by a timeout, so keep it fast."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.389.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "buildSystemChannelContext for system tenant mails outside a handler",
|
|
12
|
+
"detail": "buildSystemChannelContext(deps, { config, secrets }) returns a ChannelContext for the system tenant whose readAttachment reads file and inline attachments. SystemWireDeps (wire hook and runWorkerApp wireComponents) has a new required filesForTenant that deps can pass straight in."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.388.0",
|
|
16
|
+
"type": "fix",
|
|
17
|
+
"title": "Preferences query reports renamed notification types under the current name",
|
|
18
|
+
"detail": "Rows stored under a former notification name are returned with the current name and collapsed per channel with the delivery precedence (current-name row wins, otherwise any disabled row disables), so settings screens show the effective state."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.388.0",
|
|
22
|
+
"type": "fix",
|
|
23
|
+
"title": "Inline mail image retries on transient storage errors",
|
|
24
|
+
"detail": "Only a missing file storage for the file's tenant sends the mail without the inline image; any other read error (timeout, provider failure, DB) is rethrown so the send job retries instead of mailing without the logo."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"version": "0.388.0",
|
|
28
|
+
"type": "fix",
|
|
29
|
+
"title": "Unsubscribe tenant origin resolved once per notify call",
|
|
30
|
+
"detail": "The app's tenantOrigin callback runs once per notify call and tenant instead of once per recipient and channel, so a newsletter no longer causes one lookup and one fallback warning per address. The docs state the callback must only return verified, active domains."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"version": "0.388.0",
|
|
34
|
+
"type": "fix",
|
|
35
|
+
"title": "One-click unsubscribe POST keeps the JSON rejection",
|
|
36
|
+
"detail": "RFC 8058 one-click POSTs (form body List-Unsubscribe=One-Click) answer an invalid or expired token with the JSON 400 again and skip the branding lookup; only browser form submits get the HTML page."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"version": "0.388.0",
|
|
40
|
+
"type": "fix",
|
|
41
|
+
"title": "Resubscribe limit page has its own text",
|
|
42
|
+
"detail": "A browser that hits the resubscribe limit sees that the address cannot be subscribed again here, instead of the generic \"try again later\" error (new key mail.unsubscribe.resubscribeLimitReached in en, de, es, fr, it)."
|
|
43
|
+
},
|
|
2
44
|
{
|
|
3
45
|
"version": "0.383.0",
|
|
4
46
|
"type": "fix",
|
|
@@ -5,7 +5,11 @@ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
5
5
|
import type { FileContext } from "@cosmicdrift/kumiko-framework/files";
|
|
6
6
|
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
7
7
|
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
type FileRefContent,
|
|
10
|
+
FileStorageUnavailableError,
|
|
11
|
+
readFileRefContent,
|
|
12
|
+
} from "../file-foundation/index.ts";
|
|
9
13
|
import {
|
|
10
14
|
collectRendererPlugins,
|
|
11
15
|
createRendererFoundationApi,
|
|
@@ -86,7 +90,8 @@ function createPdfRenderer(
|
|
|
86
90
|
};
|
|
87
91
|
}
|
|
88
92
|
|
|
89
|
-
// A decorative inline image (logo)
|
|
93
|
+
// A decorative inline image (logo) of a tenant without file storage must not fail the whole mail;
|
|
94
|
+
// transient read errors rethrow so the send job retries.
|
|
90
95
|
async function readInlineImageOrNull(
|
|
91
96
|
fileRefId: string,
|
|
92
97
|
read: () => Promise<FileRefContent | null>,
|
|
@@ -94,9 +99,10 @@ async function readInlineImageOrNull(
|
|
|
94
99
|
try {
|
|
95
100
|
return await read();
|
|
96
101
|
} catch (err) {
|
|
102
|
+
if (!(err instanceof FileStorageUnavailableError)) throw err;
|
|
97
103
|
createFallbackLogger("delivery").warn("inline image could not be read, sending without it", {
|
|
98
104
|
fileRefId,
|
|
99
|
-
errorName: err
|
|
105
|
+
errorName: err.name,
|
|
100
106
|
});
|
|
101
107
|
return null;
|
|
102
108
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
-
import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { toPostgresArrayLiteral, unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
|
|
5
5
|
export type NotificationPreferenceRow = {
|
|
@@ -27,6 +27,6 @@ export async function selectNotificationPreferences(
|
|
|
27
27
|
OR (notification_type = '*' AND channel = $4)
|
|
28
28
|
OR (notification_type = ANY($3::text[]) AND channel = '*')
|
|
29
29
|
)`,
|
|
30
|
-
[tenantId, userId, notificationTypeNames, channelName],
|
|
30
|
+
[tenantId, userId, toPostgresArrayLiteral(notificationTypeNames), channelName],
|
|
31
31
|
);
|
|
32
32
|
}
|
|
@@ -484,9 +484,37 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
484
484
|
}
|
|
485
485
|
}
|
|
486
486
|
|
|
487
|
+
// The app's tenantOrigin callback typically hits the DB: once per run and tenant, not per recipient.
|
|
488
|
+
const unsubscribeOriginsByRun = new WeakMap<NotifyRun, Map<TenantId, Promise<string>>>();
|
|
489
|
+
|
|
490
|
+
function unsubscribeOriginOnce(
|
|
491
|
+
run: NotifyRun,
|
|
492
|
+
links: UnsubscribeLinkConfig,
|
|
493
|
+
channelCtx: ChannelContext,
|
|
494
|
+
notificationType: string,
|
|
495
|
+
): Promise<string> {
|
|
496
|
+
let originsByTenant = unsubscribeOriginsByRun.get(run);
|
|
497
|
+
if (originsByTenant === undefined) {
|
|
498
|
+
originsByTenant = new Map();
|
|
499
|
+
unsubscribeOriginsByRun.set(run, originsByTenant);
|
|
500
|
+
}
|
|
501
|
+
let origin = originsByTenant.get(channelCtx.tenantId);
|
|
502
|
+
if (origin === undefined) {
|
|
503
|
+
origin = unsubscribeLinkOrigin(links, channelCtx, (reason) =>
|
|
504
|
+
logError.warn(`unsubscribe link falls back to the app origin: ${reason}`, {
|
|
505
|
+
tenantId: channelCtx.tenantId,
|
|
506
|
+
notificationType,
|
|
507
|
+
}),
|
|
508
|
+
);
|
|
509
|
+
originsByTenant.set(channelCtx.tenantId, origin);
|
|
510
|
+
}
|
|
511
|
+
return origin;
|
|
512
|
+
}
|
|
513
|
+
|
|
487
514
|
// A caller-supplied unsubscribeUrl wins; the recipient comes from the server-side notify
|
|
488
515
|
// context, never from the notification data.
|
|
489
516
|
async function withUnsubscribeUrl(
|
|
517
|
+
run: NotifyRun,
|
|
490
518
|
message: ChannelMessage,
|
|
491
519
|
channelName: string,
|
|
492
520
|
recipient: UnsubscribeLinkRecipient,
|
|
@@ -498,11 +526,11 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
498
526
|
}
|
|
499
527
|
const supplied = message.data?.["unsubscribeUrl"];
|
|
500
528
|
if (typeof supplied === "string" && supplied !== "") return message;
|
|
501
|
-
const origin = await
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
529
|
+
const origin = await unsubscribeOriginOnce(
|
|
530
|
+
run,
|
|
531
|
+
unsubscribeLinks,
|
|
532
|
+
channelCtx,
|
|
533
|
+
message.notificationType,
|
|
506
534
|
);
|
|
507
535
|
const unsubscribeUrl = await buildUnsubscribeUrl(unsubscribeLinks, origin, recipient, {
|
|
508
536
|
tenantId: channelCtx.tenantId,
|
|
@@ -811,6 +839,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
811
839
|
channel,
|
|
812
840
|
address,
|
|
813
841
|
message: await withUnsubscribeUrl(
|
|
842
|
+
run,
|
|
814
843
|
message,
|
|
815
844
|
channel.name,
|
|
816
845
|
{ kind: "user", userId },
|
|
@@ -914,6 +943,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
914
943
|
channel,
|
|
915
944
|
address,
|
|
916
945
|
message: await withUnsubscribeUrl(
|
|
946
|
+
run,
|
|
917
947
|
message,
|
|
918
948
|
channel.name,
|
|
919
949
|
{ kind: "address", address },
|
|
@@ -62,6 +62,7 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
62
62
|
const resolvedAccess = options?.access ?? access.admin;
|
|
63
63
|
const attemptLogRetentionDays = resolveAttemptLogRetentionDays(options?.attemptLogRetentionDays);
|
|
64
64
|
return defineFeature("delivery", (r) => {
|
|
65
|
+
r.adoptsBundledLists();
|
|
65
66
|
r.describe(
|
|
66
67
|
"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` (the `runProdApp` and `runDevApp` 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. An app serving its own (e.g. legacy) unsubscribe links renders the same result page with `renderUnsubscribeResultPage({ kind, locale })`. 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.",
|
|
67
68
|
);
|
|
@@ -2,6 +2,7 @@ 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";
|
|
5
|
+
import { currentNotificationType } from "../notification-type-aliases.ts";
|
|
5
6
|
import { notificationPreferencesTable } from "../tables.ts";
|
|
6
7
|
|
|
7
8
|
export const preferencesQuery = defineQueryHandler({
|
|
@@ -37,6 +38,24 @@ export const preferencesQuery = defineQueryHandler({
|
|
|
37
38
|
userId: query.user.id,
|
|
38
39
|
});
|
|
39
40
|
|
|
40
|
-
|
|
41
|
+
// Rows stored under a former notification name are reported under the current
|
|
42
|
+
// one, collapsed with the same precedence as delivery: an exact row wins,
|
|
43
|
+
// otherwise any disabled former-name row disables.
|
|
44
|
+
const collapsed = new Map<string, { row: (typeof rows)[number]; isExact: boolean }>();
|
|
45
|
+
for (const row of rows) {
|
|
46
|
+
const notificationType = currentNotificationType(ctx.registry, row.notificationType);
|
|
47
|
+
const key = `${notificationType}\u0000${row.channel}`;
|
|
48
|
+
const isExact = row.notificationType === notificationType;
|
|
49
|
+
const existing = collapsed.get(key);
|
|
50
|
+
if (existing?.isExact) continue;
|
|
51
|
+
const formerNameRowDisablesAlready =
|
|
52
|
+
existing !== undefined &&
|
|
53
|
+
!isExact &&
|
|
54
|
+
(existing.row.enabled === false || row.enabled !== false);
|
|
55
|
+
if (formerNameRowDisablesAlready) continue;
|
|
56
|
+
collapsed.set(key, { row: { ...row, notificationType }, isExact });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { rows: [...collapsed.values()].map((entry) => entry.row) };
|
|
41
60
|
},
|
|
42
61
|
});
|
|
@@ -33,7 +33,7 @@ export const unsubscribePageChromeQuery = defineQueryHandler({
|
|
|
33
33
|
// or createUnsubscribePageChromeReader for a tenant the app resolved server-side.
|
|
34
34
|
agent: { expose: false },
|
|
35
35
|
description:
|
|
36
|
-
"Returns the tenant's mail brand row and footer resolved for one locale, or null without a mail renderer; dispatched by the signed unsubscribe route to dress its pages like the mail
|
|
36
|
+
"Returns the tenant's mail brand row and footer resolved for one locale, or null without a mail renderer; dispatched by the signed unsubscribe route or by createUnsubscribePageChromeReader for a tenant the app resolved server-side, to dress its pages like the mail; not meant for UI callers.",
|
|
37
37
|
handler: async (query, ctx): Promise<MailChrome | null> => {
|
|
38
38
|
const { tenantId } = query.user;
|
|
39
39
|
if (!ctx.systemDb) {
|
|
@@ -73,6 +73,11 @@ export {
|
|
|
73
73
|
} from "./handlers/unsubscribe-address.write.ts";
|
|
74
74
|
export { currentNotificationType } from "./notification-type-aliases.ts";
|
|
75
75
|
export { createDeliveryNotifyFactory } from "./notify-factory.ts";
|
|
76
|
+
export {
|
|
77
|
+
buildSystemChannelContext,
|
|
78
|
+
type SystemChannelContextDeps,
|
|
79
|
+
type SystemChannelContextOptions,
|
|
80
|
+
} from "./system-channel-context.ts";
|
|
76
81
|
export {
|
|
77
82
|
deliveryAttemptsTable,
|
|
78
83
|
notificationAddressOptOutEntity,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import type { ConfigAccessor, Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import type { FileContext } from "@cosmicdrift/kumiko-framework/files";
|
|
5
|
+
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
6
|
+
import { buildChannelContext } from "./channel-context.ts";
|
|
7
|
+
import type { ChannelContext } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
export type SystemChannelContextDeps = {
|
|
10
|
+
readonly db: DbConnection;
|
|
11
|
+
readonly registry: Registry;
|
|
12
|
+
readonly filesForTenant: (tenantId: string) => FileContext | undefined;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type SystemChannelContextOptions = {
|
|
16
|
+
readonly config?: ConfigAccessor;
|
|
17
|
+
readonly secrets?: SecretsContext;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function buildSystemChannelContext(
|
|
21
|
+
deps: SystemChannelContextDeps,
|
|
22
|
+
options: SystemChannelContextOptions = {},
|
|
23
|
+
): ChannelContext {
|
|
24
|
+
return buildChannelContext(
|
|
25
|
+
deps.db,
|
|
26
|
+
deps.registry,
|
|
27
|
+
undefined,
|
|
28
|
+
SYSTEM_TENANT_ID,
|
|
29
|
+
options.secrets,
|
|
30
|
+
options.config,
|
|
31
|
+
deps.filesForTenant,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -10,7 +10,9 @@ export type UnsubscribeLinkConfig = {
|
|
|
10
10
|
readonly secret: string;
|
|
11
11
|
readonly baseUrl: string;
|
|
12
12
|
/** The sending tenant's own public origin (subdomain or custom domain), null when it has
|
|
13
|
-
* none. The host must serve this app's `/api` routes
|
|
13
|
+
* none. The host must serve this app's `/api` routes, and the callback must only return a
|
|
14
|
+
* verified, active domain of the tenant (the check that lets the app serve under it), else null:
|
|
15
|
+
* the link carries a bearer token that unsubscribes and yields a resubscribe token. Null, a throw or an unusable value
|
|
14
16
|
* fall back to `baseUrl`, so a missing tenant origin never fails a send. */
|
|
15
17
|
readonly tenantOrigin?: (ctx: ChannelContext) => Promise<string | null | undefined>;
|
|
16
18
|
};
|
|
@@ -14,6 +14,7 @@ export const UNSUBSCRIBE_PAGE_EN = {
|
|
|
14
14
|
"mail.unsubscribe.resubscribedMessage": "You have been subscribed again.",
|
|
15
15
|
"mail.unsubscribe.failed": "That did not work. Please try again later.",
|
|
16
16
|
"mail.unsubscribe.failedTitle": "Something went wrong",
|
|
17
|
+
"mail.unsubscribe.resubscribeLimitReached": "This address cannot be subscribed again here.",
|
|
17
18
|
"mail.unsubscribe.invalidTitle": "Link no longer valid",
|
|
18
19
|
"mail.unsubscribe.invalidMessage":
|
|
19
20
|
"This link is invalid or has expired. Please use the unsubscribe link in a more recent email.",
|
|
@@ -11,6 +11,7 @@ export type UnsubscribePageState =
|
|
|
11
11
|
| { readonly kind: "unsubscribed"; readonly resubscribeToken?: string }
|
|
12
12
|
| { readonly kind: "resubscribed" }
|
|
13
13
|
| { readonly kind: "failed" }
|
|
14
|
+
| { readonly kind: "resubscribeLimitReached" }
|
|
14
15
|
| { readonly kind: "invalid" };
|
|
15
16
|
|
|
16
17
|
export type UnsubscribePageInput = {
|
|
@@ -162,6 +163,11 @@ ${tokenForm(DELIVERY_RESUBSCRIBE_PATH, state.resubscribeToken, mailT(lang, "mail
|
|
|
162
163
|
title: mailT(lang, "mail.unsubscribe.failedTitle"),
|
|
163
164
|
bodyHtml: paragraph("mail.unsubscribe.failed"),
|
|
164
165
|
};
|
|
166
|
+
case "resubscribeLimitReached":
|
|
167
|
+
return {
|
|
168
|
+
title: mailT(lang, "mail.unsubscribe.failedTitle"),
|
|
169
|
+
bodyHtml: paragraph("mail.unsubscribe.resubscribeLimitReached"),
|
|
170
|
+
};
|
|
165
171
|
case "invalid":
|
|
166
172
|
return {
|
|
167
173
|
title: mailT(lang, "mail.unsubscribe.invalidTitle"),
|
|
@@ -569,11 +569,20 @@ async function verifyPost(
|
|
|
569
569
|
encodedSecret: Uint8Array,
|
|
570
570
|
purpose: TokenPurpose,
|
|
571
571
|
): Promise<VerifiedUnsubscribe | RejectedForPage> {
|
|
572
|
-
return isFormRequest(request.headers["content-type"])
|
|
572
|
+
return isFormRequest(request.headers["content-type"]) && !isOneClickPost(request)
|
|
573
573
|
? verifyForPage(token, encodedSecret, purpose, request.headers)
|
|
574
574
|
: verifyUnsubscribeToken(token, encodedSecret, purpose);
|
|
575
575
|
}
|
|
576
576
|
|
|
577
|
+
// RFC 8058 mail-provider POSTs are form-encoded too, but nobody looks at the answer: they keep
|
|
578
|
+
// the JSON rejection and get no branded page.
|
|
579
|
+
function isOneClickPost(request: SignatureExtraRouteVerifyRequest): boolean {
|
|
580
|
+
return (
|
|
581
|
+
isFormRequest(request.headers["content-type"]) &&
|
|
582
|
+
request.rawBody.trim() === DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
577
586
|
function isFormRequest(contentType: string | undefined): boolean {
|
|
578
587
|
return contentType?.includes("application/x-www-form-urlencoded") === true;
|
|
579
588
|
}
|
|
@@ -635,22 +644,37 @@ export function createUnsubscribeRoutes(
|
|
|
635
644
|
: pageResponse(c, deps, { kind: "confirm", token: verified.token }, verified, 200),
|
|
636
645
|
});
|
|
637
646
|
|
|
638
|
-
const writeRoute = signatureRoute<
|
|
647
|
+
const writeRoute = signatureRoute<
|
|
648
|
+
(VerifiedUnsubscribe & { readonly oneClick: boolean }) | RejectedForPage
|
|
649
|
+
>({
|
|
639
650
|
method: "POST",
|
|
640
651
|
path: DELIVERY_UNSUBSCRIBE_PATH,
|
|
641
652
|
entry: "signature",
|
|
642
|
-
verify: async (request) =>
|
|
643
|
-
|
|
653
|
+
verify: async (request) => {
|
|
654
|
+
const verified = await verifyPost(
|
|
655
|
+
request,
|
|
656
|
+
tokenFromPostRequest(request),
|
|
657
|
+
encodedSecret,
|
|
658
|
+
"unsubscribe",
|
|
659
|
+
);
|
|
660
|
+
return verified.kind === "rejected"
|
|
661
|
+
? verified
|
|
662
|
+
: { ...verified, oneClick: isOneClickPost(request) };
|
|
663
|
+
},
|
|
644
664
|
handler: async (c, verified, deps) => {
|
|
645
665
|
if (verified.kind === "rejected") {
|
|
646
666
|
return pageResponse(c, deps, { kind: "invalid" }, verified, 400);
|
|
647
667
|
}
|
|
668
|
+
// No chrome lookup for a one-click client: it never renders the page.
|
|
669
|
+
const pageScope: PageScope = verified.oneClick
|
|
670
|
+
? { tenantId: undefined, locale: verified.locale }
|
|
671
|
+
: verified;
|
|
648
672
|
// Token-verify passed — everything below is a legitimate write. Don't
|
|
649
673
|
// swallow write-errors as "invalid token", that would mask real bugs
|
|
650
674
|
// (e.g. events-table missing, DB down) behind a misleading 400.
|
|
651
675
|
const succeeded = await dispatchUnsubscribeWrite(verified, deps);
|
|
652
676
|
if (!succeeded) {
|
|
653
|
-
return pageResponse(c, deps, { kind: "failed" },
|
|
677
|
+
return pageResponse(c, deps, { kind: "failed" }, pageScope, 500);
|
|
654
678
|
}
|
|
655
679
|
return pageResponse(
|
|
656
680
|
c,
|
|
@@ -659,7 +683,7 @@ export function createUnsubscribeRoutes(
|
|
|
659
683
|
kind: "unsubscribed",
|
|
660
684
|
resubscribeToken: await signResubscribeToken(verified, encodedSecret),
|
|
661
685
|
},
|
|
662
|
-
|
|
686
|
+
pageScope,
|
|
663
687
|
200,
|
|
664
688
|
);
|
|
665
689
|
},
|
|
@@ -681,15 +705,12 @@ export function createUnsubscribeRoutes(
|
|
|
681
705
|
const outcome = await dispatchResubscribeWrite(verified, deps);
|
|
682
706
|
// The undo button on the unsubscribed page posts a form; a browser should land on a page.
|
|
683
707
|
if (isFormRequest(c.req.header("content-type"))) {
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
verified,
|
|
691
|
-
outcome === "limit_reached" ? 409 : 500,
|
|
692
|
-
);
|
|
708
|
+
if (outcome === "success") {
|
|
709
|
+
return pageResponse(c, deps, { kind: "resubscribed" }, verified, 200);
|
|
710
|
+
}
|
|
711
|
+
return outcome === "limit_reached"
|
|
712
|
+
? pageResponse(c, deps, { kind: "resubscribeLimitReached" }, verified, 409)
|
|
713
|
+
: pageResponse(c, deps, { kind: "failed" }, verified, 500);
|
|
693
714
|
}
|
|
694
715
|
if (outcome === "limit_reached") {
|
|
695
716
|
return c.json(
|
package/packages/bundled-features/src/document-ingest-foundation/append-missing-extract-event.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import type { Registry, SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import {
|
|
5
|
+
appendEventInTenantDb,
|
|
6
|
+
getStreamVersionInTenantDb,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/event-store";
|
|
8
|
+
import {
|
|
9
|
+
DOCUMENT_EXTRACT_AGGREGATE_TYPE,
|
|
10
|
+
documentExtractFailedPayloadSchema,
|
|
11
|
+
documentExtractReadyPayloadSchema,
|
|
12
|
+
} from "./events.ts";
|
|
13
|
+
|
|
14
|
+
export type ExistingDocumentExtract = {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly meta: { readonly error?: unknown } | null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type AppendMissingExtractEventDeps = {
|
|
20
|
+
readonly tenantDb: TenantDb;
|
|
21
|
+
readonly registry: Registry;
|
|
22
|
+
readonly actor: SessionUser;
|
|
23
|
+
readonly tenantId: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type AppendMissingExtractEventOptions = {
|
|
27
|
+
readonly readyEventQn: string;
|
|
28
|
+
readonly failedEventQn: string;
|
|
29
|
+
readonly mapError?: (error: string) => string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type AppendMissingExtractEventResult =
|
|
33
|
+
| "appended-ready"
|
|
34
|
+
| "appended-failed"
|
|
35
|
+
| "already-terminal";
|
|
36
|
+
|
|
37
|
+
// Version 1 of the stream is the extract's created event; anything beyond it is the terminal event.
|
|
38
|
+
const CREATED_ONLY_STREAM_VERSION = 1;
|
|
39
|
+
const UNKNOWN_EXTRACT_ERROR = "unknown extract error";
|
|
40
|
+
|
|
41
|
+
// A previous delivery created the extract but died before appending its ready/failed event.
|
|
42
|
+
// Without the event, consumers waiting on it hang forever.
|
|
43
|
+
export async function appendMissingDocumentExtractEvent(
|
|
44
|
+
deps: AppendMissingExtractEventDeps,
|
|
45
|
+
existing: ExistingDocumentExtract,
|
|
46
|
+
fileRefId: string,
|
|
47
|
+
options: AppendMissingExtractEventOptions,
|
|
48
|
+
): Promise<AppendMissingExtractEventResult> {
|
|
49
|
+
if (deps.tenantDb.tenantId !== deps.tenantId) {
|
|
50
|
+
throw new InternalError({
|
|
51
|
+
message: `document-ingest-foundation: tenantDb is bound to tenant ${deps.tenantDb.tenantId}, not ${deps.tenantId}`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const version = await getStreamVersionInTenantDb(deps.tenantDb, existing.id);
|
|
55
|
+
// A missing stream means a wrong id; appending would make the terminal event the first event of a new stream.
|
|
56
|
+
if (version === 0) {
|
|
57
|
+
throw new InternalError({
|
|
58
|
+
message: `document-ingest-foundation: no document extract stream for id ${existing.id}`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
// skip: the terminal event was already written, nothing to catch up
|
|
62
|
+
if (version > CREATED_ONLY_STREAM_VERSION) return "already-terminal";
|
|
63
|
+
const metaError = existing.meta?.error;
|
|
64
|
+
const failed = typeof metaError === "string";
|
|
65
|
+
await appendEventInTenantDb(
|
|
66
|
+
deps.tenantDb,
|
|
67
|
+
{
|
|
68
|
+
aggregateId: existing.id,
|
|
69
|
+
aggregateType: DOCUMENT_EXTRACT_AGGREGATE_TYPE,
|
|
70
|
+
expectedVersion: version,
|
|
71
|
+
type: failed ? options.failedEventQn : options.readyEventQn,
|
|
72
|
+
payload: failed
|
|
73
|
+
? documentExtractFailedPayloadSchema.parse({
|
|
74
|
+
documentExtractId: existing.id,
|
|
75
|
+
fileRefId,
|
|
76
|
+
error: options.mapError?.(metaError) || metaError || UNKNOWN_EXTRACT_ERROR,
|
|
77
|
+
})
|
|
78
|
+
: documentExtractReadyPayloadSchema.parse({ documentExtractId: existing.id, fileRefId }),
|
|
79
|
+
metadata: { userId: deps.actor.id },
|
|
80
|
+
},
|
|
81
|
+
{ registry: deps.registry },
|
|
82
|
+
);
|
|
83
|
+
return failed ? "appended-failed" : "appended-ready";
|
|
84
|
+
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.389.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "appendMissingDocumentExtractEvent and shared ready/failed payload schemas",
|
|
6
|
+
"detail": "A provider whose job died after creating the extract calls appendMissingDocumentExtractEvent(deps, existing, fileRefId, { readyEventQn, failedEventQn, mapError }) to append the missing terminal event; it returns appended-ready, appended-failed or already-terminal. documentExtractReadyPayloadSchema and documentExtractFailedPayloadSchema are exported so providers register their own event names with the shared payload shape."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.379.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -47,3 +47,18 @@ export const documentIngestSkippedPayloadSchema = z.object({
|
|
|
47
47
|
reason: z.enum(["file-too-large", "unsupported-mime-type"]),
|
|
48
48
|
});
|
|
49
49
|
export type DocumentIngestSkippedPayload = z.infer<typeof documentIngestSkippedPayloadSchema>;
|
|
50
|
+
|
|
51
|
+
// Terminal events of a document-extract stream. The QNs belong to the provider that registers
|
|
52
|
+
// them; the foundation only owns the shared payload shape.
|
|
53
|
+
export const documentExtractReadyPayloadSchema = z.object({
|
|
54
|
+
documentExtractId: z.string().min(1),
|
|
55
|
+
fileRefId: z.string().min(1),
|
|
56
|
+
});
|
|
57
|
+
export type DocumentExtractReadyPayload = z.infer<typeof documentExtractReadyPayloadSchema>;
|
|
58
|
+
|
|
59
|
+
export const documentExtractFailedPayloadSchema = z.object({
|
|
60
|
+
documentExtractId: z.string().min(1),
|
|
61
|
+
fileRefId: z.string().min(1),
|
|
62
|
+
error: z.string().min(1),
|
|
63
|
+
});
|
|
64
|
+
export type DocumentExtractFailedPayload = z.infer<typeof documentExtractFailedPayloadSchema>;
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
// Public API of the document-ingest-foundation bundled-feature.
|
|
2
2
|
|
|
3
3
|
export { documentIngestAggregateId } from "./aggregate-id.ts";
|
|
4
|
+
export {
|
|
5
|
+
type AppendMissingExtractEventDeps,
|
|
6
|
+
type AppendMissingExtractEventOptions,
|
|
7
|
+
type AppendMissingExtractEventResult,
|
|
8
|
+
appendMissingDocumentExtractEvent,
|
|
9
|
+
type ExistingDocumentExtract,
|
|
10
|
+
} from "./append-missing-extract-event.ts";
|
|
4
11
|
export {
|
|
5
12
|
type DocumentExtractMeta,
|
|
6
13
|
documentExtractEntity,
|
|
@@ -14,8 +21,12 @@ export {
|
|
|
14
21
|
DOCUMENT_INGEST_REQUESTED_EVENT_SHORT,
|
|
15
22
|
DOCUMENT_INGEST_SKIPPED_EVENT_QN,
|
|
16
23
|
DOCUMENT_INGEST_SKIPPED_EVENT_SHORT,
|
|
24
|
+
type DocumentExtractFailedPayload,
|
|
25
|
+
type DocumentExtractReadyPayload,
|
|
17
26
|
type DocumentIngestRequestedPayload,
|
|
18
27
|
type DocumentIngestSkippedPayload,
|
|
28
|
+
documentExtractFailedPayloadSchema,
|
|
29
|
+
documentExtractReadyPayloadSchema,
|
|
19
30
|
documentIngestRequestedPayloadSchema,
|
|
20
31
|
documentIngestSkippedPayloadSchema,
|
|
21
32
|
} from "./events.ts";
|
|
@@ -44,6 +44,7 @@ export function createFeatureTogglesFeature(
|
|
|
44
44
|
options: FeatureTogglesOptions = {},
|
|
45
45
|
): FeatureDefinition {
|
|
46
46
|
return defineFeature("feature-toggles", (r) => {
|
|
47
|
+
r.adoptsBundledLists();
|
|
47
48
|
r.describe(
|
|
48
49
|
'Persists per-feature enabled/disabled state in the `store_global_feature_state` table and exposes a `set` write-handler plus `list`/`registered` query-handlers so operators can flip features at runtime without redeploying. Each API instance keeps an in-memory `GlobalFeatureToggleRuntime` snapshot (initialize it via `createFeatureToggleRuntime`, pass a `() => runtime` accessor to `createFeatureTogglesFeature`) that the dispatcher gate reads on every request; a `toggle-cache-sync` multi-stream projection with `delivery: "shared"` syncs the snapshot across instances whenever a `toggle-set` event is appended.',
|
|
49
50
|
);
|