Skip to content
File

Blob: src/shared/slugs.ts

typescript49 lines
1// Shared slug policy used by namespace and repository slugs. Browser-safe so
2// the same validator runs in SSR pages and Worker validation paths.
3//
4// The regex enforces lowercase ASCII alphanumerics with internal dashes.
5// Length is bounded at 1..40. Reserved slugs cover Worker-owned routes and
6// common static paths so that creating a namespace cannot shadow existing
7// surfaces.
8 
9const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
10export const SLUG_MAX_LENGTH = 40;
11 
12export const RESERVED_SLUGS: readonly string[] = [
13 "auth",
14 "sign-in",
15 "sign-out",
16 "api",
17 "assets",
18 "_cache",
19 "_routes",
20 "favicon.ico",
21 "robots.txt",
22];
23 
24const reservedSet: ReadonlySet<string> = new Set(RESERVED_SLUGS);
25 
26export function isValidSlug(input: string): boolean {
27 if (typeof input !== "string") return false;
28 if (input.length === 0 || input.length > SLUG_MAX_LENGTH) return false;
29 if (!SLUG_PATTERN.test(input)) return false;
30 if (reservedSet.has(input)) return false;
31 return true;
32}
33 
34export type SlugValidation =
35 | { ok: true; slug: string }
36 | { ok: false; reason: "format" | "reserved" | "length" };
37 
38// Granular result so the route handler can decide which message to surface
39// (taken vs format vs reserved) without re-checking the regex.
40export function validateSlugForRoute(input: string): SlugValidation {
41 if (typeof input !== "string" || input.length === 0) {
42 return { ok: false, reason: "length" };
43 }
44 if (input.length > SLUG_MAX_LENGTH) return { ok: false, reason: "length" };
45 if (reservedSet.has(input)) return { ok: false, reason: "reserved" };
46 if (!SLUG_PATTERN.test(input)) return { ok: false, reason: "format" };
47 return { ok: true, slug: input };
48}