mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
271 lines
7.9 KiB
TypeScript
271 lines
7.9 KiB
TypeScript
import { ConvexError } from "convex/values";
|
|
|
|
// Slug shape rules:
|
|
// - Lowercase letters, digits, and single hyphens only.
|
|
// - Must start and end with a letter or digit.
|
|
// - No consecutive hyphens ("--", "---", ...).
|
|
// - Length 3..96 (URL-friendly, but long enough for source-backed upstream slugs).
|
|
//
|
|
// The pattern enforces first/last char class and forbids consecutive hyphens
|
|
// via a negative lookahead. Length bounds are checked separately so we can
|
|
// emit precise error messages.
|
|
const SLUG_PATTERN = /^[a-z0-9](?:(?!--)[a-z0-9-])*[a-z0-9]$/;
|
|
|
|
const MIN_SLUG_LENGTH = 3;
|
|
const MAX_SLUG_LENGTH = 96;
|
|
|
|
// Reserved slugs. These are blocked because they would:
|
|
// 1. Clash semantically with top-level routes under src/routes/*.
|
|
// 2. Allow brand/role impersonation (e.g. "official", "clawhub").
|
|
// 3. Lock future route expansion (e.g. "api", "auth", "oauth").
|
|
//
|
|
// Keep this list in sync with:
|
|
// - src/routes/*.tsx top-level segments
|
|
// - brand names shipped in README.md
|
|
const RESERVED_SKILL_SLUGS: ReadonlySet<string> = new Set([
|
|
// Current top-level route segments under src/routes/.
|
|
"about",
|
|
"account-banned",
|
|
"admin",
|
|
"cli",
|
|
"dashboard",
|
|
"import",
|
|
"management",
|
|
"orgs",
|
|
"packages",
|
|
"plugins",
|
|
"official",
|
|
"publishers",
|
|
"publish",
|
|
"publish-plugin",
|
|
"publish-skill",
|
|
"search",
|
|
"settings",
|
|
"skills",
|
|
"souls",
|
|
"stars",
|
|
"u",
|
|
"upload",
|
|
"users",
|
|
// Reserved for likely future additions.
|
|
"api",
|
|
"auth",
|
|
"oauth",
|
|
"callback",
|
|
"login",
|
|
"logout",
|
|
"signin",
|
|
"signout",
|
|
"signup",
|
|
"register",
|
|
"docs",
|
|
"doc",
|
|
"help",
|
|
"support",
|
|
"status",
|
|
"health",
|
|
"blog",
|
|
"news",
|
|
"pricing",
|
|
"terms",
|
|
"privacy",
|
|
"legal",
|
|
"contact",
|
|
"home",
|
|
"explore",
|
|
// Brand and project names.
|
|
"openclaw",
|
|
"clawhub",
|
|
"clawd",
|
|
"clawdbot",
|
|
"onlycrabs",
|
|
"soulhub",
|
|
// Generic identity / role words.
|
|
"me",
|
|
"self",
|
|
"system",
|
|
"root",
|
|
"owner",
|
|
"staff",
|
|
"team",
|
|
"mod",
|
|
"moderator",
|
|
// Reserved CRUD/action words that would make URLs ambiguous.
|
|
"new",
|
|
"edit",
|
|
"delete",
|
|
"create",
|
|
"update",
|
|
"remove",
|
|
"public",
|
|
"private",
|
|
"internal",
|
|
// Literals that would be confusing in URLs.
|
|
"null",
|
|
"undefined",
|
|
"true",
|
|
"false",
|
|
]);
|
|
|
|
// Protected affixes block namespace squatting such as "openclaw-foo",
|
|
// "foo-openclaw", "official-foo", or "foo-official". Exact matches are
|
|
// already covered by RESERVED_SKILL_SLUGS.
|
|
const PROTECTED_SKILL_SLUG_AFFIXES = [
|
|
"openclaw",
|
|
"clawhub",
|
|
"clawd",
|
|
"clawdbot",
|
|
"onlycrabs",
|
|
"soulhub",
|
|
"official",
|
|
"verified",
|
|
"staff",
|
|
"admin",
|
|
"moderator",
|
|
] as const;
|
|
|
|
interface ValidateSlugOptions {
|
|
/**
|
|
* Bypass the reserved/protected namespace blocklists.
|
|
* Intended for admin migrations / internal seeding only.
|
|
*/
|
|
allowReserved?: boolean;
|
|
}
|
|
|
|
export const SKILL_SLUG_CONSTRAINTS = {
|
|
minLength: MIN_SLUG_LENGTH,
|
|
maxLength: MAX_SLUG_LENGTH,
|
|
pattern: SLUG_PATTERN,
|
|
reserved: RESERVED_SKILL_SLUGS,
|
|
protectedAffixes: PROTECTED_SKILL_SLUG_AFFIXES,
|
|
} as const;
|
|
|
|
/**
|
|
* Lowercase and trim a slug. Does not throw.
|
|
*
|
|
* Safe to call on any read-path input (query by slug, redirect lookup, ...)
|
|
* without rejecting legacy data.
|
|
*/
|
|
export function normalizeSkillSlug(raw: string | undefined | null): string {
|
|
return (raw ?? "").trim().toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* Variant that returns null when the input normalizes to an empty string.
|
|
* Useful at read-path call sites that want to short-circuit lookup.
|
|
*/
|
|
export function normalizeSkillSlugOrNull(raw: string | undefined | null): string | null {
|
|
const normalized = normalizeSkillSlug(raw);
|
|
return normalized.length ? normalized : null;
|
|
}
|
|
|
|
/**
|
|
* Check whether a string already matches the full slug shape rules.
|
|
* Returns true only when the value is a plausible slug (length, pattern).
|
|
*
|
|
* Note: this intentionally does NOT consult the reserved-word blocklist
|
|
* because legacy rows may still carry reserved slugs and we want to
|
|
* keep them readable. It DOES enforce the current min-length floor
|
|
* (MIN_SLUG_LENGTH) and is therefore only appropriate for call sites
|
|
* that treat a value as a "newly-shaped" slug. For read-only lookups
|
|
* (search, redirect) that must stay discoverable for pre-existing
|
|
* short slugs, use isSearchableSkillSlugShape instead.
|
|
*/
|
|
export function isValidSkillSlugShape(value: string | undefined | null): boolean {
|
|
const normalized = normalizeSkillSlug(value);
|
|
if (normalized.length < MIN_SLUG_LENGTH || normalized.length > MAX_SLUG_LENGTH) {
|
|
return false;
|
|
}
|
|
return SLUG_PATTERN.test(normalized);
|
|
}
|
|
|
|
/**
|
|
* Lenient shape check used by read paths (search exact-slug optimization,
|
|
* redirect lookups, etc.).
|
|
*
|
|
* Unlike isValidSkillSlugShape, this predicate intentionally omits:
|
|
* - the min-length floor (legacy rows with 1- or 2-char slugs must stay
|
|
* retrievable via the by_slug fast path),
|
|
* - the max-length cap (rows persisted before MAX_SLUG_LENGTH was
|
|
* introduced may exceed 48 chars and must remain lookup-able; the
|
|
* indexed point lookup for a missing key is cheap, and upstream
|
|
* request-body limits bound the practical query length),
|
|
* - the reserved-word blocklist (grandfathered data must stay readable).
|
|
*
|
|
* Write paths MUST continue to use assertValidSkillSlug, which enforces
|
|
* the full validation surface (length floor + length cap + pattern +
|
|
* reserved blocklist).
|
|
*/
|
|
export function isSearchableSkillSlugShape(value: string | undefined | null): boolean {
|
|
const normalized = normalizeSkillSlug(value);
|
|
if (normalized.length === 0) {
|
|
return false;
|
|
}
|
|
// Single-character legacy slug: a bare [a-z0-9] is searchable. The full
|
|
// SLUG_PATTERN requires >=2 chars (separate first/last classes), so we
|
|
// handle the single-char case explicitly before delegating to it.
|
|
if (normalized.length === 1) {
|
|
return /^[a-z0-9]$/.test(normalized);
|
|
}
|
|
return SLUG_PATTERN.test(normalized);
|
|
}
|
|
|
|
/**
|
|
* Returns a normalized slug or throws ConvexError describing the first
|
|
* violation encountered. Use this on every write path (publish/rename).
|
|
*/
|
|
export function assertValidSkillSlug(
|
|
rawSlug: string | undefined | null,
|
|
options: ValidateSlugOptions = {},
|
|
): string {
|
|
const normalized = normalizeSkillSlug(rawSlug);
|
|
|
|
if (!normalized) {
|
|
throw new ConvexError("Slug is required.");
|
|
}
|
|
if (normalized.length < MIN_SLUG_LENGTH) {
|
|
throw new ConvexError(`Slug must be at least ${MIN_SLUG_LENGTH} characters.`);
|
|
}
|
|
if (normalized.length > MAX_SLUG_LENGTH) {
|
|
throw new ConvexError(`Slug must be at most ${MAX_SLUG_LENGTH} characters.`);
|
|
}
|
|
if (!SLUG_PATTERN.test(normalized)) {
|
|
throw new ConvexError(
|
|
"Slug must start and end with a letter or digit, contain only lowercase letters, " +
|
|
"digits, and single hyphens, and not contain consecutive hyphens.",
|
|
);
|
|
}
|
|
if (!options.allowReserved && RESERVED_SKILL_SLUGS.has(normalized)) {
|
|
throw new ConvexError(`"${normalized}" is reserved and cannot be used as a slug.`);
|
|
}
|
|
if (!options.allowReserved) {
|
|
const protectedAffix = getProtectedSkillSlugAffix(normalized);
|
|
if (protectedAffix) {
|
|
throw new ConvexError(
|
|
`"${normalized}" uses the protected "${protectedAffix}" slug namespace. ` +
|
|
`Choose a slug that does not start with "${protectedAffix}-" or end with ` +
|
|
`"-${protectedAffix}".`,
|
|
);
|
|
}
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function getProtectedSkillSlugAffix(normalizedSlug: string): string | null {
|
|
for (const affix of PROTECTED_SKILL_SLUG_AFFIXES) {
|
|
if (normalizedSlug.startsWith(`${affix}-`) || normalizedSlug.endsWith(`-${affix}`)) {
|
|
return affix;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Convenience predicate: is the slug on the reserved/protected blocklist?
|
|
* Exposed so callers (e.g. admin tooling) can pre-check without a throw.
|
|
*/
|
|
export function isReservedSkillSlug(slug: string | undefined | null): boolean {
|
|
const normalized = normalizeSkillSlug(slug);
|
|
return RESERVED_SKILL_SLUGS.has(normalized) || getProtectedSkillSlugAffix(normalized) !== null;
|
|
}
|