@cosmicdrift/kumiko-samples 0.364.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 +1 -1
- package/packages/bundled-features/src/audit/changes.json +12 -0
- package/packages/bundled-features/src/audit/feature.ts +1 -1
- package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +63 -11
- package/packages/bundled-features/src/auth-email-password/bootstrap.ts +1 -3
- package/packages/bundled-features/src/auth-email-password/changes.json +18 -0
- package/packages/bundled-features/src/auth-email-password/feature.ts +4 -7
- 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/switch-tenant-mfa-gate.write.ts +2 -2
- 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/billing-foundation/changes.json +12 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +8 -1
- package/packages/bundled-features/src/billing-foundation/i18n.ts +6 -0
- package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +10 -2
- package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +20 -8
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +7 -2
- package/packages/bundled-features/src/cap-counter/changes.json +13 -0
- package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +2 -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 +30 -20
- 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/crypto-shredding/handlers/forget-subject.write.ts +7 -1
- package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
- package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
- package/packages/bundled-features/src/delivery/attachment-ref.ts +18 -1
- package/packages/bundled-features/src/delivery/changes.json +12 -0
- package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
- 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/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/jobs/changes.json +12 -0
- package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -2
- 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/secrets/changes.json +6 -0
- package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
- package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
- package/packages/bundled-features/src/shared/index.ts +8 -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/template-resolver/changes.json +81 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +78 -12
- package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
- package/packages/bundled-features/src/template-resolver/constants.ts +11 -1
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
- package/packages/bundled-features/src/template-resolver/document-schema.ts +7 -1
- package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +12 -0
- 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 +111 -61
- package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +1 -0
- 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/overview.query.ts +12 -2
- package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +48 -9
- package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +4 -1
- package/packages/bundled-features/src/template-resolver/index.ts +6 -1
- package/packages/bundled-features/src/template-resolver/layout-appearance.ts +25 -9
- package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -0
- package/packages/bundled-features/src/template-resolver/resource-image.ts +9 -16
- package/packages/bundled-features/src/template-resolver/seeding.ts +2 -0
- package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
- package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
- package/packages/bundled-features/src/template-resolver/template-draft.ts +1 -1
- package/packages/bundled-features/src/template-resolver/template-meta.ts +8 -0
- package/packages/bundled-features/src/template-resolver/template-preview.ts +1 -1
- package/packages/bundled-features/src/template-resolver/template-resources.ts +2 -43
- package/packages/bundled-features/src/template-resolver/template-validation.ts +15 -9
- package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +34 -0
- package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +3 -2
- package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +56 -11
- package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +6 -3
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +7 -0
- package/packages/bundled-features/src/template-resolver/web/index.ts +1 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +14 -5
- 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 +12 -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/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/constants.ts +1 -0
- package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -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/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/workflow-runner/feature.ts +1 -1
- package/samples/apps/ui-walkthrough/src/run-config.ts +14 -1
- package/samples/apps/use-all-bundled/README.md +3 -13
- package/samples/apps/use-all-bundled/feature-manifest.json +3 -3
- 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,4 +1,22 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.365.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "SMTP display names drop address-like characters, inline images get matching extensions",
|
|
6
|
+
"detail": "Control characters, angle brackets and at signs in a From display name are replaced by spaces so it cannot smuggle a second address; inline image file names derive their extension from the shared image format list."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.365.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "List-Unsubscribe accepts an app-owned unsubscribe URL",
|
|
12
|
+
"detail": "When unsubscribeUrl in the mail data points to an http(s) URL outside the framework unsubscribe route, the email channel sets List-Unsubscribe to that URL without the one-click List-Unsubscribe-Post header, because the framework cannot vouch for a foreign endpoint accepting POST. Other schemes set no header."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.365.0",
|
|
16
|
+
"type": "fix",
|
|
17
|
+
"title": "A mail whose file attachment is missing fails at once instead of retrying",
|
|
18
|
+
"detail": "A missing or foreign-tenant file ref ends the delivery attempt as failed (send_failed) without a retry. The send job no longer throws, so the idempotency key is released and the caller can resend. A template test mail whose template file is missing now answers not_sent instead of channel_error."
|
|
19
|
+
},
|
|
2
20
|
{
|
|
3
21
|
"version": "0.363.0",
|
|
4
22
|
"type": "breaking",
|
|
@@ -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 = {
|
|
@@ -232,7 +234,7 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
|
|
|
232
234
|
...(replyTo && { replyTo }),
|
|
233
235
|
...emailEnvelopeFrom(message.data),
|
|
234
236
|
};
|
|
235
|
-
const
|
|
237
|
+
const resolution = await resolveAttachments(
|
|
236
238
|
[
|
|
237
239
|
...(attachments ?? []).map((ref): AttachmentSource => ({ ref, origin: "template" })),
|
|
238
240
|
...fileAttachmentsFrom(message.data, message.locale).map(
|
|
@@ -241,6 +243,14 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
|
|
|
241
243
|
],
|
|
242
244
|
ctx,
|
|
243
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;
|
|
244
254
|
await transport.send(
|
|
245
255
|
guardEmailMessage({
|
|
246
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 = [
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
recordRowExistsInTenant,
|
|
22
22
|
recordRowOwningTenantId,
|
|
23
23
|
subjectRowExistsInTenant,
|
|
24
|
+
WritePurposes,
|
|
24
25
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
25
26
|
import {
|
|
26
27
|
defineWriteHandler,
|
|
@@ -360,7 +361,12 @@ async function closeUserLoginDoor(
|
|
|
360
361
|
if (!userRow) return;
|
|
361
362
|
|
|
362
363
|
const closeDoor = async (runner: DbRunner): Promise<void> => {
|
|
363
|
-
await updateUserLifecycle(
|
|
364
|
+
await updateUserLifecycle(
|
|
365
|
+
runner,
|
|
366
|
+
userId,
|
|
367
|
+
{ status: USER_STATUS.Deleted },
|
|
368
|
+
{ writePurpose: WritePurposes.erasure },
|
|
369
|
+
);
|
|
364
370
|
if (ctx.registry.features.has("personal-access-tokens")) {
|
|
365
371
|
await revokeAllPatTokensForUser(runner, userId);
|
|
366
372
|
}
|
|
@@ -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
|
}
|
|
@@ -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) {
|
|
@@ -55,14 +55,31 @@ export const fileAttachmentRefSchema = z
|
|
|
55
55
|
})
|
|
56
56
|
.strict();
|
|
57
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
|
+
|
|
58
73
|
export const renderedPdfAttachmentRefSchema = z
|
|
59
74
|
.object({
|
|
60
75
|
kind: z.literal("rendered-pdf"),
|
|
61
76
|
templateSlug: templateSlugSchema,
|
|
62
77
|
variables: z
|
|
63
78
|
.record(z.string(), z.unknown())
|
|
79
|
+
.refine(isJsonSerializable, { message: "variables must be JSON-serializable" })
|
|
64
80
|
.refine(
|
|
65
|
-
(value) =>
|
|
81
|
+
(value) =>
|
|
82
|
+
!isJsonSerializable(value) || jsonByteLength(value) <= MAX_RENDERED_PDF_VARIABLES_BYTES,
|
|
66
83
|
{
|
|
67
84
|
message: `variables must not exceed ${MAX_RENDERED_PDF_VARIABLES_BYTES} bytes as JSON`,
|
|
68
85
|
},
|
|
@@ -1,4 +1,16 @@
|
|
|
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
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.364.0",
|
|
4
16
|
"type": "fix",
|
|
@@ -205,14 +205,23 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
205
205
|
|
|
206
206
|
// A key that guarded nothing must not turn the caller's retry into a skipped duplicate.
|
|
207
207
|
// Partial success keeps it: a retry would re-send to those who got it.
|
|
208
|
+
// A failing release is logged and swallowed: it must neither replace the dispatch error the
|
|
209
|
+
// caller retries on nor fail a notify() that already dispatched.
|
|
208
210
|
async function releaseIdempotencyIfNothingDelivered(
|
|
209
211
|
tenantId: TenantId,
|
|
210
212
|
key: string | undefined,
|
|
211
213
|
run: NotifyRun,
|
|
214
|
+
notificationType: string,
|
|
212
215
|
): Promise<void> {
|
|
213
216
|
// skip: no key claimed, or a recipient already got the notification
|
|
214
217
|
if (!key || run.deliveries.some(isDeliveredOrQueued)) return;
|
|
215
|
-
|
|
218
|
+
try {
|
|
219
|
+
await idemRedis?.del(deliveryIdempotencyRedisKey(tenantId, key));
|
|
220
|
+
} catch (releaseErr) {
|
|
221
|
+
logError.warn(`idempotency key release failed: ${redactedMessageOf(releaseErr)}`, {
|
|
222
|
+
notificationType,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
216
225
|
}
|
|
217
226
|
|
|
218
227
|
// Only a single-channel route lets the queued job free the key on terminal failure
|
|
@@ -508,29 +517,53 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
508
517
|
|
|
509
518
|
const recipientChannelKey = (userId: string, channelName: string) => `${userId}|${channelName}`;
|
|
510
519
|
|
|
511
|
-
|
|
520
|
+
type ChannelGate = "open" | "channel_disabled" | "preference_disabled" | "rate_limited";
|
|
521
|
+
type PrefetchedRecipients = {
|
|
522
|
+
readonly addresses: ReadonlyMap<string, string | null>;
|
|
523
|
+
readonly gates: ReadonlyMap<string, ChannelGate>;
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
// Kill switch, then the user's preference, then the rate limit — the order deliverToUser reports
|
|
527
|
+
// skips in. The rate limit consumes a token, so each recipient/channel pair is evaluated once.
|
|
528
|
+
async function evaluateChannelGate(
|
|
512
529
|
run: NotifyRun,
|
|
513
530
|
userId: string,
|
|
514
531
|
tenantId: TenantId,
|
|
515
532
|
notificationType: string,
|
|
516
533
|
channelName: string,
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
534
|
+
priority: NotifyPriority,
|
|
535
|
+
): Promise<ChannelGate> {
|
|
536
|
+
if (isChannelKilled && (await isChannelKilled(tenantId, channelName))) {
|
|
537
|
+
return "channel_disabled";
|
|
538
|
+
}
|
|
539
|
+
// Critical priority skips the preference check
|
|
540
|
+
if (
|
|
541
|
+
priority !== "critical" &&
|
|
542
|
+
!(await isChannelEnabled(run, userId, tenantId, notificationType, channelName))
|
|
543
|
+
) {
|
|
544
|
+
return "preference_disabled";
|
|
545
|
+
}
|
|
546
|
+
if (rateLimit && !(await checkRateLimit(rateLimit, tenantId, channelName))) {
|
|
547
|
+
return "rate_limited";
|
|
548
|
+
}
|
|
549
|
+
return "open";
|
|
520
550
|
}
|
|
521
551
|
|
|
522
552
|
// Resolves every user's channel addresses up front so the opt-out lookup can
|
|
523
|
-
// cover them all at once. A resolve that throws stays out of the map:
|
|
524
|
-
// delivery loop resolves it again and reports channel_error as before.
|
|
525
|
-
// Channels
|
|
526
|
-
// here: resolve may have side effects and deliverToUser skips those channels.
|
|
527
|
-
|
|
553
|
+
// cover them all at once. A resolve that throws stays out of the address map:
|
|
554
|
+
// the delivery loop resolves it again and reports channel_error as before.
|
|
555
|
+
// Channels that are gated (kill switch, preferences, rate limit) are never resolved
|
|
556
|
+
// here: resolve may have side effects and deliverToUser skips those channels. The
|
|
557
|
+
// gate verdict is kept so deliverToUser does not query it a second time.
|
|
558
|
+
async function prefetchRecipients(
|
|
528
559
|
run: NotifyRun,
|
|
529
560
|
userIds: readonly string[],
|
|
530
561
|
tenantId: TenantId,
|
|
531
562
|
notificationType: string,
|
|
532
|
-
|
|
563
|
+
priority: NotifyPriority,
|
|
564
|
+
): Promise<PrefetchedRecipients> {
|
|
533
565
|
const addresses = new Map<string, string | null>();
|
|
566
|
+
const gates = new Map<string, ChannelGate>();
|
|
534
567
|
const channelCtx = buildChannelContext(
|
|
535
568
|
run.db,
|
|
536
569
|
registry,
|
|
@@ -543,22 +576,25 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
543
576
|
for (const userId of userIds) {
|
|
544
577
|
for (const channel of channels) {
|
|
545
578
|
if (!channel.resolve) continue;
|
|
579
|
+
const key = recipientChannelKey(userId, channel.name);
|
|
546
580
|
try {
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
await channel.resolve(userId, channelCtx),
|
|
581
|
+
const gate = await evaluateChannelGate(
|
|
582
|
+
run,
|
|
583
|
+
userId,
|
|
584
|
+
tenantId,
|
|
585
|
+
notificationType,
|
|
586
|
+
channel.name,
|
|
587
|
+
priority,
|
|
555
588
|
);
|
|
589
|
+
gates.set(key, gate);
|
|
590
|
+
if (gate !== "open") continue;
|
|
591
|
+
addresses.set(key, await channel.resolve(userId, channelCtx));
|
|
556
592
|
} catch {
|
|
557
593
|
// skip: re-resolved (and logged) by deliverToUser
|
|
558
594
|
}
|
|
559
595
|
}
|
|
560
596
|
}
|
|
561
|
-
return addresses;
|
|
597
|
+
return { addresses, gates };
|
|
562
598
|
}
|
|
563
599
|
|
|
564
600
|
// Check if user has disabled this notification+channel combo.
|
|
@@ -605,7 +641,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
605
641
|
tenantId: TenantId,
|
|
606
642
|
priority: NotifyPriority,
|
|
607
643
|
run: NotifyRun,
|
|
608
|
-
|
|
644
|
+
prefetched: PrefetchedRecipients,
|
|
609
645
|
suppressed: ReadonlySet<AddressOptOutKey>,
|
|
610
646
|
): Promise<void> {
|
|
611
647
|
const channelCtx = buildChannelContext(
|
|
@@ -624,70 +660,35 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
624
660
|
|
|
625
661
|
const message = buildMessage(notificationType, data, channel.name, run.locale);
|
|
626
662
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
await logDelivery(run, {
|
|
632
|
-
tenantId,
|
|
633
|
-
notificationType,
|
|
634
|
-
channel: channel.name,
|
|
635
|
-
recipientId: userId,
|
|
636
|
-
recipientAddress: null,
|
|
637
|
-
status: "skipped",
|
|
638
|
-
error: "channel_disabled",
|
|
639
|
-
priority,
|
|
640
|
-
});
|
|
641
|
-
continue;
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
// Check preferences (critical priority skips preference check)
|
|
646
|
-
if (priority !== "critical") {
|
|
647
|
-
const enabled = await isChannelEnabled(
|
|
663
|
+
const prefetchKey = recipientChannelKey(userId, channel.name);
|
|
664
|
+
const gate =
|
|
665
|
+
prefetched.gates.get(prefetchKey) ??
|
|
666
|
+
(await evaluateChannelGate(
|
|
648
667
|
run,
|
|
649
668
|
userId,
|
|
650
669
|
tenantId,
|
|
651
670
|
notificationType,
|
|
652
671
|
channel.name,
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
// Rate limiting
|
|
670
|
-
if (rateLimit) {
|
|
671
|
-
const allowed = await checkRateLimit(rateLimit, tenantId, channel.name);
|
|
672
|
-
if (!allowed) {
|
|
673
|
-
await logDelivery(run, {
|
|
674
|
-
tenantId,
|
|
675
|
-
notificationType,
|
|
676
|
-
channel: channel.name,
|
|
677
|
-
recipientId: userId,
|
|
678
|
-
recipientAddress: null,
|
|
679
|
-
status: "skipped",
|
|
680
|
-
error: "rate_limited",
|
|
681
|
-
priority,
|
|
682
|
-
});
|
|
683
|
-
continue;
|
|
684
|
-
}
|
|
672
|
+
priority,
|
|
673
|
+
));
|
|
674
|
+
if (gate !== "open") {
|
|
675
|
+
await logDelivery(run, {
|
|
676
|
+
tenantId,
|
|
677
|
+
notificationType,
|
|
678
|
+
channel: channel.name,
|
|
679
|
+
recipientId: userId,
|
|
680
|
+
recipientAddress: null,
|
|
681
|
+
status: "skipped",
|
|
682
|
+
error: gate,
|
|
683
|
+
priority,
|
|
684
|
+
});
|
|
685
|
+
continue;
|
|
685
686
|
}
|
|
686
687
|
|
|
687
688
|
try {
|
|
688
|
-
const
|
|
689
|
-
const address =
|
|
690
|
-
?
|
|
689
|
+
const wasPrefetched = prefetched.addresses.has(prefetchKey);
|
|
690
|
+
const address = wasPrefetched
|
|
691
|
+
? prefetched.addresses.get(prefetchKey)
|
|
691
692
|
: await channel.resolve(userId, channelCtx);
|
|
692
693
|
if (!address) {
|
|
693
694
|
await logDelivery(run, {
|
|
@@ -703,7 +704,14 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
703
704
|
continue;
|
|
704
705
|
}
|
|
705
706
|
|
|
706
|
-
|
|
707
|
+
// A fallback resolve yields an address the batched opt-out lookup never covered.
|
|
708
|
+
const suppressedForAddress = wasPrefetched
|
|
709
|
+
? suppressed
|
|
710
|
+
: await loadSuppressedAddresses(run, tenantId, notificationType, priority, [address]);
|
|
711
|
+
if (
|
|
712
|
+
priority !== "critical" &&
|
|
713
|
+
isAddressSuppressed(suppressedForAddress, address, channel.name)
|
|
714
|
+
) {
|
|
707
715
|
await logDelivery(run, {
|
|
708
716
|
tenantId,
|
|
709
717
|
notificationType,
|
|
@@ -878,16 +886,16 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
878
886
|
userIds = to;
|
|
879
887
|
}
|
|
880
888
|
|
|
881
|
-
const
|
|
889
|
+
const prefetched: PrefetchedRecipients =
|
|
882
890
|
priority === "critical"
|
|
883
|
-
? new Map
|
|
884
|
-
: await
|
|
891
|
+
? { addresses: new Map(), gates: new Map() }
|
|
892
|
+
: await prefetchRecipients(run, userIds, tenantId, notificationType, priority);
|
|
885
893
|
const suppressed = await loadSuppressedAddresses(
|
|
886
894
|
run,
|
|
887
895
|
tenantId,
|
|
888
896
|
notificationType,
|
|
889
897
|
priority,
|
|
890
|
-
[...
|
|
898
|
+
[...prefetched.addresses.values()].filter((address): address is string => !!address),
|
|
891
899
|
);
|
|
892
900
|
|
|
893
901
|
for (const userId of userIds) {
|
|
@@ -898,7 +906,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
898
906
|
tenantId,
|
|
899
907
|
priority,
|
|
900
908
|
run,
|
|
901
|
-
|
|
909
|
+
prefetched,
|
|
902
910
|
suppressed,
|
|
903
911
|
);
|
|
904
912
|
}
|
|
@@ -955,17 +963,10 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
955
963
|
try {
|
|
956
964
|
await dispatchNotification(notificationType, options, tenantId, priority, run);
|
|
957
965
|
} catch (err) {
|
|
958
|
-
|
|
959
|
-
await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run).catch(
|
|
960
|
-
(releaseErr: unknown) => {
|
|
961
|
-
logError.warn(`idempotency key release failed: ${redactedMessageOf(releaseErr)}`, {
|
|
962
|
-
notificationType,
|
|
963
|
-
});
|
|
964
|
-
},
|
|
965
|
-
);
|
|
966
|
+
await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run, notificationType);
|
|
966
967
|
throw err;
|
|
967
968
|
}
|
|
968
|
-
await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run);
|
|
969
|
+
await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run, notificationType);
|
|
969
970
|
return dispatched(run);
|
|
970
971
|
},
|
|
971
972
|
};
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.365.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Ingest events: correlate by event type and payload.fileRefId",
|
|
6
|
+
"detail": "New documentIngest events live on their own stream while older ones stay on the fileRef stream (aggregateId = fileRefId). Consumers must not correlate by aggregateId or the fileRef stream version."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.362.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.365.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Overlapping toggle refreshes no longer drop a flip, and the runtime can reuse the server cache-sync bus",
|
|
6
|
+
"detail": "initialize() and refresh() now run one snapshot load at a time, so a flip applied while a second load is in flight is kept instead of being overwritten by older rows. createFeatureToggleRuntime takes an optional fourth cacheSyncBus argument; with it no second bus (and no extra Redis connections) is created and close() leaves the shared bus open.",
|
|
7
|
+
"migration": "Pass the server's cache-sync bus as the fourth argument of createFeatureToggleRuntime to avoid the additional Redis publisher and subscriber connections of the default bus; without it the runtime still builds and closes its own bus."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.336.0",
|
|
4
11
|
"type": "fix",
|