Pass one at getting it into the db

This commit is contained in:
Owen
2026-04-23 14:05:08 -07:00
parent f03d0cd47f
commit fa117198a0
12 changed files with 377 additions and 44 deletions

View File

@@ -1947,6 +1947,8 @@
"httpMethod": "Scheme", "httpMethod": "Scheme",
"selectHttpMethod": "Select scheme", "selectHttpMethod": "Select scheme",
"domainPickerSubdomainLabel": "Subdomain", "domainPickerSubdomainLabel": "Subdomain",
"domainPickerWildcard": "Wildcard",
"domainPickerWildcardPaidOnly": "Wildcard subdomains are a paid feature. Please upgrade to access this feature.",
"domainPickerBaseDomainLabel": "Base Domain", "domainPickerBaseDomainLabel": "Base Domain",
"domainPickerSearchDomains": "Search domains...", "domainPickerSearchDomains": "Search domains...",
"domainPickerNoDomainsFound": "No domains found", "domainPickerNoDomainsFound": "No domains found",

View File

@@ -158,7 +158,8 @@ export const resources = pgTable("resources", {
maintenanceMessage: text("maintenanceMessage"), maintenanceMessage: text("maintenanceMessage"),
maintenanceEstimatedTime: text("maintenanceEstimatedTime"), maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath"), postAuthPath: text("postAuthPath"),
health: varchar("health") // "healthy", "unhealthy" health: varchar("health"), // "healthy", "unhealthy"
wildcard: boolean("wildcard").notNull().default(false)
}); });
export const targets = pgTable("targets", { export const targets = pgTable("targets", {

View File

@@ -179,7 +179,8 @@ export const resources = sqliteTable("resources", {
maintenanceMessage: text("maintenanceMessage"), maintenanceMessage: text("maintenanceMessage"),
maintenanceEstimatedTime: text("maintenanceEstimatedTime"), maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath"), postAuthPath: text("postAuthPath"),
health: text("health") // "healthy", "unhealthy" health: text("health"), // "healthy", "unhealthy"
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false)
}); });
export const targets = sqliteTable("targets", { export const targets = sqliteTable("targets", {

View File

@@ -23,7 +23,8 @@ export enum TierFeature {
HTTPPrivateResources = "httpPrivateResources", // handle downgrade by disabling HTTP private resources HTTPPrivateResources = "httpPrivateResources", // handle downgrade by disabling HTTP private resources
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
StandaloneHealthChecks = "standaloneHealthChecks", StandaloneHealthChecks = "standaloneHealthChecks",
AlertingRules = "alertingRules" AlertingRules = "alertingRules",
WildcardSubdomain = "wildcardSubdomain"
} }
export const tierMatrix: Record<TierFeature, Tier[]> = { export const tierMatrix: Record<TierFeature, Tier[]> = {
@@ -64,5 +65,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"], [TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"],
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.StandaloneHealthChecks]: ["tier2", "tier3", "enterprise"], [TierFeature.StandaloneHealthChecks]: ["tier2", "tier3", "enterprise"],
[TierFeature.AlertingRules]: ["tier2", "tier3", "enterprise"] [TierFeature.AlertingRules]: ["tier2", "tier3", "enterprise"],
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"]
}; };

View File

@@ -1,5 +1,6 @@
import { import {
domains, domains,
domainNamespaces,
orgDomains, orgDomains,
Resource, Resource,
resourceHeaderAuth, resourceHeaderAuth,
@@ -236,6 +237,7 @@ export async function updateProxyResources(
fullDomain: http ? resourceData["full-domain"] : null, fullDomain: http ? resourceData["full-domain"] : null,
subdomain: domain ? domain.subdomain : null, subdomain: domain ? domain.subdomain : null,
domainId: domain ? domain.domainId : null, domainId: domain ? domain.domainId : null,
wildcard: domain ? domain.wildcard : false,
enabled: resourceEnabled, enabled: resourceEnabled,
sso: resourceData.auth?.["sso-enabled"] || false, sso: resourceData.auth?.["sso-enabled"] || false,
skipToIdpId: skipToIdpId:
@@ -683,6 +685,7 @@ export async function updateProxyResources(
fullDomain: http ? resourceData["full-domain"] : null, fullDomain: http ? resourceData["full-domain"] : null,
subdomain: domain ? domain.subdomain : null, subdomain: domain ? domain.subdomain : null,
domainId: domain ? domain.domainId : null, domainId: domain ? domain.domainId : null,
wildcard: domain ? domain.wildcard : false,
enabled: resourceEnabled, enabled: resourceEnabled,
sso: resourceData.auth?.["sso-enabled"] || false, sso: resourceData.auth?.["sso-enabled"] || false,
skipToIdpId: resourceData.auth?.["auto-login-idp"] || null, skipToIdpId: resourceData.auth?.["auto-login-idp"] || null,
@@ -1152,7 +1155,9 @@ async function getDomainId(
orgId: string, orgId: string,
fullDomain: string, fullDomain: string,
trx: Transaction trx: Transaction
): Promise<{ subdomain: string | null; domainId: string } | null> { ): Promise<{ subdomain: string | null; domainId: string; wildcard: boolean } | null> {
const isWildcardFullDomain = fullDomain.startsWith("*.");
const possibleDomains = await trx const possibleDomains = await trx
.select() .select()
.from(domains) .from(domains)
@@ -1165,6 +1170,11 @@ async function getDomainId(
} }
const validDomains = possibleDomains.filter((domain) => { const validDomains = possibleDomains.filter((domain) => {
// Wildcard full-domains are not allowed on CNAME domains
if (isWildcardFullDomain && domain.domains.type === "cname") {
return false;
}
if (domain.domains.type == "ns" || domain.domains.type == "wildcard") { if (domain.domains.type == "ns" || domain.domains.type == "wildcard") {
return ( return (
fullDomain === domain.domains.baseDomain || fullDomain === domain.domains.baseDomain ||
@@ -1182,6 +1192,21 @@ async function getDomainId(
const domainSelection = validDomains[0].domains; const domainSelection = validDomains[0].domains;
const baseDomain = domainSelection.baseDomain; const baseDomain = domainSelection.baseDomain;
// Wildcard full-domains are not allowed on namespace (provided/free) domains
if (isWildcardFullDomain) {
const [namespaceDomain] = await trx
.select()
.from(domainNamespaces)
.where(eq(domainNamespaces.domainId, domainSelection.domainId))
.limit(1);
if (namespaceDomain) {
throw new Error(
`Wildcard full-domains are not supported for provided or free domains: ${fullDomain}`
);
}
}
// remove the base domain of the domain // remove the base domain of the domain
let subdomain = null; let subdomain = null;
if (fullDomain != baseDomain) { if (fullDomain != baseDomain) {
@@ -1191,6 +1216,7 @@ async function getDomainId(
// Return the first valid domain // Return the first valid domain
return { return {
subdomain: subdomain, subdomain: subdomain,
domainId: domainSelection.domainId domainId: domainSelection.domainId,
wildcard: isWildcardFullDomain
}; };
} }

View File

@@ -2,6 +2,7 @@ import { z } from "zod";
import { portRangeStringSchema } from "@server/lib/ip"; import { portRangeStringSchema } from "@server/lib/ip";
import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema"; import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema";
import { isValidRegionId } from "@server/db/regions"; import { isValidRegionId } from "@server/db/regions";
import { wildcardSubdomainSchema } from "@server/lib/schemas";
export const SiteSchema = z.object({ export const SiteSchema = z.object({
name: z.string().min(1).max(100), name: z.string().min(1).max(100),
@@ -319,6 +320,34 @@ export const ResourceSchema = z
message: message:
"Rules have conflicting or invalid priorities (must be unique, including auto-assigned ones)" "Rules have conflicting or invalid priorities (must be unique, including auto-assigned ones)"
} }
)
.refine(
(resource) => {
const fullDomain = resource["full-domain"];
if (!fullDomain || !fullDomain.includes("*")) return true;
// A wildcard full-domain must be of the form *.labels.basedomain
// Extract the leftmost label(s) before the first non-wildcard segment.
// e.g. "*.level1.example.com" → subdomain candidate is "*.level1"
// We do this by finding the base domain: everything after the first
// real (non-wildcard) dot-separated segment pair.
//
// Simple rule: split on ".", first token must be "*", rest must be
// valid hostname labels, and there must be at least 2 remaining labels
// (so the full domain has a real base domain).
const parts = fullDomain.split(".");
if (parts[0] !== "*") return false; // * must be the very first label
if (parts.includes("*", 1)) return false; // no further wildcards
if (parts.length < 3) return false; // need at least *.label.tld
const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
return parts.slice(1).every((label) => labelRegex.test(label));
},
{
path: ["full-domain"],
message:
'Wildcard full-domain must have "*" as the leftmost label only, followed by at least two valid hostname labels (e.g. "*.example.com" or "*.level1.example.com"). Patterns like "*example.com" or "level2.*.example.com" are not supported.'
}
); );
export function isTargetsOnlyResource(resource: any): boolean { export function isTargetsOnlyResource(resource: any): boolean {

View File

@@ -1,7 +1,7 @@
import { db } from "@server/db"; import { db } from "@server/db";
import { domains, orgDomains } from "@server/db"; import { domains, orgDomains, domainNamespaces } from "@server/db";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { subdomainSchema } from "@server/lib/schemas"; import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
export type DomainValidationResult = export type DomainValidationResult =
@@ -9,6 +9,7 @@ export type DomainValidationResult =
success: true; success: true;
fullDomain: string; fullDomain: string;
subdomain: string | null; subdomain: string | null;
wildcard: boolean;
} }
| { | {
success: false; success: false;
@@ -66,6 +67,47 @@ export async function validateAndConstructDomain(
}; };
} }
// Detect wildcard subdomain request
const isWildcard =
subdomain !== undefined &&
subdomain !== null &&
subdomain.includes("*");
// Wildcard subdomains are not allowed on CNAME domains
if (isWildcard && domainRes.domains.type === "cname") {
return {
success: false,
error: "Wildcard subdomains are not supported for CNAME domains. CNAME domains must use a specific hostname."
};
}
// Wildcard subdomains are not allowed on namespace (provided/free) domains
if (isWildcard) {
const [namespaceDomain] = await db
.select()
.from(domainNamespaces)
.where(eq(domainNamespaces.domainId, domainId))
.limit(1);
if (namespaceDomain) {
return {
success: false,
error: "Wildcard subdomains are not supported for provided or free domains. Use a specific subdomain instead."
};
}
}
// Validate wildcard subdomain format
if (isWildcard) {
const parsedWildcard = wildcardSubdomainSchema.safeParse(subdomain);
if (!parsedWildcard.success) {
return {
success: false,
error: fromError(parsedWildcard.error).toString()
};
}
}
// Construct full domain based on domain type // Construct full domain based on domain type
let fullDomain = ""; let fullDomain = "";
let finalSubdomain = subdomain; let finalSubdomain = subdomain;
@@ -81,13 +123,15 @@ export async function validateAndConstructDomain(
finalSubdomain = null; // CNAME domains don't use subdomains finalSubdomain = null; // CNAME domains don't use subdomains
} else if (domainRes.domains.type === "wildcard") { } else if (domainRes.domains.type === "wildcard") {
if (subdomain !== undefined && subdomain !== null) { if (subdomain !== undefined && subdomain !== null) {
// Validate subdomain format for wildcard domains if (!isWildcard) {
const parsedSubdomain = subdomainSchema.safeParse(subdomain); // Validate regular subdomain format for wildcard domains
if (!parsedSubdomain.success) { const parsedSubdomain = subdomainSchema.safeParse(subdomain);
return { if (!parsedSubdomain.success) {
success: false, return {
error: fromError(parsedSubdomain.error).toString() success: false,
}; error: fromError(parsedSubdomain.error).toString()
};
}
} }
fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`; fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`;
} else { } else {
@@ -100,13 +144,14 @@ export async function validateAndConstructDomain(
finalSubdomain = null; finalSubdomain = null;
} }
// Convert to lowercase // Convert to lowercase (preserve * as-is)
fullDomain = fullDomain.toLowerCase(); fullDomain = fullDomain.toLowerCase();
return { return {
success: true, success: true,
fullDomain, fullDomain,
subdomain: finalSubdomain ?? null subdomain: isWildcard ? "*" : (finalSubdomain ?? null),
wildcard: isWildcard
}; };
} catch (error) { } catch (error) {
return { return {

View File

@@ -1,5 +1,41 @@
import { z } from "zod"; import { z } from "zod";
/**
* Validates a wildcard subdomain passed as the leftmost component of a full domain.
*
* The value represents everything to the left of the base domain, so when combined
* with e.g. "example.com" it must produce a valid SSL-style wildcard hostname.
*
* Valid:
* "*" → *.example.com
* "*.level1" → *.level1.example.com
*
* Invalid:
* "*example" → *example.com (no dot after *)
* "level2.*.level1" → wildcard not in leftmost position
* "*.level1.*" → multiple wildcards
*/
export const wildcardSubdomainSchema = z
.string()
.refine(
(val) => {
// Must start with "*."; the remainder (if any) must be valid hostname labels.
// A bare "*" is also valid (becomes *.baseDomain directly).
if (val === "*") return true;
if (!val.startsWith("*.")) return false;
const rest = val.slice(2); // everything after "*."
// rest must not be empty, must not contain another "*",
// and every label must be a valid hostname label.
if (!rest || rest.includes("*")) return false;
const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
return rest.split(".").every((label) => labelRegex.test(label));
},
{
message:
'Invalid wildcard subdomain. The wildcard "*" must be the leftmost label followed by a dot and valid hostname labels (e.g. "*" or "*.level1"). Patterns like "*example", "level2.*.level1", or multiple wildcards are not supported.'
}
);
export const subdomainSchema = z export const subdomainSchema = z
.string() .string()
.regex( .regex(

View File

@@ -17,7 +17,7 @@ import createHttpError from "http-errors";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import { subdomainSchema } from "@server/lib/schemas"; import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { build } from "@server/build"; import { build } from "@server/build";
@@ -25,6 +25,7 @@ import { createCertificate } from "#dynamic/routers/certificates/createCertifica
import { getUniqueResourceName } from "@server/db/names"; import { getUniqueResourceName } from "@server/db/names";
import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { isSubscribed } from "#dynamic/lib/isSubscribed"; import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
const createResourceParamsSchema = z.strictObject({ const createResourceParamsSchema = z.strictObject({
@@ -44,7 +45,10 @@ const createHttpResourceSchema = z
.refine( .refine(
(data) => { (data) => {
if (data.subdomain) { if (data.subdomain) {
return subdomainSchema.safeParse(data.subdomain).success; return (
subdomainSchema.safeParse(data.subdomain).success ||
wildcardSubdomainSchema.safeParse(data.subdomain).success
);
} }
return true; return true;
}, },
@@ -198,6 +202,22 @@ async function createHttpResource(
const subdomain = parsedBody.data.subdomain; const subdomain = parsedBody.data.subdomain;
const stickySession = parsedBody.data.stickySession; const stickySession = parsedBody.data.stickySession;
// Wildcard subdomains are a paid feature
if (subdomain && subdomain.includes("*")) {
const isLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.wildcardSubdomain
);
if (!isLicensed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Wildcard subdomains are not supported on your current plan. Please upgrade to access this feature."
)
);
}
}
if (build == "saas" && !isSubscribed(orgId!, tierMatrix.domainNamespaces)) { if (build == "saas" && !isSubscribed(orgId!, tierMatrix.domainNamespaces)) {
// grandfather in existing users // grandfather in existing users
const lastAllowedDate = new Date("2026-04-13"); const lastAllowedDate = new Date("2026-04-13");
@@ -232,7 +252,7 @@ async function createHttpResource(
return next(createHttpError(HttpCode.BAD_REQUEST, domainResult.error)); return next(createHttpError(HttpCode.BAD_REQUEST, domainResult.error));
} }
const { fullDomain, subdomain: finalSubdomain } = domainResult; const { fullDomain, subdomain: finalSubdomain, wildcard } = domainResult;
logger.debug(`Full domain: ${fullDomain}`); logger.debug(`Full domain: ${fullDomain}`);
@@ -299,7 +319,8 @@ async function createHttpResource(
protocol: "tcp", protocol: "tcp",
ssl: true, ssl: true,
stickySession: stickySession, stickySession: stickySession,
postAuthPath: postAuthPath postAuthPath: postAuthPath,
wildcard
}) })
.returning(); .returning();

View File

@@ -16,8 +16,11 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { tlsNameSchema } from "@server/lib/schemas"; import {
import { subdomainSchema } from "@server/lib/schemas"; tlsNameSchema,
subdomainSchema,
wildcardSubdomainSchema
} from "@server/lib/schemas";
import { registry } from "@server/openApi"; import { registry } from "@server/openApi";
import { OpenAPITags } from "@server/openApi"; import { OpenAPITags } from "@server/openApi";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
@@ -73,7 +76,10 @@ const updateHttpResourceBodySchema = z
.refine( .refine(
(data) => { (data) => {
if (data.subdomain) { if (data.subdomain) {
return subdomainSchema.safeParse(data.subdomain).success; return (
subdomainSchema.safeParse(data.subdomain).success ||
wildcardSubdomainSchema.safeParse(data.subdomain).success
);
} }
return true; return true;
}, },
@@ -318,6 +324,22 @@ async function updateHttpResource(
} }
} }
// Wildcard subdomains are a paid feature
if (updateData.subdomain && updateData.subdomain.includes("*")) {
const isLicensed = await isLicensedOrSubscribed(
resource.orgId,
tierMatrix.wildcardSubdomain
);
if (!isLicensed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Wildcard subdomains are not supported on your current plan. Please upgrade to access this feature."
)
);
}
}
if (updateData.domainId) { if (updateData.domainId) {
const domainId = updateData.domainId; const domainId = updateData.domainId;
@@ -362,7 +384,11 @@ async function updateHttpResource(
); );
} }
const { fullDomain, subdomain: finalSubdomain } = domainResult; const {
fullDomain,
subdomain: finalSubdomain,
wildcard
} = domainResult;
logger.debug(`Full domain: ${fullDomain}`); logger.debug(`Full domain: ${fullDomain}`);
@@ -419,7 +445,7 @@ async function updateHttpResource(
if (fullDomain && fullDomain !== resource.fullDomain) { if (fullDomain && fullDomain !== resource.fullDomain) {
await db await db
.update(resources) .update(resources)
.set({ fullDomain }) .set({ fullDomain, wildcard })
.where(eq(resources.resourceId, resource.resourceId)); .where(eq(resources.resourceId, resource.resourceId));
} }

View File

@@ -27,6 +27,7 @@ import { cn } from "@/lib/cn";
import { import {
finalizeSubdomainSanitize, finalizeSubdomainSanitize,
isValidSubdomainStructure, isValidSubdomainStructure,
isWildcardSubdomain,
sanitizeInputRaw, sanitizeInputRaw,
validateByDomainType validateByDomainType
} from "@/lib/subdomain-utils"; } from "@/lib/subdomain-utils";
@@ -77,6 +78,7 @@ interface DomainPickerProps {
subdomain?: string; subdomain?: string;
fullDomain: string; fullDomain: string;
baseDomain: string; baseDomain: string;
wildcard?: boolean;
} | null } | null
) => void; ) => void;
cols?: number; cols?: number;
@@ -85,6 +87,7 @@ interface DomainPickerProps {
defaultSubdomain?: string | null; defaultSubdomain?: string | null;
defaultDomainId?: string | null; defaultDomainId?: string | null;
warnOnProvidedDomain?: boolean; warnOnProvidedDomain?: boolean;
allowWildcard?: boolean;
} }
export default function DomainPicker({ export default function DomainPicker({
@@ -95,23 +98,33 @@ export default function DomainPicker({
defaultSubdomain, defaultSubdomain,
defaultFullDomain, defaultFullDomain,
defaultDomainId, defaultDomainId,
warnOnProvidedDomain = false warnOnProvidedDomain = false,
allowWildcard = false
}: DomainPickerProps) { }: DomainPickerProps) {
const { env } = useEnvContext(); const { env } = useEnvContext();
const { user } = useUserContext(); const { user } = useUserContext();
const api = createApiClient({ env }); const api = createApiClient({ env });
const t = useTranslations(); const t = useTranslations();
const { hasSaasSubscription } = usePaidStatus(); const { hasSaasSubscription, isPaidUser } = usePaidStatus();
const requiresPaywall = const requiresPaywall =
build === "saas" && build === "saas" &&
!hasSaasSubscription(tierMatrix[TierFeature.DomainNamespaces]) && !hasSaasSubscription(tierMatrix[TierFeature.DomainNamespaces]) &&
new Date(user.dateCreated) > new Date("2026-04-13"); new Date(user.dateCreated) > new Date("2026-04-13");
const wildcardAllowed =
allowWildcard && isPaidUser(tierMatrix[TierFeature.WildcardSubdomain]);
const { data = [], isLoading: loadingDomains } = useQuery( const { data = [], isLoading: loadingDomains } = useQuery(
orgQueries.domains({ orgId }) orgQueries.domains({ orgId })
); );
// Wildcard mode toggle — only relevant when allowWildcard is true, the
// user has a paid plan, and the selected base domain supports it.
const [wildcardMode, setWildcardMode] = useState(
wildcardAllowed && !!defaultSubdomain && isWildcardSubdomain(defaultSubdomain)
);
if (!env.flags.usePangolinDns) { if (!env.flags.usePangolinDns) {
hideFreeDomain = true; hideFreeDomain = true;
} }
@@ -180,13 +193,15 @@ export default function DomainPicker({
firstOrExistingDomain.type !== "cname" firstOrExistingDomain.type !== "cname"
? defaultSubdomain?.trim() || undefined ? defaultSubdomain?.trim() || undefined
: undefined; : undefined;
const isWc = allowWildcard && !!sub && isWildcardSubdomain(sub);
onDomainChange?.({ onDomainChange?.({
domainId: firstOrExistingDomain.domainId, domainId: firstOrExistingDomain.domainId,
type: "organization", type: "organization",
subdomain: sub, subdomain: sub,
fullDomain: sub ? `${sub}.${base}` : base, fullDomain: sub ? `${sub}.${base}` : base,
baseDomain: base baseDomain: base,
wildcard: isWc
}); });
} }
} }
@@ -285,7 +300,7 @@ export default function DomainPicker({
}, [userInput, debouncedCheckAvailability, selectedBaseDomain]); }, [userInput, debouncedCheckAvailability, selectedBaseDomain]);
const finalizeSubdomain = (sub: string, base: DomainOption): string => { const finalizeSubdomain = (sub: string, base: DomainOption): string => {
const sanitized = finalizeSubdomainSanitize(sub); const sanitized = finalizeSubdomainSanitize(sub, wildcardAllowed && wildcardMode);
if (!sanitized) { if (!sanitized) {
toast({ toast({
@@ -301,7 +316,8 @@ export default function DomainPicker({
base.type === "provided-search" base.type === "provided-search"
? "provided-search" ? "provided-search"
: "organization", : "organization",
domainType: base.domainType domainType: base.domainType,
allowWildcard: wildcardAllowed && wildcardMode
}); });
if (!ok) { if (!ok) {
@@ -330,7 +346,7 @@ export default function DomainPicker({
}; };
const handleSubdomainChange = (value: string) => { const handleSubdomainChange = (value: string) => {
const raw = sanitizeInputRaw(value); const raw = sanitizeInputRaw(value, wildcardAllowed && wildcardMode);
setSubdomainInput(raw); setSubdomainInput(raw);
setSelectedProvidedDomain(null); setSelectedProvidedDomain(null);
@@ -338,13 +354,15 @@ export default function DomainPicker({
const fullDomain = raw const fullDomain = raw
? `${raw}.${selectedBaseDomain.domain}` ? `${raw}.${selectedBaseDomain.domain}`
: selectedBaseDomain.domain; : selectedBaseDomain.domain;
const isWc = wildcardAllowed && wildcardMode && isWildcardSubdomain(raw);
onDomainChange?.({ onDomainChange?.({
domainId: selectedBaseDomain.domainId!, domainId: selectedBaseDomain.domainId!,
type: "organization", type: "organization",
subdomain: raw || undefined, subdomain: raw || undefined,
fullDomain, fullDomain,
baseDomain: selectedBaseDomain.domain baseDomain: selectedBaseDomain.domain,
wildcard: isWc
}); });
} }
}; };
@@ -366,6 +384,18 @@ export default function DomainPicker({
const handleBaseDomainSelect = (option: DomainOption) => { const handleBaseDomainSelect = (option: DomainOption) => {
let sub = subdomainInput; let sub = subdomainInput;
// Wildcard mode is not applicable for cname or provided-search domains,
// or when the user doesn't have a paid plan.
const newWildcardMode =
wildcardAllowed &&
wildcardMode &&
option.type === "organization" &&
option.domainType !== "cname";
if (newWildcardMode !== wildcardMode) {
setWildcardMode(newWildcardMode);
}
if (sub && sub.trim() !== "") { if (sub && sub.trim() !== "") {
sub = finalizeSubdomain(sub, option) || ""; sub = finalizeSubdomain(sub, option) || "";
setSubdomainInput(sub); setSubdomainInput(sub);
@@ -389,6 +419,7 @@ export default function DomainPicker({
} }
const fullDomain = sub ? `${sub}.${option.domain}` : option.domain; const fullDomain = sub ? `${sub}.${option.domain}` : option.domain;
const isWc = newWildcardMode && !!sub && isWildcardSubdomain(sub);
if (option.type === "provided-search") { if (option.type === "provided-search") {
onDomainChange?.(null); // prevent the modal from closing with `<subdomain>.Free Provided domain` onDomainChange?.(null); // prevent the modal from closing with `<subdomain>.Free Provided domain`
@@ -402,7 +433,8 @@ export default function DomainPicker({
? sub || undefined ? sub || undefined
: undefined, : undefined,
fullDomain, fullDomain,
baseDomain: option.domain baseDomain: option.domain,
wildcard: isWc
}); });
} }
}; };
@@ -431,7 +463,8 @@ export default function DomainPicker({
selectedBaseDomain.type === "provided-search" selectedBaseDomain.type === "provided-search"
? "provided-search" ? "provided-search"
: "organization", : "organization",
domainType: selectedBaseDomain.domainType domainType: selectedBaseDomain.domainType,
allowWildcard: wildcardAllowed && wildcardMode
}) })
: true; : true;
@@ -439,6 +472,31 @@ export default function DomainPicker({
selectedBaseDomain && selectedBaseDomain &&
selectedBaseDomain.type === "organization" && selectedBaseDomain.type === "organization" &&
selectedBaseDomain.domainType !== "cname"; selectedBaseDomain.domainType !== "cname";
// Wildcard toggle is shown when the caller opts in and the selected domain
// supports it (ns or wildcard type). Shown even when unpaid so we can
// render a disabled state explaining it's a paid feature.
const showWildcardToggle =
allowWildcard &&
showSubdomainInput &&
(selectedBaseDomain?.domainType === "ns" ||
selectedBaseDomain?.domainType === "wildcard");
const handleWildcardModeChange = (enabled: boolean) => {
setWildcardMode(enabled);
// Reset subdomain input when toggling modes to avoid invalid state
setSubdomainInput("");
if (selectedBaseDomain?.type === "organization") {
onDomainChange?.({
domainId: selectedBaseDomain.domainId!,
type: "organization",
subdomain: undefined,
fullDomain: selectedBaseDomain.domain,
baseDomain: selectedBaseDomain.domain,
wildcard: enabled ? true : false
});
}
};
const showProvidedDomainSearch = const showProvidedDomainSearch =
selectedBaseDomain?.type === "provided-search"; selectedBaseDomain?.type === "provided-search";
@@ -463,9 +521,35 @@ export default function DomainPicker({
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="subdomain-input"> <div className="flex items-center justify-between">
{t("domainPickerSubdomainLabel")} <Label htmlFor="subdomain-input">
</Label> {t("domainPickerSubdomainLabel")}
</Label>
{showWildcardToggle && (
<button
type="button"
onClick={() =>
wildcardAllowed &&
handleWildcardModeChange(!wildcardMode)
}
title={
!wildcardAllowed
? t("domainPickerWildcardPaidOnly")
: undefined
}
className={cn(
"text-xs px-2 py-0.5 rounded-full border transition-colors",
!wildcardAllowed
? "opacity-50 cursor-not-allowed border-input text-muted-foreground"
: wildcardMode
? "bg-primary text-primary-foreground border-primary"
: "bg-transparent text-muted-foreground border-input hover:border-primary hover:text-primary"
)}
>
{t("domainPickerWildcard")}
</button>
)}
</div>
<Input <Input
id="subdomain-input" id="subdomain-input"
value={ value={
@@ -477,7 +561,9 @@ export default function DomainPicker({
showProvidedDomainSearch showProvidedDomainSearch
? "" ? ""
: showSubdomainInput : showSubdomainInput
? "" ? wildcardMode
? "*.level1"
: ""
: t("domainPickerNotAvailableForCname") : t("domainPickerNotAvailableForCname")
} }
disabled={ disabled={
@@ -498,7 +584,7 @@ export default function DomainPicker({
/> />
{showSubdomainInput && {showSubdomainInput &&
subdomainInput && subdomainInput &&
!isValidSubdomainStructure(subdomainInput) && ( !isValidSubdomainStructure(subdomainInput, wildcardAllowed && wildcardMode) && (
<p className="text-sm text-red-500"> <p className="text-sm text-red-500">
{t("domainPickerInvalidSubdomainStructure")} {t("domainPickerInvalidSubdomainStructure")}
</p> </p>

View File

@@ -5,16 +5,58 @@ export const MULTI_LABEL_RE = /^[\p{L}\p{N}-]+(\.[\p{L}\p{N}-]+)*$/u; // ns/wild
export const SINGLE_LABEL_STRICT_RE = export const SINGLE_LABEL_STRICT_RE =
/^[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?$/u; // start/end alnum /^[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?$/u; // start/end alnum
export function sanitizeInputRaw(input: string): string { /**
* A wildcard subdomain is either bare "*" or "*.label1.label2…" where every
* label after the dot is a valid hostname label. This mirrors the shape that
* the server's `wildcardSubdomainSchema` accepts.
*/
export const WILDCARD_SUBDOMAIN_RE =
/^\*(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
export function isWildcardSubdomain(input: string): boolean {
return WILDCARD_SUBDOMAIN_RE.test(input);
}
export function sanitizeInputRaw(input: string, allowWildcard = false): string {
if (!input) return ""; if (!input) return "";
// When wildcard mode is active, preserve a leading "* " / "*." prefix and
// only sanitize the remainder so the user can type "*.level1" naturally.
if (allowWildcard && input.startsWith("*")) {
const rest = input.slice(1);
const sanitizedRest = rest
.toLowerCase()
.normalize("NFC")
.replace(/[^\p{L}\p{N}.-]/gu, "");
return "*" + sanitizedRest;
}
return input return input
.toLowerCase() .toLowerCase()
.normalize("NFC") // normalize Unicode .normalize("NFC") // normalize Unicode
.replace(/[^\p{L}\p{N}.-]/gu, ""); // allow Unicode letters, numbers, dot, hyphen .replace(/[^\p{L}\p{N}.-]/gu, ""); // allow Unicode letters, numbers, dot, hyphen
} }
export function finalizeSubdomainSanitize(input: string): string { export function finalizeSubdomainSanitize(
input: string,
allowWildcard = false
): string {
if (!input) return ""; if (!input) return "";
// If the input is a valid wildcard and the caller permits it, keep it as-is
// (just lowercase the non-wildcard labels).
if (allowWildcard && input.startsWith("*")) {
const rest = input.slice(1); // everything after the leading "*"
const sanitizedRest = rest
.toLowerCase()
.normalize("NFC")
.replace(/[^\p{L}\p{N}.-]/gu, "")
.replace(/\.{2,}/g, ".")
.replace(/^-+|-+$/g, "")
.replace(/(\.-)|(-\.)/g, ".");
const candidate = "*" + sanitizedRest;
// Return only if it still forms a valid wildcard after sanitizing
return isWildcardSubdomain(candidate) ? candidate : "";
}
return input return input
.toLowerCase() .toLowerCase()
.normalize("NFC") .normalize("NFC")
@@ -30,6 +72,7 @@ export function validateByDomainType(
domainType: { domainType: {
type: "provided-search" | "organization"; type: "provided-search" | "organization";
domainType?: "ns" | "cname" | "wildcard"; domainType?: "ns" | "cname" | "wildcard";
allowWildcard?: boolean;
} }
): boolean { ): boolean {
if (!domainType) return false; if (!domainType) return false;
@@ -46,6 +89,12 @@ export function validateByDomainType(
domainType.domainType === "wildcard" domainType.domainType === "wildcard"
) { ) {
if (subdomain === "") return true; if (subdomain === "") return true;
// Wildcard subdomain validation (only when caller opts in)
if (domainType.allowWildcard && subdomain.startsWith("*")) {
return isWildcardSubdomain(subdomain);
}
if (!MULTI_LABEL_RE.test(subdomain)) return false; if (!MULTI_LABEL_RE.test(subdomain)) return false;
const labels = subdomain.split("."); const labels = subdomain.split(".");
return labels.every( return labels.every(
@@ -57,10 +106,19 @@ export function validateByDomainType(
return false; return false;
} }
export const isValidSubdomainStructure = (input: string): boolean => { export const isValidSubdomainStructure = (
input: string,
allowWildcard = false
): boolean => {
if (!input) return false;
// A valid wildcard subdomain is structurally valid when the caller allows it
if (allowWildcard && input.startsWith("*")) {
return isWildcardSubdomain(input);
}
const regex = /^(?!-)([\p{L}\p{N}-]{1,63})(?<!-)$/u; const regex = /^(?!-)([\p{L}\p{N}-]{1,63})(?<!-)$/u;
if (!input) return false;
if (input.includes("..")) return false; if (input.includes("..")) return false;
return input.split(".").every((label) => regex.test(label)); return input.split(".").every((label) => regex.test(label));