@cosmicdrift/kumiko-samples 0.387.0 → 0.388.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +8 -1
- package/packages/bundled-features/src/agent-tools/changes.json +6 -0
- package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +9 -9
- package/packages/bundled-features/src/audit/changes.json +12 -0
- package/packages/bundled-features/src/audit/handlers/audit-events.ts +1 -1
- package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
- package/packages/bundled-features/src/auth-mfa/feature-exports.ts +2 -2
- package/packages/bundled-features/src/auth-mfa/feature.ts +6 -0
- package/packages/bundled-features/src/auth-mfa/web/i18n.ts +6 -1
- package/packages/bundled-features/src/auth-mfa/web/mfa-code-or-recovery-field.tsx +68 -0
- package/packages/bundled-features/src/auth-mfa/web/mfa-disable-dialog.tsx +23 -13
- package/packages/bundled-features/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +23 -13
- package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +15 -6
- package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +65 -18
- package/packages/bundled-features/src/billing-foundation/changes.json +55 -0
- package/packages/bundled-features/src/billing-foundation/checkout-core.ts +32 -42
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +8 -2
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +14 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +3 -4
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +12 -6
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +35 -19
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +12 -8
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +14 -8
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +5 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +8 -10
- package/packages/bundled-features/src/billing-foundation/events.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/feature.ts +3 -1
- package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +71 -6
- package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/handlers/plan-switch-preview.query.ts +65 -0
- package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +9 -2
- package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +44 -8
- package/packages/bundled-features/src/billing-foundation/handlers/switch-plan.write.ts +72 -82
- package/packages/bundled-features/src/billing-foundation/handlers/sync-subscription.write.ts +14 -1
- package/packages/bundled-features/src/billing-foundation/i18n.ts +22 -0
- package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +62 -27
- package/packages/bundled-features/src/billing-foundation/plan-switch.ts +84 -0
- package/packages/bundled-features/src/billing-foundation/types.ts +80 -1
- package/packages/bundled-features/src/billing-foundation/validate-options.ts +19 -0
- package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +119 -47
- package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +89 -42
- package/packages/bundled-features/src/billing-foundation/web/constants.ts +4 -0
- package/packages/bundled-features/src/billing-foundation/web/plan-price.ts +55 -0
- package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +224 -0
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +5 -1
- package/packages/bundled-features/src/cap-counter/changes.json +18 -0
- package/packages/bundled-features/src/cap-counter/enforce-cap.ts +2 -1
- package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +8 -1
- package/packages/bundled-features/src/channel-email/changes.json +6 -0
- package/packages/bundled-features/src/channel-email/feature.ts +1 -1
- package/packages/bundled-features/src/config/changes.json +12 -0
- package/packages/bundled-features/src/config/handlers/cascade.query.ts +4 -2
- package/packages/bundled-features/src/config/handlers/values.query.ts +4 -2
- package/packages/bundled-features/src/config/write-helpers.ts +27 -8
- package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +10 -9
- package/packages/bundled-features/src/delivery/changes.json +30 -0
- package/packages/bundled-features/src/delivery/channel-context.ts +9 -3
- package/packages/bundled-features/src/delivery/db/queries/preferences.ts +2 -2
- package/packages/bundled-features/src/delivery/delivery-service.ts +35 -5
- package/packages/bundled-features/src/delivery/handlers/preferences.query.ts +20 -1
- package/packages/bundled-features/src/delivery/handlers/unsubscribe-page-chrome.query.ts +1 -1
- package/packages/bundled-features/src/delivery/unsubscribe-link.ts +3 -1
- package/packages/bundled-features/src/delivery/unsubscribe-page-strings.ts +1 -0
- package/packages/bundled-features/src/delivery/unsubscribe-page.ts +6 -0
- package/packages/bundled-features/src/delivery/unsubscribe.ts +36 -15
- package/packages/bundled-features/src/file-foundation/index.ts +1 -0
- package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +7 -1
- package/packages/bundled-features/src/file-provider-s3/env-named-feature.ts +0 -2
- package/packages/bundled-features/src/files/README.md +1 -1
- package/packages/bundled-features/src/form-draft/changes.json +6 -0
- package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +2 -2
- package/packages/bundled-features/src/form-draft/decrypt-draft.ts +4 -1
- package/packages/bundled-features/src/host-routing/README.md +1 -1
- package/packages/bundled-features/src/host-routing/changes.json +6 -0
- package/packages/bundled-features/src/host-routing/dispatch.ts +16 -17
- package/packages/bundled-features/src/host-routing/options.ts +2 -1
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +18 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +17 -5
- package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +0 -1
- package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +0 -3
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +4 -3
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +32 -16
- package/packages/bundled-features/src/jobs/changes.json +13 -0
- package/packages/bundled-features/src/jobs/feature.ts +14 -4
- package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +45 -40
- package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +63 -59
- package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -4
- package/packages/bundled-features/src/legal-pages/README.md +1 -1
- package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/mail-foundation/feature.ts +5 -3
- package/packages/bundled-features/src/personal-access-tokens/feature.ts +4 -1
- package/packages/bundled-features/src/renderer-pdf/changes.json +6 -0
- package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +27 -10
- package/packages/bundled-features/src/sessions/feature.ts +7 -2
- package/packages/bundled-features/src/shared/decrypt-stored-pii.ts +10 -0
- package/packages/bundled-features/src/shared/index.ts +1 -1
- package/packages/bundled-features/src/shared/mail-template-body.ts +10 -3
- package/packages/bundled-features/src/step-dispatcher/changes.json +6 -0
- package/packages/bundled-features/src/step-dispatcher/feature.ts +1 -0
- package/packages/bundled-features/src/subscription-dodo/changes.json +14 -0
- package/packages/bundled-features/src/subscription-dodo/client.ts +45 -0
- package/packages/bundled-features/src/subscription-dodo/constants.ts +24 -0
- package/packages/bundled-features/src/subscription-dodo/feature.ts +207 -0
- package/packages/bundled-features/src/subscription-dodo/index.ts +16 -0
- package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +345 -0
- package/packages/bundled-features/src/subscription-dodo/runtime.ts +126 -0
- package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +262 -0
- package/packages/bundled-features/src/subscription-stripe/changes.json +12 -0
- package/packages/bundled-features/src/subscription-stripe/feature.ts +1 -0
- package/packages/bundled-features/src/subscription-stripe/runtime.ts +7 -6
- package/packages/bundled-features/src/template-resolver/api.ts +31 -4
- package/packages/bundled-features/src/template-resolver/changes.json +48 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +1 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +2 -2
- package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +3 -2
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +27 -5
- package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +3 -2
- package/packages/bundled-features/src/template-resolver/handlers/document-base.ts +3 -2
- package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +3 -2
- package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +18 -19
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +15 -5
- package/packages/bundled-features/src/template-resolver/handlers/tenant-override-counts.query.ts +9 -5
- package/packages/bundled-features/src/template-resolver/handlers/tenant-override-selection.ts +8 -6
- package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +44 -5
- package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +37 -26
- package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +7 -2
- package/packages/bundled-features/src/template-resolver/seeding.ts +32 -14
- package/packages/bundled-features/src/template-resolver/store-template-resource.ts +22 -4
- package/packages/bundled-features/src/template-resolver/template-delivery-policy.ts +0 -11
- package/packages/bundled-features/src/template-resolver/template-meta.ts +12 -4
- package/packages/bundled-features/src/template-resolver/template-preview.ts +11 -3
- package/packages/bundled-features/src/template-resolver/web/constants.ts +7 -5
- package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
- package/packages/bundled-features/src/template-resolver/web/mail-layout-constants.ts +8 -0
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/handlers/member-directory.query.ts +9 -3
- package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +7 -4
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
- package/packages/bundled-features/src/tenant-lifecycle/constants.ts +5 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +5 -1
- package/packages/bundled-features/src/tier-engine/changes.json +6 -0
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +4 -1
- package/packages/bundled-features/src/tier-engine/web/constants.ts +3 -3
- package/packages/bundled-features/src/user-data-rights/run-export-jobs.ts +3 -4
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -1
- package/packages/bundled-features/src/waitlist/changes.json +18 -0
- package/packages/bundled-features/src/waitlist/handlers/export-purge.write.ts +2 -1
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +5 -5
- package/packages/bundled-features/src/waitlist/import.ts +5 -0
- package/packages/bundled-features/src/waitlist/legacy-migrate.ts +22 -3
- package/packages/bundled-features/src/waitlist/link-on-signup.ts +20 -10
- package/packages/bundled-features/src/waitlist/options.ts +3 -0
- package/samples/apps/styleguide/src/features/widgets/i18n.ts +11 -0
- package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +24 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +5 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +90 -15
- package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +16 -6
- package/samples/apps/use-all-bundled/feature-manifest.json +120 -3
- package/samples/apps/use-all-bundled/src/run-config.ts +9 -3
- package/samples/recipes/auth-mfa/README.md +5 -0
- package/samples/recipes/auth-mfa/package.json +5 -2
- package/samples/recipes/auth-mfa/src/web/confirmation-code-step.tsx +49 -0
- package/samples/recipes/auth-mfa/tsconfig.json +8 -0
- package/samples/recipes/env-composition/README.md +3 -1
- package/samples/recipes/env-composition/src/env.ts +2 -0
- package/samples/recipes/legal-pages/README.md +1 -1
- package/samples/recipes/rate-limiting/README.md +18 -0
- package/samples/recipes/rate-limiting/src/feature.ts +12 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/stored-user-email.ts +0 -12
|
@@ -75,8 +75,9 @@ export async function configRowHoldsValue(
|
|
|
75
75
|
serializedValue: string,
|
|
76
76
|
): Promise<boolean> {
|
|
77
77
|
if (row?.value === null || row?.value === undefined) return false;
|
|
78
|
-
if (!keyDef
|
|
79
|
-
|
|
78
|
+
if (keyDef === undefined || (!keyDef.encrypted && !keyDef.piiEncrypted)) {
|
|
79
|
+
return row.value === serializedValue;
|
|
80
|
+
}
|
|
80
81
|
try {
|
|
81
82
|
return (await decryptStoredValue(row.value, keyDef, cipher, qualifiedKey)) === serializedValue;
|
|
82
83
|
} catch {
|
|
@@ -194,12 +195,7 @@ export function validateScope(
|
|
|
194
195
|
definedScope: ConfigScope,
|
|
195
196
|
key: string,
|
|
196
197
|
): KumikoError | null {
|
|
197
|
-
|
|
198
|
-
[ConfigScopes.system]: 0,
|
|
199
|
-
[ConfigScopes.tenant]: 1,
|
|
200
|
-
[ConfigScopes.user]: 2,
|
|
201
|
-
};
|
|
202
|
-
if (levels[requestedScope] > levels[definedScope]) {
|
|
198
|
+
if (SCOPE_LEVEL[requestedScope] > SCOPE_LEVEL[definedScope]) {
|
|
203
199
|
return new UnprocessableError("invalid_scope", {
|
|
204
200
|
i18nKey: "config.errors.invalidScope",
|
|
205
201
|
details: { key, definedScope, requestedScope },
|
|
@@ -208,6 +204,29 @@ export function validateScope(
|
|
|
208
204
|
return null;
|
|
209
205
|
}
|
|
210
206
|
|
|
207
|
+
const SCOPE_LEVEL: Record<ConfigScope, number> = {
|
|
208
|
+
[ConfigScopes.system]: 0,
|
|
209
|
+
[ConfigScopes.tenant]: 1,
|
|
210
|
+
[ConfigScopes.user]: 2,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// Reads a tenant/user key at a narrower scope, e.g. system for a system-level screen.
|
|
214
|
+
// Only a SystemAdmin may widen the read to the platform level; everyone else keeps the normal cascade.
|
|
215
|
+
export function keyDefAtScope(
|
|
216
|
+
keyDef: ConfigKeyDefinition,
|
|
217
|
+
readScope: ConfigScope | undefined,
|
|
218
|
+
roles: readonly string[],
|
|
219
|
+
): ConfigKeyDefinition {
|
|
220
|
+
if (
|
|
221
|
+
readScope === undefined ||
|
|
222
|
+
!roles.includes("SystemAdmin") ||
|
|
223
|
+
SCOPE_LEVEL[readScope] >= SCOPE_LEVEL[keyDef.scope]
|
|
224
|
+
) {
|
|
225
|
+
return keyDef;
|
|
226
|
+
}
|
|
227
|
+
return { ...keyDef, scope: readScope };
|
|
228
|
+
}
|
|
229
|
+
|
|
211
230
|
export function resolveScopeIds(
|
|
212
231
|
scope: ConfigScope,
|
|
213
232
|
tenantId: TenantId,
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.388.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "forget-subject audit event survives a failing record owner lookup",
|
|
6
|
+
"detail": "The lookup of the record row's owning tenant now runs inside the file purge step, so a database error there is reported as cleanupError on the subject-forgotten event instead of aborting the handler after the key was erased."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.377.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -324,11 +324,6 @@ async function sweepDerivedSubjectData(
|
|
|
324
324
|
audit: { readonly requestId: string; readonly erasedBy: string },
|
|
325
325
|
rawSubject: SubjectIdInput,
|
|
326
326
|
): Promise<string | undefined> {
|
|
327
|
-
const recordTenantId = await owningTenantOfRecordSubject(
|
|
328
|
-
crossTenantRunner,
|
|
329
|
-
ctx.registry.features,
|
|
330
|
-
rawSubject,
|
|
331
|
-
);
|
|
332
327
|
const failureLabels: string[] = [];
|
|
333
328
|
const runStep = async (step: (runner: DbRunner) => Promise<void>): Promise<void> => {
|
|
334
329
|
try {
|
|
@@ -367,8 +362,14 @@ async function sweepDerivedSubjectData(
|
|
|
367
362
|
});
|
|
368
363
|
|
|
369
364
|
// File columns hold only the fileRef id, so the key erase never reaches the bytes.
|
|
370
|
-
await runStep((crossTenantSubjectRunner) =>
|
|
371
|
-
|
|
365
|
+
await runStep(async (crossTenantSubjectRunner) => {
|
|
366
|
+
// Inside the step: a failing owner lookup must land in the audit label, not abort the handler.
|
|
367
|
+
const recordTenantId = await owningTenantOfRecordSubject(
|
|
368
|
+
crossTenantSubjectRunner,
|
|
369
|
+
ctx.registry.features,
|
|
370
|
+
rawSubject,
|
|
371
|
+
);
|
|
372
|
+
await purgeFilesReachingSubject({
|
|
372
373
|
registry: ctx.registry,
|
|
373
374
|
db: crossTenantSubjectRunner,
|
|
374
375
|
subject,
|
|
@@ -376,8 +377,8 @@ async function sweepDerivedSubjectData(
|
|
|
376
377
|
...(recordTenantId !== undefined && { recordTenantId }),
|
|
377
378
|
resolveDeleteBytes: resolveFileBytesDeleter(ctx),
|
|
378
379
|
missingStorage: { mode: "fail" },
|
|
379
|
-
})
|
|
380
|
-
);
|
|
380
|
+
});
|
|
381
|
+
});
|
|
381
382
|
|
|
382
383
|
return failureLabels[0];
|
|
383
384
|
}
|
|
@@ -1,4 +1,34 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.388.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Preferences query reports renamed notification types under the current name",
|
|
6
|
+
"detail": "Rows stored under a former notification name are returned with the current name and collapsed per channel with the delivery precedence (current-name row wins, otherwise any disabled row disables), so settings screens show the effective state."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.388.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Inline mail image retries on transient storage errors",
|
|
12
|
+
"detail": "Only a missing file storage for the file's tenant sends the mail without the inline image; any other read error (timeout, provider failure, DB) is rethrown so the send job retries instead of mailing without the logo."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.388.0",
|
|
16
|
+
"type": "fix",
|
|
17
|
+
"title": "Unsubscribe tenant origin resolved once per notify call",
|
|
18
|
+
"detail": "The app's tenantOrigin callback runs once per notify call and tenant instead of once per recipient and channel, so a newsletter no longer causes one lookup and one fallback warning per address. The docs state the callback must only return verified, active domains."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.388.0",
|
|
22
|
+
"type": "fix",
|
|
23
|
+
"title": "One-click unsubscribe POST keeps the JSON rejection",
|
|
24
|
+
"detail": "RFC 8058 one-click POSTs (form body List-Unsubscribe=One-Click) answer an invalid or expired token with the JSON 400 again and skip the branding lookup; only browser form submits get the HTML page."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"version": "0.388.0",
|
|
28
|
+
"type": "fix",
|
|
29
|
+
"title": "Resubscribe limit page has its own text",
|
|
30
|
+
"detail": "A browser that hits the resubscribe limit sees that the address cannot be subscribed again here, instead of the generic \"try again later\" error (new key mail.unsubscribe.resubscribeLimitReached in en, de, es, fr, it)."
|
|
31
|
+
},
|
|
2
32
|
{
|
|
3
33
|
"version": "0.383.0",
|
|
4
34
|
"type": "fix",
|
|
@@ -5,7 +5,11 @@ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
5
5
|
import type { FileContext } from "@cosmicdrift/kumiko-framework/files";
|
|
6
6
|
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
7
7
|
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
type FileRefContent,
|
|
10
|
+
FileStorageUnavailableError,
|
|
11
|
+
readFileRefContent,
|
|
12
|
+
} from "../file-foundation/index.ts";
|
|
9
13
|
import {
|
|
10
14
|
collectRendererPlugins,
|
|
11
15
|
createRendererFoundationApi,
|
|
@@ -86,7 +90,8 @@ function createPdfRenderer(
|
|
|
86
90
|
};
|
|
87
91
|
}
|
|
88
92
|
|
|
89
|
-
// A decorative inline image (logo)
|
|
93
|
+
// A decorative inline image (logo) of a tenant without file storage must not fail the whole mail;
|
|
94
|
+
// transient read errors rethrow so the send job retries.
|
|
90
95
|
async function readInlineImageOrNull(
|
|
91
96
|
fileRefId: string,
|
|
92
97
|
read: () => Promise<FileRefContent | null>,
|
|
@@ -94,9 +99,10 @@ async function readInlineImageOrNull(
|
|
|
94
99
|
try {
|
|
95
100
|
return await read();
|
|
96
101
|
} catch (err) {
|
|
102
|
+
if (!(err instanceof FileStorageUnavailableError)) throw err;
|
|
97
103
|
createFallbackLogger("delivery").warn("inline image could not be read, sending without it", {
|
|
98
104
|
fileRefId,
|
|
99
|
-
errorName: err
|
|
105
|
+
errorName: err.name,
|
|
100
106
|
});
|
|
101
107
|
return null;
|
|
102
108
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
-
import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { toPostgresArrayLiteral, unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
|
|
5
5
|
export type NotificationPreferenceRow = {
|
|
@@ -27,6 +27,6 @@ export async function selectNotificationPreferences(
|
|
|
27
27
|
OR (notification_type = '*' AND channel = $4)
|
|
28
28
|
OR (notification_type = ANY($3::text[]) AND channel = '*')
|
|
29
29
|
)`,
|
|
30
|
-
[tenantId, userId, notificationTypeNames, channelName],
|
|
30
|
+
[tenantId, userId, toPostgresArrayLiteral(notificationTypeNames), channelName],
|
|
31
31
|
);
|
|
32
32
|
}
|
|
@@ -484,9 +484,37 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
484
484
|
}
|
|
485
485
|
}
|
|
486
486
|
|
|
487
|
+
// The app's tenantOrigin callback typically hits the DB: once per run and tenant, not per recipient.
|
|
488
|
+
const unsubscribeOriginsByRun = new WeakMap<NotifyRun, Map<TenantId, Promise<string>>>();
|
|
489
|
+
|
|
490
|
+
function unsubscribeOriginOnce(
|
|
491
|
+
run: NotifyRun,
|
|
492
|
+
links: UnsubscribeLinkConfig,
|
|
493
|
+
channelCtx: ChannelContext,
|
|
494
|
+
notificationType: string,
|
|
495
|
+
): Promise<string> {
|
|
496
|
+
let originsByTenant = unsubscribeOriginsByRun.get(run);
|
|
497
|
+
if (originsByTenant === undefined) {
|
|
498
|
+
originsByTenant = new Map();
|
|
499
|
+
unsubscribeOriginsByRun.set(run, originsByTenant);
|
|
500
|
+
}
|
|
501
|
+
let origin = originsByTenant.get(channelCtx.tenantId);
|
|
502
|
+
if (origin === undefined) {
|
|
503
|
+
origin = unsubscribeLinkOrigin(links, channelCtx, (reason) =>
|
|
504
|
+
logError.warn(`unsubscribe link falls back to the app origin: ${reason}`, {
|
|
505
|
+
tenantId: channelCtx.tenantId,
|
|
506
|
+
notificationType,
|
|
507
|
+
}),
|
|
508
|
+
);
|
|
509
|
+
originsByTenant.set(channelCtx.tenantId, origin);
|
|
510
|
+
}
|
|
511
|
+
return origin;
|
|
512
|
+
}
|
|
513
|
+
|
|
487
514
|
// A caller-supplied unsubscribeUrl wins; the recipient comes from the server-side notify
|
|
488
515
|
// context, never from the notification data.
|
|
489
516
|
async function withUnsubscribeUrl(
|
|
517
|
+
run: NotifyRun,
|
|
490
518
|
message: ChannelMessage,
|
|
491
519
|
channelName: string,
|
|
492
520
|
recipient: UnsubscribeLinkRecipient,
|
|
@@ -498,11 +526,11 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
498
526
|
}
|
|
499
527
|
const supplied = message.data?.["unsubscribeUrl"];
|
|
500
528
|
if (typeof supplied === "string" && supplied !== "") return message;
|
|
501
|
-
const origin = await
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
529
|
+
const origin = await unsubscribeOriginOnce(
|
|
530
|
+
run,
|
|
531
|
+
unsubscribeLinks,
|
|
532
|
+
channelCtx,
|
|
533
|
+
message.notificationType,
|
|
506
534
|
);
|
|
507
535
|
const unsubscribeUrl = await buildUnsubscribeUrl(unsubscribeLinks, origin, recipient, {
|
|
508
536
|
tenantId: channelCtx.tenantId,
|
|
@@ -811,6 +839,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
811
839
|
channel,
|
|
812
840
|
address,
|
|
813
841
|
message: await withUnsubscribeUrl(
|
|
842
|
+
run,
|
|
814
843
|
message,
|
|
815
844
|
channel.name,
|
|
816
845
|
{ kind: "user", userId },
|
|
@@ -914,6 +943,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
914
943
|
channel,
|
|
915
944
|
address,
|
|
916
945
|
message: await withUnsubscribeUrl(
|
|
946
|
+
run,
|
|
917
947
|
message,
|
|
918
948
|
channel.name,
|
|
919
949
|
{ kind: "address", address },
|
|
@@ -2,6 +2,7 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
|
2
2
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
4
|
import * as z from "zod";
|
|
5
|
+
import { currentNotificationType } from "../notification-type-aliases.ts";
|
|
5
6
|
import { notificationPreferencesTable } from "../tables.ts";
|
|
6
7
|
|
|
7
8
|
export const preferencesQuery = defineQueryHandler({
|
|
@@ -37,6 +38,24 @@ export const preferencesQuery = defineQueryHandler({
|
|
|
37
38
|
userId: query.user.id,
|
|
38
39
|
});
|
|
39
40
|
|
|
40
|
-
|
|
41
|
+
// Rows stored under a former notification name are reported under the current
|
|
42
|
+
// one, collapsed with the same precedence as delivery: an exact row wins,
|
|
43
|
+
// otherwise any disabled former-name row disables.
|
|
44
|
+
const collapsed = new Map<string, { row: (typeof rows)[number]; isExact: boolean }>();
|
|
45
|
+
for (const row of rows) {
|
|
46
|
+
const notificationType = currentNotificationType(ctx.registry, row.notificationType);
|
|
47
|
+
const key = `${notificationType}\u0000${row.channel}`;
|
|
48
|
+
const isExact = row.notificationType === notificationType;
|
|
49
|
+
const existing = collapsed.get(key);
|
|
50
|
+
if (existing?.isExact) continue;
|
|
51
|
+
const formerNameRowDisablesAlready =
|
|
52
|
+
existing !== undefined &&
|
|
53
|
+
!isExact &&
|
|
54
|
+
(existing.row.enabled === false || row.enabled !== false);
|
|
55
|
+
if (formerNameRowDisablesAlready) continue;
|
|
56
|
+
collapsed.set(key, { row: { ...row, notificationType }, isExact });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { rows: [...collapsed.values()].map((entry) => entry.row) };
|
|
41
60
|
},
|
|
42
61
|
});
|
|
@@ -33,7 +33,7 @@ export const unsubscribePageChromeQuery = defineQueryHandler({
|
|
|
33
33
|
// or createUnsubscribePageChromeReader for a tenant the app resolved server-side.
|
|
34
34
|
agent: { expose: false },
|
|
35
35
|
description:
|
|
36
|
-
"Returns the tenant's mail brand row and footer resolved for one locale, or null without a mail renderer; dispatched by the signed unsubscribe route to dress its pages like the mail
|
|
36
|
+
"Returns the tenant's mail brand row and footer resolved for one locale, or null without a mail renderer; dispatched by the signed unsubscribe route or by createUnsubscribePageChromeReader for a tenant the app resolved server-side, to dress its pages like the mail; not meant for UI callers.",
|
|
37
37
|
handler: async (query, ctx): Promise<MailChrome | null> => {
|
|
38
38
|
const { tenantId } = query.user;
|
|
39
39
|
if (!ctx.systemDb) {
|
|
@@ -10,7 +10,9 @@ export type UnsubscribeLinkConfig = {
|
|
|
10
10
|
readonly secret: string;
|
|
11
11
|
readonly baseUrl: string;
|
|
12
12
|
/** The sending tenant's own public origin (subdomain or custom domain), null when it has
|
|
13
|
-
* none. The host must serve this app's `/api` routes
|
|
13
|
+
* none. The host must serve this app's `/api` routes, and the callback must only return a
|
|
14
|
+
* verified, active domain of the tenant (the check that lets the app serve under it), else null:
|
|
15
|
+
* the link carries a bearer token that unsubscribes and yields a resubscribe token. Null, a throw or an unusable value
|
|
14
16
|
* fall back to `baseUrl`, so a missing tenant origin never fails a send. */
|
|
15
17
|
readonly tenantOrigin?: (ctx: ChannelContext) => Promise<string | null | undefined>;
|
|
16
18
|
};
|
|
@@ -14,6 +14,7 @@ export const UNSUBSCRIBE_PAGE_EN = {
|
|
|
14
14
|
"mail.unsubscribe.resubscribedMessage": "You have been subscribed again.",
|
|
15
15
|
"mail.unsubscribe.failed": "That did not work. Please try again later.",
|
|
16
16
|
"mail.unsubscribe.failedTitle": "Something went wrong",
|
|
17
|
+
"mail.unsubscribe.resubscribeLimitReached": "This address cannot be subscribed again here.",
|
|
17
18
|
"mail.unsubscribe.invalidTitle": "Link no longer valid",
|
|
18
19
|
"mail.unsubscribe.invalidMessage":
|
|
19
20
|
"This link is invalid or has expired. Please use the unsubscribe link in a more recent email.",
|
|
@@ -11,6 +11,7 @@ export type UnsubscribePageState =
|
|
|
11
11
|
| { readonly kind: "unsubscribed"; readonly resubscribeToken?: string }
|
|
12
12
|
| { readonly kind: "resubscribed" }
|
|
13
13
|
| { readonly kind: "failed" }
|
|
14
|
+
| { readonly kind: "resubscribeLimitReached" }
|
|
14
15
|
| { readonly kind: "invalid" };
|
|
15
16
|
|
|
16
17
|
export type UnsubscribePageInput = {
|
|
@@ -162,6 +163,11 @@ ${tokenForm(DELIVERY_RESUBSCRIBE_PATH, state.resubscribeToken, mailT(lang, "mail
|
|
|
162
163
|
title: mailT(lang, "mail.unsubscribe.failedTitle"),
|
|
163
164
|
bodyHtml: paragraph("mail.unsubscribe.failed"),
|
|
164
165
|
};
|
|
166
|
+
case "resubscribeLimitReached":
|
|
167
|
+
return {
|
|
168
|
+
title: mailT(lang, "mail.unsubscribe.failedTitle"),
|
|
169
|
+
bodyHtml: paragraph("mail.unsubscribe.resubscribeLimitReached"),
|
|
170
|
+
};
|
|
165
171
|
case "invalid":
|
|
166
172
|
return {
|
|
167
173
|
title: mailT(lang, "mail.unsubscribe.invalidTitle"),
|
|
@@ -569,11 +569,20 @@ async function verifyPost(
|
|
|
569
569
|
encodedSecret: Uint8Array,
|
|
570
570
|
purpose: TokenPurpose,
|
|
571
571
|
): Promise<VerifiedUnsubscribe | RejectedForPage> {
|
|
572
|
-
return isFormRequest(request.headers["content-type"])
|
|
572
|
+
return isFormRequest(request.headers["content-type"]) && !isOneClickPost(request)
|
|
573
573
|
? verifyForPage(token, encodedSecret, purpose, request.headers)
|
|
574
574
|
: verifyUnsubscribeToken(token, encodedSecret, purpose);
|
|
575
575
|
}
|
|
576
576
|
|
|
577
|
+
// RFC 8058 mail-provider POSTs are form-encoded too, but nobody looks at the answer: they keep
|
|
578
|
+
// the JSON rejection and get no branded page.
|
|
579
|
+
function isOneClickPost(request: SignatureExtraRouteVerifyRequest): boolean {
|
|
580
|
+
return (
|
|
581
|
+
isFormRequest(request.headers["content-type"]) &&
|
|
582
|
+
request.rawBody.trim() === DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
577
586
|
function isFormRequest(contentType: string | undefined): boolean {
|
|
578
587
|
return contentType?.includes("application/x-www-form-urlencoded") === true;
|
|
579
588
|
}
|
|
@@ -635,22 +644,37 @@ export function createUnsubscribeRoutes(
|
|
|
635
644
|
: pageResponse(c, deps, { kind: "confirm", token: verified.token }, verified, 200),
|
|
636
645
|
});
|
|
637
646
|
|
|
638
|
-
const writeRoute = signatureRoute<
|
|
647
|
+
const writeRoute = signatureRoute<
|
|
648
|
+
(VerifiedUnsubscribe & { readonly oneClick: boolean }) | RejectedForPage
|
|
649
|
+
>({
|
|
639
650
|
method: "POST",
|
|
640
651
|
path: DELIVERY_UNSUBSCRIBE_PATH,
|
|
641
652
|
entry: "signature",
|
|
642
|
-
verify: async (request) =>
|
|
643
|
-
|
|
653
|
+
verify: async (request) => {
|
|
654
|
+
const verified = await verifyPost(
|
|
655
|
+
request,
|
|
656
|
+
tokenFromPostRequest(request),
|
|
657
|
+
encodedSecret,
|
|
658
|
+
"unsubscribe",
|
|
659
|
+
);
|
|
660
|
+
return verified.kind === "rejected"
|
|
661
|
+
? verified
|
|
662
|
+
: { ...verified, oneClick: isOneClickPost(request) };
|
|
663
|
+
},
|
|
644
664
|
handler: async (c, verified, deps) => {
|
|
645
665
|
if (verified.kind === "rejected") {
|
|
646
666
|
return pageResponse(c, deps, { kind: "invalid" }, verified, 400);
|
|
647
667
|
}
|
|
668
|
+
// No chrome lookup for a one-click client: it never renders the page.
|
|
669
|
+
const pageScope: PageScope = verified.oneClick
|
|
670
|
+
? { tenantId: undefined, locale: verified.locale }
|
|
671
|
+
: verified;
|
|
648
672
|
// Token-verify passed — everything below is a legitimate write. Don't
|
|
649
673
|
// swallow write-errors as "invalid token", that would mask real bugs
|
|
650
674
|
// (e.g. events-table missing, DB down) behind a misleading 400.
|
|
651
675
|
const succeeded = await dispatchUnsubscribeWrite(verified, deps);
|
|
652
676
|
if (!succeeded) {
|
|
653
|
-
return pageResponse(c, deps, { kind: "failed" },
|
|
677
|
+
return pageResponse(c, deps, { kind: "failed" }, pageScope, 500);
|
|
654
678
|
}
|
|
655
679
|
return pageResponse(
|
|
656
680
|
c,
|
|
@@ -659,7 +683,7 @@ export function createUnsubscribeRoutes(
|
|
|
659
683
|
kind: "unsubscribed",
|
|
660
684
|
resubscribeToken: await signResubscribeToken(verified, encodedSecret),
|
|
661
685
|
},
|
|
662
|
-
|
|
686
|
+
pageScope,
|
|
663
687
|
200,
|
|
664
688
|
);
|
|
665
689
|
},
|
|
@@ -681,15 +705,12 @@ export function createUnsubscribeRoutes(
|
|
|
681
705
|
const outcome = await dispatchResubscribeWrite(verified, deps);
|
|
682
706
|
// The undo button on the unsubscribed page posts a form; a browser should land on a page.
|
|
683
707
|
if (isFormRequest(c.req.header("content-type"))) {
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
verified,
|
|
691
|
-
outcome === "limit_reached" ? 409 : 500,
|
|
692
|
-
);
|
|
708
|
+
if (outcome === "success") {
|
|
709
|
+
return pageResponse(c, deps, { kind: "resubscribed" }, verified, 200);
|
|
710
|
+
}
|
|
711
|
+
return outcome === "limit_reached"
|
|
712
|
+
? pageResponse(c, deps, { kind: "resubscribeLimitReached" }, verified, 409)
|
|
713
|
+
: pageResponse(c, deps, { kind: "failed" }, verified, 500);
|
|
693
714
|
}
|
|
694
715
|
if (outcome === "limit_reached") {
|
|
695
716
|
return c.json(
|
|
@@ -53,6 +53,12 @@ export class FileRefTooLargeError extends Error {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
export class FileStorageUnavailableError extends Error {
|
|
57
|
+
constructor() {
|
|
58
|
+
super("readFileRefContent: no file storage is available for the file's tenant");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
56
62
|
// fileName of the row is never used: it is personal data and may be a PII ciphertext.
|
|
57
63
|
export async function readFileRefContent(args: {
|
|
58
64
|
readonly db: DbRunner | TenantDb;
|
|
@@ -82,7 +88,7 @@ export async function readFileRefContent(args: {
|
|
|
82
88
|
}
|
|
83
89
|
const files = args.filesForTenant(row.tenantId);
|
|
84
90
|
if (files === undefined) {
|
|
85
|
-
throw new
|
|
91
|
+
throw new FileStorageUnavailableError();
|
|
86
92
|
}
|
|
87
93
|
const content = await files.refFromFileRef(row).read();
|
|
88
94
|
return { content, contentType: row.mimeType, tenantId: row.tenantId, size };
|
|
@@ -13,8 +13,6 @@ export type NamedS3EnvProviderOptions = {
|
|
|
13
13
|
readonly name: string;
|
|
14
14
|
/** Env prefix of the credential set, e.g. `RELEASES_S3_`. */
|
|
15
15
|
readonly envPrefix: string;
|
|
16
|
-
/** Tolerate missing credentials at boot; the provider then fails on its first file operation. */
|
|
17
|
-
readonly lazy?: boolean;
|
|
18
16
|
};
|
|
19
17
|
|
|
20
18
|
/** An additional S3 provider next to `"s3-env"`, reading its credentials from `<envPrefix>BUCKET` etc. */
|
|
@@ -15,7 +15,7 @@ bewirtschaftet. Das öffnet die Tür für Cross-Feature-Hooks:
|
|
|
15
15
|
## Was es NICHT macht
|
|
16
16
|
|
|
17
17
|
- **Keine Upload-/Download-Routes** — die bleiben in
|
|
18
|
-
`framework/src/api/server.ts`
|
|
18
|
+
`framework/src/api/server-routes.ts` (`mountFileRoutes`).
|
|
19
19
|
Multipart-Form-Body und Binary-Streaming passen nicht ins Write/Query-
|
|
20
20
|
Handler-Pattern; ein Refactor zu `r.httpRoute` wäre orthogonal zu
|
|
21
21
|
diesem Sprint.
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.388.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Encrypted drafts without a PII key service raise an error",
|
|
6
|
+
"detail": "Reading a ciphertext draft with no PII subject key service configured throws instead of returning an empty draft, so the stale-draft cleanup defers it and no longer releases its file refs as if it were empty."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.379.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
-
import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { toPostgresArrayLiteral, unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
5
5
|
import { readStoredDraft } from "../../decrypt-draft.ts";
|
|
@@ -48,7 +48,7 @@ export async function selectStaleDraftsBatch(
|
|
|
48
48
|
AND NOT ("id"::text = ANY($3::text[]))
|
|
49
49
|
ORDER BY COALESCE("modified_at", "inserted_at") ASC
|
|
50
50
|
LIMIT $2`,
|
|
51
|
-
[olderThanDays, batchSize, [...excludeIds]],
|
|
51
|
+
[olderThanDays, batchSize, toPostgresArrayLiteral([...excludeIds])],
|
|
52
52
|
);
|
|
53
53
|
return Promise.all(
|
|
54
54
|
rows.map(async (row) => {
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
decryptPiiFieldValues,
|
|
5
5
|
isPiiCiphertext,
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
7
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
7
8
|
import { type FormDraftBlob, formDraftBlobSchema } from "./schemas.ts";
|
|
8
9
|
|
|
9
10
|
// Raw reads (fetchOne/selectMany/SQL) return the stored column, which is
|
|
@@ -13,7 +14,9 @@ import { type FormDraftBlob, formDraftBlobSchema } from "./schemas.ts";
|
|
|
13
14
|
export async function readStoredDraft(stored: unknown): Promise<FormDraftBlob | null> {
|
|
14
15
|
let plain = stored;
|
|
15
16
|
const kms = configuredPiiSubjectKms();
|
|
16
|
-
if (isPiiCiphertext(stored)
|
|
17
|
+
if (isPiiCiphertext(stored)) {
|
|
18
|
+
// A silent null would make the stale-draft cleanup treat the draft as empty and leak its file refs.
|
|
19
|
+
if (!kms) throw new InternalError({ message: "form-draft: pii kms not configured" });
|
|
17
20
|
const out = await decryptPiiFieldValues({ draft: stored }, ["draft"], kms, {
|
|
18
21
|
requestId: requestContext.get()?.requestId ?? "form-draft:read",
|
|
19
22
|
});
|
|
@@ -92,5 +92,5 @@ allowlists stay in the app: wrap the dispatch function and fall through to it.
|
|
|
92
92
|
|
|
93
93
|
- `tenantBy: (host, db) => TenantId | null` resolves every host that is neither the apex nor a fixed host (profile subdomains, custom domains). The tenant must be enabled and active; null means 404 for pages and `tenant_required` for the API. `createHostTenantIdResolver(options, db)` exposes the same mapping for managed-pages' `resolveApexTenant`.
|
|
94
94
|
- `tenantPathPrefix` is optional in host and resolver mode and serves `<prefix>/<key>` on the apex. The API half still takes the apex anchor there; an app that needs a path-named tenant on API calls resolves it itself.
|
|
95
|
-
- `redirects: [{ from, to, host?, status? }]` run before host classification. `to` is configured, never derived from the request.
|
|
95
|
+
- `redirects: [{ from, to, host?, status? }]` run before host classification inside hostDispatch, i.e. only for requests that reach the HTML fallback (no static file, no API route); a `from` that matches a static file or `/api/...` never fires. `to` is configured, never derived from the request.
|
|
96
96
|
- `apexAnonymousQueryAllowlist: { "example.com": ["feature:query:x"] }` limits the anonymous apex anchor to the listed query types on that host, for `/api/query` and `/api/stream`; other paths keep the anchor. `example.com` and `www.example.com` share one entry (a host without its own uses the other's). Combined with `tenantBy: "path"` it throws at boot, because the client names its tenant there (X-Tenant wins over the resolver) and would bypass the allowlist.
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.388.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Redirects documented as hostDispatch-only",
|
|
6
|
+
"detail": "The redirects option and README state that redirects run inside hostDispatch, so only for requests that reach the HTML fallback; a from matching a static file or an API route never fires. The shared existence lookup of the host and resolver modes is one helper."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.376.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -30,33 +30,32 @@ export type HostRoutingDispatchFn = (
|
|
|
30
30
|
|
|
31
31
|
const tenantKeyExistsResultSchema = z.object({ exists: z.boolean() });
|
|
32
32
|
|
|
33
|
-
async function
|
|
33
|
+
async function existsViaSystemQuery(
|
|
34
|
+
systemQuery: PageHeadSystemQuery,
|
|
35
|
+
queryType: string,
|
|
36
|
+
payload: Record<string, string>,
|
|
37
|
+
): Promise<boolean> {
|
|
34
38
|
let result: unknown;
|
|
35
39
|
try {
|
|
36
|
-
result = await systemQuery(
|
|
40
|
+
result = await systemQuery(queryType, payload, SYSTEM_TENANT_ID);
|
|
37
41
|
} catch (error) {
|
|
38
42
|
if (error instanceof ValidationError) return false;
|
|
39
|
-
// Over the rate limit every key looks the same: serve the
|
|
40
|
-
//
|
|
43
|
+
// Over the rate limit every key looks the same: serve the shell, which
|
|
44
|
+
// leaks nothing. The API resolver is authoritative and answers
|
|
45
|
+
// tenant_required for unknown hosts, while a 404 would hit visitors
|
|
46
|
+
// behind a shared NAT under load.
|
|
41
47
|
if (error instanceof RateLimitError) return true;
|
|
42
48
|
throw error;
|
|
43
49
|
}
|
|
44
50
|
return tenantKeyExistsResultSchema.parse(result).exists;
|
|
45
51
|
}
|
|
46
52
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
// The shell is harmless: the API resolver is authoritative and answers
|
|
54
|
-
// tenant_required for unknown hosts, while a 404 would hit visitors behind
|
|
55
|
-
// a shared NAT under load.
|
|
56
|
-
if (error instanceof RateLimitError) return true;
|
|
57
|
-
throw error;
|
|
58
|
-
}
|
|
59
|
-
return tenantKeyExistsResultSchema.parse(result).exists;
|
|
53
|
+
function isTenantKeyKnown(systemQuery: PageHeadSystemQuery, key: string): Promise<boolean> {
|
|
54
|
+
return existsViaSystemQuery(systemQuery, HostRoutingQueries.tenantKeyExists, { key });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isHostKnown(systemQuery: PageHeadSystemQuery, host: string): Promise<boolean> {
|
|
58
|
+
return existsViaSystemQuery(systemQuery, HostRoutingQueries.hostTenantExists, { host });
|
|
60
59
|
}
|
|
61
60
|
|
|
62
61
|
function serve(
|
|
@@ -81,6 +81,7 @@ export type HostRoutingOptions = HostRoutingTenantMode & {
|
|
|
81
81
|
// Not allowed with tenantBy "path" (boot error): there the client's X-Tenant
|
|
82
82
|
// wins over the resolver, so the allowlist would be ineffective.
|
|
83
83
|
readonly apexAnonymousQueryAllowlist?: Readonly<Record<string, readonly string[]>>;
|
|
84
|
-
// Checked first,
|
|
84
|
+
// Checked first within hostDispatch, i.e. only for requests that reach the HTML
|
|
85
|
+
// fallback (no static file, no API route): a `from` matching either never fires.
|
|
85
86
|
readonly redirects?: readonly HostRoutingRedirect[];
|
|
86
87
|
};
|