mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-07-28 08:12:37 +00:00
refactor(frontend): delete utility libraries and validation
This commit is contained in:
@@ -1,205 +0,0 @@
|
||||
import type { Option } from "./option.ts";
|
||||
|
||||
export function first<T>(arr: readonly T[]): Option<T> {
|
||||
return arr[0] ?? null;
|
||||
}
|
||||
|
||||
export function last<T>(arr: readonly T[]): Option<T> {
|
||||
return arr[arr.length - 1] ?? null;
|
||||
}
|
||||
|
||||
export function at<T>(arr: readonly T[], index: number): Option<T> {
|
||||
if (index < 0) index = arr.length + index;
|
||||
return arr[index] ?? null;
|
||||
}
|
||||
|
||||
export function find<T>(
|
||||
arr: readonly T[],
|
||||
predicate: (t: T) => boolean,
|
||||
): Option<T> {
|
||||
return arr.find(predicate) ?? null;
|
||||
}
|
||||
|
||||
export function findMap<T, U>(
|
||||
arr: readonly T[],
|
||||
fn: (t: T) => Option<U>,
|
||||
): Option<U> {
|
||||
for (const item of arr) {
|
||||
const result = fn(item);
|
||||
if (result != null) return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findIndex<T>(
|
||||
arr: readonly T[],
|
||||
predicate: (t: T) => boolean,
|
||||
): Option<number> {
|
||||
const index = arr.findIndex(predicate);
|
||||
return index >= 0 ? index : null;
|
||||
}
|
||||
|
||||
export function partition<T>(
|
||||
arr: readonly T[],
|
||||
predicate: (t: T) => boolean,
|
||||
): [T[], T[]] {
|
||||
const pass: T[] = [];
|
||||
const fail: T[] = [];
|
||||
for (const item of arr) {
|
||||
if (predicate(item)) {
|
||||
pass.push(item);
|
||||
} else {
|
||||
fail.push(item);
|
||||
}
|
||||
}
|
||||
return [pass, fail];
|
||||
}
|
||||
|
||||
export function groupBy<T, K extends string | number>(
|
||||
arr: readonly T[],
|
||||
keyFn: (t: T) => K,
|
||||
): Record<K, T[]> {
|
||||
const result = {} as Record<K, T[]>;
|
||||
for (const item of arr) {
|
||||
const key = keyFn(item);
|
||||
if (!result[key]) {
|
||||
result[key] = [];
|
||||
}
|
||||
result[key].push(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function unique<T>(arr: readonly T[]): T[] {
|
||||
return [...new Set(arr)];
|
||||
}
|
||||
|
||||
export function uniqueBy<T, K>(arr: readonly T[], keyFn: (t: T) => K): T[] {
|
||||
const seen = new Set<K>();
|
||||
const result: T[] = [];
|
||||
for (const item of arr) {
|
||||
const key = keyFn(item);
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function sortBy<T>(
|
||||
arr: readonly T[],
|
||||
keyFn: (t: T) => number | string,
|
||||
): T[] {
|
||||
return [...arr].sort((a, b) => {
|
||||
const ka = keyFn(a);
|
||||
const kb = keyFn(b);
|
||||
if (ka < kb) return -1;
|
||||
if (ka > kb) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function sortByDesc<T>(
|
||||
arr: readonly T[],
|
||||
keyFn: (t: T) => number | string,
|
||||
): T[] {
|
||||
return [...arr].sort((a, b) => {
|
||||
const ka = keyFn(a);
|
||||
const kb = keyFn(b);
|
||||
if (ka > kb) return -1;
|
||||
if (ka < kb) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function chunk<T>(arr: readonly T[], size: number): T[][] {
|
||||
const result: T[][] = [];
|
||||
for (let i = 0; i < arr.length; i += size) {
|
||||
result.push(arr.slice(i, i + size));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function zip<T, U>(a: readonly T[], b: readonly U[]): [T, U][] {
|
||||
const length = Math.min(a.length, b.length);
|
||||
const result: [T, U][] = [];
|
||||
for (let i = 0; i < length; i++) {
|
||||
result.push([a[i], b[i]]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function zipWith<T, U, R>(
|
||||
a: readonly T[],
|
||||
b: readonly U[],
|
||||
fn: (t: T, u: U) => R,
|
||||
): R[] {
|
||||
const length = Math.min(a.length, b.length);
|
||||
const result: R[] = [];
|
||||
for (let i = 0; i < length; i++) {
|
||||
result.push(fn(a[i], b[i]));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function intersperse<T>(arr: readonly T[], separator: T): T[] {
|
||||
if (arr.length <= 1) return [...arr];
|
||||
const result: T[] = [arr[0]];
|
||||
for (let i = 1; i < arr.length; i++) {
|
||||
result.push(separator, arr[i]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function range(start: number, end: number): number[] {
|
||||
const result: number[] = [];
|
||||
for (let i = start; i < end; i++) {
|
||||
result.push(i);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function isEmpty<T>(arr: readonly T[]): boolean {
|
||||
return arr.length === 0;
|
||||
}
|
||||
|
||||
export function isNonEmpty<T>(arr: readonly T[]): arr is [T, ...T[]] {
|
||||
return arr.length > 0;
|
||||
}
|
||||
|
||||
export function sum(arr: readonly number[]): number {
|
||||
return arr.reduce((acc, n) => acc + n, 0);
|
||||
}
|
||||
|
||||
export function sumBy<T>(arr: readonly T[], fn: (t: T) => number): number {
|
||||
return arr.reduce((acc, t) => acc + fn(t), 0);
|
||||
}
|
||||
|
||||
export function maxBy<T>(arr: readonly T[], fn: (t: T) => number): Option<T> {
|
||||
if (arr.length === 0) return null;
|
||||
let max = arr[0];
|
||||
let maxValue = fn(max);
|
||||
for (let i = 1; i < arr.length; i++) {
|
||||
const value = fn(arr[i]);
|
||||
if (value > maxValue) {
|
||||
max = arr[i];
|
||||
maxValue = value;
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
export function minBy<T>(arr: readonly T[], fn: (t: T) => number): Option<T> {
|
||||
if (arr.length === 0) return null;
|
||||
let min = arr[0];
|
||||
let minValue = fn(min);
|
||||
for (let i = 1; i < arr.length; i++) {
|
||||
const value = fn(arr[i]);
|
||||
if (value < minValue) {
|
||||
min = arr[i];
|
||||
minValue = value;
|
||||
}
|
||||
}
|
||||
return min;
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
import { err, type Result } from "../types/result.ts";
|
||||
|
||||
export function debounce<T extends (...args: Parameters<T>) => void>(
|
||||
fn: T,
|
||||
ms: number,
|
||||
): T & { cancel: () => void } {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const debounced = ((...args: Parameters<T>) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => {
|
||||
fn(...args);
|
||||
timeoutId = null;
|
||||
}, ms);
|
||||
}) as T & { cancel: () => void };
|
||||
|
||||
debounced.cancel = () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
};
|
||||
|
||||
return debounced;
|
||||
}
|
||||
|
||||
export function throttle<T extends (...args: Parameters<T>) => void>(
|
||||
fn: T,
|
||||
ms: number,
|
||||
): T {
|
||||
let lastCall = 0;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
return ((...args: Parameters<T>) => {
|
||||
const now = Date.now();
|
||||
const remaining = ms - (now - lastCall);
|
||||
|
||||
if (remaining <= 0) {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
lastCall = now;
|
||||
fn(...args);
|
||||
} else if (!timeoutId) {
|
||||
timeoutId = setTimeout(() => {
|
||||
lastCall = Date.now();
|
||||
timeoutId = null;
|
||||
fn(...args);
|
||||
}, remaining);
|
||||
}
|
||||
}) as T;
|
||||
}
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function retry<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: {
|
||||
attempts?: number;
|
||||
delay?: number;
|
||||
backoff?: number;
|
||||
shouldRetry?: (error: unknown, attempt: number) => boolean;
|
||||
} = {},
|
||||
): Promise<T> {
|
||||
const {
|
||||
attempts = 3,
|
||||
delay = 1000,
|
||||
backoff = 2,
|
||||
shouldRetry = () => true,
|
||||
} = options;
|
||||
|
||||
let lastError: unknown;
|
||||
let currentDelay = delay;
|
||||
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt === attempts || !shouldRetry(error, attempt)) {
|
||||
throw error;
|
||||
}
|
||||
await sleep(currentDelay);
|
||||
currentDelay *= backoff;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export async function retryResult<T, E>(
|
||||
fn: () => Promise<Result<T, E>>,
|
||||
options: {
|
||||
attempts?: number;
|
||||
delay?: number;
|
||||
backoff?: number;
|
||||
shouldRetry?: (error: E, attempt: number) => boolean;
|
||||
} = {},
|
||||
): Promise<Result<T, E>> {
|
||||
const {
|
||||
attempts = 3,
|
||||
delay = 1000,
|
||||
backoff = 2,
|
||||
shouldRetry = () => true,
|
||||
} = options;
|
||||
|
||||
let lastResult: Result<T, E> | null = null;
|
||||
let currentDelay = delay;
|
||||
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
const result = await fn();
|
||||
lastResult = result;
|
||||
|
||||
if (result.ok) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (attempt === attempts || !shouldRetry(result.error, attempt)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
await sleep(currentDelay);
|
||||
currentDelay *= backoff;
|
||||
}
|
||||
|
||||
return lastResult!;
|
||||
}
|
||||
|
||||
export function timeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
reject(new Error(`Timeout after ${ms}ms`));
|
||||
}, ms);
|
||||
|
||||
promise
|
||||
.then((value) => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve(value);
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function timeoutResult<T>(
|
||||
promise: Promise<Result<T, Error>>,
|
||||
ms: number,
|
||||
): Promise<Result<T, Error>> {
|
||||
try {
|
||||
return await timeout(promise, ms);
|
||||
} catch (e) {
|
||||
return err(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
}
|
||||
|
||||
export async function parallel<T>(
|
||||
tasks: (() => Promise<T>)[],
|
||||
concurrency: number,
|
||||
): Promise<T[]> {
|
||||
const results: T[] = [];
|
||||
const executing: Promise<void>[] = [];
|
||||
|
||||
for (const task of tasks) {
|
||||
const p = task().then((result) => {
|
||||
results.push(result);
|
||||
});
|
||||
|
||||
executing.push(p);
|
||||
|
||||
if (executing.length >= concurrency) {
|
||||
await Promise.race(executing);
|
||||
executing.splice(
|
||||
executing.findIndex((e) => e === p),
|
||||
1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(executing);
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function mapParallel<T, U>(
|
||||
items: T[],
|
||||
fn: (item: T, index: number) => Promise<U>,
|
||||
concurrency: number,
|
||||
): Promise<U[]> {
|
||||
const results: U[] = new Array(items.length);
|
||||
const executing: Promise<void>[] = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const index = i;
|
||||
const p = fn(items[index], index).then((result) => {
|
||||
results[index] = result;
|
||||
});
|
||||
|
||||
executing.push(p);
|
||||
|
||||
if (executing.length >= concurrency) {
|
||||
await Promise.race(executing);
|
||||
const doneIndex = executing.findIndex(
|
||||
(e) => (e as Promise<void> & { _done?: boolean })._done !== false,
|
||||
);
|
||||
if (doneIndex >= 0) {
|
||||
executing.splice(doneIndex, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(executing);
|
||||
return results;
|
||||
}
|
||||
|
||||
export function createAbortable<T>(
|
||||
fn: (signal: AbortSignal) => Promise<T>,
|
||||
): { promise: Promise<T>; abort: () => void } {
|
||||
const controller = new AbortController();
|
||||
return {
|
||||
promise: fn(controller.signal),
|
||||
abort: () => controller.abort(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
export * from "./option.ts";
|
||||
export {
|
||||
at,
|
||||
chunk,
|
||||
find,
|
||||
findIndex,
|
||||
findMap,
|
||||
first,
|
||||
groupBy,
|
||||
intersperse,
|
||||
isEmpty,
|
||||
isNonEmpty,
|
||||
last,
|
||||
maxBy,
|
||||
minBy,
|
||||
partition,
|
||||
range,
|
||||
sortBy,
|
||||
sortByDesc,
|
||||
sum,
|
||||
sumBy,
|
||||
unique,
|
||||
uniqueBy,
|
||||
zip as zipArrays,
|
||||
zipWith as zipArraysWith,
|
||||
} from "./array.ts";
|
||||
export * from "./async.ts";
|
||||
@@ -1,85 +0,0 @@
|
||||
export type Option<T> = T | null | undefined;
|
||||
|
||||
export function isSome<T>(opt: Option<T>): opt is T {
|
||||
return opt != null;
|
||||
}
|
||||
|
||||
export function isNone<T>(opt: Option<T>): opt is null | undefined {
|
||||
return opt == null;
|
||||
}
|
||||
|
||||
export function map<T, U>(opt: Option<T>, fn: (t: T) => U): Option<U> {
|
||||
return isSome(opt) ? fn(opt) : null;
|
||||
}
|
||||
|
||||
export function flatMap<T, U>(
|
||||
opt: Option<T>,
|
||||
fn: (t: T) => Option<U>,
|
||||
): Option<U> {
|
||||
return isSome(opt) ? fn(opt) : null;
|
||||
}
|
||||
|
||||
export function filter<T>(
|
||||
opt: Option<T>,
|
||||
predicate: (t: T) => boolean,
|
||||
): Option<T> {
|
||||
return isSome(opt) && predicate(opt) ? opt : null;
|
||||
}
|
||||
|
||||
export function getOrElse<T>(opt: Option<T>, defaultValue: T): T {
|
||||
return isSome(opt) ? opt : defaultValue;
|
||||
}
|
||||
|
||||
export function getOrElseLazy<T>(opt: Option<T>, fn: () => T): T {
|
||||
return isSome(opt) ? opt : fn();
|
||||
}
|
||||
|
||||
export function getOrThrow<T>(opt: Option<T>, error?: string | Error): T {
|
||||
if (isSome(opt)) return opt;
|
||||
if (error instanceof Error) throw error;
|
||||
throw new Error(error ?? "Expected value but got null/undefined");
|
||||
}
|
||||
|
||||
export function tap<T>(opt: Option<T>, fn: (t: T) => void): Option<T> {
|
||||
if (isSome(opt)) fn(opt);
|
||||
return opt;
|
||||
}
|
||||
|
||||
export function match<T, U>(
|
||||
opt: Option<T>,
|
||||
handlers: { some: (t: T) => U; none: () => U },
|
||||
): U {
|
||||
return isSome(opt) ? handlers.some(opt) : handlers.none();
|
||||
}
|
||||
|
||||
export function toArray<T>(opt: Option<T>): T[] {
|
||||
return isSome(opt) ? [opt] : [];
|
||||
}
|
||||
|
||||
export function fromArray<T>(arr: T[]): Option<T> {
|
||||
return arr.length > 0 ? arr[0] : null;
|
||||
}
|
||||
|
||||
export function zip<T, U>(a: Option<T>, b: Option<U>): Option<[T, U]> {
|
||||
return isSome(a) && isSome(b) ? [a, b] : null;
|
||||
}
|
||||
|
||||
export function zipWith<T, U, R>(
|
||||
a: Option<T>,
|
||||
b: Option<U>,
|
||||
fn: (t: T, u: U) => R,
|
||||
): Option<R> {
|
||||
return isSome(a) && isSome(b) ? fn(a, b) : null;
|
||||
}
|
||||
|
||||
export function or<T>(a: Option<T>, b: Option<T>): Option<T> {
|
||||
return isSome(a) ? a : b;
|
||||
}
|
||||
|
||||
export function orLazy<T>(a: Option<T>, fn: () => Option<T>): Option<T> {
|
||||
return isSome(a) ? a : fn();
|
||||
}
|
||||
|
||||
export function and<T, U>(a: Option<T>, b: Option<U>): Option<U> {
|
||||
return isSome(a) ? b : null;
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
import { err, ok, type Result } from "./types/result.ts";
|
||||
import {
|
||||
type AtUri,
|
||||
type Cid,
|
||||
type Did,
|
||||
type DidPlc,
|
||||
type DidWeb,
|
||||
type EmailAddress,
|
||||
type Handle,
|
||||
isAtUri,
|
||||
isCid,
|
||||
isDid,
|
||||
isDidPlc,
|
||||
isDidWeb,
|
||||
isEmail,
|
||||
isHandle,
|
||||
isISODate,
|
||||
isNsid,
|
||||
type ISODateString,
|
||||
type Nsid,
|
||||
} from "./types/branded.ts";
|
||||
|
||||
export class ValidationError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly field?: string,
|
||||
public readonly value?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
export function parseDid(s: string): Result<Did, ValidationError> {
|
||||
if (isDid(s)) {
|
||||
return ok(s);
|
||||
}
|
||||
return err(new ValidationError(`Invalid DID: ${s}`, "did", s));
|
||||
}
|
||||
|
||||
export function parseDidPlc(s: string): Result<DidPlc, ValidationError> {
|
||||
if (isDidPlc(s)) {
|
||||
return ok(s);
|
||||
}
|
||||
return err(new ValidationError(`Invalid DID:PLC: ${s}`, "did", s));
|
||||
}
|
||||
|
||||
export function parseDidWeb(s: string): Result<DidWeb, ValidationError> {
|
||||
if (isDidWeb(s)) {
|
||||
return ok(s);
|
||||
}
|
||||
return err(new ValidationError(`Invalid DID:WEB: ${s}`, "did", s));
|
||||
}
|
||||
|
||||
export function parseHandle(s: string): Result<Handle, ValidationError> {
|
||||
const trimmed = s.trim().toLowerCase();
|
||||
if (isHandle(trimmed)) {
|
||||
return ok(trimmed);
|
||||
}
|
||||
return err(new ValidationError(`Invalid handle: ${s}`, "handle", s));
|
||||
}
|
||||
|
||||
export function parseEmail(s: string): Result<EmailAddress, ValidationError> {
|
||||
const trimmed = s.trim().toLowerCase();
|
||||
if (isEmail(trimmed)) {
|
||||
return ok(trimmed);
|
||||
}
|
||||
return err(new ValidationError(`Invalid email: ${s}`, "email", s));
|
||||
}
|
||||
|
||||
export function parseAtUri(s: string): Result<AtUri, ValidationError> {
|
||||
if (isAtUri(s)) {
|
||||
return ok(s);
|
||||
}
|
||||
return err(new ValidationError(`Invalid AT-URI: ${s}`, "uri", s));
|
||||
}
|
||||
|
||||
export function parseCid(s: string): Result<Cid, ValidationError> {
|
||||
if (isCid(s)) {
|
||||
return ok(s);
|
||||
}
|
||||
return err(new ValidationError(`Invalid CID: ${s}`, "cid", s));
|
||||
}
|
||||
|
||||
export function parseNsid(s: string): Result<Nsid, ValidationError> {
|
||||
if (isNsid(s)) {
|
||||
return ok(s);
|
||||
}
|
||||
return err(new ValidationError(`Invalid NSID: ${s}`, "nsid", s));
|
||||
}
|
||||
|
||||
export function parseISODate(
|
||||
s: string,
|
||||
): Result<ISODateString, ValidationError> {
|
||||
if (isISODate(s)) {
|
||||
return ok(s);
|
||||
}
|
||||
return err(new ValidationError(`Invalid ISO date: ${s}`, "date", s));
|
||||
}
|
||||
|
||||
export interface PasswordValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
strength: "weak" | "fair" | "good" | "strong";
|
||||
}
|
||||
|
||||
export function validatePassword(password: string): PasswordValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (password.length < 8) {
|
||||
errors.push("Password must be at least 8 characters");
|
||||
}
|
||||
if (password.length > 256) {
|
||||
errors.push("Password must be at most 256 characters");
|
||||
}
|
||||
if (!/[a-z]/.test(password)) {
|
||||
errors.push("Password must contain a lowercase letter");
|
||||
}
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
errors.push("Password must contain an uppercase letter");
|
||||
}
|
||||
if (!/\d/.test(password)) {
|
||||
errors.push("Password must contain a number");
|
||||
}
|
||||
|
||||
let strength: PasswordValidationResult["strength"] = "weak";
|
||||
if (errors.length === 0) {
|
||||
const hasSpecial = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password);
|
||||
const isLong = password.length >= 12;
|
||||
const isVeryLong = password.length >= 16;
|
||||
|
||||
if (isVeryLong && hasSpecial) {
|
||||
strength = "strong";
|
||||
} else if (isLong || hasSpecial) {
|
||||
strength = "good";
|
||||
} else {
|
||||
strength = "fair";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
strength,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateHandle(
|
||||
handle: string,
|
||||
): Result<Handle, ValidationError> {
|
||||
const trimmed = handle.trim().toLowerCase();
|
||||
|
||||
if (trimmed.length < 3) {
|
||||
return err(
|
||||
new ValidationError(
|
||||
"Handle must be at least 3 characters",
|
||||
"handle",
|
||||
handle,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (trimmed.length > 253) {
|
||||
return err(
|
||||
new ValidationError(
|
||||
"Handle must be at most 253 characters",
|
||||
"handle",
|
||||
handle,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isHandle(trimmed)) {
|
||||
return err(new ValidationError("Invalid handle format", "handle", handle));
|
||||
}
|
||||
|
||||
return ok(trimmed);
|
||||
}
|
||||
|
||||
export function validateInviteCode(
|
||||
code: string,
|
||||
): Result<string, ValidationError> {
|
||||
const trimmed = code.trim();
|
||||
|
||||
if (trimmed.length === 0) {
|
||||
return err(
|
||||
new ValidationError("Invite code is required", "inviteCode", code),
|
||||
);
|
||||
}
|
||||
|
||||
const pattern = /^[a-zA-Z0-9-]+$/;
|
||||
if (!pattern.test(trimmed)) {
|
||||
return err(
|
||||
new ValidationError("Invalid invite code format", "inviteCode", code),
|
||||
);
|
||||
}
|
||||
|
||||
return ok(trimmed);
|
||||
}
|
||||
|
||||
export function validateTotpCode(
|
||||
code: string,
|
||||
): Result<string, ValidationError> {
|
||||
const trimmed = code.trim().replace(/\s/g, "");
|
||||
|
||||
if (!/^\d{6}$/.test(trimmed)) {
|
||||
return err(new ValidationError("TOTP code must be 6 digits", "code", code));
|
||||
}
|
||||
|
||||
return ok(trimmed);
|
||||
}
|
||||
|
||||
export function validateBackupCode(
|
||||
code: string,
|
||||
): Result<string, ValidationError> {
|
||||
const trimmed = code.trim().replace(/\s/g, "").toLowerCase();
|
||||
|
||||
if (!/^[a-z0-9]{8}$/.test(trimmed)) {
|
||||
return err(new ValidationError("Invalid backup code format", "code", code));
|
||||
}
|
||||
|
||||
return ok(trimmed);
|
||||
}
|
||||
|
||||
export interface FormValidation<T> {
|
||||
validate: () => Result<T, ValidationError[]>;
|
||||
field: <K extends keyof T>(
|
||||
key: K,
|
||||
validator: (value: unknown) => Result<T[K], ValidationError>,
|
||||
) => FormValidation<T>;
|
||||
optional: <K extends keyof T>(
|
||||
key: K,
|
||||
validator: (value: unknown) => Result<T[K], ValidationError>,
|
||||
) => FormValidation<T>;
|
||||
}
|
||||
|
||||
export function createFormValidation<T extends Record<string, unknown>>(
|
||||
data: Record<string, unknown>,
|
||||
): FormValidation<T> {
|
||||
const validators: Array<{
|
||||
key: string;
|
||||
validator: (value: unknown) => Result<unknown, ValidationError>;
|
||||
optional: boolean;
|
||||
}> = [];
|
||||
|
||||
const builder: FormValidation<T> = {
|
||||
field: (key, validator) => {
|
||||
validators.push({ key: key as string, validator, optional: false });
|
||||
return builder;
|
||||
},
|
||||
optional: (key, validator) => {
|
||||
validators.push({ key: key as string, validator, optional: true });
|
||||
return builder;
|
||||
},
|
||||
validate: () => {
|
||||
const errors: ValidationError[] = [];
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const { key, validator, optional } of validators) {
|
||||
const value = data[key];
|
||||
|
||||
if (value == null || value === "") {
|
||||
if (!optional) {
|
||||
errors.push(new ValidationError(`${key} is required`, key));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const validated = validator(value);
|
||||
if (validated.ok) {
|
||||
result[key] = validated.value;
|
||||
} else {
|
||||
errors.push(validated.error);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return err(errors);
|
||||
}
|
||||
|
||||
return ok(result as T);
|
||||
},
|
||||
};
|
||||
|
||||
return builder;
|
||||
}
|
||||
@@ -1,17 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
AtprotoClient,
|
||||
base64UrlDecode,
|
||||
base64UrlEncode,
|
||||
buildOAuthAuthorizationUrl,
|
||||
clearDPoPKey,
|
||||
generateDPoPKeyPair,
|
||||
generateOAuthState,
|
||||
generatePKCE,
|
||||
getMigrationOAuthClientId,
|
||||
getMigrationOAuthRedirectUri,
|
||||
loadDPoPKey,
|
||||
prepareWebAuthnCreationOptions,
|
||||
saveDPoPKey,
|
||||
} from "../../lib/migration/atproto-client.ts";
|
||||
import type { OAuthServerMetadata } from "../../lib/migration/types.ts";
|
||||
@@ -23,135 +18,6 @@ describe("migration/atproto-client", () => {
|
||||
localStorage.removeItem(DPOP_KEY_STORAGE);
|
||||
});
|
||||
|
||||
describe("base64UrlEncode", () => {
|
||||
it("encodes empty buffer", () => {
|
||||
const result = base64UrlEncode(new Uint8Array([]));
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("encodes simple data", () => {
|
||||
const data = new TextEncoder().encode("hello");
|
||||
const result = base64UrlEncode(data);
|
||||
expect(result).toBe("aGVsbG8");
|
||||
});
|
||||
|
||||
it("uses URL-safe characters (no +, /, or =)", () => {
|
||||
const data = new Uint8Array([251, 255, 254]);
|
||||
const result = base64UrlEncode(data);
|
||||
expect(result).not.toContain("+");
|
||||
expect(result).not.toContain("/");
|
||||
expect(result).not.toContain("=");
|
||||
});
|
||||
|
||||
it("replaces + with -", () => {
|
||||
const data = new Uint8Array([251]);
|
||||
const result = base64UrlEncode(data);
|
||||
expect(result).toContain("-");
|
||||
});
|
||||
|
||||
it("replaces / with _", () => {
|
||||
const data = new Uint8Array([255]);
|
||||
const result = base64UrlEncode(data);
|
||||
expect(result).toContain("_");
|
||||
});
|
||||
|
||||
it("accepts ArrayBuffer", () => {
|
||||
const arrayBuffer = new ArrayBuffer(4);
|
||||
const view = new Uint8Array(arrayBuffer);
|
||||
view[0] = 116; // t
|
||||
view[1] = 101; // e
|
||||
view[2] = 115; // s
|
||||
view[3] = 116; // t
|
||||
const result = base64UrlEncode(arrayBuffer);
|
||||
expect(result).toBe("dGVzdA");
|
||||
});
|
||||
});
|
||||
|
||||
describe("base64UrlDecode", () => {
|
||||
it("decodes empty string", () => {
|
||||
const result = base64UrlDecode("");
|
||||
expect(result.length).toBe(0);
|
||||
});
|
||||
|
||||
it("decodes URL-safe base64", () => {
|
||||
const result = base64UrlDecode("aGVsbG8");
|
||||
expect(new TextDecoder().decode(result)).toBe("hello");
|
||||
});
|
||||
|
||||
it("handles - and _ characters", () => {
|
||||
const encoded = base64UrlEncode(new Uint8Array([251, 255, 254]));
|
||||
const decoded = base64UrlDecode(encoded);
|
||||
expect(decoded).toEqual(new Uint8Array([251, 255, 254]));
|
||||
});
|
||||
|
||||
it("is inverse of base64UrlEncode", () => {
|
||||
const original = new Uint8Array([0, 1, 2, 255, 254, 253]);
|
||||
const encoded = base64UrlEncode(original);
|
||||
const decoded = base64UrlDecode(encoded);
|
||||
expect(decoded).toEqual(original);
|
||||
});
|
||||
|
||||
it("handles missing padding", () => {
|
||||
const result = base64UrlDecode("YQ");
|
||||
expect(new TextDecoder().decode(result)).toBe("a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateOAuthState", () => {
|
||||
it("generates a non-empty string", () => {
|
||||
const state = generateOAuthState();
|
||||
expect(state).toBeTruthy();
|
||||
expect(typeof state).toBe("string");
|
||||
});
|
||||
|
||||
it("generates URL-safe characters only", () => {
|
||||
const state = generateOAuthState();
|
||||
expect(state).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
|
||||
it("generates different values each time", () => {
|
||||
const state1 = generateOAuthState();
|
||||
const state2 = generateOAuthState();
|
||||
expect(state1).not.toBe(state2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generatePKCE", () => {
|
||||
it("generates code_verifier and code_challenge", async () => {
|
||||
const pkce = await generatePKCE();
|
||||
expect(pkce.codeVerifier).toBeTruthy();
|
||||
expect(pkce.codeChallenge).toBeTruthy();
|
||||
});
|
||||
|
||||
it("generates URL-safe code_verifier", async () => {
|
||||
const pkce = await generatePKCE();
|
||||
expect(pkce.codeVerifier).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
|
||||
it("generates URL-safe code_challenge", async () => {
|
||||
const pkce = await generatePKCE();
|
||||
expect(pkce.codeChallenge).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
|
||||
it("code_challenge is SHA-256 hash of code_verifier", async () => {
|
||||
const pkce = await generatePKCE();
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(pkce.codeVerifier);
|
||||
const digest = await crypto.subtle.digest("SHA-256", data);
|
||||
const expectedChallenge = base64UrlEncode(new Uint8Array(digest));
|
||||
|
||||
expect(pkce.codeChallenge).toBe(expectedChallenge);
|
||||
});
|
||||
|
||||
it("generates different values each time", async () => {
|
||||
const pkce1 = await generatePKCE();
|
||||
const pkce2 = await generatePKCE();
|
||||
expect(pkce1.codeVerifier).not.toBe(pkce2.codeVerifier);
|
||||
expect(pkce1.codeChallenge).not.toBe(pkce2.codeChallenge);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildOAuthAuthorizationUrl", () => {
|
||||
const mockMetadata: OAuthServerMetadata = {
|
||||
issuer: "https://bsky.social",
|
||||
@@ -398,127 +264,6 @@ describe("migration/atproto-client", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareWebAuthnCreationOptions", () => {
|
||||
it("decodes challenge from base64url", () => {
|
||||
const options = {
|
||||
publicKey: {
|
||||
challenge: "dGVzdC1jaGFsbGVuZ2U",
|
||||
user: {
|
||||
id: "dXNlci1pZA",
|
||||
name: "test@example.com",
|
||||
displayName: "Test User",
|
||||
},
|
||||
excludeCredentials: [],
|
||||
rp: { name: "Test" },
|
||||
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
|
||||
},
|
||||
};
|
||||
|
||||
const prepared = prepareWebAuthnCreationOptions(options);
|
||||
|
||||
expect(prepared.challenge).toBeInstanceOf(Uint8Array);
|
||||
expect(new TextDecoder().decode(prepared.challenge as Uint8Array)).toBe(
|
||||
"test-challenge",
|
||||
);
|
||||
});
|
||||
|
||||
it("decodes user.id from base64url", () => {
|
||||
const options = {
|
||||
publicKey: {
|
||||
challenge: "Y2hhbGxlbmdl",
|
||||
user: {
|
||||
id: "dXNlci1pZA",
|
||||
name: "test@example.com",
|
||||
displayName: "Test User",
|
||||
},
|
||||
excludeCredentials: [],
|
||||
rp: { name: "Test" },
|
||||
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
|
||||
},
|
||||
};
|
||||
|
||||
const prepared = prepareWebAuthnCreationOptions(options);
|
||||
|
||||
expect(prepared.user?.id).toBeInstanceOf(Uint8Array);
|
||||
expect(new TextDecoder().decode(prepared.user?.id as Uint8Array)).toBe(
|
||||
"user-id",
|
||||
);
|
||||
});
|
||||
|
||||
it("decodes excludeCredentials ids from base64url", () => {
|
||||
const options = {
|
||||
publicKey: {
|
||||
challenge: "Y2hhbGxlbmdl",
|
||||
user: {
|
||||
id: "dXNlcg",
|
||||
name: "test@example.com",
|
||||
displayName: "Test User",
|
||||
},
|
||||
excludeCredentials: [
|
||||
{ id: "Y3JlZDE", type: "public-key" },
|
||||
{ id: "Y3JlZDI", type: "public-key" },
|
||||
],
|
||||
rp: { name: "Test" },
|
||||
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
|
||||
},
|
||||
};
|
||||
|
||||
const prepared = prepareWebAuthnCreationOptions(options);
|
||||
|
||||
expect(prepared.excludeCredentials).toHaveLength(2);
|
||||
expect(
|
||||
new TextDecoder().decode(
|
||||
prepared.excludeCredentials![0].id as Uint8Array,
|
||||
),
|
||||
).toBe("cred1");
|
||||
expect(
|
||||
new TextDecoder().decode(
|
||||
prepared.excludeCredentials![1].id as Uint8Array,
|
||||
),
|
||||
).toBe("cred2");
|
||||
});
|
||||
|
||||
it("handles empty excludeCredentials", () => {
|
||||
const options = {
|
||||
publicKey: {
|
||||
challenge: "Y2hhbGxlbmdl",
|
||||
user: {
|
||||
id: "dXNlcg",
|
||||
name: "test@example.com",
|
||||
displayName: "Test User",
|
||||
},
|
||||
rp: { name: "Test" },
|
||||
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
|
||||
},
|
||||
};
|
||||
|
||||
const prepared = prepareWebAuthnCreationOptions(options);
|
||||
|
||||
expect(prepared.excludeCredentials).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves other user properties", () => {
|
||||
const options = {
|
||||
publicKey: {
|
||||
challenge: "Y2hhbGxlbmdl",
|
||||
user: {
|
||||
id: "dXNlcg",
|
||||
name: "test@example.com",
|
||||
displayName: "Test User",
|
||||
},
|
||||
excludeCredentials: [],
|
||||
rp: { name: "Test" },
|
||||
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
|
||||
},
|
||||
};
|
||||
|
||||
const prepared = prepareWebAuthnCreationOptions(options);
|
||||
|
||||
expect(prepared.user?.name).toBe("test@example.com");
|
||||
expect(prepared.user?.displayName).toBe("Test User");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AtprotoClient.verifyHandleOwnership", () => {
|
||||
function createMockJsonResponse(data: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
|
||||
@@ -88,6 +88,10 @@ function createInboundState(
|
||||
generatedAppPasswordName: null,
|
||||
handlePreservation: "new",
|
||||
existingHandleVerified: false,
|
||||
verificationChannel: "email",
|
||||
discordUsername: "",
|
||||
telegramUsername: "",
|
||||
signalUsername: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -130,8 +134,7 @@ function createOutboundState(
|
||||
|
||||
describe("migration/storage", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(DPOP_KEY_STORAGE);
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("saveMigrationState", () => {
|
||||
|
||||
@@ -218,7 +218,7 @@ describe("OAuth Registration Flow", () => {
|
||||
});
|
||||
|
||||
const RegisterPassword =
|
||||
(await import("../routes/RegisterPassword.svelte")).default;
|
||||
(await import("../routes/Register.svelte")).default;
|
||||
render(RegisterPassword);
|
||||
|
||||
await waitFor(
|
||||
@@ -253,7 +253,7 @@ describe("OAuth Registration Flow", () => {
|
||||
);
|
||||
|
||||
const RegisterPassword =
|
||||
(await import("../routes/RegisterPassword.svelte")).default;
|
||||
(await import("../routes/Register.svelte")).default;
|
||||
render(RegisterPassword);
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { render } from "@testing-library/svelte";
|
||||
import { tick } from "svelte";
|
||||
import type { ComponentType } from "svelte";
|
||||
|
||||
export async function renderAndWait(
|
||||
component: ComponentType,
|
||||
options?: Parameters<typeof render>[1],
|
||||
) {
|
||||
const result = render(component, options);
|
||||
await tick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function waitForElement(
|
||||
queryFn: () => HTMLElement | null,
|
||||
timeout = 1000,
|
||||
): Promise<HTMLElement> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
const element = queryFn();
|
||||
if (element) return element;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error("Element not found within timeout");
|
||||
}
|
||||
|
||||
export async function waitForElementToDisappear(
|
||||
queryFn: () => HTMLElement | null,
|
||||
timeout = 1000,
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
const element = queryFn();
|
||||
if (!element) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error("Element still present after timeout");
|
||||
}
|
||||
|
||||
export async function waitForText(
|
||||
container: HTMLElement,
|
||||
text: string | RegExp,
|
||||
timeout = 1000,
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
const content = container.textContent || "";
|
||||
if (
|
||||
typeof text === "string" ? content.includes(text) : text.test(content)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error(`Text "${text}" not found within timeout`);
|
||||
}
|
||||
|
||||
export function mockLocalStorage(
|
||||
initialData: Record<string, string> = {},
|
||||
): void {
|
||||
const store: Record<string, string> = { ...initialData };
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: {
|
||||
getItem: (key: string) => store[key] || null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store[key] = value;
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
delete store[key];
|
||||
},
|
||||
clear: () => {
|
||||
Object.keys(store).forEach((key) => delete store[key]);
|
||||
},
|
||||
key: (index: number) => Object.keys(store)[index] || null,
|
||||
get length() {
|
||||
return Object.keys(store).length;
|
||||
},
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function setAuthState(session: {
|
||||
did: string;
|
||||
handle: string;
|
||||
email?: string;
|
||||
emailConfirmed?: boolean;
|
||||
accessJwt: string;
|
||||
refreshJwt: string;
|
||||
}): void {
|
||||
localStorage.setItem("session", JSON.stringify(session));
|
||||
}
|
||||
|
||||
export function clearAuthState(): void {
|
||||
localStorage.removeItem("session");
|
||||
}
|
||||
Reference in New Issue
Block a user