@cosmicdrift/kumiko-bundled-features 0.334.0 → 0.336.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin-shell/overview-panels.js +2 -2
- package/dist/agent-tools/agent-doc-lint.d.ts +1 -0
- package/dist/agent-tools/agent-doc-lint.js +33 -3
- package/dist/agent-tools/agent-manifest.js +47 -6
- package/dist/agent-tools/filter-ops.d.ts +2 -0
- package/dist/agent-tools/filter-ops.js +4 -0
- package/dist/agent-tools/tool-catalog.js +1 -4
- package/dist/agent-tools/tool-dispatch.js +1 -4
- package/dist/audit/constants.d.ts +1 -1
- package/dist/audit/constants.js +1 -1
- package/dist/auth-email-password/handlers/change-password.write.js +2 -0
- package/dist/auth-email-password/handlers/invite-create.write.js +12 -2
- package/dist/auth-email-password/handlers/login.write.js +2 -0
- package/dist/auth-email-password/handlers/self-registration-status.query.js +1 -1
- package/dist/auth-email-password/i18n.js +2 -0
- package/dist/auth-email-password/web/auth-client.d.ts +1 -0
- package/dist/auth-email-password/web/auth-client.js +1 -1
- package/dist/auth-email-password/web/auth-gate.js +6 -8
- package/dist/auth-email-password/web/index.d.ts +1 -1
- package/dist/auth-email-password/web/index.js +1 -1
- package/dist/auth-email-password/web/invite-accept-screen.js +16 -12
- package/dist/auth-email-password/web/login-screen.js +1 -1
- package/dist/auth-email-password/web/session-bootstrap-error.d.ts +5 -1
- package/dist/auth-email-password/web/session-bootstrap-error.js +2 -2
- package/dist/auth-email-password/web/session.d.ts +4 -0
- package/dist/auth-email-password/web/session.js +15 -0
- package/dist/auth-mfa/feature.js +3 -2
- package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +2 -2
- package/dist/auth-mfa/handlers/status.query.js +1 -0
- package/dist/auth-mfa/handlers/verify.write.js +2 -0
- package/dist/auth-mfa/hmac-token-codec.js +3 -0
- package/dist/auth-mfa/mfa-challenge-token.js +4 -0
- package/dist/auth-mfa/mfa-preauth-setup-token.js +3 -0
- package/dist/auth-mfa/web/mfa-client.d.ts +1 -1
- package/dist/auth-mfa/web/mfa-client.js +4 -10
- package/dist/auth-mfa-user-data/hooks.js +4 -2
- package/dist/billing-foundation/billing-info-query.js +1 -1
- package/dist/billing-foundation/checkout-core.d.ts +5 -5
- package/dist/billing-foundation/checkout-core.js +10 -6
- package/dist/billing-foundation/handlers/switch-plan.write.js +5 -8
- package/dist/cap-counter/book-cap-usage.js +20 -5
- package/dist/cap-counter/enforce-cap.js +9 -3
- package/dist/cap-counter/feature.js +3 -2
- package/dist/cap-counter/i18n.d.ts +1 -0
- package/dist/cap-counter/i18n.js +6 -5
- package/dist/cap-overview/handlers/caps-usage.query.js +9 -16
- package/dist/cap-overview/handlers/tenant-caps-list.query.js +41 -15
- package/dist/cap-overview/i18n.js +5 -0
- package/dist/cap-overview/web/cap-cards-panel.js +4 -1
- package/dist/cap-overview/web/cap-usage-bar.js +1 -1
- package/dist/channel-email/email-channel.js +12 -1
- package/dist/channel-in-app/tables.js +2 -6
- package/dist/compliance-profiles/handlers/sub-processors.query.js +1 -1
- package/dist/config/db/queries/resolver.js +3 -1
- package/dist/config/handlers/set.write.js +8 -4
- package/dist/config/web/i18n.js +1 -0
- package/dist/config/write-helpers.d.ts +1 -1
- package/dist/config/write-helpers.js +11 -5
- package/dist/crypto-shredding/handlers/forget-subject.write.js +15 -8
- package/dist/custom-fields/constants.js +1 -4
- package/dist/data-retention/resolve-for-tenant.d.ts +2 -3
- package/dist/data-retention/run-retention-cleanup.js +100 -44
- package/dist/delivery/handlers/log.query.js +33 -24
- package/dist/delivery/types.d.ts +2 -1
- package/dist/delivery/types.js +7 -0
- package/dist/delivery/unsubscribe.js +5 -11
- package/dist/derivatives-sharp/render.js +27 -9
- package/dist/document-ingest-foundation/feature.js +5 -5
- package/dist/document-ingest-foundation/providers.d.ts +1 -0
- package/dist/document-ingest-foundation/providers.js +6 -0
- package/dist/document-ingest-foundation/write-document-extract.d.ts +2 -2
- package/dist/document-ingest-foundation/write-document-extract.js +10 -0
- package/dist/feature-toggles/global-feature-state-table.js +2 -7
- package/dist/feature-toggles/toggle-runtime.d.ts +1 -0
- package/dist/feature-toggles/toggle-runtime.js +16 -2
- package/dist/feature-toggles/toggle-sync-signal.d.ts +2 -1
- package/dist/feature-toggles/toggle-sync-signal.js +3 -2
- package/dist/files-tenant-data/index.js +3 -2
- package/dist/folders/handlers/clear-folder.write.js +4 -11
- package/dist/folders/handlers/set-folder.write.js +4 -10
- package/dist/form-draft/feature.js +3 -2
- package/dist/form-draft/handlers/discard.write.js +7 -8
- package/dist/form-draft/handlers/get.query.js +7 -1
- package/dist/foundation-shared/host-allowlist.d.ts +4 -0
- package/dist/foundation-shared/host-allowlist.js +14 -0
- package/dist/foundation-shared/index.d.ts +1 -0
- package/dist/foundation-shared/index.js +1 -0
- package/dist/foundation-shared/mail-host-policy.d.ts +0 -2
- package/dist/foundation-shared/mail-host-policy.js +3 -13
- package/dist/inbound-mail-foundation/connect-routes.js +7 -5
- package/dist/inbound-mail-foundation/watch-supervisor.js +47 -43
- package/dist/jobs/db/queries/stale-run-sweep.js +22 -15
- package/dist/jobs/handlers/list.query.d.ts +7 -2
- package/dist/jobs/handlers/list.query.js +9 -2
- package/dist/jobs/handlers/retry.write.js +3 -2
- package/dist/jobs/handlers/tenant-failures.query.js +3 -9
- package/dist/jobs/job-run-logger.js +18 -2
- package/dist/jobs/tenant-job-failure-table.js +2 -6
- package/dist/ledger/handlers/confirm-schedule-period.write.js +2 -2
- package/dist/ledger/handlers/reverse-transaction.write.js +2 -2
- package/dist/ledger/subject-reference.d.ts +4 -0
- package/dist/ledger/subject-reference.js +7 -0
- package/dist/legal-pages/feature.js +8 -1
- package/dist/managed-pages/handlers/branding.query.js +1 -1
- package/dist/managed-pages/handlers/by-slug.query.js +1 -1
- package/dist/managed-pages/handlers/by-tenant-published.query.js +1 -1
- package/dist/metrics/compute.js +7 -1
- package/dist/metrics/window.js +5 -0
- package/dist/notes-history/handlers/add-note.write.js +4 -9
- package/dist/notes-history-user-data/hooks.js +28 -14
- package/dist/personal-access-tokens/constants.d.ts +1 -0
- package/dist/personal-access-tokens/constants.js +1 -0
- package/dist/personal-access-tokens/feature.js +2 -2
- package/dist/personal-access-tokens/handlers/create.write.js +2 -2
- package/dist/personal-access-tokens/handlers/revoke.write.js +2 -2
- package/dist/personal-access-tokens/i18n.js +12 -13
- package/dist/personal-access-tokens/revoke-for-user.js +12 -1
- package/dist/personal-access-tokens/schema/api-token.js +2 -1
- package/dist/personal-access-tokens/screens.js +5 -1
- package/dist/rate-limiting/feature.js +2 -0
- package/dist/rate-limiting/handlers/bucket-access.js +15 -7
- package/dist/rate-limiting/i18n.d.ts +10 -0
- package/dist/rate-limiting/i18n.js +10 -0
- package/dist/secrets/feature.js +3 -2
- package/dist/seo/handlers/seo-config.query.js +1 -1
- package/dist/sessions/constants.d.ts +1 -0
- package/dist/sessions/constants.js +1 -0
- package/dist/sessions/feature.js +5 -3
- package/dist/sessions/handlers/revoke-all-for-user.write.d.ts +1 -0
- package/dist/sessions/handlers/revoke-all-for-user.write.js +12 -2
- package/dist/sessions/handlers/revoke.write.js +2 -2
- package/dist/sessions/schema/user-session.js +2 -1
- package/dist/sessions/screens.js +5 -1
- package/dist/sessions/session-callbacks.js +88 -77
- package/dist/shared/assert-erased.d.ts +2 -1
- package/dist/shared/assert-erased.js +12 -4
- package/dist/shared/index.d.ts +2 -2
- package/dist/shared/index.js +2 -2
- package/dist/shared/lockout-counter.js +1 -1
- package/dist/shared/parent-visibility.d.ts +5 -0
- package/dist/shared/parent-visibility.js +17 -1
- package/dist/shared/row-bound-grant.js +20 -9
- package/dist/shared/single-use-token-store.js +5 -13
- package/dist/step-dispatcher/webhook-runner.d.ts +0 -2
- package/dist/step-dispatcher/webhook-runner.js +18 -17
- package/dist/subscription-mollie/plugin-methods.d.ts +3 -2
- package/dist/subscription-mollie/plugin-methods.js +7 -1
- package/dist/subscription-mollie/verify-webhook.js +6 -0
- package/dist/subscription-stripe/plugin-methods.js +25 -6
- package/dist/subscription-stripe/verify-webhook.js +13 -6
- package/dist/tags/handlers/assign-tag.write.js +4 -10
- package/dist/tags/handlers/remove-tag.write.js +4 -11
- package/dist/template-resolver/api.d.ts +2 -1
- package/dist/template-resolver/api.js +1 -1
- package/dist/template-resolver/handlers/by-slug.query.d.ts +1 -1
- package/dist/template-resolver/handlers/by-slug.query.js +2 -2
- package/dist/template-resolver/handlers/by-tenant.query.d.ts +3 -2
- package/dist/template-resolver/handlers/by-tenant.query.js +2 -2
- package/dist/template-resolver/handlers/collection-item.query.d.ts +1 -1
- package/dist/template-resolver/handlers/collection-list.query.d.ts +1 -1
- package/dist/template-resolver/handlers/collection-shared.d.ts +3 -2
- package/dist/template-resolver/handlers/collection-shared.js +1 -1
- package/dist/template-resolver/handlers/find-by-id.query.d.ts +1 -1
- package/dist/template-resolver/handlers/find-by-id.query.js +1 -1
- package/dist/template-resolver/handlers/list.query.d.ts +1 -1
- package/dist/template-resolver/handlers/list.query.js +1 -1
- package/dist/template-resolver/seeding.js +41 -39
- package/dist/template-resolver/table.d.ts +8 -5
- package/dist/template-resolver/testing.js +2 -2
- package/dist/template-resolver/user-content-table.d.ts +2 -5
- package/dist/template-resolver/web/client-plugin.d.ts +1 -1
- package/dist/template-resolver-user-data/hooks.js +1 -1
- package/dist/tenant/cancel-pending-invitation.d.ts +8 -0
- package/dist/tenant/cancel-pending-invitation.js +22 -0
- package/dist/tenant/feature.js +4 -2
- package/dist/tenant/handlers/cancel-invitation.write.js +6 -21
- package/dist/tenant/handlers/member-directory.query.d.ts +1 -0
- package/dist/tenant/handlers/member-directory.query.js +17 -4
- package/dist/tenant/handlers/remove-member.write.js +9 -14
- package/dist/tenant/handlers/team-list.query.js +2 -2
- package/dist/tenant/handlers/tenant-directory.query.d.ts +1 -0
- package/dist/tenant/handlers/tenant-directory.query.js +13 -3
- package/dist/tenant/handlers/update-member-roles.write.d.ts +1 -1
- package/dist/tenant/handlers/update-member-roles.write.js +28 -12
- package/dist/tenant/i18n.js +4 -0
- package/dist/tenant/index.d.ts +1 -1
- package/dist/tenant/index.js +1 -1
- package/dist/tenant/last-tenant-admin.d.ts +1 -1
- package/dist/tenant/last-tenant-admin.js +5 -3
- package/dist/tenant/seeding.d.ts +4 -0
- package/dist/tenant/seeding.js +9 -2
- package/dist/tenant-lifecycle/boot-checks.d.ts +1 -0
- package/dist/tenant-lifecycle/boot-checks.js +27 -1
- package/dist/tenant-lifecycle/feature.js +2 -1
- package/dist/tenant-lifecycle/stages.js +2 -2
- package/dist/tier-engine/feature.js +3 -2
- package/dist/user/command-schemas.d.ts +2 -2
- package/dist/user/handlers/create.write.d.ts +1 -1
- package/dist/user/handlers/create.write.js +8 -2
- package/dist/user/handlers/detail.query.d.ts +1 -0
- package/dist/user/handlers/update.write.d.ts +1 -1
- package/dist/user/handlers/update.write.js +8 -2
- package/dist/user-data-rights/db/queries/export-jobs.d.ts +2 -0
- package/dist/user-data-rights/feature.js +22 -9
- package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +4 -7
- package/dist/user-data-rights/handlers/deletion-grace-period.d.ts +3 -2
- package/dist/user-data-rights/handlers/deletion-grace-period.js +30 -8
- package/dist/user-data-rights/handlers/download-by-token.query.js +1 -1
- package/dist/user-data-rights/handlers/request-deletion-by-email.write.js +1 -1
- package/dist/user-data-rights/handlers/request-deletion.write.js +3 -6
- package/dist/user-data-rights/lib/update-user-lifecycle.js +7 -14
- package/dist/user-data-rights/run-export-jobs.d.ts +1 -0
- package/dist/user-data-rights/run-export-jobs.js +74 -41
- package/dist/user-data-rights/run-forget-cleanup.js +2 -2
- package/dist/user-data-rights/run-user-export.js +2 -2
- package/dist/user-data-rights-defaults/hooks/config-value.userdata-hook.js +4 -2
- package/dist/user-data-rights-defaults/hooks/file-ref.userdata-hook.js +8 -3
- package/dist/user-data-rights-defaults/hooks/notification-preference.userdata-hook.js +4 -2
- package/dist/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.js +4 -2
- package/dist/user-profile/feature.js +6 -1
- package/dist/user-profile/handlers/change-email.write.js +2 -0
- package/dist/workflow-runner/db/queries/due-runs.js +6 -9
- package/dist/workflow-runner/event-subscriber.js +29 -9
- package/dist/workflow-runner/handlers/resume-run.write.js +8 -5
- package/dist/workflow-runner/pending-projection.js +8 -1
- package/package.json +11 -9
- package/src/agent-tools/changes.json +12 -0
- package/src/auth-email-password/changes.json +18 -0
- package/src/auth-mfa/changes.json +6 -0
- package/src/billing-foundation/changes.json +7 -0
- package/src/cap-counter/changes.json +13 -0
- package/src/cap-overview/changes.json +12 -0
- package/src/channel-email/changes.json +6 -0
- package/src/config/changes.json +6 -0
- package/src/crypto-shredding/changes.json +6 -0
- package/src/data-retention/changes.json +12 -0
- package/src/delivery/changes.json +13 -0
- package/src/derivatives-sharp/changes.json +11 -0
- package/src/document-ingest-foundation/changes.json +12 -0
- package/src/feature-toggles/changes.json +5 -0
- package/src/inbound-mail-foundation/changes.json +12 -0
- package/src/jobs/changes.json +18 -0
- package/src/legal-pages/changes.json +8 -1
- package/src/managed-pages/changes.json +5 -0
- package/src/metrics/changes.json +6 -0
- package/src/personal-access-tokens/changes.json +5 -0
- package/src/rate-limiting/changes.json +5 -0
- package/src/secrets/changes.json +7 -0
- package/src/sessions/changes.json +11 -0
- package/src/shared/changes.json +6 -0
- package/src/step-dispatcher/changes.json +5 -0
- package/src/subscription-mollie/changes.json +7 -0
- package/src/subscription-stripe/changes.json +11 -0
- package/src/template-resolver/changes.json +7 -0
- package/src/tenant/changes.json +29 -0
- package/src/user-data-rights/changes.json +22 -0
- package/src/user-data-rights-defaults/changes.json +5 -0
- package/src/workflow-runner/changes.json +6 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { requestContext } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
import { configuredPiiSubjectKms, encryptPiiValueForSubject, } from "@cosmicdrift/kumiko-framework/crypto";
|
|
3
3
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
4
|
-
import { ConfigScopes, defineWriteHandler, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import { ConfigScopes, defineWriteHandler, isEncryptedAtRest, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
5
|
import { InternalError, UnprocessableError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
|
|
6
6
|
import * as z from "zod";
|
|
7
7
|
import { requireConfigEncryption, requireSystemDb } from "../feature.js";
|
|
8
|
+
import { MASKED } from "../read-redaction.js";
|
|
8
9
|
import { configValueEntity, configValuesTable } from "../table.js";
|
|
9
10
|
import { findConfigRow, prepareConfigWrite, resolvePiiSubject, validateBounds, validateExtensionPlugin, validatePattern, validateScope, validateType, } from "../write-helpers.js";
|
|
10
11
|
const scopeEnum = z.enum([ConfigScopes.system, ConfigScopes.tenant, ConfigScopes.user]);
|
|
@@ -53,7 +54,7 @@ export const setWrite = defineWriteHandler({
|
|
|
53
54
|
const typeError = validateType(event.payload.value, keyDef);
|
|
54
55
|
if (typeError)
|
|
55
56
|
return writeFailure(typeError);
|
|
56
|
-
const pluginError = validateExtensionPlugin(ctx.registry, event.payload.key, event.payload.value);
|
|
57
|
+
const pluginError = validateExtensionPlugin(ctx.registry, event.payload.key, event.payload.value, keyDef);
|
|
57
58
|
if (pluginError)
|
|
58
59
|
return writeFailure(pluginError);
|
|
59
60
|
// Bounds enforcement: hard-reject (not silent-clamp). A caller that
|
|
@@ -66,6 +67,9 @@ export const setWrite = defineWriteHandler({
|
|
|
66
67
|
const patternError = validatePattern(event.payload.value, keyDef);
|
|
67
68
|
if (patternError)
|
|
68
69
|
return writeFailure(patternError);
|
|
70
|
+
// The batch idempotency cache stores this result and only masks entity
|
|
71
|
+
// writeOnly fields, so an at-rest-encrypted value must not be echoed.
|
|
72
|
+
const echoedValue = isEncryptedAtRest(keyDef) ? MASKED : event.payload.value;
|
|
69
73
|
// backing="secrets": persist into the secrets store (system tenant, own
|
|
70
74
|
// envelope encryption + audit) instead of config_values. Same JSON
|
|
71
75
|
// serialization as a config row so the read path round-trips via
|
|
@@ -82,7 +86,7 @@ export const setWrite = defineWriteHandler({
|
|
|
82
86
|
});
|
|
83
87
|
return {
|
|
84
88
|
isSuccess: true,
|
|
85
|
-
data: { key: event.payload.key, value:
|
|
89
|
+
data: { key: event.payload.key, value: echoedValue, scope },
|
|
86
90
|
};
|
|
87
91
|
}
|
|
88
92
|
let serialized = JSON.stringify(event.payload.value);
|
|
@@ -139,7 +143,7 @@ export const setWrite = defineWriteHandler({
|
|
|
139
143
|
}
|
|
140
144
|
return {
|
|
141
145
|
isSuccess: true,
|
|
142
|
-
data: { key: event.payload.key, value:
|
|
146
|
+
data: { key: event.payload.key, value: echoedValue, scope },
|
|
143
147
|
};
|
|
144
148
|
},
|
|
145
149
|
});
|
package/dist/config/web/i18n.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
export const defaultTranslations = {
|
|
10
10
|
en: {
|
|
11
11
|
"config.secrets.delete": "Remove",
|
|
12
|
+
"config.secrets.deleteConfirm": "Remove this secret? Anything using it stops working, and it cannot be restored.",
|
|
12
13
|
"config.secrets.notSet": "Not set",
|
|
13
14
|
"config.secrets.placeholder": "Enter a value",
|
|
14
15
|
"config.secrets.replacePlaceholder": "Enter a new value to replace it",
|
|
@@ -38,7 +38,7 @@ export declare function resolveScopeIds(scope: ConfigScope, tenantId: TenantId,
|
|
|
38
38
|
userId: string | null;
|
|
39
39
|
};
|
|
40
40
|
export declare function resolvePiiSubject(scope: Exclude<ConfigScope, "system">, tenantId: TenantId, userId: string | null): SubjectId;
|
|
41
|
-
export declare function validateExtensionPlugin(registry: Registry, key: string, value: string | number | boolean): KumikoError | null;
|
|
41
|
+
export declare function validateExtensionPlugin(registry: Pick<Registry, "getAllExtensionSelectors" | "getExtensionUsages">, key: string, value: string | number | boolean, keyDef: ConfigKeyDefinition): KumikoError | null;
|
|
42
42
|
export declare function validateType(value: string | number | boolean, keyDef: ConfigKeyDefinition): KumikoError | null;
|
|
43
43
|
export declare function validateBounds(value: string | number | boolean, keyDef: ConfigKeyDefinition): KumikoError | null;
|
|
44
44
|
export declare function validatePattern(value: string | number | boolean, keyDef: ConfigKeyDefinition): KumikoError | null;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Extracted from set.write.ts so reset.write.ts + values.query.ts don't
|
|
3
3
|
// have to cross-import from another handler file.
|
|
4
4
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
5
|
-
import { ConfigScopes, extensionSelectorTargets, SYSTEM_ROLE, SYSTEM_TENANT_ID, selectablePluginIds, } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
|
+
import { ConfigScopes, extensionSelectorTargets, isEncryptedAtRest, SYSTEM_ROLE, SYSTEM_TENANT_ID, selectablePluginIds, } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
6
|
import { AccessDeniedError, InternalError, NotFoundError, UnprocessableError, ValidationError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
|
|
7
7
|
import { assertUnreachable } from "@cosmicdrift/kumiko-framework/utils";
|
|
8
8
|
import { ConfigErrors } from "./constants.js";
|
|
@@ -134,9 +134,14 @@ export function resolvePiiSubject(scope, tenantId, userId) {
|
|
|
134
134
|
}
|
|
135
135
|
return { kind: "tenant", tenantId };
|
|
136
136
|
}
|
|
137
|
+
// An encrypted value must not travel back in an error (logs, idempotency cache),
|
|
138
|
+
// so rejection details spread this instead of naming `value` directly.
|
|
139
|
+
function echoableValue(keyDef, value) {
|
|
140
|
+
return isEncryptedAtRest(keyDef) ? {} : { value };
|
|
141
|
+
}
|
|
137
142
|
// An extension-selector key may only name a mounted plugin (or "" to clear) —
|
|
138
143
|
// otherwise the settings screen would show no panel for a typo'd value.
|
|
139
|
-
export function validateExtensionPlugin(registry, key, value) {
|
|
144
|
+
export function validateExtensionPlugin(registry, key, value, keyDef) {
|
|
140
145
|
const extensionName = extensionSelectorTargets(registry).get(key);
|
|
141
146
|
if (extensionName === undefined || value === "")
|
|
142
147
|
return null;
|
|
@@ -145,7 +150,7 @@ export function validateExtensionPlugin(registry, key, value) {
|
|
|
145
150
|
return null;
|
|
146
151
|
return new UnprocessableError(ConfigErrors.unknownExtensionPlugin, {
|
|
147
152
|
i18nKey: "config.errors.unknownExtensionPlugin",
|
|
148
|
-
details: { key, value, allowed },
|
|
153
|
+
details: { key, ...echoableValue(keyDef, value), allowed },
|
|
149
154
|
});
|
|
150
155
|
}
|
|
151
156
|
export function validateType(value, keyDef) {
|
|
@@ -172,7 +177,7 @@ export function validateType(value, keyDef) {
|
|
|
172
177
|
path: "value",
|
|
173
178
|
code: "invalid_option",
|
|
174
179
|
i18nKey: "errors.validation.invalid_option",
|
|
175
|
-
params: { value, options: keyDef.options },
|
|
180
|
+
params: { ...echoableValue(keyDef, value), options: keyDef.options },
|
|
176
181
|
},
|
|
177
182
|
],
|
|
178
183
|
});
|
|
@@ -254,13 +259,14 @@ export function validatePattern(value, keyDef) {
|
|
|
254
259
|
}
|
|
255
260
|
if (re.test(value))
|
|
256
261
|
return null;
|
|
262
|
+
const echoedParams = { ...echoableValue(keyDef, value), pattern: keyDef.pattern.regex };
|
|
257
263
|
return new ValidationError({
|
|
258
264
|
fields: [
|
|
259
265
|
{
|
|
260
266
|
path: "value",
|
|
261
267
|
code: "invalid_format",
|
|
262
268
|
i18nKey: "errors.validation.invalid_format",
|
|
263
|
-
params:
|
|
269
|
+
params: echoedParams,
|
|
264
270
|
},
|
|
265
271
|
],
|
|
266
272
|
});
|
|
@@ -133,6 +133,17 @@ getRunner) {
|
|
|
133
133
|
});
|
|
134
134
|
return effective.policy?.strategy === "blockDelete" ? deny() : undefined;
|
|
135
135
|
}
|
|
136
|
+
// AccessDeniedError.code is the generic "access_denied" for every branch;
|
|
137
|
+
// the specific branch lives in details.reason.
|
|
138
|
+
function denialReasonOf(failure) {
|
|
139
|
+
const details = failure.error.details;
|
|
140
|
+
if (typeof details === "object" && details !== null && "reason" in details) {
|
|
141
|
+
const { reason } = details;
|
|
142
|
+
if (typeof reason === "string")
|
|
143
|
+
return reason;
|
|
144
|
+
}
|
|
145
|
+
return failure.error.code;
|
|
146
|
+
}
|
|
136
147
|
// Denied cross-tenant probes must still leave an audit trail (fw#2348).
|
|
137
148
|
// Appended outside the handler tx (fw#2592): the caller's
|
|
138
149
|
// `return tenantScopeDenial` rolls that tx back.
|
|
@@ -228,18 +239,14 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
228
239
|
const subjectKey = subjectIdToKey(subject);
|
|
229
240
|
const tenantScopeDenial = await resolveTenantScopeDenial(ctx.db.unsafeRaw("tenant-scope check runs against the subject's tenant, not necessarily the caller's"), ctx.registry.features, event.user, raw);
|
|
230
241
|
if (tenantScopeDenial) {
|
|
231
|
-
const auditFailure = await appendDenialAuditEvent(ctx, event, subjectKey, raw.kind, tenantScopeDenial
|
|
242
|
+
const auditFailure = await appendDenialAuditEvent(ctx, event, subjectKey, raw.kind, denialReasonOf(tenantScopeDenial), ctx.dbOutsideTransaction?.unsafeRaw("denial audit append: names the prober's own tenant stream on the outside-transaction db"));
|
|
232
243
|
return auditFailure ?? tenantScopeDenial;
|
|
233
244
|
}
|
|
234
|
-
// Runs
|
|
235
|
-
//
|
|
236
|
-
//
|
|
245
|
+
// Runs after the tenant gate but before any key is touched: a retention
|
|
246
|
+
// check ahead of the tenant gate would leak a foreign entity's retention
|
|
247
|
+
// posture to a cross-tenant prober.
|
|
237
248
|
const retentionDenial = await resolveRetentionDenial(ctx, raw, () => ctx.db.unsafeRaw("retention check reads the record row's owning tenant, not the caller's"));
|
|
238
249
|
if (retentionDenial) {
|
|
239
|
-
// Own reason constant, not tenantScopeDenial's `.error.code` pattern:
|
|
240
|
-
// AccessDeniedError.code is the generic "access_denied" for every
|
|
241
|
-
// branch, so re-deriving it here would make a blockDelete refusal
|
|
242
|
-
// indistinguishable from a cross-tenant one in the audit trail.
|
|
243
250
|
const auditFailure = await appendDenialAuditEvent(ctx, event, subjectKey, raw.kind, TARGET_RECORD_RETENTION_BLOCK_DELETE, ctx.dbOutsideTransaction?.unsafeRaw("denial audit append: names the prober's own tenant stream on the outside-transaction db"));
|
|
244
251
|
return auditFailure ?? retentionDenial;
|
|
245
252
|
}
|
|
@@ -19,10 +19,7 @@ export const CustomFieldsHandlers = {
|
|
|
19
19
|
export const CustomFieldsQueries = {
|
|
20
20
|
fieldDefinitionList: "custom-fields:query:field-definition:list",
|
|
21
21
|
};
|
|
22
|
-
// Name the web component registers under in ExtensionSectionsProvider
|
|
23
|
-
// apps reference it in the screen schema via:
|
|
24
|
-
// { kind: "extension", title, contributesToFormSubmit: true,
|
|
25
|
-
// component: { react: { __component: CUSTOM_FIELDS_FORM_EXTENSION_NAME } } }
|
|
22
|
+
// Name the web component registers under in ExtensionSectionsProvider.
|
|
26
23
|
export const CUSTOM_FIELDS_FORM_EXTENSION_NAME = "CustomFieldsFormSection";
|
|
27
24
|
// Entity-CRUD auto-events for the `field-definition` entity. registry.ts emits
|
|
28
25
|
// these as `${entityName}.created`/`.updated` (dot form) — they do NOT run
|
|
@@ -10,9 +10,8 @@ export interface ResolveForTenantArgs {
|
|
|
10
10
|
/**
|
|
11
11
|
* Layer 2 — Tenant-Preset. `undefined` (the default) means this resolver
|
|
12
12
|
* derives it itself via resolveTenantRetentionPreset — one compliance-
|
|
13
|
-
* profile read per call, fine for single-lookup callers (`policy-for
|
|
14
|
-
*
|
|
15
|
-
* iterating N entities × M tenants (the forget-cleanup runner, the
|
|
13
|
+
* profile read per call, fine for single-lookup callers (`policy-for`). A
|
|
14
|
+
* bulk caller iterating N entities × M tenants (the forget-cleanup runner, the
|
|
16
15
|
* retention-cleanup cron) MUST resolve it ONCE per tenant and pass it in
|
|
17
16
|
* here instead, or it re-derives per entity. `null` means "resolved
|
|
18
17
|
* already, no preset applies" — the resolver falls back to Entity-Default
|
|
@@ -41,7 +41,7 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
|
41
41
|
import { collectPiiSubjectFields, configuredPiiSubjectKms, isSelfPiiField, resolveSubjectForField, subjectIdToKey, } from "@cosmicdrift/kumiko-framework/crypto";
|
|
42
42
|
import { createEventStoreExecutor, createTenantDb, nullBlindIndexesForSubject, } from "@cosmicdrift/kumiko-framework/db";
|
|
43
43
|
import { deleteStoredFileAndDerivatives } from "@cosmicdrift/kumiko-framework/derivatives";
|
|
44
|
-
import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
|
|
44
|
+
import { createSystemUser, entityHasAnonymizableSubjectField, } from "@cosmicdrift/kumiko-framework/engine";
|
|
45
45
|
import { fileRefEntity, fileRefsTable, } from "@cosmicdrift/kumiko-framework/files";
|
|
46
46
|
import { runInSubTransaction } from "../shared/index.js";
|
|
47
47
|
import { computeCutoff } from "./keep-for.js";
|
|
@@ -204,22 +204,62 @@ function isFileRefBoundElsewhere(fileRef, entityName, rowId) {
|
|
|
204
204
|
return true;
|
|
205
205
|
return false;
|
|
206
206
|
}
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
for (const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
207
|
+
// Every implicit entity table with file/image fields: an unbound fileRef
|
|
208
|
+
// (POST /files without entityType/entityId) can sit in single fields of
|
|
209
|
+
// several entity types, so the sharing check must look beyond the purged
|
|
210
|
+
// row's own table.
|
|
211
|
+
function collectSingleFileHolders(registry) {
|
|
212
|
+
const holders = [];
|
|
213
|
+
for (const proj of registry.getAllProjections().values()) {
|
|
214
|
+
if (proj.isImplicit !== true || typeof proj.source !== "string" || !proj.table)
|
|
215
|
+
continue;
|
|
216
|
+
const entity = registry.getEntity(proj.source);
|
|
217
|
+
if (!entity)
|
|
218
|
+
continue;
|
|
219
|
+
const { singleFields } = planFileFields(entity.fields);
|
|
220
|
+
if (singleFields.length === 0)
|
|
221
|
+
continue;
|
|
222
|
+
const table = proj.table; // @cast-boundary column-presence probe
|
|
223
|
+
holders.push({
|
|
224
|
+
table: proj.table,
|
|
225
|
+
tableHasTenantId: table["tenantId"] !== undefined,
|
|
226
|
+
singleFields,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return holders;
|
|
230
|
+
}
|
|
231
|
+
// A single-field fileRef-id can be shared by more than one row, in the SAME
|
|
232
|
+
// table (e.g. a stock-photo re-picked across records) or in another entity's
|
|
233
|
+
// table. Purging its bytes would corrupt the row that isn't being deleted.
|
|
234
|
+
// Bounded by batchLimit rows per run and holderFields queries per fileRef —
|
|
235
|
+
// the same per-row event-cost tradeoff as the rest of this cron (see header).
|
|
236
|
+
async function isFileRefSharedByOtherRow(db, holders, ownTable, tenantId, rowId, fileRefId) {
|
|
237
|
+
for (const holder of holders) {
|
|
238
|
+
for (const field of holder.singleFields) {
|
|
239
|
+
const where = { [field]: fileRefId };
|
|
240
|
+
if (holder.table === ownTable)
|
|
241
|
+
where["id"] = { ne: rowId };
|
|
242
|
+
if (holder.tableHasTenantId)
|
|
243
|
+
where["tenantId"] = tenantId;
|
|
244
|
+
const others = await selectMany(db, holder.table, where, { limit: 1 });
|
|
245
|
+
if (others.length > 0)
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
220
248
|
}
|
|
221
249
|
return false;
|
|
222
250
|
}
|
|
251
|
+
// Throws (unsafe key, missing list permission) must fail this row closed,
|
|
252
|
+
// not abort the whole tenant run.
|
|
253
|
+
async function deleteFileFailingClosed(key, store, logContext) {
|
|
254
|
+
try {
|
|
255
|
+
return await deleteStoredFileAndDerivatives(key, store, "data-retention:hardDelete");
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
// biome-ignore lint/suspicious/noConsole: operator-visibility for storage-cleanup failures
|
|
259
|
+
console.warn(`[data-retention:hardDelete] ${logContext} storage cleanup threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
260
|
+
return [key];
|
|
261
|
+
}
|
|
262
|
+
}
|
|
223
263
|
// hardDelete for ONE row of an entity that has file/image/files/images
|
|
224
264
|
// fields: delete the un-shared fileRefs' bytes (+ derivatives) first, then
|
|
225
265
|
// their fileRef rows, then the entity row itself — bytes before rows so a
|
|
@@ -251,7 +291,7 @@ async function purgeHardDeleteRowWithFiles(args) {
|
|
|
251
291
|
for (const fileRef of fileRefRows) {
|
|
252
292
|
if (isFileRefBoundElsewhere(fileRef, args.entityName, rowId))
|
|
253
293
|
continue;
|
|
254
|
-
const shared = await isFileRefSharedByOtherRow(args.db, args.
|
|
294
|
+
const shared = await isFileRefSharedByOtherRow(args.db, args.fileHolders, args.table, args.tenantId, rowId, String(fileRef["id"]));
|
|
255
295
|
if (shared)
|
|
256
296
|
continue;
|
|
257
297
|
toDelete.push(fileRef);
|
|
@@ -270,7 +310,7 @@ async function purgeHardDeleteRowWithFiles(args) {
|
|
|
270
310
|
const key = fileRef["storageKey"];
|
|
271
311
|
if (typeof key !== "string" || key.length === 0)
|
|
272
312
|
continue;
|
|
273
|
-
const failedKeys = await
|
|
313
|
+
const failedKeys = await deleteFileFailingClosed(key, store, `tenant=${args.tenantId} entity=${args.entityName} row=${rowId}`);
|
|
274
314
|
if (failedKeys.length > 0) {
|
|
275
315
|
anyFailed = true;
|
|
276
316
|
// biome-ignore lint/suspicious/noConsole: operator-visibility for storage-cleanup failures
|
|
@@ -295,9 +335,6 @@ async function purgeHardDeleteRowWithFiles(args) {
|
|
|
295
335
|
// each skip reason at most once per entity per run (repeat skips would just
|
|
296
336
|
// be noise; the per-row failure is already warned above).
|
|
297
337
|
async function purgeHardDeleteRowsWithFiles(args) {
|
|
298
|
-
const rows = await selectMany(args.db, args.table, args.where, {
|
|
299
|
-
limit: args.batchLimit,
|
|
300
|
-
});
|
|
301
338
|
const reasonsSeen = new Set();
|
|
302
339
|
const onSkip = (reason) => {
|
|
303
340
|
// skip: reason already reported for this entity in this run
|
|
@@ -306,28 +343,44 @@ async function purgeHardDeleteRowsWithFiles(args) {
|
|
|
306
343
|
reasonsSeen.add(reason);
|
|
307
344
|
args.skipped.push({ entityName: args.entityName, reason });
|
|
308
345
|
};
|
|
346
|
+
// id-cursor paging: rows skipped (missing storage, failed delete) stay in the
|
|
347
|
+
// table, so an unordered limit-page would re-fetch the same stuck rows every
|
|
348
|
+
// run and starve every row behind them. batchLimit bounds the DELETES.
|
|
309
349
|
let count = 0;
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
entityName: args.entityName,
|
|
317
|
-
entity: args.entity,
|
|
318
|
-
row,
|
|
319
|
-
plan: args.plan,
|
|
320
|
-
files: args.files,
|
|
321
|
-
entityExecutor: args.entityExecutor,
|
|
322
|
-
fileRefExecutor: args.fileRefExecutor,
|
|
323
|
-
systemUser: args.systemUser,
|
|
324
|
-
tdb: args.tdb,
|
|
325
|
-
kms: args.kms,
|
|
326
|
-
registry: args.registry,
|
|
327
|
-
onSkip,
|
|
350
|
+
let cursor = null;
|
|
351
|
+
while (count < args.batchLimit) {
|
|
352
|
+
const pageWhere = cursor === null ? args.where : { ...args.where, id: { gt: cursor } };
|
|
353
|
+
const rows = await selectMany(args.db, args.table, pageWhere, {
|
|
354
|
+
limit: args.batchLimit,
|
|
355
|
+
orderBy: { col: "id" },
|
|
328
356
|
});
|
|
329
|
-
if (
|
|
330
|
-
|
|
357
|
+
if (rows.length === 0)
|
|
358
|
+
break;
|
|
359
|
+
for (const row of rows) {
|
|
360
|
+
cursor = row["id"]; // @cast-boundary db-row
|
|
361
|
+
const ok = await purgeHardDeleteRowWithFiles({
|
|
362
|
+
db: args.db,
|
|
363
|
+
table: args.table,
|
|
364
|
+
fileHolders: args.fileHolders,
|
|
365
|
+
tenantId: args.tenantId,
|
|
366
|
+
entityName: args.entityName,
|
|
367
|
+
entity: args.entity,
|
|
368
|
+
row,
|
|
369
|
+
plan: args.plan,
|
|
370
|
+
files: args.files,
|
|
371
|
+
entityExecutor: args.entityExecutor,
|
|
372
|
+
fileRefExecutor: args.fileRefExecutor,
|
|
373
|
+
systemUser: args.systemUser,
|
|
374
|
+
tdb: args.tdb,
|
|
375
|
+
kms: args.kms,
|
|
376
|
+
registry: args.registry,
|
|
377
|
+
onSkip,
|
|
378
|
+
});
|
|
379
|
+
if (ok)
|
|
380
|
+
count++;
|
|
381
|
+
if (count >= args.batchLimit)
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
331
384
|
}
|
|
332
385
|
return count;
|
|
333
386
|
}
|
|
@@ -394,6 +447,7 @@ export async function runRetentionCleanup(args) {
|
|
|
394
447
|
// filter shrinks the set). A single batched forget/delete event is a follow-up.
|
|
395
448
|
const systemUser = createSystemUser(tenantId);
|
|
396
449
|
const tdb = createTenantDb(db, tenantId, "system");
|
|
450
|
+
const fileHolders = collectSingleFileHolders(registry);
|
|
397
451
|
// Pre-load every override row for this tenant ONCE — N entities × M
|
|
398
452
|
// tenants would otherwise mean one fetchOne per entity per tenant, even
|
|
399
453
|
// though the whole set for a tenant is a single small, indexed read.
|
|
@@ -458,7 +512,7 @@ export async function runRetentionCleanup(args) {
|
|
|
458
512
|
hardDeleted += await purgeHardDeleteRowsWithFiles({
|
|
459
513
|
db,
|
|
460
514
|
table: proj.table,
|
|
461
|
-
|
|
515
|
+
fileHolders,
|
|
462
516
|
where,
|
|
463
517
|
batchLimit,
|
|
464
518
|
tenantId,
|
|
@@ -495,10 +549,12 @@ export async function runRetentionCleanup(args) {
|
|
|
495
549
|
case "blockDelete": {
|
|
496
550
|
const targets = await resolveAnonymizeTargets(entity.fields);
|
|
497
551
|
if (!targets) {
|
|
498
|
-
// blockDelete without anonymize fields is a pure hold
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
|
|
552
|
+
// blockDelete without anonymize fields is a pure hold only while the
|
|
553
|
+
// entity has no field that CAN carry anonymize: a subjectRef-only
|
|
554
|
+
// person link outlives the hold and is cut by the user-data forget
|
|
555
|
+
// hook. A personal field lacking its anonymize function would stay
|
|
556
|
+
// stored past the hold, so that case is still reported.
|
|
557
|
+
if (policy.strategy === "anonymize" || entityHasAnonymizableSubjectField(entity.fields)) {
|
|
502
558
|
skipped.push({ entityName, reason: "missing_anonymize_fields" });
|
|
503
559
|
}
|
|
504
560
|
break;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import {
|
|
2
|
+
import { decodeKeysetCursor, encodeKeysetCursor } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import { access, definePagedQueryHandler, MAX_LIST_LIMIT, } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
5
5
|
import { Temporal } from "temporal-polyfill";
|
|
@@ -28,15 +28,15 @@ function isDeliveryLogSortField(value) {
|
|
|
28
28
|
// PHYSICAL column, not the display alias — row only has the DB field names
|
|
29
29
|
// (e.g. notificationType), never the alias (e.g. "type").
|
|
30
30
|
function encodeSortCursor(column, row) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
return encodeCursor(String(row[column]));
|
|
31
|
+
const sortValue = column === "createdAt" ? row.createdAt.toString() : String(row[column]);
|
|
32
|
+
return encodeKeysetCursor(sortValue, row.id);
|
|
34
33
|
}
|
|
34
|
+
// lastId is undefined for a legacy sort-value-only cursor still in flight.
|
|
35
35
|
function decodeSortCursor(column, cursor) {
|
|
36
|
-
const decoded =
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return decoded;
|
|
36
|
+
const decoded = decodeKeysetCursor(cursor);
|
|
37
|
+
const raw = decoded.sortValue ?? decoded.id;
|
|
38
|
+
const sortValue = column === "createdAt" ? Temporal.Instant.from(raw) : raw;
|
|
39
|
+
return { sortValue, lastId: decoded.sortValue === undefined ? undefined : decoded.id };
|
|
40
40
|
}
|
|
41
41
|
export const logQuery = definePagedQueryHandler({
|
|
42
42
|
name: "log",
|
|
@@ -70,23 +70,32 @@ export const logQuery = definePagedQueryHandler({
|
|
|
70
70
|
const sortColumn = DELIVERY_LOG_SORT_COLUMNS[sortField];
|
|
71
71
|
// TenantAdmin/Admin stay strictly tenant-scoped; SystemAdmin sees every
|
|
72
72
|
// tenant's attempts (platform waitlist confirmations live on SYSTEM_TENANT_ID).
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
73
|
+
const baseWhere = isSystemAdmin ? {} : { tenantId: query.user.tenantId };
|
|
74
|
+
const limit = query.payload.limit;
|
|
75
|
+
const cursor = query.payload.cursor
|
|
76
|
+
? decodeSortCursor(sortColumn, query.payload.cursor)
|
|
77
|
+
: undefined;
|
|
78
|
+
const beyondSortValue = sortDirection === "asc" ? "gt" : "lt";
|
|
79
|
+
// Keyset page = rows still tied on the cursor's sort value (id after the
|
|
80
|
+
// cursor's id), then rows strictly beyond it. Both halves are ordered the
|
|
81
|
+
// same way as the overall ORDER BY, so concatenation preserves it.
|
|
82
|
+
const tiedRows = cursor?.lastId !== undefined
|
|
83
|
+
? await selectMany(db, deliveryAttemptsTable, { ...baseWhere, [sortColumn]: cursor.sortValue, id: { gt: cursor.lastId } }, { orderBy: [{ col: "id", direction: "asc" }], limit })
|
|
84
|
+
: [];
|
|
85
|
+
const beyondRows = tiedRows.length < limit
|
|
86
|
+
? await selectMany(db, deliveryAttemptsTable, cursor
|
|
87
|
+
? { ...baseWhere, [sortColumn]: { [beyondSortValue]: cursor.sortValue } }
|
|
88
|
+
: baseWhere, {
|
|
89
|
+
orderBy: [
|
|
90
|
+
{ col: sortColumn, direction: sortDirection },
|
|
91
|
+
{ col: "id", direction: "asc" },
|
|
92
|
+
],
|
|
93
|
+
limit: limit - tiedRows.length,
|
|
94
|
+
})
|
|
95
|
+
: [];
|
|
96
|
+
const rows = [...tiedRows, ...beyondRows];
|
|
88
97
|
const lastRow = rows[rows.length - 1];
|
|
89
|
-
const nextCursor = rows.length ===
|
|
98
|
+
const nextCursor = rows.length === limit && lastRow ? encodeSortCursor(sortColumn, lastRow) : null;
|
|
90
99
|
// recipientAddress is stored encrypted under the recipient's DEK (#799)
|
|
91
100
|
// — decrypt for the admin log view; forgotten subjects show [[erased]].
|
|
92
101
|
// The notificationType/recipientAddress → type/recipient rename happens
|
package/dist/delivery/types.d.ts
CHANGED
|
@@ -23,7 +23,8 @@ export type RenderedMessage = {
|
|
|
23
23
|
readonly html: string;
|
|
24
24
|
readonly subject: string;
|
|
25
25
|
};
|
|
26
|
-
export
|
|
26
|
+
export declare const DELIVERY_CHANNEL_MODES: readonly ["inline", "queued"];
|
|
27
|
+
export type DeliveryChannelMode = (typeof DELIVERY_CHANNEL_MODES)[number];
|
|
27
28
|
export type DeliveryChannel = {
|
|
28
29
|
readonly name: string;
|
|
29
30
|
readonly mode: DeliveryChannelMode;
|
package/dist/delivery/types.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { DELIVERY_CHANNEL_EXTENSION } from "./constants.js";
|
|
2
|
+
// `mode` decides how the delivery-service dispatches a channel:
|
|
3
|
+
// inline — sent synchronously inside notify() (inApp: DB insert + SSE).
|
|
4
|
+
// queued — sent asynchronously via the delivery.send job; channels with a
|
|
5
|
+
// render() additionally run through delivery.render first.
|
|
6
|
+
export const DELIVERY_CHANNEL_MODES = ["inline", "queued"];
|
|
2
7
|
export function isDeliveryChannelPlugin(o) {
|
|
3
8
|
return (typeof o === "object" &&
|
|
4
9
|
o !== null &&
|
|
5
10
|
"mode" in o &&
|
|
11
|
+
DELIVERY_CHANNEL_MODES.some((mode) => mode === o.mode) &&
|
|
12
|
+
(!("render" in o) || o.render === undefined || typeof o.render === "function") &&
|
|
6
13
|
"resolve" in o &&
|
|
7
14
|
typeof o.resolve === "function" &&
|
|
8
15
|
"send" in o &&
|
|
@@ -213,13 +213,7 @@ function confirmationPage(token) {
|
|
|
213
213
|
// One-click clients put the token in the query string; only the
|
|
214
214
|
// confirmation-page form submits it in the body.
|
|
215
215
|
function tokenFromPostRequest(request) {
|
|
216
|
-
|
|
217
|
-
if (contentType?.includes("application/x-www-form-urlencoded")) {
|
|
218
|
-
const fromBody = new URLSearchParams(request.rawBody).get("token");
|
|
219
|
-
if (fromBody)
|
|
220
|
-
return fromBody;
|
|
221
|
-
}
|
|
222
|
-
return request.query["token"];
|
|
216
|
+
return tokenFromRequestBody(request) ?? request.query["token"];
|
|
223
217
|
}
|
|
224
218
|
// Resubscribe is deliberately NOT reachable via the query string — a mail
|
|
225
219
|
// client's link prefetcher (or a scanner) following an unsubscribe link
|
|
@@ -227,10 +221,10 @@ function tokenFromPostRequest(request) {
|
|
|
227
221
|
// convention is built to avoid; resubscribe carries the same risk in
|
|
228
222
|
// reverse (a prefetch that undoes a real opt-out) and gets no query-param
|
|
229
223
|
// fallback at all.
|
|
230
|
-
function
|
|
224
|
+
function tokenFromRequestBody(request) {
|
|
231
225
|
const contentType = request.headers["content-type"];
|
|
232
226
|
if (contentType?.includes("application/x-www-form-urlencoded")) {
|
|
233
|
-
return new URLSearchParams(request.rawBody).get("token")
|
|
227
|
+
return new URLSearchParams(request.rawBody).get("token") || undefined;
|
|
234
228
|
}
|
|
235
229
|
if (contentType?.includes("application/json")) {
|
|
236
230
|
try {
|
|
@@ -278,13 +272,13 @@ export function createUnsubscribeRoutes(options) {
|
|
|
278
272
|
},
|
|
279
273
|
});
|
|
280
274
|
// Undo direction: token only from the JSON/form body, never the query
|
|
281
|
-
// (see
|
|
275
|
+
// (see tokenFromRequestBody) — no GET confirmation-page variant,
|
|
282
276
|
// this is a one-shot POST from the unsubscribe-page's "Undo" button.
|
|
283
277
|
const resubscribeRoute = signatureRoute({
|
|
284
278
|
method: "POST",
|
|
285
279
|
path: DELIVERY_RESUBSCRIBE_PATH,
|
|
286
280
|
entry: "signature",
|
|
287
|
-
verify: async (request) => verifyUnsubscribeToken(
|
|
281
|
+
verify: async (request) => verifyUnsubscribeToken(tokenFromRequestBody(request), encodedSecret),
|
|
288
282
|
handler: async (c, verified, deps) => {
|
|
289
283
|
const outcome = await dispatchResubscribeWrite(verified, deps);
|
|
290
284
|
if (outcome === "limit_reached") {
|
|
@@ -124,24 +124,40 @@ function overlayPosition(gravity, marginPx, outputWidth, outputHeight, layerWidt
|
|
|
124
124
|
// fraction of the output is a fraction of what the caller actually gets,
|
|
125
125
|
// regardless of the source's own dimensions or `fit` crop.
|
|
126
126
|
async function applyOverlays(pipeline, layers) {
|
|
127
|
-
|
|
127
|
+
// PNG intermediate: a bare toBuffer() re-encodes in the source format, so a
|
|
128
|
+
// JPEG source would gain an extra lossy generation before applyEncoder runs.
|
|
129
|
+
const { data, info } = await pipeline.png().toBuffer({ resolveWithObject: true });
|
|
128
130
|
const composites = [];
|
|
129
131
|
for (const layer of layers) {
|
|
132
|
+
const targetWidth = Math.max(1, Math.round(info.width * layer.widthPct));
|
|
133
|
+
// The QR is square, so the output height bounds its side just like the width does.
|
|
134
|
+
const qrSide = Math.min(targetWidth, info.height);
|
|
135
|
+
if (layer.kind === "qr" && qrSide < MIN_QR_PIXEL_WIDTH) {
|
|
136
|
+
throw new Error(`derivatives-sharp: qr overlay would render at ${qrSide}px wide, below the ${MIN_QR_PIXEL_WIDTH}px minimum a camera can reliably scan.`);
|
|
137
|
+
}
|
|
138
|
+
// Rendered at its final size so the modules stay hard-edged; resampling a
|
|
139
|
+
// small default-size QR to a non-integer module size blurs them.
|
|
130
140
|
const layerBytes = layer.kind === "qr"
|
|
131
|
-
? await QRCode.toBuffer(layer.data, {
|
|
141
|
+
? await QRCode.toBuffer(layer.data, {
|
|
142
|
+
width: qrSide,
|
|
143
|
+
margin: 1,
|
|
144
|
+
errorCorrectionLevel: "M",
|
|
145
|
+
})
|
|
132
146
|
: Buffer.from(layer.imageBase64, "base64");
|
|
133
147
|
const layerMeta = await sharp(layerBytes, SHARP_INPUT_OPTIONS).metadata();
|
|
134
148
|
assertNotSvg(layerMeta.format);
|
|
135
|
-
const targetWidth = Math.max(1, Math.round(info.width * layer.widthPct));
|
|
136
|
-
if (layer.kind === "qr" && targetWidth < MIN_QR_PIXEL_WIDTH) {
|
|
137
|
-
throw new Error(`derivatives-sharp: qr overlay would render at ${targetWidth}px wide, below the ${MIN_QR_PIXEL_WIDTH}px minimum a camera can reliably scan.`);
|
|
138
|
-
}
|
|
139
149
|
// PNG (not the layer's own format) to keep any alpha through the resize.
|
|
140
150
|
// height caps the other axis too — sharp's composite() rejects an input
|
|
141
151
|
// larger than the base image, which a tall/square layer would otherwise
|
|
142
152
|
// hit once widthPct alone drives its width past the output's height.
|
|
143
153
|
const resized = await sharp(layerBytes, SHARP_INPUT_OPTIONS)
|
|
144
|
-
.resize({
|
|
154
|
+
.resize({
|
|
155
|
+
width: targetWidth,
|
|
156
|
+
height: info.height,
|
|
157
|
+
fit: "inside",
|
|
158
|
+
// Only ever a sub-pixel correction for a QR; keeps module edges crisp.
|
|
159
|
+
kernel: layer.kind === "qr" ? "nearest" : "lanczos3",
|
|
160
|
+
})
|
|
145
161
|
.png()
|
|
146
162
|
.toBuffer({ resolveWithObject: true });
|
|
147
163
|
const marginPx = Math.round(info.width * (layer.marginPct ?? 0));
|
|
@@ -212,7 +228,8 @@ function assertOverlayLayerBounds(layer) {
|
|
|
212
228
|
if (!Number.isFinite(layer.widthPct) || layer.widthPct <= 0 || layer.widthPct > 1) {
|
|
213
229
|
throw new Error(`derivatives-sharp: overlay widthPct ${layer.widthPct} is out of range — must be a finite number in (0, 1].`);
|
|
214
230
|
}
|
|
215
|
-
if (layer.marginPct !== undefined &&
|
|
231
|
+
if (layer.marginPct !== undefined &&
|
|
232
|
+
(!Number.isFinite(layer.marginPct) || layer.marginPct < 0 || layer.marginPct >= 0.5)) {
|
|
216
233
|
throw new Error(`derivatives-sharp: overlay marginPct ${layer.marginPct} is out of range — must be within [0, 0.5).`);
|
|
217
234
|
}
|
|
218
235
|
if (layer.kind === "qr") {
|
|
@@ -278,7 +295,8 @@ export const renderImage = async (input, spec, sourceMimeType) => {
|
|
|
278
295
|
// the output.
|
|
279
296
|
let pipeline = sharp(input, SHARP_INPUT_OPTIONS).rotate();
|
|
280
297
|
if (spec.blurRegions && spec.blurRegions.length > 0) {
|
|
281
|
-
|
|
298
|
+
// PNG intermediate for the same reason as in applyOverlays (no extra lossy generation).
|
|
299
|
+
const { data, info } = await pipeline.png().toBuffer({ resolveWithObject: true });
|
|
282
300
|
const blurred = await applyBlurRegions(data, info.width, info.height, spec.blurRegions);
|
|
283
301
|
pipeline = sharp(blurred, SHARP_INPUT_OPTIONS);
|
|
284
302
|
}
|