this repo has no description
1import { err, ok, type Result } from "./types/result.ts";
2import {
3 type AtUri,
4 type Cid,
5 type Did,
6 type DidPlc,
7 type DidWeb,
8 type EmailAddress,
9 type Handle,
10 isAtUri,
11 isCid,
12 isDid,
13 isDidPlc,
14 isDidWeb,
15 isEmail,
16 isHandle,
17 isISODate,
18 isNsid,
19 type ISODateString,
20 type Nsid,
21} from "./types/branded.ts";
22
23export class ValidationError extends Error {
24 constructor(
25 message: string,
26 public readonly field?: string,
27 public readonly value?: unknown,
28 ) {
29 super(message);
30 this.name = "ValidationError";
31 }
32}
33
34export function parseDid(s: string): Result<Did, ValidationError> {
35 if (isDid(s)) {
36 return ok(s);
37 }
38 return err(new ValidationError(`Invalid DID: ${s}`, "did", s));
39}
40
41export function parseDidPlc(s: string): Result<DidPlc, ValidationError> {
42 if (isDidPlc(s)) {
43 return ok(s);
44 }
45 return err(new ValidationError(`Invalid DID:PLC: ${s}`, "did", s));
46}
47
48export function parseDidWeb(s: string): Result<DidWeb, ValidationError> {
49 if (isDidWeb(s)) {
50 return ok(s);
51 }
52 return err(new ValidationError(`Invalid DID:WEB: ${s}`, "did", s));
53}
54
55export function parseHandle(s: string): Result<Handle, ValidationError> {
56 const trimmed = s.trim().toLowerCase();
57 if (isHandle(trimmed)) {
58 return ok(trimmed);
59 }
60 return err(new ValidationError(`Invalid handle: ${s}`, "handle", s));
61}
62
63export function parseEmail(s: string): Result<EmailAddress, ValidationError> {
64 const trimmed = s.trim().toLowerCase();
65 if (isEmail(trimmed)) {
66 return ok(trimmed);
67 }
68 return err(new ValidationError(`Invalid email: ${s}`, "email", s));
69}
70
71export function parseAtUri(s: string): Result<AtUri, ValidationError> {
72 if (isAtUri(s)) {
73 return ok(s);
74 }
75 return err(new ValidationError(`Invalid AT-URI: ${s}`, "uri", s));
76}
77
78export function parseCid(s: string): Result<Cid, ValidationError> {
79 if (isCid(s)) {
80 return ok(s);
81 }
82 return err(new ValidationError(`Invalid CID: ${s}`, "cid", s));
83}
84
85export function parseNsid(s: string): Result<Nsid, ValidationError> {
86 if (isNsid(s)) {
87 return ok(s);
88 }
89 return err(new ValidationError(`Invalid NSID: ${s}`, "nsid", s));
90}
91
92export function parseISODate(
93 s: string,
94): Result<ISODateString, ValidationError> {
95 if (isISODate(s)) {
96 return ok(s);
97 }
98 return err(new ValidationError(`Invalid ISO date: ${s}`, "date", s));
99}
100
101export interface PasswordValidationResult {
102 valid: boolean;
103 errors: string[];
104 strength: "weak" | "fair" | "good" | "strong";
105}
106
107export function validatePassword(password: string): PasswordValidationResult {
108 const errors: string[] = [];
109
110 if (password.length < 8) {
111 errors.push("Password must be at least 8 characters");
112 }
113 if (password.length > 256) {
114 errors.push("Password must be at most 256 characters");
115 }
116 if (!/[a-z]/.test(password)) {
117 errors.push("Password must contain a lowercase letter");
118 }
119 if (!/[A-Z]/.test(password)) {
120 errors.push("Password must contain an uppercase letter");
121 }
122 if (!/\d/.test(password)) {
123 errors.push("Password must contain a number");
124 }
125
126 let strength: PasswordValidationResult["strength"] = "weak";
127 if (errors.length === 0) {
128 const hasSpecial = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password);
129 const isLong = password.length >= 12;
130 const isVeryLong = password.length >= 16;
131
132 if (isVeryLong && hasSpecial) {
133 strength = "strong";
134 } else if (isLong || hasSpecial) {
135 strength = "good";
136 } else {
137 strength = "fair";
138 }
139 }
140
141 return {
142 valid: errors.length === 0,
143 errors,
144 strength,
145 };
146}
147
148export function validateHandle(
149 handle: string,
150): Result<Handle, ValidationError> {
151 const trimmed = handle.trim().toLowerCase();
152
153 if (trimmed.length < 3) {
154 return err(
155 new ValidationError(
156 "Handle must be at least 3 characters",
157 "handle",
158 handle,
159 ),
160 );
161 }
162
163 if (trimmed.length > 253) {
164 return err(
165 new ValidationError(
166 "Handle must be at most 253 characters",
167 "handle",
168 handle,
169 ),
170 );
171 }
172
173 if (!isHandle(trimmed)) {
174 return err(new ValidationError("Invalid handle format", "handle", handle));
175 }
176
177 return ok(trimmed);
178}
179
180export function validateInviteCode(
181 code: string,
182): Result<string, ValidationError> {
183 const trimmed = code.trim();
184
185 if (trimmed.length === 0) {
186 return err(
187 new ValidationError("Invite code is required", "inviteCode", code),
188 );
189 }
190
191 const pattern = /^[a-zA-Z0-9-]+$/;
192 if (!pattern.test(trimmed)) {
193 return err(
194 new ValidationError("Invalid invite code format", "inviteCode", code),
195 );
196 }
197
198 return ok(trimmed);
199}
200
201export function validateTotpCode(
202 code: string,
203): Result<string, ValidationError> {
204 const trimmed = code.trim().replace(/\s/g, "");
205
206 if (!/^\d{6}$/.test(trimmed)) {
207 return err(new ValidationError("TOTP code must be 6 digits", "code", code));
208 }
209
210 return ok(trimmed);
211}
212
213export function validateBackupCode(
214 code: string,
215): Result<string, ValidationError> {
216 const trimmed = code.trim().replace(/\s/g, "").toLowerCase();
217
218 if (!/^[a-z0-9]{8}$/.test(trimmed)) {
219 return err(new ValidationError("Invalid backup code format", "code", code));
220 }
221
222 return ok(trimmed);
223}
224
225export interface FormValidation<T> {
226 validate: () => Result<T, ValidationError[]>;
227 field: <K extends keyof T>(
228 key: K,
229 validator: (value: unknown) => Result<T[K], ValidationError>,
230 ) => FormValidation<T>;
231 optional: <K extends keyof T>(
232 key: K,
233 validator: (value: unknown) => Result<T[K], ValidationError>,
234 ) => FormValidation<T>;
235}
236
237export function createFormValidation<T extends Record<string, unknown>>(
238 data: Record<string, unknown>,
239): FormValidation<T> {
240 const validators: Array<{
241 key: string;
242 validator: (value: unknown) => Result<unknown, ValidationError>;
243 optional: boolean;
244 }> = [];
245
246 const builder: FormValidation<T> = {
247 field: (key, validator) => {
248 validators.push({ key: key as string, validator, optional: false });
249 return builder;
250 },
251 optional: (key, validator) => {
252 validators.push({ key: key as string, validator, optional: true });
253 return builder;
254 },
255 validate: () => {
256 const errors: ValidationError[] = [];
257 const result: Record<string, unknown> = {};
258
259 for (const { key, validator, optional } of validators) {
260 const value = data[key];
261
262 if (value == null || value === "") {
263 if (!optional) {
264 errors.push(new ValidationError(`${key} is required`, key));
265 }
266 continue;
267 }
268
269 const validated = validator(value);
270 if (validated.ok) {
271 result[key] = validated.value;
272 } else {
273 errors.push(validated.error);
274 }
275 }
276
277 if (errors.length > 0) {
278 return err(errors);
279 }
280
281 return ok(result as T);
282 },
283 };
284
285 return builder;
286}