@cosmicdrift/kumiko-samples 0.363.0 → 0.365.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/LICENSE +1 -1
- package/package.json +1 -1
- package/packages/bundled-features/package.json +7 -1
- package/packages/bundled-features/src/audit/changes.json +18 -0
- package/packages/bundled-features/src/audit/feature.ts +1 -1
- package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
- package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +63 -11
- package/packages/bundled-features/src/auth-email-password/bootstrap.ts +30 -11
- package/packages/bundled-features/src/auth-email-password/changes.json +55 -0
- package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
- package/packages/bundled-features/src/auth-email-password/feature.ts +4 -7
- package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -3
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +6 -4
- package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
- package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +2 -2
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -1
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +7 -3
- package/packages/bundled-features/src/auth-email-password/mail-i18n.ts +10 -0
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
- package/packages/bundled-features/src/billing-foundation/changes.json +48 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +57 -5
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +4 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +9 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +18 -2
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +35 -19
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
- package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
- package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +10 -4
- package/packages/bundled-features/src/billing-foundation/i18n.ts +9 -0
- package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +27 -7
- package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
- package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +20 -8
- package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +7 -2
- package/packages/bundled-features/src/cap-counter/changes.json +19 -0
- package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
- package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +2 -0
- package/packages/bundled-features/src/cap-counter/index.ts +1 -0
- package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
- package/packages/bundled-features/src/cap-overview/changes.json +6 -0
- package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
- package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +2 -1
- package/packages/bundled-features/src/channel-discord/discord-channel.ts +4 -2
- package/packages/bundled-features/src/channel-email/changes.json +18 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +47 -24
- package/packages/bundled-features/src/channel-email/smtp-transport.ts +7 -1
- package/packages/bundled-features/src/channel-slack/slack-channel.ts +4 -2
- package/packages/bundled-features/src/channel-teams/teams-channel.ts +4 -2
- package/packages/bundled-features/src/config/changes.json +6 -0
- package/packages/bundled-features/src/config/handlers/set.write.ts +2 -2
- package/packages/bundled-features/src/config/resolver.ts +15 -12
- package/packages/bundled-features/src/config/write-helpers.ts +23 -0
- package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +54 -27
- package/packages/bundled-features/src/custom-fields/changes.json +6 -0
- package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
- package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
- package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
- package/packages/bundled-features/src/delivery/attachment-ref.ts +39 -1
- package/packages/bundled-features/src/delivery/changes.json +30 -0
- package/packages/bundled-features/src/delivery/channel-context.ts +31 -8
- package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
- package/packages/bundled-features/src/delivery/index.ts +2 -0
- package/packages/bundled-features/src/delivery/jobs.ts +51 -14
- package/packages/bundled-features/src/delivery/types.ts +7 -1
- package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
- package/packages/bundled-features/src/feature-toggles/changes.json +7 -0
- package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +26 -12
- package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
- package/packages/bundled-features/src/file-foundation/changes.json +6 -0
- package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +6 -3
- package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
- package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
- package/packages/bundled-features/src/files-tenant-data/index.ts +3 -1
- package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +12 -3
- package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
- package/packages/bundled-features/src/jobs/changes.json +31 -0
- package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
- package/packages/bundled-features/src/jobs/feature.ts +7 -4
- package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
- package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
- package/packages/bundled-features/src/jobs/job-run-logger.ts +56 -18
- package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
- package/packages/bundled-features/src/ledger/changes.json +7 -0
- package/packages/bundled-features/src/ledger/entity.ts +3 -1
- package/packages/bundled-features/src/metrics/changes.json +6 -0
- package/packages/bundled-features/src/metrics/filters.ts +28 -12
- package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
- package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +7 -8
- package/packages/bundled-features/src/renderer-foundation/types.ts +2 -0
- package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
- package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
- package/packages/bundled-features/src/secrets/changes.json +12 -0
- package/packages/bundled-features/src/secrets/feature.ts +4 -3
- package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
- package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
- package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
- package/packages/bundled-features/src/shared/changes.json +6 -0
- package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
- package/packages/bundled-features/src/shared/index.ts +8 -0
- package/packages/bundled-features/src/shared/mail-template-body.ts +17 -0
- package/packages/bundled-features/src/shared/resource-image-formats.ts +31 -0
- package/packages/bundled-features/src/shared/sharp-input-limits.ts +7 -8
- package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +2 -2
- package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
- package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
- package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
- package/packages/bundled-features/src/template-resolver/README.md +4 -2
- package/packages/bundled-features/src/template-resolver/api.ts +3 -2
- package/packages/bundled-features/src/template-resolver/changes.json +149 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +148 -18
- package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
- package/packages/bundled-features/src/template-resolver/constants.ts +23 -3
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
- package/packages/bundled-features/src/template-resolver/document-schema.ts +33 -5
- package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +16 -1
- package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +81 -39
- package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +4 -3
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +121 -53
- package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +6 -44
- package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
- package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +56 -1
- package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +6 -3
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +25 -15
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +12 -1
- package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +73 -30
- package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +14 -11
- package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +102 -19
- package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +49 -10
- package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +39 -10
- package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +14 -2
- package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +54 -4
- package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +96 -14
- package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +51 -24
- package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +16 -2
- package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +4 -1
- package/packages/bundled-features/src/template-resolver/index.ts +18 -2
- package/packages/bundled-features/src/template-resolver/layout-appearance.ts +56 -0
- package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -0
- package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
- package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
- package/packages/bundled-features/src/template-resolver/resource-image.ts +9 -16
- package/packages/bundled-features/src/template-resolver/seeding.ts +27 -4
- package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
- package/packages/bundled-features/src/template-resolver/table.ts +13 -1
- package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
- package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
- package/packages/bundled-features/src/template-resolver/template-meta.ts +95 -24
- package/packages/bundled-features/src/template-resolver/template-preview.ts +160 -57
- package/packages/bundled-features/src/template-resolver/template-resources.ts +3 -4
- package/packages/bundled-features/src/template-resolver/template-usage.ts +31 -11
- package/packages/bundled-features/src/template-resolver/template-validation.ts +16 -9
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +12 -8
- package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +97 -10
- package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +15 -5
- package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +405 -145
- package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +17 -3
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +32 -4
- package/packages/bundled-features/src/template-resolver/web/index.ts +8 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +23 -7
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +3 -8
- package/packages/bundled-features/src/tenant/seeding.ts +33 -27
- package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +14 -3
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +9 -1
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +3 -2
- package/packages/bundled-features/src/tier-engine/changes.json +18 -0
- package/packages/bundled-features/src/tier-engine/constants.ts +9 -0
- package/packages/bundled-features/src/tier-engine/entity.ts +9 -4
- package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +8 -5
- package/packages/bundled-features/src/tier-engine/index.ts +2 -0
- package/packages/bundled-features/src/user/changes.json +6 -0
- package/packages/bundled-features/src/user/constants.ts +1 -0
- package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
- package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
- package/packages/bundled-features/src/user/seeding.ts +19 -5
- package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
- package/packages/bundled-features/src/user-data-rights/email-templates.ts +0 -1
- package/packages/bundled-features/src/user-data-rights/i18n.ts +5 -0
- package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +13 -2
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +7 -1
- package/packages/bundled-features/src/user-profile/changes.json +1 -1
- package/packages/bundled-features/src/waitlist/changes.json +18 -0
- package/packages/bundled-features/src/waitlist/feature.ts +1 -1
- package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +35 -3
- package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +2 -17
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +33 -9
- package/packages/bundled-features/src/waitlist/i18n.ts +9 -0
- package/packages/bundled-features/src/waitlist/lib.ts +20 -0
- package/packages/bundled-features/src/waitlist/options.ts +1 -1
- package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
- package/packages/bundled-features/src/workflow-runner/event-trigger.ts +2 -1
- package/packages/bundled-features/src/workflow-runner/feature.ts +1 -1
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
- package/samples/apps/ui-walkthrough/package.json +9 -0
- package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
- package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +87 -2
- package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +246 -30
- package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +4 -0
- package/samples/apps/ui-walkthrough/src/run-config.ts +18 -1
- package/samples/apps/use-all-bundled/README.md +3 -13
- package/samples/apps/use-all-bundled/feature-manifest.json +7 -5
- package/samples/apps/use-all-bundled/scripts/check-coverage.ts +34 -118
- package/samples/apps/use-all-bundled/src/app/notes-feature.ts +1 -1
- package/samples/apps/use-all-bundled/src/run-config.ts +0 -3
- package/samples/recipes/auth-mfa/README.md +2 -1
- package/samples/recipes/auth-mfa/src/feature.ts +1 -0
- package/samples/recipes/custom-handlers/README.md +4 -3
- package/samples/recipes/custom-handlers/src/feature.ts +21 -12
- package/samples/recipes/dashboard-stats/README.md +38 -0
- package/samples/recipes/dashboard-stats/package.json +8 -0
- package/samples/recipes/dashboard-stats/src/feature.ts +104 -0
- package/samples/recipes/legal-pages/README.md +2 -2
- package/samples/recipes/managed-pages/README.md +2 -2
- package/samples/recipes/pdf-audit-export/README.md +2 -1
- package/samples/recipes/pdf-audit-export/package.json +1 -1
- package/samples/recipes/pdf-audit-export/src/feature.ts +68 -48
- package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +2 -2
- package/samples/apps/use-all-bundled/schema/generate.ts +0 -617
- package/samples/apps/use-all-bundled/schema/migrations/meta/0000_snapshot.json +0 -3055
- package/samples/apps/use-all-bundled/schema/migrations/meta/_journal.json +0 -13
- package/samples/apps/use-all-bundled/schema/schema.generated.ts +0 -94
- package/samples/apps/use-all-bundled/schema/schema.ts +0 -9
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-framework/crypto";
|
|
2
2
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
3
4
|
import * as z from "zod";
|
|
4
5
|
import {
|
|
5
6
|
type AttachmentOrigin,
|
|
@@ -15,7 +16,7 @@ import {
|
|
|
15
16
|
type RenderedMessage,
|
|
16
17
|
renderedPdfAttachmentRefSchema,
|
|
17
18
|
} from "../delivery/index.js";
|
|
18
|
-
import { decryptStoredPii } from "../shared/index.js";
|
|
19
|
+
import { decryptStoredPii, extensionForResourceContentType } from "../shared/index.js";
|
|
19
20
|
import { guardEmailMessage } from "./pii-guard.js";
|
|
20
21
|
import type { EmailAttachment, EmailTransport } from "./types.js";
|
|
21
22
|
|
|
@@ -39,7 +40,8 @@ const UNSUBSCRIBE_HEADER_NAMES: ReadonlySet<string> = new Set([
|
|
|
39
40
|
"list-unsubscribe-post",
|
|
40
41
|
]);
|
|
41
42
|
|
|
42
|
-
// Only the framework's own unsubscribe route can honor a one-click POST
|
|
43
|
+
// Only the framework's own unsubscribe route can honor a one-click POST; an app's
|
|
44
|
+
// own unsubscribe page still gets the plain List-Unsubscribe link (RFC 2369).
|
|
43
45
|
function listUnsubscribeHeaders(
|
|
44
46
|
data: Readonly<Record<string, unknown>> | undefined,
|
|
45
47
|
): Readonly<Record<string, string>> | undefined {
|
|
@@ -52,7 +54,9 @@ function listUnsubscribeHeaders(
|
|
|
52
54
|
return undefined;
|
|
53
55
|
}
|
|
54
56
|
if (url.protocol !== "https:" && url.protocol !== "http:") return undefined;
|
|
55
|
-
if (url.pathname !== DELIVERY_UNSUBSCRIBE_PATH)
|
|
57
|
+
if (url.pathname !== DELIVERY_UNSUBSCRIBE_PATH) {
|
|
58
|
+
return { "List-Unsubscribe": `<${url.toString()}>` };
|
|
59
|
+
}
|
|
56
60
|
return {
|
|
57
61
|
"List-Unsubscribe": `<${url.toString()}>`,
|
|
58
62
|
"List-Unsubscribe-Post": DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE,
|
|
@@ -110,20 +114,22 @@ export function fileAttachmentsFrom(
|
|
|
110
114
|
);
|
|
111
115
|
}
|
|
112
116
|
|
|
113
|
-
const INLINE_EXTENSION_BY_CONTENT_TYPE: Readonly<Record<string, string>> = {
|
|
114
|
-
"image/png": "png",
|
|
115
|
-
"image/jpeg": "jpg",
|
|
116
|
-
"image/gif": "gif",
|
|
117
|
-
"image/webp": "webp",
|
|
118
|
-
};
|
|
119
|
-
|
|
120
117
|
type AttachmentSource = { readonly ref: EmailAttachmentRef; readonly origin: AttachmentOrigin };
|
|
121
118
|
|
|
119
|
+
function inlineFileName(cid: string, contentType: string): string {
|
|
120
|
+
const extension = extensionForResourceContentType(contentType);
|
|
121
|
+
return extension === undefined ? cid : `${cid}.${extension}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
type ResolvedAttachments =
|
|
125
|
+
| { readonly kind: "resolved"; readonly attachments: readonly EmailAttachment[] }
|
|
126
|
+
| { readonly kind: "missing-file"; readonly fileRefId: string };
|
|
127
|
+
|
|
122
128
|
async function resolveAttachments(
|
|
123
129
|
sources: readonly AttachmentSource[],
|
|
124
130
|
ctx: ChannelContext,
|
|
125
|
-
): Promise<
|
|
126
|
-
if (sources.length === 0) return [];
|
|
131
|
+
): Promise<ResolvedAttachments> {
|
|
132
|
+
if (sources.length === 0) return { kind: "resolved", attachments: [] };
|
|
127
133
|
if (ctx.readAttachment === undefined) {
|
|
128
134
|
throw new Error(
|
|
129
135
|
"email channel: the message has attachments but the channel context cannot read files",
|
|
@@ -148,17 +154,13 @@ async function resolveAttachments(
|
|
|
148
154
|
if (found === null) {
|
|
149
155
|
// A missing inline image only degrades the look; a missing file is content the recipient expects.
|
|
150
156
|
if (ref.kind === "inline") continue;
|
|
151
|
-
|
|
157
|
+
return { kind: "missing-file", fileRefId: ref.fileRefId };
|
|
152
158
|
}
|
|
153
159
|
const fileName =
|
|
154
|
-
ref.kind === "file"
|
|
155
|
-
? ref.fileName
|
|
156
|
-
: INLINE_EXTENSION_BY_CONTENT_TYPE[found.contentType]
|
|
157
|
-
? `${ref.cid}.${INLINE_EXTENSION_BY_CONTENT_TYPE[found.contentType]}`
|
|
158
|
-
: ref.cid;
|
|
160
|
+
ref.kind === "file" ? ref.fileName : inlineFileName(ref.cid, found.contentType);
|
|
159
161
|
resolved.push({ ...ref, fileName, content: found.content, contentType: found.contentType });
|
|
160
162
|
}
|
|
161
|
-
return resolved;
|
|
163
|
+
return { kind: "resolved", attachments: resolved };
|
|
162
164
|
}
|
|
163
165
|
|
|
164
166
|
export type EmailChannelOptions = {
|
|
@@ -191,13 +193,20 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
|
|
|
191
193
|
);
|
|
192
194
|
}
|
|
193
195
|
const unsubscribeUrl = message.data?.["unsubscribeUrl"];
|
|
194
|
-
const { subject, html, text, attachments } = await ctx.renderMail({
|
|
196
|
+
const { subject, html, text, attachments, fromName, replyTo } = await ctx.renderMail({
|
|
195
197
|
slug: notificationType,
|
|
196
198
|
locale: message.locale ?? "en",
|
|
197
199
|
variables: message.data ?? {},
|
|
198
200
|
...(typeof unsubscribeUrl === "string" && { unsubscribeUrl }),
|
|
199
201
|
});
|
|
200
|
-
return {
|
|
202
|
+
return {
|
|
203
|
+
html,
|
|
204
|
+
subject,
|
|
205
|
+
text,
|
|
206
|
+
...(fromName !== undefined && { fromName }),
|
|
207
|
+
...(replyTo !== undefined && { replyTo }),
|
|
208
|
+
...(attachments !== undefined && { attachments }),
|
|
209
|
+
};
|
|
201
210
|
}
|
|
202
211
|
|
|
203
212
|
return {
|
|
@@ -217,9 +226,15 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
|
|
|
217
226
|
},
|
|
218
227
|
|
|
219
228
|
async send(address, message, ctx, rendered) {
|
|
220
|
-
const { html, subject, text, attachments } =
|
|
221
|
-
|
|
222
|
-
|
|
229
|
+
const { html, subject, text, attachments, fromName, replyTo } =
|
|
230
|
+
rendered ?? (await renderMessage(message, ctx));
|
|
231
|
+
// Template-level sender defaults; notify data wins.
|
|
232
|
+
const envelope = {
|
|
233
|
+
...(fromName && { fromName }),
|
|
234
|
+
...(replyTo && { replyTo }),
|
|
235
|
+
...emailEnvelopeFrom(message.data),
|
|
236
|
+
};
|
|
237
|
+
const resolution = await resolveAttachments(
|
|
223
238
|
[
|
|
224
239
|
...(attachments ?? []).map((ref): AttachmentSource => ({ ref, origin: "template" })),
|
|
225
240
|
...fileAttachmentsFrom(message.data, message.locale).map(
|
|
@@ -228,6 +243,14 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
|
|
|
228
243
|
],
|
|
229
244
|
ctx,
|
|
230
245
|
);
|
|
246
|
+
if (resolution.kind === "missing-file") {
|
|
247
|
+
// A missing file is permanent, a retry cannot bring it back.
|
|
248
|
+
createFallbackLogger("delivery").warn("mail attachment file not found, send failed", {
|
|
249
|
+
fileRefId: resolution.fileRefId,
|
|
250
|
+
});
|
|
251
|
+
return { status: "failed", error: "send_failed", address };
|
|
252
|
+
}
|
|
253
|
+
const resolvedAttachments = resolution.attachments;
|
|
231
254
|
await transport.send(
|
|
232
255
|
guardEmailMessage({
|
|
233
256
|
to: address,
|
|
@@ -54,11 +54,17 @@ function isControlCharacter(char: string): boolean {
|
|
|
54
54
|
return code <= 0x1f || code === 0x7f;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// Mail clients often show only the display name: an embedded address ("<", ">", "@") would
|
|
58
|
+
// pass for a foreign sender on the platform's own SPF/DKIM-signed domain.
|
|
59
|
+
function isUnsafeDisplayNameCharacter(char: string): boolean {
|
|
60
|
+
return isControlCharacter(char) || char === "<" || char === ">" || char === "@";
|
|
61
|
+
}
|
|
62
|
+
|
|
57
63
|
// Control characters in a display name would let a caller inject headers.
|
|
58
64
|
function sanitizedDisplayName(name: string | undefined): string | undefined {
|
|
59
65
|
const cleaned = name
|
|
60
66
|
?.split("")
|
|
61
|
-
.map((char) => (
|
|
67
|
+
.map((char) => (isUnsafeDisplayNameCharacter(char) ? " " : char))
|
|
62
68
|
.join("")
|
|
63
69
|
.trim();
|
|
64
70
|
return cleaned === undefined || cleaned === "" ? undefined : cleaned;
|
|
@@ -7,9 +7,11 @@ import {
|
|
|
7
7
|
|
|
8
8
|
export type SlackChannelOptions = ChatWebhookChannelOptions;
|
|
9
9
|
|
|
10
|
+
const SLACK_WEBHOOK_PREFIX = "channel-slack:webhooks.";
|
|
11
|
+
|
|
10
12
|
export const SLACK_SECRET_KEYS = {
|
|
11
|
-
webhookPrefix:
|
|
12
|
-
webhookKeyFor: (connection: string) =>
|
|
13
|
+
webhookPrefix: SLACK_WEBHOOK_PREFIX,
|
|
14
|
+
webhookKeyFor: (connection: string) => `${SLACK_WEBHOOK_PREFIX}${connection}`,
|
|
13
15
|
} as const;
|
|
14
16
|
|
|
15
17
|
export const SLACK_DEFAULT_ALLOWED_HOSTS = ["hooks.slack.com"] as const;
|
|
@@ -8,9 +8,11 @@ import {
|
|
|
8
8
|
|
|
9
9
|
export type TeamsChannelOptions = ChatWebhookChannelOptions;
|
|
10
10
|
|
|
11
|
+
const TEAMS_WEBHOOK_PREFIX = "channel-teams:webhooks.";
|
|
12
|
+
|
|
11
13
|
export const TEAMS_SECRET_KEYS = {
|
|
12
|
-
webhookPrefix:
|
|
13
|
-
webhookKeyFor: (connection: string) =>
|
|
14
|
+
webhookPrefix: TEAMS_WEBHOOK_PREFIX,
|
|
15
|
+
webhookKeyFor: (connection: string) => `${TEAMS_WEBHOOK_PREFIX}${connection}`,
|
|
14
16
|
} as const;
|
|
15
17
|
|
|
16
18
|
export const TEAMS_DEFAULT_ALLOWED_HOSTS = [
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.364.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Config key validate() receives a db scoped to the written tenant",
|
|
6
|
+
"detail": "A SystemAdmin writing a config value for another tenant previously handed the validator a system-mode db that could read every tenant. `ConfigValidateContext.db` is now a `TenantDb` locked to the tenant the value is written for. The validator runs inside the write transaction, so a slow query there should set its own timeout."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.338.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
encryptPiiValueForSubject,
|
|
5
5
|
type KmsContext,
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
7
|
-
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
7
|
+
import { createEventStoreExecutor, scopeTenantDbToTenant } from "@cosmicdrift/kumiko-framework/db";
|
|
8
8
|
import {
|
|
9
9
|
ConfigScopes,
|
|
10
10
|
defineWriteHandler,
|
|
@@ -111,7 +111,7 @@ export const setWrite = defineWriteHandler({
|
|
|
111
111
|
scope,
|
|
112
112
|
tenantId,
|
|
113
113
|
userId,
|
|
114
|
-
db,
|
|
114
|
+
db: scopeTenantDbToTenant(db, tenantId),
|
|
115
115
|
});
|
|
116
116
|
} catch (error) {
|
|
117
117
|
if (isKumikoError(error)) return writeFailure(error);
|
|
@@ -119,6 +119,19 @@ async function decryptPiiEncrypted(raw: string, qualifiedKey: string): Promise<s
|
|
|
119
119
|
return decryptPiiValueForSubject(kms, raw, kmsCtx, qualifiedKey);
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
// Plaintext form of a stored config_values.value for every read path (resolver,
|
|
123
|
+
// bootstrap idempotency check).
|
|
124
|
+
export async function decryptStoredValue(
|
|
125
|
+
raw: string,
|
|
126
|
+
keyDef: ConfigKeyDefinition,
|
|
127
|
+
cipher: EnvelopeCipher | undefined,
|
|
128
|
+
qualifiedKey: string,
|
|
129
|
+
): Promise<string> {
|
|
130
|
+
if (keyDef.encrypted) return decryptEncrypted(cipher, raw, qualifiedKey);
|
|
131
|
+
if (keyDef.piiEncrypted) return decryptPiiEncrypted(raw, qualifiedKey);
|
|
132
|
+
return raw;
|
|
133
|
+
}
|
|
134
|
+
|
|
122
135
|
// backing="secrets" keys store their value in the secrets store (flat per
|
|
123
136
|
// (tenant,key) at SYSTEM_TENANT_ID), not in config_values. Both read paths
|
|
124
137
|
// (getWithSource + buildCascade) route the system rung here. Missing reader =
|
|
@@ -230,12 +243,7 @@ async function buildCascade(
|
|
|
230
243
|
|
|
231
244
|
const row = await fetchRow(lookup.tenantId, lookup.userId);
|
|
232
245
|
if (row?.value !== null && row?.value !== undefined) {
|
|
233
|
-
|
|
234
|
-
if (keyDef.encrypted) {
|
|
235
|
-
raw = await decryptEncrypted(cipher, raw, qualifiedKey);
|
|
236
|
-
} else if (keyDef.piiEncrypted) {
|
|
237
|
-
raw = await decryptPiiEncrypted(raw, qualifiedKey);
|
|
238
|
-
}
|
|
246
|
+
const raw = await decryptStoredValue(row.value, keyDef, cipher, qualifiedKey);
|
|
239
247
|
if (activeIndex === -1) activeIndex = levels.length;
|
|
240
248
|
levels.push({
|
|
241
249
|
label: lookup.label,
|
|
@@ -409,12 +417,7 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
|
|
|
409
417
|
for (const lookup of lookups) {
|
|
410
418
|
const row = await findRow(qualifiedKey, lookup.tenantId, lookup.userId, db);
|
|
411
419
|
if (row?.value !== null && row?.value !== undefined) {
|
|
412
|
-
|
|
413
|
-
if (keyDef.encrypted) {
|
|
414
|
-
raw = await decryptEncrypted(cipher, raw, qualifiedKey);
|
|
415
|
-
} else if (keyDef.piiEncrypted) {
|
|
416
|
-
raw = await decryptPiiEncrypted(raw, qualifiedKey);
|
|
417
|
-
}
|
|
420
|
+
const raw = await decryptStoredValue(row.value, keyDef, cipher, qualifiedKey);
|
|
418
421
|
return { value: deserializeValue(raw, keyDef.type), source: lookup.source };
|
|
419
422
|
}
|
|
420
423
|
}
|
|
@@ -28,8 +28,10 @@ import {
|
|
|
28
28
|
type WriteFailure,
|
|
29
29
|
writeFailure,
|
|
30
30
|
} from "@cosmicdrift/kumiko-framework/errors";
|
|
31
|
+
import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
|
|
31
32
|
import { assertUnreachable } from "@cosmicdrift/kumiko-framework/utils";
|
|
32
33
|
import { ConfigErrors } from "./constants.js";
|
|
34
|
+
import { decryptStoredValue } from "./resolver.js";
|
|
33
35
|
import { configValuesTable } from "./table.js";
|
|
34
36
|
|
|
35
37
|
export type ConfigRowLookup = {
|
|
@@ -62,6 +64,27 @@ export async function findConfigRow(
|
|
|
62
64
|
};
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
// Idempotency check for declarative writers: does the stored row already hold
|
|
68
|
+
// `serializedValue` (JSON of the plaintext)? Encrypted rows are decrypted
|
|
69
|
+
// first, since their ciphertext differs on every encrypt.
|
|
70
|
+
export async function configRowHoldsValue(
|
|
71
|
+
row: ConfigRowLookup | null,
|
|
72
|
+
keyDef: ConfigKeyDefinition | undefined,
|
|
73
|
+
cipher: EnvelopeCipher | undefined,
|
|
74
|
+
qualifiedKey: string,
|
|
75
|
+
serializedValue: string,
|
|
76
|
+
): Promise<boolean> {
|
|
77
|
+
if (row?.value === null || row?.value === undefined) return false;
|
|
78
|
+
if (!keyDef?.encrypted && !keyDef?.piiEncrypted) return row.value === serializedValue;
|
|
79
|
+
if (!keyDef) return false;
|
|
80
|
+
try {
|
|
81
|
+
return (await decryptStoredValue(row.value, keyDef, cipher, qualifiedKey)) === serializedValue;
|
|
82
|
+
} catch {
|
|
83
|
+
// Unreadable (rotated key, missing cipher/KMS): the caller rewrites the row, which is the repair.
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
65
88
|
// Three-stage pre-write gate that set + reset both need: resolve the key
|
|
66
89
|
// definition (404 when unknown), check the user's roles can write it (403),
|
|
67
90
|
// and map the target scope to the (tenantId | null, userId | null) pair the
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.364.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "forget-subject keeps the user status flip and PAT revocation when the sweep fails",
|
|
6
|
+
"detail": "The user is set to Deleted and its personal access tokens are revoked right after the key erase, in their own transaction on the outside-transaction db. A failing blind-index or search sweep still makes the handler throw so the cleanup can be retried, but it no longer rolls the login door back. Without an outside-transaction db the previous in-transaction savepoint path is used. A failure while closing the login door of an existing user now fails the forget (retry is safe) instead of passing silently; subjects without a user row are skipped."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.357.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { requestContext } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
fetchOne,
|
|
5
|
+
runInSavepointIfSupported,
|
|
6
|
+
runnerCanBeginTransaction,
|
|
7
|
+
transaction,
|
|
8
|
+
} from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
9
|
import {
|
|
5
10
|
computeBlindIndex,
|
|
6
11
|
configuredBlindIndexKey,
|
|
@@ -16,6 +21,7 @@ import {
|
|
|
16
21
|
recordRowExistsInTenant,
|
|
17
22
|
recordRowOwningTenantId,
|
|
18
23
|
subjectRowExistsInTenant,
|
|
24
|
+
WritePurposes,
|
|
19
25
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
20
26
|
import {
|
|
21
27
|
defineWriteHandler,
|
|
@@ -42,7 +48,7 @@ import * as z from "zod";
|
|
|
42
48
|
import { resolveRetentionPolicyForTenant } from "../../data-retention/index.js";
|
|
43
49
|
import { eraseNoteBodiesReachingSubject } from "../../notes-history-user-data/index.js";
|
|
44
50
|
import { revokeAllPatTokensForUser } from "../../personal-access-tokens/index.js";
|
|
45
|
-
import { USER_STATUS } from "../../user/index.js";
|
|
51
|
+
import { USER_STATUS, userTable } from "../../user/index.js";
|
|
46
52
|
import {
|
|
47
53
|
denyIfTargetOutsideAdminTenant,
|
|
48
54
|
isSystemAdminActor,
|
|
@@ -337,25 +343,38 @@ async function sweepDerivedSubjectData(
|
|
|
337
343
|
}
|
|
338
344
|
}
|
|
339
345
|
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
|
|
346
|
+
// Email subscribers and other non-user entities use user-style subject keys
|
|
347
|
+
// without a read_users row — the key erase is the GDPR-relevant part, so no row
|
|
348
|
+
// means nothing to close. For an existing row, failures propagate: the handler
|
|
349
|
+
// throws and the retry re-runs the (idempotent) erase plus the door close. With
|
|
350
|
+
// an outside-transaction runner that can open its own transaction, the door
|
|
351
|
+
// closes there and stays committed when the handler throws afterwards (sweep
|
|
352
|
+
// error). Otherwise a savepoint confines a failure to the handler transaction.
|
|
353
|
+
async function closeUserLoginDoor(
|
|
346
354
|
ctx: HandlerContext,
|
|
347
355
|
userId: string,
|
|
348
|
-
|
|
356
|
+
outsideRunner: DbRunner | undefined,
|
|
357
|
+
handlerTxRunner: DbRunner,
|
|
349
358
|
): Promise<void> {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
+
const userRow = await fetchOne(handlerTxRunner, userTable, { id: userId });
|
|
360
|
+
// skip: no user row for this subject key (e.g. email subscribers); there is no door to close.
|
|
361
|
+
if (!userRow) return;
|
|
362
|
+
|
|
363
|
+
const closeDoor = async (runner: DbRunner): Promise<void> => {
|
|
364
|
+
await updateUserLifecycle(
|
|
365
|
+
runner,
|
|
366
|
+
userId,
|
|
367
|
+
{ status: USER_STATUS.Deleted },
|
|
368
|
+
{ writePurpose: WritePurposes.erasure },
|
|
369
|
+
);
|
|
370
|
+
if (ctx.registry.features.has("personal-access-tokens")) {
|
|
371
|
+
await revokeAllPatTokensForUser(runner, userId);
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
if (outsideRunner && runnerCanBeginTransaction(outsideRunner)) {
|
|
375
|
+
await transaction(outsideRunner, closeDoor);
|
|
376
|
+
} else {
|
|
377
|
+
await runInSavepointIfSupported(handlerTxRunner, closeDoor);
|
|
359
378
|
}
|
|
360
379
|
}
|
|
361
380
|
|
|
@@ -490,14 +509,6 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
490
509
|
eraseReason: event.payload.reason,
|
|
491
510
|
});
|
|
492
511
|
|
|
493
|
-
const cleanupError = await sweepDerivedSubjectData(
|
|
494
|
-
ctx,
|
|
495
|
-
subject,
|
|
496
|
-
subjectKey,
|
|
497
|
-
ctx.db.unsafeRaw(),
|
|
498
|
-
{ requestId, erasedBy: event.user.id },
|
|
499
|
-
);
|
|
500
|
-
|
|
501
512
|
// User subject: close the login door. DEK-erase makes the passwordHash
|
|
502
513
|
// ciphertext unreadable, but status + PATs are standalone credentials —
|
|
503
514
|
// the PAT resolver only checks revokedAt/expiresAt, NOT user.status
|
|
@@ -513,10 +524,26 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
513
524
|
// crash recovery is safe. User-feature guard: without the user feature
|
|
514
525
|
// read_users doesn't exist (crypto-only stack). Tenant subjects have no
|
|
515
526
|
// credentials.
|
|
527
|
+
// Runs before the sweep and in its own transaction: a failing sweep rolls the
|
|
528
|
+
// handler transaction back, and the handler tx holds no lock on this user's
|
|
529
|
+
// read_users row yet (only read gates ran), so there is no lock conflict.
|
|
516
530
|
if (raw.kind === "user" && ctx.registry.features.has("user")) {
|
|
517
|
-
await
|
|
531
|
+
await closeUserLoginDoor(
|
|
532
|
+
ctx,
|
|
533
|
+
raw.userId,
|
|
534
|
+
ctx.dbOutsideTransaction?.unsafeRaw(),
|
|
535
|
+
ctx.db.unsafeRaw(),
|
|
536
|
+
);
|
|
518
537
|
}
|
|
519
538
|
|
|
539
|
+
const cleanupError = await sweepDerivedSubjectData(
|
|
540
|
+
ctx,
|
|
541
|
+
subject,
|
|
542
|
+
subjectKey,
|
|
543
|
+
ctx.db.unsafeRaw(),
|
|
544
|
+
{ requestId, erasedBy: event.user.id },
|
|
545
|
+
);
|
|
546
|
+
|
|
520
547
|
await appendSubjectForgotten(
|
|
521
548
|
ctx,
|
|
522
549
|
event,
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.364.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "update-tenant-field no longer hides `type` from the handler schema",
|
|
6
|
+
"detail": "The `type` field is part of the documented payload; the 422 check against changing the stored type remains the protection."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.359.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { asRawClient, unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import type
|
|
2
|
+
import { type DbRunner, toPostgresArrayLiteral } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
|
|
4
4
|
export async function selectHostRowsWithCustomFields(
|
|
5
5
|
db: DbRunner,
|
|
@@ -38,6 +38,6 @@ export async function applyRetentionRemovals(
|
|
|
38
38
|
ELSE custom_fields
|
|
39
39
|
END
|
|
40
40
|
WHERE id = $3`,
|
|
41
|
-
[deleteKeys, anonymizeKeys, rowId],
|
|
41
|
+
[toPostgresArrayLiteral(deleteKeys), toPostgresArrayLiteral(anonymizeKeys), rowId],
|
|
42
42
|
);
|
|
43
43
|
}
|
|
@@ -46,7 +46,6 @@ export function createUpdateTenantFieldHandler(
|
|
|
46
46
|
access: { roles: opts.roles ?? DEFAULT_FIELD_DEFINITION_WRITE_ROLES },
|
|
47
47
|
description:
|
|
48
48
|
"Replaces a tenant-owned custom-field definition with a complete new spec; serializedField.type may be omitted, in which case the stored type applies, and a different type is refused. Use it to relabel or re-constrain an existing field rather than to create or remove one.",
|
|
49
|
-
excludedFields: ["type"],
|
|
50
49
|
handler: async (event, ctx) => {
|
|
51
50
|
const payload = event.payload as UpdateFieldPayload; // @cast-boundary engine-payload
|
|
52
51
|
const tenantId = event.user.tenantId;
|
|
@@ -57,7 +57,7 @@ import {
|
|
|
57
57
|
type WhereObject,
|
|
58
58
|
WritePurposes,
|
|
59
59
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
60
|
-
import {
|
|
60
|
+
import { deleteFileRefBytes } from "@cosmicdrift/kumiko-framework/derivatives";
|
|
61
61
|
import {
|
|
62
62
|
createSystemUser,
|
|
63
63
|
type EntityDefinition,
|
|
@@ -386,11 +386,11 @@ async function isFileRefSharedByOtherRow(
|
|
|
386
386
|
// not abort the whole tenant run.
|
|
387
387
|
async function deleteFileFailingClosed(
|
|
388
388
|
key: string,
|
|
389
|
-
|
|
389
|
+
files: FileContext,
|
|
390
390
|
logContext: string,
|
|
391
391
|
): Promise<readonly string[]> {
|
|
392
392
|
try {
|
|
393
|
-
return await
|
|
393
|
+
return await deleteFileRefBytes(files, { storageKey: key }, "data-retention:hardDelete");
|
|
394
394
|
} catch (err) {
|
|
395
395
|
// biome-ignore lint/suspicious/noConsole: operator-visibility for storage-cleanup failures
|
|
396
396
|
console.warn(
|
|
@@ -477,7 +477,6 @@ async function purgeHardDeleteRowWithFiles(args: {
|
|
|
477
477
|
return false;
|
|
478
478
|
}
|
|
479
479
|
const files = args.files;
|
|
480
|
-
const store = { list: files.list, delete: (key: string) => files.ref(key).delete() };
|
|
481
480
|
|
|
482
481
|
let anyFailed = false;
|
|
483
482
|
for (const fileRef of toDelete) {
|
|
@@ -485,7 +484,7 @@ async function purgeHardDeleteRowWithFiles(args: {
|
|
|
485
484
|
if (typeof key !== "string" || key.length === 0) continue;
|
|
486
485
|
const failedKeys = await deleteFileFailingClosed(
|
|
487
486
|
key,
|
|
488
|
-
|
|
487
|
+
files,
|
|
489
488
|
`tenant=${args.tenantId} entity=${args.entityName} row=${rowId}`,
|
|
490
489
|
);
|
|
491
490
|
if (failedKeys.length > 0) {
|
|
@@ -14,6 +14,27 @@ export const MAX_EMAIL_ATTACHMENTS = 20;
|
|
|
14
14
|
|
|
15
15
|
export const MAX_RENDERED_PDF_VARIABLES_BYTES = 64_000;
|
|
16
16
|
|
|
17
|
+
// Channel-data keys that steer delivery itself (envelope, headers, attachments);
|
|
18
|
+
// they are not document variables and must not be copied into attached PDF refs.
|
|
19
|
+
export const DELIVERY_ENVELOPE_DATA_KEYS = [
|
|
20
|
+
"attachments",
|
|
21
|
+
"from",
|
|
22
|
+
"fromName",
|
|
23
|
+
"replyTo",
|
|
24
|
+
"headers",
|
|
25
|
+
"unsubscribeUrl",
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
const DELIVERY_ENVELOPE_DATA_KEY_SET: ReadonlySet<string> = new Set(DELIVERY_ENVELOPE_DATA_KEYS);
|
|
29
|
+
|
|
30
|
+
export function withoutDeliveryEnvelopeKeys(
|
|
31
|
+
variables: Readonly<Record<string, unknown>>,
|
|
32
|
+
): Readonly<Record<string, unknown>> {
|
|
33
|
+
return Object.fromEntries(
|
|
34
|
+
Object.entries(variables).filter(([key]) => !DELIVERY_ENVELOPE_DATA_KEY_SET.has(key)),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
17
38
|
const MAX_ID_LENGTH = 64;
|
|
18
39
|
|
|
19
40
|
const fileRefIdSchema = z.string().min(1).max(MAX_ID_LENGTH);
|
|
@@ -34,14 +55,31 @@ export const fileAttachmentRefSchema = z
|
|
|
34
55
|
})
|
|
35
56
|
.strict();
|
|
36
57
|
|
|
58
|
+
// BigInt values and cycles make JSON.stringify throw; that must surface as a validation issue,
|
|
59
|
+
// not a raw TypeError that a retrying job hits again on the same data.
|
|
60
|
+
function isJsonSerializable(value: unknown): boolean {
|
|
61
|
+
try {
|
|
62
|
+
JSON.stringify(value);
|
|
63
|
+
return true;
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function jsonByteLength(value: unknown): number {
|
|
70
|
+
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
71
|
+
}
|
|
72
|
+
|
|
37
73
|
export const renderedPdfAttachmentRefSchema = z
|
|
38
74
|
.object({
|
|
39
75
|
kind: z.literal("rendered-pdf"),
|
|
40
76
|
templateSlug: templateSlugSchema,
|
|
41
77
|
variables: z
|
|
42
78
|
.record(z.string(), z.unknown())
|
|
79
|
+
.refine(isJsonSerializable, { message: "variables must be JSON-serializable" })
|
|
43
80
|
.refine(
|
|
44
|
-
(value) =>
|
|
81
|
+
(value) =>
|
|
82
|
+
!isJsonSerializable(value) || jsonByteLength(value) <= MAX_RENDERED_PDF_VARIABLES_BYTES,
|
|
45
83
|
{
|
|
46
84
|
message: `variables must not exceed ${MAX_RENDERED_PDF_VARIABLES_BYTES} bytes as JSON`,
|
|
47
85
|
},
|
|
@@ -1,4 +1,34 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.365.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Rendered-PDF attachment variables must be JSON-serializable",
|
|
6
|
+
"detail": "BigInt values or cycles in rendered-pdf variables now fail validation instead of throwing a TypeError that a retrying job hits again."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.365.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Delivery checks opt-outs for addresses resolved on the retry path and logs idempotency release failures",
|
|
12
|
+
"detail": "The kill switch, preference and rate-limit result per recipient channel is computed once in the prefetch and reused when delivering; the rate limit now runs before the address is resolved. When the prefetch resolve failed and the address is resolved again, the suppression list is reloaded for exactly that address, so an unsubscribed address no longer receives the mail. A failing idempotency-key release is logged and swallowed instead of masking the delivery outcome."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.364.0",
|
|
16
|
+
"type": "fix",
|
|
17
|
+
"title": "A failed final delivery attempt frees the idempotency key",
|
|
18
|
+
"detail": "A soft send failure and any error thrown on the final render or send attempt release the idempotency key, so the caller's retry is not dropped as a duplicate."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.364.0",
|
|
22
|
+
"type": "fix",
|
|
23
|
+
"title": "A mail without a readable inline image is sent without it",
|
|
24
|
+
"detail": "An inline image whose bytes could not be read (for example a system template logo while the job context has no provider resolver) failed the whole mail send. The attachment reader now logs a warning with the fileRef id and skips the image."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"version": "0.364.0",
|
|
28
|
+
"type": "fix",
|
|
29
|
+
"title": "Delivery exports the envelope-key filter for mail variables",
|
|
30
|
+
"detail": "withoutDeliveryEnvelopeKeys strips reserved delivery keys from template variables."
|
|
31
|
+
},
|
|
2
32
|
{
|
|
3
33
|
"version": "0.363.0",
|
|
4
34
|
"type": "breaking",
|