Our Personal Data Server from scratch! tranquil.farm
atproto pds rust postgresql fun oauth

refactor(frontend): delete utility libraries and validation #69

merged opened by oyster.cafe targeting main from refactor/frontend-deletions
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3mhg53l5ush22
+7 -1204
Diff #2
-205
frontend/src/lib/utils/array.ts
··· 1 - import type { Option } from "./option.ts"; 2 - 3 - export function first<T>(arr: readonly T[]): Option<T> { 4 - return arr[0] ?? null; 5 - } 6 - 7 - export function last<T>(arr: readonly T[]): Option<T> { 8 - return arr[arr.length - 1] ?? null; 9 - } 10 - 11 - export function at<T>(arr: readonly T[], index: number): Option<T> { 12 - if (index < 0) index = arr.length + index; 13 - return arr[index] ?? null; 14 - } 15 - 16 - export function find<T>( 17 - arr: readonly T[], 18 - predicate: (t: T) => boolean, 19 - ): Option<T> { 20 - return arr.find(predicate) ?? null; 21 - } 22 - 23 - export function findMap<T, U>( 24 - arr: readonly T[], 25 - fn: (t: T) => Option<U>, 26 - ): Option<U> { 27 - for (const item of arr) { 28 - const result = fn(item); 29 - if (result != null) return result; 30 - } 31 - return null; 32 - } 33 - 34 - export function findIndex<T>( 35 - arr: readonly T[], 36 - predicate: (t: T) => boolean, 37 - ): Option<number> { 38 - const index = arr.findIndex(predicate); 39 - return index >= 0 ? index : null; 40 - } 41 - 42 - export function partition<T>( 43 - arr: readonly T[], 44 - predicate: (t: T) => boolean, 45 - ): [T[], T[]] { 46 - const pass: T[] = []; 47 - const fail: T[] = []; 48 - for (const item of arr) { 49 - if (predicate(item)) { 50 - pass.push(item); 51 - } else { 52 - fail.push(item); 53 - } 54 - } 55 - return [pass, fail]; 56 - } 57 - 58 - export function groupBy<T, K extends string | number>( 59 - arr: readonly T[], 60 - keyFn: (t: T) => K, 61 - ): Record<K, T[]> { 62 - const result = {} as Record<K, T[]>; 63 - for (const item of arr) { 64 - const key = keyFn(item); 65 - if (!result[key]) { 66 - result[key] = []; 67 - } 68 - result[key].push(item); 69 - } 70 - return result; 71 - } 72 - 73 - export function unique<T>(arr: readonly T[]): T[] { 74 - return [...new Set(arr)]; 75 - } 76 - 77 - export function uniqueBy<T, K>(arr: readonly T[], keyFn: (t: T) => K): T[] { 78 - const seen = new Set<K>(); 79 - const result: T[] = []; 80 - for (const item of arr) { 81 - const key = keyFn(item); 82 - if (!seen.has(key)) { 83 - seen.add(key); 84 - result.push(item); 85 - } 86 - } 87 - return result; 88 - } 89 - 90 - export function sortBy<T>( 91 - arr: readonly T[], 92 - keyFn: (t: T) => number | string, 93 - ): T[] { 94 - return [...arr].sort((a, b) => { 95 - const ka = keyFn(a); 96 - const kb = keyFn(b); 97 - if (ka < kb) return -1; 98 - if (ka > kb) return 1; 99 - return 0; 100 - }); 101 - } 102 - 103 - export function sortByDesc<T>( 104 - arr: readonly T[], 105 - keyFn: (t: T) => number | string, 106 - ): T[] { 107 - return [...arr].sort((a, b) => { 108 - const ka = keyFn(a); 109 - const kb = keyFn(b); 110 - if (ka > kb) return -1; 111 - if (ka < kb) return 1; 112 - return 0; 113 - }); 114 - } 115 - 116 - export function chunk<T>(arr: readonly T[], size: number): T[][] { 117 - const result: T[][] = []; 118 - for (let i = 0; i < arr.length; i += size) { 119 - result.push(arr.slice(i, i + size)); 120 - } 121 - return result; 122 - } 123 - 124 - export function zip<T, U>(a: readonly T[], b: readonly U[]): [T, U][] { 125 - const length = Math.min(a.length, b.length); 126 - const result: [T, U][] = []; 127 - for (let i = 0; i < length; i++) { 128 - result.push([a[i], b[i]]); 129 - } 130 - return result; 131 - } 132 - 133 - export function zipWith<T, U, R>( 134 - a: readonly T[], 135 - b: readonly U[], 136 - fn: (t: T, u: U) => R, 137 - ): R[] { 138 - const length = Math.min(a.length, b.length); 139 - const result: R[] = []; 140 - for (let i = 0; i < length; i++) { 141 - result.push(fn(a[i], b[i])); 142 - } 143 - return result; 144 - } 145 - 146 - export function intersperse<T>(arr: readonly T[], separator: T): T[] { 147 - if (arr.length <= 1) return [...arr]; 148 - const result: T[] = [arr[0]]; 149 - for (let i = 1; i < arr.length; i++) { 150 - result.push(separator, arr[i]); 151 - } 152 - return result; 153 - } 154 - 155 - export function range(start: number, end: number): number[] { 156 - const result: number[] = []; 157 - for (let i = start; i < end; i++) { 158 - result.push(i); 159 - } 160 - return result; 161 - } 162 - 163 - export function isEmpty<T>(arr: readonly T[]): boolean { 164 - return arr.length === 0; 165 - } 166 - 167 - export function isNonEmpty<T>(arr: readonly T[]): arr is [T, ...T[]] { 168 - return arr.length > 0; 169 - } 170 - 171 - export function sum(arr: readonly number[]): number { 172 - return arr.reduce((acc, n) => acc + n, 0); 173 - } 174 - 175 - export function sumBy<T>(arr: readonly T[], fn: (t: T) => number): number { 176 - return arr.reduce((acc, t) => acc + fn(t), 0); 177 - } 178 - 179 - export function maxBy<T>(arr: readonly T[], fn: (t: T) => number): Option<T> { 180 - if (arr.length === 0) return null; 181 - let max = arr[0]; 182 - let maxValue = fn(max); 183 - for (let i = 1; i < arr.length; i++) { 184 - const value = fn(arr[i]); 185 - if (value > maxValue) { 186 - max = arr[i]; 187 - maxValue = value; 188 - } 189 - } 190 - return max; 191 - } 192 - 193 - export function minBy<T>(arr: readonly T[], fn: (t: T) => number): Option<T> { 194 - if (arr.length === 0) return null; 195 - let min = arr[0]; 196 - let minValue = fn(min); 197 - for (let i = 1; i < arr.length; i++) { 198 - const value = fn(arr[i]); 199 - if (value < minValue) { 200 - min = arr[i]; 201 - minValue = value; 202 - } 203 - } 204 - return min; 205 - }
-245
frontend/src/lib/utils/async.ts
··· 1 - import { err, type Result } from "../types/result.ts"; 2 - 3 - export function debounce<T extends (...args: Parameters<T>) => void>( 4 - fn: T, 5 - ms: number, 6 - ): T & { cancel: () => void } { 7 - let timeoutId: ReturnType<typeof setTimeout> | null = null; 8 - 9 - const debounced = ((...args: Parameters<T>) => { 10 - if (timeoutId) clearTimeout(timeoutId); 11 - timeoutId = setTimeout(() => { 12 - fn(...args); 13 - timeoutId = null; 14 - }, ms); 15 - }) as T & { cancel: () => void }; 16 - 17 - debounced.cancel = () => { 18 - if (timeoutId) { 19 - clearTimeout(timeoutId); 20 - timeoutId = null; 21 - } 22 - }; 23 - 24 - return debounced; 25 - } 26 - 27 - export function throttle<T extends (...args: Parameters<T>) => void>( 28 - fn: T, 29 - ms: number, 30 - ): T { 31 - let lastCall = 0; 32 - let timeoutId: ReturnType<typeof setTimeout> | null = null; 33 - 34 - return ((...args: Parameters<T>) => { 35 - const now = Date.now(); 36 - const remaining = ms - (now - lastCall); 37 - 38 - if (remaining <= 0) { 39 - if (timeoutId) { 40 - clearTimeout(timeoutId); 41 - timeoutId = null; 42 - } 43 - lastCall = now; 44 - fn(...args); 45 - } else if (!timeoutId) { 46 - timeoutId = setTimeout(() => { 47 - lastCall = Date.now(); 48 - timeoutId = null; 49 - fn(...args); 50 - }, remaining); 51 - } 52 - }) as T; 53 - } 54 - 55 - export function sleep(ms: number): Promise<void> { 56 - return new Promise((resolve) => setTimeout(resolve, ms)); 57 - } 58 - 59 - export async function retry<T>( 60 - fn: () => Promise<T>, 61 - options: { 62 - attempts?: number; 63 - delay?: number; 64 - backoff?: number; 65 - shouldRetry?: (error: unknown, attempt: number) => boolean; 66 - } = {}, 67 - ): Promise<T> { 68 - const { 69 - attempts = 3, 70 - delay = 1000, 71 - backoff = 2, 72 - shouldRetry = () => true, 73 - } = options; 74 - 75 - let lastError: unknown; 76 - let currentDelay = delay; 77 - 78 - for (let attempt = 1; attempt <= attempts; attempt++) { 79 - try { 80 - return await fn(); 81 - } catch (error) { 82 - lastError = error; 83 - if (attempt === attempts || !shouldRetry(error, attempt)) { 84 - throw error; 85 - } 86 - await sleep(currentDelay); 87 - currentDelay *= backoff; 88 - } 89 - } 90 - 91 - throw lastError; 92 - } 93 - 94 - export async function retryResult<T, E>( 95 - fn: () => Promise<Result<T, E>>, 96 - options: { 97 - attempts?: number; 98 - delay?: number; 99 - backoff?: number; 100 - shouldRetry?: (error: E, attempt: number) => boolean; 101 - } = {}, 102 - ): Promise<Result<T, E>> { 103 - const { 104 - attempts = 3, 105 - delay = 1000, 106 - backoff = 2, 107 - shouldRetry = () => true, 108 - } = options; 109 - 110 - let lastResult: Result<T, E> | null = null; 111 - let currentDelay = delay; 112 - 113 - for (let attempt = 1; attempt <= attempts; attempt++) { 114 - const result = await fn(); 115 - lastResult = result; 116 - 117 - if (result.ok) { 118 - return result; 119 - } 120 - 121 - if (attempt === attempts || !shouldRetry(result.error, attempt)) { 122 - return result; 123 - } 124 - 125 - await sleep(currentDelay); 126 - currentDelay *= backoff; 127 - } 128 - 129 - return lastResult!; 130 - } 131 - 132 - export function timeout<T>(promise: Promise<T>, ms: number): Promise<T> { 133 - return new Promise((resolve, reject) => { 134 - const timeoutId = setTimeout(() => { 135 - reject(new Error(`Timeout after ${ms}ms`)); 136 - }, ms); 137 - 138 - promise 139 - .then((value) => { 140 - clearTimeout(timeoutId); 141 - resolve(value); 142 - }) 143 - .catch((error) => { 144 - clearTimeout(timeoutId); 145 - reject(error); 146 - }); 147 - }); 148 - } 149 - 150 - export async function timeoutResult<T>( 151 - promise: Promise<Result<T, Error>>, 152 - ms: number, 153 - ): Promise<Result<T, Error>> { 154 - try { 155 - return await timeout(promise, ms); 156 - } catch (e) { 157 - return err(e instanceof Error ? e : new Error(String(e))); 158 - } 159 - } 160 - 161 - export async function parallel<T>( 162 - tasks: (() => Promise<T>)[], 163 - concurrency: number, 164 - ): Promise<T[]> { 165 - const results: T[] = []; 166 - const executing: Promise<void>[] = []; 167 - 168 - for (const task of tasks) { 169 - const p = task().then((result) => { 170 - results.push(result); 171 - }); 172 - 173 - executing.push(p); 174 - 175 - if (executing.length >= concurrency) { 176 - await Promise.race(executing); 177 - executing.splice( 178 - executing.findIndex((e) => e === p), 179 - 1, 180 - ); 181 - } 182 - } 183 - 184 - await Promise.all(executing); 185 - return results; 186 - } 187 - 188 - export async function mapParallel<T, U>( 189 - items: T[], 190 - fn: (item: T, index: number) => Promise<U>, 191 - concurrency: number, 192 - ): Promise<U[]> { 193 - const results: U[] = new Array(items.length); 194 - const executing: Promise<void>[] = []; 195 - 196 - for (let i = 0; i < items.length; i++) { 197 - const index = i; 198 - const p = fn(items[index], index).then((result) => { 199 - results[index] = result; 200 - }); 201 - 202 - executing.push(p); 203 - 204 - if (executing.length >= concurrency) { 205 - await Promise.race(executing); 206 - const doneIndex = executing.findIndex( 207 - (e) => (e as Promise<void> & { _done?: boolean })._done !== false, 208 - ); 209 - if (doneIndex >= 0) { 210 - executing.splice(doneIndex, 1); 211 - } 212 - } 213 - } 214 - 215 - await Promise.all(executing); 216 - return results; 217 - } 218 - 219 - export function createAbortable<T>( 220 - fn: (signal: AbortSignal) => Promise<T>, 221 - ): { promise: Promise<T>; abort: () => void } { 222 - const controller = new AbortController(); 223 - return { 224 - promise: fn(controller.signal), 225 - abort: () => controller.abort(), 226 - }; 227 - } 228 - 229 - export interface Deferred<T> { 230 - promise: Promise<T>; 231 - resolve: (value: T) => void; 232 - reject: (error: unknown) => void; 233 - } 234 - 235 - export function deferred<T>(): Deferred<T> { 236 - let resolve!: (value: T) => void; 237 - let reject!: (error: unknown) => void; 238 - 239 - const promise = new Promise<T>((res, rej) => { 240 - resolve = res; 241 - reject = rej; 242 - }); 243 - 244 - return { promise, resolve, reject }; 245 - }
-27
frontend/src/lib/utils/index.ts
··· 1 - export * from "./option.ts"; 2 - export { 3 - at, 4 - chunk, 5 - find, 6 - findIndex, 7 - findMap, 8 - first, 9 - groupBy, 10 - intersperse, 11 - isEmpty, 12 - isNonEmpty, 13 - last, 14 - maxBy, 15 - minBy, 16 - partition, 17 - range, 18 - sortBy, 19 - sortByDesc, 20 - sum, 21 - sumBy, 22 - unique, 23 - uniqueBy, 24 - zip as zipArrays, 25 - zipWith as zipArraysWith, 26 - } from "./array.ts"; 27 - export * from "./async.ts";
-85
frontend/src/lib/utils/option.ts
··· 1 - export type Option<T> = T | null | undefined; 2 - 3 - export function isSome<T>(opt: Option<T>): opt is T { 4 - return opt != null; 5 - } 6 - 7 - export function isNone<T>(opt: Option<T>): opt is null | undefined { 8 - return opt == null; 9 - } 10 - 11 - export function map<T, U>(opt: Option<T>, fn: (t: T) => U): Option<U> { 12 - return isSome(opt) ? fn(opt) : null; 13 - } 14 - 15 - export function flatMap<T, U>( 16 - opt: Option<T>, 17 - fn: (t: T) => Option<U>, 18 - ): Option<U> { 19 - return isSome(opt) ? fn(opt) : null; 20 - } 21 - 22 - export function filter<T>( 23 - opt: Option<T>, 24 - predicate: (t: T) => boolean, 25 - ): Option<T> { 26 - return isSome(opt) && predicate(opt) ? opt : null; 27 - } 28 - 29 - export function getOrElse<T>(opt: Option<T>, defaultValue: T): T { 30 - return isSome(opt) ? opt : defaultValue; 31 - } 32 - 33 - export function getOrElseLazy<T>(opt: Option<T>, fn: () => T): T { 34 - return isSome(opt) ? opt : fn(); 35 - } 36 - 37 - export function getOrThrow<T>(opt: Option<T>, error?: string | Error): T { 38 - if (isSome(opt)) return opt; 39 - if (error instanceof Error) throw error; 40 - throw new Error(error ?? "Expected value but got null/undefined"); 41 - } 42 - 43 - export function tap<T>(opt: Option<T>, fn: (t: T) => void): Option<T> { 44 - if (isSome(opt)) fn(opt); 45 - return opt; 46 - } 47 - 48 - export function match<T, U>( 49 - opt: Option<T>, 50 - handlers: { some: (t: T) => U; none: () => U }, 51 - ): U { 52 - return isSome(opt) ? handlers.some(opt) : handlers.none(); 53 - } 54 - 55 - export function toArray<T>(opt: Option<T>): T[] { 56 - return isSome(opt) ? [opt] : []; 57 - } 58 - 59 - export function fromArray<T>(arr: T[]): Option<T> { 60 - return arr.length > 0 ? arr[0] : null; 61 - } 62 - 63 - export function zip<T, U>(a: Option<T>, b: Option<U>): Option<[T, U]> { 64 - return isSome(a) && isSome(b) ? [a, b] : null; 65 - } 66 - 67 - export function zipWith<T, U, R>( 68 - a: Option<T>, 69 - b: Option<U>, 70 - fn: (t: T, u: U) => R, 71 - ): Option<R> { 72 - return isSome(a) && isSome(b) ? fn(a, b) : null; 73 - } 74 - 75 - export function or<T>(a: Option<T>, b: Option<T>): Option<T> { 76 - return isSome(a) ? a : b; 77 - } 78 - 79 - export function orLazy<T>(a: Option<T>, fn: () => Option<T>): Option<T> { 80 - return isSome(a) ? a : fn(); 81 - } 82 - 83 - export function and<T, U>(a: Option<T>, b: Option<U>): Option<U> { 84 - return isSome(a) ? b : null; 85 - }
-286
frontend/src/lib/validation.ts
··· 1 - import { err, ok, type Result } from "./types/result.ts"; 2 - import { 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 - 23 - export 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 - 34 - export 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 - 41 - export 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 - 48 - export 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 - 55 - export 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 - 63 - export 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 - 71 - export 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 - 78 - export 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 - 85 - export 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 - 92 - export 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 - 101 - export interface PasswordValidationResult { 102 - valid: boolean; 103 - errors: string[]; 104 - strength: "weak" | "fair" | "good" | "strong"; 105 - } 106 - 107 - export 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 - 148 - export 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 - 180 - export 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 - 201 - export 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 - 213 - export 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 - 225 - export 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 - 237 - export 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 - }
-255
frontend/src/tests/migration/atproto-client.test.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 2 import { 3 3 AtprotoClient, 4 - base64UrlDecode, 5 - base64UrlEncode, 6 4 buildOAuthAuthorizationUrl, 7 5 clearDPoPKey, 8 6 generateDPoPKeyPair, 9 - generateOAuthState, 10 - generatePKCE, 11 7 getMigrationOAuthClientId, 12 8 getMigrationOAuthRedirectUri, 13 9 loadDPoPKey, 14 - prepareWebAuthnCreationOptions, 15 10 saveDPoPKey, 16 11 } from "../../lib/migration/atproto-client.ts"; 17 12 import type { OAuthServerMetadata } from "../../lib/migration/types.ts"; ··· 23 18 localStorage.removeItem(DPOP_KEY_STORAGE); 24 19 }); 25 20 26 - describe("base64UrlEncode", () => { 27 - it("encodes empty buffer", () => { 28 - const result = base64UrlEncode(new Uint8Array([])); 29 - expect(result).toBe(""); 30 - }); 31 - 32 - it("encodes simple data", () => { 33 - const data = new TextEncoder().encode("hello"); 34 - const result = base64UrlEncode(data); 35 - expect(result).toBe("aGVsbG8"); 36 - }); 37 - 38 - it("uses URL-safe characters (no +, /, or =)", () => { 39 - const data = new Uint8Array([251, 255, 254]); 40 - const result = base64UrlEncode(data); 41 - expect(result).not.toContain("+"); 42 - expect(result).not.toContain("/"); 43 - expect(result).not.toContain("="); 44 - }); 45 - 46 - it("replaces + with -", () => { 47 - const data = new Uint8Array([251]); 48 - const result = base64UrlEncode(data); 49 - expect(result).toContain("-"); 50 - }); 51 - 52 - it("replaces / with _", () => { 53 - const data = new Uint8Array([255]); 54 - const result = base64UrlEncode(data); 55 - expect(result).toContain("_"); 56 - }); 57 - 58 - it("accepts ArrayBuffer", () => { 59 - const arrayBuffer = new ArrayBuffer(4); 60 - const view = new Uint8Array(arrayBuffer); 61 - view[0] = 116; // t 62 - view[1] = 101; // e 63 - view[2] = 115; // s 64 - view[3] = 116; // t 65 - const result = base64UrlEncode(arrayBuffer); 66 - expect(result).toBe("dGVzdA"); 67 - }); 68 - }); 69 - 70 - describe("base64UrlDecode", () => { 71 - it("decodes empty string", () => { 72 - const result = base64UrlDecode(""); 73 - expect(result.length).toBe(0); 74 - }); 75 - 76 - it("decodes URL-safe base64", () => { 77 - const result = base64UrlDecode("aGVsbG8"); 78 - expect(new TextDecoder().decode(result)).toBe("hello"); 79 - }); 80 - 81 - it("handles - and _ characters", () => { 82 - const encoded = base64UrlEncode(new Uint8Array([251, 255, 254])); 83 - const decoded = base64UrlDecode(encoded); 84 - expect(decoded).toEqual(new Uint8Array([251, 255, 254])); 85 - }); 86 - 87 - it("is inverse of base64UrlEncode", () => { 88 - const original = new Uint8Array([0, 1, 2, 255, 254, 253]); 89 - const encoded = base64UrlEncode(original); 90 - const decoded = base64UrlDecode(encoded); 91 - expect(decoded).toEqual(original); 92 - }); 93 - 94 - it("handles missing padding", () => { 95 - const result = base64UrlDecode("YQ"); 96 - expect(new TextDecoder().decode(result)).toBe("a"); 97 - }); 98 - }); 99 - 100 - describe("generateOAuthState", () => { 101 - it("generates a non-empty string", () => { 102 - const state = generateOAuthState(); 103 - expect(state).toBeTruthy(); 104 - expect(typeof state).toBe("string"); 105 - }); 106 - 107 - it("generates URL-safe characters only", () => { 108 - const state = generateOAuthState(); 109 - expect(state).toMatch(/^[A-Za-z0-9_-]+$/); 110 - }); 111 - 112 - it("generates different values each time", () => { 113 - const state1 = generateOAuthState(); 114 - const state2 = generateOAuthState(); 115 - expect(state1).not.toBe(state2); 116 - }); 117 - }); 118 - 119 - describe("generatePKCE", () => { 120 - it("generates code_verifier and code_challenge", async () => { 121 - const pkce = await generatePKCE(); 122 - expect(pkce.codeVerifier).toBeTruthy(); 123 - expect(pkce.codeChallenge).toBeTruthy(); 124 - }); 125 - 126 - it("generates URL-safe code_verifier", async () => { 127 - const pkce = await generatePKCE(); 128 - expect(pkce.codeVerifier).toMatch(/^[A-Za-z0-9_-]+$/); 129 - }); 130 - 131 - it("generates URL-safe code_challenge", async () => { 132 - const pkce = await generatePKCE(); 133 - expect(pkce.codeChallenge).toMatch(/^[A-Za-z0-9_-]+$/); 134 - }); 135 - 136 - it("code_challenge is SHA-256 hash of code_verifier", async () => { 137 - const pkce = await generatePKCE(); 138 - 139 - const encoder = new TextEncoder(); 140 - const data = encoder.encode(pkce.codeVerifier); 141 - const digest = await crypto.subtle.digest("SHA-256", data); 142 - const expectedChallenge = base64UrlEncode(new Uint8Array(digest)); 143 - 144 - expect(pkce.codeChallenge).toBe(expectedChallenge); 145 - }); 146 - 147 - it("generates different values each time", async () => { 148 - const pkce1 = await generatePKCE(); 149 - const pkce2 = await generatePKCE(); 150 - expect(pkce1.codeVerifier).not.toBe(pkce2.codeVerifier); 151 - expect(pkce1.codeChallenge).not.toBe(pkce2.codeChallenge); 152 - }); 153 - }); 154 - 155 21 describe("buildOAuthAuthorizationUrl", () => { 156 22 const mockMetadata: OAuthServerMetadata = { 157 23 issuer: "https://bsky.social", ··· 398 264 }); 399 265 }); 400 266 401 - describe("prepareWebAuthnCreationOptions", () => { 402 - it("decodes challenge from base64url", () => { 403 - const options = { 404 - publicKey: { 405 - challenge: "dGVzdC1jaGFsbGVuZ2U", 406 - user: { 407 - id: "dXNlci1pZA", 408 - name: "test@example.com", 409 - displayName: "Test User", 410 - }, 411 - excludeCredentials: [], 412 - rp: { name: "Test" }, 413 - pubKeyCredParams: [{ type: "public-key", alg: -7 }], 414 - }, 415 - }; 416 - 417 - const prepared = prepareWebAuthnCreationOptions(options); 418 - 419 - expect(prepared.challenge).toBeInstanceOf(Uint8Array); 420 - expect(new TextDecoder().decode(prepared.challenge as Uint8Array)).toBe( 421 - "test-challenge", 422 - ); 423 - }); 424 - 425 - it("decodes user.id from base64url", () => { 426 - const options = { 427 - publicKey: { 428 - challenge: "Y2hhbGxlbmdl", 429 - user: { 430 - id: "dXNlci1pZA", 431 - name: "test@example.com", 432 - displayName: "Test User", 433 - }, 434 - excludeCredentials: [], 435 - rp: { name: "Test" }, 436 - pubKeyCredParams: [{ type: "public-key", alg: -7 }], 437 - }, 438 - }; 439 - 440 - const prepared = prepareWebAuthnCreationOptions(options); 441 - 442 - expect(prepared.user?.id).toBeInstanceOf(Uint8Array); 443 - expect(new TextDecoder().decode(prepared.user?.id as Uint8Array)).toBe( 444 - "user-id", 445 - ); 446 - }); 447 - 448 - it("decodes excludeCredentials ids from base64url", () => { 449 - const options = { 450 - publicKey: { 451 - challenge: "Y2hhbGxlbmdl", 452 - user: { 453 - id: "dXNlcg", 454 - name: "test@example.com", 455 - displayName: "Test User", 456 - }, 457 - excludeCredentials: [ 458 - { id: "Y3JlZDE", type: "public-key" }, 459 - { id: "Y3JlZDI", type: "public-key" }, 460 - ], 461 - rp: { name: "Test" }, 462 - pubKeyCredParams: [{ type: "public-key", alg: -7 }], 463 - }, 464 - }; 465 - 466 - const prepared = prepareWebAuthnCreationOptions(options); 467 - 468 - expect(prepared.excludeCredentials).toHaveLength(2); 469 - expect( 470 - new TextDecoder().decode( 471 - prepared.excludeCredentials![0].id as Uint8Array, 472 - ), 473 - ).toBe("cred1"); 474 - expect( 475 - new TextDecoder().decode( 476 - prepared.excludeCredentials![1].id as Uint8Array, 477 - ), 478 - ).toBe("cred2"); 479 - }); 480 - 481 - it("handles empty excludeCredentials", () => { 482 - const options = { 483 - publicKey: { 484 - challenge: "Y2hhbGxlbmdl", 485 - user: { 486 - id: "dXNlcg", 487 - name: "test@example.com", 488 - displayName: "Test User", 489 - }, 490 - rp: { name: "Test" }, 491 - pubKeyCredParams: [{ type: "public-key", alg: -7 }], 492 - }, 493 - }; 494 - 495 - const prepared = prepareWebAuthnCreationOptions(options); 496 - 497 - expect(prepared.excludeCredentials).toEqual([]); 498 - }); 499 - 500 - it("preserves other user properties", () => { 501 - const options = { 502 - publicKey: { 503 - challenge: "Y2hhbGxlbmdl", 504 - user: { 505 - id: "dXNlcg", 506 - name: "test@example.com", 507 - displayName: "Test User", 508 - }, 509 - excludeCredentials: [], 510 - rp: { name: "Test" }, 511 - pubKeyCredParams: [{ type: "public-key", alg: -7 }], 512 - }, 513 - }; 514 - 515 - const prepared = prepareWebAuthnCreationOptions(options); 516 - 517 - expect(prepared.user?.name).toBe("test@example.com"); 518 - expect(prepared.user?.displayName).toBe("Test User"); 519 - }); 520 - }); 521 - 522 267 describe("AtprotoClient.verifyHandleOwnership", () => { 523 268 function createMockJsonResponse(data: unknown, status = 200) { 524 269 return new Response(JSON.stringify(data), {
+5 -2
frontend/src/tests/migration/storage.test.ts
··· 88 88 generatedAppPasswordName: null, 89 89 handlePreservation: "new", 90 90 existingHandleVerified: false, 91 + verificationChannel: "email", 92 + discordUsername: "", 93 + telegramUsername: "", 94 + signalUsername: "", 91 95 ...overrides, 92 96 }; 93 97 } ··· 130 134 131 135 describe("migration/storage", () => { 132 136 beforeEach(() => { 133 - localStorage.removeItem(STORAGE_KEY); 134 - localStorage.removeItem(DPOP_KEY_STORAGE); 137 + localStorage.clear(); 135 138 }); 136 139 137 140 describe("saveMigrationState", () => {
+2 -2
frontend/src/tests/oauth-registration.test.ts
··· 218 218 }); 219 219 220 220 const RegisterPassword = 221 - (await import("../routes/RegisterPassword.svelte")).default; 221 + (await import("../routes/Register.svelte")).default; 222 222 render(RegisterPassword); 223 223 224 224 await waitFor( ··· 253 253 ); 254 254 255 255 const RegisterPassword = 256 - (await import("../routes/RegisterPassword.svelte")).default; 256 + (await import("../routes/Register.svelte")).default; 257 257 render(RegisterPassword); 258 258 259 259 await waitFor(() => {
-97
frontend/src/tests/utils.ts
··· 1 - import { render } from "@testing-library/svelte"; 2 - import { tick } from "svelte"; 3 - import type { ComponentType } from "svelte"; 4 - 5 - export async function renderAndWait( 6 - component: ComponentType, 7 - options?: Parameters<typeof render>[1], 8 - ) { 9 - const result = render(component, options); 10 - await tick(); 11 - await new Promise((resolve) => setTimeout(resolve, 0)); 12 - return result; 13 - } 14 - 15 - export async function waitForElement( 16 - queryFn: () => HTMLElement | null, 17 - timeout = 1000, 18 - ): Promise<HTMLElement> { 19 - const start = Date.now(); 20 - while (Date.now() - start < timeout) { 21 - const element = queryFn(); 22 - if (element) return element; 23 - await new Promise((resolve) => setTimeout(resolve, 10)); 24 - } 25 - throw new Error("Element not found within timeout"); 26 - } 27 - 28 - export async function waitForElementToDisappear( 29 - queryFn: () => HTMLElement | null, 30 - timeout = 1000, 31 - ): Promise<void> { 32 - const start = Date.now(); 33 - while (Date.now() - start < timeout) { 34 - const element = queryFn(); 35 - if (!element) return; 36 - await new Promise((resolve) => setTimeout(resolve, 10)); 37 - } 38 - throw new Error("Element still present after timeout"); 39 - } 40 - 41 - export async function waitForText( 42 - container: HTMLElement, 43 - text: string | RegExp, 44 - timeout = 1000, 45 - ): Promise<void> { 46 - const start = Date.now(); 47 - while (Date.now() - start < timeout) { 48 - const content = container.textContent || ""; 49 - if ( 50 - typeof text === "string" ? content.includes(text) : text.test(content) 51 - ) { 52 - return; 53 - } 54 - await new Promise((resolve) => setTimeout(resolve, 10)); 55 - } 56 - throw new Error(`Text "${text}" not found within timeout`); 57 - } 58 - 59 - export function mockLocalStorage( 60 - initialData: Record<string, string> = {}, 61 - ): void { 62 - const store: Record<string, string> = { ...initialData }; 63 - Object.defineProperty(window, "localStorage", { 64 - value: { 65 - getItem: (key: string) => store[key] || null, 66 - setItem: (key: string, value: string) => { 67 - store[key] = value; 68 - }, 69 - removeItem: (key: string) => { 70 - delete store[key]; 71 - }, 72 - clear: () => { 73 - Object.keys(store).forEach((key) => delete store[key]); 74 - }, 75 - key: (index: number) => Object.keys(store)[index] || null, 76 - get length() { 77 - return Object.keys(store).length; 78 - }, 79 - }, 80 - writable: true, 81 - }); 82 - } 83 - 84 - export function setAuthState(session: { 85 - did: string; 86 - handle: string; 87 - email?: string; 88 - emailConfirmed?: boolean; 89 - accessJwt: string; 90 - refreshJwt: string; 91 - }): void { 92 - localStorage.setItem("session", JSON.stringify(session)); 93 - } 94 - 95 - export function clearAuthState(): void { 96 - localStorage.removeItem("session"); 97 - }

History

3 rounds 0 comments
sign up or login to add to the discussion
1 commit
expand
refactor(frontend): delete utility libraries and validation
expand 0 comments
pull request successfully merged
1 commit
expand
refactor(frontend): delete utility libraries and validation
expand 0 comments
1 commit
expand
refactor(frontend): delete utility libraries and validation
expand 0 comments