@cosmicdrift/kumiko-bundled-features 0.374.0 → 0.378.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/account-security/constants.d.ts +1 -0
- package/dist/account-security/constants.js +1 -0
- package/dist/account-security/index.d.ts +1 -1
- package/dist/account-security/index.js +1 -1
- package/dist/admin-shell/web/client-plugin.d.ts +1 -1
- package/dist/audit/web/client-plugin.d.ts +1 -1
- package/dist/auth-email-password/mail-notifications.js +1 -1
- package/dist/auth-email-password/web/auth-client.js +1 -1
- package/dist/auth-email-password/web/auth-form-primitives.js +1 -1
- package/dist/auth-email-password/web/default-topbar-actions.js +1 -1
- package/dist/auth-email-password/web/index.js +1 -1
- package/dist/auth-email-password/web/session.js +1 -1
- package/dist/auth-email-password/web/tenant-menu-items.js +1 -1
- package/dist/auth-email-password/web/tenant-switcher.js +1 -1
- package/dist/auth-email-password/web/user-menu.js +1 -1
- package/dist/auth-foundation/env.d.ts +9 -0
- package/dist/auth-foundation/env.js +21 -0
- package/dist/auth-foundation/index.d.ts +1 -0
- package/dist/auth-foundation/index.js +1 -0
- package/dist/auth-mfa/schema/user-mfa.d.ts +6 -6
- package/dist/billing-foundation/consumer-protection/confirmation-mail.js +1 -1
- package/dist/billing-foundation/consumer-protection/termination-pages.js +1 -1
- package/dist/billing-foundation/contact-card.d.ts +2 -0
- package/dist/billing-foundation/contact-card.js +16 -0
- package/dist/billing-foundation/entities.d.ts +8 -8
- package/dist/billing-foundation/handlers/billing-plans.query.js +1 -1
- package/dist/billing-foundation/index.d.ts +1 -1
- package/dist/billing-foundation/plan-catalog.js +1 -0
- package/dist/billing-foundation/projection.d.ts +8 -8
- package/dist/billing-foundation/types.d.ts +11 -0
- package/dist/billing-foundation/validate-options.js +13 -0
- package/dist/billing-foundation/web/billing-contact-plan.d.ts +5 -0
- package/dist/billing-foundation/web/billing-contact-plan.js +17 -0
- package/dist/billing-foundation/web/billing-plans-panel.js +5 -13
- package/dist/billing-foundation/web/cancel-contract-dialog.js +2 -2
- package/dist/billing-foundation/web/checkout-consent-dialog.d.ts +1 -1
- package/dist/billing-foundation/web/checkout-consent-dialog.js +1 -0
- package/dist/billing-foundation/web/client-plugin.d.ts +1 -1
- package/dist/billing-foundation/web/localized-params.d.ts +1 -0
- package/dist/billing-foundation/web/localized-params.js +11 -0
- package/dist/cap-counter/entity.d.ts +1 -1
- package/dist/cap-counter/stock-cap-guard.js +8 -0
- package/dist/cap-overview/web/cap-cards-panel.js +3 -3
- package/dist/cap-overview/web/client-plugin.d.ts +1 -1
- package/dist/channel-email/email-channel.d.ts +2 -1
- package/dist/compliance-profiles/web/client-plugin.d.ts +1 -1
- package/dist/config/feature.js +2 -2
- package/dist/config/table.d.ts +6 -6
- package/dist/config/web/i18n.js +1 -0
- package/dist/custom-fields/entity.d.ts +4 -4
- package/dist/custom-fields/web/client-plugin.d.ts +1 -1
- package/dist/data-retention/schema/tenant-retention-override.d.ts +4 -4
- package/dist/delivery/tables.d.ts +6 -6
- package/dist/delivery/unsubscribe-page.js +1 -1
- package/dist/delivery/web/client-plugin.d.ts +1 -1
- package/dist/delivery/web/delivery-status-cell.js +1 -1
- package/dist/document-ingest-foundation/entity.d.ts +4 -4
- package/dist/feature-toggles/web/client-plugin.d.ts +1 -1
- package/dist/file-provider-s3/env-named-feature.d.ts +17 -0
- package/dist/file-provider-s3/env-named-feature.js +23 -0
- package/dist/file-provider-s3/env-schema.d.ts +7 -0
- package/dist/file-provider-s3/env-schema.js +15 -10
- package/dist/file-provider-s3/index.d.ts +1 -0
- package/dist/file-provider-s3/index.js +1 -0
- package/dist/folders/entity.d.ts +8 -8
- package/dist/folders/web/client-plugin.d.ts +1 -1
- package/dist/form-draft/entity.d.ts +2 -2
- package/dist/host-routing/constants.d.ts +2 -0
- package/dist/host-routing/constants.js +3 -0
- package/dist/host-routing/dispatch.d.ts +1 -1
- package/dist/host-routing/dispatch.js +35 -25
- package/dist/host-routing/feature.js +100 -27
- package/dist/host-routing/host.d.ts +9 -0
- package/dist/host-routing/host.js +24 -2
- package/dist/host-routing/index.d.ts +2 -1
- package/dist/host-routing/index.js +1 -0
- package/dist/host-routing/options.d.ts +14 -0
- package/dist/host-routing/redirects.d.ts +6 -0
- package/dist/host-routing/redirects.js +27 -0
- package/dist/host-routing/tenant-lookup.d.ts +7 -0
- package/dist/host-routing/tenant-lookup.js +39 -0
- package/dist/host-routing/validate-options.js +34 -1
- package/dist/inbound-mail-foundation/entities.d.ts +25 -25
- package/dist/inbound-mail-foundation/feature.js +7 -0
- package/dist/inbound-mail-foundation/handlers/list-accounts.query.d.ts +1 -1
- package/dist/inbound-mail-foundation/handlers/list-accounts.query.js +11 -3
- package/dist/inbound-mail-foundation/i18n.d.ts +3 -0
- package/dist/inbound-mail-foundation/i18n.js +35 -0
- package/dist/inbound-mail-foundation/index.d.ts +1 -0
- package/dist/inbound-mail-foundation/index.js +1 -0
- package/dist/inbound-mail-foundation/projection.d.ts +20 -20
- package/dist/inbound-mail-foundation/screens.d.ts +7 -0
- package/dist/inbound-mail-foundation/screens.js +86 -0
- package/dist/inbound-mail-foundation/types.d.ts +2 -2
- package/dist/jobs/job-run-table.d.ts +6 -6
- package/dist/jobs/web/client-plugin.d.ts +1 -1
- package/dist/ledger/entity.d.ts +13 -13
- package/dist/legal-pages/feature.js +11 -1
- package/dist/legal-pages/layout.d.ts +2 -2
- package/dist/legal-pages/layout.js +4 -4
- package/dist/legal-pages/web/client-plugin.d.ts +1 -1
- package/dist/mail-foundation/feature.d.ts +20 -21
- package/dist/mail-foundation/feature.js +1 -1
- package/dist/mail-foundation/i18n.d.ts +5 -2
- package/dist/mail-foundation/i18n.js +3 -2
- package/dist/mail-transport-smtp/i18n.d.ts +1 -1
- package/dist/mail-transport-smtp/i18n.js +1 -1
- package/dist/managed-pages/table.d.ts +12 -12
- package/dist/managed-pages/web/client-plugin.d.ts +1 -1
- package/dist/notes-history/entity.d.ts +12 -12
- package/dist/notes-history/web/client-plugin.d.ts +1 -1
- package/dist/page-render/branding.js +1 -1
- package/dist/page-render/layout.d.ts +1 -1
- package/dist/page-render/layout.js +2 -2
- package/dist/page-render/markdown.js +1 -1
- package/dist/personal-access-tokens/i18n.js +1 -1
- package/dist/personal-access-tokens/schema/api-token.d.ts +6 -6
- package/dist/personal-access-tokens/screens.js +1 -1
- package/dist/secrets/feature.d.ts +1 -1
- package/dist/secrets/feature.js +3 -3
- package/dist/secrets/table.d.ts +1 -1
- package/dist/seo/sitemap.js +1 -1
- package/dist/sessions/schema/user-session.d.ts +4 -4
- package/dist/shared/mail-template-body.js +1 -1
- package/dist/shared/purge-files-reaching-subject.js +98 -3
- package/dist/tags/entity.d.ts +9 -9
- package/dist/tags/web/client-plugin.d.ts +1 -1
- package/dist/template-resolver/compile-document-body.d.ts +3 -0
- package/dist/template-resolver/compile-document-body.js +50 -3
- package/dist/template-resolver/constants.d.ts +3 -0
- package/dist/template-resolver/constants.js +2 -0
- package/dist/template-resolver/document-blocks.d.ts +5 -0
- package/dist/template-resolver/document-blocks.js +22 -0
- package/dist/template-resolver/document-images.d.ts +2 -0
- package/dist/template-resolver/document-images.js +28 -0
- package/dist/template-resolver/document-ops.d.ts +167 -0
- package/dist/template-resolver/document-ops.js +390 -0
- package/dist/template-resolver/document-sanitizer.d.ts +4 -1
- package/dist/template-resolver/document-sanitizer.js +26 -0
- package/dist/template-resolver/document-schema.d.ts +2 -1
- package/dist/template-resolver/document-schema.js +4 -0
- package/dist/template-resolver/feature.d.ts +11 -0
- package/dist/template-resolver/feature.js +60 -31
- package/dist/template-resolver/handlers/bundle-import.js +1 -0
- package/dist/template-resolver/handlers/collection-item.query.js +2 -1
- package/dist/template-resolver/handlers/collection-list.query.js +2 -1
- package/dist/template-resolver/handlers/collection-set.write.js +2 -2
- package/dist/template-resolver/handlers/collection-shared.d.ts +1 -2
- package/dist/template-resolver/handlers/collection-shared.js +0 -4
- package/dist/template-resolver/handlers/copy-locale.write.d.ts +2 -1
- package/dist/template-resolver/handlers/copy-locale.write.js +67 -63
- package/dist/template-resolver/handlers/discard-draft.write.d.ts +3 -2
- package/dist/template-resolver/handlers/discard-draft.write.js +18 -6
- package/dist/template-resolver/handlers/document-base.d.ts +23 -0
- package/dist/template-resolver/handlers/document-base.js +55 -0
- package/dist/template-resolver/handlers/document-blocks.query.d.ts +22 -0
- package/dist/template-resolver/handlers/document-blocks.query.js +46 -0
- package/dist/template-resolver/handlers/export-bundle.query.d.ts +1 -0
- package/dist/template-resolver/handlers/export-bundle.query.js +9 -2
- package/dist/template-resolver/handlers/import-bundle-system.write.d.ts +1 -0
- package/dist/template-resolver/handlers/import-bundle.write.d.ts +3 -1
- package/dist/template-resolver/handlers/import-bundle.write.js +30 -25
- package/dist/template-resolver/handlers/overview.query.d.ts +2 -1
- package/dist/template-resolver/handlers/overview.query.js +46 -42
- package/dist/template-resolver/handlers/patch-draft.write.d.ts +67 -0
- package/dist/template-resolver/handlers/patch-draft.write.js +111 -0
- package/dist/template-resolver/handlers/preview.query.d.ts +1 -0
- package/dist/template-resolver/handlers/preview.query.js +2 -1
- package/dist/template-resolver/handlers/reset-to-system.write.d.ts +2 -1
- package/dist/template-resolver/handlers/reset-to-system.write.js +51 -44
- package/dist/template-resolver/handlers/resource-link-pdf.write.d.ts +2 -1
- package/dist/template-resolver/handlers/resource-link-pdf.write.js +34 -28
- package/dist/template-resolver/handlers/resource-remove.write.d.ts +2 -1
- package/dist/template-resolver/handlers/resource-remove.write.js +39 -33
- package/dist/template-resolver/handlers/resource-upload.write.d.ts +2 -1
- package/dist/template-resolver/handlers/resource-upload.write.js +104 -96
- package/dist/template-resolver/handlers/send-test-shared.d.ts +5 -0
- package/dist/template-resolver/handlers/send-test-shared.js +19 -0
- package/dist/template-resolver/handlers/send-test-status.query.d.ts +9 -0
- package/dist/template-resolver/handlers/send-test-status.query.js +18 -0
- package/dist/template-resolver/handlers/send-test.write.d.ts +3 -1
- package/dist/template-resolver/handlers/send-test.write.js +87 -88
- package/dist/template-resolver/handlers/set.write.d.ts +2 -1
- package/dist/template-resolver/handlers/set.write.js +94 -90
- package/dist/template-resolver/handlers/template-write.d.ts +2 -0
- package/dist/template-resolver/handlers/template-write.js +14 -2
- package/dist/template-resolver/handlers/toggle-status.write.d.ts +6 -7
- package/dist/template-resolver/handlers/toggle-status.write.js +28 -10
- package/dist/template-resolver/handlers/upsert-tenant.write.d.ts +2 -1
- package/dist/template-resolver/handlers/upsert-tenant.write.js +30 -24
- package/dist/template-resolver/handlers/validate.query.d.ts +10 -4
- package/dist/template-resolver/handlers/validate.query.js +12 -7
- package/dist/template-resolver/index.d.ts +4 -2
- package/dist/template-resolver/index.js +3 -1
- package/dist/template-resolver/{web/placeholder-text.js → placeholder-text.js} +1 -1
- package/dist/template-resolver/qualified-names.d.ts +4 -0
- package/dist/template-resolver/qualified-names.js +4 -0
- package/dist/template-resolver/seeding.d.ts +1 -0
- package/dist/template-resolver/seeding.js +2 -1
- package/dist/template-resolver/table.d.ts +10 -10
- package/dist/template-resolver/template-bundle.d.ts +1 -0
- package/dist/template-resolver/template-bundle.js +6 -1
- package/dist/template-resolver/template-edit-policy.d.ts +22 -0
- package/dist/template-resolver/template-edit-policy.js +45 -0
- package/dist/template-resolver/template-preview.d.ts +4 -1
- package/dist/template-resolver/template-preview.js +23 -5
- package/dist/template-resolver/template-validation.d.ts +2 -1
- package/dist/template-resolver/template-validation.js +34 -14
- package/dist/template-resolver/user-content-table.d.ts +10 -10
- package/dist/template-resolver/web/client-plugin.d.ts +1 -1
- package/dist/template-resolver/web/client-plugin.js +3 -3
- package/dist/template-resolver/web/document-editor-extensions.d.ts +1 -0
- package/dist/template-resolver/web/document-editor-extensions.js +21 -1
- package/dist/template-resolver/web/document-tiptap-editor.d.ts +1 -1
- package/dist/template-resolver/web/document-tiptap-editor.js +23 -7
- package/dist/template-resolver/web/document-toolbar-blocks.d.ts +4 -5
- package/dist/template-resolver/web/document-toolbar-blocks.js +1 -21
- package/dist/template-resolver/web/i18n.js +18 -0
- package/dist/template-resolver/web/placeholder-line-editor.js +2 -2
- package/dist/tenant/invitation-table.d.ts +6 -6
- package/dist/tenant/membership-table.d.ts +4 -4
- package/dist/tenant/schema/tenant.d.ts +6 -6
- package/dist/tenant/web/client-plugin.d.ts +1 -1
- package/dist/tenant/web/member-status-cell.js +1 -1
- package/dist/tier-engine/entity.d.ts +1 -1
- package/dist/tier-engine/tier-resolver.d.ts +1 -1
- package/dist/tier-engine/web/client-plugin.d.ts +1 -1
- package/dist/user/schema/user.d.ts +12 -12
- package/dist/user-data-rights/env.d.ts +5 -0
- package/dist/user-data-rights/env.js +11 -0
- package/dist/user-data-rights/feature.js +4 -2
- package/dist/user-data-rights/index.d.ts +1 -0
- package/dist/user-data-rights/index.js +1 -0
- package/dist/user-data-rights/lib/mail-transport-resolver.js +1 -1
- package/dist/user-data-rights/lib/storage-provider-resolver.js +1 -1
- package/dist/user-data-rights/mail-notifications.js +1 -1
- package/dist/user-data-rights/schema/download-attempt.d.ts +14 -14
- package/dist/user-data-rights/schema/download-token.d.ts +8 -8
- package/dist/user-data-rights/schema/export-job.d.ts +6 -6
- package/dist/user-data-rights/web/client-plugin.d.ts +1 -1
- package/dist/user-data-rights/web/privacy-center-screen.js +2 -2
- package/dist/waitlist/entity.d.ts +14 -14
- package/dist/waitlist/legacy-migrate.d.ts +2 -0
- package/dist/waitlist/legacy-migrate.js +38 -3
- package/package.json +16 -11
- package/src/account-security/changes.json +6 -0
- package/src/auth-foundation/changes.json +6 -0
- package/src/billing-foundation/changes.json +6 -0
- package/src/cap-counter/changes.json +12 -0
- package/src/channel-email/changes.json +6 -0
- package/src/crypto-shredding/changes.json +6 -0
- package/src/file-provider-s3/changes.json +6 -0
- package/src/host-routing/changes.json +6 -0
- package/src/inbound-mail-foundation/changes.json +13 -0
- package/src/legal-pages/changes.json +6 -0
- package/src/mail-foundation/changes.json +6 -0
- package/src/template-resolver/changes.json +38 -0
- package/src/user-data-rights/changes.json +6 -0
- package/src/waitlist/changes.json +6 -0
- /package/dist/template-resolver/{web/placeholder-text.d.ts → placeholder-text.d.ts} +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export const HOST_ROUTING_FEATURE = "host-routing";
|
|
2
2
|
export const HostRoutingQueries = {
|
|
3
3
|
tenantKeyExists: `${HOST_ROUTING_FEATURE}:query:tenant-key-exists`,
|
|
4
|
+
hostTenantExists: `${HOST_ROUTING_FEATURE}:query:host-tenant-exists`,
|
|
4
5
|
};
|
|
5
6
|
// Matches tenant:write:create's key limit; keeps lookups for absurd keys out
|
|
6
7
|
// of the database.
|
|
@@ -17,3 +18,5 @@ const PATH_TENANT_KEY_PATTERN = /^[A-Za-z0-9._~-]+$/;
|
|
|
17
18
|
export function isPathTenantKeyCandidate(value) {
|
|
18
19
|
return value.length <= TENANT_KEY_MAX_LENGTH && PATH_TENANT_KEY_PATTERN.test(value);
|
|
19
20
|
}
|
|
21
|
+
// Longest Host header value a hostname plus port can take.
|
|
22
|
+
export const HOST_HEADER_MAX_LENGTH = 260;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PageHeadSystemQuery } from "@cosmicdrift/kumiko-headless/apex";
|
|
1
|
+
import type { PageHeadSystemQuery } from "@cosmicdrift/kumiko-renderer/headless/apex";
|
|
2
2
|
import type { HostRoutingOptions } from "./options.ts";
|
|
3
3
|
export type HostRoutingDispatchResult = {
|
|
4
4
|
readonly kind: "html";
|
|
@@ -2,7 +2,8 @@ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
2
2
|
import { RateLimitError, ValidationError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import * as z from "zod";
|
|
4
4
|
import { HostRoutingQueries, isPathTenantKeyCandidate, isTenantKeyCandidate } from "./constants.js";
|
|
5
|
-
import { classifyHost } from "./host.js";
|
|
5
|
+
import { classifyHost, splitFirstSegment, tenantKeyFromPath } from "./host.js";
|
|
6
|
+
import { matchRedirect, sameOriginPath } from "./redirects.js";
|
|
6
7
|
import { validateHostRoutingOptions } from "./validate-options.js";
|
|
7
8
|
const tenantKeyExistsResultSchema = z.object({ exists: z.boolean() });
|
|
8
9
|
async function isTenantKeyKnown(systemQuery, key) {
|
|
@@ -21,6 +22,23 @@ async function isTenantKeyKnown(systemQuery, key) {
|
|
|
21
22
|
}
|
|
22
23
|
return tenantKeyExistsResultSchema.parse(result).exists;
|
|
23
24
|
}
|
|
25
|
+
async function isHostKnown(systemQuery, host) {
|
|
26
|
+
let result;
|
|
27
|
+
try {
|
|
28
|
+
result = await systemQuery(HostRoutingQueries.hostTenantExists, { host }, SYSTEM_TENANT_ID);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error instanceof ValidationError)
|
|
32
|
+
return false;
|
|
33
|
+
// The shell is harmless: the API resolver is authoritative and answers
|
|
34
|
+
// tenant_required for unknown hosts, while a 404 would hit visitors behind
|
|
35
|
+
// a shared NAT under load.
|
|
36
|
+
if (error instanceof RateLimitError)
|
|
37
|
+
return true;
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
return tenantKeyExistsResultSchema.parse(result).exists;
|
|
41
|
+
}
|
|
24
42
|
function serve(surface, file = surface.file) {
|
|
25
43
|
return surface.csp === undefined
|
|
26
44
|
? { kind: "html", file }
|
|
@@ -33,19 +51,6 @@ function notFound(surface) {
|
|
|
33
51
|
? { kind: "not-found", file: surface.file }
|
|
34
52
|
: { kind: "not-found", file: surface.file, csp: surface.csp };
|
|
35
53
|
}
|
|
36
|
-
// Redirect targets are always same-origin paths. Collapsing leading slashes
|
|
37
|
-
// and backslashes keeps "/de//evil.example" from turning into the
|
|
38
|
-
// protocol-relative "//evil.example".
|
|
39
|
-
function sameOriginPath(path) {
|
|
40
|
-
return `/${path.replace(/^[/\\]+/, "")}`;
|
|
41
|
-
}
|
|
42
|
-
function splitFirstSegment(path) {
|
|
43
|
-
const withoutLeading = path.replace(/^\/+/, "");
|
|
44
|
-
const slash = withoutLeading.indexOf("/");
|
|
45
|
-
if (slash === -1)
|
|
46
|
-
return { segment: withoutLeading, rest: "/" };
|
|
47
|
-
return { segment: withoutLeading.slice(0, slash), rest: withoutLeading.slice(slash) };
|
|
48
|
-
}
|
|
49
54
|
function apexPage(options, req) {
|
|
50
55
|
const { apex, locales } = options;
|
|
51
56
|
if (locales === undefined)
|
|
@@ -57,28 +62,26 @@ function apexPage(options, req) {
|
|
|
57
62
|
const locale = locales.prefixed.includes(segment) ? segment : locales.default;
|
|
58
63
|
return serve(apex, apex.localeFiles?.[locale] ?? apex.file);
|
|
59
64
|
}
|
|
60
|
-
async function tenantPage(options, key, systemQuery) {
|
|
65
|
+
async function tenantPage(options, key, systemQuery, keySource) {
|
|
61
66
|
// No key (bare tenant prefix): the shell handles login and tenant choice.
|
|
62
67
|
if (key === null)
|
|
63
68
|
return serve(options.tenant);
|
|
64
|
-
const isCandidate =
|
|
69
|
+
const isCandidate = keySource === "path" ? isPathTenantKeyCandidate : isTenantKeyCandidate;
|
|
65
70
|
if (!isCandidate(key))
|
|
66
71
|
return notFound(options.unknownTenant);
|
|
67
72
|
return (await isTenantKeyKnown(systemQuery, key))
|
|
68
73
|
? serve(options.tenant)
|
|
69
74
|
: notFound(options.unknownTenant);
|
|
70
75
|
}
|
|
71
|
-
function tenantKeyFromPath(prefix, path) {
|
|
72
|
-
if (path !== prefix && !path.startsWith(`${prefix}/`))
|
|
73
|
-
return undefined;
|
|
74
|
-
const { segment } = splitFirstSegment(path.slice(prefix.length));
|
|
75
|
-
return segment === "" ? null : segment;
|
|
76
|
-
}
|
|
77
76
|
// The routing decision for runProdApp's `hostDispatch` (and, via
|
|
78
77
|
// toDevHostDispatch, the dev server). Unknown hosts fail closed with 404.
|
|
79
78
|
export function createHostDispatch(options) {
|
|
80
79
|
validateHostRoutingOptions(options);
|
|
80
|
+
const redirects = options.redirects ?? [];
|
|
81
81
|
return async (req, { systemQuery }) => {
|
|
82
|
+
const redirect = matchRedirect(redirects, req.host, req.path, req.search);
|
|
83
|
+
if (redirect !== undefined)
|
|
84
|
+
return { kind: "redirect", ...redirect };
|
|
82
85
|
const host = classifyHost(req.host, options);
|
|
83
86
|
switch (host.kind) {
|
|
84
87
|
case "fixed":
|
|
@@ -88,12 +91,19 @@ export function createHostDispatch(options) {
|
|
|
88
91
|
case "subdomain":
|
|
89
92
|
if (options.tenantBy !== "host")
|
|
90
93
|
return notFound(undefined);
|
|
91
|
-
return tenantPage(options, host.key, systemQuery);
|
|
94
|
+
return tenantPage(options, host.key, systemQuery, "subdomain");
|
|
95
|
+
case "resolved": {
|
|
96
|
+
if (await isHostKnown(systemQuery, host.hostname))
|
|
97
|
+
return serve(options.tenant);
|
|
98
|
+
return notFound(host.hostname.endsWith(`.${options.baseDomain.toLowerCase()}`)
|
|
99
|
+
? options.unknownTenant
|
|
100
|
+
: undefined);
|
|
101
|
+
}
|
|
92
102
|
case "apex": {
|
|
93
|
-
if (options.
|
|
103
|
+
if (options.tenantPathPrefix !== undefined) {
|
|
94
104
|
const key = tenantKeyFromPath(options.tenantPathPrefix, req.path);
|
|
95
105
|
if (key !== undefined)
|
|
96
|
-
return tenantPage(options, key, systemQuery);
|
|
106
|
+
return tenantPage(options, key, systemQuery, "path");
|
|
97
107
|
}
|
|
98
108
|
return apexPage(options, req);
|
|
99
109
|
}
|
|
@@ -1,31 +1,70 @@
|
|
|
1
|
-
import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
2
1
|
import { defineFeature, defineQueryHandler, isSystemTenant, } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
2
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
3
|
import * as z from "zod";
|
|
5
4
|
import { EXT_TENANT_EXISTENCE, EXT_TENANT_RESOLVER, } from "../auth-foundation/index.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
5
|
+
import { HOST_HEADER_MAX_LENGTH, HOST_ROUTING_FEATURE, TENANT_KEY_MAX_LENGTH, } from "./constants.js";
|
|
6
|
+
import { classifyHost, normalizeHostname } from "./host.js";
|
|
7
|
+
import { isTenantServing, servingTenantIdByKey, servingTenantIdByResolver, } from "./tenant-lookup.js";
|
|
9
8
|
import { validateHostRoutingOptions } from "./validate-options.js";
|
|
10
|
-
// Disabled tenants and tenants in any destroy phase are not routable, the same
|
|
11
|
-
// rule public files and pages follow.
|
|
12
|
-
async function servingTenantIdByKey(db, key) {
|
|
13
|
-
const row = await fetchOne(db, tenantTable, { key });
|
|
14
|
-
return row !== undefined && isTenantServingPublicContent(row) ? row.id : null;
|
|
15
|
-
}
|
|
16
|
-
async function isTenantServing(db, id) {
|
|
17
|
-
const row = await fetchOne(db, tenantTable, { id });
|
|
18
|
-
return row !== undefined && isTenantServingPublicContent(row);
|
|
19
|
-
}
|
|
20
9
|
// auth-foundation types the Hono context as unknown to stay free of a hono dep.
|
|
21
|
-
function
|
|
10
|
+
function isRequestReader(c) {
|
|
22
11
|
return (typeof c === "object" &&
|
|
23
12
|
c !== null &&
|
|
24
13
|
"req" in c &&
|
|
25
14
|
typeof c.req === "object" &&
|
|
26
15
|
c.req !== null &&
|
|
27
16
|
"header" in c.req &&
|
|
28
|
-
typeof c.req.header === "function"
|
|
17
|
+
typeof c.req.header === "function" &&
|
|
18
|
+
"url" in c.req &&
|
|
19
|
+
typeof c.req.url === "string" &&
|
|
20
|
+
"path" in c.req &&
|
|
21
|
+
typeof c.req.path === "string");
|
|
22
|
+
}
|
|
23
|
+
// Hono routes on the decoded path and ignores a trailing slash, so the
|
|
24
|
+
// allowlist must compare the same shape: "/api/%71uery" and "/api/query/"
|
|
25
|
+
// reach the query handler too.
|
|
26
|
+
function routedPathOf(c) {
|
|
27
|
+
return c.req.path.length > 1 ? c.req.path.replace(/\/+$/, "") : c.req.path;
|
|
28
|
+
}
|
|
29
|
+
// "example.com" and "www.example.com" are one site: a host without its own
|
|
30
|
+
// entry falls back to the other variant's entry instead of keeping the anchor.
|
|
31
|
+
function allowlistFor(options, hostname) {
|
|
32
|
+
const allowlists = options.apexAnonymousQueryAllowlist;
|
|
33
|
+
if (allowlists === undefined)
|
|
34
|
+
return undefined;
|
|
35
|
+
const counterpart = hostname.startsWith("www.") ? hostname.slice(4) : `www.${hostname}`;
|
|
36
|
+
for (const key of [hostname, counterpart]) {
|
|
37
|
+
if (Object.hasOwn(allowlists, key))
|
|
38
|
+
return allowlists[key];
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
// Both carry { type, payload } and run read handlers; /api/batch is commands only.
|
|
43
|
+
const TYPED_READ_PATHS = new Set(["/api/query", "/api/stream"]);
|
|
44
|
+
const queryTypeBodySchema = z.object({ type: z.string() });
|
|
45
|
+
// Reads the query type from a clone, so the body stays intact for the handler.
|
|
46
|
+
async function peekQueryType(c) {
|
|
47
|
+
if (c.req.raw === undefined)
|
|
48
|
+
return null;
|
|
49
|
+
try {
|
|
50
|
+
const parsed = queryTypeBodySchema.safeParse(await c.req.raw.clone().json());
|
|
51
|
+
return parsed.success ? parsed.data.type : null;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Apex anchor tenant for anonymous calls. A host with an allowlist only gets
|
|
58
|
+
// the anchor for the listed query types on read paths; other paths keep it.
|
|
59
|
+
async function apexAnchorFor(options, c, hostname) {
|
|
60
|
+
const anchor = options.apexAnonymousTenantId ?? null;
|
|
61
|
+
if (anchor === null)
|
|
62
|
+
return null;
|
|
63
|
+
const allowlist = allowlistFor(options, hostname);
|
|
64
|
+
if (allowlist === undefined || !TYPED_READ_PATHS.has(routedPathOf(c)))
|
|
65
|
+
return anchor;
|
|
66
|
+
const type = await peekQueryType(c);
|
|
67
|
+
return type !== null && allowlist.includes(type) ? anchor : null;
|
|
29
68
|
}
|
|
30
69
|
// Returns only { exists }: the same fact the tenant 404 page already makes
|
|
31
70
|
// public, never other tenant fields. Rate-limited so it is no cheap oracle.
|
|
@@ -47,34 +86,66 @@ const tenantKeyExistsQuery = defineQueryHandler({
|
|
|
47
86
|
return { exists: (await servingTenantIdByKey(db, query.payload.key)) !== null };
|
|
48
87
|
},
|
|
49
88
|
});
|
|
89
|
+
function hostTenantExistsQueryFor(options) {
|
|
90
|
+
return defineQueryHandler({
|
|
91
|
+
name: "host-tenant-exists",
|
|
92
|
+
schema: z.object({ host: z.string().min(1).max(HOST_HEADER_MAX_LENGTH) }),
|
|
93
|
+
access: { roles: ["anonymous"] },
|
|
94
|
+
rateLimit: { per: "ip+handler", limit: 120, windowSeconds: 60 },
|
|
95
|
+
// Page-routing plumbing, not a task an agent performs.
|
|
96
|
+
agent: { expose: false },
|
|
97
|
+
description: "Anonymous host check backing the unknown-host 404 of the host-routing hostDispatch in tenantBy resolver mode. Returns only { exists }.",
|
|
98
|
+
handler: async (query, ctx) => {
|
|
99
|
+
if (!ctx.systemDb) {
|
|
100
|
+
throw new InternalError({
|
|
101
|
+
message: "host-routing:query:host-tenant-exists requires ctx.systemDb (r.systemScope())",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
const hostname = normalizeHostname(query.payload.host);
|
|
105
|
+
if (hostname === null)
|
|
106
|
+
return { exists: false };
|
|
107
|
+
const db = ctx.systemDb.acknowledgeCrossTenant("host existence check for the host-routing 404 — publicly observable via the 404 itself");
|
|
108
|
+
return { exists: (await servingTenantIdByResolver(options, db, hostname)) !== null };
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
50
112
|
function tenantResolverFor(options) {
|
|
51
|
-
|
|
52
|
-
if (options.tenantBy === "host") {
|
|
113
|
+
if (options.tenantBy !== "path") {
|
|
53
114
|
return {
|
|
54
115
|
trust: "authoritative",
|
|
55
116
|
build: ({ db }) => async (c) => {
|
|
56
|
-
if (!
|
|
117
|
+
if (!isRequestReader(c))
|
|
57
118
|
return null;
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
return servingTenantIdByKey(db, host.key);
|
|
119
|
+
const hostHeader = c.req.header("Host") ?? "";
|
|
120
|
+
const host = classifyHost(hostHeader, options);
|
|
61
121
|
// Fixed and unknown hosts have no anonymous tenant: an authoritative
|
|
62
122
|
// null answers tenant_required, whatever X-Tenant says.
|
|
63
|
-
|
|
123
|
+
switch (host.kind) {
|
|
124
|
+
case "subdomain":
|
|
125
|
+
return servingTenantIdByKey(db, host.key);
|
|
126
|
+
case "resolved":
|
|
127
|
+
return servingTenantIdByResolver(options, db, host.hostname);
|
|
128
|
+
case "apex":
|
|
129
|
+
return apexAnchorFor(options, c, normalizeHostname(hostHeader) ?? "");
|
|
130
|
+
case "fixed":
|
|
131
|
+
case "unknown":
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
64
134
|
},
|
|
65
135
|
};
|
|
66
136
|
}
|
|
67
|
-
if (
|
|
137
|
+
if (options.apexAnonymousTenantId === undefined)
|
|
68
138
|
return undefined;
|
|
69
139
|
// Path mode: the client names its tenant; the apex default only fills in
|
|
70
140
|
// when it does not.
|
|
71
141
|
return {
|
|
72
142
|
trust: "fallback-only",
|
|
73
143
|
build: () => async (c) => {
|
|
74
|
-
if (!
|
|
144
|
+
if (!isRequestReader(c))
|
|
75
145
|
return null;
|
|
76
|
-
|
|
77
|
-
|
|
146
|
+
const hostHeader = c.req.header("Host") ?? "";
|
|
147
|
+
return classifyHost(hostHeader, options).kind === "apex"
|
|
148
|
+
? apexAnchorFor(options, c, normalizeHostname(hostHeader) ?? "")
|
|
78
149
|
: null;
|
|
79
150
|
},
|
|
80
151
|
};
|
|
@@ -89,6 +160,8 @@ export function createHostRoutingFeature(options) {
|
|
|
89
160
|
r.requires({ features: ["auth-foundation", "tenant"] });
|
|
90
161
|
r.systemScope();
|
|
91
162
|
r.queryHandler(tenantKeyExistsQuery);
|
|
163
|
+
if (typeof options.tenantBy === "function")
|
|
164
|
+
r.queryHandler(hostTenantExistsQueryFor(options));
|
|
92
165
|
const resolver = tenantResolverFor(options);
|
|
93
166
|
if (resolver !== undefined) {
|
|
94
167
|
// biome-ignore lint/correctness/useHookAtTopLevel: r.useExtension is a registrar method, not a React hook; path mode without an apex tenant must leave the resolver slot free.
|
|
@@ -8,7 +8,16 @@ export type HostClass = {
|
|
|
8
8
|
} | {
|
|
9
9
|
readonly kind: "subdomain";
|
|
10
10
|
readonly key: string;
|
|
11
|
+
} | {
|
|
12
|
+
readonly kind: "resolved";
|
|
13
|
+
readonly hostname: string;
|
|
11
14
|
} | {
|
|
12
15
|
readonly kind: "unknown";
|
|
13
16
|
};
|
|
14
17
|
export declare function classifyHost(host: string, options: HostRoutingOptions): HostClass;
|
|
18
|
+
export declare function splitFirstSegment(path: string): {
|
|
19
|
+
readonly segment: string;
|
|
20
|
+
readonly rest: string;
|
|
21
|
+
};
|
|
22
|
+
export declare function tenantKeyFromPath(prefix: string, path: string): string | null | undefined;
|
|
23
|
+
export declare function isApexHostname(hostname: string, baseDomain: string): boolean;
|
|
@@ -23,9 +23,11 @@ export function classifyHost(host, options) {
|
|
|
23
23
|
: undefined;
|
|
24
24
|
if (fixed !== undefined)
|
|
25
25
|
return { kind: "fixed", surface: fixed };
|
|
26
|
-
|
|
27
|
-
if (hostname === baseDomain || hostname === `www.${baseDomain}`)
|
|
26
|
+
if (isApexHostname(hostname, options.baseDomain))
|
|
28
27
|
return { kind: "apex" };
|
|
28
|
+
const baseDomain = options.baseDomain.toLowerCase();
|
|
29
|
+
if (typeof options.tenantBy === "function")
|
|
30
|
+
return { kind: "resolved", hostname };
|
|
29
31
|
const suffix = `.${baseDomain}`;
|
|
30
32
|
if (!hostname.endsWith(suffix))
|
|
31
33
|
return { kind: "unknown" };
|
|
@@ -35,3 +37,23 @@ export function classifyHost(host, options) {
|
|
|
35
37
|
return { kind: "unknown" };
|
|
36
38
|
return { kind: "subdomain", key: label };
|
|
37
39
|
}
|
|
40
|
+
export function splitFirstSegment(path) {
|
|
41
|
+
const withoutLeading = path.replace(/^\/+/, "");
|
|
42
|
+
const slash = withoutLeading.indexOf("/");
|
|
43
|
+
if (slash === -1)
|
|
44
|
+
return { segment: withoutLeading, rest: "/" };
|
|
45
|
+
return { segment: withoutLeading.slice(0, slash), rest: withoutLeading.slice(slash) };
|
|
46
|
+
}
|
|
47
|
+
// undefined: path is outside the prefix; null: bare prefix without a key.
|
|
48
|
+
export function tenantKeyFromPath(prefix, path) {
|
|
49
|
+
if (path !== prefix && !path.startsWith(`${prefix}/`))
|
|
50
|
+
return undefined;
|
|
51
|
+
const { segment } = splitFirstSegment(path.slice(prefix.length));
|
|
52
|
+
return segment === "" ? null : segment;
|
|
53
|
+
}
|
|
54
|
+
// Apex hostnames are the only ones that may carry a path-named tenant or an
|
|
55
|
+
// anonymous anchor tenant.
|
|
56
|
+
export function isApexHostname(hostname, baseDomain) {
|
|
57
|
+
const base = baseDomain.toLowerCase();
|
|
58
|
+
return hostname === base || hostname === `www.${base}`;
|
|
59
|
+
}
|
|
@@ -2,4 +2,5 @@ export { HOST_ROUTING_FEATURE, HostRoutingQueries, isTenantKeyCandidate, TENANT_
|
|
|
2
2
|
export { createHostDispatch, type HostRoutingDispatchFn, type HostRoutingDispatchResult, type HostRoutingRequest, } from "./dispatch.ts";
|
|
3
3
|
export { createHostRoutingFeature } from "./feature.ts";
|
|
4
4
|
export { normalizeHostname } from "./host.ts";
|
|
5
|
-
export type { HostRoutingApexSurface, HostRoutingLocales, HostRoutingOptions, HostRoutingSurface, HostRoutingTenantMode, } from "./options.ts";
|
|
5
|
+
export type { HostRoutingApexSurface, HostRoutingLocales, HostRoutingOptions, HostRoutingRedirect, HostRoutingSurface, HostRoutingTenantMode, HostTenantResolver, } from "./options.ts";
|
|
6
|
+
export { createHostTenantIdResolver } from "./tenant-lookup.ts";
|
|
@@ -2,3 +2,4 @@ export { HOST_ROUTING_FEATURE, HostRoutingQueries, isTenantKeyCandidate, TENANT_
|
|
|
2
2
|
export { createHostDispatch, } from "./dispatch.js";
|
|
3
3
|
export { createHostRoutingFeature } from "./feature.js";
|
|
4
4
|
export { normalizeHostname } from "./host.js";
|
|
5
|
+
export { createHostTenantIdResolver } from "./tenant-lookup.js";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DbConnection, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
1
2
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
export type HostRoutingSurface = {
|
|
3
4
|
readonly file: string;
|
|
@@ -10,11 +11,22 @@ export type HostRoutingLocales = {
|
|
|
10
11
|
readonly default: string;
|
|
11
12
|
readonly prefixed: readonly string[];
|
|
12
13
|
};
|
|
14
|
+
export type HostTenantResolver = (host: string, db: DbConnection | TenantDb) => Promise<TenantId | null> | TenantId | null;
|
|
13
15
|
export type HostRoutingTenantMode = {
|
|
14
16
|
readonly tenantBy: "host";
|
|
17
|
+
readonly tenantPathPrefix?: string;
|
|
15
18
|
} | {
|
|
16
19
|
readonly tenantBy: "path";
|
|
17
20
|
readonly tenantPathPrefix: string;
|
|
21
|
+
} | {
|
|
22
|
+
readonly tenantBy: HostTenantResolver;
|
|
23
|
+
readonly tenantPathPrefix?: string;
|
|
24
|
+
};
|
|
25
|
+
export type HostRoutingRedirect = {
|
|
26
|
+
readonly from: string;
|
|
27
|
+
readonly to: string;
|
|
28
|
+
readonly host?: string;
|
|
29
|
+
readonly status?: 301 | 302;
|
|
18
30
|
};
|
|
19
31
|
export type HostRoutingOptions = HostRoutingTenantMode & {
|
|
20
32
|
readonly baseDomain: string;
|
|
@@ -24,4 +36,6 @@ export type HostRoutingOptions = HostRoutingTenantMode & {
|
|
|
24
36
|
readonly hosts?: Readonly<Record<string, HostRoutingSurface>>;
|
|
25
37
|
readonly locales?: HostRoutingLocales;
|
|
26
38
|
readonly apexAnonymousTenantId?: TenantId;
|
|
39
|
+
readonly apexAnonymousQueryAllowlist?: Readonly<Record<string, readonly string[]>>;
|
|
40
|
+
readonly redirects?: readonly HostRoutingRedirect[];
|
|
27
41
|
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { HostRoutingRedirect } from "./options.ts";
|
|
2
|
+
export declare function sameOriginPath(path: string): string;
|
|
3
|
+
export declare function matchRedirect(redirects: readonly HostRoutingRedirect[], host: string, path: string, search: string): {
|
|
4
|
+
readonly to: string;
|
|
5
|
+
readonly status: 301 | 302;
|
|
6
|
+
} | undefined;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { normalizeHostname } from "./host.js";
|
|
2
|
+
// Same-origin targets collapse leading slashes and backslashes so an appended
|
|
3
|
+
// "//evil.example" remainder can never turn into a protocol-relative URL.
|
|
4
|
+
export function sameOriginPath(path) {
|
|
5
|
+
return `/${path.replace(/^[/\\]+/, "")}`;
|
|
6
|
+
}
|
|
7
|
+
function remainderAfter(from, path) {
|
|
8
|
+
if (path === from)
|
|
9
|
+
return "";
|
|
10
|
+
const prefix = from.endsWith("/") ? from : `${from}/`;
|
|
11
|
+
return path.startsWith(prefix) ? path.slice(prefix.length - 1) : null;
|
|
12
|
+
}
|
|
13
|
+
export function matchRedirect(redirects, host, path, search) {
|
|
14
|
+
const hostname = normalizeHostname(host);
|
|
15
|
+
for (const rule of redirects) {
|
|
16
|
+
if (rule.host !== undefined && rule.host !== hostname)
|
|
17
|
+
continue;
|
|
18
|
+
const remainder = remainderAfter(rule.from, path);
|
|
19
|
+
if (remainder === null)
|
|
20
|
+
continue;
|
|
21
|
+
const target = rule.to.startsWith("https://")
|
|
22
|
+
? `${rule.to.replace(/\/$/, "")}${remainder}`
|
|
23
|
+
: sameOriginPath(`${rule.to.replace(/\/$/, "")}${remainder}`);
|
|
24
|
+
return { to: `${target}${search}`, status: rule.status ?? 301 };
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { DbConnection, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import type { HostRoutingOptions } from "./options.ts";
|
|
4
|
+
export declare function servingTenantIdByKey(db: DbConnection | TenantDb, key: string): Promise<TenantId | null>;
|
|
5
|
+
export declare function isTenantServing(db: DbConnection | TenantDb, id: TenantId): Promise<boolean>;
|
|
6
|
+
export declare function servingTenantIdByResolver(options: HostRoutingOptions, db: DbConnection | TenantDb, hostname: string): Promise<TenantId | null>;
|
|
7
|
+
export declare function createHostTenantIdResolver(options: HostRoutingOptions, db: DbConnection | TenantDb): (host: string) => Promise<TenantId | null>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { isTenantServingPublicContent, tenantTable, } from "../tenant/index.js";
|
|
3
|
+
import { classifyHost } from "./host.js";
|
|
4
|
+
// Disabled tenants and tenants in any destroy phase are not routable, the same
|
|
5
|
+
// rule public files and pages follow.
|
|
6
|
+
export async function servingTenantIdByKey(db, key) {
|
|
7
|
+
const row = await fetchOne(db, tenantTable, { key });
|
|
8
|
+
return row !== undefined && isTenantServingPublicContent(row) ? row.id : null;
|
|
9
|
+
}
|
|
10
|
+
export async function isTenantServing(db, id) {
|
|
11
|
+
const row = await fetchOne(db, tenantTable, { id });
|
|
12
|
+
return row !== undefined && isTenantServingPublicContent(row);
|
|
13
|
+
}
|
|
14
|
+
export async function servingTenantIdByResolver(options, db, hostname) {
|
|
15
|
+
if (typeof options.tenantBy !== "function")
|
|
16
|
+
return null;
|
|
17
|
+
const resolved = await options.tenantBy(hostname, db);
|
|
18
|
+
return resolved !== null && (await isTenantServing(db, resolved)) ? resolved : null;
|
|
19
|
+
}
|
|
20
|
+
// The tenant a Host header belongs to, without any request: subdomain key,
|
|
21
|
+
// resolver result or the apex anchor. Fixed and unknown hosts have none.
|
|
22
|
+
// Meant for features that serve per-tenant content by host, e.g. the
|
|
23
|
+
// `resolveApexTenant` option of managed-pages.
|
|
24
|
+
export function createHostTenantIdResolver(options, db) {
|
|
25
|
+
return async (host) => {
|
|
26
|
+
const classified = classifyHost(host, options);
|
|
27
|
+
switch (classified.kind) {
|
|
28
|
+
case "subdomain":
|
|
29
|
+
return options.tenantBy === "host" ? servingTenantIdByKey(db, classified.key) : null;
|
|
30
|
+
case "resolved":
|
|
31
|
+
return servingTenantIdByResolver(options, db, classified.hostname);
|
|
32
|
+
case "apex":
|
|
33
|
+
return options.apexAnonymousTenantId ?? null;
|
|
34
|
+
case "fixed":
|
|
35
|
+
case "unknown":
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -1,6 +1,36 @@
|
|
|
1
|
+
import { isApexHostname } from "./host.js";
|
|
1
2
|
const DOMAIN_PATTERN = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/;
|
|
2
3
|
const PATH_PREFIX_PATTERN = /^\/[a-z0-9-]+(?:\/[a-z0-9-]+)*$/;
|
|
3
4
|
const LOCALE_PATTERN = /^[a-z]{2}(?:-[a-z]{2})?$/;
|
|
5
|
+
const REDIRECT_PATH_PATTERN = /^\/[A-Za-z0-9._~/-]*$/;
|
|
6
|
+
// A redirect target is either a same-origin path or an https URL; neither may
|
|
7
|
+
// carry a query or fragment, because the request's query string is appended.
|
|
8
|
+
const REDIRECT_TARGET_PATTERN = /^(?:\/(?!\/)[A-Za-z0-9._~/-]*|https:\/\/[a-z0-9.-]+(?::[0-9]{1,5})?(?:\/[A-Za-z0-9._~/-]*)?)$/;
|
|
9
|
+
function validateRedirects(options) {
|
|
10
|
+
for (const rule of options.redirects ?? []) {
|
|
11
|
+
if (!REDIRECT_PATH_PATTERN.test(rule.from)) {
|
|
12
|
+
throw new Error(`host-routing: redirect from must be a path like "/old", got "${rule.from}"`);
|
|
13
|
+
}
|
|
14
|
+
if (!REDIRECT_TARGET_PATTERN.test(rule.to)) {
|
|
15
|
+
throw new Error(`host-routing: redirect to must be a same-origin path or an https URL without query, got "${rule.to}"`);
|
|
16
|
+
}
|
|
17
|
+
if (rule.host !== undefined && !DOMAIN_PATTERN.test(rule.host)) {
|
|
18
|
+
throw new Error(`host-routing: redirect host must be a lowercase hostname, got "${rule.host}"`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function validateAnonymousQueryAllowlist(options) {
|
|
23
|
+
if (options.apexAnonymousQueryAllowlist !== undefined && options.tenantBy === "path") {
|
|
24
|
+
// Path mode resolves as fallback-only: a client-sent X-Tenant or cookie wins
|
|
25
|
+
// before the resolver runs, so the allowlist could never hold.
|
|
26
|
+
throw new Error('host-routing: apexAnonymousQueryAllowlist cannot be used with tenantBy "path", because the client names its tenant there and bypasses the allowlist');
|
|
27
|
+
}
|
|
28
|
+
for (const host of Object.keys(options.apexAnonymousQueryAllowlist ?? {})) {
|
|
29
|
+
if (!isApexHostname(host, options.baseDomain)) {
|
|
30
|
+
throw new Error(`host-routing: apexAnonymousQueryAllowlist key must be the apex or www.<apex> hostname, got "${host}"`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
4
34
|
// Checked once when the app composes its features, so a config typo fails
|
|
5
35
|
// the boot instead of routing every request to a 404.
|
|
6
36
|
export function validateHostRoutingOptions(options) {
|
|
@@ -12,9 +42,12 @@ export function validateHostRoutingOptions(options) {
|
|
|
12
42
|
throw new Error(`host-routing: hosts key must be a lowercase hostname, got "${host}"`);
|
|
13
43
|
}
|
|
14
44
|
}
|
|
15
|
-
if (options.
|
|
45
|
+
if (options.tenantPathPrefix !== undefined &&
|
|
46
|
+
!PATH_PREFIX_PATTERN.test(options.tenantPathPrefix)) {
|
|
16
47
|
throw new Error(`host-routing: tenantPathPrefix must look like "/app" (leading slash, no trailing slash), got "${options.tenantPathPrefix}"`);
|
|
17
48
|
}
|
|
49
|
+
validateRedirects(options);
|
|
50
|
+
validateAnonymousQueryAllowlist(options);
|
|
18
51
|
const { locales } = options;
|
|
19
52
|
if (locales !== undefined) {
|
|
20
53
|
for (const locale of [locales.default, ...locales.prefixed]) {
|