+7
-1204
Diff
round #2
-205
frontend/src/lib/utils/array.ts
-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
-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
-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
-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
-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
-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
+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
+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
-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
oyster.cafe
submitted
#2
1 commit
expand
collapse
refactor(frontend): delete utility libraries and validation
expand 0 comments
pull request successfully merged
oyster.cafe
submitted
#1
1 commit
expand
collapse
refactor(frontend): delete utility libraries and validation
expand 0 comments
oyster.cafe
submitted
#0
1 commit
expand
collapse
refactor(frontend): delete utility libraries and validation