diff --git a/Cargo.toml b/Cargo.toml index facabed..d20935b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,6 @@ name = "tranquil-pds" version = "0.1.0" edition = "2024" license = "AGPL-3.0-or-later" -license-file = "LICENSE-AGPL-3.0-or-later" [dependencies] anyhow = "1.0.100" async-trait = "0.1.89" diff --git a/frontend/deno.json b/frontend/deno.json index 642eca7..6f3d5a0 100644 --- a/frontend/deno.json +++ b/frontend/deno.json @@ -3,6 +3,7 @@ "dev": "deno run -A npm:vite", "build": "deno run -A npm:vite build", "preview": "deno run -A npm:vite preview", + "check": "deno run -A npm:svelte-check --tsconfig ./tsconfig.json", "test": "deno run -A npm:vitest", "test:run": "deno run -A npm:vitest run", "test:watch": "deno run -A npm:vitest watch", diff --git a/frontend/deno.lock b/frontend/deno.lock index c3a9dc8..4535f15 100644 --- a/frontend/deno.lock +++ b/frontend/deno.lock @@ -12,8 +12,11 @@ "npm:@testing-library/user-event@^14.6.1": "14.6.1_@testing-library+dom@10.4.1", "npm:jsdom@^25.0.1": "25.0.1", "npm:multiformats@^13.4.2": "13.4.2", + "npm:svelte-check@*": "4.3.5_svelte@5.46.1__acorn@8.15.0_typescript@5.9.3", + "npm:svelte-check@^4.3.5": "4.3.5_svelte@5.46.1__acorn@8.15.0_typescript@5.9.3", "npm:svelte-i18n@^4.0.1": "4.0.1_svelte@5.46.1__acorn@8.15.0", "npm:svelte@^5.46.1": "5.46.1_acorn@8.15.0", + "npm:typescript@^5.9.3": "5.9.3", "npm:vite@*": "7.3.0_picomatch@4.0.3", "npm:vite@^7.3.0": "7.3.0_picomatch@4.0.3", "npm:vitest@*": "4.0.16_jsdom@25.0.1_vite@7.3.0__picomatch@4.0.3", @@ -765,6 +768,12 @@ "chai@6.2.2": { "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==" }, + "chokidar@4.0.3": { + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dependencies": [ + "readdirp" + ] + }, "cli-color@2.0.4": { "integrity": "sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA==", "dependencies": [ @@ -1271,6 +1280,9 @@ "react-is@17.0.2": { "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, + "readdirp@4.1.2": { + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==" + }, "redent@3.0.0": { "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", "dependencies": [ @@ -1349,6 +1361,19 @@ "min-indent" ] }, + "svelte-check@4.3.5_svelte@5.46.1__acorn@8.15.0_typescript@5.9.3": { + "integrity": "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==", + "dependencies": [ + "@jridgewell/trace-mapping", + "chokidar", + "fdir", + "picocolors", + "sade", + "svelte", + "typescript" + ], + "bin": true + }, "svelte-i18n@4.0.1_svelte@5.46.1__acorn@8.15.0": { "integrity": "sha512-jaykGlGT5PUaaq04JWbJREvivlCnALtT+m87Kbm0fxyYHynkQaxQMnIKHLm2WeIuBRoljzwgyvz0Z6/CMwfdmQ==", "dependencies": [ @@ -1444,6 +1469,10 @@ "type@2.7.3": { "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==" }, + "typescript@5.9.3": { + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "bin": true + }, "unicode-segmenter@0.14.5": { "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==" }, @@ -1565,8 +1594,10 @@ "npm:@testing-library/user-event@^14.6.1", "npm:jsdom@^25.0.1", "npm:multiformats@^13.4.2", + "npm:svelte-check@^4.3.5", "npm:svelte-i18n@^4.0.1", "npm:svelte@^5.46.1", + "npm:typescript@^5.9.3", "npm:vite@^7.3.0", "npm:vitest@^4.0.16", "npm:zod@^4.3.5" diff --git a/frontend/package.json b/frontend/package.json index d0aa605..7f5a42b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,8 @@ "@testing-library/user-event": "^14.6.1", "jsdom": "^25.0.1", "svelte": "^5.46.1", + "svelte-check": "^4.3.5", + "typescript": "^5.9.3", "vite": "^7.3.0", "vitest": "^4.0.16" } diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 1adaa5a..257e13c 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -53,7 +53,7 @@ initServerConfig() initAuth().then(({ oauthLoginCompleted }) => { if (oauthLoginCompleted) { - navigate('/dashboard', true) + navigate('/dashboard', { replace: true }) } oauthCallbackPending = false }) @@ -64,9 +64,9 @@ const path = getCurrentPath() if (path === '/') { if (auth.kind === 'authenticated') { - navigate('/dashboard', true) + navigate('/dashboard', { replace: true }) } else { - navigate('/login', true) + navigate('/login', { replace: true }) } } }) diff --git a/frontend/src/components/ReauthModal.svelte b/frontend/src/components/ReauthModal.svelte index e0a27ae..d69a7e6 100644 --- a/frontend/src/components/ReauthModal.svelte +++ b/frontend/src/components/ReauthModal.svelte @@ -106,7 +106,7 @@ return } const { options } = await api.reauthPasskeyStart(token) - const publicKeyOptions = prepareRequestOptions(options as WebAuthnRequestOptionsResponse) + const publicKeyOptions = prepareRequestOptions(options as unknown as WebAuthnRequestOptionsResponse) const credential = await navigator.credentials.get({ publicKey: publicKeyOptions }) diff --git a/frontend/src/components/migration/InboundWizard.svelte b/frontend/src/components/migration/InboundWizard.svelte index a935390..58c6a90 100644 --- a/frontend/src/components/migration/InboundWizard.svelte +++ b/frontend/src/components/migration/InboundWizard.svelte @@ -81,6 +81,7 @@ }, 3000) return () => clearInterval(interval) } + return undefined }) async function loadServerInfo() { diff --git a/frontend/src/components/migration/OfflineInboundWizard.svelte b/frontend/src/components/migration/OfflineInboundWizard.svelte index 5c6d29d..8de47b3 100644 --- a/frontend/src/components/migration/OfflineInboundWizard.svelte +++ b/frontend/src/components/migration/OfflineInboundWizard.svelte @@ -62,6 +62,7 @@ }, 3000) return () => clearInterval(interval) } + return undefined }) async function loadServerInfo() { diff --git a/frontend/src/lib/api-validated.ts b/frontend/src/lib/api-validated.ts index 827eff8..04d18ef 100644 --- a/frontend/src/lib/api-validated.ts +++ b/frontend/src/lib/api-validated.ts @@ -1,98 +1,104 @@ -import { z } from 'zod' -import { ok, err, type Result } from './types/result' -import { ApiError } from './api' -import type { AccessToken, RefreshToken, Did, Handle, Nsid, Rkey } from './types/branded' +import { z } from "zod"; +import { err, ok, type Result } from "./types/result.ts"; +import { ApiError } from "./api.ts"; +import type { + AccessToken, + Did, + Nsid, + RefreshToken, + Rkey, +} from "./types/branded.ts"; import { - sessionSchema, - serverDescriptionSchema, - appPasswordSchema, - createdAppPasswordSchema, - listSessionsResponseSchema, - totpStatusSchema, - totpSecretSchema, - enableTotpResponseSchema, - listPasskeysResponseSchema, - listTrustedDevicesResponseSchema, - reauthStatusSchema, - notificationPrefsSchema, - didDocumentSchema, - repoDescriptionSchema, - listRecordsResponseSchema, - recordResponseSchema, - createRecordResponseSchema, - serverStatsSchema, - serverConfigSchema, - passwordStatusSchema, - successResponseSchema, - legacyLoginPreferenceSchema, accountInfoSchema, - searchAccountsResponseSchema, - listBackupsResponseSchema, + appPasswordSchema, createBackupResponseSchema, - type ValidatedSession, - type ValidatedServerDescription, - type ValidatedListSessionsResponse, - type ValidatedTotpStatus, - type ValidatedTotpSecret, - type ValidatedEnableTotpResponse, - type ValidatedListPasskeysResponse, - type ValidatedListTrustedDevicesResponse, - type ValidatedReauthStatus, - type ValidatedNotificationPrefs, - type ValidatedDidDocument, - type ValidatedRepoDescription, - type ValidatedListRecordsResponse, - type ValidatedRecordResponse, - type ValidatedCreateRecordResponse, - type ValidatedServerStats, - type ValidatedServerConfig, - type ValidatedPasswordStatus, - type ValidatedSuccessResponse, - type ValidatedLegacyLoginPreference, + createdAppPasswordSchema, + createRecordResponseSchema, + didDocumentSchema, + enableTotpResponseSchema, + legacyLoginPreferenceSchema, + listBackupsResponseSchema, + listPasskeysResponseSchema, + listRecordsResponseSchema, + listSessionsResponseSchema, + listTrustedDevicesResponseSchema, + notificationPrefsSchema, + passwordStatusSchema, + reauthStatusSchema, + recordResponseSchema, + repoDescriptionSchema, + searchAccountsResponseSchema, + serverConfigSchema, + serverDescriptionSchema, + serverStatsSchema, + sessionSchema, + successResponseSchema, + totpSecretSchema, + totpStatusSchema, type ValidatedAccountInfo, - type ValidatedSearchAccountsResponse, - type ValidatedListBackupsResponse, + type ValidatedAppPassword, type ValidatedCreateBackupResponse, type ValidatedCreatedAppPassword, - type ValidatedAppPassword, -} from './types/schemas' + type ValidatedCreateRecordResponse, + type ValidatedDidDocument, + type ValidatedEnableTotpResponse, + type ValidatedLegacyLoginPreference, + type ValidatedListBackupsResponse, + type ValidatedListPasskeysResponse, + type ValidatedListRecordsResponse, + type ValidatedListSessionsResponse, + type ValidatedListTrustedDevicesResponse, + type ValidatedNotificationPrefs, + type ValidatedPasswordStatus, + type ValidatedReauthStatus, + type ValidatedRecordResponse, + type ValidatedRepoDescription, + type ValidatedSearchAccountsResponse, + type ValidatedServerConfig, + type ValidatedServerDescription, + type ValidatedServerStats, + type ValidatedSession, + type ValidatedSuccessResponse, + type ValidatedTotpSecret, + type ValidatedTotpStatus, +} from "./types/schemas.ts"; -const API_BASE = '/xrpc' +const API_BASE = "/xrpc"; interface XrpcOptions { - method?: 'GET' | 'POST' - params?: Record - body?: unknown - token?: string + method?: "GET" | "POST"; + params?: Record; + body?: unknown; + token?: string; } class ValidationError extends Error { constructor( public issues: z.ZodIssue[], - message: string = 'API response validation failed' + message: string = "API response validation failed", ) { - super(message) - this.name = 'ValidationError' + super(message); + this.name = "ValidationError"; } } async function xrpcValidated( method: string, schema: z.ZodType, - options?: XrpcOptions + options?: XrpcOptions, ): Promise> { - const { method: httpMethod = 'GET', params, body, token } = options ?? {} - let url = `${API_BASE}/${method}` + const { method: httpMethod = "GET", params, body, token } = options ?? {}; + let url = `${API_BASE}/${method}`; if (params) { - const searchParams = new URLSearchParams(params) - url += `?${searchParams}` + const searchParams = new URLSearchParams(params); + url += `?${searchParams}`; } - const headers: Record = {} + const headers: Record = {}; if (token) { - headers['Authorization'] = `Bearer ${token}` + headers["Authorization"] = `Bearer ${token}`; } if (body) { - headers['Content-Type'] = 'application/json' + headers["Content-Type"] = "application/json"; } try { @@ -100,166 +106,239 @@ async function xrpcValidated( method: httpMethod, headers, body: body ? JSON.stringify(body) : undefined, - }) + }); if (!res.ok) { const errData = await res.json().catch(() => ({ - error: 'Unknown', + error: "Unknown", message: res.statusText, - })) - return err(new ApiError(res.status, errData.error, errData.message)) + })); + return err(new ApiError(res.status, errData.error, errData.message)); } - const data = await res.json() - const parsed = schema.safeParse(data) + const data = await res.json(); + const parsed = schema.safeParse(data); if (!parsed.success) { - return err(new ValidationError(parsed.error.issues)) + return err(new ValidationError(parsed.error.issues)); } - return ok(parsed.data) + return ok(parsed.data); } catch (e) { if (e instanceof ApiError || e instanceof ValidationError) { - return err(e) + return err(e); } - return err(new ApiError(0, 'Unknown', e instanceof Error ? e.message : String(e))) + return err( + new ApiError(0, "Unknown", e instanceof Error ? e.message : String(e)), + ); } } export const validatedApi = { - getSession(token: AccessToken): Promise> { - return xrpcValidated('com.atproto.server.getSession', sessionSchema, { token }) + getSession( + token: AccessToken, + ): Promise> { + return xrpcValidated("com.atproto.server.getSession", sessionSchema, { + token, + }); }, - refreshSession(refreshJwt: RefreshToken): Promise> { - return xrpcValidated('com.atproto.server.refreshSession', sessionSchema, { - method: 'POST', + refreshSession( + refreshJwt: RefreshToken, + ): Promise> { + return xrpcValidated("com.atproto.server.refreshSession", sessionSchema, { + method: "POST", token: refreshJwt, - }) + }); }, createSession( identifier: string, - password: string + password: string, ): Promise> { - return xrpcValidated('com.atproto.server.createSession', sessionSchema, { - method: 'POST', + return xrpcValidated("com.atproto.server.createSession", sessionSchema, { + method: "POST", body: { identifier, password }, - }) + }); }, - describeServer(): Promise> { - return xrpcValidated('com.atproto.server.describeServer', serverDescriptionSchema) + describeServer(): Promise< + Result + > { + return xrpcValidated( + "com.atproto.server.describeServer", + serverDescriptionSchema, + ); }, listAppPasswords( - token: AccessToken - ): Promise> { + token: AccessToken, + ): Promise< + Result<{ passwords: ValidatedAppPassword[] }, ApiError | ValidationError> + > { return xrpcValidated( - 'com.atproto.server.listAppPasswords', + "com.atproto.server.listAppPasswords", z.object({ passwords: z.array(appPasswordSchema) }), - { token } - ) + { token }, + ); }, createAppPassword( token: AccessToken, name: string, - scopes?: string + scopes?: string, ): Promise> { - return xrpcValidated('com.atproto.server.createAppPassword', createdAppPasswordSchema, { - method: 'POST', + return xrpcValidated( + "com.atproto.server.createAppPassword", + createdAppPasswordSchema, + { + method: "POST", + token, + body: { name, scopes }, + }, + ); + }, + + listSessions( + token: AccessToken, + ): Promise< + Result + > { + return xrpcValidated("_account.listSessions", listSessionsResponseSchema, { token, - body: { name, scopes }, - }) + }); }, - listSessions(token: AccessToken): Promise> { - return xrpcValidated('_account.listSessions', listSessionsResponseSchema, { token }) - }, - - getTotpStatus(token: AccessToken): Promise> { - return xrpcValidated('com.atproto.server.getTotpStatus', totpStatusSchema, { token }) - }, - - createTotpSecret(token: AccessToken): Promise> { - return xrpcValidated('com.atproto.server.createTotpSecret', totpSecretSchema, { - method: 'POST', + getTotpStatus( + token: AccessToken, + ): Promise> { + return xrpcValidated("com.atproto.server.getTotpStatus", totpStatusSchema, { token, - }) + }); + }, + + createTotpSecret( + token: AccessToken, + ): Promise> { + return xrpcValidated( + "com.atproto.server.createTotpSecret", + totpSecretSchema, + { + method: "POST", + token, + }, + ); }, enableTotp( token: AccessToken, - code: string + code: string, ): Promise> { - return xrpcValidated('com.atproto.server.enableTotp', enableTotpResponseSchema, { - method: 'POST', - token, - body: { code }, - }) + return xrpcValidated( + "com.atproto.server.enableTotp", + enableTotpResponseSchema, + { + method: "POST", + token, + body: { code }, + }, + ); }, - listPasskeys(token: AccessToken): Promise> { - return xrpcValidated('com.atproto.server.listPasskeys', listPasskeysResponseSchema, { token }) + listPasskeys( + token: AccessToken, + ): Promise< + Result + > { + return xrpcValidated( + "com.atproto.server.listPasskeys", + listPasskeysResponseSchema, + { token }, + ); }, listTrustedDevices( - token: AccessToken - ): Promise> { - return xrpcValidated('_account.listTrustedDevices', listTrustedDevicesResponseSchema, { token }) + token: AccessToken, + ): Promise< + Result + > { + return xrpcValidated( + "_account.listTrustedDevices", + listTrustedDevicesResponseSchema, + { token }, + ); }, - getReauthStatus(token: AccessToken): Promise> { - return xrpcValidated('_account.getReauthStatus', reauthStatusSchema, { token }) + getReauthStatus( + token: AccessToken, + ): Promise> { + return xrpcValidated("_account.getReauthStatus", reauthStatusSchema, { + token, + }); }, getNotificationPrefs( - token: AccessToken + token: AccessToken, ): Promise> { - return xrpcValidated('_account.getNotificationPrefs', notificationPrefsSchema, { token }) + return xrpcValidated( + "_account.getNotificationPrefs", + notificationPrefsSchema, + { token }, + ); }, - getDidDocument(token: AccessToken): Promise> { - return xrpcValidated('_account.getDidDocument', didDocumentSchema, { token }) + getDidDocument( + token: AccessToken, + ): Promise> { + return xrpcValidated("_account.getDidDocument", didDocumentSchema, { + token, + }); }, describeRepo( token: AccessToken, - repo: Did + repo: Did, ): Promise> { - return xrpcValidated('com.atproto.repo.describeRepo', repoDescriptionSchema, { - token, - params: { repo }, - }) + return xrpcValidated( + "com.atproto.repo.describeRepo", + repoDescriptionSchema, + { + token, + params: { repo }, + }, + ); }, listRecords( token: AccessToken, repo: Did, collection: Nsid, - options?: { limit?: number; cursor?: string; reverse?: boolean } + options?: { limit?: number; cursor?: string; reverse?: boolean }, ): Promise> { - const params: Record = { repo, collection } - if (options?.limit) params.limit = String(options.limit) - if (options?.cursor) params.cursor = options.cursor - if (options?.reverse) params.reverse = 'true' - return xrpcValidated('com.atproto.repo.listRecords', listRecordsResponseSchema, { - token, - params, - }) + const params: Record = { repo, collection }; + if (options?.limit) params.limit = String(options.limit); + if (options?.cursor) params.cursor = options.cursor; + if (options?.reverse) params.reverse = "true"; + return xrpcValidated( + "com.atproto.repo.listRecords", + listRecordsResponseSchema, + { + token, + params, + }, + ); }, getRecord( token: AccessToken, repo: Did, collection: Nsid, - rkey: Rkey + rkey: Rkey, ): Promise> { - return xrpcValidated('com.atproto.repo.getRecord', recordResponseSchema, { + return xrpcValidated("com.atproto.repo.getRecord", recordResponseSchema, { token, params: { repo, collection, rkey }, - }) + }); }, createRecord( @@ -267,79 +346,117 @@ export const validatedApi = { repo: Did, collection: Nsid, record: unknown, - rkey?: Rkey - ): Promise> { - return xrpcValidated('com.atproto.repo.createRecord', createRecordResponseSchema, { - method: 'POST', + rkey?: Rkey, + ): Promise< + Result + > { + return xrpcValidated( + "com.atproto.repo.createRecord", + createRecordResponseSchema, + { + method: "POST", + token, + body: { repo, collection, record, rkey }, + }, + ); + }, + + getServerStats( + token: AccessToken, + ): Promise> { + return xrpcValidated("_admin.getServerStats", serverStatsSchema, { token }); + }, + + getServerConfig(): Promise< + Result + > { + return xrpcValidated("_server.getConfig", serverConfigSchema); + }, + + getPasswordStatus( + token: AccessToken, + ): Promise> { + return xrpcValidated("_account.getPasswordStatus", passwordStatusSchema, { token, - body: { repo, collection, record, rkey }, - }) - }, - - getServerStats(token: AccessToken): Promise> { - return xrpcValidated('_admin.getServerStats', serverStatsSchema, { token }) - }, - - getServerConfig(): Promise> { - return xrpcValidated('_server.getConfig', serverConfigSchema) - }, - - getPasswordStatus(token: AccessToken): Promise> { - return xrpcValidated('_account.getPasswordStatus', passwordStatusSchema, { token }) + }); }, changePassword( token: AccessToken, currentPassword: string, - newPassword: string + newPassword: string, ): Promise> { - return xrpcValidated('_account.changePassword', successResponseSchema, { - method: 'POST', + return xrpcValidated("_account.changePassword", successResponseSchema, { + method: "POST", token, body: { currentPassword, newPassword }, - }) + }); }, getLegacyLoginPreference( - token: AccessToken - ): Promise> { - return xrpcValidated('_account.getLegacyLoginPreference', legacyLoginPreferenceSchema, { token }) + token: AccessToken, + ): Promise< + Result + > { + return xrpcValidated( + "_account.getLegacyLoginPreference", + legacyLoginPreferenceSchema, + { token }, + ); }, getAccountInfo( token: AccessToken, - did: Did + did: Did, ): Promise> { - return xrpcValidated('com.atproto.admin.getAccountInfo', accountInfoSchema, { - token, - params: { did }, - }) + return xrpcValidated( + "com.atproto.admin.getAccountInfo", + accountInfoSchema, + { + token, + params: { did }, + }, + ); }, searchAccounts( token: AccessToken, - options?: { handle?: string; cursor?: string; limit?: number } - ): Promise> { - const params: Record = {} - if (options?.handle) params.handle = options.handle - if (options?.cursor) params.cursor = options.cursor - if (options?.limit) params.limit = String(options.limit) - return xrpcValidated('com.atproto.admin.searchAccounts', searchAccountsResponseSchema, { + options?: { handle?: string; cursor?: string; limit?: number }, + ): Promise< + Result + > { + const params: Record = {}; + if (options?.handle) params.handle = options.handle; + if (options?.cursor) params.cursor = options.cursor; + if (options?.limit) params.limit = String(options.limit); + return xrpcValidated( + "com.atproto.admin.searchAccounts", + searchAccountsResponseSchema, + { + token, + params, + }, + ); + }, + + listBackups( + token: AccessToken, + ): Promise> { + return xrpcValidated("_backup.listBackups", listBackupsResponseSchema, { token, - params, - }) + }); }, - listBackups(token: AccessToken): Promise> { - return xrpcValidated('_backup.listBackups', listBackupsResponseSchema, { token }) - }, - - createBackup(token: AccessToken): Promise> { - return xrpcValidated('_backup.createBackup', createBackupResponseSchema, { - method: 'POST', + createBackup( + token: AccessToken, + ): Promise< + Result + > { + return xrpcValidated("_backup.createBackup", createBackupResponseSchema, { + method: "POST", token, - }) + }); }, -} +}; -export { ValidationError } +export { ValidationError }; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 339ba8f..5c9617f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,90 +1,82 @@ -import { ok, err, type Result } from './types/result' +import { err, ok, type Result } from "./types/result.ts"; import type { - Did, - Handle, AccessToken, - RefreshToken, - Cid, - Rkey, - AtUri, - Nsid, - ISODateString, + Did, EmailAddress, - InviteCode as InviteCodeBrand, -} from './types/branded' + Handle, + Nsid, + RefreshToken, + Rkey, +} from "./types/branded.ts"; import { - unsafeAsDid, - unsafeAsHandle, unsafeAsAccessToken, - unsafeAsRefreshToken, - unsafeAsCid, - unsafeAsISODate, + unsafeAsDid, unsafeAsEmail, - unsafeAsInviteCode, -} from './types/branded' + unsafeAsHandle, + unsafeAsISODate, + unsafeAsRefreshToken, +} from "./types/branded.ts"; import type { - Session, - DidDocument, - AppPassword, - CreatedAppPassword, - InviteCodeInfo, - ServerDescription, - NotificationPrefs, - NotificationHistoryResponse, - ServerStats, - ServerConfig, - UploadBlobResponse, - ListSessionsResponse, - SearchAccountsResponse, - GetInviteCodesResponse, AccountInfo, - RepoDescription, - ListRecordsResponse, - RecordResponse, - CreateRecordResponse, - TotpStatus, - TotpSecret, - EnableTotpResponse, - RegenerateBackupCodesResponse, - ListPasskeysResponse, - StartPasskeyRegistrationResponse, - FinishPasskeyRegistrationResponse, - ListTrustedDevicesResponse, - ReauthStatus, - ReauthResponse, - ReauthPasskeyStartResponse, - ReserveSigningKeyResponse, - RecommendedDidCredentials, - PasskeyAccountCreateResponse, - CompletePasskeySetupResponse, - VerifyTokenResponse, - ListBackupsResponse, - CreateBackupResponse, - SetBackupEnabledResponse, - EmailUpdateResponse, - LegacyLoginPreference, - UpdateLegacyLoginResponse, - UpdateLocaleResponse, - PasswordStatus, - SuccessResponse, - CheckEmailVerifiedResponse, - VerifyMigrationEmailResponse, - ResendMigrationVerificationResponse, - ListReposResponse, - VerificationChannel, - DidType, ApiErrorCode, - VerificationMethod as VerificationMethodType, + AppPassword, + CompletePasskeySetupResponse, + ConfirmSignupResult, CreateAccountParams, CreateAccountResult, - ConfirmSignupResult, -} from './types/api' + CreateBackupResponse, + CreatedAppPassword, + CreateRecordResponse, + DidDocument, + DidType, + EmailUpdateResponse, + EnableTotpResponse, + FinishPasskeyRegistrationResponse, + GetInviteCodesResponse, + InviteCodeInfo, + LegacyLoginPreference, + ListBackupsResponse, + ListPasskeysResponse, + ListRecordsResponse, + ListReposResponse, + ListSessionsResponse, + ListTrustedDevicesResponse, + NotificationHistoryResponse, + NotificationPrefs, + PasskeyAccountCreateResponse, + PasswordStatus, + ReauthPasskeyStartResponse, + ReauthResponse, + ReauthStatus, + RecommendedDidCredentials, + RecordResponse, + RegenerateBackupCodesResponse, + RepoDescription, + ResendMigrationVerificationResponse, + ReserveSigningKeyResponse, + SearchAccountsResponse, + ServerConfig, + ServerDescription, + ServerStats, + Session, + SetBackupEnabledResponse, + StartPasskeyRegistrationResponse, + SuccessResponse, + TotpSecret, + TotpStatus, + UpdateLegacyLoginResponse, + UpdateLocaleResponse, + UploadBlobResponse, + VerificationChannel, + VerifyMigrationEmailResponse, + VerifyTokenResponse, +} from "./types/api.ts"; -const API_BASE = '/xrpc' +const API_BASE = "/xrpc"; export class ApiError extends Error { - public did?: Did - public reauthMethods?: string[] + public did?: Did; + public reauthMethods?: string[]; constructor( public status: number, public error: ApiErrorCode, @@ -92,62 +84,63 @@ export class ApiError extends Error { did?: string, reauthMethods?: string[], ) { - super(message) - this.name = 'ApiError' - this.did = did ? unsafeAsDid(did) : undefined - this.reauthMethods = reauthMethods + super(message); + this.name = "ApiError"; + this.did = did ? unsafeAsDid(did) : undefined; + this.reauthMethods = reauthMethods; } } -let tokenRefreshCallback: (() => Promise) | null = null +let tokenRefreshCallback: (() => Promise) | null = null; export function setTokenRefreshCallback( callback: () => Promise, ) { - tokenRefreshCallback = callback + tokenRefreshCallback = callback; } interface XrpcOptions { - method?: 'GET' | 'POST' - params?: Record - body?: unknown - token?: string - skipRetry?: boolean + method?: "GET" | "POST"; + params?: Record; + body?: unknown; + token?: string; + skipRetry?: boolean; } async function xrpc(method: string, options?: XrpcOptions): Promise { - const { method: httpMethod = 'GET', params, body, token, skipRetry } = - options ?? {} - let url = `${API_BASE}/${method}` + const { method: httpMethod = "GET", params, body, token, skipRetry } = + options ?? {}; + let url = `${API_BASE}/${method}`; if (params) { - const searchParams = new URLSearchParams(params) - url += `?${searchParams}` + const searchParams = new URLSearchParams(params); + url += `?${searchParams}`; } - const headers: Record = {} + const headers: Record = {}; if (token) { - headers['Authorization'] = `Bearer ${token}` + headers["Authorization"] = `Bearer ${token}`; } if (body) { - headers['Content-Type'] = 'application/json' + headers["Content-Type"] = "application/json"; } const res = await fetch(url, { method: httpMethod, headers, body: body ? JSON.stringify(body) : undefined, - }) + }); if (!res.ok) { const errData = await res.json().catch(() => ({ - error: 'Unknown', + error: "Unknown", message: res.statusText, - })) + })); if ( res.status === 401 && - (errData.error === 'AuthenticationFailed' || errData.error === 'ExpiredToken') && + (errData.error === "AuthenticationFailed" || + errData.error === "ExpiredToken") && token && tokenRefreshCallback && !skipRetry ) { - const newToken = await tokenRefreshCallback() + const newToken = await tokenRefreshCallback(); if (newToken && newToken !== token) { - return xrpc(method, { ...options, token: newToken, skipRetry: true }) + return xrpc(method, { ...options, token: newToken, skipRetry: true }); } } throw new ApiError( @@ -156,37 +149,45 @@ async function xrpc(method: string, options?: XrpcOptions): Promise { errData.message, errData.did, errData.reauthMethods, - ) + ); } - return res.json() + return res.json(); } async function xrpcResult( method: string, - options?: XrpcOptions + options?: XrpcOptions, ): Promise> { try { - const value = await xrpc(method, options) - return ok(value) + const value = await xrpc(method, options); + return ok(value); } catch (e) { if (e instanceof ApiError) { - return err(e) + return err(e); } - return err(new ApiError(0, 'Unknown', e instanceof Error ? e.message : String(e))) + return err( + new ApiError(0, "Unknown", e instanceof Error ? e.message : String(e)), + ); } } export interface VerificationMethod { - id: string - type: string - publicKeyMultibase: string + id: string; + type: string; + publicKeyMultibase: string; } -export type { Session, DidDocument, AppPassword, InviteCodeInfo as InviteCode } -export type { VerificationChannel, DidType, CreateAccountParams, CreateAccountResult, ConfirmSignupResult } +export type { AppPassword, DidDocument, InviteCodeInfo as InviteCode, Session }; +export type { + ConfirmSignupResult, + CreateAccountParams, + CreateAccountResult, + DidType, + VerificationChannel, +}; function castSession(raw: unknown): Session { - const s = raw as Record + const s = raw as Record; return { did: unsafeAsDid(s.did as string), handle: unsafeAsHandle(s.handle as string), @@ -196,12 +197,14 @@ function castSession(raw: unknown): Session { preferredChannelVerified: s.preferredChannelVerified as boolean | undefined, isAdmin: s.isAdmin as boolean | undefined, active: s.active as boolean | undefined, - status: s.status as Session['status'], + status: s.status as Session["status"], migratedToPds: s.migratedToPds as string | undefined, - migratedAt: s.migratedAt ? unsafeAsISODate(s.migratedAt as string) : undefined, + migratedAt: s.migratedAt + ? unsafeAsISODate(s.migratedAt as string) + : undefined, accessJwt: unsafeAsAccessToken(s.accessJwt as string), refreshJwt: unsafeAsRefreshToken(s.refreshJwt as string), - } + }; } export const api = { @@ -209,15 +212,15 @@ export const api = { params: CreateAccountParams, byodToken?: string, ): Promise { - const url = `${API_BASE}/com.atproto.server.createAccount` + const url = `${API_BASE}/com.atproto.server.createAccount`; const headers: Record = { - 'Content-Type': 'application/json', - } + "Content-Type": "application/json", + }; if (byodToken) { - headers['Authorization'] = `Bearer ${byodToken}` + headers["Authorization"] = `Bearer ${byodToken}`; } const response = await fetch(url, { - method: 'POST', + method: "POST", headers, body: JSON.stringify({ handle: params.handle, @@ -232,30 +235,30 @@ export const api = { telegramUsername: params.telegramUsername, signalNumber: params.signalNumber, }), - }) - const data = await response.json() + }); + const data = await response.json(); if (!response.ok) { - throw new ApiError(response.status, data.error, data.message) + throw new ApiError(response.status, data.error, data.message); } - return data + return data; }, async createAccountWithServiceAuth( serviceAuthToken: string, params: { - did: Did - handle: Handle - email: EmailAddress - password: string - inviteCode?: string + did: Did; + handle: Handle; + email: EmailAddress; + password: string; + inviteCode?: string; }, ): Promise { - const url = `${API_BASE}/com.atproto.server.createAccount` + const url = `${API_BASE}/com.atproto.server.createAccount`; const response = await fetch(url, { - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${serviceAuthToken}`, + "Content-Type": "application/json", + "Authorization": `Bearer ${serviceAuthToken}`, }, body: JSON.stringify({ did: params.did, @@ -264,68 +267,68 @@ export const api = { password: params.password, inviteCode: params.inviteCode, }), - }) - const data = await response.json() + }); + const data = await response.json(); if (!response.ok) { - throw new ApiError(response.status, data.error, data.message) + throw new ApiError(response.status, data.error, data.message); } - return castSession(data) + return castSession(data); }, confirmSignup( did: Did, verificationCode: string, ): Promise { - return xrpc('com.atproto.server.confirmSignup', { - method: 'POST', + return xrpc("com.atproto.server.confirmSignup", { + method: "POST", body: { did, verificationCode }, - }) + }); }, resendVerification(did: Did): Promise<{ success: boolean }> { - return xrpc('com.atproto.server.resendVerification', { - method: 'POST', + return xrpc("com.atproto.server.resendVerification", { + method: "POST", body: { did }, - }) + }); }, async createSession(identifier: string, password: string): Promise { - const raw = await xrpc('com.atproto.server.createSession', { - method: 'POST', + const raw = await xrpc("com.atproto.server.createSession", { + method: "POST", body: { identifier, password }, - }) - return castSession(raw) + }); + return castSession(raw); }, checkEmailVerified(identifier: string): Promise<{ verified: boolean }> { - return xrpc('_checkEmailVerified', { - method: 'POST', + return xrpc("_checkEmailVerified", { + method: "POST", body: { identifier }, - }) + }); }, async getSession(token: AccessToken): Promise { - const raw = await xrpc('com.atproto.server.getSession', { token }) - return castSession(raw) + const raw = await xrpc("com.atproto.server.getSession", { token }); + return castSession(raw); }, async refreshSession(refreshJwt: RefreshToken): Promise { - const raw = await xrpc('com.atproto.server.refreshSession', { - method: 'POST', + const raw = await xrpc("com.atproto.server.refreshSession", { + method: "POST", token: refreshJwt, - }) - return castSession(raw) + }); + return castSession(raw); }, async deleteSession(token: AccessToken): Promise { - await xrpc('com.atproto.server.deleteSession', { - method: 'POST', + await xrpc("com.atproto.server.deleteSession", { + method: "POST", token, - }) + }); }, listAppPasswords(token: AccessToken): Promise<{ passwords: AppPassword[] }> { - return xrpc('com.atproto.server.listAppPasswords', { token }) + return xrpc("com.atproto.server.listAppPasswords", { token }); }, createAppPassword( @@ -333,55 +336,57 @@ export const api = { name: string, scopes?: string, ): Promise { - return xrpc('com.atproto.server.createAppPassword', { - method: 'POST', + return xrpc("com.atproto.server.createAppPassword", { + method: "POST", token, body: { name, scopes }, - }) + }); }, async revokeAppPassword(token: AccessToken, name: string): Promise { - await xrpc('com.atproto.server.revokeAppPassword', { - method: 'POST', + await xrpc("com.atproto.server.revokeAppPassword", { + method: "POST", token, body: { name }, - }) + }); }, - getAccountInviteCodes(token: AccessToken): Promise<{ codes: InviteCodeInfo[] }> { - return xrpc('com.atproto.server.getAccountInviteCodes', { token }) + getAccountInviteCodes( + token: AccessToken, + ): Promise<{ codes: InviteCodeInfo[] }> { + return xrpc("com.atproto.server.getAccountInviteCodes", { token }); }, createInviteCode( token: AccessToken, useCount: number = 1, ): Promise<{ code: string }> { - return xrpc('com.atproto.server.createInviteCode', { - method: 'POST', + return xrpc("com.atproto.server.createInviteCode", { + method: "POST", token, body: { useCount }, - }) + }); }, async requestPasswordReset(email: EmailAddress): Promise { - await xrpc('com.atproto.server.requestPasswordReset', { - method: 'POST', + await xrpc("com.atproto.server.requestPasswordReset", { + method: "POST", body: { email }, - }) + }); }, async resetPassword(token: string, password: string): Promise { - await xrpc('com.atproto.server.resetPassword', { - method: 'POST', + await xrpc("com.atproto.server.resetPassword", { + method: "POST", body: { token, password }, - }) + }); }, requestEmailUpdate(token: AccessToken): Promise { - return xrpc('com.atproto.server.requestEmailUpdate', { - method: 'POST', + return xrpc("com.atproto.server.requestEmailUpdate", { + method: "POST", token, - }) + }); }, async updateEmail( @@ -389,26 +394,26 @@ export const api = { email: string, emailToken?: string, ): Promise { - await xrpc('com.atproto.server.updateEmail', { - method: 'POST', + await xrpc("com.atproto.server.updateEmail", { + method: "POST", token, body: { email, token: emailToken }, - }) + }); }, async updateHandle(token: AccessToken, handle: Handle): Promise { - await xrpc('com.atproto.identity.updateHandle', { - method: 'POST', + await xrpc("com.atproto.identity.updateHandle", { + method: "POST", token, body: { handle }, - }) + }); }, async requestAccountDelete(token: AccessToken): Promise { - await xrpc('com.atproto.server.requestAccountDelete', { - method: 'POST', + await xrpc("com.atproto.server.requestAccountDelete", { + method: "POST", token, - }) + }); }, async deleteAccount( @@ -416,37 +421,37 @@ export const api = { password: string, deleteToken: string, ): Promise { - await xrpc('com.atproto.server.deleteAccount', { - method: 'POST', + await xrpc("com.atproto.server.deleteAccount", { + method: "POST", body: { did, password, token: deleteToken }, - }) + }); }, describeServer(): Promise { - return xrpc('com.atproto.server.describeServer') + return xrpc("com.atproto.server.describeServer"); }, listRepos(limit?: number): Promise { - const params: Record = {} - if (limit) params.limit = String(limit) - return xrpc('com.atproto.sync.listRepos', { params }) + const params: Record = {}; + if (limit) params.limit = String(limit); + return xrpc("com.atproto.sync.listRepos", { params }); }, getNotificationPrefs(token: AccessToken): Promise { - return xrpc('_account.getNotificationPrefs', { token }) + return xrpc("_account.getNotificationPrefs", { token }); }, updateNotificationPrefs(token: AccessToken, prefs: { - preferredChannel?: string - discordId?: string - telegramUsername?: string - signalNumber?: string + preferredChannel?: string; + discordId?: string; + telegramUsername?: string; + signalNumber?: string; }): Promise { - return xrpc('_account.updateNotificationPrefs', { - method: 'POST', + return xrpc("_account.updateNotificationPrefs", { + method: "POST", token, body: prefs, - }) + }); }, confirmChannelVerification( @@ -455,60 +460,65 @@ export const api = { identifier: string, code: string, ): Promise { - return xrpc('_account.confirmChannelVerification', { - method: 'POST', + return xrpc("_account.confirmChannelVerification", { + method: "POST", token, body: { channel, identifier, code }, - }) + }); }, - getNotificationHistory(token: AccessToken): Promise { - return xrpc('_account.getNotificationHistory', { token }) + getNotificationHistory( + token: AccessToken, + ): Promise { + return xrpc("_account.getNotificationHistory", { token }); }, getServerStats(token: AccessToken): Promise { - return xrpc('_admin.getServerStats', { token }) + return xrpc("_admin.getServerStats", { token }); }, getServerConfig(): Promise { - return xrpc('_server.getConfig') + return xrpc("_server.getConfig"); }, updateServerConfig( token: AccessToken, config: { - serverName?: string - primaryColor?: string - primaryColorDark?: string - secondaryColor?: string - secondaryColorDark?: string - logoCid?: string + serverName?: string; + primaryColor?: string; + primaryColorDark?: string; + secondaryColor?: string; + secondaryColorDark?: string; + logoCid?: string; }, ): Promise { - return xrpc('_admin.updateServerConfig', { - method: 'POST', + return xrpc("_admin.updateServerConfig", { + method: "POST", token, body: config, - }) + }); }, - async uploadBlob(token: AccessToken, file: File): Promise { - const res = await fetch('/xrpc/com.atproto.repo.uploadBlob', { - method: 'POST', + async uploadBlob( + token: AccessToken, + file: File, + ): Promise { + const res = await fetch("/xrpc/com.atproto.repo.uploadBlob", { + method: "POST", headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': file.type, + "Authorization": `Bearer ${token}`, + "Content-Type": file.type, }, body: file, - }) + }); if (!res.ok) { const errData = await res.json().catch(() => ({ - error: 'Unknown', + error: "Unknown", message: res.statusText, - })) - throw new ApiError(res.status, errData.error, errData.message) + })); + throw new ApiError(res.status, errData.error, errData.message); } - return res.json() + return res.json(); }, async changePassword( @@ -516,88 +526,91 @@ export const api = { currentPassword: string, newPassword: string, ): Promise { - await xrpc('_account.changePassword', { - method: 'POST', + await xrpc("_account.changePassword", { + method: "POST", token, body: { currentPassword, newPassword }, - }) + }); }, removePassword(token: AccessToken): Promise { - return xrpc('_account.removePassword', { - method: 'POST', + return xrpc("_account.removePassword", { + method: "POST", token, - }) + }); }, getPasswordStatus(token: AccessToken): Promise { - return xrpc('_account.getPasswordStatus', { token }) + return xrpc("_account.getPasswordStatus", { token }); }, getLegacyLoginPreference(token: AccessToken): Promise { - return xrpc('_account.getLegacyLoginPreference', { token }) + return xrpc("_account.getLegacyLoginPreference", { token }); }, updateLegacyLoginPreference( token: AccessToken, allowLegacyLogin: boolean, ): Promise { - return xrpc('_account.updateLegacyLoginPreference', { - method: 'POST', + return xrpc("_account.updateLegacyLoginPreference", { + method: "POST", token, body: { allowLegacyLogin }, - }) + }); }, - updateLocale(token: AccessToken, preferredLocale: string): Promise { - return xrpc('_account.updateLocale', { - method: 'POST', + updateLocale( + token: AccessToken, + preferredLocale: string, + ): Promise { + return xrpc("_account.updateLocale", { + method: "POST", token, body: { preferredLocale }, - }) + }); }, listSessions(token: AccessToken): Promise { - return xrpc('_account.listSessions', { token }) + return xrpc("_account.listSessions", { token }); }, async revokeSession(token: AccessToken, sessionId: string): Promise { - await xrpc('_account.revokeSession', { - method: 'POST', + await xrpc("_account.revokeSession", { + method: "POST", token, body: { sessionId }, - }) + }); }, revokeAllSessions(token: AccessToken): Promise<{ revokedCount: number }> { - return xrpc('_account.revokeAllSessions', { - method: 'POST', + return xrpc("_account.revokeAllSessions", { + method: "POST", token, - }) + }); }, searchAccounts(token: AccessToken, options?: { - handle?: string - cursor?: string - limit?: number + handle?: string; + cursor?: string; + limit?: number; }): Promise { - const params: Record = {} - if (options?.handle) params.handle = options.handle - if (options?.cursor) params.cursor = options.cursor - if (options?.limit) params.limit = String(options.limit) - return xrpc('com.atproto.admin.searchAccounts', { token, params }) + const params: Record = {}; + if (options?.handle) params.handle = options.handle; + if (options?.cursor) params.cursor = options.cursor; + if (options?.limit) params.limit = String(options.limit); + return xrpc("com.atproto.admin.searchAccounts", { token, params }); }, getInviteCodes(token: AccessToken, options?: { - sort?: 'recent' | 'usage' - cursor?: string - limit?: number + sort?: "recent" | "usage"; + cursor?: string; + limit?: number; }): Promise { - const params: Record = {} - if (options?.sort) params.sort = options.sort - if (options?.cursor) params.cursor = options.cursor - if (options?.limit) params.limit = String(options.limit) - return xrpc('com.atproto.admin.getInviteCodes', { token, params }) + const params: Record = {}; + if (options?.sort) params.sort = options.sort; + if (options?.cursor) params.cursor = options.cursor; + if (options?.limit) params.limit = String(options.limit); + return xrpc("com.atproto.admin.getInviteCodes", { token, params }); }, async disableInviteCodes( @@ -605,58 +618,58 @@ export const api = { codes?: string[], accounts?: string[], ): Promise { - await xrpc('com.atproto.admin.disableInviteCodes', { - method: 'POST', + await xrpc("com.atproto.admin.disableInviteCodes", { + method: "POST", token, body: { codes, accounts }, - }) + }); }, getAccountInfo(token: AccessToken, did: Did): Promise { - return xrpc('com.atproto.admin.getAccountInfo', { token, params: { did } }) + return xrpc("com.atproto.admin.getAccountInfo", { token, params: { did } }); }, async disableAccountInvites(token: AccessToken, account: Did): Promise { - await xrpc('com.atproto.admin.disableAccountInvites', { - method: 'POST', + await xrpc("com.atproto.admin.disableAccountInvites", { + method: "POST", token, body: { account }, - }) + }); }, async enableAccountInvites(token: AccessToken, account: Did): Promise { - await xrpc('com.atproto.admin.enableAccountInvites', { - method: 'POST', + await xrpc("com.atproto.admin.enableAccountInvites", { + method: "POST", token, body: { account }, - }) + }); }, async adminDeleteAccount(token: AccessToken, did: Did): Promise { - await xrpc('com.atproto.admin.deleteAccount', { - method: 'POST', + await xrpc("com.atproto.admin.deleteAccount", { + method: "POST", token, body: { did }, - }) + }); }, describeRepo(token: AccessToken, repo: Did): Promise { - return xrpc('com.atproto.repo.describeRepo', { + return xrpc("com.atproto.repo.describeRepo", { token, params: { repo }, - }) + }); }, listRecords(token: AccessToken, repo: Did, collection: Nsid, options?: { - limit?: number - cursor?: string - reverse?: boolean + limit?: number; + cursor?: string; + reverse?: boolean; }): Promise { - const params: Record = { repo, collection } - if (options?.limit) params.limit = String(options.limit) - if (options?.cursor) params.cursor = options.cursor - if (options?.reverse) params.reverse = 'true' - return xrpc('com.atproto.repo.listRecords', { token, params }) + const params: Record = { repo, collection }; + if (options?.limit) params.limit = String(options.limit); + if (options?.cursor) params.cursor = options.cursor; + if (options?.reverse) params.reverse = "true"; + return xrpc("com.atproto.repo.listRecords", { token, params }); }, getRecord( @@ -665,10 +678,10 @@ export const api = { collection: Nsid, rkey: Rkey, ): Promise { - return xrpc('com.atproto.repo.getRecord', { + return xrpc("com.atproto.repo.getRecord", { token, params: { repo, collection, rkey }, - }) + }); }, createRecord( @@ -678,11 +691,11 @@ export const api = { record: unknown, rkey?: Rkey, ): Promise { - return xrpc('com.atproto.repo.createRecord', { - method: 'POST', + return xrpc("com.atproto.repo.createRecord", { + method: "POST", token, body: { repo, collection, record, rkey }, - }) + }); }, putRecord( @@ -692,11 +705,11 @@ export const api = { rkey: Rkey, record: unknown, ): Promise { - return xrpc('com.atproto.repo.putRecord', { - method: 'POST', + return xrpc("com.atproto.repo.putRecord", { + method: "POST", token, body: { repo, collection, rkey, record }, - }) + }); }, async deleteRecord( @@ -705,30 +718,30 @@ export const api = { collection: Nsid, rkey: Rkey, ): Promise { - await xrpc('com.atproto.repo.deleteRecord', { - method: 'POST', + await xrpc("com.atproto.repo.deleteRecord", { + method: "POST", token, body: { repo, collection, rkey }, - }) + }); }, getTotpStatus(token: AccessToken): Promise { - return xrpc('com.atproto.server.getTotpStatus', { token }) + return xrpc("com.atproto.server.getTotpStatus", { token }); }, createTotpSecret(token: AccessToken): Promise { - return xrpc('com.atproto.server.createTotpSecret', { - method: 'POST', + return xrpc("com.atproto.server.createTotpSecret", { + method: "POST", token, - }) + }); }, enableTotp(token: AccessToken, code: string): Promise { - return xrpc('com.atproto.server.enableTotp', { - method: 'POST', + return xrpc("com.atproto.server.enableTotp", { + method: "POST", token, body: { code }, - }) + }); }, disableTotp( @@ -736,11 +749,11 @@ export const api = { password: string, code: string, ): Promise { - return xrpc('com.atproto.server.disableTotp', { - method: 'POST', + return xrpc("com.atproto.server.disableTotp", { + method: "POST", token, body: { password, code }, - }) + }); }, regenerateBackupCodes( @@ -748,22 +761,22 @@ export const api = { password: string, code: string, ): Promise { - return xrpc('com.atproto.server.regenerateBackupCodes', { - method: 'POST', + return xrpc("com.atproto.server.regenerateBackupCodes", { + method: "POST", token, body: { password, code }, - }) + }); }, startPasskeyRegistration( token: AccessToken, friendlyName?: string, ): Promise { - return xrpc('com.atproto.server.startPasskeyRegistration', { - method: 'POST', + return xrpc("com.atproto.server.startPasskeyRegistration", { + method: "POST", token, body: { friendlyName }, - }) + }); }, finishPasskeyRegistration( @@ -771,23 +784,23 @@ export const api = { credential: unknown, friendlyName?: string, ): Promise { - return xrpc('com.atproto.server.finishPasskeyRegistration', { - method: 'POST', + return xrpc("com.atproto.server.finishPasskeyRegistration", { + method: "POST", token, body: { credential, friendlyName }, - }) + }); }, listPasskeys(token: AccessToken): Promise { - return xrpc('com.atproto.server.listPasskeys', { token }) + return xrpc("com.atproto.server.listPasskeys", { token }); }, async deletePasskey(token: AccessToken, id: string): Promise { - await xrpc('com.atproto.server.deletePasskey', { - method: 'POST', + await xrpc("com.atproto.server.deletePasskey", { + method: "POST", token, body: { id }, - }) + }); }, async updatePasskey( @@ -795,23 +808,26 @@ export const api = { id: string, friendlyName: string, ): Promise { - await xrpc('com.atproto.server.updatePasskey', { - method: 'POST', + await xrpc("com.atproto.server.updatePasskey", { + method: "POST", token, body: { id, friendlyName }, - }) + }); }, listTrustedDevices(token: AccessToken): Promise { - return xrpc('_account.listTrustedDevices', { token }) + return xrpc("_account.listTrustedDevices", { token }); }, - revokeTrustedDevice(token: AccessToken, deviceId: string): Promise { - return xrpc('_account.revokeTrustedDevice', { - method: 'POST', + revokeTrustedDevice( + token: AccessToken, + deviceId: string, + ): Promise { + return xrpc("_account.revokeTrustedDevice", { + method: "POST", token, body: { deviceId }, - }) + }); }, updateTrustedDevice( @@ -819,98 +835,106 @@ export const api = { deviceId: string, friendlyName: string, ): Promise { - return xrpc('_account.updateTrustedDevice', { - method: 'POST', + return xrpc("_account.updateTrustedDevice", { + method: "POST", token, body: { deviceId, friendlyName }, - }) + }); }, getReauthStatus(token: AccessToken): Promise { - return xrpc('_account.getReauthStatus', { token }) + return xrpc("_account.getReauthStatus", { token }); }, - reauthPassword(token: AccessToken, password: string): Promise { - return xrpc('_account.reauthPassword', { - method: 'POST', + reauthPassword( + token: AccessToken, + password: string, + ): Promise { + return xrpc("_account.reauthPassword", { + method: "POST", token, body: { password }, - }) + }); }, reauthTotp(token: AccessToken, code: string): Promise { - return xrpc('_account.reauthTotp', { - method: 'POST', + return xrpc("_account.reauthTotp", { + method: "POST", token, body: { code }, - }) + }); }, reauthPasskeyStart(token: AccessToken): Promise { - return xrpc('_account.reauthPasskeyStart', { - method: 'POST', + return xrpc("_account.reauthPasskeyStart", { + method: "POST", token, - }) + }); }, - reauthPasskeyFinish(token: AccessToken, credential: unknown): Promise { - return xrpc('_account.reauthPasskeyFinish', { - method: 'POST', + reauthPasskeyFinish( + token: AccessToken, + credential: unknown, + ): Promise { + return xrpc("_account.reauthPasskeyFinish", { + method: "POST", token, body: { credential }, - }) + }); }, reserveSigningKey(did?: Did): Promise { - return xrpc('com.atproto.server.reserveSigningKey', { - method: 'POST', + return xrpc("com.atproto.server.reserveSigningKey", { + method: "POST", body: { did }, - }) + }); }, - getRecommendedDidCredentials(token: AccessToken): Promise { - return xrpc('com.atproto.identity.getRecommendedDidCredentials', { token }) + getRecommendedDidCredentials( + token: AccessToken, + ): Promise { + return xrpc("com.atproto.identity.getRecommendedDidCredentials", { token }); }, async activateAccount(token: AccessToken): Promise { - await xrpc('com.atproto.server.activateAccount', { - method: 'POST', + await xrpc("com.atproto.server.activateAccount", { + method: "POST", token, - }) + }); }, async createPasskeyAccount(params: { - handle: Handle - email?: EmailAddress - inviteCode?: string - didType?: DidType - did?: Did - signingKey?: string - verificationChannel?: VerificationChannel - discordId?: string - telegramUsername?: string - signalNumber?: string + handle: Handle; + email?: EmailAddress; + inviteCode?: string; + didType?: DidType; + did?: Did; + signingKey?: string; + verificationChannel?: VerificationChannel; + discordId?: string; + telegramUsername?: string; + signalNumber?: string; }, byodToken?: string): Promise { - const url = `${API_BASE}/_account.createPasskeyAccount` + const url = `${API_BASE}/_account.createPasskeyAccount`; const headers: Record = { - 'Content-Type': 'application/json', - } + "Content-Type": "application/json", + }; if (byodToken) { - headers['Authorization'] = `Bearer ${byodToken}` + headers["Authorization"] = `Bearer ${byodToken}`; } const res = await fetch(url, { - method: 'POST', + method: "POST", headers, body: JSON.stringify(params), - }) + }); if (!res.ok) { const errData = await res.json().catch(() => ({ - error: 'Unknown', + error: "Unknown", message: res.statusText, - })) - throw new ApiError(res.status, errData.error, errData.message) + })); + throw new ApiError(res.status, errData.error, errData.message); } - return res.json() + return res.json(); }, startPasskeyRegistrationForSetup( @@ -918,10 +942,10 @@ export const api = { setupToken: string, friendlyName?: string, ): Promise { - return xrpc('_account.startPasskeyRegistrationForSetup', { - method: 'POST', + return xrpc("_account.startPasskeyRegistrationForSetup", { + method: "POST", body: { did, setupToken, friendlyName }, - }) + }); }, completePasskeySetup( @@ -930,17 +954,17 @@ export const api = { passkeyCredential: unknown, passkeyFriendlyName?: string, ): Promise { - return xrpc('_account.completePasskeySetup', { - method: 'POST', + return xrpc("_account.completePasskeySetup", { + method: "POST", body: { did, setupToken, passkeyCredential, passkeyFriendlyName }, - }) + }); }, requestPasskeyRecovery(email: EmailAddress): Promise { - return xrpc('_account.requestPasskeyRecovery', { - method: 'POST', + return xrpc("_account.requestPasskeyRecovery", { + method: "POST", body: { email }, - }) + }); }, recoverPasskeyAccount( @@ -948,24 +972,29 @@ export const api = { recoveryToken: string, newPassword: string, ): Promise { - return xrpc('_account.recoverPasskeyAccount', { - method: 'POST', + return xrpc("_account.recoverPasskeyAccount", { + method: "POST", body: { did, recoveryToken, newPassword }, - }) + }); }, - verifyMigrationEmail(token: string, email: EmailAddress): Promise { - return xrpc('com.atproto.server.verifyMigrationEmail', { - method: 'POST', + verifyMigrationEmail( + token: string, + email: EmailAddress, + ): Promise { + return xrpc("com.atproto.server.verifyMigrationEmail", { + method: "POST", body: { token, email }, - }) + }); }, - resendMigrationVerification(email: EmailAddress): Promise { - return xrpc('com.atproto.server.resendMigrationVerification', { - method: 'POST', + resendMigrationVerification( + email: EmailAddress, + ): Promise { + return xrpc("com.atproto.server.resendMigrationVerification", { + method: "POST", body: { email }, - }) + }); }, verifyToken( @@ -973,541 +1002,638 @@ export const api = { identifier: string, accessToken?: AccessToken, ): Promise { - return xrpc('_account.verifyToken', { - method: 'POST', + return xrpc("_account.verifyToken", { + method: "POST", body: { token, identifier }, token: accessToken, - }) + }); }, getDidDocument(token: AccessToken): Promise { - return xrpc('_account.getDidDocument', { token }) + return xrpc("_account.getDidDocument", { token }); }, updateDidDocument( token: AccessToken, params: { - verificationMethods?: VerificationMethod[] - alsoKnownAs?: string[] - serviceEndpoint?: string + verificationMethods?: VerificationMethod[]; + alsoKnownAs?: string[]; + serviceEndpoint?: string; }, ): Promise { - return xrpc('_account.updateDidDocument', { - method: 'POST', + return xrpc("_account.updateDidDocument", { + method: "POST", token, body: params, - }) + }); }, - async deactivateAccount(token: AccessToken, deleteAfter?: string): Promise { - await xrpc('com.atproto.server.deactivateAccount', { - method: 'POST', + async deactivateAccount( + token: AccessToken, + deleteAfter?: string, + ): Promise { + await xrpc("com.atproto.server.deactivateAccount", { + method: "POST", token, body: { deleteAfter }, - }) + }); }, async getRepo(token: AccessToken, did: Did): Promise { - const url = `${API_BASE}/com.atproto.sync.getRepo?did=${encodeURIComponent(did)}` + const url = `${API_BASE}/com.atproto.sync.getRepo?did=${ + encodeURIComponent(did) + }`; const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, - }) + }); if (!res.ok) { const errData = await res.json().catch(() => ({ - error: 'Unknown', + error: "Unknown", message: res.statusText, - })) - throw new ApiError(res.status, errData.error, errData.message) + })); + throw new ApiError(res.status, errData.error, errData.message); } - return res.arrayBuffer() + return res.arrayBuffer(); }, listBackups(token: AccessToken): Promise { - return xrpc('_backup.listBackups', { token }) + return xrpc("_backup.listBackups", { token }); }, async getBackup(token: AccessToken, id: string): Promise { - const url = `${API_BASE}/_backup.getBackup?id=${encodeURIComponent(id)}` + const url = `${API_BASE}/_backup.getBackup?id=${encodeURIComponent(id)}`; const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, - }) + }); if (!res.ok) { const errData = await res.json().catch(() => ({ - error: 'Unknown', + error: "Unknown", message: res.statusText, - })) - throw new ApiError(res.status, errData.error, errData.message) + })); + throw new ApiError(res.status, errData.error, errData.message); } - return res.blob() + return res.blob(); }, createBackup(token: AccessToken): Promise { - return xrpc('_backup.createBackup', { - method: 'POST', + return xrpc("_backup.createBackup", { + method: "POST", token, - }) + }); }, async deleteBackup(token: AccessToken, id: string): Promise { - await xrpc('_backup.deleteBackup', { - method: 'POST', + await xrpc("_backup.deleteBackup", { + method: "POST", token, params: { id }, - }) + }); }, - setBackupEnabled(token: AccessToken, enabled: boolean): Promise { - return xrpc('_backup.setEnabled', { - method: 'POST', + setBackupEnabled( + token: AccessToken, + enabled: boolean, + ): Promise { + return xrpc("_backup.setEnabled", { + method: "POST", token, body: { enabled }, - }) + }); }, async importRepo(token: AccessToken, car: Uint8Array): Promise { - const url = `${API_BASE}/com.atproto.repo.importRepo` + const url = `${API_BASE}/com.atproto.repo.importRepo`; const res = await fetch(url, { - method: 'POST', + method: "POST", headers: { Authorization: `Bearer ${token}`, - 'Content-Type': 'application/vnd.ipld.car', + "Content-Type": "application/vnd.ipld.car", }, - body: car, - }) + body: car as unknown as BodyInit, + }); if (!res.ok) { const errData = await res.json().catch(() => ({ - error: 'Unknown', + error: "Unknown", message: res.statusText, - })) - throw new ApiError(res.status, errData.error, errData.message) + })); + throw new ApiError(res.status, errData.error, errData.message); } }, -} +}; export const typedApi = { createSession( identifier: string, - password: string + password: string, ): Promise> { - return xrpcResult('com.atproto.server.createSession', { - method: 'POST', + return xrpcResult("com.atproto.server.createSession", { + method: "POST", body: { identifier, password }, - }).then(r => r.ok ? ok(castSession(r.value)) : r) + }).then((r) => r.ok ? ok(castSession(r.value)) : r); }, getSession(token: AccessToken): Promise> { - return xrpcResult('com.atproto.server.getSession', { token }) - .then(r => r.ok ? ok(castSession(r.value)) : r) + return xrpcResult("com.atproto.server.getSession", { token }) + .then((r) => r.ok ? ok(castSession(r.value)) : r); }, refreshSession(refreshJwt: RefreshToken): Promise> { - return xrpcResult('com.atproto.server.refreshSession', { - method: 'POST', + return xrpcResult("com.atproto.server.refreshSession", { + method: "POST", token: refreshJwt, - }).then(r => r.ok ? ok(castSession(r.value)) : r) + }).then((r) => r.ok ? ok(castSession(r.value)) : r); }, describeServer(): Promise> { - return xrpcResult('com.atproto.server.describeServer') + return xrpcResult("com.atproto.server.describeServer"); }, - listAppPasswords(token: AccessToken): Promise> { - return xrpcResult('com.atproto.server.listAppPasswords', { token }) + listAppPasswords( + token: AccessToken, + ): Promise> { + return xrpcResult("com.atproto.server.listAppPasswords", { token }); }, createAppPassword( token: AccessToken, name: string, - scopes?: string + scopes?: string, ): Promise> { - return xrpcResult('com.atproto.server.createAppPassword', { - method: 'POST', + return xrpcResult("com.atproto.server.createAppPassword", { + method: "POST", token, body: { name, scopes }, - }) + }); }, - revokeAppPassword(token: AccessToken, name: string): Promise> { - return xrpcResult('com.atproto.server.revokeAppPassword', { - method: 'POST', + revokeAppPassword( + token: AccessToken, + name: string, + ): Promise> { + return xrpcResult("com.atproto.server.revokeAppPassword", { + method: "POST", token, body: { name }, - }) + }); }, - listSessions(token: AccessToken): Promise> { - return xrpcResult('_account.listSessions', { token }) + listSessions( + token: AccessToken, + ): Promise> { + return xrpcResult("_account.listSessions", { token }); }, - revokeSession(token: AccessToken, sessionId: string): Promise> { - return xrpcResult('_account.revokeSession', { - method: 'POST', + revokeSession( + token: AccessToken, + sessionId: string, + ): Promise> { + return xrpcResult("_account.revokeSession", { + method: "POST", token, body: { sessionId }, - }) + }); }, getTotpStatus(token: AccessToken): Promise> { - return xrpcResult('com.atproto.server.getTotpStatus', { token }) + return xrpcResult("com.atproto.server.getTotpStatus", { token }); }, createTotpSecret(token: AccessToken): Promise> { - return xrpcResult('com.atproto.server.createTotpSecret', { - method: 'POST', + return xrpcResult("com.atproto.server.createTotpSecret", { + method: "POST", token, - }) + }); }, - enableTotp(token: AccessToken, code: string): Promise> { - return xrpcResult('com.atproto.server.enableTotp', { - method: 'POST', + enableTotp( + token: AccessToken, + code: string, + ): Promise> { + return xrpcResult("com.atproto.server.enableTotp", { + method: "POST", token, body: { code }, - }) + }); }, disableTotp( token: AccessToken, password: string, - code: string + code: string, ): Promise> { - return xrpcResult('com.atproto.server.disableTotp', { - method: 'POST', + return xrpcResult("com.atproto.server.disableTotp", { + method: "POST", token, body: { password, code }, - }) + }); }, - listPasskeys(token: AccessToken): Promise> { - return xrpcResult('com.atproto.server.listPasskeys', { token }) + listPasskeys( + token: AccessToken, + ): Promise> { + return xrpcResult("com.atproto.server.listPasskeys", { token }); }, - deletePasskey(token: AccessToken, id: string): Promise> { - return xrpcResult('com.atproto.server.deletePasskey', { - method: 'POST', + deletePasskey( + token: AccessToken, + id: string, + ): Promise> { + return xrpcResult("com.atproto.server.deletePasskey", { + method: "POST", token, body: { id }, - }) + }); }, - listTrustedDevices(token: AccessToken): Promise> { - return xrpcResult('_account.listTrustedDevices', { token }) + listTrustedDevices( + token: AccessToken, + ): Promise> { + return xrpcResult("_account.listTrustedDevices", { token }); }, getReauthStatus(token: AccessToken): Promise> { - return xrpcResult('_account.getReauthStatus', { token }) + return xrpcResult("_account.getReauthStatus", { token }); }, - getNotificationPrefs(token: AccessToken): Promise> { - return xrpcResult('_account.getNotificationPrefs', { token }) + getNotificationPrefs( + token: AccessToken, + ): Promise> { + return xrpcResult("_account.getNotificationPrefs", { token }); }, - updateHandle(token: AccessToken, handle: Handle): Promise> { - return xrpcResult('com.atproto.identity.updateHandle', { - method: 'POST', + updateHandle( + token: AccessToken, + handle: Handle, + ): Promise> { + return xrpcResult("com.atproto.identity.updateHandle", { + method: "POST", token, body: { handle }, - }) + }); }, - describeRepo(token: AccessToken, repo: Did): Promise> { - return xrpcResult('com.atproto.repo.describeRepo', { + describeRepo( + token: AccessToken, + repo: Did, + ): Promise> { + return xrpcResult("com.atproto.repo.describeRepo", { token, params: { repo }, - }) + }); }, listRecords( token: AccessToken, repo: Did, collection: Nsid, - options?: { limit?: number; cursor?: string; reverse?: boolean } + options?: { limit?: number; cursor?: string; reverse?: boolean }, ): Promise> { - const params: Record = { repo, collection } - if (options?.limit) params.limit = String(options.limit) - if (options?.cursor) params.cursor = options.cursor - if (options?.reverse) params.reverse = 'true' - return xrpcResult('com.atproto.repo.listRecords', { token, params }) + const params: Record = { repo, collection }; + if (options?.limit) params.limit = String(options.limit); + if (options?.cursor) params.cursor = options.cursor; + if (options?.reverse) params.reverse = "true"; + return xrpcResult("com.atproto.repo.listRecords", { token, params }); }, getRecord( token: AccessToken, repo: Did, collection: Nsid, - rkey: Rkey + rkey: Rkey, ): Promise> { - return xrpcResult('com.atproto.repo.getRecord', { + return xrpcResult("com.atproto.repo.getRecord", { token, params: { repo, collection, rkey }, - }) + }); }, deleteRecord( token: AccessToken, repo: Did, collection: Nsid, - rkey: Rkey + rkey: Rkey, ): Promise> { - return xrpcResult('com.atproto.repo.deleteRecord', { - method: 'POST', + return xrpcResult("com.atproto.repo.deleteRecord", { + method: "POST", token, body: { repo, collection, rkey }, - }) + }); }, searchAccounts( token: AccessToken, - options?: { handle?: string; cursor?: string; limit?: number } + options?: { handle?: string; cursor?: string; limit?: number }, ): Promise> { - const params: Record = {} - if (options?.handle) params.handle = options.handle - if (options?.cursor) params.cursor = options.cursor - if (options?.limit) params.limit = String(options.limit) - return xrpcResult('com.atproto.admin.searchAccounts', { token, params }) + const params: Record = {}; + if (options?.handle) params.handle = options.handle; + if (options?.cursor) params.cursor = options.cursor; + if (options?.limit) params.limit = String(options.limit); + return xrpcResult("com.atproto.admin.searchAccounts", { token, params }); }, - getAccountInfo(token: AccessToken, did: Did): Promise> { - return xrpcResult('com.atproto.admin.getAccountInfo', { token, params: { did } }) + getAccountInfo( + token: AccessToken, + did: Did, + ): Promise> { + return xrpcResult("com.atproto.admin.getAccountInfo", { + token, + params: { did }, + }); }, getServerStats(token: AccessToken): Promise> { - return xrpcResult('_admin.getServerStats', { token }) + return xrpcResult("_admin.getServerStats", { token }); }, - listBackups(token: AccessToken): Promise> { - return xrpcResult('_backup.listBackups', { token }) + listBackups( + token: AccessToken, + ): Promise> { + return xrpcResult("_backup.listBackups", { token }); }, - createBackup(token: AccessToken): Promise> { - return xrpcResult('_backup.createBackup', { - method: 'POST', + createBackup( + token: AccessToken, + ): Promise> { + return xrpcResult("_backup.createBackup", { + method: "POST", token, - }) + }); }, getDidDocument(token: AccessToken): Promise> { - return xrpcResult('_account.getDidDocument', { token }) + return xrpcResult("_account.getDidDocument", { token }); }, deleteSession(token: AccessToken): Promise> { - return xrpcResult('com.atproto.server.deleteSession', { - method: 'POST', + return xrpcResult("com.atproto.server.deleteSession", { + method: "POST", token, - }) + }); }, - revokeAllSessions(token: AccessToken): Promise> { - return xrpcResult('_account.revokeAllSessions', { - method: 'POST', + revokeAllSessions( + token: AccessToken, + ): Promise> { + return xrpcResult("_account.revokeAllSessions", { + method: "POST", token, - }) + }); }, - getAccountInviteCodes(token: AccessToken): Promise> { - return xrpcResult('com.atproto.server.getAccountInviteCodes', { token }) + getAccountInviteCodes( + token: AccessToken, + ): Promise> { + return xrpcResult("com.atproto.server.getAccountInviteCodes", { token }); }, - createInviteCode(token: AccessToken, useCount: number = 1): Promise> { - return xrpcResult('com.atproto.server.createInviteCode', { - method: 'POST', + createInviteCode( + token: AccessToken, + useCount: number = 1, + ): Promise> { + return xrpcResult("com.atproto.server.createInviteCode", { + method: "POST", token, body: { useCount }, - }) + }); }, changePassword( token: AccessToken, currentPassword: string, - newPassword: string + newPassword: string, ): Promise> { - return xrpcResult('_account.changePassword', { - method: 'POST', + return xrpcResult("_account.changePassword", { + method: "POST", token, body: { currentPassword, newPassword }, - }) + }); }, - getPasswordStatus(token: AccessToken): Promise> { - return xrpcResult('_account.getPasswordStatus', { token }) + getPasswordStatus( + token: AccessToken, + ): Promise> { + return xrpcResult("_account.getPasswordStatus", { token }); }, getServerConfig(): Promise> { - return xrpcResult('_server.getConfig') + return xrpcResult("_server.getConfig"); }, - getLegacyLoginPreference(token: AccessToken): Promise> { - return xrpcResult('_account.getLegacyLoginPreference', { token }) + getLegacyLoginPreference( + token: AccessToken, + ): Promise> { + return xrpcResult("_account.getLegacyLoginPreference", { token }); }, updateLegacyLoginPreference( token: AccessToken, - allowLegacyLogin: boolean + allowLegacyLogin: boolean, ): Promise> { - return xrpcResult('_account.updateLegacyLoginPreference', { - method: 'POST', + return xrpcResult("_account.updateLegacyLoginPreference", { + method: "POST", token, body: { allowLegacyLogin }, - }) + }); }, - getNotificationHistory(token: AccessToken): Promise> { - return xrpcResult('_account.getNotificationHistory', { token }) + getNotificationHistory( + token: AccessToken, + ): Promise> { + return xrpcResult("_account.getNotificationHistory", { token }); }, updateNotificationPrefs( token: AccessToken, prefs: { - preferredChannel?: string - discordId?: string - telegramUsername?: string - signalNumber?: string - } + preferredChannel?: string; + discordId?: string; + telegramUsername?: string; + signalNumber?: string; + }, ): Promise> { - return xrpcResult('_account.updateNotificationPrefs', { - method: 'POST', + return xrpcResult("_account.updateNotificationPrefs", { + method: "POST", token, body: prefs, - }) + }); }, - revokeTrustedDevice(token: AccessToken, deviceId: string): Promise> { - return xrpcResult('_account.revokeTrustedDevice', { - method: 'POST', + revokeTrustedDevice( + token: AccessToken, + deviceId: string, + ): Promise> { + return xrpcResult("_account.revokeTrustedDevice", { + method: "POST", token, body: { deviceId }, - }) + }); }, updateTrustedDevice( token: AccessToken, deviceId: string, - friendlyName: string + friendlyName: string, ): Promise> { - return xrpcResult('_account.updateTrustedDevice', { - method: 'POST', + return xrpcResult("_account.updateTrustedDevice", { + method: "POST", token, body: { deviceId, friendlyName }, - }) + }); }, - reauthPassword(token: AccessToken, password: string): Promise> { - return xrpcResult('_account.reauthPassword', { - method: 'POST', + reauthPassword( + token: AccessToken, + password: string, + ): Promise> { + return xrpcResult("_account.reauthPassword", { + method: "POST", token, body: { password }, - }) + }); }, - reauthTotp(token: AccessToken, code: string): Promise> { - return xrpcResult('_account.reauthTotp', { - method: 'POST', + reauthTotp( + token: AccessToken, + code: string, + ): Promise> { + return xrpcResult("_account.reauthTotp", { + method: "POST", token, body: { code }, - }) + }); }, - reauthPasskeyStart(token: AccessToken): Promise> { - return xrpcResult('_account.reauthPasskeyStart', { - method: 'POST', + reauthPasskeyStart( + token: AccessToken, + ): Promise> { + return xrpcResult("_account.reauthPasskeyStart", { + method: "POST", token, - }) + }); }, - reauthPasskeyFinish(token: AccessToken, credential: unknown): Promise> { - return xrpcResult('_account.reauthPasskeyFinish', { - method: 'POST', + reauthPasskeyFinish( + token: AccessToken, + credential: unknown, + ): Promise> { + return xrpcResult("_account.reauthPasskeyFinish", { + method: "POST", token, body: { credential }, - }) + }); }, - confirmSignup(did: Did, verificationCode: string): Promise> { - return xrpcResult('com.atproto.server.confirmSignup', { - method: 'POST', + confirmSignup( + did: Did, + verificationCode: string, + ): Promise> { + return xrpcResult("com.atproto.server.confirmSignup", { + method: "POST", body: { did, verificationCode }, - }) + }); }, - resendVerification(did: Did): Promise> { - return xrpcResult('com.atproto.server.resendVerification', { - method: 'POST', + resendVerification( + did: Did, + ): Promise> { + return xrpcResult("com.atproto.server.resendVerification", { + method: "POST", body: { did }, - }) + }); }, - requestEmailUpdate(token: AccessToken): Promise> { - return xrpcResult('com.atproto.server.requestEmailUpdate', { - method: 'POST', + requestEmailUpdate( + token: AccessToken, + ): Promise> { + return xrpcResult("com.atproto.server.requestEmailUpdate", { + method: "POST", token, - }) + }); }, - updateEmail(token: AccessToken, email: string, emailToken?: string): Promise> { - return xrpcResult('com.atproto.server.updateEmail', { - method: 'POST', + updateEmail( + token: AccessToken, + email: string, + emailToken?: string, + ): Promise> { + return xrpcResult("com.atproto.server.updateEmail", { + method: "POST", token, body: { email, token: emailToken }, - }) + }); }, requestAccountDelete(token: AccessToken): Promise> { - return xrpcResult('com.atproto.server.requestAccountDelete', { - method: 'POST', + return xrpcResult("com.atproto.server.requestAccountDelete", { + method: "POST", token, - }) + }); }, - deleteAccount(did: Did, password: string, deleteToken: string): Promise> { - return xrpcResult('com.atproto.server.deleteAccount', { - method: 'POST', + deleteAccount( + did: Did, + password: string, + deleteToken: string, + ): Promise> { + return xrpcResult("com.atproto.server.deleteAccount", { + method: "POST", body: { did, password, token: deleteToken }, - }) + }); }, updateDidDocument( token: AccessToken, params: { - verificationMethods?: VerificationMethod[] - alsoKnownAs?: string[] - serviceEndpoint?: string - } + verificationMethods?: VerificationMethod[]; + alsoKnownAs?: string[]; + serviceEndpoint?: string; + }, ): Promise> { - return xrpcResult('_account.updateDidDocument', { - method: 'POST', + return xrpcResult("_account.updateDidDocument", { + method: "POST", token, body: params, - }) + }); }, - deactivateAccount(token: AccessToken, deleteAfter?: string): Promise> { - return xrpcResult('com.atproto.server.deactivateAccount', { - method: 'POST', + deactivateAccount( + token: AccessToken, + deleteAfter?: string, + ): Promise> { + return xrpcResult("com.atproto.server.deactivateAccount", { + method: "POST", token, body: { deleteAfter }, - }) + }); }, activateAccount(token: AccessToken): Promise> { - return xrpcResult('com.atproto.server.activateAccount', { - method: 'POST', + return xrpcResult("com.atproto.server.activateAccount", { + method: "POST", token, - }) + }); }, - setBackupEnabled(token: AccessToken, enabled: boolean): Promise> { - return xrpcResult('_backup.setEnabled', { - method: 'POST', + setBackupEnabled( + token: AccessToken, + enabled: boolean, + ): Promise> { + return xrpcResult("_backup.setEnabled", { + method: "POST", token, body: { enabled }, - }) + }); }, - deleteBackup(token: AccessToken, id: string): Promise> { - return xrpcResult('_backup.deleteBackup', { - method: 'POST', + deleteBackup( + token: AccessToken, + id: string, + ): Promise> { + return xrpcResult("_backup.deleteBackup", { + method: "POST", token, params: { id }, - }) + }); }, createRecord( @@ -1515,13 +1641,13 @@ export const typedApi = { repo: Did, collection: Nsid, record: unknown, - rkey?: Rkey + rkey?: Rkey, ): Promise> { - return xrpcResult('com.atproto.repo.createRecord', { - method: 'POST', + return xrpcResult("com.atproto.repo.createRecord", { + method: "POST", token, body: { repo, collection, record, rkey }, - }) + }); }, putRecord( @@ -1529,122 +1655,136 @@ export const typedApi = { repo: Did, collection: Nsid, rkey: Rkey, - record: unknown + record: unknown, ): Promise> { - return xrpcResult('com.atproto.repo.putRecord', { - method: 'POST', + return xrpcResult("com.atproto.repo.putRecord", { + method: "POST", token, body: { repo, collection, rkey, record }, - }) + }); }, getInviteCodes( token: AccessToken, - options?: { sort?: 'recent' | 'usage'; cursor?: string; limit?: number } + options?: { sort?: "recent" | "usage"; cursor?: string; limit?: number }, ): Promise> { - const params: Record = {} - if (options?.sort) params.sort = options.sort - if (options?.cursor) params.cursor = options.cursor - if (options?.limit) params.limit = String(options.limit) - return xrpcResult('com.atproto.admin.getInviteCodes', { token, params }) + const params: Record = {}; + if (options?.sort) params.sort = options.sort; + if (options?.cursor) params.cursor = options.cursor; + if (options?.limit) params.limit = String(options.limit); + return xrpcResult("com.atproto.admin.getInviteCodes", { token, params }); }, - disableAccountInvites(token: AccessToken, account: Did): Promise> { - return xrpcResult('com.atproto.admin.disableAccountInvites', { - method: 'POST', + disableAccountInvites( + token: AccessToken, + account: Did, + ): Promise> { + return xrpcResult("com.atproto.admin.disableAccountInvites", { + method: "POST", token, body: { account }, - }) + }); }, - enableAccountInvites(token: AccessToken, account: Did): Promise> { - return xrpcResult('com.atproto.admin.enableAccountInvites', { - method: 'POST', + enableAccountInvites( + token: AccessToken, + account: Did, + ): Promise> { + return xrpcResult("com.atproto.admin.enableAccountInvites", { + method: "POST", token, body: { account }, - }) + }); }, - adminDeleteAccount(token: AccessToken, did: Did): Promise> { - return xrpcResult('com.atproto.admin.deleteAccount', { - method: 'POST', + adminDeleteAccount( + token: AccessToken, + did: Did, + ): Promise> { + return xrpcResult("com.atproto.admin.deleteAccount", { + method: "POST", token, body: { did }, - }) + }); }, startPasskeyRegistration( token: AccessToken, - friendlyName?: string + friendlyName?: string, ): Promise> { - return xrpcResult('com.atproto.server.startPasskeyRegistration', { - method: 'POST', + return xrpcResult("com.atproto.server.startPasskeyRegistration", { + method: "POST", token, body: { friendlyName }, - }) + }); }, finishPasskeyRegistration( token: AccessToken, credential: unknown, - friendlyName?: string + friendlyName?: string, ): Promise> { - return xrpcResult('com.atproto.server.finishPasskeyRegistration', { - method: 'POST', + return xrpcResult("com.atproto.server.finishPasskeyRegistration", { + method: "POST", token, body: { credential, friendlyName }, - }) + }); }, updatePasskey( token: AccessToken, id: string, - friendlyName: string + friendlyName: string, ): Promise> { - return xrpcResult('com.atproto.server.updatePasskey', { - method: 'POST', + return xrpcResult("com.atproto.server.updatePasskey", { + method: "POST", token, body: { id, friendlyName }, - }) + }); }, regenerateBackupCodes( token: AccessToken, password: string, - code: string + code: string, ): Promise> { - return xrpcResult('com.atproto.server.regenerateBackupCodes', { - method: 'POST', + return xrpcResult("com.atproto.server.regenerateBackupCodes", { + method: "POST", token, body: { password, code }, - }) + }); }, - updateLocale(token: AccessToken, preferredLocale: string): Promise> { - return xrpcResult('_account.updateLocale', { - method: 'POST', + updateLocale( + token: AccessToken, + preferredLocale: string, + ): Promise> { + return xrpcResult("_account.updateLocale", { + method: "POST", token, body: { preferredLocale }, - }) + }); }, confirmChannelVerification( token: AccessToken, channel: string, identifier: string, - code: string + code: string, ): Promise> { - return xrpcResult('_account.confirmChannelVerification', { - method: 'POST', + return xrpcResult("_account.confirmChannelVerification", { + method: "POST", token, body: { channel, identifier, code }, - }) + }); }, - removePassword(token: AccessToken): Promise> { - return xrpcResult('_account.removePassword', { - method: 'POST', + removePassword( + token: AccessToken, + ): Promise> { + return xrpcResult("_account.removePassword", { + method: "POST", token, - }) + }); }, -} +}; diff --git a/frontend/src/lib/auth.svelte.ts b/frontend/src/lib/auth.svelte.ts index 455e75d..e1617df 100644 --- a/frontend/src/lib/auth.svelte.ts +++ b/frontend/src/lib/auth.svelte.ts @@ -1,31 +1,31 @@ import { api, ApiError, - typedApi, type CreateAccountParams, type CreateAccountResult, -} from "./api"; -import type { Session } from "./types/api"; + typedApi, +} from "./api.ts"; +import type { Session } from "./types/api.ts"; import { + type AccessToken, type Did, type Handle, - type AccessToken, type RefreshToken, + unsafeAsAccessToken, unsafeAsDid, unsafeAsHandle, - unsafeAsAccessToken, unsafeAsRefreshToken, -} from "./types/branded"; -import { type Result, ok, err, isOk, isErr, map } from "./types/result"; -import { assertNever } from "./types/exhaustive"; +} from "./types/branded.ts"; +import { err, isErr, isOk, ok, type Result } from "./types/result.ts"; +import { assertNever } from "./types/exhaustive.ts"; import { checkForOAuthCallback, clearOAuthCallbackParams, handleOAuthCallback, refreshOAuthToken, startOAuthLogin, -} from "./oauth"; -import { setLocale, type SupportedLocale } from "./i18n"; +} from "./oauth.ts"; +import { setLocale, type SupportedLocale } from "./i18n.ts"; const STORAGE_KEY = "tranquil_pds_session"; const ACCOUNTS_KEY = "tranquil_pds_accounts"; @@ -64,24 +64,24 @@ type AuthStateKind = "unauthenticated" | "loading" | "authenticated" | "error"; export type AuthState = | { - readonly kind: "unauthenticated"; - readonly savedAccounts: readonly SavedAccount[]; - } + readonly kind: "unauthenticated"; + readonly savedAccounts: readonly SavedAccount[]; + } | { - readonly kind: "loading"; - readonly savedAccounts: readonly SavedAccount[]; - readonly previousSession: Session | null; - } + readonly kind: "loading"; + readonly savedAccounts: readonly SavedAccount[]; + readonly previousSession: Session | null; + } | { - readonly kind: "authenticated"; - readonly session: Session; - readonly savedAccounts: readonly SavedAccount[]; - } + readonly kind: "authenticated"; + readonly session: Session; + readonly savedAccounts: readonly SavedAccount[]; + } | { - readonly kind: "error"; - readonly error: AuthError; - readonly savedAccounts: readonly SavedAccount[]; - }; + readonly kind: "error"; + readonly error: AuthError; + readonly savedAccounts: readonly SavedAccount[]; + }; function createUnauthenticated( savedAccounts: readonly SavedAccount[], @@ -170,7 +170,14 @@ function parseStoredAccounts(json: string): Result { } const accounts: SavedAccount[] = parsed .filter( - (a): a is { did: string; handle: string; accessJwt: string; refreshJwt: string } => + ( + a, + ): a is { + did: string; + handle: string; + accessJwt: string; + refreshJwt: string; + } => typeof a === "object" && a !== null && typeof a.did === "string" && @@ -272,11 +279,15 @@ async function tryRefreshToken(): Promise { const currentSession = state.current.session; try { const tokens = await refreshOAuthToken(currentSession.refreshJwt); - const sessionInfo = await api.getSession(tokens.access_token); + const sessionInfo = await api.getSession( + unsafeAsAccessToken(tokens.access_token), + ); const session: Session = { ...sessionInfo, - accessJwt: tokens.access_token, - refreshJwt: tokens.refresh_token || currentSession.refreshJwt, + accessJwt: unsafeAsAccessToken(tokens.access_token), + refreshJwt: tokens.refresh_token + ? unsafeAsRefreshToken(tokens.refresh_token) + : currentSession.refreshJwt, }; setAuthenticated(session); return session.accessJwt; @@ -285,7 +296,7 @@ async function tryRefreshToken(): Promise { } } -import { setTokenRefreshCallback } from "./api"; +import { setTokenRefreshCallback } from "./api.ts"; export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> { setTokenRefreshCallback(tryRefreshToken); @@ -300,17 +311,22 @@ export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> { oauthCallback.code, oauthCallback.state, ); - const sessionInfo = await api.getSession(tokens.access_token); + const sessionInfo = await api.getSession( + unsafeAsAccessToken(tokens.access_token), + ); const session: Session = { ...sessionInfo, - accessJwt: tokens.access_token, - refreshJwt: tokens.refresh_token || "", + accessJwt: unsafeAsAccessToken(tokens.access_token), + refreshJwt: unsafeAsRefreshToken(tokens.refresh_token || ""), }; setAuthenticated(session); - applyLocaleFromSession(sessionInfo); + applyLocaleFromSession(session); return { oauthLoginCompleted: true }; } catch (e) { - setError({ type: "oauth", message: e instanceof Error ? e.message : "OAuth login failed" }); + setError({ + type: "oauth", + message: e instanceof Error ? e.message : "OAuth login failed", + }); return { oauthLoginCompleted: false }; } } @@ -318,26 +334,32 @@ export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> { const stored = loadSessionFromStorage(); if (stored) { try { - const sessionInfo = await api.getSession(stored.accessJwt); + const sessionInfo = await api.getSession( + unsafeAsAccessToken(stored.accessJwt), + ); const session: Session = { ...sessionInfo, - accessJwt: stored.accessJwt, - refreshJwt: stored.refreshJwt, + accessJwt: unsafeAsAccessToken(stored.accessJwt), + refreshJwt: unsafeAsRefreshToken(stored.refreshJwt), }; setAuthenticated(session); - applyLocaleFromSession(sessionInfo); + applyLocaleFromSession(session); } catch (e) { if (e instanceof ApiError && e.status === 401) { try { const tokens = await refreshOAuthToken(stored.refreshJwt); - const sessionInfo = await api.getSession(tokens.access_token); + const sessionInfo = await api.getSession( + unsafeAsAccessToken(tokens.access_token), + ); const session: Session = { ...sessionInfo, - accessJwt: tokens.access_token, - refreshJwt: tokens.refresh_token || stored.refreshJwt, + accessJwt: unsafeAsAccessToken(tokens.access_token), + refreshJwt: tokens.refresh_token + ? unsafeAsRefreshToken(tokens.refresh_token) + : unsafeAsRefreshToken(stored.refreshJwt), }; setAuthenticated(session); - applyLocaleFromSession(sessionInfo); + applyLocaleFromSession(session); } catch (refreshError) { console.error("Token refresh failed during init:", refreshError); setUnauthenticated(); @@ -359,8 +381,9 @@ export async function login( password: string, ): Promise> { const currentState = state.current; - const previousSession = - currentState.kind === "authenticated" ? currentState.session : null; + const previousSession = currentState.kind === "authenticated" + ? currentState.session + : null; setLoading(previousSession); const result = await typedApi.createSession(identifier, password); @@ -398,24 +421,14 @@ export async function register( } export async function confirmSignup( - did: string, + did: Did, verificationCode: string, ): Promise> { setLoading(); try { const result = await api.confirmSignup(did, verificationCode); - const session: Session = { - did: result.did, - handle: result.handle, - accessJwt: result.accessJwt, - refreshJwt: result.refreshJwt, - email: result.email, - emailConfirmed: result.emailConfirmed, - preferredChannel: result.preferredChannel, - preferredChannelVerified: result.preferredChannelVerified, - }; - setAuthenticated(session); - return ok(session); + setAuthenticated(result); + return ok(result); } catch (e) { const error = toAuthError(e); setError(error); @@ -424,7 +437,7 @@ export async function confirmSignup( } export async function resendVerification( - did: string, + did: Did, ): Promise> { try { await api.resendVerification(did); @@ -441,10 +454,10 @@ export function setSession(session: { refreshJwt: string; }): void { const newSession: Session = { - did: session.did, - handle: session.handle, - accessJwt: session.accessJwt, - refreshJwt: session.refreshJwt, + did: unsafeAsDid(session.did), + handle: unsafeAsHandle(session.handle), + accessJwt: unsafeAsAccessToken(session.accessJwt), + refreshJwt: unsafeAsRefreshToken(session.refreshJwt), }; setAuthenticated(newSession); } @@ -483,23 +496,27 @@ export async function switchAccount( setLoading(); try { - const sessionInfo = await api.getSession(account.accessJwt as string); + const sessionInfo = await api.getSession(account.accessJwt); const session: Session = { ...sessionInfo, - accessJwt: account.accessJwt as string, - refreshJwt: account.refreshJwt as string, + accessJwt: account.accessJwt, + refreshJwt: account.refreshJwt, }; setAuthenticated(session); return ok(session); } catch (e) { if (e instanceof ApiError && e.status === 401) { try { - const tokens = await refreshOAuthToken(account.refreshJwt as string); - const sessionInfo = await api.getSession(tokens.access_token); + const tokens = await refreshOAuthToken(account.refreshJwt); + const sessionInfo = await api.getSession( + unsafeAsAccessToken(tokens.access_token), + ); const session: Session = { ...sessionInfo, - accessJwt: tokens.access_token, - refreshJwt: tokens.refresh_token || (account.refreshJwt as string), + accessJwt: unsafeAsAccessToken(tokens.access_token), + refreshJwt: tokens.refresh_token + ? unsafeAsRefreshToken(tokens.refresh_token) + : account.refreshJwt, }; setAuthenticated(session); return ok(session); @@ -555,7 +572,7 @@ export async function refreshSession(): Promise> { export function getToken(): AccessToken | null { if (state.current.kind === "authenticated") { - return unsafeAsAccessToken(state.current.session.accessJwt); + return state.current.session.accessJwt; } return null; } @@ -565,19 +582,23 @@ export async function getValidToken(): Promise { const currentSession = state.current.session; try { await api.getSession(currentSession.accessJwt); - return unsafeAsAccessToken(currentSession.accessJwt); + return currentSession.accessJwt; } catch (e) { if (e instanceof ApiError && e.status === 401) { try { const tokens = await refreshOAuthToken(currentSession.refreshJwt); - const sessionInfo = await api.getSession(tokens.access_token); + const sessionInfo = await api.getSession( + unsafeAsAccessToken(tokens.access_token), + ); const session: Session = { ...sessionInfo, - accessJwt: tokens.access_token, - refreshJwt: tokens.refresh_token || currentSession.refreshJwt, + accessJwt: unsafeAsAccessToken(tokens.access_token), + refreshJwt: tokens.refresh_token + ? unsafeAsRefreshToken(tokens.refresh_token) + : currentSession.refreshJwt, }; setAuthenticated(session); - return unsafeAsAccessToken(session.accessJwt); + return session.accessJwt; } catch { return null; } @@ -604,7 +625,10 @@ export function getSession(): Session | null { export function matchAuthState(handlers: { unauthenticated: (accounts: readonly SavedAccount[]) => T; - loading: (accounts: readonly SavedAccount[], previousSession: Session | null) => T; + loading: ( + accounts: readonly SavedAccount[], + previousSession: Session | null, + ) => T; authenticated: (session: Session, accounts: readonly SavedAccount[]) => T; error: (error: AuthError, accounts: readonly SavedAccount[]) => T; }): T { @@ -633,7 +657,9 @@ export function _testSetState(newState: { if (newState.loading) { setState(createLoading(accounts, newState.session)); } else if (newState.error) { - setState(createError({ type: "unknown", message: newState.error }, accounts)); + setState( + createError({ type: "unknown", message: newState.error }, accounts), + ); } else if (newState.session) { setState(createAuthenticated(newState.session, accounts)); } else { diff --git a/frontend/src/lib/crypto.ts b/frontend/src/lib/crypto.ts index 9bc83e6..0e78745 100644 --- a/frontend/src/lib/crypto.ts +++ b/frontend/src/lib/crypto.ts @@ -11,7 +11,7 @@ export interface Keypair { } export function generateKeypair(): Keypair { - const privateKey = secp.utils.randomPrivateKey(); + const privateKey = secp.utils.randomSecretKey(); const publicKey = secp.getPublicKey(privateKey, true); const multicodecKey = new Uint8Array( @@ -35,7 +35,9 @@ function base64UrlEncode(data: Uint8Array | string): string { const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data; - const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('') + const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join( + "", + ); return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); } @@ -67,8 +69,9 @@ export async function createServiceJwt( const msgBytes = new TextEncoder().encode(message); const hashBuffer = await crypto.subtle.digest("SHA-256", msgBytes); const msgHash = new Uint8Array(hashBuffer); - const signature = await secp.signAsync(msgHash, privateKey); - const sigBytes = signature.toCompactRawBytes(); + const sigBytes = await secp.signAsync(msgHash, privateKey, { + prehash: false, + }); const signatureEncoded = base64UrlEncode(sigBytes); return `${message}.${signatureEncoded}`; diff --git a/frontend/src/lib/migration/atproto-client.ts b/frontend/src/lib/migration/atproto-client.ts index 50eb16e..bcba6c5 100644 --- a/frontend/src/lib/migration/atproto-client.ts +++ b/frontend/src/lib/migration/atproto-client.ts @@ -14,7 +14,7 @@ import type { ServerDescription, Session, StartPasskeyRegistrationResponse, -} from "./types"; +} from "./types.ts"; function apiLog( method: string, @@ -101,7 +101,7 @@ export class AtprotoClient { let requestBody: BodyInit | undefined; if (rawBody) { headers["Content-Type"] = contentType ?? "application/octet-stream"; - requestBody = rawBody; + requestBody = rawBody as BodyInit; } else if (body) { headers["Content-Type"] = "application/json"; requestBody = JSON.stringify(body); @@ -231,7 +231,9 @@ export class AtprotoClient { did: string, cid: string, ): Promise<{ data: Uint8Array; contentType: string }> { - const url = `${this.baseUrl}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; + const url = `${this.baseUrl}/xrpc/com.atproto.sync.getBlob?did=${ + encodeURIComponent(did) + }&cid=${encodeURIComponent(cid)}`; const headers: Record = {}; if (this.accessToken) { headers["Authorization"] = `Bearer ${this.accessToken}`; @@ -244,7 +246,8 @@ export class AtprotoClient { })); throw new Error(err.message || err.error || res.statusText); } - const contentType = res.headers.get("content-type") || "application/octet-stream"; + const contentType = res.headers.get("content-type") || + "application/octet-stream"; const data = new Uint8Array(await res.arrayBuffer()); return { data, contentType }; } @@ -600,7 +603,9 @@ export async function generatePKCE(): Promise<{ export function base64UrlEncode(buffer: Uint8Array | ArrayBuffer): string { const bytes = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : buffer; - const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('') + const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join( + "", + ); return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace( /=+$/, "", @@ -632,7 +637,7 @@ export function prepareWebAuthnCreationOptions( id: base64UrlDecode(cred.id as string), }), ), - } as PublicKeyCredentialCreationOptions; + } as unknown as PublicKeyCredentialCreationOptions; } async function computeAccessTokenHash(accessToken: string): Promise { diff --git a/frontend/src/lib/migration/blob-migration.ts b/frontend/src/lib/migration/blob-migration.ts index 79375cb..572a732 100644 --- a/frontend/src/lib/migration/blob-migration.ts +++ b/frontend/src/lib/migration/blob-migration.ts @@ -1,5 +1,5 @@ -import type { AtprotoClient } from "./atproto-client"; -import type { MigrationProgress } from "./types"; +import type { AtprotoClient } from "./atproto-client.ts"; +import type { MigrationProgress } from "./types.ts"; export interface BlobMigrationResult { migrated: number; @@ -85,7 +85,8 @@ export async function migrateBlobs( }); console.log("[blob-migration] Fetching blob", cid, "from source"); - const { data: blobData, contentType } = await sourceClient.getBlobWithContentType(userDid, cid); + const { data: blobData, contentType } = await sourceClient + .getBlobWithContentType(userDid, cid); console.log( "[blob-migration] Got blob", cid, @@ -95,7 +96,12 @@ export async function migrateBlobs( contentType, ); await localClient.uploadBlob(blobData, contentType); - console.log("[blob-migration] Uploaded blob", cid, "with contentType:", contentType); + console.log( + "[blob-migration] Uploaded blob", + cid, + "with contentType:", + contentType, + ); migrated++; onProgress({ blobsMigrated: migrated }); } catch (e) { diff --git a/frontend/src/lib/migration/flow.svelte.ts b/frontend/src/lib/migration/flow.svelte.ts index 4eafd48..06b0f00 100644 --- a/frontend/src/lib/migration/flow.svelte.ts +++ b/frontend/src/lib/migration/flow.svelte.ts @@ -5,7 +5,7 @@ import type { PasskeyAccountSetup, ServerDescription, StoredMigrationState, -} from "./types"; +} from "./types.ts"; import { AtprotoClient, clearDPoPKey, @@ -21,14 +21,14 @@ import { loadDPoPKey, resolvePdsUrl, saveDPoPKey, -} from "./atproto-client"; +} from "./atproto-client.ts"; import { clearMigrationState, saveMigrationState, updateProgress, updateStep, -} from "./storage"; -import { migrateBlobs as migrateBlobsUtil } from "./blob-migration"; +} from "./storage.ts"; +import { migrateBlobs as migrateBlobsUtil } from "./blob-migration.ts"; function migrationLog(stage: string, data?: Record) { const timestamp = new Date().toISOString(); @@ -94,7 +94,7 @@ export function createInboundMigrationFlow() { } } - function setError(error: string) { + function setError(error: string | null) { state.error = error; saveMigrationState(state); } diff --git a/frontend/src/lib/migration/offline-flow.svelte.ts b/frontend/src/lib/migration/offline-flow.svelte.ts index c4883a0..90fe440 100644 --- a/frontend/src/lib/migration/offline-flow.svelte.ts +++ b/frontend/src/lib/migration/offline-flow.svelte.ts @@ -4,17 +4,23 @@ import type { OfflineInboundMigrationState, OfflineInboundStep, ServerDescription, -} from "./types"; +} from "./types.ts"; import { AtprotoClient, base64UrlEncode, createLocalClient, prepareWebAuthnCreationOptions, -} from "./atproto-client"; -import { api } from "../api"; -import { type KeypairInfo, plcOps, type PrivateKey } from "./plc-ops"; -import { migrateBlobs as migrateBlobsUtil } from "./blob-migration"; +} from "./atproto-client.ts"; +import { api } from "../api.ts"; +import { type KeypairInfo, plcOps, type PrivateKey } from "./plc-ops.ts"; +import { migrateBlobs as migrateBlobsUtil } from "./blob-migration.ts"; import { Secp256k1PrivateKeyExportable } from "@atcute/crypto"; +import { + unsafeAsAccessToken, + unsafeAsDid, + unsafeAsEmail, + unsafeAsHandle, +} from "../types/branded.ts"; const OFFLINE_STORAGE_KEY = "tranquil_offline_migration_state"; const MAX_AGE_MS = 24 * 60 * 60 * 1000; @@ -303,9 +309,9 @@ export function createOfflineInboundMigrationFlow() { const createResult = await api.createAccountWithServiceAuth( serviceAuthToken, { - did: state.userDid, - handle: fullHandle, - email: state.targetEmail, + did: unsafeAsDid(state.userDid), + handle: unsafeAsHandle(fullHandle), + email: unsafeAsEmail(state.targetEmail), password: state.targetPassword, inviteCode: state.inviteCode || undefined, }, @@ -326,9 +332,9 @@ export function createOfflineInboundMigrationFlow() { : `${state.targetHandle}.${serverInfo.availableUserDomains[0]}`; const createResult = await api.createPasskeyAccount({ - did: state.userDid, - handle: fullHandle, - email: state.targetEmail, + did: unsafeAsDid(state.userDid), + handle: unsafeAsHandle(fullHandle), + email: unsafeAsEmail(state.targetEmail), inviteCode: state.inviteCode || undefined, }, serviceAuthToken); @@ -349,7 +355,7 @@ export function createOfflineInboundMigrationFlow() { const prevCid = base.cid; const credentials = await api.getRecommendedDidCredentials( - state.localAccessToken, + unsafeAsAccessToken(state.localAccessToken), ); await plcOps.signPlcOperationWithCredentials( @@ -374,7 +380,10 @@ export function createOfflineInboundMigrationFlow() { } setProgress({ currentOperation: "Importing repository..." }); - await api.importRepo(state.localAccessToken, state.carFile); + await api.importRepo( + unsafeAsAccessToken(state.localAccessToken), + state.carFile, + ); setProgress({ repoImported: true }); } @@ -384,7 +393,7 @@ export function createOfflineInboundMigrationFlow() { } const localClient = createLocalClient(); - localClient.setAccessToken(state.localAccessToken); + localClient.setAccessToken(unsafeAsAccessToken(state.localAccessToken)); if (state.oldPdsUrl) { setProgress({ @@ -436,7 +445,7 @@ export function createOfflineInboundMigrationFlow() { } setProgress({ currentOperation: "Activating account..." }); - await api.activateAccount(state.localAccessToken); + await api.activateAccount(unsafeAsAccessToken(state.localAccessToken)); setProgress({ activated: true }); } @@ -445,7 +454,7 @@ export function createOfflineInboundMigrationFlow() { setError(null); try { - await api.verifyMigrationEmail(token, state.targetEmail); + await api.verifyMigrationEmail(token, unsafeAsEmail(state.targetEmail)); if (state.authMethod === "passkey") { setStep("passkey-setup"); @@ -474,7 +483,7 @@ export function createOfflineInboundMigrationFlow() { } async function resendEmailVerification(): Promise { - await api.resendMigrationVerification(state.targetEmail); + await api.resendMigrationVerification(unsafeAsEmail(state.targetEmail)); } let checkingEmailVerification = false; @@ -518,7 +527,7 @@ export function createOfflineInboundMigrationFlow() { } return api.startPasskeyRegistrationForSetup( - state.userDid, + unsafeAsDid(state.userDid), state.passkeySetupToken, ); } @@ -560,7 +569,7 @@ export function createOfflineInboundMigrationFlow() { }; const result = await api.completePasskeySetup( - state.userDid, + unsafeAsDid(state.userDid), state.passkeySetupToken, credentialData, passkeyName, diff --git a/frontend/src/lib/migration/plc-ops.ts b/frontend/src/lib/migration/plc-ops.ts index a1c3df1..a381c7e 100644 --- a/frontend/src/lib/migration/plc-ops.ts +++ b/frontend/src/lib/migration/plc-ops.ts @@ -28,7 +28,7 @@ export interface PlcService { export interface PlcOperationData { type: "plc_operation"; - prev: string; + prev: string | null; alsoKnownAs: string[]; rotationKeys: string[]; services: Record; @@ -66,6 +66,9 @@ export class PlcOps { if (!lastOp) { throw new Error("No PLC operations found for this DID"); } + if (lastOp.operation.type === "plc_tombstone") { + throw new Error("DID has been tombstoned"); + } return { lastOperation: normalizeOp(lastOp.operation), base: lastOp }; } @@ -108,7 +111,9 @@ export class PlcOps { } else if (match.type === "secp256k1") { keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes); } else { - throw new Error(`Unsupported key type: ${match.type}`); + throw new Error( + `Unsupported key type: ${(match as { type: string }).type}`, + ); } } else { throw new Error( diff --git a/frontend/src/lib/migration/storage.ts b/frontend/src/lib/migration/storage.ts index 078c334..8bb0f25 100644 --- a/frontend/src/lib/migration/storage.ts +++ b/frontend/src/lib/migration/storage.ts @@ -2,8 +2,8 @@ import type { MigrationDirection, MigrationState, StoredMigrationState, -} from "./types"; -import { clearDPoPKey } from "./atproto-client"; +} from "./types.ts"; +import { clearDPoPKey } from "./atproto-client.ts"; const STORAGE_KEY = "tranquil_migration_state"; const MAX_AGE_MS = 24 * 60 * 60 * 1000; @@ -12,22 +12,16 @@ export function saveMigrationState(state: MigrationState): void { const storedState: StoredMigrationState = { version: 1, direction: state.direction, - step: state.direction === "inbound" ? state.step : state.step, + step: state.step, startedAt: new Date().toISOString(), - sourcePdsUrl: state.direction === "inbound" - ? state.sourcePdsUrl - : globalThis.location.origin, - targetPdsUrl: state.direction === "inbound" - ? globalThis.location.origin - : state.targetPdsUrl, - sourceDid: state.direction === "inbound" ? state.sourceDid : "", - sourceHandle: state.direction === "inbound" ? state.sourceHandle : "", + sourcePdsUrl: state.sourcePdsUrl, + targetPdsUrl: globalThis.location.origin, + sourceDid: state.sourceDid, + sourceHandle: state.sourceHandle, targetHandle: state.targetHandle, targetEmail: state.targetEmail, - authMethod: state.direction === "inbound" ? state.authMethod : undefined, - passkeySetupToken: state.direction === "inbound" - ? state.passkeySetupToken ?? undefined - : undefined, + authMethod: state.authMethod, + passkeySetupToken: state.passkeySetupToken ?? undefined, progress: { repoExported: state.progress.repoExported, repoImported: state.progress.repoImported, diff --git a/frontend/src/lib/oauth.ts b/frontend/src/lib/oauth.ts index dcd6d5b..a7e560c 100644 --- a/frontend/src/lib/oauth.ts +++ b/frontend/src/lib/oauth.ts @@ -34,7 +34,9 @@ function sha256(plain: string): Promise { function base64UrlEncode(buffer: ArrayBuffer): string { const bytes = new Uint8Array(buffer); - const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('') + const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join( + "", + ); return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace( /=+$/, "", diff --git a/frontend/src/lib/registration/flow.svelte.ts b/frontend/src/lib/registration/flow.svelte.ts index 508996c..f9fb066 100644 --- a/frontend/src/lib/registration/flow.svelte.ts +++ b/frontend/src/lib/registration/flow.svelte.ts @@ -1,10 +1,15 @@ -import { api, ApiError } from "../api"; -import { setSession } from "../auth.svelte"; +import { api, ApiError } from "../api.ts"; +import { setSession } from "../auth.svelte.ts"; import { createServiceJwt, generateDidDocument, generateKeypair, -} from "../crypto"; +} from "../crypto.ts"; +import { + unsafeAsDid, + unsafeAsEmail, + unsafeAsHandle, +} from "../types/branded.ts"; import type { AccountResult, ExternalDidWebState, @@ -12,7 +17,7 @@ import type { RegistrationMode, RegistrationStep, SessionState, -} from "./types"; +} from "./types.ts"; export interface RegistrationFlowState { mode: RegistrationMode; @@ -100,7 +105,7 @@ export function createRegistrationFlow( if (keyMode === "reserved") { const result = await api.reserveSigningKey( - state.info.externalDid!.trim(), + unsafeAsDid(state.info.externalDid!.trim()), ); state.externalDidWeb.reservedSigningKey = result.signingKey; publicKeyMultibase = result.signingKey.replace("did:key:", ""); @@ -207,12 +212,14 @@ export function createRegistrationFlow( } const result = await api.createPasskeyAccount({ - handle: state.info.handle.trim(), - email: state.info.email?.trim() || undefined, + handle: unsafeAsHandle(state.info.handle.trim()), + email: state.info.email?.trim() + ? unsafeAsEmail(state.info.email.trim()) + : undefined, inviteCode: state.info.inviteCode?.trim() || undefined, didType: state.info.didType, did: state.info.didType === "web-external" - ? state.info.externalDid!.trim() + ? unsafeAsDid(state.info.externalDid!.trim()) : undefined, signingKey: state.info.didType === "web-external" && state.externalDidWeb.keyMode === "reserved" diff --git a/frontend/src/lib/registration/types.ts b/frontend/src/lib/registration/types.ts index 2c0e9f9..b51ef15 100644 --- a/frontend/src/lib/registration/types.ts +++ b/frontend/src/lib/registration/types.ts @@ -1,4 +1,10 @@ -import type { DidType, VerificationChannel } from "../api"; +import type { DidType, VerificationChannel } from "../api.ts"; +import type { + AccessToken, + Did, + Handle, + RefreshToken, +} from "../types/branded.ts"; export type RegistrationMode = "password" | "passkey"; @@ -37,14 +43,14 @@ export interface ExternalDidWebState { } export interface AccountResult { - did: string; - handle: string; + did: Did; + handle: Handle; setupToken?: string; appPassword?: string; appPasswordName?: string; } export interface SessionState { - accessJwt: string; - refreshJwt: string; + accessJwt: AccessToken; + refreshJwt: RefreshToken; } diff --git a/frontend/src/lib/router.svelte.ts b/frontend/src/lib/router.svelte.ts index 407e3ce..04dcce3 100644 --- a/frontend/src/lib/router.svelte.ts +++ b/frontend/src/lib/router.svelte.ts @@ -1,12 +1,12 @@ import { - routes, + buildUrl, + isValidRoute, + parseRouteParams, type Route, type RouteParams, + routes, type RoutesWithParams, - buildUrl, - parseRouteParams, - isValidRoute, -} from "./types/routes"; +} from "./types/routes.ts"; const APP_BASE = "/app"; @@ -120,7 +120,11 @@ export function getRouteParams( } export type RouteMatch = - | { readonly matched: true; readonly route: Route; readonly params: URLSearchParams } + | { + readonly matched: true; + readonly route: Route; + readonly params: URLSearchParams; + } | { readonly matched: false }; export function match(): RouteMatch { @@ -135,4 +139,4 @@ export function match(): RouteMatch { return { matched: false }; } -export { routes, type Route, type RouteParams, type RoutesWithParams }; +export { type Route, type RouteParams, routes, type RoutesWithParams }; diff --git a/frontend/src/lib/toast.svelte.ts b/frontend/src/lib/toast.svelte.ts index ecb5cd2..b077bae 100644 --- a/frontend/src/lib/toast.svelte.ts +++ b/frontend/src/lib/toast.svelte.ts @@ -1,66 +1,66 @@ -export type ToastType = 'success' | 'error' | 'warning' | 'info' +export type ToastType = "success" | "error" | "warning" | "info"; export interface Toast { - id: number - type: ToastType - message: string - duration: number - dismissing?: boolean + id: number; + type: ToastType; + message: string; + duration: number; + dismissing?: boolean; } -let nextId = 0 -let toasts = $state([]) +let nextId = 0; +let toasts = $state([]); export function getToasts(): readonly Toast[] { - return toasts + return toasts; } export function showToast( type: ToastType, message: string, - duration = 5000 + duration = 5000, ): number { - const id = nextId++ - toasts = [...toasts, { id, type, message, duration }] + const id = nextId++; + toasts = [...toasts, { id, type, message, duration }]; if (duration > 0) { setTimeout(() => { - dismissToast(id) - }, duration) + dismissToast(id); + }, duration); } - return id + return id; } export function dismissToast(id: number): void { - const toast = toasts.find(t => t.id === id) - if (!toast || toast.dismissing) return + const toast = toasts.find((t) => t.id === id); + if (!toast || toast.dismissing) return; - toasts = toasts.map(t => t.id === id ? { ...t, dismissing: true } : t) + toasts = toasts.map((t) => t.id === id ? { ...t, dismissing: true } : t); setTimeout(() => { - toasts = toasts.filter(t => t.id !== id) - }, 150) + toasts = toasts.filter((t) => t.id !== id); + }, 150); } export function clearAllToasts(): void { - toasts = [] + toasts = []; } export function success(message: string, duration?: number): number { - return showToast('success', message, duration) + return showToast("success", message, duration); } export function error(message: string, duration?: number): number { - return showToast('error', message, duration) + return showToast("error", message, duration); } export function warning(message: string, duration?: number): number { - return showToast('warning', message, duration) + return showToast("warning", message, duration); } export function info(message: string, duration?: number): number { - return showToast('info', message, duration) + return showToast("info", message, duration); } export const toast = { @@ -71,4 +71,4 @@ export const toast = { info, dismiss: dismissToast, clear: clearAllToasts, -} +}; diff --git a/frontend/src/lib/types/api.ts b/frontend/src/lib/types/api.ts index 4fbb1f2..da55287 100644 --- a/frontend/src/lib/types/api.ts +++ b/frontend/src/lib/types/api.ts @@ -1,486 +1,495 @@ import type { - Did, - Handle, AccessToken, - RefreshToken, - Cid, - Rkey, AtUri, - Nsid, - ISODateString, + Cid, + Did, EmailAddress, + Handle, InviteCode as InviteCodeBrand, + ISODateString, + Nsid, PublicKeyMultibase, -} from './branded' + RefreshToken, +} from "./branded.ts"; export type ApiErrorCode = - | 'InvalidRequest' - | 'AuthenticationRequired' - | 'ExpiredToken' - | 'InvalidToken' - | 'AccountNotFound' - | 'HandleNotAvailable' - | 'InvalidHandle' - | 'InvalidPassword' - | 'RateLimitExceeded' - | 'InternalServerError' - | 'AccountTakedown' - | 'AccountDeactivated' - | 'AccountNotVerified' - | 'RepoNotFound' - | 'RecordNotFound' - | 'BlobNotFound' - | 'InvalidInviteCode' - | 'DuplicateCreate' - | 'Unknown' + | "InvalidRequest" + | "AuthenticationRequired" + | "ExpiredToken" + | "InvalidToken" + | "AccountNotFound" + | "HandleNotAvailable" + | "InvalidHandle" + | "InvalidPassword" + | "RateLimitExceeded" + | "InternalServerError" + | "AccountTakedown" + | "AccountDeactivated" + | "AccountNotVerified" + | "RepoNotFound" + | "RecordNotFound" + | "BlobNotFound" + | "InvalidInviteCode" + | "DuplicateCreate" + | "ReauthRequired" + | "MfaVerificationRequired" + | "RecoveryLinkExpired" + | "InvalidRecoveryLink" + | "Unknown"; -export type AccountStatus = 'active' | 'deactivated' | 'migrated' | 'suspended' | 'deleted' +export type AccountStatus = + | "active" + | "deactivated" + | "migrated" + | "suspended" + | "deleted"; -export type SessionType = 'oauth' | 'legacy' | 'app_password' +export type SessionType = "oauth" | "legacy" | "app_password"; -export type VerificationChannel = 'email' | 'discord' | 'telegram' | 'signal' +export type VerificationChannel = "email" | "discord" | "telegram" | "signal"; -export type DidType = 'plc' | 'web' | 'web-external' +export type DidType = "plc" | "web" | "web-external"; -export type ReauthMethod = 'password' | 'totp' | 'passkey' +export type ReauthMethod = "password" | "totp" | "passkey"; export interface Session { - did: Did - handle: Handle - email?: EmailAddress - emailConfirmed?: boolean - preferredChannel?: VerificationChannel - preferredChannelVerified?: boolean - isAdmin?: boolean - active?: boolean - status?: AccountStatus - migratedToPds?: string - migratedAt?: ISODateString - accessJwt: AccessToken - refreshJwt: RefreshToken + did: Did; + handle: Handle; + email?: EmailAddress; + emailConfirmed?: boolean; + preferredChannel?: VerificationChannel; + preferredChannelVerified?: boolean; + preferredLocale?: string | null; + isAdmin?: boolean; + active?: boolean; + status?: AccountStatus; + migratedToPds?: string; + migratedAt?: ISODateString; + accessJwt: AccessToken; + refreshJwt: RefreshToken; } export interface VerificationMethod { - id: string - type: string - controller: string - publicKeyMultibase: PublicKeyMultibase + id: string; + type: string; + controller: string; + publicKeyMultibase: PublicKeyMultibase; } export interface ServiceEndpoint { - id: string - type: string - serviceEndpoint: string + id: string; + type: string; + serviceEndpoint: string; } export interface DidDocument { - '@context': string[] - id: Did - alsoKnownAs: string[] - verificationMethod: VerificationMethod[] - service: ServiceEndpoint[] + "@context": string[]; + id: Did; + alsoKnownAs: string[]; + verificationMethod: VerificationMethod[]; + service: ServiceEndpoint[]; } export interface AppPassword { - name: string - createdAt: ISODateString - scopes?: string - createdByController?: string + name: string; + createdAt: ISODateString; + scopes?: string; + createdByController?: string; } export interface CreatedAppPassword { - name: string - password: string - createdAt: ISODateString - scopes?: string + name: string; + password: string; + createdAt: ISODateString; + scopes?: string; } export interface InviteCodeUse { - usedBy: Did - usedByHandle?: Handle - usedAt: ISODateString + usedBy: Did; + usedByHandle?: Handle; + usedAt: ISODateString; } export interface InviteCodeInfo { - code: InviteCodeBrand - available: number - disabled: boolean - forAccount: Did - createdBy: Did - createdAt: ISODateString - uses: InviteCodeUse[] + code: InviteCodeBrand; + available: number; + disabled: boolean; + forAccount: Did; + createdBy: Did; + createdAt: ISODateString; + uses: InviteCodeUse[]; } export interface CreateAccountParams { - handle: string - email: string - password: string - inviteCode?: string - didType?: DidType - did?: string - signingKey?: string - verificationChannel?: VerificationChannel - discordId?: string - telegramUsername?: string - signalNumber?: string + handle: string; + email: string; + password: string; + inviteCode?: string; + didType?: DidType; + did?: string; + signingKey?: string; + verificationChannel?: VerificationChannel; + discordId?: string; + telegramUsername?: string; + signalNumber?: string; } export interface CreateAccountResult { - handle: Handle - did: Did - verificationRequired: boolean - verificationChannel: VerificationChannel + handle: Handle; + did: Did; + verificationRequired: boolean; + verificationChannel: VerificationChannel; } export interface ConfirmSignupResult { - accessJwt: AccessToken - refreshJwt: RefreshToken - handle: Handle - did: Did - email?: EmailAddress - emailConfirmed?: boolean - preferredChannel?: VerificationChannel - preferredChannelVerified?: boolean + accessJwt: AccessToken; + refreshJwt: RefreshToken; + handle: Handle; + did: Did; + email?: EmailAddress; + emailConfirmed?: boolean; + preferredChannel?: VerificationChannel; + preferredChannelVerified?: boolean; } export interface ListAppPasswordsResponse { - passwords: AppPassword[] + passwords: AppPassword[]; } export interface AccountInviteCodesResponse { - codes: InviteCodeInfo[] + codes: InviteCodeInfo[]; } export interface CreateInviteCodeResponse { - code: InviteCodeBrand + code: InviteCodeBrand; } export interface ServerLinks { - privacyPolicy?: string - termsOfService?: string + privacyPolicy?: string; + termsOfService?: string; } export interface ServerDescription { - availableUserDomains: string[] - inviteCodeRequired: boolean - links?: ServerLinks - version?: string - availableCommsChannels?: VerificationChannel[] - selfHostedDidWebEnabled?: boolean + availableUserDomains: string[]; + inviteCodeRequired: boolean; + links?: ServerLinks; + version?: string; + availableCommsChannels?: VerificationChannel[]; + selfHostedDidWebEnabled?: boolean; } export interface RepoInfo { - did: Did - head: Cid - rev: string + did: Did; + head: Cid; + rev: string; } export interface ListReposResponse { - repos: RepoInfo[] - cursor?: string + repos: RepoInfo[]; + cursor?: string; } export interface NotificationPrefs { - preferredChannel: VerificationChannel - email: EmailAddress - discordId: string | null - discordVerified: boolean - telegramUsername: string | null - telegramVerified: boolean - signalNumber: string | null - signalVerified: boolean + preferredChannel: VerificationChannel; + email: EmailAddress; + discordId: string | null; + discordVerified: boolean; + telegramUsername: string | null; + telegramVerified: boolean; + signalNumber: string | null; + signalVerified: boolean; } export interface NotificationHistoryItem { - createdAt: ISODateString - channel: VerificationChannel - notificationType: string - status: string - subject: string | null - body: string + createdAt: ISODateString; + channel: VerificationChannel; + notificationType: string; + status: string; + subject: string | null; + body: string; } export interface NotificationHistoryResponse { - notifications: NotificationHistoryItem[] + notifications: NotificationHistoryItem[]; } export interface ServerStats { - userCount: number - repoCount: number - recordCount: number - blobStorageBytes: number + userCount: number; + repoCount: number; + recordCount: number; + blobStorageBytes: number; } export interface ServerConfig { - serverName: string - primaryColor: string | null - primaryColorDark: string | null - secondaryColor: string | null - secondaryColorDark: string | null - logoCid: Cid | null + serverName: string; + primaryColor: string | null; + primaryColorDark: string | null; + secondaryColor: string | null; + secondaryColorDark: string | null; + logoCid: Cid | null; } export interface BlobRef { - $type: 'blob' - ref: { $link: Cid } - mimeType: string - size: number + $type: "blob"; + ref: { $link: Cid }; + mimeType: string; + size: number; } export interface UploadBlobResponse { - blob: BlobRef + blob: BlobRef; } export interface SessionInfo { - id: string - sessionType: SessionType - clientName: string | null - createdAt: ISODateString - expiresAt: ISODateString - isCurrent: boolean + id: string; + sessionType: SessionType; + clientName: string | null; + createdAt: ISODateString; + expiresAt: ISODateString; + isCurrent: boolean; } export interface ListSessionsResponse { - sessions: SessionInfo[] + sessions: SessionInfo[]; } export interface RevokeAllSessionsResponse { - revokedCount: number + revokedCount: number; } export interface AccountSearchResult { - did: Did - handle: Handle - email?: EmailAddress - indexedAt: ISODateString - emailConfirmedAt?: ISODateString - deactivatedAt?: ISODateString + did: Did; + handle: Handle; + email?: EmailAddress; + indexedAt: ISODateString; + emailConfirmedAt?: ISODateString; + deactivatedAt?: ISODateString; } export interface SearchAccountsResponse { - cursor?: string - accounts: AccountSearchResult[] + cursor?: string; + accounts: AccountSearchResult[]; } export interface AdminInviteCodeUse { - usedBy: Did - usedAt: ISODateString + usedBy: Did; + usedAt: ISODateString; } export interface AdminInviteCode { - code: InviteCodeBrand - available: number - disabled: boolean - forAccount: Did - createdBy: Did - createdAt: ISODateString - uses: AdminInviteCodeUse[] + code: InviteCodeBrand; + available: number; + disabled: boolean; + forAccount: Did; + createdBy: Did; + createdAt: ISODateString; + uses: AdminInviteCodeUse[]; } export interface GetInviteCodesResponse { - cursor?: string - codes: AdminInviteCode[] + cursor?: string; + codes: AdminInviteCode[]; } export interface AccountInfo { - did: Did - handle: Handle - email?: EmailAddress - indexedAt: ISODateString - emailConfirmedAt?: ISODateString - invitesDisabled?: boolean - deactivatedAt?: ISODateString + did: Did; + handle: Handle; + email?: EmailAddress; + indexedAt: ISODateString; + emailConfirmedAt?: ISODateString; + invitesDisabled?: boolean; + deactivatedAt?: ISODateString; } export interface RepoDescription { - handle: Handle - did: Did - didDoc: DidDocument - collections: Nsid[] - handleIsCorrect: boolean + handle: Handle; + did: Did; + didDoc: DidDocument; + collections: Nsid[]; + handleIsCorrect: boolean; } export interface RecordInfo { - uri: AtUri - cid: Cid - value: unknown + uri: AtUri; + cid: Cid; + value: unknown; } export interface ListRecordsResponse { - records: RecordInfo[] - cursor?: string + records: RecordInfo[]; + cursor?: string; } export interface RecordResponse { - uri: AtUri - cid: Cid - value: unknown + uri: AtUri; + cid: Cid; + value: unknown; } export interface CreateRecordResponse { - uri: AtUri - cid: Cid + uri: AtUri; + cid: Cid; } export interface TotpStatus { - enabled: boolean - hasBackupCodes: boolean + enabled: boolean; + hasBackupCodes: boolean; } export interface TotpSecret { - uri: string - qrBase64: string + uri: string; + qrBase64: string; } export interface EnableTotpResponse { - success: boolean - backupCodes: string[] + success: boolean; + backupCodes: string[]; } export interface RegenerateBackupCodesResponse { - backupCodes: string[] + backupCodes: string[]; } export interface PasskeyInfo { - id: string - credentialId: string - friendlyName: string | null - createdAt: ISODateString - lastUsed: ISODateString | null + id: string; + credentialId: string; + friendlyName: string | null; + createdAt: ISODateString; + lastUsed: ISODateString | null; } export interface ListPasskeysResponse { - passkeys: PasskeyInfo[] + passkeys: PasskeyInfo[]; } export interface StartPasskeyRegistrationResponse { - options: PublicKeyCredentialCreationOptions + options: PublicKeyCredentialCreationOptions; } export interface FinishPasskeyRegistrationResponse { - id: string - credentialId: string + id: string; + credentialId: string; } export interface TrustedDevice { - id: string - userAgent: string | null - friendlyName: string | null - trustedAt: ISODateString | null - trustedUntil: ISODateString | null - lastSeenAt: ISODateString + id: string; + userAgent: string | null; + friendlyName: string | null; + trustedAt: ISODateString | null; + trustedUntil: ISODateString | null; + lastSeenAt: ISODateString; } export interface ListTrustedDevicesResponse { - devices: TrustedDevice[] + devices: TrustedDevice[]; } export interface ReauthStatus { - requiresReauth: boolean - lastReauthAt: ISODateString | null - availableMethods: ReauthMethod[] + requiresReauth: boolean; + lastReauthAt: ISODateString | null; + availableMethods: ReauthMethod[]; } export interface ReauthResponse { - success: boolean - reauthAt: ISODateString + success: boolean; + reauthAt: ISODateString; } export interface ReauthPasskeyStartResponse { - options: PublicKeyCredentialRequestOptions + options: PublicKeyCredentialRequestOptions; } export interface ReserveSigningKeyResponse { - signingKey: PublicKeyMultibase + signingKey: PublicKeyMultibase; } export interface RecommendedDidCredentials { - rotationKeys?: PublicKeyMultibase[] - alsoKnownAs?: string[] - verificationMethods?: { atproto?: PublicKeyMultibase } - services?: { atproto_pds?: { type: string; endpoint: string } } + rotationKeys?: PublicKeyMultibase[]; + alsoKnownAs?: string[]; + verificationMethods?: { atproto?: PublicKeyMultibase }; + services?: { atproto_pds?: { type: string; endpoint: string } }; } export interface PasskeyAccountCreateResponse { - did: Did - handle: Handle - setupToken: string - setupExpiresAt: ISODateString + did: Did; + handle: Handle; + setupToken: string; + setupExpiresAt: ISODateString; } export interface CompletePasskeySetupResponse { - did: Did - handle: Handle - appPassword: string - appPasswordName: string + did: Did; + handle: Handle; + appPassword: string; + appPasswordName: string; } export interface VerifyTokenResponse { - success: boolean - did: Did - purpose: string - channel: VerificationChannel + success: boolean; + did: Did; + purpose: string; + channel: VerificationChannel; } export interface BackupInfo { - id: string - repoRev: string - repoRootCid: Cid - blockCount: number - sizeBytes: number - createdAt: ISODateString + id: string; + repoRev: string; + repoRootCid: Cid; + blockCount: number; + sizeBytes: number; + createdAt: ISODateString; } export interface ListBackupsResponse { - backups: BackupInfo[] - backupEnabled: boolean + backups: BackupInfo[]; + backupEnabled: boolean; } export interface CreateBackupResponse { - id: string - repoRev: string - sizeBytes: number - blockCount: number + id: string; + repoRev: string; + sizeBytes: number; + blockCount: number; } export interface SetBackupEnabledResponse { - enabled: boolean + enabled: boolean; } export interface EmailUpdateResponse { - tokenRequired: boolean + tokenRequired: boolean; } export interface LegacyLoginPreference { - allowLegacyLogin: boolean - hasMfa: boolean + allowLegacyLogin: boolean; + hasMfa: boolean; } export interface UpdateLegacyLoginResponse { - allowLegacyLogin: boolean + allowLegacyLogin: boolean; } export interface UpdateLocaleResponse { - preferredLocale: string + preferredLocale: string; } export interface PasswordStatus { - hasPassword: boolean + hasPassword: boolean; } export interface SuccessResponse { - success: boolean + success: boolean; } export interface CheckEmailVerifiedResponse { - verified: boolean + verified: boolean; } export interface VerifyMigrationEmailResponse { - success: boolean - did: Did + success: boolean; + did: Did; } export interface ResendMigrationVerificationResponse { - sent: boolean + sent: boolean; } diff --git a/frontend/src/lib/types/branded.ts b/frontend/src/lib/types/branded.ts index 6690705..08d1a43 100644 --- a/frontend/src/lib/types/branded.ts +++ b/frontend/src/lib/types/branded.ts @@ -1,188 +1,195 @@ -declare const __brand: unique symbol +declare const __brand: unique symbol; -type Brand = T & { readonly [__brand]: B } +type Brand = T & { readonly [__brand]: B }; -export type Did = Brand -export type DidPlc = Brand -export type DidWeb = Brand +export type Did = Brand; +export type DidPlc = Brand; +export type DidWeb = Brand; -export type Handle = Brand -export type AccessToken = Brand -export type RefreshToken = Brand -export type ServiceToken = Brand -export type SetupToken = Brand +export type Handle = Brand; +export type AccessToken = Brand; +export type RefreshToken = Brand; +export type ServiceToken = Brand; +export type SetupToken = Brand; -export type Cid = Brand -export type Rkey = Brand -export type AtUri = Brand -export type Nsid = Brand +export type Cid = Brand; +export type Rkey = Brand; +export type AtUri = Brand; +export type Nsid = Brand; -export type ISODateString = Brand -export type EmailAddress = Brand -export type InviteCode = Brand +export type ISODateString = Brand; +export type EmailAddress = Brand; +export type InviteCode = Brand; -export type PublicKeyMultibase = Brand -export type DidKeyString = Brand +export type PublicKeyMultibase = Brand; +export type DidKeyString = Brand; -const DID_PLC_REGEX = /^did:plc:[a-z2-7]{24}$/ -const DID_WEB_REGEX = /^did:web:.+$/ -const HANDLE_REGEX = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/ -const AT_URI_REGEX = /^at:\/\/[^/]+\/[^/]+\/[^/]+$/ -const CID_REGEX = /^[a-z2-7]{59}$|^baf[a-z2-7]+$/ -const NSID_REGEX = /^[a-z]([a-z0-9-]*[a-z0-9])?(\.[a-z]([a-z0-9-]*[a-z0-9])?)+$/ -const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ -const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/ +const DID_PLC_REGEX = /^did:plc:[a-z2-7]{24}$/; +const DID_WEB_REGEX = /^did:web:.+$/; +const HANDLE_REGEX = + /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/; +const AT_URI_REGEX = /^at:\/\/[^/]+\/[^/]+\/[^/]+$/; +const CID_REGEX = /^[a-z2-7]{59}$|^baf[a-z2-7]+$/; +const NSID_REGEX = + /^[a-z]([a-z0-9-]*[a-z0-9])?(\.[a-z]([a-z0-9-]*[a-z0-9])?)+$/; +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const ISO_DATE_REGEX = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/; export function isDid(s: string): s is Did { - return s.startsWith('did:plc:') || s.startsWith('did:web:') + return s.startsWith("did:plc:") || s.startsWith("did:web:"); } export function isDidPlc(s: string): s is DidPlc { - return DID_PLC_REGEX.test(s) + return DID_PLC_REGEX.test(s); } export function isDidWeb(s: string): s is DidWeb { - return DID_WEB_REGEX.test(s) + return DID_WEB_REGEX.test(s); } export function isHandle(s: string): s is Handle { - return HANDLE_REGEX.test(s) && s.length <= 253 + return HANDLE_REGEX.test(s) && s.length <= 253; } export function isAtUri(s: string): s is AtUri { - return AT_URI_REGEX.test(s) + return AT_URI_REGEX.test(s); } export function isCid(s: string): s is Cid { - return CID_REGEX.test(s) + return CID_REGEX.test(s); } export function isNsid(s: string): s is Nsid { - return NSID_REGEX.test(s) + return NSID_REGEX.test(s); } export function isEmail(s: string): s is EmailAddress { - return EMAIL_REGEX.test(s) + return EMAIL_REGEX.test(s); } export function isISODate(s: string): s is ISODateString { - return ISO_DATE_REGEX.test(s) + return ISO_DATE_REGEX.test(s); } export function asDid(s: string): Did { - if (!isDid(s)) throw new TypeError(`Invalid DID: ${s}`) - return s + if (!isDid(s)) throw new TypeError(`Invalid DID: ${s}`); + return s; } export function asDidPlc(s: string): DidPlc { - if (!isDidPlc(s)) throw new TypeError(`Invalid DID:PLC: ${s}`) - return s as DidPlc + if (!isDidPlc(s)) throw new TypeError(`Invalid DID:PLC: ${s}`); + return s as DidPlc; } export function asDidWeb(s: string): DidWeb { - if (!isDidWeb(s)) throw new TypeError(`Invalid DID:WEB: ${s}`) - return s as DidWeb + if (!isDidWeb(s)) throw new TypeError(`Invalid DID:WEB: ${s}`); + return s as DidWeb; } export function asHandle(s: string): Handle { - if (!isHandle(s)) throw new TypeError(`Invalid handle: ${s}`) - return s + if (!isHandle(s)) throw new TypeError(`Invalid handle: ${s}`); + return s; } export function asAtUri(s: string): AtUri { - if (!isAtUri(s)) throw new TypeError(`Invalid AT-URI: ${s}`) - return s + if (!isAtUri(s)) throw new TypeError(`Invalid AT-URI: ${s}`); + return s; } export function asCid(s: string): Cid { - if (!isCid(s)) throw new TypeError(`Invalid CID: ${s}`) - return s + if (!isCid(s)) throw new TypeError(`Invalid CID: ${s}`); + return s; } export function asNsid(s: string): Nsid { - if (!isNsid(s)) throw new TypeError(`Invalid NSID: ${s}`) - return s + if (!isNsid(s)) throw new TypeError(`Invalid NSID: ${s}`); + return s; } export function asEmail(s: string): EmailAddress { - if (!isEmail(s)) throw new TypeError(`Invalid email: ${s}`) - return s + if (!isEmail(s)) throw new TypeError(`Invalid email: ${s}`); + return s; } export function asISODate(s: string): ISODateString { - if (!isISODate(s)) throw new TypeError(`Invalid ISO date: ${s}`) - return s + if (!isISODate(s)) throw new TypeError(`Invalid ISO date: ${s}`); + return s; } export function unsafeAsDid(s: string): Did { - return s as Did + return s as Did; } export function unsafeAsHandle(s: string): Handle { - return s as Handle + return s as Handle; } export function unsafeAsAccessToken(s: string): AccessToken { - return s as AccessToken + return s as AccessToken; } export function unsafeAsRefreshToken(s: string): RefreshToken { - return s as RefreshToken + return s as RefreshToken; } export function unsafeAsServiceToken(s: string): ServiceToken { - return s as ServiceToken + return s as ServiceToken; } export function unsafeAsSetupToken(s: string): SetupToken { - return s as SetupToken + return s as SetupToken; } export function unsafeAsCid(s: string): Cid { - return s as Cid + return s as Cid; } export function unsafeAsRkey(s: string): Rkey { - return s as Rkey + return s as Rkey; } export function unsafeAsAtUri(s: string): AtUri { - return s as AtUri + return s as AtUri; } export function unsafeAsNsid(s: string): Nsid { - return s as Nsid + return s as Nsid; } export function unsafeAsISODate(s: string): ISODateString { - return s as ISODateString + return s as ISODateString; } +export const unsafeAsISODateString = unsafeAsISODate; + export function unsafeAsEmail(s: string): EmailAddress { - return s as EmailAddress + return s as EmailAddress; } export function unsafeAsInviteCode(s: string): InviteCode { - return s as InviteCode + return s as InviteCode; } export function unsafeAsPublicKeyMultibase(s: string): PublicKeyMultibase { - return s as PublicKeyMultibase + return s as PublicKeyMultibase; } export function unsafeAsDidKey(s: string): DidKeyString { - return s as DidKeyString + return s as DidKeyString; } -export function parseAtUri(uri: AtUri): { repo: Did; collection: Nsid; rkey: Rkey } { - const parts = uri.replace('at://', '').split('/') +export function parseAtUri( + uri: AtUri, +): { repo: Did; collection: Nsid; rkey: Rkey } { + const parts = uri.replace("at://", "").split("/"); return { repo: unsafeAsDid(parts[0]), collection: unsafeAsNsid(parts[1]), rkey: unsafeAsRkey(parts[2]), - } + }; } export function makeAtUri(repo: Did, collection: Nsid, rkey: Rkey): AtUri { - return `at://${repo}/${collection}/${rkey}` as AtUri + return `at://${repo}/${collection}/${rkey}` as AtUri; } diff --git a/frontend/src/lib/types/exhaustive.ts b/frontend/src/lib/types/exhaustive.ts index 1f1395e..7e10a71 100644 --- a/frontend/src/lib/types/exhaustive.ts +++ b/frontend/src/lib/types/exhaustive.ts @@ -1,49 +1,49 @@ export function assertNever(x: never, message?: string): never { - throw new Error(message ?? `Unexpected value: ${JSON.stringify(x)}`) + throw new Error(message ?? `Unexpected value: ${JSON.stringify(x)}`); } export function exhaustive( value: T, - handlers: Record void> + handlers: Record void>, ): void { - const handler = handlers[value] + const handler = handlers[value]; if (handler) { - handler() + handler(); } else { - assertNever(value as never, `Unhandled case: ${String(value)}`) + assertNever(value as never, `Unhandled case: ${String(value)}`); } } export function exhaustiveMap( value: T, - handlers: Record R> + handlers: Record R>, ): R { - const handler = handlers[value] + const handler = handlers[value]; if (handler) { - return handler() + return handler(); } - return assertNever(value as never, `Unhandled case: ${String(value)}`) + return assertNever(value as never, `Unhandled case: ${String(value)}`); } export async function exhaustiveAsync( value: T, - handlers: Record Promise> + handlers: Record Promise>, ): Promise { - const handler = handlers[value] + const handler = handlers[value]; if (handler) { - await handler() + await handler(); } else { - assertNever(value as never, `Unhandled case: ${String(value)}`) + assertNever(value as never, `Unhandled case: ${String(value)}`); } } export async function exhaustiveMapAsync( value: T, - handlers: Record Promise> + handlers: Record Promise>, ): Promise { - const handler = handlers[value] + const handler = handlers[value]; if (handler) { - return handler() + return handler(); } - return assertNever(value as never, `Unhandled case: ${String(value)}`) + return assertNever(value as never, `Unhandled case: ${String(value)}`); } diff --git a/frontend/src/lib/types/index.ts b/frontend/src/lib/types/index.ts index 6d01365..7791987 100644 --- a/frontend/src/lib/types/index.ts +++ b/frontend/src/lib/types/index.ts @@ -1,5 +1,5 @@ -export * from './result' -export * from './branded' -export * from './exhaustive' -export * from './api' -export * from './routes' +export * from "./result.ts"; +export * from "./branded.ts"; +export * from "./exhaustive.ts"; +export * from "./api.ts"; +export * from "./routes.ts"; diff --git a/frontend/src/lib/types/result.ts b/frontend/src/lib/types/result.ts index 81f484e..a4cde54 100644 --- a/frontend/src/lib/types/result.ts +++ b/frontend/src/lib/types/result.ts @@ -1,94 +1,111 @@ export type Result = | { ok: true; value: T } - | { ok: false; error: E } + | { ok: false; error: E }; export function ok(value: T): Result { - return { ok: true, value } + return { ok: true, value }; } export function err(error: E): Result { - return { ok: false, error } + return { ok: false, error }; } -export function isOk(result: Result): result is { ok: true; value: T } { - return result.ok +export function isOk( + result: Result, +): result is { ok: true; value: T } { + return result.ok; } -export function isErr(result: Result): result is { ok: false; error: E } { - return !result.ok +export function isErr( + result: Result, +): result is { ok: false; error: E } { + return !result.ok; } -export function map(result: Result, fn: (t: T) => U): Result { - return result.ok ? ok(fn(result.value)) : result +export function map( + result: Result, + fn: (t: T) => U, +): Result { + return result.ok ? ok(fn(result.value)) : result; } -export function mapErr(result: Result, fn: (e: E) => F): Result { - return result.ok ? result : err(fn(result.error)) +export function mapErr( + result: Result, + fn: (e: E) => F, +): Result { + return result.ok ? result : err(fn(result.error)); } -export function flatMap(result: Result, fn: (t: T) => Result): Result { - return result.ok ? fn(result.value) : result +export function flatMap( + result: Result, + fn: (t: T) => Result, +): Result { + return result.ok ? fn(result.value) : result; } export function unwrap(result: Result): T { - if (result.ok) return result.value - throw result.error instanceof Error ? result.error : new Error(String(result.error)) + if (result.ok) return result.value; + throw result.error instanceof Error + ? result.error + : new Error(String(result.error)); } export function unwrapOr(result: Result, defaultValue: T): T { - return result.ok ? result.value : defaultValue + return result.ok ? result.value : defaultValue; } export function unwrapOrElse(result: Result, fn: (e: E) => T): T { - return result.ok ? result.value : fn(result.error) + return result.ok ? result.value : fn(result.error); } export function match( result: Result, - handlers: { ok: (t: T) => U; err: (e: E) => U } + handlers: { ok: (t: T) => U; err: (e: E) => U }, ): U { - return result.ok ? handlers.ok(result.value) : handlers.err(result.error) + return result.ok ? handlers.ok(result.value) : handlers.err(result.error); } -export async function tryAsync(fn: () => Promise): Promise> { +export async function tryAsync( + fn: () => Promise, +): Promise> { try { - return ok(await fn()) + return ok(await fn()); } catch (e) { - return err(e instanceof Error ? e : new Error(String(e))) + return err(e instanceof Error ? e : new Error(String(e))); } } export async function tryAsyncWith( fn: () => Promise, - mapError: (e: unknown) => E + mapError: (e: unknown) => E, ): Promise> { try { - return ok(await fn()) + return ok(await fn()); } catch (e) { - return err(mapError(e)) + return err(mapError(e)); } } export function fromNullable(value: T | null | undefined): Result { - return value != null ? ok(value) : err(null) + return value != null ? ok(value) : err(null); } export function toNullable(result: Result): T | null { - return result.ok ? result.value : null + return result.ok ? result.value : null; } export function collect(results: Result[]): Result { - const values: T[] = [] + const values: T[] = []; for (const result of results) { - if (!result.ok) return result - values.push(result.value) + if (!result.ok) return result; + values.push(result.value); } - return ok(values) + return ok(values); } export async function collectAsync( - results: Promise>[] + results: Promise>[], ): Promise> { - const settled = await Promise.all(results) - return collect(settled) + const settled = await Promise.all(results); + return collect(settled); } diff --git a/frontend/src/lib/types/routes.ts b/frontend/src/lib/types/routes.ts index 5266459..e69743a 100644 --- a/frontend/src/lib/types/routes.ts +++ b/frontend/src/lib/types/routes.ts @@ -1,83 +1,83 @@ export const routes = { - login: '/login', - register: '/register', - registerPasskey: '/register-passkey', - dashboard: '/dashboard', - settings: '/settings', - security: '/security', - sessions: '/sessions', - appPasswords: '/app-passwords', - trustedDevices: '/trusted-devices', - inviteCodes: '/invite-codes', - comms: '/comms', - repo: '/repo', - controllers: '/controllers', - delegationAudit: '/delegation-audit', - actAs: '/act-as', - didDocument: '/did-document', - migrate: '/migrate', - admin: '/admin', - verify: '/verify', - resetPassword: '/reset-password', - recoverPasskey: '/recover-passkey', - requestPasskeyRecovery: '/request-passkey-recovery', - oauthLogin: '/oauth/login', - oauthConsent: '/oauth/consent', - oauthAccounts: '/oauth/accounts', - oauth2fa: '/oauth/2fa', - oauthTotp: '/oauth/totp', - oauthPasskey: '/oauth/passkey', - oauthDelegation: '/oauth/delegation', - oauthError: '/oauth/error', -} as const + login: "/login", + register: "/register", + registerPasskey: "/register-passkey", + dashboard: "/dashboard", + settings: "/settings", + security: "/security", + sessions: "/sessions", + appPasswords: "/app-passwords", + trustedDevices: "/trusted-devices", + inviteCodes: "/invite-codes", + comms: "/comms", + repo: "/repo", + controllers: "/controllers", + delegationAudit: "/delegation-audit", + actAs: "/act-as", + didDocument: "/did-document", + migrate: "/migrate", + admin: "/admin", + verify: "/verify", + resetPassword: "/reset-password", + recoverPasskey: "/recover-passkey", + requestPasskeyRecovery: "/request-passkey-recovery", + oauthLogin: "/oauth/login", + oauthConsent: "/oauth/consent", + oauthAccounts: "/oauth/accounts", + oauth2fa: "/oauth/2fa", + oauthTotp: "/oauth/totp", + oauthPasskey: "/oauth/passkey", + oauthDelegation: "/oauth/delegation", + oauthError: "/oauth/error", +} as const; -export type Route = (typeof routes)[keyof typeof routes] +export type Route = (typeof routes)[keyof typeof routes]; -export type RouteKey = keyof typeof routes +export type RouteKey = keyof typeof routes; export function isValidRoute(path: string): path is Route { - return Object.values(routes).includes(path as Route) + return Object.values(routes).includes(path as Route); } export interface RouteParams { - [routes.verify]: { token?: string; email?: string } - [routes.resetPassword]: { token?: string } - [routes.recoverPasskey]: { token?: string; did?: string } - [routes.oauthLogin]: { request_uri?: string; error?: string } - [routes.oauthConsent]: { request_uri?: string; client_id?: string } - [routes.oauthAccounts]: { request_uri?: string } - [routes.oauth2fa]: { request_uri?: string; channel?: string } - [routes.oauthTotp]: { request_uri?: string } - [routes.oauthPasskey]: { request_uri?: string } - [routes.oauthDelegation]: { request_uri?: string; delegated_did?: string } - [routes.oauthError]: { error?: string; error_description?: string } - [routes.migrate]: { code?: string; state?: string } + [routes.verify]: { token?: string; email?: string }; + [routes.resetPassword]: { token?: string }; + [routes.recoverPasskey]: { token?: string; did?: string }; + [routes.oauthLogin]: { request_uri?: string; error?: string }; + [routes.oauthConsent]: { request_uri?: string; client_id?: string }; + [routes.oauthAccounts]: { request_uri?: string }; + [routes.oauth2fa]: { request_uri?: string; channel?: string }; + [routes.oauthTotp]: { request_uri?: string }; + [routes.oauthPasskey]: { request_uri?: string }; + [routes.oauthDelegation]: { request_uri?: string; delegated_did?: string }; + [routes.oauthError]: { error?: string; error_description?: string }; + [routes.migrate]: { code?: string; state?: string }; } -export type RoutesWithParams = keyof RouteParams +export type RoutesWithParams = keyof RouteParams; export function buildUrl( route: R, - params?: R extends RoutesWithParams ? RouteParams[R] : never + params?: R extends RoutesWithParams ? RouteParams[R] : never, ): string { - if (!params) return route - const searchParams = new URLSearchParams() + if (!params) return route; + const searchParams = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { if (value != null) { - searchParams.set(key, String(value)) + searchParams.set(key, String(value)); } } - const queryString = searchParams.toString() - return queryString ? `${route}?${queryString}` : route + const queryString = searchParams.toString(); + return queryString ? `${route}?${queryString}` : route; } export function parseRouteParams( - route: R + _route: R, ): RouteParams[R] { - const params = new URLSearchParams(globalThis.location.search) - const result: Record = {} + const params = new URLSearchParams(globalThis.location.search); + const result: Record = {}; for (const [key, value] of params.entries()) { - result[key] = value + result[key] = value; } - return result as RouteParams[R] + return result as RouteParams[R]; } diff --git a/frontend/src/lib/types/schemas.ts b/frontend/src/lib/types/schemas.ts index 19a9f1c..95dc358 100644 --- a/frontend/src/lib/types/schemas.ts +++ b/frontend/src/lib/types/schemas.ts @@ -1,51 +1,50 @@ -import { z } from 'zod' -import type { - Did, - Handle, - AccessToken, - RefreshToken, - Cid, - Nsid, - AtUri, - Rkey, - ISODateString, - EmailAddress, - InviteCode, - PublicKeyMultibase, -} from './branded' +import { z } from "zod"; import { - unsafeAsDid, - unsafeAsHandle, unsafeAsAccessToken, - unsafeAsRefreshToken, - unsafeAsCid, - unsafeAsNsid, unsafeAsAtUri, - unsafeAsRkey, - unsafeAsISODate, + unsafeAsCid, + unsafeAsDid, unsafeAsEmail, + unsafeAsHandle, unsafeAsInviteCode, + unsafeAsISODate, + unsafeAsNsid, unsafeAsPublicKeyMultibase, -} from './branded' + unsafeAsRefreshToken, + unsafeAsRkey, +} from "./branded.ts"; -const did = z.string().transform((s) => unsafeAsDid(s)) -const handle = z.string().transform((s) => unsafeAsHandle(s)) -const accessToken = z.string().transform((s) => unsafeAsAccessToken(s)) -const refreshToken = z.string().transform((s) => unsafeAsRefreshToken(s)) -const cid = z.string().transform((s) => unsafeAsCid(s)) -const nsid = z.string().transform((s) => unsafeAsNsid(s)) -const atUri = z.string().transform((s) => unsafeAsAtUri(s)) -const rkey = z.string().transform((s) => unsafeAsRkey(s)) -const isoDate = z.string().transform((s) => unsafeAsISODate(s)) -const email = z.string().transform((s) => unsafeAsEmail(s)) -const inviteCode = z.string().transform((s) => unsafeAsInviteCode(s)) -const publicKeyMultibase = z.string().transform((s) => unsafeAsPublicKeyMultibase(s)) +const did = z.string().transform((s) => unsafeAsDid(s)); +const handle = z.string().transform((s) => unsafeAsHandle(s)); +const accessToken = z.string().transform((s) => unsafeAsAccessToken(s)); +const refreshToken = z.string().transform((s) => unsafeAsRefreshToken(s)); +const cid = z.string().transform((s) => unsafeAsCid(s)); +const nsid = z.string().transform((s) => unsafeAsNsid(s)); +const atUri = z.string().transform((s) => unsafeAsAtUri(s)); +const _rkey = z.string().transform((s) => unsafeAsRkey(s)); +const isoDate = z.string().transform((s) => unsafeAsISODate(s)); +const email = z.string().transform((s) => unsafeAsEmail(s)); +const inviteCode = z.string().transform((s) => unsafeAsInviteCode(s)); +const publicKeyMultibase = z.string().transform((s) => + unsafeAsPublicKeyMultibase(s) +); -export const verificationChannel = z.enum(['email', 'discord', 'telegram', 'signal']) -export const didType = z.enum(['plc', 'web', 'web-external']) -export const accountStatus = z.enum(['active', 'deactivated', 'migrated', 'suspended', 'deleted']) -export const sessionType = z.enum(['oauth', 'legacy', 'app_password']) -export const reauthMethod = z.enum(['password', 'totp', 'passkey']) +export const verificationChannel = z.enum([ + "email", + "discord", + "telegram", + "signal", +]); +export const didType = z.enum(["plc", "web", "web-external"]); +export const accountStatus = z.enum([ + "active", + "deactivated", + "migrated", + "suspended", + "deleted", +]); +export const sessionType = z.enum(["oauth", "legacy", "app_password"]); +export const reauthMethod = z.enum(["password", "totp", "passkey"]); export const sessionSchema = z.object({ did: did, @@ -61,12 +60,12 @@ export const sessionSchema = z.object({ migratedAt: isoDate.optional(), accessJwt: accessToken, refreshJwt: refreshToken, -}) +}); export const serverLinksSchema = z.object({ privacyPolicy: z.string().optional(), termsOfService: z.string().optional(), -}) +}); export const serverDescriptionSchema = z.object({ availableUserDomains: z.array(z.string()), @@ -75,27 +74,27 @@ export const serverDescriptionSchema = z.object({ version: z.string().optional(), availableCommsChannels: z.array(verificationChannel).optional(), selfHostedDidWebEnabled: z.boolean().optional(), -}) +}); export const appPasswordSchema = z.object({ name: z.string(), createdAt: isoDate, scopes: z.string().optional(), createdByController: z.string().optional(), -}) +}); export const createdAppPasswordSchema = z.object({ name: z.string(), password: z.string(), createdAt: isoDate, scopes: z.string().optional(), -}) +}); export const inviteCodeUseSchema = z.object({ usedBy: did, usedByHandle: handle.optional(), usedAt: isoDate, -}) +}); export const inviteCodeInfoSchema = z.object({ code: inviteCode, @@ -105,7 +104,7 @@ export const inviteCodeInfoSchema = z.object({ createdBy: did, createdAt: isoDate, uses: z.array(inviteCodeUseSchema), -}) +}); export const sessionInfoSchema = z.object({ id: z.string(), @@ -114,26 +113,26 @@ export const sessionInfoSchema = z.object({ createdAt: isoDate, expiresAt: isoDate, isCurrent: z.boolean(), -}) +}); export const listSessionsResponseSchema = z.object({ sessions: z.array(sessionInfoSchema), -}) +}); export const totpStatusSchema = z.object({ enabled: z.boolean(), hasBackupCodes: z.boolean(), -}) +}); export const totpSecretSchema = z.object({ uri: z.string(), qrBase64: z.string(), -}) +}); export const enableTotpResponseSchema = z.object({ success: z.boolean(), backupCodes: z.array(z.string()), -}) +}); export const passkeyInfoSchema = z.object({ id: z.string(), @@ -141,11 +140,11 @@ export const passkeyInfoSchema = z.object({ friendlyName: z.string().nullable(), createdAt: isoDate, lastUsed: isoDate.nullable(), -}) +}); export const listPasskeysResponseSchema = z.object({ passkeys: z.array(passkeyInfoSchema), -}) +}); export const trustedDeviceSchema = z.object({ id: z.string(), @@ -154,22 +153,22 @@ export const trustedDeviceSchema = z.object({ trustedAt: isoDate.nullable(), trustedUntil: isoDate.nullable(), lastSeenAt: isoDate, -}) +}); export const listTrustedDevicesResponseSchema = z.object({ devices: z.array(trustedDeviceSchema), -}) +}); export const reauthStatusSchema = z.object({ requiresReauth: z.boolean(), lastReauthAt: isoDate.nullable(), availableMethods: z.array(reauthMethod), -}) +}); export const reauthResponseSchema = z.object({ success: z.boolean(), reauthAt: isoDate, -}) +}); export const notificationPrefsSchema = z.object({ preferredChannel: verificationChannel, @@ -180,28 +179,28 @@ export const notificationPrefsSchema = z.object({ telegramVerified: z.boolean(), signalNumber: z.string().nullable(), signalVerified: z.boolean(), -}) +}); export const verificationMethodSchema = z.object({ id: z.string(), type: z.string(), controller: z.string(), publicKeyMultibase: publicKeyMultibase, -}) +}); export const serviceEndpointSchema = z.object({ id: z.string(), type: z.string(), serviceEndpoint: z.string(), -}) +}); export const didDocumentSchema = z.object({ - '@context': z.array(z.string()), + "@context": z.array(z.string()), id: did, alsoKnownAs: z.array(z.string()), verificationMethod: z.array(verificationMethodSchema), service: z.array(serviceEndpointSchema), -}) +}); export const repoDescriptionSchema = z.object({ handle: handle, @@ -209,36 +208,36 @@ export const repoDescriptionSchema = z.object({ didDoc: didDocumentSchema, collections: z.array(nsid), handleIsCorrect: z.boolean(), -}) +}); export const recordInfoSchema = z.object({ uri: atUri, cid: cid, value: z.unknown(), -}) +}); export const listRecordsResponseSchema = z.object({ records: z.array(recordInfoSchema), cursor: z.string().optional(), -}) +}); export const recordResponseSchema = z.object({ uri: atUri, cid: cid, value: z.unknown(), -}) +}); export const createRecordResponseSchema = z.object({ uri: atUri, cid: cid, -}) +}); export const serverStatsSchema = z.object({ userCount: z.number(), repoCount: z.number(), recordCount: z.number(), blobStorageBytes: z.number(), -}) +}); export const serverConfigSchema = z.object({ serverName: z.string(), @@ -247,20 +246,20 @@ export const serverConfigSchema = z.object({ secondaryColor: z.string().nullable(), secondaryColorDark: z.string().nullable(), logoCid: cid.nullable(), -}) +}); export const passwordStatusSchema = z.object({ hasPassword: z.boolean(), -}) +}); export const successResponseSchema = z.object({ success: z.boolean(), -}) +}); export const legacyLoginPreferenceSchema = z.object({ allowLegacyLogin: z.boolean(), hasMfa: z.boolean(), -}) +}); export const accountInfoSchema = z.object({ did: did, @@ -270,12 +269,12 @@ export const accountInfoSchema = z.object({ emailConfirmedAt: isoDate.optional(), invitesDisabled: z.boolean().optional(), deactivatedAt: isoDate.optional(), -}) +}); export const searchAccountsResponseSchema = z.object({ cursor: z.string().optional(), accounts: z.array(accountInfoSchema), -}) +}); export const backupInfoSchema = z.object({ id: z.string(), @@ -284,49 +283,75 @@ export const backupInfoSchema = z.object({ blockCount: z.number(), sizeBytes: z.number(), createdAt: isoDate, -}) +}); export const listBackupsResponseSchema = z.object({ backups: z.array(backupInfoSchema), backupEnabled: z.boolean(), -}) +}); export const createBackupResponseSchema = z.object({ id: z.string(), repoRev: z.string(), sizeBytes: z.number(), blockCount: z.number(), -}) +}); -export type ValidatedSession = z.infer -export type ValidatedServerDescription = z.infer -export type ValidatedAppPassword = z.infer -export type ValidatedCreatedAppPassword = z.infer -export type ValidatedInviteCodeInfo = z.infer -export type ValidatedSessionInfo = z.infer -export type ValidatedListSessionsResponse = z.infer -export type ValidatedTotpStatus = z.infer -export type ValidatedTotpSecret = z.infer -export type ValidatedEnableTotpResponse = z.infer -export type ValidatedPasskeyInfo = z.infer -export type ValidatedListPasskeysResponse = z.infer -export type ValidatedTrustedDevice = z.infer -export type ValidatedListTrustedDevicesResponse = z.infer -export type ValidatedReauthStatus = z.infer -export type ValidatedReauthResponse = z.infer -export type ValidatedNotificationPrefs = z.infer -export type ValidatedDidDocument = z.infer -export type ValidatedRepoDescription = z.infer -export type ValidatedListRecordsResponse = z.infer -export type ValidatedRecordResponse = z.infer -export type ValidatedCreateRecordResponse = z.infer -export type ValidatedServerStats = z.infer -export type ValidatedServerConfig = z.infer -export type ValidatedPasswordStatus = z.infer -export type ValidatedSuccessResponse = z.infer -export type ValidatedLegacyLoginPreference = z.infer -export type ValidatedAccountInfo = z.infer -export type ValidatedSearchAccountsResponse = z.infer -export type ValidatedBackupInfo = z.infer -export type ValidatedListBackupsResponse = z.infer -export type ValidatedCreateBackupResponse = z.infer +export type ValidatedSession = z.infer; +export type ValidatedServerDescription = z.infer< + typeof serverDescriptionSchema +>; +export type ValidatedAppPassword = z.infer; +export type ValidatedCreatedAppPassword = z.infer< + typeof createdAppPasswordSchema +>; +export type ValidatedInviteCodeInfo = z.infer; +export type ValidatedSessionInfo = z.infer; +export type ValidatedListSessionsResponse = z.infer< + typeof listSessionsResponseSchema +>; +export type ValidatedTotpStatus = z.infer; +export type ValidatedTotpSecret = z.infer; +export type ValidatedEnableTotpResponse = z.infer< + typeof enableTotpResponseSchema +>; +export type ValidatedPasskeyInfo = z.infer; +export type ValidatedListPasskeysResponse = z.infer< + typeof listPasskeysResponseSchema +>; +export type ValidatedTrustedDevice = z.infer; +export type ValidatedListTrustedDevicesResponse = z.infer< + typeof listTrustedDevicesResponseSchema +>; +export type ValidatedReauthStatus = z.infer; +export type ValidatedReauthResponse = z.infer; +export type ValidatedNotificationPrefs = z.infer< + typeof notificationPrefsSchema +>; +export type ValidatedDidDocument = z.infer; +export type ValidatedRepoDescription = z.infer; +export type ValidatedListRecordsResponse = z.infer< + typeof listRecordsResponseSchema +>; +export type ValidatedRecordResponse = z.infer; +export type ValidatedCreateRecordResponse = z.infer< + typeof createRecordResponseSchema +>; +export type ValidatedServerStats = z.infer; +export type ValidatedServerConfig = z.infer; +export type ValidatedPasswordStatus = z.infer; +export type ValidatedSuccessResponse = z.infer; +export type ValidatedLegacyLoginPreference = z.infer< + typeof legacyLoginPreferenceSchema +>; +export type ValidatedAccountInfo = z.infer; +export type ValidatedSearchAccountsResponse = z.infer< + typeof searchAccountsResponseSchema +>; +export type ValidatedBackupInfo = z.infer; +export type ValidatedListBackupsResponse = z.infer< + typeof listBackupsResponseSchema +>; +export type ValidatedCreateBackupResponse = z.infer< + typeof createBackupResponseSchema +>; diff --git a/frontend/src/lib/utils/array.ts b/frontend/src/lib/utils/array.ts index 231c841..3ad9228 100644 --- a/frontend/src/lib/utils/array.ts +++ b/frontend/src/lib/utils/array.ts @@ -1,190 +1,205 @@ -import type { Option } from './option' +import type { Option } from "./option.ts"; export function first(arr: readonly T[]): Option { - return arr[0] ?? null + return arr[0] ?? null; } export function last(arr: readonly T[]): Option { - return arr[arr.length - 1] ?? null + return arr[arr.length - 1] ?? null; } export function at(arr: readonly T[], index: number): Option { - if (index < 0) index = arr.length + index - return arr[index] ?? null + if (index < 0) index = arr.length + index; + return arr[index] ?? null; } -export function find(arr: readonly T[], predicate: (t: T) => boolean): Option { - return arr.find(predicate) ?? null +export function find( + arr: readonly T[], + predicate: (t: T) => boolean, +): Option { + return arr.find(predicate) ?? null; } -export function findMap(arr: readonly T[], fn: (t: T) => Option): Option { +export function findMap( + arr: readonly T[], + fn: (t: T) => Option, +): Option { for (const item of arr) { - const result = fn(item) - if (result != null) return result + const result = fn(item); + if (result != null) return result; } - return null + return null; } -export function findIndex(arr: readonly T[], predicate: (t: T) => boolean): Option { - const index = arr.findIndex(predicate) - return index >= 0 ? index : null +export function findIndex( + arr: readonly T[], + predicate: (t: T) => boolean, +): Option { + const index = arr.findIndex(predicate); + return index >= 0 ? index : null; } export function partition( arr: readonly T[], - predicate: (t: T) => boolean + predicate: (t: T) => boolean, ): [T[], T[]] { - const pass: T[] = [] - const fail: T[] = [] + const pass: T[] = []; + const fail: T[] = []; for (const item of arr) { if (predicate(item)) { - pass.push(item) + pass.push(item); } else { - fail.push(item) + fail.push(item); } } - return [pass, fail] + return [pass, fail]; } export function groupBy( arr: readonly T[], - keyFn: (t: T) => K + keyFn: (t: T) => K, ): Record { - const result = {} as Record + const result = {} as Record; for (const item of arr) { - const key = keyFn(item) + const key = keyFn(item); if (!result[key]) { - result[key] = [] + result[key] = []; } - result[key].push(item) + result[key].push(item); } - return result + return result; } export function unique(arr: readonly T[]): T[] { - return [...new Set(arr)] + return [...new Set(arr)]; } export function uniqueBy(arr: readonly T[], keyFn: (t: T) => K): T[] { - const seen = new Set() - const result: T[] = [] + const seen = new Set(); + const result: T[] = []; for (const item of arr) { - const key = keyFn(item) + const key = keyFn(item); if (!seen.has(key)) { - seen.add(key) - result.push(item) + seen.add(key); + result.push(item); } } - return result + return result; } -export function sortBy(arr: readonly T[], keyFn: (t: T) => number | string): T[] { +export function sortBy( + 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 - }) + const ka = keyFn(a); + const kb = keyFn(b); + if (ka < kb) return -1; + if (ka > kb) return 1; + return 0; + }); } -export function sortByDesc(arr: readonly T[], keyFn: (t: T) => number | string): T[] { +export function sortByDesc( + 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 - }) + const ka = keyFn(a); + const kb = keyFn(b); + if (ka > kb) return -1; + if (ka < kb) return 1; + return 0; + }); } export function chunk(arr: readonly T[], size: number): T[][] { - const result: T[][] = [] + const result: T[][] = []; for (let i = 0; i < arr.length; i += size) { - result.push(arr.slice(i, i + size)) + result.push(arr.slice(i, i + size)); } - return result + return result; } export function zip(a: readonly T[], b: readonly U[]): [T, U][] { - const length = Math.min(a.length, b.length) - const result: [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]]) + result.push([a[i], b[i]]); } - return result + return result; } export function zipWith( a: readonly T[], b: readonly U[], - fn: (t: T, u: U) => R + fn: (t: T, u: U) => R, ): R[] { - const length = Math.min(a.length, b.length) - const result: 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])) + result.push(fn(a[i], b[i])); } - return result + return result; } export function intersperse(arr: readonly T[], separator: T): T[] { - if (arr.length <= 1) return [...arr] - const result: T[] = [arr[0]] + if (arr.length <= 1) return [...arr]; + const result: T[] = [arr[0]]; for (let i = 1; i < arr.length; i++) { - result.push(separator, arr[i]) + result.push(separator, arr[i]); } - return result + return result; } export function range(start: number, end: number): number[] { - const result: number[] = [] + const result: number[] = []; for (let i = start; i < end; i++) { - result.push(i) + result.push(i); } - return result + return result; } export function isEmpty(arr: readonly T[]): boolean { - return arr.length === 0 + return arr.length === 0; } export function isNonEmpty(arr: readonly T[]): arr is [T, ...T[]] { - return arr.length > 0 + return arr.length > 0; } export function sum(arr: readonly number[]): number { - return arr.reduce((acc, n) => acc + n, 0) + return arr.reduce((acc, n) => acc + n, 0); } export function sumBy(arr: readonly T[], fn: (t: T) => number): number { - return arr.reduce((acc, t) => acc + fn(t), 0) + return arr.reduce((acc, t) => acc + fn(t), 0); } export function maxBy(arr: readonly T[], fn: (t: T) => number): Option { - if (arr.length === 0) return null - let max = arr[0] - let maxValue = fn(max) + 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]) + const value = fn(arr[i]); if (value > maxValue) { - max = arr[i] - maxValue = value + max = arr[i]; + maxValue = value; } } - return max + return max; } export function minBy(arr: readonly T[], fn: (t: T) => number): Option { - if (arr.length === 0) return null - let min = arr[0] - let minValue = fn(min) + 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]) + const value = fn(arr[i]); if (value < minValue) { - min = arr[i] - minValue = value + min = arr[i]; + minValue = value; } } - return min + return min; } diff --git a/frontend/src/lib/utils/async.ts b/frontend/src/lib/utils/async.ts index da9c383..90e71da 100644 --- a/frontend/src/lib/utils/async.ts +++ b/frontend/src/lib/utils/async.ts @@ -1,246 +1,245 @@ -import { ok, err, type Result } from '../types/result' +import { err, type Result } from "../types/result.ts"; export function debounce) => void>( fn: T, - ms: number + ms: number, ): T & { cancel: () => void } { - let timeoutId: ReturnType | null = null + let timeoutId: ReturnType | null = null; const debounced = ((...args: Parameters) => { - if (timeoutId) clearTimeout(timeoutId) + if (timeoutId) clearTimeout(timeoutId); timeoutId = setTimeout(() => { - fn(...args) - timeoutId = null - }, ms) - }) as T & { cancel: () => void } + fn(...args); + timeoutId = null; + }, ms); + }) as T & { cancel: () => void }; debounced.cancel = () => { if (timeoutId) { - clearTimeout(timeoutId) - timeoutId = null + clearTimeout(timeoutId); + timeoutId = null; } - } + }; - return debounced + return debounced; } export function throttle) => void>( fn: T, - ms: number + ms: number, ): T { - let lastCall = 0 - let timeoutId: ReturnType | null = null + let lastCall = 0; + let timeoutId: ReturnType | null = null; return ((...args: Parameters) => { - const now = Date.now() - const remaining = ms - (now - lastCall) + const now = Date.now(); + const remaining = ms - (now - lastCall); if (remaining <= 0) { if (timeoutId) { - clearTimeout(timeoutId) - timeoutId = null + clearTimeout(timeoutId); + timeoutId = null; } - lastCall = now - fn(...args) + lastCall = now; + fn(...args); } else if (!timeoutId) { timeoutId = setTimeout(() => { - lastCall = Date.now() - timeoutId = null - fn(...args) - }, remaining) + lastCall = Date.now(); + timeoutId = null; + fn(...args); + }, remaining); } - }) as T + }) as T; } export function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) + return new Promise((resolve) => setTimeout(resolve, ms)); } export async function retry( fn: () => Promise, options: { - attempts?: number - delay?: number - backoff?: number - shouldRetry?: (error: unknown, attempt: number) => boolean - } = {} + attempts?: number; + delay?: number; + backoff?: number; + shouldRetry?: (error: unknown, attempt: number) => boolean; + } = {}, ): Promise { const { attempts = 3, delay = 1000, backoff = 2, shouldRetry = () => true, - } = options + } = options; - let lastError: unknown - let currentDelay = delay + let lastError: unknown; + let currentDelay = delay; for (let attempt = 1; attempt <= attempts; attempt++) { try { - return await fn() + return await fn(); } catch (error) { - lastError = error + lastError = error; if (attempt === attempts || !shouldRetry(error, attempt)) { - throw error + throw error; } - await sleep(currentDelay) - currentDelay *= backoff + await sleep(currentDelay); + currentDelay *= backoff; } } - throw lastError + throw lastError; } export async function retryResult( fn: () => Promise>, options: { - attempts?: number - delay?: number - backoff?: number - shouldRetry?: (error: E, attempt: number) => boolean - } = {} + attempts?: number; + delay?: number; + backoff?: number; + shouldRetry?: (error: E, attempt: number) => boolean; + } = {}, ): Promise> { const { attempts = 3, delay = 1000, backoff = 2, shouldRetry = () => true, - } = options + } = options; - let lastResult: Result | null = null - let currentDelay = delay + let lastResult: Result | null = null; + let currentDelay = delay; for (let attempt = 1; attempt <= attempts; attempt++) { - const result = await fn() - lastResult = result + const result = await fn(); + lastResult = result; if (result.ok) { - return result + return result; } if (attempt === attempts || !shouldRetry(result.error, attempt)) { - return result + return result; } - await sleep(currentDelay) - currentDelay *= backoff + await sleep(currentDelay); + currentDelay *= backoff; } - return lastResult! + return lastResult!; } export function timeout(promise: Promise, ms: number): Promise { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { - reject(new Error(`Timeout after ${ms}ms`)) - }, ms) + reject(new Error(`Timeout after ${ms}ms`)); + }, ms); promise .then((value) => { - clearTimeout(timeoutId) - resolve(value) + clearTimeout(timeoutId); + resolve(value); }) .catch((error) => { - clearTimeout(timeoutId) - reject(error) - }) - }) + clearTimeout(timeoutId); + reject(error); + }); + }); } export async function timeoutResult( promise: Promise>, - ms: number + ms: number, ): Promise> { try { - return await timeout(promise, ms) + return await timeout(promise, ms); } catch (e) { - return err(e instanceof Error ? e : new Error(String(e))) + return err(e instanceof Error ? e : new Error(String(e))); } } export async function parallel( tasks: (() => Promise)[], - concurrency: number + concurrency: number, ): Promise { - const results: T[] = [] - const executing: Promise[] = [] + const results: T[] = []; + const executing: Promise[] = []; for (const task of tasks) { const p = task().then((result) => { - results.push(result) - }) + results.push(result); + }); - executing.push(p) + executing.push(p); if (executing.length >= concurrency) { - await Promise.race(executing) + await Promise.race(executing); executing.splice( executing.findIndex((e) => e === p), - 1 - ) + 1, + ); } } - await Promise.all(executing) - return results + await Promise.all(executing); + return results; } export async function mapParallel( items: T[], fn: (item: T, index: number) => Promise, - concurrency: number + concurrency: number, ): Promise { - const results: U[] = new Array(items.length) - const executing: Promise[] = [] + const results: U[] = new Array(items.length); + const executing: Promise[] = []; for (let i = 0; i < items.length; i++) { - const index = i + const index = i; const p = fn(items[index], index).then((result) => { - results[index] = result - }) + results[index] = result; + }); - executing.push(p) + executing.push(p); if (executing.length >= concurrency) { - await Promise.race(executing) + await Promise.race(executing); const doneIndex = executing.findIndex( - (e) => - (e as Promise & { _done?: boolean })._done !== false - ) + (e) => (e as Promise & { _done?: boolean })._done !== false, + ); if (doneIndex >= 0) { - executing.splice(doneIndex, 1) + executing.splice(doneIndex, 1); } } } - await Promise.all(executing) - return results + await Promise.all(executing); + return results; } export function createAbortable( - fn: (signal: AbortSignal) => Promise + fn: (signal: AbortSignal) => Promise, ): { promise: Promise; abort: () => void } { - const controller = new AbortController() + const controller = new AbortController(); return { promise: fn(controller.signal), abort: () => controller.abort(), - } + }; } export interface Deferred { - promise: Promise - resolve: (value: T) => void - reject: (error: unknown) => void + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; } export function deferred(): Deferred { - let resolve!: (value: T) => void - let reject!: (error: unknown) => void + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; const promise = new Promise((res, rej) => { - resolve = res - reject = rej - }) + resolve = res; + reject = rej; + }); - return { promise, resolve, reject } + return { promise, resolve, reject }; } diff --git a/frontend/src/lib/utils/index.ts b/frontend/src/lib/utils/index.ts index 3f653fd..e2c95c8 100644 --- a/frontend/src/lib/utils/index.ts +++ b/frontend/src/lib/utils/index.ts @@ -1,3 +1,27 @@ -export * from './option' -export * from './array' -export * from './async' +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"; diff --git a/frontend/src/lib/utils/option.ts b/frontend/src/lib/utils/option.ts index 7293d47..252fa8e 100644 --- a/frontend/src/lib/utils/option.ts +++ b/frontend/src/lib/utils/option.ts @@ -1,79 +1,85 @@ -export type Option = T | null | undefined +export type Option = T | null | undefined; export function isSome(opt: Option): opt is T { - return opt != null + return opt != null; } export function isNone(opt: Option): opt is null | undefined { - return opt == null + return opt == null; } export function map(opt: Option, fn: (t: T) => U): Option { - return isSome(opt) ? fn(opt) : null + return isSome(opt) ? fn(opt) : null; } -export function flatMap(opt: Option, fn: (t: T) => Option): Option { - return isSome(opt) ? fn(opt) : null +export function flatMap( + opt: Option, + fn: (t: T) => Option, +): Option { + return isSome(opt) ? fn(opt) : null; } -export function filter(opt: Option, predicate: (t: T) => boolean): Option { - return isSome(opt) && predicate(opt) ? opt : null +export function filter( + opt: Option, + predicate: (t: T) => boolean, +): Option { + return isSome(opt) && predicate(opt) ? opt : null; } export function getOrElse(opt: Option, defaultValue: T): T { - return isSome(opt) ? opt : defaultValue + return isSome(opt) ? opt : defaultValue; } export function getOrElseLazy(opt: Option, fn: () => T): T { - return isSome(opt) ? opt : fn() + return isSome(opt) ? opt : fn(); } export function getOrThrow(opt: Option, 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') + if (isSome(opt)) return opt; + if (error instanceof Error) throw error; + throw new Error(error ?? "Expected value but got null/undefined"); } export function tap(opt: Option, fn: (t: T) => void): Option { - if (isSome(opt)) fn(opt) - return opt + if (isSome(opt)) fn(opt); + return opt; } export function match( opt: Option, - handlers: { some: (t: T) => U; none: () => U } + handlers: { some: (t: T) => U; none: () => U }, ): U { - return isSome(opt) ? handlers.some(opt) : handlers.none() + return isSome(opt) ? handlers.some(opt) : handlers.none(); } export function toArray(opt: Option): T[] { - return isSome(opt) ? [opt] : [] + return isSome(opt) ? [opt] : []; } export function fromArray(arr: T[]): Option { - return arr.length > 0 ? arr[0] : null + return arr.length > 0 ? arr[0] : null; } export function zip(a: Option, b: Option): Option<[T, U]> { - return isSome(a) && isSome(b) ? [a, b] : null + return isSome(a) && isSome(b) ? [a, b] : null; } export function zipWith( a: Option, b: Option, - fn: (t: T, u: U) => R + fn: (t: T, u: U) => R, ): Option { - return isSome(a) && isSome(b) ? fn(a, b) : null + return isSome(a) && isSome(b) ? fn(a, b) : null; } export function or(a: Option, b: Option): Option { - return isSome(a) ? a : b + return isSome(a) ? a : b; } export function orLazy(a: Option, fn: () => Option): Option { - return isSome(a) ? a : fn() + return isSome(a) ? a : fn(); } export function and(a: Option, b: Option): Option { - return isSome(a) ? b : null + return isSome(a) ? b : null; } diff --git a/frontend/src/lib/validation.ts b/frontend/src/lib/validation.ts index 15ee4ba..ed816cb 100644 --- a/frontend/src/lib/validation.ts +++ b/frontend/src/lib/validation.ts @@ -1,138 +1,140 @@ -import { ok, err, type Result } from './types/result' +import { err, ok, type Result } from "./types/result.ts"; import { + type AtUri, + type Cid, type Did, type DidPlc, type DidWeb, - type Handle, type EmailAddress, - type AtUri, - type Cid, - type Nsid, - type ISODateString, + type Handle, + isAtUri, + isCid, isDid, isDidPlc, isDidWeb, - isHandle, isEmail, - isAtUri, - isCid, - isNsid, + isHandle, isISODate, -} from './types/branded' + 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 + public readonly value?: unknown, ) { - super(message) - this.name = 'ValidationError' + super(message); + this.name = "ValidationError"; } } export function parseDid(s: string): Result { if (isDid(s)) { - return ok(s) + return ok(s); } - return err(new ValidationError(`Invalid DID: ${s}`, 'did', s)) + return err(new ValidationError(`Invalid DID: ${s}`, "did", s)); } export function parseDidPlc(s: string): Result { if (isDidPlc(s)) { - return ok(s) + return ok(s); } - return err(new ValidationError(`Invalid DID:PLC: ${s}`, 'did', s)) + return err(new ValidationError(`Invalid DID:PLC: ${s}`, "did", s)); } export function parseDidWeb(s: string): Result { if (isDidWeb(s)) { - return ok(s) + return ok(s); } - return err(new ValidationError(`Invalid DID:WEB: ${s}`, 'did', s)) + return err(new ValidationError(`Invalid DID:WEB: ${s}`, "did", s)); } export function parseHandle(s: string): Result { - const trimmed = s.trim().toLowerCase() + const trimmed = s.trim().toLowerCase(); if (isHandle(trimmed)) { - return ok(trimmed) + return ok(trimmed); } - return err(new ValidationError(`Invalid handle: ${s}`, 'handle', s)) + return err(new ValidationError(`Invalid handle: ${s}`, "handle", s)); } export function parseEmail(s: string): Result { - const trimmed = s.trim().toLowerCase() + const trimmed = s.trim().toLowerCase(); if (isEmail(trimmed)) { - return ok(trimmed) + return ok(trimmed); } - return err(new ValidationError(`Invalid email: ${s}`, 'email', s)) + return err(new ValidationError(`Invalid email: ${s}`, "email", s)); } export function parseAtUri(s: string): Result { if (isAtUri(s)) { - return ok(s) + return ok(s); } - return err(new ValidationError(`Invalid AT-URI: ${s}`, 'uri', s)) + return err(new ValidationError(`Invalid AT-URI: ${s}`, "uri", s)); } export function parseCid(s: string): Result { if (isCid(s)) { - return ok(s) + return ok(s); } - return err(new ValidationError(`Invalid CID: ${s}`, 'cid', s)) + return err(new ValidationError(`Invalid CID: ${s}`, "cid", s)); } export function parseNsid(s: string): Result { if (isNsid(s)) { - return ok(s) + return ok(s); } - return err(new ValidationError(`Invalid NSID: ${s}`, 'nsid', s)) + return err(new ValidationError(`Invalid NSID: ${s}`, "nsid", s)); } -export function parseISODate(s: string): Result { +export function parseISODate( + s: string, +): Result { if (isISODate(s)) { - return ok(s) + return ok(s); } - return err(new ValidationError(`Invalid ISO date: ${s}`, 'date', s)) + return err(new ValidationError(`Invalid ISO date: ${s}`, "date", s)); } export interface PasswordValidationResult { - valid: boolean - errors: string[] - strength: 'weak' | 'fair' | 'good' | 'strong' + valid: boolean; + errors: string[]; + strength: "weak" | "fair" | "good" | "strong"; } export function validatePassword(password: string): PasswordValidationResult { - const errors: string[] = [] + const errors: string[] = []; if (password.length < 8) { - errors.push('Password must be at least 8 characters') + errors.push("Password must be at least 8 characters"); } if (password.length > 256) { - errors.push('Password must be at most 256 characters') + errors.push("Password must be at most 256 characters"); } if (!/[a-z]/.test(password)) { - errors.push('Password must contain a lowercase letter') + errors.push("Password must contain a lowercase letter"); } if (!/[A-Z]/.test(password)) { - errors.push('Password must contain an uppercase letter') + errors.push("Password must contain an uppercase letter"); } if (!/\d/.test(password)) { - errors.push('Password must contain a number') + errors.push("Password must contain a number"); } - let strength: PasswordValidationResult['strength'] = 'weak' + let strength: PasswordValidationResult["strength"] = "weak"; if (errors.length === 0) { - const hasSpecial = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password) - const isLong = password.length >= 12 - const isVeryLong = password.length >= 16 + const hasSpecial = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password); + const isLong = password.length >= 12; + const isVeryLong = password.length >= 16; if (isVeryLong && hasSpecial) { - strength = 'strong' + strength = "strong"; } else if (isLong || hasSpecial) { - strength = 'good' + strength = "good"; } else { - strength = 'fair' + strength = "fair"; } } @@ -140,121 +142,145 @@ export function validatePassword(password: string): PasswordValidationResult { valid: errors.length === 0, errors, strength, - } + }; } -export function validateHandle(handle: string): Result { - const trimmed = handle.trim().toLowerCase() +export function validateHandle( + handle: string, +): Result { + const trimmed = handle.trim().toLowerCase(); if (trimmed.length < 3) { - return err(new ValidationError('Handle must be at least 3 characters', 'handle', handle)) + 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)) + 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 err(new ValidationError("Invalid handle format", "handle", handle)); } - return ok(trimmed) + return ok(trimmed); } -export function validateInviteCode(code: string): Result { - const trimmed = code.trim() +export function validateInviteCode( + code: string, +): Result { + const trimmed = code.trim(); if (trimmed.length === 0) { - return err(new ValidationError('Invite code is required', 'inviteCode', code)) + return err( + new ValidationError("Invite code is required", "inviteCode", code), + ); } - const pattern = /^[a-zA-Z0-9-]+$/ + const pattern = /^[a-zA-Z0-9-]+$/; if (!pattern.test(trimmed)) { - return err(new ValidationError('Invalid invite code format', 'inviteCode', code)) + return err( + new ValidationError("Invalid invite code format", "inviteCode", code), + ); } - return ok(trimmed) + return ok(trimmed); } -export function validateTotpCode(code: string): Result { - const trimmed = code.trim().replace(/\s/g, '') +export function validateTotpCode( + code: string, +): Result { + 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 err(new ValidationError("TOTP code must be 6 digits", "code", code)); } - return ok(trimmed) + return ok(trimmed); } -export function validateBackupCode(code: string): Result { - const trimmed = code.trim().replace(/\s/g, '').toLowerCase() +export function validateBackupCode( + code: string, +): Result { + 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 err(new ValidationError("Invalid backup code format", "code", code)); } - return ok(trimmed) + return ok(trimmed); } export interface FormValidation { - validate: () => Result + validate: () => Result; field: ( key: K, - validator: (value: unknown) => Result - ) => FormValidation + validator: (value: unknown) => Result, + ) => FormValidation; optional: ( key: K, - validator: (value: unknown) => Result - ) => FormValidation + validator: (value: unknown) => Result, + ) => FormValidation; } export function createFormValidation>( - data: Record + data: Record, ): FormValidation { const validators: Array<{ - key: string - validator: (value: unknown) => Result - optional: boolean - }> = [] + key: string; + validator: (value: unknown) => Result; + optional: boolean; + }> = []; const builder: FormValidation = { field: (key, validator) => { - validators.push({ key: key as string, validator, optional: false }) - return builder + 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 + validators.push({ key: key as string, validator, optional: true }); + return builder; }, validate: () => { - const errors: ValidationError[] = [] - const result: Record = {} + const errors: ValidationError[] = []; + const result: Record = {}; for (const { key, validator, optional } of validators) { - const value = data[key] + const value = data[key]; - if (value == null || value === '') { + if (value == null || value === "") { if (!optional) { - errors.push(new ValidationError(`${key} is required`, key)) + errors.push(new ValidationError(`${key} is required`, key)); } - continue + continue; } - const validated = validator(value) + const validated = validator(value); if (validated.ok) { - result[key] = validated.value + result[key] = validated.value; } else { - errors.push(validated.error) + errors.push(validated.error); } } if (errors.length > 0) { - return err(errors) + return err(errors); } - return ok(result as T) + return ok(result as T); }, - } + }; - return builder + return builder; } diff --git a/frontend/src/lib/webauthn.ts b/frontend/src/lib/webauthn.ts index 165ad70..a4ff39b 100644 --- a/frontend/src/lib/webauthn.ts +++ b/frontend/src/lib/webauthn.ts @@ -1,98 +1,100 @@ export interface PublicKeyCredentialDescriptorJSON { - type: 'public-key' - id: string - transports?: AuthenticatorTransport[] + type: "public-key"; + id: string; + transports?: AuthenticatorTransport[]; } export interface PublicKeyCredentialUserEntityJSON { - id: string - name: string - displayName: string + id: string; + name: string; + displayName: string; } export interface PublicKeyCredentialRpEntityJSON { - name: string - id?: string + name: string; + id?: string; } export interface PublicKeyCredentialParametersJSON { - type: 'public-key' - alg: number + type: "public-key"; + alg: number; } export interface AuthenticatorSelectionCriteriaJSON { - authenticatorAttachment?: AuthenticatorAttachment - residentKey?: ResidentKeyRequirement - requireResidentKey?: boolean - userVerification?: UserVerificationRequirement + authenticatorAttachment?: AuthenticatorAttachment; + residentKey?: ResidentKeyRequirement; + requireResidentKey?: boolean; + userVerification?: UserVerificationRequirement; } export interface PublicKeyCredentialCreationOptionsJSON { - rp: PublicKeyCredentialRpEntityJSON - user: PublicKeyCredentialUserEntityJSON - challenge: string - pubKeyCredParams: PublicKeyCredentialParametersJSON[] - timeout?: number - excludeCredentials?: PublicKeyCredentialDescriptorJSON[] - authenticatorSelection?: AuthenticatorSelectionCriteriaJSON - attestation?: AttestationConveyancePreference + rp: PublicKeyCredentialRpEntityJSON; + user: PublicKeyCredentialUserEntityJSON; + challenge: string; + pubKeyCredParams: PublicKeyCredentialParametersJSON[]; + timeout?: number; + excludeCredentials?: PublicKeyCredentialDescriptorJSON[]; + authenticatorSelection?: AuthenticatorSelectionCriteriaJSON; + attestation?: AttestationConveyancePreference; } export interface PublicKeyCredentialRequestOptionsJSON { - challenge: string - timeout?: number - rpId?: string - allowCredentials?: PublicKeyCredentialDescriptorJSON[] - userVerification?: UserVerificationRequirement + challenge: string; + timeout?: number; + rpId?: string; + allowCredentials?: PublicKeyCredentialDescriptorJSON[]; + userVerification?: UserVerificationRequirement; } export interface WebAuthnCreationOptionsResponse { - publicKey: PublicKeyCredentialCreationOptionsJSON + publicKey: PublicKeyCredentialCreationOptionsJSON; } export interface WebAuthnRequestOptionsResponse { - publicKey: PublicKeyCredentialRequestOptionsJSON + publicKey: PublicKeyCredentialRequestOptionsJSON; } export interface CredentialAssertionJSON { - id: string - type: string - rawId: string + id: string; + type: string; + rawId: string; response: { - clientDataJSON: string - authenticatorData: string - signature: string - userHandle: string | null - } + clientDataJSON: string; + authenticatorData: string; + signature: string; + userHandle: string | null; + }; } export interface CredentialAttestationJSON { - id: string - type: string - rawId: string + id: string; + type: string; + rawId: string; response: { - clientDataJSON: string - attestationObject: string - } + clientDataJSON: string; + attestationObject: string; + }; } export function base64UrlToArrayBuffer(base64url: string): ArrayBuffer { - const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') - const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4) - const binary = atob(padded) - return Uint8Array.from(binary, (char) => char.charCodeAt(0)).buffer + const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4); + const binary = atob(padded); + return Uint8Array.from(binary, (char) => char.charCodeAt(0)).buffer; } export function arrayBufferToBase64Url(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer) - const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('') - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') + const bytes = new Uint8Array(buffer); + const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join( + "", + ); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); } export function prepareCreationOptions( - options: WebAuthnCreationOptionsResponse + options: WebAuthnCreationOptionsResponse, ): PublicKeyCredentialCreationOptions { - const pk = options.publicKey + const pk = options.publicKey; return { ...pk, challenge: base64UrlToArrayBuffer(pk.challenge), @@ -104,13 +106,13 @@ export function prepareCreationOptions( ...cred, id: base64UrlToArrayBuffer(cred.id), })), - } + }; } export function prepareRequestOptions( - options: WebAuthnRequestOptionsResponse + options: WebAuthnRequestOptionsResponse, ): PublicKeyCredentialRequestOptions { - const pk = options.publicKey + const pk = options.publicKey; return { ...pk, challenge: base64UrlToArrayBuffer(pk.challenge), @@ -118,13 +120,13 @@ export function prepareRequestOptions( ...cred, id: base64UrlToArrayBuffer(cred.id), })), - } + }; } export function serializeAttestationResponse( - credential: PublicKeyCredential + credential: PublicKeyCredential, ): CredentialAttestationJSON { - const response = credential.response as AuthenticatorAttestationResponse + const response = credential.response as AuthenticatorAttestationResponse; return { id: credential.id, type: credential.type, @@ -133,13 +135,13 @@ export function serializeAttestationResponse( clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON), attestationObject: arrayBufferToBase64Url(response.attestationObject), }, - } + }; } export function serializeAssertionResponse( - credential: PublicKeyCredential + credential: PublicKeyCredential, ): CredentialAssertionJSON { - const response = credential.response as AuthenticatorAssertionResponse + const response = credential.response as AuthenticatorAssertionResponse; return { id: credential.id, type: credential.type, @@ -152,5 +154,5 @@ export function serializeAssertionResponse( ? arrayBufferToBase64Url(response.userHandle) : null, }, - } + }; } diff --git a/frontend/src/routes/Admin.svelte b/frontend/src/routes/Admin.svelte index ed1aeac..9f84d42 100644 --- a/frontend/src/routes/Admin.svelte +++ b/frontend/src/routes/Admin.svelte @@ -5,6 +5,7 @@ import { api, ApiError } from '../lib/api' import { _ } from '../lib/i18n' import { formatDate, formatDateTime } from '../lib/date' + import { unsafeAsDid } from '../lib/types/branded' import type { Session } from '../lib/types/api' import { toast } from '../lib/toast.svelte' @@ -257,7 +258,7 @@ if (!session) return userDetailLoading = true try { - selectedUser = await api.getAccountInfo(session.accessJwt, did) + selectedUser = await api.getAccountInfo(session.accessJwt, unsafeAsDid(did)) } catch (e) { toast.error(e instanceof ApiError ? e.message : $_('admin.failedToLoadUserDetails')) } finally { @@ -272,11 +273,11 @@ userActionLoading = true try { if (selectedUser.invitesDisabled) { - await api.enableAccountInvites(session.accessJwt, selectedUser.did) + await api.enableAccountInvites(session.accessJwt, unsafeAsDid(selectedUser.did)) selectedUser = { ...selectedUser, invitesDisabled: false } toast.success($_('admin.invitesEnabled')) } else { - await api.disableAccountInvites(session.accessJwt, selectedUser.did) + await api.disableAccountInvites(session.accessJwt, unsafeAsDid(selectedUser.did)) selectedUser = { ...selectedUser, invitesDisabled: true } toast.success($_('admin.invitesDisabled')) } @@ -291,7 +292,7 @@ if (!confirm($_('admin.deleteConfirm', { values: { handle: selectedUser.handle } }))) return userActionLoading = true try { - await api.adminDeleteAccount(session.accessJwt, selectedUser.did) + await api.adminDeleteAccount(session.accessJwt, unsafeAsDid(selectedUser.did)) users = users.filter(u => u.did !== selectedUser!.did) selectedUser = null toast.success($_('admin.userDeleted')) @@ -639,7 +640,7 @@ {/if} -{:else if auth.loading} +{:else if authLoading}
{$_('admin.loading')}
{/if}