NO BACKWARD COMPATIBILITY. NO MIGRATION. NO DEPRECATION.
We are building the best system possible. If old behavior is broken or incomplete:
- Replace it directly with the correct implementation
- Remove dead code that serves no purpose
- Refactor aggressively to achieve clean architecture
- No half-measures - validation either works completely or we fix it completely
This is version 0.1. We implement features cleanly as if building from scratch. No legacy concerns, no compatibility layers, no "TODO: migrate later". Even migration files that need to be edited should be edited instead of introducing a new patch migration.
| Aspect | Current Value | Location |
|---|---|---|
| First char | lowercase a-z | lib/validate/validators/fields.rs:170 |
| Remaining | lowercase a-z, 0-9, hyphen | lib/validate/validators/fields.rs:174 |
| Underscore | Not allowed | - |
| Min length | 1 (implicit) | - |
| Max length | None | - |
| Duplicates | 3 identical functions | fields.rs, init.rs, prompt.rs |
| Aspect | Current Value | Location |
|---|---|---|
| Regex | ^[a-zA-Z][a-zA-Z0-9_-]{2,19}$ |
lib/api/utils/validation.rs:13 |
| First char | letter (a-z, A-Z) | - |
| Remaining | alphanumeric, underscore, hyphen | - |
| Underscore | Allowed | - |
| Uppercase | Allowed (normalized to lowercase) | - |
| Min length | 3 | - |
| Max length | 20 | - |
| DB column | VARCHAR(100) | migrations/20250920224112 |
| Aspect | Current Value | Location |
|---|---|---|
| Namespace min | 3 | lib/defaults.rs:83 |
| Namespace max | 100 | lib/defaults.rs:84 |
| Namespace chars | alphanumeric, hyphen, underscore | lib/defaults.rs:128 |
| Artifact min | 1 | lib/defaults.rs:85 |
| Artifact max | 255 | lib/defaults.rs:86 |
| Artifact chars | Any (only no leading/trailing hyphen) | lib/defaults.rs:131-135 |
- Inconsistent rules: tool-cli is strict, backend-registries is permissive, backend-users is in between
- Uppercase allowed in backend-users but not tool-cli
- Underscores allowed in backend-users and backend-registries but not tool-cli
- No max length in tool-cli
- Artifact names too permissive in backend-registries (allows almost anything)
- Duplicate code in tool-cli (3 identical validation functions)
- OAuth incompatibility: GitHub doesn't allow underscores, Google doesn't allow hyphens
One pattern for everything:
Pattern: ^[a-z][a-z0-9-]{2,63}$
| Rule | Value | Rationale |
|---|---|---|
| First char | lowercase letter (a-z) | URL-safe, DNS-safe, prevents numeric conflicts |
| Remaining | lowercase letters, digits, hyphens | GitHub-compatible (no underscores), URL-friendly |
| Min length | 3 | Prevents squatting on short names |
| Max length | 64 | DNS label max (63 + null), power of 2, practical limit |
| Uppercase | Reject (not just normalize) | Simplicity, no case confusion |
| Underscores | Not allowed | GitHub incompatible, URL-unfriendly |
This applies to:
- Usernames
- Organization slugs
- Namespaces
- Package/artifact names
When users login via GitHub/Google:
- Fetch their username from provider
- Normalize: lowercase, replace non-alphanumeric with hyphens, collapse consecutive hyphens, trim hyphens
- If result is valid, use it
- If result is invalid or taken, prompt user to choose a username
fn normalize_oauth_username(external: &str) -> String {
let normalized: String = external
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
// Collapse consecutive hyphens and trim
let mut result = String::new();
let mut prev_hyphen = false;
for c in normalized.chars() {
if c == '-' {
if !prev_hyphen && !result.is_empty() {
result.push(c);
}
prev_hyphen = true;
} else {
result.push(c);
prev_hyphen = false;
}
}
result.trim_matches('-').to_string()
}File: services/backend-users/lib/api/utils/validation.rs
Delete lines 13-14 (old regex):
pub static ref USERNAME_REGEX: Regex = Regex::new(r"^[a-zA-Z][a-zA-Z0-9_-]{2,19}$").unwrap();Replace with:
pub static ref USERNAME_REGEX: Regex = Regex::new(r"^[a-z][a-z0-9-]{2,63}$").unwrap();Update validate_username() (lines 49-57):
/// Validates username format (3-64 chars, lowercase, starts with letter, no underscores)
pub fn validate_username(username: &str) -> Result<(), ValidationError> {
if !USERNAME_REGEX.is_match(username) {
let mut error = ValidationError::new("invalid_username");
error.message = Some("Username must be 3-64 characters, start with a lowercase letter, and contain only lowercase letters, numbers, and hyphens".into());
return Err(error);
}
Ok(())
}Update validate_slug() (lines 59-67) - same pattern:
/// Validates slug format (3-64 chars, lowercase, starts with letter, no underscores)
pub fn validate_slug(slug: &str) -> Result<(), ValidationError> {
if !USERNAME_REGEX.is_match(slug) {
let mut error = ValidationError::new("invalid_slug");
error.message = Some("Slug must be 3-64 characters, start with a lowercase letter, and contain only lowercase letters, numbers, and hyphens".into());
return Err(error);
}
Ok(())
}Update tests (lines 280-290):
#[test]
fn test_username_validation() {
// Valid
assert!(validate_username("user123").is_ok());
assert!(validate_username("user-name").is_ok());
assert!(validate_username("abc").is_ok());
assert!(validate_username(&"a".repeat(64)).is_ok());
// Invalid - too short
assert!(validate_username("ab").is_err());
// Invalid - too long
assert!(validate_username(&"a".repeat(65)).is_err());
// Invalid - uppercase
assert!(validate_username("User123").is_err());
assert!(validate_username("USER").is_err());
// Invalid - underscore (no longer allowed)
assert!(validate_username("user_name").is_err());
// Invalid - starts with number
assert!(validate_username("123user").is_err());
// Invalid - starts with hyphen
assert!(validate_username("-user").is_err());
// Invalid - special characters
assert!(validate_username("user@123").is_err());
assert!(validate_username("user.name").is_err());
}File: services/backend-users/lib/api/types/auth.rs
Update RegisterRequest (line 15):
#[validate(length(min = 3, max = 64, message = "Username must be 3-64 characters"), custom(function = "crate::api::utils::validation::validate_username"))]
pub username: String,File: services/backend-users/lib/api/types/organization.rs
Update CreateOrganizationRequest (line 32):
#[validate(length(min = 3, max = 64, message = "Slug must be 3-64 characters"), custom(function = "crate::api::utils::validation::validate_slug"))]
pub slug: String,File: services/backend-users/lib/api/handlers/auth.rs
Update normalization (lines 74-76) to reject uppercase instead of normalizing:
// Normalize email only - username must already be lowercase
let email = request.email.trim().to_lowercase();
let username = request.username.trim();
// Reject if username contains uppercase (validation will catch format, this is for clear error)
if username.chars().any(|c| c.is_ascii_uppercase()) {
return Err(ApiError::validation("Username must be lowercase"));
}File: services/backend-users/migrations/20250920224112_create_users_table.up.sql
Change line 4:
username VARCHAR(64) NOT NULL UNIQUE,File: services/backend-users/migrations/20250920224117_create_organizations_table.up.sql
Change line 4:
slug VARCHAR(64) NOT NULL UNIQUE,File: services/backend-users/lib/api/utils/validation.rs
Add new function:
/// Normalize external OAuth username to our format
pub fn normalize_oauth_username(external: &str) -> String {
let normalized: String = external
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
// Collapse consecutive hyphens and trim
let mut result = String::new();
let mut prev_hyphen = false;
for c in normalized.chars() {
if c == '-' {
if !prev_hyphen && !result.is_empty() {
result.push(c);
}
prev_hyphen = true;
} else {
result.push(c);
prev_hyphen = false;
}
}
result.trim_matches('-').to_string()
}
/// Check if normalized username is valid
pub fn is_valid_normalized_username(username: &str) -> bool {
USERNAME_REGEX.is_match(username)
}File: services/backend-registries/lib/defaults.rs
Update constants (lines 83-86):
pub const MIN_NAME_LENGTH: usize = 3;
pub const MAX_NAME_LENGTH: usize = 64;File: services/backend-registries/lib/defaults.rs
Replace is_valid_namespace() (lines 125-129):
/// Validate namespace format (3-64 chars, lowercase, starts with letter, no underscores)
pub fn is_valid_namespace(namespace: &str) -> bool {
let len = namespace.len();
if len < MIN_NAME_LENGTH || len > MAX_NAME_LENGTH {
return false;
}
let mut chars = namespace.chars();
match chars.next() {
Some(c) if c.is_ascii_lowercase() => {}
_ => return false,
}
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}Replace is_valid_artifact_name() (lines 131-136):
/// Validate artifact name format (3-64 chars, lowercase, starts with letter, no underscores)
pub fn is_valid_artifact_name(name: &str) -> bool {
let len = name.len();
if len < MIN_NAME_LENGTH || len > MAX_NAME_LENGTH {
return false;
}
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_lowercase() => {}
_ => return false,
}
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}Update default_slug() (lines 103-110):
/// Generate URL-friendly slug from name
pub fn default_slug(name: &str) -> String {
let slug: String = name
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
// Collapse consecutive hyphens and trim
let mut result = String::new();
let mut prev_hyphen = false;
for c in slug.chars() {
if c == '-' {
if !prev_hyphen && !result.is_empty() {
result.push(c);
}
prev_hyphen = true;
} else {
result.push(c);
prev_hyphen = false;
}
}
result.trim_matches('-').to_string()
}File: services/backend-registries/lib/defaults.rs
Replace tests:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_slug() {
assert_eq!(default_slug("My Cool Tool"), "my-cool-tool");
assert_eq!(default_slug("tool_name"), "tool-name");
assert_eq!(default_slug("UPPERCASE"), "uppercase");
assert_eq!(default_slug("--double--hyphen--"), "double-hyphen");
assert_eq!(default_slug("special@#$chars"), "special-chars");
assert_eq!(default_slug(" spaces "), "spaces");
}
#[test]
fn test_is_valid_namespace() {
// Valid
assert!(is_valid_namespace("abc"));
assert!(is_valid_namespace("my-namespace"));
assert!(is_valid_namespace("user123"));
assert!(is_valid_namespace(&"a".repeat(64)));
// Invalid - too short
assert!(!is_valid_namespace("ab"));
// Invalid - too long
assert!(!is_valid_namespace(&"a".repeat(65)));
// Invalid - uppercase
assert!(!is_valid_namespace("MyNamespace"));
// Invalid - underscore
assert!(!is_valid_namespace("my_namespace"));
// Invalid - starts with number
assert!(!is_valid_namespace("123namespace"));
// Invalid - starts with hyphen
assert!(!is_valid_namespace("-namespace"));
}
#[test]
fn test_is_valid_artifact_name() {
// Valid
assert!(is_valid_artifact_name("abc"));
assert!(is_valid_artifact_name("my-tool"));
assert!(is_valid_artifact_name("tool123"));
assert!(is_valid_artifact_name(&"a".repeat(64)));
// Invalid - too short
assert!(!is_valid_artifact_name("ab"));
// Invalid - too long
assert!(!is_valid_artifact_name(&"a".repeat(65)));
// Invalid - uppercase
assert!(!is_valid_artifact_name("MyTool"));
// Invalid - underscore
assert!(!is_valid_artifact_name("my_tool"));
// Invalid - starts with number
assert!(!is_valid_artifact_name("123tool"));
// Invalid - starts with hyphen
assert!(!is_valid_artifact_name("-tool"));
}
#[test]
fn test_is_valid_version() {
assert!(is_valid_version("1.0.0"));
assert!(is_valid_version("0.1.0-alpha"));
assert!(is_valid_version("2.0.0-rc.1+build.123"));
assert!(!is_valid_version("1.0"));
assert!(!is_valid_version("v1.0.0"));
assert!(!is_valid_version("01.0.0"));
}
#[test]
fn test_is_valid_visibility() {
assert!(is_valid_visibility("public"));
assert!(is_valid_visibility("private"));
assert!(is_valid_visibility("unlisted"));
assert!(!is_valid_visibility("secret"));
assert!(!is_valid_visibility(""));
}
}File: services/backend-registries/migrations/20250918093610_create_artifacts_table.up.sql
Update column definitions:
namespace VARCHAR(64) NOT NULL,
name VARCHAR(64) NOT NULL,
slug VARCHAR(64) NOT NULL,File: tool-cli/lib/validate/validators/fields.rs
Update is_valid_package_name() (lines 164-175):
/// Minimum package name length
pub const MIN_PACKAGE_NAME_LENGTH: usize = 3;
/// Maximum package name length
pub const MAX_PACKAGE_NAME_LENGTH: usize = 64;
/// Check if a package name is valid.
/// Rules: 3-64 chars, starts with lowercase letter, contains only lowercase letters, digits, hyphens
pub fn is_valid_package_name(name: &str) -> bool {
let len = name.len();
if len < MIN_PACKAGE_NAME_LENGTH || len > MAX_PACKAGE_NAME_LENGTH {
return false;
}
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_lowercase() => {}
_ => return false,
}
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}Update error message in validate_formats() (lines 76-87):
// Validate name format
if let Some(name) = &manifest.name
&& !is_valid_package_name(name)
{
result.errors.push(ValidationIssue {
code: ErrorCode::InvalidPackageName.into(),
message: "invalid package name".into(),
location: "manifest.json:name".into(),
details: format!("`{}` must be 3-64 lowercase alphanumeric chars with hyphens, starting with a letter", name),
help: Some("use format: my-package-name (3-64 chars)".into()),
});
}File: tool-cli/lib/handlers/tool/init.rs
Delete lines 758-769 (the duplicate is_valid_package_name function)
Add import at top of file:
use crate::validate::validators::fields::is_valid_package_name;Update error message (lines 162-168):
// Validate name format
if !is_valid_package_name(&pkg_name) {
return Err(ToolError::Generic(format!(
"Invalid package name \"{}\"\nName must be 3-64 characters, start with a lowercase letter, and contain only lowercase letters, numbers, and hyphens.",
pkg_name
)));
}File: tool-cli/lib/prompt.rs
Delete lines 183-199 (the duplicate is_valid_package_name function)
Add import at top of file:
use crate::validate::validators::fields::is_valid_package_name;Update validation message (lines 221-228):
.validate(|input: &String| {
if input.is_empty() {
Err("Package name is required")
} else if !is_valid_package_name(input) {
Err("Must be 3-64 lowercase letters, numbers, and hyphens, starting with a letter")
} else {
Ok(())
}
})File: tool-cli/lib/validate/tests.rs
Delete lines 7-17 (duplicate function)
Add import:
use crate::validate::validators::fields::is_valid_package_name;Update test (lines 19-29):
#[test]
fn test_valid_package_name() {
// Valid
assert!(is_valid_package_name("my-tool"));
assert!(is_valid_package_name("tool123"));
assert!(is_valid_package_name("abc"));
assert!(is_valid_package_name(&"a".repeat(64)));
// Invalid - too short
assert!(!is_valid_package_name("ab"));
assert!(!is_valid_package_name("a"));
// Invalid - too long
assert!(!is_valid_package_name(&"a".repeat(65)));
// Invalid - empty
assert!(!is_valid_package_name(""));
// Invalid - uppercase
assert!(!is_valid_package_name("My-Tool"));
assert!(!is_valid_package_name("TOOL"));
// Invalid - starts with digit
assert!(!is_valid_package_name("123tool"));
// Invalid - starts with hyphen
assert!(!is_valid_package_name("-tool"));
// Invalid - underscore
assert!(!is_valid_package_name("tool_name"));
// Invalid - special chars
assert!(!is_valid_package_name("tool@name"));
assert!(!is_valid_package_name("tool.name"));
}File: tool-cli/lib/lib.rs
Ensure is_valid_package_name is exported if needed by other crates.
File: tool-cli/lib/references.rs
The PluginRef struct is used for parsing tool references like namespace/tool-name@version.
This validation MUST match the unified standard.
Update constants (lines 17-21):
/// Regex pattern for validating namespace segments.
/// Rules: 3-64 chars, starts with lowercase letter, contains only lowercase letters, digits, hyphens
const NAMESPACE_PATTERN: &str = r"^[a-z][a-z0-9-]{2,63}$";
/// Regex pattern for validating name segments.
/// Rules: 3-64 chars, starts with lowercase letter, contains only lowercase letters, digits, hyphens
const NAME_PATTERN: &str = r"^[a-z][a-z0-9-]{2,63}$";Update validate_namespace() (lines 183-202):
fn validate_namespace(namespace: &str) -> ToolResult<()> {
if namespace.len() < 3 {
return Err(ToolError::InvalidReference(format!(
"Namespace '{}' must be at least 3 characters",
namespace
)));
}
if namespace.len() > 64 {
return Err(ToolError::InvalidReference(format!(
"Namespace '{}' exceeds 64 character limit",
namespace
)));
}
if !NAMESPACE_REGEX.is_match(namespace) {
return Err(ToolError::InvalidReference(format!(
"Namespace '{}' must start with lowercase letter and contain only lowercase letters, numbers, and hyphens",
namespace
)));
}
Ok(())
}Update validate_name() (lines 205-223):
fn validate_name(name: &str) -> ToolResult<()> {
if name.len() < 3 {
return Err(ToolError::InvalidReference(format!(
"Name '{}' must be at least 3 characters",
name
)));
}
if name.len() > 64 {
return Err(ToolError::InvalidReference(format!(
"Name '{}' exceeds 64 character limit",
name
)));
}
if !NAME_REGEX.is_match(name) {
return Err(ToolError::InvalidReference(format!(
"Name '{}' must start with lowercase letter and contain only lowercase letters, numbers, and hyphens",
name
)));
}
Ok(())
}Key changes:
- No underscores allowed (removed
_from pattern) - Minimum length: 3 chars (was 2 for namespace, 1 for name)
- Maximum length: 64 chars (was 50 for namespace, 100 for name)
- Error messages updated to remove mention of underscores
File: services/frontend-plugin.store/lib/validations/auth.ts (new file)
import { z } from "zod";
// Shared validation pattern - matches backend exactly
const usernamePattern = /^[a-z][a-z0-9-]{2,63}$/;
export const registerSchema = z
.object({
username: z
.string()
.min(3, "Username must be at least 3 characters")
.max(64, "Username must be at most 64 characters")
.regex(
usernamePattern,
"Username must be lowercase letters, numbers, and hyphens, starting with a letter"
),
email: z.string().email("Invalid email address"),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.max(128, "Password must be at most 128 characters"),
confirmPassword: z.string(),
terms: z.literal(true, {
errorMap: () => ({ message: "You must accept the terms and conditions" }),
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
export type RegisterFormData = z.infer<typeof registerSchema>;
export const loginSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(1, "Password is required"),
});
export type LoginFormData = z.infer<typeof loginSchema>;File: services/frontend-plugin.store/app/register/page.tsx
Replace the current useState-based form with React Hook Form + Zod:
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { registerSchema, RegisterFormData } from "@/lib/validations/auth";
import { apiClient } from "@/lib/api/client";
import { useState } from "react";
import { toast } from "sonner";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export default function RegisterPage() {
const [isLoading, setIsLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
defaultValues: {
username: "",
email: "",
password: "",
confirmPassword: "",
terms: false,
},
});
const onSubmit = async (data: RegisterFormData) => {
setIsLoading(true);
try {
await apiClient.register({
username: data.username,
email: data.email,
password: data.password,
});
setIsSuccess(true);
toast.success("Registration successful! Please check your email.");
} catch (error) {
toast.error(error instanceof Error ? error.message : "Registration failed");
} finally {
setIsLoading(false);
}
};
if (isSuccess) {
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Check your email</CardTitle>
<CardDescription>
We sent you a verification link. Please check your email to complete registration.
</CardDescription>
</CardHeader>
<CardFooter>
<Link href="/login" className="w-full">
<Button variant="outline" className="w-full">
Back to login
</Button>
</Link>
</CardFooter>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Create an account</CardTitle>
<CardDescription>Enter your details to get started</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
placeholder="my-username"
{...register("username")}
/>
{errors.username && (
<p className="text-sm text-destructive">{errors.username.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
{...register("email")}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
{...register("password")}
/>
{errors.password && (
<p className="text-sm text-destructive">{errors.password.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
{...register("confirmPassword")}
/>
{errors.confirmPassword && (
<p className="text-sm text-destructive">{errors.confirmPassword.message}</p>
)}
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="terms"
checked={watch("terms")}
onCheckedChange={(checked) => setValue("terms", checked === true)}
/>
<Label htmlFor="terms" className="text-sm">
I agree to the{" "}
<Link href="/terms" className="underline">
terms and conditions
</Link>
</Label>
</div>
{errors.terms && (
<p className="text-sm text-destructive">{errors.terms.message}</p>
)}
</CardContent>
<CardFooter className="flex flex-col space-y-4">
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Creating account..." : "Create account"}
</Button>
<p className="text-sm text-muted-foreground">
Already have an account?{" "}
<Link href="/login" className="underline">
Sign in
</Link>
</p>
</CardFooter>
</form>
</Card>
</div>
);
}File: services/frontend-plugin.store/app/login/page.tsx
Add Zod validation for consistency (uses loginSchema from the same file).
File: services/frontend-plugin.store/lib/validations/auth.test.ts (new file)
import { describe, it, expect } from "vitest";
import { registerSchema, loginSchema } from "./auth";
describe("registerSchema", () => {
describe("username validation", () => {
it("accepts valid usernames", () => {
const validUsernames = [
"abc",
"my-username",
"user123",
"a".repeat(64),
];
for (const username of validUsernames) {
const result = registerSchema.safeParse({
username,
email: "test@example.com",
password: "Password123",
confirmPassword: "Password123",
terms: true,
});
expect(result.success, `Expected "${username}" to be valid`).toBe(true);
}
});
it("rejects usernames that are too short", () => {
const result = registerSchema.safeParse({
username: "ab",
email: "test@example.com",
password: "Password123",
confirmPassword: "Password123",
terms: true,
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].message).toContain("at least 3");
});
it("rejects usernames that are too long", () => {
const result = registerSchema.safeParse({
username: "a".repeat(65),
email: "test@example.com",
password: "Password123",
confirmPassword: "Password123",
terms: true,
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].message).toContain("at most 64");
});
it("rejects uppercase usernames", () => {
const result = registerSchema.safeParse({
username: "MyUsername",
email: "test@example.com",
password: "Password123",
confirmPassword: "Password123",
terms: true,
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].message).toContain("lowercase");
});
it("rejects usernames with underscores", () => {
const result = registerSchema.safeParse({
username: "my_username",
email: "test@example.com",
password: "Password123",
confirmPassword: "Password123",
terms: true,
});
expect(result.success).toBe(false);
});
it("rejects usernames starting with a number", () => {
const result = registerSchema.safeParse({
username: "123user",
email: "test@example.com",
password: "Password123",
confirmPassword: "Password123",
terms: true,
});
expect(result.success).toBe(false);
});
it("rejects usernames starting with a hyphen", () => {
const result = registerSchema.safeParse({
username: "-username",
email: "test@example.com",
password: "Password123",
confirmPassword: "Password123",
terms: true,
});
expect(result.success).toBe(false);
});
it("rejects usernames with special characters", () => {
const invalidUsernames = ["user@name", "user.name", "user!name"];
for (const username of invalidUsernames) {
const result = registerSchema.safeParse({
username,
email: "test@example.com",
password: "Password123",
confirmPassword: "Password123",
terms: true,
});
expect(result.success, `Expected "${username}" to be invalid`).toBe(false);
}
});
});
describe("password validation", () => {
it("rejects passwords that are too short", () => {
const result = registerSchema.safeParse({
username: "validuser",
email: "test@example.com",
password: "Short1",
confirmPassword: "Short1",
terms: true,
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].message).toContain("at least 8");
});
it("rejects mismatched passwords", () => {
const result = registerSchema.safeParse({
username: "validuser",
email: "test@example.com",
password: "Password123",
confirmPassword: "Different123",
terms: true,
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].message).toContain("match");
});
});
describe("email validation", () => {
it("rejects invalid emails", () => {
const result = registerSchema.safeParse({
username: "validuser",
email: "not-an-email",
password: "Password123",
confirmPassword: "Password123",
terms: true,
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].message).toContain("email");
});
});
describe("terms validation", () => {
it("rejects when terms not accepted", () => {
const result = registerSchema.safeParse({
username: "validuser",
email: "test@example.com",
password: "Password123",
confirmPassword: "Password123",
terms: false,
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].message).toContain("terms");
});
});
});
describe("loginSchema", () => {
it("accepts valid login credentials", () => {
const result = loginSchema.safeParse({
email: "test@example.com",
password: "anypassword",
});
expect(result.success).toBe(true);
});
it("rejects invalid email", () => {
const result = loginSchema.safeParse({
email: "not-an-email",
password: "anypassword",
});
expect(result.success).toBe(false);
});
it("rejects empty password", () => {
const result = loginSchema.safeParse({
email: "test@example.com",
password: "",
});
expect(result.success).toBe(false);
});
});| Codebase | File | Changes |
|---|---|---|
| backend-users | lib/api/utils/validation.rs |
New regex, update functions, add OAuth helper |
| backend-users | lib/api/types/auth.rs |
Update length validation |
| backend-users | lib/api/types/organization.rs |
Update length validation |
| backend-users | lib/api/handlers/auth.rs |
Reject uppercase instead of normalize |
| backend-users | migrations/20250920224112_create_users_table.up.sql |
VARCHAR(64) |
| backend-users | migrations/20250920224117_create_organizations_table.up.sql |
VARCHAR(64) |
| backend-registries | lib/defaults.rs |
Update constants, validation functions, tests |
| backend-registries | migrations/20250918093610_create_artifacts_table.up.sql |
Update column sizes |
| tool-cli | lib/validate/validators/fields.rs |
Add constants, update function and error |
| tool-cli | lib/handlers/tool/init.rs |
Remove duplicate, use import |
| tool-cli | lib/prompt.rs |
Remove duplicate, use import |
| tool-cli | lib/validate/tests.rs |
Remove duplicate, update tests |
| tool-cli | lib/references.rs |
Update regex patterns (3-64 chars, no underscores) |
| frontend-plugin.store | lib/validations/auth.ts |
New file - Zod schemas |
| frontend-plugin.store | lib/validations/auth.test.ts |
New file - validation tests |
| frontend-plugin.store | app/register/page.tsx |
Refactor to React Hook Form + Zod |
| frontend-plugin.store | app/login/page.tsx |
Add Zod validation (optional) |
One rule for everything:
| Type | Pattern | Length | Example |
|---|---|---|---|
| Username | ^[a-z][a-z0-9-]{2,63}$ |
3-64 | john-doe |
| Namespace | ^[a-z][a-z0-9-]{2,63}$ |
3-64 | acme-corp |
| Org Slug | ^[a-z][a-z0-9-]{2,63}$ |
3-64 | my-org |
| Package Name | ^[a-z][a-z0-9-]{2,63}$ |
3-64 | my-awesome-tool |
| Artifact Name | ^[a-z][a-z0-9-]{2,63}$ |
3-64 | data-fetcher |
- Uppercase letters (must be lowercase)
- Underscores (use hyphens instead)
- Starting with a number
- Starting or ending with a hyphen
- Special characters (only alphanumeric + hyphen)
- Names shorter than 3 characters
- Names longer than 64 characters