this repo has no description

Typechecks and linting

lewis 3c47a8f0 dcdb8e28

Changed files
+3107 -2322
frontend
src
tests
-1
Cargo.toml
··· 3 version = "0.1.0" 4 edition = "2024" 5 license = "AGPL-3.0-or-later" 6 - license-file = "LICENSE-AGPL-3.0-or-later" 7 [dependencies] 8 anyhow = "1.0.100" 9 async-trait = "0.1.89"
··· 3 version = "0.1.0" 4 edition = "2024" 5 license = "AGPL-3.0-or-later" 6 [dependencies] 7 anyhow = "1.0.100" 8 async-trait = "0.1.89"
+1
frontend/deno.json
··· 3 "dev": "deno run -A npm:vite", 4 "build": "deno run -A npm:vite build", 5 "preview": "deno run -A npm:vite preview", 6 "test": "deno run -A npm:vitest", 7 "test:run": "deno run -A npm:vitest run", 8 "test:watch": "deno run -A npm:vitest watch",
··· 3 "dev": "deno run -A npm:vite", 4 "build": "deno run -A npm:vite build", 5 "preview": "deno run -A npm:vite preview", 6 + "check": "deno run -A npm:svelte-check --tsconfig ./tsconfig.json", 7 "test": "deno run -A npm:vitest", 8 "test:run": "deno run -A npm:vitest run", 9 "test:watch": "deno run -A npm:vitest watch",
+31
frontend/deno.lock
··· 12 "npm:@testing-library/user-event@^14.6.1": "14.6.1_@testing-library+dom@10.4.1", 13 "npm:jsdom@^25.0.1": "25.0.1", 14 "npm:multiformats@^13.4.2": "13.4.2", 15 "npm:svelte-i18n@^4.0.1": "4.0.1_svelte@5.46.1__acorn@8.15.0", 16 "npm:svelte@^5.46.1": "5.46.1_acorn@8.15.0", 17 "npm:vite@*": "7.3.0_picomatch@4.0.3", 18 "npm:vite@^7.3.0": "7.3.0_picomatch@4.0.3", 19 "npm:vitest@*": "4.0.16_jsdom@25.0.1_vite@7.3.0__picomatch@4.0.3", ··· 765 "chai@6.2.2": { 766 "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==" 767 }, 768 "cli-color@2.0.4": { 769 "integrity": "sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA==", 770 "dependencies": [ ··· 1271 "react-is@17.0.2": { 1272 "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" 1273 }, 1274 "redent@3.0.0": { 1275 "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", 1276 "dependencies": [ ··· 1349 "min-indent" 1350 ] 1351 }, 1352 "svelte-i18n@4.0.1_svelte@5.46.1__acorn@8.15.0": { 1353 "integrity": "sha512-jaykGlGT5PUaaq04JWbJREvivlCnALtT+m87Kbm0fxyYHynkQaxQMnIKHLm2WeIuBRoljzwgyvz0Z6/CMwfdmQ==", 1354 "dependencies": [ ··· 1443 }, 1444 "type@2.7.3": { 1445 "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==" 1446 }, 1447 "unicode-segmenter@0.14.5": { 1448 "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==" ··· 1565 "npm:@testing-library/user-event@^14.6.1", 1566 "npm:jsdom@^25.0.1", 1567 "npm:multiformats@^13.4.2", 1568 "npm:svelte-i18n@^4.0.1", 1569 "npm:svelte@^5.46.1", 1570 "npm:vite@^7.3.0", 1571 "npm:vitest@^4.0.16", 1572 "npm:zod@^4.3.5"
··· 12 "npm:@testing-library/user-event@^14.6.1": "14.6.1_@testing-library+dom@10.4.1", 13 "npm:jsdom@^25.0.1": "25.0.1", 14 "npm:multiformats@^13.4.2": "13.4.2", 15 + "npm:svelte-check@*": "4.3.5_svelte@5.46.1__acorn@8.15.0_typescript@5.9.3", 16 + "npm:svelte-check@^4.3.5": "4.3.5_svelte@5.46.1__acorn@8.15.0_typescript@5.9.3", 17 "npm:svelte-i18n@^4.0.1": "4.0.1_svelte@5.46.1__acorn@8.15.0", 18 "npm:svelte@^5.46.1": "5.46.1_acorn@8.15.0", 19 + "npm:typescript@^5.9.3": "5.9.3", 20 "npm:vite@*": "7.3.0_picomatch@4.0.3", 21 "npm:vite@^7.3.0": "7.3.0_picomatch@4.0.3", 22 "npm:vitest@*": "4.0.16_jsdom@25.0.1_vite@7.3.0__picomatch@4.0.3", ··· 768 "chai@6.2.2": { 769 "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==" 770 }, 771 + "chokidar@4.0.3": { 772 + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", 773 + "dependencies": [ 774 + "readdirp" 775 + ] 776 + }, 777 "cli-color@2.0.4": { 778 "integrity": "sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA==", 779 "dependencies": [ ··· 1280 "react-is@17.0.2": { 1281 "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" 1282 }, 1283 + "readdirp@4.1.2": { 1284 + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==" 1285 + }, 1286 "redent@3.0.0": { 1287 "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", 1288 "dependencies": [ ··· 1361 "min-indent" 1362 ] 1363 }, 1364 + "svelte-check@4.3.5_svelte@5.46.1__acorn@8.15.0_typescript@5.9.3": { 1365 + "integrity": "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==", 1366 + "dependencies": [ 1367 + "@jridgewell/trace-mapping", 1368 + "chokidar", 1369 + "fdir", 1370 + "picocolors", 1371 + "sade", 1372 + "svelte", 1373 + "typescript" 1374 + ], 1375 + "bin": true 1376 + }, 1377 "svelte-i18n@4.0.1_svelte@5.46.1__acorn@8.15.0": { 1378 "integrity": "sha512-jaykGlGT5PUaaq04JWbJREvivlCnALtT+m87Kbm0fxyYHynkQaxQMnIKHLm2WeIuBRoljzwgyvz0Z6/CMwfdmQ==", 1379 "dependencies": [ ··· 1468 }, 1469 "type@2.7.3": { 1470 "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==" 1471 + }, 1472 + "typescript@5.9.3": { 1473 + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 1474 + "bin": true 1475 }, 1476 "unicode-segmenter@0.14.5": { 1477 "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==" ··· 1594 "npm:@testing-library/user-event@^14.6.1", 1595 "npm:jsdom@^25.0.1", 1596 "npm:multiformats@^13.4.2", 1597 + "npm:svelte-check@^4.3.5", 1598 "npm:svelte-i18n@^4.0.1", 1599 "npm:svelte@^5.46.1", 1600 + "npm:typescript@^5.9.3", 1601 "npm:vite@^7.3.0", 1602 "npm:vitest@^4.0.16", 1603 "npm:zod@^4.3.5"
+2
frontend/package.json
··· 28 "@testing-library/user-event": "^14.6.1", 29 "jsdom": "^25.0.1", 30 "svelte": "^5.46.1", 31 "vite": "^7.3.0", 32 "vitest": "^4.0.16" 33 }
··· 28 "@testing-library/user-event": "^14.6.1", 29 "jsdom": "^25.0.1", 30 "svelte": "^5.46.1", 31 + "svelte-check": "^4.3.5", 32 + "typescript": "^5.9.3", 33 "vite": "^7.3.0", 34 "vitest": "^4.0.16" 35 }
+3 -3
frontend/src/App.svelte
··· 53 initServerConfig() 54 initAuth().then(({ oauthLoginCompleted }) => { 55 if (oauthLoginCompleted) { 56 - navigate('/dashboard', true) 57 } 58 oauthCallbackPending = false 59 }) ··· 64 const path = getCurrentPath() 65 if (path === '/') { 66 if (auth.kind === 'authenticated') { 67 - navigate('/dashboard', true) 68 } else { 69 - navigate('/login', true) 70 } 71 } 72 })
··· 53 initServerConfig() 54 initAuth().then(({ oauthLoginCompleted }) => { 55 if (oauthLoginCompleted) { 56 + navigate('/dashboard', { replace: true }) 57 } 58 oauthCallbackPending = false 59 }) ··· 64 const path = getCurrentPath() 65 if (path === '/') { 66 if (auth.kind === 'authenticated') { 67 + navigate('/dashboard', { replace: true }) 68 } else { 69 + navigate('/login', { replace: true }) 70 } 71 } 72 })
+1 -1
frontend/src/components/ReauthModal.svelte
··· 106 return 107 } 108 const { options } = await api.reauthPasskeyStart(token) 109 - const publicKeyOptions = prepareRequestOptions(options as WebAuthnRequestOptionsResponse) 110 const credential = await navigator.credentials.get({ 111 publicKey: publicKeyOptions 112 })
··· 106 return 107 } 108 const { options } = await api.reauthPasskeyStart(token) 109 + const publicKeyOptions = prepareRequestOptions(options as unknown as WebAuthnRequestOptionsResponse) 110 const credential = await navigator.credentials.get({ 111 publicKey: publicKeyOptions 112 })
+1
frontend/src/components/migration/InboundWizard.svelte
··· 81 }, 3000) 82 return () => clearInterval(interval) 83 } 84 }) 85 86 async function loadServerInfo() {
··· 81 }, 3000) 82 return () => clearInterval(interval) 83 } 84 + return undefined 85 }) 86 87 async function loadServerInfo() {
+1
frontend/src/components/migration/OfflineInboundWizard.svelte
··· 62 }, 3000) 63 return () => clearInterval(interval) 64 } 65 }) 66 67 async function loadServerInfo() {
··· 62 }, 3000) 63 return () => clearInterval(interval) 64 } 65 + return undefined 66 }) 67 68 async function loadServerInfo() {
+293 -176
frontend/src/lib/api-validated.ts
··· 1 - import { z } from 'zod' 2 - import { ok, err, type Result } from './types/result' 3 - import { ApiError } from './api' 4 - import type { AccessToken, RefreshToken, Did, Handle, Nsid, Rkey } from './types/branded' 5 import { 6 - sessionSchema, 7 - serverDescriptionSchema, 8 appPasswordSchema, 9 createdAppPasswordSchema, 10 - listSessionsResponseSchema, 11 - totpStatusSchema, 12 - totpSecretSchema, 13 enableTotpResponseSchema, 14 listPasskeysResponseSchema, 15 listTrustedDevicesResponseSchema, 16 reauthStatusSchema, 17 - notificationPrefsSchema, 18 - didDocumentSchema, 19 repoDescriptionSchema, 20 - listRecordsResponseSchema, 21 - recordResponseSchema, 22 - createRecordResponseSchema, 23 serverStatsSchema, 24 - serverConfigSchema, 25 - passwordStatusSchema, 26 successResponseSchema, 27 - legacyLoginPreferenceSchema, 28 - accountInfoSchema, 29 - searchAccountsResponseSchema, 30 - listBackupsResponseSchema, 31 - createBackupResponseSchema, 32 - type ValidatedSession, 33 - type ValidatedServerDescription, 34 - type ValidatedListSessionsResponse, 35 - type ValidatedTotpStatus, 36 - type ValidatedTotpSecret, 37 type ValidatedEnableTotpResponse, 38 type ValidatedListPasskeysResponse, 39 type ValidatedListTrustedDevicesResponse, 40 - type ValidatedReauthStatus, 41 type ValidatedNotificationPrefs, 42 - type ValidatedDidDocument, 43 type ValidatedRepoDescription, 44 - type ValidatedListRecordsResponse, 45 - type ValidatedRecordResponse, 46 - type ValidatedCreateRecordResponse, 47 - type ValidatedServerStats, 48 type ValidatedServerConfig, 49 - type ValidatedPasswordStatus, 50 type ValidatedSuccessResponse, 51 - type ValidatedLegacyLoginPreference, 52 - type ValidatedAccountInfo, 53 - type ValidatedSearchAccountsResponse, 54 - type ValidatedListBackupsResponse, 55 - type ValidatedCreateBackupResponse, 56 - type ValidatedCreatedAppPassword, 57 - type ValidatedAppPassword, 58 - } from './types/schemas' 59 60 - const API_BASE = '/xrpc' 61 62 interface XrpcOptions { 63 - method?: 'GET' | 'POST' 64 - params?: Record<string, string> 65 - body?: unknown 66 - token?: string 67 } 68 69 class ValidationError extends Error { 70 constructor( 71 public issues: z.ZodIssue[], 72 - message: string = 'API response validation failed' 73 ) { 74 - super(message) 75 - this.name = 'ValidationError' 76 } 77 } 78 79 async function xrpcValidated<T>( 80 method: string, 81 schema: z.ZodType<T>, 82 - options?: XrpcOptions 83 ): Promise<Result<T, ApiError | ValidationError>> { 84 - const { method: httpMethod = 'GET', params, body, token } = options ?? {} 85 - let url = `${API_BASE}/${method}` 86 if (params) { 87 - const searchParams = new URLSearchParams(params) 88 - url += `?${searchParams}` 89 } 90 - const headers: Record<string, string> = {} 91 if (token) { 92 - headers['Authorization'] = `Bearer ${token}` 93 } 94 if (body) { 95 - headers['Content-Type'] = 'application/json' 96 } 97 98 try { ··· 100 method: httpMethod, 101 headers, 102 body: body ? JSON.stringify(body) : undefined, 103 - }) 104 105 if (!res.ok) { 106 const errData = await res.json().catch(() => ({ 107 - error: 'Unknown', 108 message: res.statusText, 109 - })) 110 - return err(new ApiError(res.status, errData.error, errData.message)) 111 } 112 113 - const data = await res.json() 114 - const parsed = schema.safeParse(data) 115 116 if (!parsed.success) { 117 - return err(new ValidationError(parsed.error.issues)) 118 } 119 120 - return ok(parsed.data) 121 } catch (e) { 122 if (e instanceof ApiError || e instanceof ValidationError) { 123 - return err(e) 124 } 125 - return err(new ApiError(0, 'Unknown', e instanceof Error ? e.message : String(e))) 126 } 127 } 128 129 export const validatedApi = { 130 - getSession(token: AccessToken): Promise<Result<ValidatedSession, ApiError | ValidationError>> { 131 - return xrpcValidated('com.atproto.server.getSession', sessionSchema, { token }) 132 }, 133 134 - refreshSession(refreshJwt: RefreshToken): Promise<Result<ValidatedSession, ApiError | ValidationError>> { 135 - return xrpcValidated('com.atproto.server.refreshSession', sessionSchema, { 136 - method: 'POST', 137 token: refreshJwt, 138 - }) 139 }, 140 141 createSession( 142 identifier: string, 143 - password: string 144 ): Promise<Result<ValidatedSession, ApiError | ValidationError>> { 145 - return xrpcValidated('com.atproto.server.createSession', sessionSchema, { 146 - method: 'POST', 147 body: { identifier, password }, 148 - }) 149 }, 150 151 - describeServer(): Promise<Result<ValidatedServerDescription, ApiError | ValidationError>> { 152 - return xrpcValidated('com.atproto.server.describeServer', serverDescriptionSchema) 153 }, 154 155 listAppPasswords( 156 - token: AccessToken 157 - ): Promise<Result<{ passwords: ValidatedAppPassword[] }, ApiError | ValidationError>> { 158 return xrpcValidated( 159 - 'com.atproto.server.listAppPasswords', 160 z.object({ passwords: z.array(appPasswordSchema) }), 161 - { token } 162 - ) 163 }, 164 165 createAppPassword( 166 token: AccessToken, 167 name: string, 168 - scopes?: string 169 ): Promise<Result<ValidatedCreatedAppPassword, ApiError | ValidationError>> { 170 - return xrpcValidated('com.atproto.server.createAppPassword', createdAppPasswordSchema, { 171 - method: 'POST', 172 - token, 173 - body: { name, scopes }, 174 - }) 175 }, 176 177 - listSessions(token: AccessToken): Promise<Result<ValidatedListSessionsResponse, ApiError | ValidationError>> { 178 - return xrpcValidated('_account.listSessions', listSessionsResponseSchema, { token }) 179 }, 180 181 - getTotpStatus(token: AccessToken): Promise<Result<ValidatedTotpStatus, ApiError | ValidationError>> { 182 - return xrpcValidated('com.atproto.server.getTotpStatus', totpStatusSchema, { token }) 183 }, 184 185 - createTotpSecret(token: AccessToken): Promise<Result<ValidatedTotpSecret, ApiError | ValidationError>> { 186 - return xrpcValidated('com.atproto.server.createTotpSecret', totpSecretSchema, { 187 - method: 'POST', 188 - token, 189 - }) 190 }, 191 192 enableTotp( 193 token: AccessToken, 194 - code: string 195 ): Promise<Result<ValidatedEnableTotpResponse, ApiError | ValidationError>> { 196 - return xrpcValidated('com.atproto.server.enableTotp', enableTotpResponseSchema, { 197 - method: 'POST', 198 - token, 199 - body: { code }, 200 - }) 201 }, 202 203 - listPasskeys(token: AccessToken): Promise<Result<ValidatedListPasskeysResponse, ApiError | ValidationError>> { 204 - return xrpcValidated('com.atproto.server.listPasskeys', listPasskeysResponseSchema, { token }) 205 }, 206 207 listTrustedDevices( 208 - token: AccessToken 209 - ): Promise<Result<ValidatedListTrustedDevicesResponse, ApiError | ValidationError>> { 210 - return xrpcValidated('_account.listTrustedDevices', listTrustedDevicesResponseSchema, { token }) 211 }, 212 213 - getReauthStatus(token: AccessToken): Promise<Result<ValidatedReauthStatus, ApiError | ValidationError>> { 214 - return xrpcValidated('_account.getReauthStatus', reauthStatusSchema, { token }) 215 }, 216 217 getNotificationPrefs( 218 - token: AccessToken 219 ): Promise<Result<ValidatedNotificationPrefs, ApiError | ValidationError>> { 220 - return xrpcValidated('_account.getNotificationPrefs', notificationPrefsSchema, { token }) 221 }, 222 223 - getDidDocument(token: AccessToken): Promise<Result<ValidatedDidDocument, ApiError | ValidationError>> { 224 - return xrpcValidated('_account.getDidDocument', didDocumentSchema, { token }) 225 }, 226 227 describeRepo( 228 token: AccessToken, 229 - repo: Did 230 ): Promise<Result<ValidatedRepoDescription, ApiError | ValidationError>> { 231 - return xrpcValidated('com.atproto.repo.describeRepo', repoDescriptionSchema, { 232 - token, 233 - params: { repo }, 234 - }) 235 }, 236 237 listRecords( 238 token: AccessToken, 239 repo: Did, 240 collection: Nsid, 241 - options?: { limit?: number; cursor?: string; reverse?: boolean } 242 ): Promise<Result<ValidatedListRecordsResponse, ApiError | ValidationError>> { 243 - const params: Record<string, string> = { repo, collection } 244 - if (options?.limit) params.limit = String(options.limit) 245 - if (options?.cursor) params.cursor = options.cursor 246 - if (options?.reverse) params.reverse = 'true' 247 - return xrpcValidated('com.atproto.repo.listRecords', listRecordsResponseSchema, { 248 - token, 249 - params, 250 - }) 251 }, 252 253 getRecord( 254 token: AccessToken, 255 repo: Did, 256 collection: Nsid, 257 - rkey: Rkey 258 ): Promise<Result<ValidatedRecordResponse, ApiError | ValidationError>> { 259 - return xrpcValidated('com.atproto.repo.getRecord', recordResponseSchema, { 260 token, 261 params: { repo, collection, rkey }, 262 - }) 263 }, 264 265 createRecord( ··· 267 repo: Did, 268 collection: Nsid, 269 record: unknown, 270 - rkey?: Rkey 271 - ): Promise<Result<ValidatedCreateRecordResponse, ApiError | ValidationError>> { 272 - return xrpcValidated('com.atproto.repo.createRecord', createRecordResponseSchema, { 273 - method: 'POST', 274 - token, 275 - body: { repo, collection, record, rkey }, 276 - }) 277 }, 278 279 - getServerStats(token: AccessToken): Promise<Result<ValidatedServerStats, ApiError | ValidationError>> { 280 - return xrpcValidated('_admin.getServerStats', serverStatsSchema, { token }) 281 }, 282 283 - getServerConfig(): Promise<Result<ValidatedServerConfig, ApiError | ValidationError>> { 284 - return xrpcValidated('_server.getConfig', serverConfigSchema) 285 }, 286 287 - getPasswordStatus(token: AccessToken): Promise<Result<ValidatedPasswordStatus, ApiError | ValidationError>> { 288 - return xrpcValidated('_account.getPasswordStatus', passwordStatusSchema, { token }) 289 }, 290 291 changePassword( 292 token: AccessToken, 293 currentPassword: string, 294 - newPassword: string 295 ): Promise<Result<ValidatedSuccessResponse, ApiError | ValidationError>> { 296 - return xrpcValidated('_account.changePassword', successResponseSchema, { 297 - method: 'POST', 298 token, 299 body: { currentPassword, newPassword }, 300 - }) 301 }, 302 303 getLegacyLoginPreference( 304 - token: AccessToken 305 - ): Promise<Result<ValidatedLegacyLoginPreference, ApiError | ValidationError>> { 306 - return xrpcValidated('_account.getLegacyLoginPreference', legacyLoginPreferenceSchema, { token }) 307 }, 308 309 getAccountInfo( 310 token: AccessToken, 311 - did: Did 312 ): Promise<Result<ValidatedAccountInfo, ApiError | ValidationError>> { 313 - return xrpcValidated('com.atproto.admin.getAccountInfo', accountInfoSchema, { 314 - token, 315 - params: { did }, 316 - }) 317 }, 318 319 searchAccounts( 320 token: AccessToken, 321 - options?: { handle?: string; cursor?: string; limit?: number } 322 - ): Promise<Result<ValidatedSearchAccountsResponse, ApiError | ValidationError>> { 323 - const params: Record<string, string> = {} 324 - if (options?.handle) params.handle = options.handle 325 - if (options?.cursor) params.cursor = options.cursor 326 - if (options?.limit) params.limit = String(options.limit) 327 - return xrpcValidated('com.atproto.admin.searchAccounts', searchAccountsResponseSchema, { 328 - token, 329 - params, 330 - }) 331 }, 332 333 - listBackups(token: AccessToken): Promise<Result<ValidatedListBackupsResponse, ApiError | ValidationError>> { 334 - return xrpcValidated('_backup.listBackups', listBackupsResponseSchema, { token }) 335 }, 336 337 - createBackup(token: AccessToken): Promise<Result<ValidatedCreateBackupResponse, ApiError | ValidationError>> { 338 - return xrpcValidated('_backup.createBackup', createBackupResponseSchema, { 339 - method: 'POST', 340 token, 341 - }) 342 }, 343 - } 344 345 - export { ValidationError }
··· 1 + import { z } from "zod"; 2 + import { err, ok, type Result } from "./types/result.ts"; 3 + import { ApiError } from "./api.ts"; 4 + import type { 5 + AccessToken, 6 + Did, 7 + Nsid, 8 + RefreshToken, 9 + Rkey, 10 + } from "./types/branded.ts"; 11 import { 12 + accountInfoSchema, 13 appPasswordSchema, 14 + createBackupResponseSchema, 15 createdAppPasswordSchema, 16 + createRecordResponseSchema, 17 + didDocumentSchema, 18 enableTotpResponseSchema, 19 + legacyLoginPreferenceSchema, 20 + listBackupsResponseSchema, 21 listPasskeysResponseSchema, 22 + listRecordsResponseSchema, 23 + listSessionsResponseSchema, 24 listTrustedDevicesResponseSchema, 25 + notificationPrefsSchema, 26 + passwordStatusSchema, 27 reauthStatusSchema, 28 + recordResponseSchema, 29 repoDescriptionSchema, 30 + searchAccountsResponseSchema, 31 + serverConfigSchema, 32 + serverDescriptionSchema, 33 serverStatsSchema, 34 + sessionSchema, 35 successResponseSchema, 36 + totpSecretSchema, 37 + totpStatusSchema, 38 + type ValidatedAccountInfo, 39 + type ValidatedAppPassword, 40 + type ValidatedCreateBackupResponse, 41 + type ValidatedCreatedAppPassword, 42 + type ValidatedCreateRecordResponse, 43 + type ValidatedDidDocument, 44 type ValidatedEnableTotpResponse, 45 + type ValidatedLegacyLoginPreference, 46 + type ValidatedListBackupsResponse, 47 type ValidatedListPasskeysResponse, 48 + type ValidatedListRecordsResponse, 49 + type ValidatedListSessionsResponse, 50 type ValidatedListTrustedDevicesResponse, 51 type ValidatedNotificationPrefs, 52 + type ValidatedPasswordStatus, 53 + type ValidatedReauthStatus, 54 + type ValidatedRecordResponse, 55 type ValidatedRepoDescription, 56 + type ValidatedSearchAccountsResponse, 57 type ValidatedServerConfig, 58 + type ValidatedServerDescription, 59 + type ValidatedServerStats, 60 + type ValidatedSession, 61 type ValidatedSuccessResponse, 62 + type ValidatedTotpSecret, 63 + type ValidatedTotpStatus, 64 + } from "./types/schemas.ts"; 65 66 + const API_BASE = "/xrpc"; 67 68 interface XrpcOptions { 69 + method?: "GET" | "POST"; 70 + params?: Record<string, string>; 71 + body?: unknown; 72 + token?: string; 73 } 74 75 class ValidationError extends Error { 76 constructor( 77 public issues: z.ZodIssue[], 78 + message: string = "API response validation failed", 79 ) { 80 + super(message); 81 + this.name = "ValidationError"; 82 } 83 } 84 85 async function xrpcValidated<T>( 86 method: string, 87 schema: z.ZodType<T>, 88 + options?: XrpcOptions, 89 ): Promise<Result<T, ApiError | ValidationError>> { 90 + const { method: httpMethod = "GET", params, body, token } = options ?? {}; 91 + let url = `${API_BASE}/${method}`; 92 if (params) { 93 + const searchParams = new URLSearchParams(params); 94 + url += `?${searchParams}`; 95 } 96 + const headers: Record<string, string> = {}; 97 if (token) { 98 + headers["Authorization"] = `Bearer ${token}`; 99 } 100 if (body) { 101 + headers["Content-Type"] = "application/json"; 102 } 103 104 try { ··· 106 method: httpMethod, 107 headers, 108 body: body ? JSON.stringify(body) : undefined, 109 + }); 110 111 if (!res.ok) { 112 const errData = await res.json().catch(() => ({ 113 + error: "Unknown", 114 message: res.statusText, 115 + })); 116 + return err(new ApiError(res.status, errData.error, errData.message)); 117 } 118 119 + const data = await res.json(); 120 + const parsed = schema.safeParse(data); 121 122 if (!parsed.success) { 123 + return err(new ValidationError(parsed.error.issues)); 124 } 125 126 + return ok(parsed.data); 127 } catch (e) { 128 if (e instanceof ApiError || e instanceof ValidationError) { 129 + return err(e); 130 } 131 + return err( 132 + new ApiError(0, "Unknown", e instanceof Error ? e.message : String(e)), 133 + ); 134 } 135 } 136 137 export const validatedApi = { 138 + getSession( 139 + token: AccessToken, 140 + ): Promise<Result<ValidatedSession, ApiError | ValidationError>> { 141 + return xrpcValidated("com.atproto.server.getSession", sessionSchema, { 142 + token, 143 + }); 144 }, 145 146 + refreshSession( 147 + refreshJwt: RefreshToken, 148 + ): Promise<Result<ValidatedSession, ApiError | ValidationError>> { 149 + return xrpcValidated("com.atproto.server.refreshSession", sessionSchema, { 150 + method: "POST", 151 token: refreshJwt, 152 + }); 153 }, 154 155 createSession( 156 identifier: string, 157 + password: string, 158 ): Promise<Result<ValidatedSession, ApiError | ValidationError>> { 159 + return xrpcValidated("com.atproto.server.createSession", sessionSchema, { 160 + method: "POST", 161 body: { identifier, password }, 162 + }); 163 }, 164 165 + describeServer(): Promise< 166 + Result<ValidatedServerDescription, ApiError | ValidationError> 167 + > { 168 + return xrpcValidated( 169 + "com.atproto.server.describeServer", 170 + serverDescriptionSchema, 171 + ); 172 }, 173 174 listAppPasswords( 175 + token: AccessToken, 176 + ): Promise< 177 + Result<{ passwords: ValidatedAppPassword[] }, ApiError | ValidationError> 178 + > { 179 return xrpcValidated( 180 + "com.atproto.server.listAppPasswords", 181 z.object({ passwords: z.array(appPasswordSchema) }), 182 + { token }, 183 + ); 184 }, 185 186 createAppPassword( 187 token: AccessToken, 188 name: string, 189 + scopes?: string, 190 ): Promise<Result<ValidatedCreatedAppPassword, ApiError | ValidationError>> { 191 + return xrpcValidated( 192 + "com.atproto.server.createAppPassword", 193 + createdAppPasswordSchema, 194 + { 195 + method: "POST", 196 + token, 197 + body: { name, scopes }, 198 + }, 199 + ); 200 }, 201 202 + listSessions( 203 + token: AccessToken, 204 + ): Promise< 205 + Result<ValidatedListSessionsResponse, ApiError | ValidationError> 206 + > { 207 + return xrpcValidated("_account.listSessions", listSessionsResponseSchema, { 208 + token, 209 + }); 210 }, 211 212 + getTotpStatus( 213 + token: AccessToken, 214 + ): Promise<Result<ValidatedTotpStatus, ApiError | ValidationError>> { 215 + return xrpcValidated("com.atproto.server.getTotpStatus", totpStatusSchema, { 216 + token, 217 + }); 218 }, 219 220 + createTotpSecret( 221 + token: AccessToken, 222 + ): Promise<Result<ValidatedTotpSecret, ApiError | ValidationError>> { 223 + return xrpcValidated( 224 + "com.atproto.server.createTotpSecret", 225 + totpSecretSchema, 226 + { 227 + method: "POST", 228 + token, 229 + }, 230 + ); 231 }, 232 233 enableTotp( 234 token: AccessToken, 235 + code: string, 236 ): Promise<Result<ValidatedEnableTotpResponse, ApiError | ValidationError>> { 237 + return xrpcValidated( 238 + "com.atproto.server.enableTotp", 239 + enableTotpResponseSchema, 240 + { 241 + method: "POST", 242 + token, 243 + body: { code }, 244 + }, 245 + ); 246 }, 247 248 + listPasskeys( 249 + token: AccessToken, 250 + ): Promise< 251 + Result<ValidatedListPasskeysResponse, ApiError | ValidationError> 252 + > { 253 + return xrpcValidated( 254 + "com.atproto.server.listPasskeys", 255 + listPasskeysResponseSchema, 256 + { token }, 257 + ); 258 }, 259 260 listTrustedDevices( 261 + token: AccessToken, 262 + ): Promise< 263 + Result<ValidatedListTrustedDevicesResponse, ApiError | ValidationError> 264 + > { 265 + return xrpcValidated( 266 + "_account.listTrustedDevices", 267 + listTrustedDevicesResponseSchema, 268 + { token }, 269 + ); 270 }, 271 272 + getReauthStatus( 273 + token: AccessToken, 274 + ): Promise<Result<ValidatedReauthStatus, ApiError | ValidationError>> { 275 + return xrpcValidated("_account.getReauthStatus", reauthStatusSchema, { 276 + token, 277 + }); 278 }, 279 280 getNotificationPrefs( 281 + token: AccessToken, 282 ): Promise<Result<ValidatedNotificationPrefs, ApiError | ValidationError>> { 283 + return xrpcValidated( 284 + "_account.getNotificationPrefs", 285 + notificationPrefsSchema, 286 + { token }, 287 + ); 288 }, 289 290 + getDidDocument( 291 + token: AccessToken, 292 + ): Promise<Result<ValidatedDidDocument, ApiError | ValidationError>> { 293 + return xrpcValidated("_account.getDidDocument", didDocumentSchema, { 294 + token, 295 + }); 296 }, 297 298 describeRepo( 299 token: AccessToken, 300 + repo: Did, 301 ): Promise<Result<ValidatedRepoDescription, ApiError | ValidationError>> { 302 + return xrpcValidated( 303 + "com.atproto.repo.describeRepo", 304 + repoDescriptionSchema, 305 + { 306 + token, 307 + params: { repo }, 308 + }, 309 + ); 310 }, 311 312 listRecords( 313 token: AccessToken, 314 repo: Did, 315 collection: Nsid, 316 + options?: { limit?: number; cursor?: string; reverse?: boolean }, 317 ): Promise<Result<ValidatedListRecordsResponse, ApiError | ValidationError>> { 318 + const params: Record<string, string> = { repo, collection }; 319 + if (options?.limit) params.limit = String(options.limit); 320 + if (options?.cursor) params.cursor = options.cursor; 321 + if (options?.reverse) params.reverse = "true"; 322 + return xrpcValidated( 323 + "com.atproto.repo.listRecords", 324 + listRecordsResponseSchema, 325 + { 326 + token, 327 + params, 328 + }, 329 + ); 330 }, 331 332 getRecord( 333 token: AccessToken, 334 repo: Did, 335 collection: Nsid, 336 + rkey: Rkey, 337 ): Promise<Result<ValidatedRecordResponse, ApiError | ValidationError>> { 338 + return xrpcValidated("com.atproto.repo.getRecord", recordResponseSchema, { 339 token, 340 params: { repo, collection, rkey }, 341 + }); 342 }, 343 344 createRecord( ··· 346 repo: Did, 347 collection: Nsid, 348 record: unknown, 349 + rkey?: Rkey, 350 + ): Promise< 351 + Result<ValidatedCreateRecordResponse, ApiError | ValidationError> 352 + > { 353 + return xrpcValidated( 354 + "com.atproto.repo.createRecord", 355 + createRecordResponseSchema, 356 + { 357 + method: "POST", 358 + token, 359 + body: { repo, collection, record, rkey }, 360 + }, 361 + ); 362 }, 363 364 + getServerStats( 365 + token: AccessToken, 366 + ): Promise<Result<ValidatedServerStats, ApiError | ValidationError>> { 367 + return xrpcValidated("_admin.getServerStats", serverStatsSchema, { token }); 368 }, 369 370 + getServerConfig(): Promise< 371 + Result<ValidatedServerConfig, ApiError | ValidationError> 372 + > { 373 + return xrpcValidated("_server.getConfig", serverConfigSchema); 374 }, 375 376 + getPasswordStatus( 377 + token: AccessToken, 378 + ): Promise<Result<ValidatedPasswordStatus, ApiError | ValidationError>> { 379 + return xrpcValidated("_account.getPasswordStatus", passwordStatusSchema, { 380 + token, 381 + }); 382 }, 383 384 changePassword( 385 token: AccessToken, 386 currentPassword: string, 387 + newPassword: string, 388 ): Promise<Result<ValidatedSuccessResponse, ApiError | ValidationError>> { 389 + return xrpcValidated("_account.changePassword", successResponseSchema, { 390 + method: "POST", 391 token, 392 body: { currentPassword, newPassword }, 393 + }); 394 }, 395 396 getLegacyLoginPreference( 397 + token: AccessToken, 398 + ): Promise< 399 + Result<ValidatedLegacyLoginPreference, ApiError | ValidationError> 400 + > { 401 + return xrpcValidated( 402 + "_account.getLegacyLoginPreference", 403 + legacyLoginPreferenceSchema, 404 + { token }, 405 + ); 406 }, 407 408 getAccountInfo( 409 token: AccessToken, 410 + did: Did, 411 ): Promise<Result<ValidatedAccountInfo, ApiError | ValidationError>> { 412 + return xrpcValidated( 413 + "com.atproto.admin.getAccountInfo", 414 + accountInfoSchema, 415 + { 416 + token, 417 + params: { did }, 418 + }, 419 + ); 420 }, 421 422 searchAccounts( 423 token: AccessToken, 424 + options?: { handle?: string; cursor?: string; limit?: number }, 425 + ): Promise< 426 + Result<ValidatedSearchAccountsResponse, ApiError | ValidationError> 427 + > { 428 + const params: Record<string, string> = {}; 429 + if (options?.handle) params.handle = options.handle; 430 + if (options?.cursor) params.cursor = options.cursor; 431 + if (options?.limit) params.limit = String(options.limit); 432 + return xrpcValidated( 433 + "com.atproto.admin.searchAccounts", 434 + searchAccountsResponseSchema, 435 + { 436 + token, 437 + params, 438 + }, 439 + ); 440 }, 441 442 + listBackups( 443 + token: AccessToken, 444 + ): Promise<Result<ValidatedListBackupsResponse, ApiError | ValidationError>> { 445 + return xrpcValidated("_backup.listBackups", listBackupsResponseSchema, { 446 + token, 447 + }); 448 }, 449 450 + createBackup( 451 + token: AccessToken, 452 + ): Promise< 453 + Result<ValidatedCreateBackupResponse, ApiError | ValidationError> 454 + > { 455 + return xrpcValidated("_backup.createBackup", createBackupResponseSchema, { 456 + method: "POST", 457 token, 458 + }); 459 }, 460 + }; 461 462 + export { ValidationError };
+818 -678
frontend/src/lib/api.ts
··· 1 - import { ok, err, type Result } from './types/result' 2 import type { 3 Did, 4 Handle, 5 - AccessToken, 6 RefreshToken, 7 - Cid, 8 Rkey, 9 - AtUri, 10 - Nsid, 11 - ISODateString, 12 - EmailAddress, 13 - InviteCode as InviteCodeBrand, 14 - } from './types/branded' 15 import { 16 unsafeAsDid, 17 unsafeAsHandle, 18 - unsafeAsAccessToken, 19 - unsafeAsRefreshToken, 20 - unsafeAsCid, 21 unsafeAsISODate, 22 - unsafeAsEmail, 23 - unsafeAsInviteCode, 24 - } from './types/branded' 25 import type { 26 - Session, 27 - DidDocument, 28 AppPassword, 29 CreatedAppPassword, 30 - InviteCodeInfo, 31 - ServerDescription, 32 - NotificationPrefs, 33 - NotificationHistoryResponse, 34 - ServerStats, 35 - ServerConfig, 36 - UploadBlobResponse, 37 - ListSessionsResponse, 38 - SearchAccountsResponse, 39 - GetInviteCodesResponse, 40 - AccountInfo, 41 - RepoDescription, 42 - ListRecordsResponse, 43 - RecordResponse, 44 CreateRecordResponse, 45 - TotpStatus, 46 - TotpSecret, 47 EnableTotpResponse, 48 - RegenerateBackupCodesResponse, 49 - ListPasskeysResponse, 50 - StartPasskeyRegistrationResponse, 51 FinishPasskeyRegistrationResponse, 52 ListTrustedDevicesResponse, 53 ReauthStatus, 54 - ReauthResponse, 55 - ReauthPasskeyStartResponse, 56 ReserveSigningKeyResponse, 57 - RecommendedDidCredentials, 58 - PasskeyAccountCreateResponse, 59 - CompletePasskeySetupResponse, 60 - VerifyTokenResponse, 61 - ListBackupsResponse, 62 - CreateBackupResponse, 63 SetBackupEnabledResponse, 64 - EmailUpdateResponse, 65 - LegacyLoginPreference, 66 UpdateLegacyLoginResponse, 67 UpdateLocaleResponse, 68 - PasswordStatus, 69 - SuccessResponse, 70 - CheckEmailVerifiedResponse, 71 - VerifyMigrationEmailResponse, 72 - ResendMigrationVerificationResponse, 73 - ListReposResponse, 74 VerificationChannel, 75 - DidType, 76 - ApiErrorCode, 77 - VerificationMethod as VerificationMethodType, 78 - CreateAccountParams, 79 - CreateAccountResult, 80 - ConfirmSignupResult, 81 - } from './types/api' 82 83 - const API_BASE = '/xrpc' 84 85 export class ApiError extends Error { 86 - public did?: Did 87 - public reauthMethods?: string[] 88 constructor( 89 public status: number, 90 public error: ApiErrorCode, ··· 92 did?: string, 93 reauthMethods?: string[], 94 ) { 95 - super(message) 96 - this.name = 'ApiError' 97 - this.did = did ? unsafeAsDid(did) : undefined 98 - this.reauthMethods = reauthMethods 99 } 100 } 101 102 - let tokenRefreshCallback: (() => Promise<string | null>) | null = null 103 104 export function setTokenRefreshCallback( 105 callback: () => Promise<string | null>, 106 ) { 107 - tokenRefreshCallback = callback 108 } 109 110 interface XrpcOptions { 111 - method?: 'GET' | 'POST' 112 - params?: Record<string, string> 113 - body?: unknown 114 - token?: string 115 - skipRetry?: boolean 116 } 117 118 async function xrpc<T>(method: string, options?: XrpcOptions): Promise<T> { 119 - const { method: httpMethod = 'GET', params, body, token, skipRetry } = 120 - options ?? {} 121 - let url = `${API_BASE}/${method}` 122 if (params) { 123 - const searchParams = new URLSearchParams(params) 124 - url += `?${searchParams}` 125 } 126 - const headers: Record<string, string> = {} 127 if (token) { 128 - headers['Authorization'] = `Bearer ${token}` 129 } 130 if (body) { 131 - headers['Content-Type'] = 'application/json' 132 } 133 const res = await fetch(url, { 134 method: httpMethod, 135 headers, 136 body: body ? JSON.stringify(body) : undefined, 137 - }) 138 if (!res.ok) { 139 const errData = await res.json().catch(() => ({ 140 - error: 'Unknown', 141 message: res.statusText, 142 - })) 143 if ( 144 res.status === 401 && 145 - (errData.error === 'AuthenticationFailed' || errData.error === 'ExpiredToken') && 146 token && tokenRefreshCallback && !skipRetry 147 ) { 148 - const newToken = await tokenRefreshCallback() 149 if (newToken && newToken !== token) { 150 - return xrpc(method, { ...options, token: newToken, skipRetry: true }) 151 } 152 } 153 throw new ApiError( ··· 156 errData.message, 157 errData.did, 158 errData.reauthMethods, 159 - ) 160 } 161 - return res.json() 162 } 163 164 async function xrpcResult<T>( 165 method: string, 166 - options?: XrpcOptions 167 ): Promise<Result<T, ApiError>> { 168 try { 169 - const value = await xrpc<T>(method, options) 170 - return ok(value) 171 } catch (e) { 172 if (e instanceof ApiError) { 173 - return err(e) 174 } 175 - return err(new ApiError(0, 'Unknown', e instanceof Error ? e.message : String(e))) 176 } 177 } 178 179 export interface VerificationMethod { 180 - id: string 181 - type: string 182 - publicKeyMultibase: string 183 } 184 185 - export type { Session, DidDocument, AppPassword, InviteCodeInfo as InviteCode } 186 - export type { VerificationChannel, DidType, CreateAccountParams, CreateAccountResult, ConfirmSignupResult } 187 188 function castSession(raw: unknown): Session { 189 - const s = raw as Record<string, unknown> 190 return { 191 did: unsafeAsDid(s.did as string), 192 handle: unsafeAsHandle(s.handle as string), ··· 196 preferredChannelVerified: s.preferredChannelVerified as boolean | undefined, 197 isAdmin: s.isAdmin as boolean | undefined, 198 active: s.active as boolean | undefined, 199 - status: s.status as Session['status'], 200 migratedToPds: s.migratedToPds as string | undefined, 201 - migratedAt: s.migratedAt ? unsafeAsISODate(s.migratedAt as string) : undefined, 202 accessJwt: unsafeAsAccessToken(s.accessJwt as string), 203 refreshJwt: unsafeAsRefreshToken(s.refreshJwt as string), 204 - } 205 } 206 207 export const api = { ··· 209 params: CreateAccountParams, 210 byodToken?: string, 211 ): Promise<CreateAccountResult> { 212 - const url = `${API_BASE}/com.atproto.server.createAccount` 213 const headers: Record<string, string> = { 214 - 'Content-Type': 'application/json', 215 - } 216 if (byodToken) { 217 - headers['Authorization'] = `Bearer ${byodToken}` 218 } 219 const response = await fetch(url, { 220 - method: 'POST', 221 headers, 222 body: JSON.stringify({ 223 handle: params.handle, ··· 232 telegramUsername: params.telegramUsername, 233 signalNumber: params.signalNumber, 234 }), 235 - }) 236 - const data = await response.json() 237 if (!response.ok) { 238 - throw new ApiError(response.status, data.error, data.message) 239 } 240 - return data 241 }, 242 243 async createAccountWithServiceAuth( 244 serviceAuthToken: string, 245 params: { 246 - did: Did 247 - handle: Handle 248 - email: EmailAddress 249 - password: string 250 - inviteCode?: string 251 }, 252 ): Promise<Session> { 253 - const url = `${API_BASE}/com.atproto.server.createAccount` 254 const response = await fetch(url, { 255 - method: 'POST', 256 headers: { 257 - 'Content-Type': 'application/json', 258 - 'Authorization': `Bearer ${serviceAuthToken}`, 259 }, 260 body: JSON.stringify({ 261 did: params.did, ··· 264 password: params.password, 265 inviteCode: params.inviteCode, 266 }), 267 - }) 268 - const data = await response.json() 269 if (!response.ok) { 270 - throw new ApiError(response.status, data.error, data.message) 271 } 272 - return castSession(data) 273 }, 274 275 confirmSignup( 276 did: Did, 277 verificationCode: string, 278 ): Promise<ConfirmSignupResult> { 279 - return xrpc('com.atproto.server.confirmSignup', { 280 - method: 'POST', 281 body: { did, verificationCode }, 282 - }) 283 }, 284 285 resendVerification(did: Did): Promise<{ success: boolean }> { 286 - return xrpc('com.atproto.server.resendVerification', { 287 - method: 'POST', 288 body: { did }, 289 - }) 290 }, 291 292 async createSession(identifier: string, password: string): Promise<Session> { 293 - const raw = await xrpc<unknown>('com.atproto.server.createSession', { 294 - method: 'POST', 295 body: { identifier, password }, 296 - }) 297 - return castSession(raw) 298 }, 299 300 checkEmailVerified(identifier: string): Promise<{ verified: boolean }> { 301 - return xrpc('_checkEmailVerified', { 302 - method: 'POST', 303 body: { identifier }, 304 - }) 305 }, 306 307 async getSession(token: AccessToken): Promise<Session> { 308 - const raw = await xrpc<unknown>('com.atproto.server.getSession', { token }) 309 - return castSession(raw) 310 }, 311 312 async refreshSession(refreshJwt: RefreshToken): Promise<Session> { 313 - const raw = await xrpc<unknown>('com.atproto.server.refreshSession', { 314 - method: 'POST', 315 token: refreshJwt, 316 - }) 317 - return castSession(raw) 318 }, 319 320 async deleteSession(token: AccessToken): Promise<void> { 321 - await xrpc('com.atproto.server.deleteSession', { 322 - method: 'POST', 323 token, 324 - }) 325 }, 326 327 listAppPasswords(token: AccessToken): Promise<{ passwords: AppPassword[] }> { 328 - return xrpc('com.atproto.server.listAppPasswords', { token }) 329 }, 330 331 createAppPassword( ··· 333 name: string, 334 scopes?: string, 335 ): Promise<CreatedAppPassword> { 336 - return xrpc('com.atproto.server.createAppPassword', { 337 - method: 'POST', 338 token, 339 body: { name, scopes }, 340 - }) 341 }, 342 343 async revokeAppPassword(token: AccessToken, name: string): Promise<void> { 344 - await xrpc('com.atproto.server.revokeAppPassword', { 345 - method: 'POST', 346 token, 347 body: { name }, 348 - }) 349 }, 350 351 - getAccountInviteCodes(token: AccessToken): Promise<{ codes: InviteCodeInfo[] }> { 352 - return xrpc('com.atproto.server.getAccountInviteCodes', { token }) 353 }, 354 355 createInviteCode( 356 token: AccessToken, 357 useCount: number = 1, 358 ): Promise<{ code: string }> { 359 - return xrpc('com.atproto.server.createInviteCode', { 360 - method: 'POST', 361 token, 362 body: { useCount }, 363 - }) 364 }, 365 366 async requestPasswordReset(email: EmailAddress): Promise<void> { 367 - await xrpc('com.atproto.server.requestPasswordReset', { 368 - method: 'POST', 369 body: { email }, 370 - }) 371 }, 372 373 async resetPassword(token: string, password: string): Promise<void> { 374 - await xrpc('com.atproto.server.resetPassword', { 375 - method: 'POST', 376 body: { token, password }, 377 - }) 378 }, 379 380 requestEmailUpdate(token: AccessToken): Promise<EmailUpdateResponse> { 381 - return xrpc('com.atproto.server.requestEmailUpdate', { 382 - method: 'POST', 383 token, 384 - }) 385 }, 386 387 async updateEmail( ··· 389 email: string, 390 emailToken?: string, 391 ): Promise<void> { 392 - await xrpc('com.atproto.server.updateEmail', { 393 - method: 'POST', 394 token, 395 body: { email, token: emailToken }, 396 - }) 397 }, 398 399 async updateHandle(token: AccessToken, handle: Handle): Promise<void> { 400 - await xrpc('com.atproto.identity.updateHandle', { 401 - method: 'POST', 402 token, 403 body: { handle }, 404 - }) 405 }, 406 407 async requestAccountDelete(token: AccessToken): Promise<void> { 408 - await xrpc('com.atproto.server.requestAccountDelete', { 409 - method: 'POST', 410 token, 411 - }) 412 }, 413 414 async deleteAccount( ··· 416 password: string, 417 deleteToken: string, 418 ): Promise<void> { 419 - await xrpc('com.atproto.server.deleteAccount', { 420 - method: 'POST', 421 body: { did, password, token: deleteToken }, 422 - }) 423 }, 424 425 describeServer(): Promise<ServerDescription> { 426 - return xrpc('com.atproto.server.describeServer') 427 }, 428 429 listRepos(limit?: number): Promise<ListReposResponse> { 430 - const params: Record<string, string> = {} 431 - if (limit) params.limit = String(limit) 432 - return xrpc('com.atproto.sync.listRepos', { params }) 433 }, 434 435 getNotificationPrefs(token: AccessToken): Promise<NotificationPrefs> { 436 - return xrpc('_account.getNotificationPrefs', { token }) 437 }, 438 439 updateNotificationPrefs(token: AccessToken, prefs: { 440 - preferredChannel?: string 441 - discordId?: string 442 - telegramUsername?: string 443 - signalNumber?: string 444 }): Promise<SuccessResponse> { 445 - return xrpc('_account.updateNotificationPrefs', { 446 - method: 'POST', 447 token, 448 body: prefs, 449 - }) 450 }, 451 452 confirmChannelVerification( ··· 455 identifier: string, 456 code: string, 457 ): Promise<SuccessResponse> { 458 - return xrpc('_account.confirmChannelVerification', { 459 - method: 'POST', 460 token, 461 body: { channel, identifier, code }, 462 - }) 463 }, 464 465 - getNotificationHistory(token: AccessToken): Promise<NotificationHistoryResponse> { 466 - return xrpc('_account.getNotificationHistory', { token }) 467 }, 468 469 getServerStats(token: AccessToken): Promise<ServerStats> { 470 - return xrpc('_admin.getServerStats', { token }) 471 }, 472 473 getServerConfig(): Promise<ServerConfig> { 474 - return xrpc('_server.getConfig') 475 }, 476 477 updateServerConfig( 478 token: AccessToken, 479 config: { 480 - serverName?: string 481 - primaryColor?: string 482 - primaryColorDark?: string 483 - secondaryColor?: string 484 - secondaryColorDark?: string 485 - logoCid?: string 486 }, 487 ): Promise<SuccessResponse> { 488 - return xrpc('_admin.updateServerConfig', { 489 - method: 'POST', 490 token, 491 body: config, 492 - }) 493 }, 494 495 - async uploadBlob(token: AccessToken, file: File): Promise<UploadBlobResponse> { 496 - const res = await fetch('/xrpc/com.atproto.repo.uploadBlob', { 497 - method: 'POST', 498 headers: { 499 - 'Authorization': `Bearer ${token}`, 500 - 'Content-Type': file.type, 501 }, 502 body: file, 503 - }) 504 if (!res.ok) { 505 const errData = await res.json().catch(() => ({ 506 - error: 'Unknown', 507 message: res.statusText, 508 - })) 509 - throw new ApiError(res.status, errData.error, errData.message) 510 } 511 - return res.json() 512 }, 513 514 async changePassword( ··· 516 currentPassword: string, 517 newPassword: string, 518 ): Promise<void> { 519 - await xrpc('_account.changePassword', { 520 - method: 'POST', 521 token, 522 body: { currentPassword, newPassword }, 523 - }) 524 }, 525 526 removePassword(token: AccessToken): Promise<SuccessResponse> { 527 - return xrpc('_account.removePassword', { 528 - method: 'POST', 529 token, 530 - }) 531 }, 532 533 getPasswordStatus(token: AccessToken): Promise<PasswordStatus> { 534 - return xrpc('_account.getPasswordStatus', { token }) 535 }, 536 537 getLegacyLoginPreference(token: AccessToken): Promise<LegacyLoginPreference> { 538 - return xrpc('_account.getLegacyLoginPreference', { token }) 539 }, 540 541 updateLegacyLoginPreference( 542 token: AccessToken, 543 allowLegacyLogin: boolean, 544 ): Promise<UpdateLegacyLoginResponse> { 545 - return xrpc('_account.updateLegacyLoginPreference', { 546 - method: 'POST', 547 token, 548 body: { allowLegacyLogin }, 549 - }) 550 }, 551 552 - updateLocale(token: AccessToken, preferredLocale: string): Promise<UpdateLocaleResponse> { 553 - return xrpc('_account.updateLocale', { 554 - method: 'POST', 555 token, 556 body: { preferredLocale }, 557 - }) 558 }, 559 560 listSessions(token: AccessToken): Promise<ListSessionsResponse> { 561 - return xrpc('_account.listSessions', { token }) 562 }, 563 564 async revokeSession(token: AccessToken, sessionId: string): Promise<void> { 565 - await xrpc('_account.revokeSession', { 566 - method: 'POST', 567 token, 568 body: { sessionId }, 569 - }) 570 }, 571 572 revokeAllSessions(token: AccessToken): Promise<{ revokedCount: number }> { 573 - return xrpc('_account.revokeAllSessions', { 574 - method: 'POST', 575 token, 576 - }) 577 }, 578 579 searchAccounts(token: AccessToken, options?: { 580 - handle?: string 581 - cursor?: string 582 - limit?: number 583 }): Promise<SearchAccountsResponse> { 584 - const params: Record<string, string> = {} 585 - if (options?.handle) params.handle = options.handle 586 - if (options?.cursor) params.cursor = options.cursor 587 - if (options?.limit) params.limit = String(options.limit) 588 - return xrpc('com.atproto.admin.searchAccounts', { token, params }) 589 }, 590 591 getInviteCodes(token: AccessToken, options?: { 592 - sort?: 'recent' | 'usage' 593 - cursor?: string 594 - limit?: number 595 }): Promise<GetInviteCodesResponse> { 596 - const params: Record<string, string> = {} 597 - if (options?.sort) params.sort = options.sort 598 - if (options?.cursor) params.cursor = options.cursor 599 - if (options?.limit) params.limit = String(options.limit) 600 - return xrpc('com.atproto.admin.getInviteCodes', { token, params }) 601 }, 602 603 async disableInviteCodes( ··· 605 codes?: string[], 606 accounts?: string[], 607 ): Promise<void> { 608 - await xrpc('com.atproto.admin.disableInviteCodes', { 609 - method: 'POST', 610 token, 611 body: { codes, accounts }, 612 - }) 613 }, 614 615 getAccountInfo(token: AccessToken, did: Did): Promise<AccountInfo> { 616 - return xrpc('com.atproto.admin.getAccountInfo', { token, params: { did } }) 617 }, 618 619 async disableAccountInvites(token: AccessToken, account: Did): Promise<void> { 620 - await xrpc('com.atproto.admin.disableAccountInvites', { 621 - method: 'POST', 622 token, 623 body: { account }, 624 - }) 625 }, 626 627 async enableAccountInvites(token: AccessToken, account: Did): Promise<void> { 628 - await xrpc('com.atproto.admin.enableAccountInvites', { 629 - method: 'POST', 630 token, 631 body: { account }, 632 - }) 633 }, 634 635 async adminDeleteAccount(token: AccessToken, did: Did): Promise<void> { 636 - await xrpc('com.atproto.admin.deleteAccount', { 637 - method: 'POST', 638 token, 639 body: { did }, 640 - }) 641 }, 642 643 describeRepo(token: AccessToken, repo: Did): Promise<RepoDescription> { 644 - return xrpc('com.atproto.repo.describeRepo', { 645 token, 646 params: { repo }, 647 - }) 648 }, 649 650 listRecords(token: AccessToken, repo: Did, collection: Nsid, options?: { 651 - limit?: number 652 - cursor?: string 653 - reverse?: boolean 654 }): Promise<ListRecordsResponse> { 655 - const params: Record<string, string> = { repo, collection } 656 - if (options?.limit) params.limit = String(options.limit) 657 - if (options?.cursor) params.cursor = options.cursor 658 - if (options?.reverse) params.reverse = 'true' 659 - return xrpc('com.atproto.repo.listRecords', { token, params }) 660 }, 661 662 getRecord( ··· 665 collection: Nsid, 666 rkey: Rkey, 667 ): Promise<RecordResponse> { 668 - return xrpc('com.atproto.repo.getRecord', { 669 token, 670 params: { repo, collection, rkey }, 671 - }) 672 }, 673 674 createRecord( ··· 678 record: unknown, 679 rkey?: Rkey, 680 ): Promise<CreateRecordResponse> { 681 - return xrpc('com.atproto.repo.createRecord', { 682 - method: 'POST', 683 token, 684 body: { repo, collection, record, rkey }, 685 - }) 686 }, 687 688 putRecord( ··· 692 rkey: Rkey, 693 record: unknown, 694 ): Promise<CreateRecordResponse> { 695 - return xrpc('com.atproto.repo.putRecord', { 696 - method: 'POST', 697 token, 698 body: { repo, collection, rkey, record }, 699 - }) 700 }, 701 702 async deleteRecord( ··· 705 collection: Nsid, 706 rkey: Rkey, 707 ): Promise<void> { 708 - await xrpc('com.atproto.repo.deleteRecord', { 709 - method: 'POST', 710 token, 711 body: { repo, collection, rkey }, 712 - }) 713 }, 714 715 getTotpStatus(token: AccessToken): Promise<TotpStatus> { 716 - return xrpc('com.atproto.server.getTotpStatus', { token }) 717 }, 718 719 createTotpSecret(token: AccessToken): Promise<TotpSecret> { 720 - return xrpc('com.atproto.server.createTotpSecret', { 721 - method: 'POST', 722 token, 723 - }) 724 }, 725 726 enableTotp(token: AccessToken, code: string): Promise<EnableTotpResponse> { 727 - return xrpc('com.atproto.server.enableTotp', { 728 - method: 'POST', 729 token, 730 body: { code }, 731 - }) 732 }, 733 734 disableTotp( ··· 736 password: string, 737 code: string, 738 ): Promise<SuccessResponse> { 739 - return xrpc('com.atproto.server.disableTotp', { 740 - method: 'POST', 741 token, 742 body: { password, code }, 743 - }) 744 }, 745 746 regenerateBackupCodes( ··· 748 password: string, 749 code: string, 750 ): Promise<RegenerateBackupCodesResponse> { 751 - return xrpc('com.atproto.server.regenerateBackupCodes', { 752 - method: 'POST', 753 token, 754 body: { password, code }, 755 - }) 756 }, 757 758 startPasskeyRegistration( 759 token: AccessToken, 760 friendlyName?: string, 761 ): Promise<StartPasskeyRegistrationResponse> { 762 - return xrpc('com.atproto.server.startPasskeyRegistration', { 763 - method: 'POST', 764 token, 765 body: { friendlyName }, 766 - }) 767 }, 768 769 finishPasskeyRegistration( ··· 771 credential: unknown, 772 friendlyName?: string, 773 ): Promise<FinishPasskeyRegistrationResponse> { 774 - return xrpc('com.atproto.server.finishPasskeyRegistration', { 775 - method: 'POST', 776 token, 777 body: { credential, friendlyName }, 778 - }) 779 }, 780 781 listPasskeys(token: AccessToken): Promise<ListPasskeysResponse> { 782 - return xrpc('com.atproto.server.listPasskeys', { token }) 783 }, 784 785 async deletePasskey(token: AccessToken, id: string): Promise<void> { 786 - await xrpc('com.atproto.server.deletePasskey', { 787 - method: 'POST', 788 token, 789 body: { id }, 790 - }) 791 }, 792 793 async updatePasskey( ··· 795 id: string, 796 friendlyName: string, 797 ): Promise<void> { 798 - await xrpc('com.atproto.server.updatePasskey', { 799 - method: 'POST', 800 token, 801 body: { id, friendlyName }, 802 - }) 803 }, 804 805 listTrustedDevices(token: AccessToken): Promise<ListTrustedDevicesResponse> { 806 - return xrpc('_account.listTrustedDevices', { token }) 807 }, 808 809 - revokeTrustedDevice(token: AccessToken, deviceId: string): Promise<SuccessResponse> { 810 - return xrpc('_account.revokeTrustedDevice', { 811 - method: 'POST', 812 token, 813 body: { deviceId }, 814 - }) 815 }, 816 817 updateTrustedDevice( ··· 819 deviceId: string, 820 friendlyName: string, 821 ): Promise<SuccessResponse> { 822 - return xrpc('_account.updateTrustedDevice', { 823 - method: 'POST', 824 token, 825 body: { deviceId, friendlyName }, 826 - }) 827 }, 828 829 getReauthStatus(token: AccessToken): Promise<ReauthStatus> { 830 - return xrpc('_account.getReauthStatus', { token }) 831 }, 832 833 - reauthPassword(token: AccessToken, password: string): Promise<ReauthResponse> { 834 - return xrpc('_account.reauthPassword', { 835 - method: 'POST', 836 token, 837 body: { password }, 838 - }) 839 }, 840 841 reauthTotp(token: AccessToken, code: string): Promise<ReauthResponse> { 842 - return xrpc('_account.reauthTotp', { 843 - method: 'POST', 844 token, 845 body: { code }, 846 - }) 847 }, 848 849 reauthPasskeyStart(token: AccessToken): Promise<ReauthPasskeyStartResponse> { 850 - return xrpc('_account.reauthPasskeyStart', { 851 - method: 'POST', 852 token, 853 - }) 854 }, 855 856 - reauthPasskeyFinish(token: AccessToken, credential: unknown): Promise<ReauthResponse> { 857 - return xrpc('_account.reauthPasskeyFinish', { 858 - method: 'POST', 859 token, 860 body: { credential }, 861 - }) 862 }, 863 864 reserveSigningKey(did?: Did): Promise<ReserveSigningKeyResponse> { 865 - return xrpc('com.atproto.server.reserveSigningKey', { 866 - method: 'POST', 867 body: { did }, 868 - }) 869 }, 870 871 - getRecommendedDidCredentials(token: AccessToken): Promise<RecommendedDidCredentials> { 872 - return xrpc('com.atproto.identity.getRecommendedDidCredentials', { token }) 873 }, 874 875 async activateAccount(token: AccessToken): Promise<void> { 876 - await xrpc('com.atproto.server.activateAccount', { 877 - method: 'POST', 878 token, 879 - }) 880 }, 881 882 async createPasskeyAccount(params: { 883 - handle: Handle 884 - email?: EmailAddress 885 - inviteCode?: string 886 - didType?: DidType 887 - did?: Did 888 - signingKey?: string 889 - verificationChannel?: VerificationChannel 890 - discordId?: string 891 - telegramUsername?: string 892 - signalNumber?: string 893 }, byodToken?: string): Promise<PasskeyAccountCreateResponse> { 894 - const url = `${API_BASE}/_account.createPasskeyAccount` 895 const headers: Record<string, string> = { 896 - 'Content-Type': 'application/json', 897 - } 898 if (byodToken) { 899 - headers['Authorization'] = `Bearer ${byodToken}` 900 } 901 const res = await fetch(url, { 902 - method: 'POST', 903 headers, 904 body: JSON.stringify(params), 905 - }) 906 if (!res.ok) { 907 const errData = await res.json().catch(() => ({ 908 - error: 'Unknown', 909 message: res.statusText, 910 - })) 911 - throw new ApiError(res.status, errData.error, errData.message) 912 } 913 - return res.json() 914 }, 915 916 startPasskeyRegistrationForSetup( ··· 918 setupToken: string, 919 friendlyName?: string, 920 ): Promise<StartPasskeyRegistrationResponse> { 921 - return xrpc('_account.startPasskeyRegistrationForSetup', { 922 - method: 'POST', 923 body: { did, setupToken, friendlyName }, 924 - }) 925 }, 926 927 completePasskeySetup( ··· 930 passkeyCredential: unknown, 931 passkeyFriendlyName?: string, 932 ): Promise<CompletePasskeySetupResponse> { 933 - return xrpc('_account.completePasskeySetup', { 934 - method: 'POST', 935 body: { did, setupToken, passkeyCredential, passkeyFriendlyName }, 936 - }) 937 }, 938 939 requestPasskeyRecovery(email: EmailAddress): Promise<SuccessResponse> { 940 - return xrpc('_account.requestPasskeyRecovery', { 941 - method: 'POST', 942 body: { email }, 943 - }) 944 }, 945 946 recoverPasskeyAccount( ··· 948 recoveryToken: string, 949 newPassword: string, 950 ): Promise<SuccessResponse> { 951 - return xrpc('_account.recoverPasskeyAccount', { 952 - method: 'POST', 953 body: { did, recoveryToken, newPassword }, 954 - }) 955 }, 956 957 - verifyMigrationEmail(token: string, email: EmailAddress): Promise<VerifyMigrationEmailResponse> { 958 - return xrpc('com.atproto.server.verifyMigrationEmail', { 959 - method: 'POST', 960 body: { token, email }, 961 - }) 962 }, 963 964 - resendMigrationVerification(email: EmailAddress): Promise<ResendMigrationVerificationResponse> { 965 - return xrpc('com.atproto.server.resendMigrationVerification', { 966 - method: 'POST', 967 body: { email }, 968 - }) 969 }, 970 971 verifyToken( ··· 973 identifier: string, 974 accessToken?: AccessToken, 975 ): Promise<VerifyTokenResponse> { 976 - return xrpc('_account.verifyToken', { 977 - method: 'POST', 978 body: { token, identifier }, 979 token: accessToken, 980 - }) 981 }, 982 983 getDidDocument(token: AccessToken): Promise<DidDocument> { 984 - return xrpc('_account.getDidDocument', { token }) 985 }, 986 987 updateDidDocument( 988 token: AccessToken, 989 params: { 990 - verificationMethods?: VerificationMethod[] 991 - alsoKnownAs?: string[] 992 - serviceEndpoint?: string 993 }, 994 ): Promise<SuccessResponse> { 995 - return xrpc('_account.updateDidDocument', { 996 - method: 'POST', 997 token, 998 body: params, 999 - }) 1000 }, 1001 1002 - async deactivateAccount(token: AccessToken, deleteAfter?: string): Promise<void> { 1003 - await xrpc('com.atproto.server.deactivateAccount', { 1004 - method: 'POST', 1005 token, 1006 body: { deleteAfter }, 1007 - }) 1008 }, 1009 1010 async getRepo(token: AccessToken, did: Did): Promise<ArrayBuffer> { 1011 - const url = `${API_BASE}/com.atproto.sync.getRepo?did=${encodeURIComponent(did)}` 1012 const res = await fetch(url, { 1013 headers: { Authorization: `Bearer ${token}` }, 1014 - }) 1015 if (!res.ok) { 1016 const errData = await res.json().catch(() => ({ 1017 - error: 'Unknown', 1018 message: res.statusText, 1019 - })) 1020 - throw new ApiError(res.status, errData.error, errData.message) 1021 } 1022 - return res.arrayBuffer() 1023 }, 1024 1025 listBackups(token: AccessToken): Promise<ListBackupsResponse> { 1026 - return xrpc('_backup.listBackups', { token }) 1027 }, 1028 1029 async getBackup(token: AccessToken, id: string): Promise<Blob> { 1030 - const url = `${API_BASE}/_backup.getBackup?id=${encodeURIComponent(id)}` 1031 const res = await fetch(url, { 1032 headers: { Authorization: `Bearer ${token}` }, 1033 - }) 1034 if (!res.ok) { 1035 const errData = await res.json().catch(() => ({ 1036 - error: 'Unknown', 1037 message: res.statusText, 1038 - })) 1039 - throw new ApiError(res.status, errData.error, errData.message) 1040 } 1041 - return res.blob() 1042 }, 1043 1044 createBackup(token: AccessToken): Promise<CreateBackupResponse> { 1045 - return xrpc('_backup.createBackup', { 1046 - method: 'POST', 1047 token, 1048 - }) 1049 }, 1050 1051 async deleteBackup(token: AccessToken, id: string): Promise<void> { 1052 - await xrpc('_backup.deleteBackup', { 1053 - method: 'POST', 1054 token, 1055 params: { id }, 1056 - }) 1057 }, 1058 1059 - setBackupEnabled(token: AccessToken, enabled: boolean): Promise<SetBackupEnabledResponse> { 1060 - return xrpc('_backup.setEnabled', { 1061 - method: 'POST', 1062 token, 1063 body: { enabled }, 1064 - }) 1065 }, 1066 1067 async importRepo(token: AccessToken, car: Uint8Array): Promise<void> { 1068 - const url = `${API_BASE}/com.atproto.repo.importRepo` 1069 const res = await fetch(url, { 1070 - method: 'POST', 1071 headers: { 1072 Authorization: `Bearer ${token}`, 1073 - 'Content-Type': 'application/vnd.ipld.car', 1074 }, 1075 - body: car, 1076 - }) 1077 if (!res.ok) { 1078 const errData = await res.json().catch(() => ({ 1079 - error: 'Unknown', 1080 message: res.statusText, 1081 - })) 1082 - throw new ApiError(res.status, errData.error, errData.message) 1083 } 1084 }, 1085 - } 1086 1087 export const typedApi = { 1088 createSession( 1089 identifier: string, 1090 - password: string 1091 ): Promise<Result<Session, ApiError>> { 1092 - return xrpcResult<Session>('com.atproto.server.createSession', { 1093 - method: 'POST', 1094 body: { identifier, password }, 1095 - }).then(r => r.ok ? ok(castSession(r.value)) : r) 1096 }, 1097 1098 getSession(token: AccessToken): Promise<Result<Session, ApiError>> { 1099 - return xrpcResult<Session>('com.atproto.server.getSession', { token }) 1100 - .then(r => r.ok ? ok(castSession(r.value)) : r) 1101 }, 1102 1103 refreshSession(refreshJwt: RefreshToken): Promise<Result<Session, ApiError>> { 1104 - return xrpcResult<Session>('com.atproto.server.refreshSession', { 1105 - method: 'POST', 1106 token: refreshJwt, 1107 - }).then(r => r.ok ? ok(castSession(r.value)) : r) 1108 }, 1109 1110 describeServer(): Promise<Result<ServerDescription, ApiError>> { 1111 - return xrpcResult('com.atproto.server.describeServer') 1112 }, 1113 1114 - listAppPasswords(token: AccessToken): Promise<Result<{ passwords: AppPassword[] }, ApiError>> { 1115 - return xrpcResult('com.atproto.server.listAppPasswords', { token }) 1116 }, 1117 1118 createAppPassword( 1119 token: AccessToken, 1120 name: string, 1121 - scopes?: string 1122 ): Promise<Result<CreatedAppPassword, ApiError>> { 1123 - return xrpcResult('com.atproto.server.createAppPassword', { 1124 - method: 'POST', 1125 token, 1126 body: { name, scopes }, 1127 - }) 1128 }, 1129 1130 - revokeAppPassword(token: AccessToken, name: string): Promise<Result<void, ApiError>> { 1131 - return xrpcResult<void>('com.atproto.server.revokeAppPassword', { 1132 - method: 'POST', 1133 token, 1134 body: { name }, 1135 - }) 1136 }, 1137 1138 - listSessions(token: AccessToken): Promise<Result<ListSessionsResponse, ApiError>> { 1139 - return xrpcResult('_account.listSessions', { token }) 1140 }, 1141 1142 - revokeSession(token: AccessToken, sessionId: string): Promise<Result<void, ApiError>> { 1143 - return xrpcResult<void>('_account.revokeSession', { 1144 - method: 'POST', 1145 token, 1146 body: { sessionId }, 1147 - }) 1148 }, 1149 1150 getTotpStatus(token: AccessToken): Promise<Result<TotpStatus, ApiError>> { 1151 - return xrpcResult('com.atproto.server.getTotpStatus', { token }) 1152 }, 1153 1154 createTotpSecret(token: AccessToken): Promise<Result<TotpSecret, ApiError>> { 1155 - return xrpcResult('com.atproto.server.createTotpSecret', { 1156 - method: 'POST', 1157 token, 1158 - }) 1159 }, 1160 1161 - enableTotp(token: AccessToken, code: string): Promise<Result<EnableTotpResponse, ApiError>> { 1162 - return xrpcResult('com.atproto.server.enableTotp', { 1163 - method: 'POST', 1164 token, 1165 body: { code }, 1166 - }) 1167 }, 1168 1169 disableTotp( 1170 token: AccessToken, 1171 password: string, 1172 - code: string 1173 ): Promise<Result<SuccessResponse, ApiError>> { 1174 - return xrpcResult('com.atproto.server.disableTotp', { 1175 - method: 'POST', 1176 token, 1177 body: { password, code }, 1178 - }) 1179 }, 1180 1181 - listPasskeys(token: AccessToken): Promise<Result<ListPasskeysResponse, ApiError>> { 1182 - return xrpcResult('com.atproto.server.listPasskeys', { token }) 1183 }, 1184 1185 - deletePasskey(token: AccessToken, id: string): Promise<Result<void, ApiError>> { 1186 - return xrpcResult<void>('com.atproto.server.deletePasskey', { 1187 - method: 'POST', 1188 token, 1189 body: { id }, 1190 - }) 1191 }, 1192 1193 - listTrustedDevices(token: AccessToken): Promise<Result<ListTrustedDevicesResponse, ApiError>> { 1194 - return xrpcResult('_account.listTrustedDevices', { token }) 1195 }, 1196 1197 getReauthStatus(token: AccessToken): Promise<Result<ReauthStatus, ApiError>> { 1198 - return xrpcResult('_account.getReauthStatus', { token }) 1199 }, 1200 1201 - getNotificationPrefs(token: AccessToken): Promise<Result<NotificationPrefs, ApiError>> { 1202 - return xrpcResult('_account.getNotificationPrefs', { token }) 1203 }, 1204 1205 - updateHandle(token: AccessToken, handle: Handle): Promise<Result<void, ApiError>> { 1206 - return xrpcResult<void>('com.atproto.identity.updateHandle', { 1207 - method: 'POST', 1208 token, 1209 body: { handle }, 1210 - }) 1211 }, 1212 1213 - describeRepo(token: AccessToken, repo: Did): Promise<Result<RepoDescription, ApiError>> { 1214 - return xrpcResult('com.atproto.repo.describeRepo', { 1215 token, 1216 params: { repo }, 1217 - }) 1218 }, 1219 1220 listRecords( 1221 token: AccessToken, 1222 repo: Did, 1223 collection: Nsid, 1224 - options?: { limit?: number; cursor?: string; reverse?: boolean } 1225 ): Promise<Result<ListRecordsResponse, ApiError>> { 1226 - const params: Record<string, string> = { repo, collection } 1227 - if (options?.limit) params.limit = String(options.limit) 1228 - if (options?.cursor) params.cursor = options.cursor 1229 - if (options?.reverse) params.reverse = 'true' 1230 - return xrpcResult('com.atproto.repo.listRecords', { token, params }) 1231 }, 1232 1233 getRecord( 1234 token: AccessToken, 1235 repo: Did, 1236 collection: Nsid, 1237 - rkey: Rkey 1238 ): Promise<Result<RecordResponse, ApiError>> { 1239 - return xrpcResult('com.atproto.repo.getRecord', { 1240 token, 1241 params: { repo, collection, rkey }, 1242 - }) 1243 }, 1244 1245 deleteRecord( 1246 token: AccessToken, 1247 repo: Did, 1248 collection: Nsid, 1249 - rkey: Rkey 1250 ): Promise<Result<void, ApiError>> { 1251 - return xrpcResult<void>('com.atproto.repo.deleteRecord', { 1252 - method: 'POST', 1253 token, 1254 body: { repo, collection, rkey }, 1255 - }) 1256 }, 1257 1258 searchAccounts( 1259 token: AccessToken, 1260 - options?: { handle?: string; cursor?: string; limit?: number } 1261 ): Promise<Result<SearchAccountsResponse, ApiError>> { 1262 - const params: Record<string, string> = {} 1263 - if (options?.handle) params.handle = options.handle 1264 - if (options?.cursor) params.cursor = options.cursor 1265 - if (options?.limit) params.limit = String(options.limit) 1266 - return xrpcResult('com.atproto.admin.searchAccounts', { token, params }) 1267 }, 1268 1269 - getAccountInfo(token: AccessToken, did: Did): Promise<Result<AccountInfo, ApiError>> { 1270 - return xrpcResult('com.atproto.admin.getAccountInfo', { token, params: { did } }) 1271 }, 1272 1273 getServerStats(token: AccessToken): Promise<Result<ServerStats, ApiError>> { 1274 - return xrpcResult('_admin.getServerStats', { token }) 1275 }, 1276 1277 - listBackups(token: AccessToken): Promise<Result<ListBackupsResponse, ApiError>> { 1278 - return xrpcResult('_backup.listBackups', { token }) 1279 }, 1280 1281 - createBackup(token: AccessToken): Promise<Result<CreateBackupResponse, ApiError>> { 1282 - return xrpcResult('_backup.createBackup', { 1283 - method: 'POST', 1284 token, 1285 - }) 1286 }, 1287 1288 getDidDocument(token: AccessToken): Promise<Result<DidDocument, ApiError>> { 1289 - return xrpcResult('_account.getDidDocument', { token }) 1290 }, 1291 1292 deleteSession(token: AccessToken): Promise<Result<void, ApiError>> { 1293 - return xrpcResult<void>('com.atproto.server.deleteSession', { 1294 - method: 'POST', 1295 token, 1296 - }) 1297 }, 1298 1299 - revokeAllSessions(token: AccessToken): Promise<Result<{ revokedCount: number }, ApiError>> { 1300 - return xrpcResult('_account.revokeAllSessions', { 1301 - method: 'POST', 1302 token, 1303 - }) 1304 }, 1305 1306 - getAccountInviteCodes(token: AccessToken): Promise<Result<{ codes: InviteCodeInfo[] }, ApiError>> { 1307 - return xrpcResult('com.atproto.server.getAccountInviteCodes', { token }) 1308 }, 1309 1310 - createInviteCode(token: AccessToken, useCount: number = 1): Promise<Result<{ code: string }, ApiError>> { 1311 - return xrpcResult('com.atproto.server.createInviteCode', { 1312 - method: 'POST', 1313 token, 1314 body: { useCount }, 1315 - }) 1316 }, 1317 1318 changePassword( 1319 token: AccessToken, 1320 currentPassword: string, 1321 - newPassword: string 1322 ): Promise<Result<void, ApiError>> { 1323 - return xrpcResult<void>('_account.changePassword', { 1324 - method: 'POST', 1325 token, 1326 body: { currentPassword, newPassword }, 1327 - }) 1328 }, 1329 1330 - getPasswordStatus(token: AccessToken): Promise<Result<PasswordStatus, ApiError>> { 1331 - return xrpcResult('_account.getPasswordStatus', { token }) 1332 }, 1333 1334 getServerConfig(): Promise<Result<ServerConfig, ApiError>> { 1335 - return xrpcResult('_server.getConfig') 1336 }, 1337 1338 - getLegacyLoginPreference(token: AccessToken): Promise<Result<LegacyLoginPreference, ApiError>> { 1339 - return xrpcResult('_account.getLegacyLoginPreference', { token }) 1340 }, 1341 1342 updateLegacyLoginPreference( 1343 token: AccessToken, 1344 - allowLegacyLogin: boolean 1345 ): Promise<Result<UpdateLegacyLoginResponse, ApiError>> { 1346 - return xrpcResult('_account.updateLegacyLoginPreference', { 1347 - method: 'POST', 1348 token, 1349 body: { allowLegacyLogin }, 1350 - }) 1351 }, 1352 1353 - getNotificationHistory(token: AccessToken): Promise<Result<NotificationHistoryResponse, ApiError>> { 1354 - return xrpcResult('_account.getNotificationHistory', { token }) 1355 }, 1356 1357 updateNotificationPrefs( 1358 token: AccessToken, 1359 prefs: { 1360 - preferredChannel?: string 1361 - discordId?: string 1362 - telegramUsername?: string 1363 - signalNumber?: string 1364 - } 1365 ): Promise<Result<SuccessResponse, ApiError>> { 1366 - return xrpcResult('_account.updateNotificationPrefs', { 1367 - method: 'POST', 1368 token, 1369 body: prefs, 1370 - }) 1371 }, 1372 1373 - revokeTrustedDevice(token: AccessToken, deviceId: string): Promise<Result<SuccessResponse, ApiError>> { 1374 - return xrpcResult('_account.revokeTrustedDevice', { 1375 - method: 'POST', 1376 token, 1377 body: { deviceId }, 1378 - }) 1379 }, 1380 1381 updateTrustedDevice( 1382 token: AccessToken, 1383 deviceId: string, 1384 - friendlyName: string 1385 ): Promise<Result<SuccessResponse, ApiError>> { 1386 - return xrpcResult('_account.updateTrustedDevice', { 1387 - method: 'POST', 1388 token, 1389 body: { deviceId, friendlyName }, 1390 - }) 1391 }, 1392 1393 - reauthPassword(token: AccessToken, password: string): Promise<Result<ReauthResponse, ApiError>> { 1394 - return xrpcResult('_account.reauthPassword', { 1395 - method: 'POST', 1396 token, 1397 body: { password }, 1398 - }) 1399 }, 1400 1401 - reauthTotp(token: AccessToken, code: string): Promise<Result<ReauthResponse, ApiError>> { 1402 - return xrpcResult('_account.reauthTotp', { 1403 - method: 'POST', 1404 token, 1405 body: { code }, 1406 - }) 1407 }, 1408 1409 - reauthPasskeyStart(token: AccessToken): Promise<Result<ReauthPasskeyStartResponse, ApiError>> { 1410 - return xrpcResult('_account.reauthPasskeyStart', { 1411 - method: 'POST', 1412 token, 1413 - }) 1414 }, 1415 1416 - reauthPasskeyFinish(token: AccessToken, credential: unknown): Promise<Result<ReauthResponse, ApiError>> { 1417 - return xrpcResult('_account.reauthPasskeyFinish', { 1418 - method: 'POST', 1419 token, 1420 body: { credential }, 1421 - }) 1422 }, 1423 1424 - confirmSignup(did: Did, verificationCode: string): Promise<Result<ConfirmSignupResult, ApiError>> { 1425 - return xrpcResult('com.atproto.server.confirmSignup', { 1426 - method: 'POST', 1427 body: { did, verificationCode }, 1428 - }) 1429 }, 1430 1431 - resendVerification(did: Did): Promise<Result<{ success: boolean }, ApiError>> { 1432 - return xrpcResult('com.atproto.server.resendVerification', { 1433 - method: 'POST', 1434 body: { did }, 1435 - }) 1436 }, 1437 1438 - requestEmailUpdate(token: AccessToken): Promise<Result<EmailUpdateResponse, ApiError>> { 1439 - return xrpcResult('com.atproto.server.requestEmailUpdate', { 1440 - method: 'POST', 1441 token, 1442 - }) 1443 }, 1444 1445 - updateEmail(token: AccessToken, email: string, emailToken?: string): Promise<Result<void, ApiError>> { 1446 - return xrpcResult<void>('com.atproto.server.updateEmail', { 1447 - method: 'POST', 1448 token, 1449 body: { email, token: emailToken }, 1450 - }) 1451 }, 1452 1453 requestAccountDelete(token: AccessToken): Promise<Result<void, ApiError>> { 1454 - return xrpcResult<void>('com.atproto.server.requestAccountDelete', { 1455 - method: 'POST', 1456 token, 1457 - }) 1458 }, 1459 1460 - deleteAccount(did: Did, password: string, deleteToken: string): Promise<Result<void, ApiError>> { 1461 - return xrpcResult<void>('com.atproto.server.deleteAccount', { 1462 - method: 'POST', 1463 body: { did, password, token: deleteToken }, 1464 - }) 1465 }, 1466 1467 updateDidDocument( 1468 token: AccessToken, 1469 params: { 1470 - verificationMethods?: VerificationMethod[] 1471 - alsoKnownAs?: string[] 1472 - serviceEndpoint?: string 1473 - } 1474 ): Promise<Result<SuccessResponse, ApiError>> { 1475 - return xrpcResult('_account.updateDidDocument', { 1476 - method: 'POST', 1477 token, 1478 body: params, 1479 - }) 1480 }, 1481 1482 - deactivateAccount(token: AccessToken, deleteAfter?: string): Promise<Result<void, ApiError>> { 1483 - return xrpcResult<void>('com.atproto.server.deactivateAccount', { 1484 - method: 'POST', 1485 token, 1486 body: { deleteAfter }, 1487 - }) 1488 }, 1489 1490 activateAccount(token: AccessToken): Promise<Result<void, ApiError>> { 1491 - return xrpcResult<void>('com.atproto.server.activateAccount', { 1492 - method: 'POST', 1493 token, 1494 - }) 1495 }, 1496 1497 - setBackupEnabled(token: AccessToken, enabled: boolean): Promise<Result<SetBackupEnabledResponse, ApiError>> { 1498 - return xrpcResult('_backup.setEnabled', { 1499 - method: 'POST', 1500 token, 1501 body: { enabled }, 1502 - }) 1503 }, 1504 1505 - deleteBackup(token: AccessToken, id: string): Promise<Result<void, ApiError>> { 1506 - return xrpcResult<void>('_backup.deleteBackup', { 1507 - method: 'POST', 1508 token, 1509 params: { id }, 1510 - }) 1511 }, 1512 1513 createRecord( ··· 1515 repo: Did, 1516 collection: Nsid, 1517 record: unknown, 1518 - rkey?: Rkey 1519 ): Promise<Result<CreateRecordResponse, ApiError>> { 1520 - return xrpcResult('com.atproto.repo.createRecord', { 1521 - method: 'POST', 1522 token, 1523 body: { repo, collection, record, rkey }, 1524 - }) 1525 }, 1526 1527 putRecord( ··· 1529 repo: Did, 1530 collection: Nsid, 1531 rkey: Rkey, 1532 - record: unknown 1533 ): Promise<Result<CreateRecordResponse, ApiError>> { 1534 - return xrpcResult('com.atproto.repo.putRecord', { 1535 - method: 'POST', 1536 token, 1537 body: { repo, collection, rkey, record }, 1538 - }) 1539 }, 1540 1541 getInviteCodes( 1542 token: AccessToken, 1543 - options?: { sort?: 'recent' | 'usage'; cursor?: string; limit?: number } 1544 ): Promise<Result<GetInviteCodesResponse, ApiError>> { 1545 - const params: Record<string, string> = {} 1546 - if (options?.sort) params.sort = options.sort 1547 - if (options?.cursor) params.cursor = options.cursor 1548 - if (options?.limit) params.limit = String(options.limit) 1549 - return xrpcResult('com.atproto.admin.getInviteCodes', { token, params }) 1550 }, 1551 1552 - disableAccountInvites(token: AccessToken, account: Did): Promise<Result<void, ApiError>> { 1553 - return xrpcResult<void>('com.atproto.admin.disableAccountInvites', { 1554 - method: 'POST', 1555 token, 1556 body: { account }, 1557 - }) 1558 }, 1559 1560 - enableAccountInvites(token: AccessToken, account: Did): Promise<Result<void, ApiError>> { 1561 - return xrpcResult<void>('com.atproto.admin.enableAccountInvites', { 1562 - method: 'POST', 1563 token, 1564 body: { account }, 1565 - }) 1566 }, 1567 1568 - adminDeleteAccount(token: AccessToken, did: Did): Promise<Result<void, ApiError>> { 1569 - return xrpcResult<void>('com.atproto.admin.deleteAccount', { 1570 - method: 'POST', 1571 token, 1572 body: { did }, 1573 - }) 1574 }, 1575 1576 startPasskeyRegistration( 1577 token: AccessToken, 1578 - friendlyName?: string 1579 ): Promise<Result<StartPasskeyRegistrationResponse, ApiError>> { 1580 - return xrpcResult('com.atproto.server.startPasskeyRegistration', { 1581 - method: 'POST', 1582 token, 1583 body: { friendlyName }, 1584 - }) 1585 }, 1586 1587 finishPasskeyRegistration( 1588 token: AccessToken, 1589 credential: unknown, 1590 - friendlyName?: string 1591 ): Promise<Result<FinishPasskeyRegistrationResponse, ApiError>> { 1592 - return xrpcResult('com.atproto.server.finishPasskeyRegistration', { 1593 - method: 'POST', 1594 token, 1595 body: { credential, friendlyName }, 1596 - }) 1597 }, 1598 1599 updatePasskey( 1600 token: AccessToken, 1601 id: string, 1602 - friendlyName: string 1603 ): Promise<Result<void, ApiError>> { 1604 - return xrpcResult<void>('com.atproto.server.updatePasskey', { 1605 - method: 'POST', 1606 token, 1607 body: { id, friendlyName }, 1608 - }) 1609 }, 1610 1611 regenerateBackupCodes( 1612 token: AccessToken, 1613 password: string, 1614 - code: string 1615 ): Promise<Result<RegenerateBackupCodesResponse, ApiError>> { 1616 - return xrpcResult('com.atproto.server.regenerateBackupCodes', { 1617 - method: 'POST', 1618 token, 1619 body: { password, code }, 1620 - }) 1621 }, 1622 1623 - updateLocale(token: AccessToken, preferredLocale: string): Promise<Result<UpdateLocaleResponse, ApiError>> { 1624 - return xrpcResult('_account.updateLocale', { 1625 - method: 'POST', 1626 token, 1627 body: { preferredLocale }, 1628 - }) 1629 }, 1630 1631 confirmChannelVerification( 1632 token: AccessToken, 1633 channel: string, 1634 identifier: string, 1635 - code: string 1636 ): Promise<Result<SuccessResponse, ApiError>> { 1637 - return xrpcResult('_account.confirmChannelVerification', { 1638 - method: 'POST', 1639 token, 1640 body: { channel, identifier, code }, 1641 - }) 1642 }, 1643 1644 - removePassword(token: AccessToken): Promise<Result<SuccessResponse, ApiError>> { 1645 - return xrpcResult('_account.removePassword', { 1646 - method: 'POST', 1647 token, 1648 - }) 1649 }, 1650 - }
··· 1 + import { err, ok, type Result } from "./types/result.ts"; 2 import type { 3 + AccessToken, 4 Did, 5 + EmailAddress, 6 Handle, 7 + Nsid, 8 RefreshToken, 9 Rkey, 10 + } from "./types/branded.ts"; 11 import { 12 + unsafeAsAccessToken, 13 unsafeAsDid, 14 + unsafeAsEmail, 15 unsafeAsHandle, 16 unsafeAsISODate, 17 + unsafeAsRefreshToken, 18 + } from "./types/branded.ts"; 19 import type { 20 + AccountInfo, 21 + ApiErrorCode, 22 AppPassword, 23 + CompletePasskeySetupResponse, 24 + ConfirmSignupResult, 25 + CreateAccountParams, 26 + CreateAccountResult, 27 + CreateBackupResponse, 28 CreatedAppPassword, 29 CreateRecordResponse, 30 + DidDocument, 31 + DidType, 32 + EmailUpdateResponse, 33 EnableTotpResponse, 34 FinishPasskeyRegistrationResponse, 35 + GetInviteCodesResponse, 36 + InviteCodeInfo, 37 + LegacyLoginPreference, 38 + ListBackupsResponse, 39 + ListPasskeysResponse, 40 + ListRecordsResponse, 41 + ListReposResponse, 42 + ListSessionsResponse, 43 ListTrustedDevicesResponse, 44 + NotificationHistoryResponse, 45 + NotificationPrefs, 46 + PasskeyAccountCreateResponse, 47 + PasswordStatus, 48 + ReauthPasskeyStartResponse, 49 + ReauthResponse, 50 ReauthStatus, 51 + RecommendedDidCredentials, 52 + RecordResponse, 53 + RegenerateBackupCodesResponse, 54 + RepoDescription, 55 + ResendMigrationVerificationResponse, 56 ReserveSigningKeyResponse, 57 + SearchAccountsResponse, 58 + ServerConfig, 59 + ServerDescription, 60 + ServerStats, 61 + Session, 62 SetBackupEnabledResponse, 63 + StartPasskeyRegistrationResponse, 64 + SuccessResponse, 65 + TotpSecret, 66 + TotpStatus, 67 UpdateLegacyLoginResponse, 68 UpdateLocaleResponse, 69 + UploadBlobResponse, 70 VerificationChannel, 71 + VerifyMigrationEmailResponse, 72 + VerifyTokenResponse, 73 + } from "./types/api.ts"; 74 75 + const API_BASE = "/xrpc"; 76 77 export class ApiError extends Error { 78 + public did?: Did; 79 + public reauthMethods?: string[]; 80 constructor( 81 public status: number, 82 public error: ApiErrorCode, ··· 84 did?: string, 85 reauthMethods?: string[], 86 ) { 87 + super(message); 88 + this.name = "ApiError"; 89 + this.did = did ? unsafeAsDid(did) : undefined; 90 + this.reauthMethods = reauthMethods; 91 } 92 } 93 94 + let tokenRefreshCallback: (() => Promise<string | null>) | null = null; 95 96 export function setTokenRefreshCallback( 97 callback: () => Promise<string | null>, 98 ) { 99 + tokenRefreshCallback = callback; 100 } 101 102 interface XrpcOptions { 103 + method?: "GET" | "POST"; 104 + params?: Record<string, string>; 105 + body?: unknown; 106 + token?: string; 107 + skipRetry?: boolean; 108 } 109 110 async function xrpc<T>(method: string, options?: XrpcOptions): Promise<T> { 111 + const { method: httpMethod = "GET", params, body, token, skipRetry } = 112 + options ?? {}; 113 + let url = `${API_BASE}/${method}`; 114 if (params) { 115 + const searchParams = new URLSearchParams(params); 116 + url += `?${searchParams}`; 117 } 118 + const headers: Record<string, string> = {}; 119 if (token) { 120 + headers["Authorization"] = `Bearer ${token}`; 121 } 122 if (body) { 123 + headers["Content-Type"] = "application/json"; 124 } 125 const res = await fetch(url, { 126 method: httpMethod, 127 headers, 128 body: body ? JSON.stringify(body) : undefined, 129 + }); 130 if (!res.ok) { 131 const errData = await res.json().catch(() => ({ 132 + error: "Unknown", 133 message: res.statusText, 134 + })); 135 if ( 136 res.status === 401 && 137 + (errData.error === "AuthenticationFailed" || 138 + errData.error === "ExpiredToken") && 139 token && tokenRefreshCallback && !skipRetry 140 ) { 141 + const newToken = await tokenRefreshCallback(); 142 if (newToken && newToken !== token) { 143 + return xrpc(method, { ...options, token: newToken, skipRetry: true }); 144 } 145 } 146 throw new ApiError( ··· 149 errData.message, 150 errData.did, 151 errData.reauthMethods, 152 + ); 153 } 154 + return res.json(); 155 } 156 157 async function xrpcResult<T>( 158 method: string, 159 + options?: XrpcOptions, 160 ): Promise<Result<T, ApiError>> { 161 try { 162 + const value = await xrpc<T>(method, options); 163 + return ok(value); 164 } catch (e) { 165 if (e instanceof ApiError) { 166 + return err(e); 167 } 168 + return err( 169 + new ApiError(0, "Unknown", e instanceof Error ? e.message : String(e)), 170 + ); 171 } 172 } 173 174 export interface VerificationMethod { 175 + id: string; 176 + type: string; 177 + publicKeyMultibase: string; 178 } 179 180 + export type { AppPassword, DidDocument, InviteCodeInfo as InviteCode, Session }; 181 + export type { 182 + ConfirmSignupResult, 183 + CreateAccountParams, 184 + CreateAccountResult, 185 + DidType, 186 + VerificationChannel, 187 + }; 188 189 function castSession(raw: unknown): Session { 190 + const s = raw as Record<string, unknown>; 191 return { 192 did: unsafeAsDid(s.did as string), 193 handle: unsafeAsHandle(s.handle as string), ··· 197 preferredChannelVerified: s.preferredChannelVerified as boolean | undefined, 198 isAdmin: s.isAdmin as boolean | undefined, 199 active: s.active as boolean | undefined, 200 + status: s.status as Session["status"], 201 migratedToPds: s.migratedToPds as string | undefined, 202 + migratedAt: s.migratedAt 203 + ? unsafeAsISODate(s.migratedAt as string) 204 + : undefined, 205 accessJwt: unsafeAsAccessToken(s.accessJwt as string), 206 refreshJwt: unsafeAsRefreshToken(s.refreshJwt as string), 207 + }; 208 } 209 210 export const api = { ··· 212 params: CreateAccountParams, 213 byodToken?: string, 214 ): Promise<CreateAccountResult> { 215 + const url = `${API_BASE}/com.atproto.server.createAccount`; 216 const headers: Record<string, string> = { 217 + "Content-Type": "application/json", 218 + }; 219 if (byodToken) { 220 + headers["Authorization"] = `Bearer ${byodToken}`; 221 } 222 const response = await fetch(url, { 223 + method: "POST", 224 headers, 225 body: JSON.stringify({ 226 handle: params.handle, ··· 235 telegramUsername: params.telegramUsername, 236 signalNumber: params.signalNumber, 237 }), 238 + }); 239 + const data = await response.json(); 240 if (!response.ok) { 241 + throw new ApiError(response.status, data.error, data.message); 242 } 243 + return data; 244 }, 245 246 async createAccountWithServiceAuth( 247 serviceAuthToken: string, 248 params: { 249 + did: Did; 250 + handle: Handle; 251 + email: EmailAddress; 252 + password: string; 253 + inviteCode?: string; 254 }, 255 ): Promise<Session> { 256 + const url = `${API_BASE}/com.atproto.server.createAccount`; 257 const response = await fetch(url, { 258 + method: "POST", 259 headers: { 260 + "Content-Type": "application/json", 261 + "Authorization": `Bearer ${serviceAuthToken}`, 262 }, 263 body: JSON.stringify({ 264 did: params.did, ··· 267 password: params.password, 268 inviteCode: params.inviteCode, 269 }), 270 + }); 271 + const data = await response.json(); 272 if (!response.ok) { 273 + throw new ApiError(response.status, data.error, data.message); 274 } 275 + return castSession(data); 276 }, 277 278 confirmSignup( 279 did: Did, 280 verificationCode: string, 281 ): Promise<ConfirmSignupResult> { 282 + return xrpc("com.atproto.server.confirmSignup", { 283 + method: "POST", 284 body: { did, verificationCode }, 285 + }); 286 }, 287 288 resendVerification(did: Did): Promise<{ success: boolean }> { 289 + return xrpc("com.atproto.server.resendVerification", { 290 + method: "POST", 291 body: { did }, 292 + }); 293 }, 294 295 async createSession(identifier: string, password: string): Promise<Session> { 296 + const raw = await xrpc<unknown>("com.atproto.server.createSession", { 297 + method: "POST", 298 body: { identifier, password }, 299 + }); 300 + return castSession(raw); 301 }, 302 303 checkEmailVerified(identifier: string): Promise<{ verified: boolean }> { 304 + return xrpc("_checkEmailVerified", { 305 + method: "POST", 306 body: { identifier }, 307 + }); 308 }, 309 310 async getSession(token: AccessToken): Promise<Session> { 311 + const raw = await xrpc<unknown>("com.atproto.server.getSession", { token }); 312 + return castSession(raw); 313 }, 314 315 async refreshSession(refreshJwt: RefreshToken): Promise<Session> { 316 + const raw = await xrpc<unknown>("com.atproto.server.refreshSession", { 317 + method: "POST", 318 token: refreshJwt, 319 + }); 320 + return castSession(raw); 321 }, 322 323 async deleteSession(token: AccessToken): Promise<void> { 324 + await xrpc("com.atproto.server.deleteSession", { 325 + method: "POST", 326 token, 327 + }); 328 }, 329 330 listAppPasswords(token: AccessToken): Promise<{ passwords: AppPassword[] }> { 331 + return xrpc("com.atproto.server.listAppPasswords", { token }); 332 }, 333 334 createAppPassword( ··· 336 name: string, 337 scopes?: string, 338 ): Promise<CreatedAppPassword> { 339 + return xrpc("com.atproto.server.createAppPassword", { 340 + method: "POST", 341 token, 342 body: { name, scopes }, 343 + }); 344 }, 345 346 async revokeAppPassword(token: AccessToken, name: string): Promise<void> { 347 + await xrpc("com.atproto.server.revokeAppPassword", { 348 + method: "POST", 349 token, 350 body: { name }, 351 + }); 352 }, 353 354 + getAccountInviteCodes( 355 + token: AccessToken, 356 + ): Promise<{ codes: InviteCodeInfo[] }> { 357 + return xrpc("com.atproto.server.getAccountInviteCodes", { token }); 358 }, 359 360 createInviteCode( 361 token: AccessToken, 362 useCount: number = 1, 363 ): Promise<{ code: string }> { 364 + return xrpc("com.atproto.server.createInviteCode", { 365 + method: "POST", 366 token, 367 body: { useCount }, 368 + }); 369 }, 370 371 async requestPasswordReset(email: EmailAddress): Promise<void> { 372 + await xrpc("com.atproto.server.requestPasswordReset", { 373 + method: "POST", 374 body: { email }, 375 + }); 376 }, 377 378 async resetPassword(token: string, password: string): Promise<void> { 379 + await xrpc("com.atproto.server.resetPassword", { 380 + method: "POST", 381 body: { token, password }, 382 + }); 383 }, 384 385 requestEmailUpdate(token: AccessToken): Promise<EmailUpdateResponse> { 386 + return xrpc("com.atproto.server.requestEmailUpdate", { 387 + method: "POST", 388 token, 389 + }); 390 }, 391 392 async updateEmail( ··· 394 email: string, 395 emailToken?: string, 396 ): Promise<void> { 397 + await xrpc("com.atproto.server.updateEmail", { 398 + method: "POST", 399 token, 400 body: { email, token: emailToken }, 401 + }); 402 }, 403 404 async updateHandle(token: AccessToken, handle: Handle): Promise<void> { 405 + await xrpc("com.atproto.identity.updateHandle", { 406 + method: "POST", 407 token, 408 body: { handle }, 409 + }); 410 }, 411 412 async requestAccountDelete(token: AccessToken): Promise<void> { 413 + await xrpc("com.atproto.server.requestAccountDelete", { 414 + method: "POST", 415 token, 416 + }); 417 }, 418 419 async deleteAccount( ··· 421 password: string, 422 deleteToken: string, 423 ): Promise<void> { 424 + await xrpc("com.atproto.server.deleteAccount", { 425 + method: "POST", 426 body: { did, password, token: deleteToken }, 427 + }); 428 }, 429 430 describeServer(): Promise<ServerDescription> { 431 + return xrpc("com.atproto.server.describeServer"); 432 }, 433 434 listRepos(limit?: number): Promise<ListReposResponse> { 435 + const params: Record<string, string> = {}; 436 + if (limit) params.limit = String(limit); 437 + return xrpc("com.atproto.sync.listRepos", { params }); 438 }, 439 440 getNotificationPrefs(token: AccessToken): Promise<NotificationPrefs> { 441 + return xrpc("_account.getNotificationPrefs", { token }); 442 }, 443 444 updateNotificationPrefs(token: AccessToken, prefs: { 445 + preferredChannel?: string; 446 + discordId?: string; 447 + telegramUsername?: string; 448 + signalNumber?: string; 449 }): Promise<SuccessResponse> { 450 + return xrpc("_account.updateNotificationPrefs", { 451 + method: "POST", 452 token, 453 body: prefs, 454 + }); 455 }, 456 457 confirmChannelVerification( ··· 460 identifier: string, 461 code: string, 462 ): Promise<SuccessResponse> { 463 + return xrpc("_account.confirmChannelVerification", { 464 + method: "POST", 465 token, 466 body: { channel, identifier, code }, 467 + }); 468 }, 469 470 + getNotificationHistory( 471 + token: AccessToken, 472 + ): Promise<NotificationHistoryResponse> { 473 + return xrpc("_account.getNotificationHistory", { token }); 474 }, 475 476 getServerStats(token: AccessToken): Promise<ServerStats> { 477 + return xrpc("_admin.getServerStats", { token }); 478 }, 479 480 getServerConfig(): Promise<ServerConfig> { 481 + return xrpc("_server.getConfig"); 482 }, 483 484 updateServerConfig( 485 token: AccessToken, 486 config: { 487 + serverName?: string; 488 + primaryColor?: string; 489 + primaryColorDark?: string; 490 + secondaryColor?: string; 491 + secondaryColorDark?: string; 492 + logoCid?: string; 493 }, 494 ): Promise<SuccessResponse> { 495 + return xrpc("_admin.updateServerConfig", { 496 + method: "POST", 497 token, 498 body: config, 499 + }); 500 }, 501 502 + async uploadBlob( 503 + token: AccessToken, 504 + file: File, 505 + ): Promise<UploadBlobResponse> { 506 + const res = await fetch("/xrpc/com.atproto.repo.uploadBlob", { 507 + method: "POST", 508 headers: { 509 + "Authorization": `Bearer ${token}`, 510 + "Content-Type": file.type, 511 }, 512 body: file, 513 + }); 514 if (!res.ok) { 515 const errData = await res.json().catch(() => ({ 516 + error: "Unknown", 517 message: res.statusText, 518 + })); 519 + throw new ApiError(res.status, errData.error, errData.message); 520 } 521 + return res.json(); 522 }, 523 524 async changePassword( ··· 526 currentPassword: string, 527 newPassword: string, 528 ): Promise<void> { 529 + await xrpc("_account.changePassword", { 530 + method: "POST", 531 token, 532 body: { currentPassword, newPassword }, 533 + }); 534 }, 535 536 removePassword(token: AccessToken): Promise<SuccessResponse> { 537 + return xrpc("_account.removePassword", { 538 + method: "POST", 539 token, 540 + }); 541 }, 542 543 getPasswordStatus(token: AccessToken): Promise<PasswordStatus> { 544 + return xrpc("_account.getPasswordStatus", { token }); 545 }, 546 547 getLegacyLoginPreference(token: AccessToken): Promise<LegacyLoginPreference> { 548 + return xrpc("_account.getLegacyLoginPreference", { token }); 549 }, 550 551 updateLegacyLoginPreference( 552 token: AccessToken, 553 allowLegacyLogin: boolean, 554 ): Promise<UpdateLegacyLoginResponse> { 555 + return xrpc("_account.updateLegacyLoginPreference", { 556 + method: "POST", 557 token, 558 body: { allowLegacyLogin }, 559 + }); 560 }, 561 562 + updateLocale( 563 + token: AccessToken, 564 + preferredLocale: string, 565 + ): Promise<UpdateLocaleResponse> { 566 + return xrpc("_account.updateLocale", { 567 + method: "POST", 568 token, 569 body: { preferredLocale }, 570 + }); 571 }, 572 573 listSessions(token: AccessToken): Promise<ListSessionsResponse> { 574 + return xrpc("_account.listSessions", { token }); 575 }, 576 577 async revokeSession(token: AccessToken, sessionId: string): Promise<void> { 578 + await xrpc("_account.revokeSession", { 579 + method: "POST", 580 token, 581 body: { sessionId }, 582 + }); 583 }, 584 585 revokeAllSessions(token: AccessToken): Promise<{ revokedCount: number }> { 586 + return xrpc("_account.revokeAllSessions", { 587 + method: "POST", 588 token, 589 + }); 590 }, 591 592 searchAccounts(token: AccessToken, options?: { 593 + handle?: string; 594 + cursor?: string; 595 + limit?: number; 596 }): Promise<SearchAccountsResponse> { 597 + const params: Record<string, string> = {}; 598 + if (options?.handle) params.handle = options.handle; 599 + if (options?.cursor) params.cursor = options.cursor; 600 + if (options?.limit) params.limit = String(options.limit); 601 + return xrpc("com.atproto.admin.searchAccounts", { token, params }); 602 }, 603 604 getInviteCodes(token: AccessToken, options?: { 605 + sort?: "recent" | "usage"; 606 + cursor?: string; 607 + limit?: number; 608 }): Promise<GetInviteCodesResponse> { 609 + const params: Record<string, string> = {}; 610 + if (options?.sort) params.sort = options.sort; 611 + if (options?.cursor) params.cursor = options.cursor; 612 + if (options?.limit) params.limit = String(options.limit); 613 + return xrpc("com.atproto.admin.getInviteCodes", { token, params }); 614 }, 615 616 async disableInviteCodes( ··· 618 codes?: string[], 619 accounts?: string[], 620 ): Promise<void> { 621 + await xrpc("com.atproto.admin.disableInviteCodes", { 622 + method: "POST", 623 token, 624 body: { codes, accounts }, 625 + }); 626 }, 627 628 getAccountInfo(token: AccessToken, did: Did): Promise<AccountInfo> { 629 + return xrpc("com.atproto.admin.getAccountInfo", { token, params: { did } }); 630 }, 631 632 async disableAccountInvites(token: AccessToken, account: Did): Promise<void> { 633 + await xrpc("com.atproto.admin.disableAccountInvites", { 634 + method: "POST", 635 token, 636 body: { account }, 637 + }); 638 }, 639 640 async enableAccountInvites(token: AccessToken, account: Did): Promise<void> { 641 + await xrpc("com.atproto.admin.enableAccountInvites", { 642 + method: "POST", 643 token, 644 body: { account }, 645 + }); 646 }, 647 648 async adminDeleteAccount(token: AccessToken, did: Did): Promise<void> { 649 + await xrpc("com.atproto.admin.deleteAccount", { 650 + method: "POST", 651 token, 652 body: { did }, 653 + }); 654 }, 655 656 describeRepo(token: AccessToken, repo: Did): Promise<RepoDescription> { 657 + return xrpc("com.atproto.repo.describeRepo", { 658 token, 659 params: { repo }, 660 + }); 661 }, 662 663 listRecords(token: AccessToken, repo: Did, collection: Nsid, options?: { 664 + limit?: number; 665 + cursor?: string; 666 + reverse?: boolean; 667 }): Promise<ListRecordsResponse> { 668 + const params: Record<string, string> = { repo, collection }; 669 + if (options?.limit) params.limit = String(options.limit); 670 + if (options?.cursor) params.cursor = options.cursor; 671 + if (options?.reverse) params.reverse = "true"; 672 + return xrpc("com.atproto.repo.listRecords", { token, params }); 673 }, 674 675 getRecord( ··· 678 collection: Nsid, 679 rkey: Rkey, 680 ): Promise<RecordResponse> { 681 + return xrpc("com.atproto.repo.getRecord", { 682 token, 683 params: { repo, collection, rkey }, 684 + }); 685 }, 686 687 createRecord( ··· 691 record: unknown, 692 rkey?: Rkey, 693 ): Promise<CreateRecordResponse> { 694 + return xrpc("com.atproto.repo.createRecord", { 695 + method: "POST", 696 token, 697 body: { repo, collection, record, rkey }, 698 + }); 699 }, 700 701 putRecord( ··· 705 rkey: Rkey, 706 record: unknown, 707 ): Promise<CreateRecordResponse> { 708 + return xrpc("com.atproto.repo.putRecord", { 709 + method: "POST", 710 token, 711 body: { repo, collection, rkey, record }, 712 + }); 713 }, 714 715 async deleteRecord( ··· 718 collection: Nsid, 719 rkey: Rkey, 720 ): Promise<void> { 721 + await xrpc("com.atproto.repo.deleteRecord", { 722 + method: "POST", 723 token, 724 body: { repo, collection, rkey }, 725 + }); 726 }, 727 728 getTotpStatus(token: AccessToken): Promise<TotpStatus> { 729 + return xrpc("com.atproto.server.getTotpStatus", { token }); 730 }, 731 732 createTotpSecret(token: AccessToken): Promise<TotpSecret> { 733 + return xrpc("com.atproto.server.createTotpSecret", { 734 + method: "POST", 735 token, 736 + }); 737 }, 738 739 enableTotp(token: AccessToken, code: string): Promise<EnableTotpResponse> { 740 + return xrpc("com.atproto.server.enableTotp", { 741 + method: "POST", 742 token, 743 body: { code }, 744 + }); 745 }, 746 747 disableTotp( ··· 749 password: string, 750 code: string, 751 ): Promise<SuccessResponse> { 752 + return xrpc("com.atproto.server.disableTotp", { 753 + method: "POST", 754 token, 755 body: { password, code }, 756 + }); 757 }, 758 759 regenerateBackupCodes( ··· 761 password: string, 762 code: string, 763 ): Promise<RegenerateBackupCodesResponse> { 764 + return xrpc("com.atproto.server.regenerateBackupCodes", { 765 + method: "POST", 766 token, 767 body: { password, code }, 768 + }); 769 }, 770 771 startPasskeyRegistration( 772 token: AccessToken, 773 friendlyName?: string, 774 ): Promise<StartPasskeyRegistrationResponse> { 775 + return xrpc("com.atproto.server.startPasskeyRegistration", { 776 + method: "POST", 777 token, 778 body: { friendlyName }, 779 + }); 780 }, 781 782 finishPasskeyRegistration( ··· 784 credential: unknown, 785 friendlyName?: string, 786 ): Promise<FinishPasskeyRegistrationResponse> { 787 + return xrpc("com.atproto.server.finishPasskeyRegistration", { 788 + method: "POST", 789 token, 790 body: { credential, friendlyName }, 791 + }); 792 }, 793 794 listPasskeys(token: AccessToken): Promise<ListPasskeysResponse> { 795 + return xrpc("com.atproto.server.listPasskeys", { token }); 796 }, 797 798 async deletePasskey(token: AccessToken, id: string): Promise<void> { 799 + await xrpc("com.atproto.server.deletePasskey", { 800 + method: "POST", 801 token, 802 body: { id }, 803 + }); 804 }, 805 806 async updatePasskey( ··· 808 id: string, 809 friendlyName: string, 810 ): Promise<void> { 811 + await xrpc("com.atproto.server.updatePasskey", { 812 + method: "POST", 813 token, 814 body: { id, friendlyName }, 815 + }); 816 }, 817 818 listTrustedDevices(token: AccessToken): Promise<ListTrustedDevicesResponse> { 819 + return xrpc("_account.listTrustedDevices", { token }); 820 }, 821 822 + revokeTrustedDevice( 823 + token: AccessToken, 824 + deviceId: string, 825 + ): Promise<SuccessResponse> { 826 + return xrpc("_account.revokeTrustedDevice", { 827 + method: "POST", 828 token, 829 body: { deviceId }, 830 + }); 831 }, 832 833 updateTrustedDevice( ··· 835 deviceId: string, 836 friendlyName: string, 837 ): Promise<SuccessResponse> { 838 + return xrpc("_account.updateTrustedDevice", { 839 + method: "POST", 840 token, 841 body: { deviceId, friendlyName }, 842 + }); 843 }, 844 845 getReauthStatus(token: AccessToken): Promise<ReauthStatus> { 846 + return xrpc("_account.getReauthStatus", { token }); 847 }, 848 849 + reauthPassword( 850 + token: AccessToken, 851 + password: string, 852 + ): Promise<ReauthResponse> { 853 + return xrpc("_account.reauthPassword", { 854 + method: "POST", 855 token, 856 body: { password }, 857 + }); 858 }, 859 860 reauthTotp(token: AccessToken, code: string): Promise<ReauthResponse> { 861 + return xrpc("_account.reauthTotp", { 862 + method: "POST", 863 token, 864 body: { code }, 865 + }); 866 }, 867 868 reauthPasskeyStart(token: AccessToken): Promise<ReauthPasskeyStartResponse> { 869 + return xrpc("_account.reauthPasskeyStart", { 870 + method: "POST", 871 token, 872 + }); 873 }, 874 875 + reauthPasskeyFinish( 876 + token: AccessToken, 877 + credential: unknown, 878 + ): Promise<ReauthResponse> { 879 + return xrpc("_account.reauthPasskeyFinish", { 880 + method: "POST", 881 token, 882 body: { credential }, 883 + }); 884 }, 885 886 reserveSigningKey(did?: Did): Promise<ReserveSigningKeyResponse> { 887 + return xrpc("com.atproto.server.reserveSigningKey", { 888 + method: "POST", 889 body: { did }, 890 + }); 891 }, 892 893 + getRecommendedDidCredentials( 894 + token: AccessToken, 895 + ): Promise<RecommendedDidCredentials> { 896 + return xrpc("com.atproto.identity.getRecommendedDidCredentials", { token }); 897 }, 898 899 async activateAccount(token: AccessToken): Promise<void> { 900 + await xrpc("com.atproto.server.activateAccount", { 901 + method: "POST", 902 token, 903 + }); 904 }, 905 906 async createPasskeyAccount(params: { 907 + handle: Handle; 908 + email?: EmailAddress; 909 + inviteCode?: string; 910 + didType?: DidType; 911 + did?: Did; 912 + signingKey?: string; 913 + verificationChannel?: VerificationChannel; 914 + discordId?: string; 915 + telegramUsername?: string; 916 + signalNumber?: string; 917 }, byodToken?: string): Promise<PasskeyAccountCreateResponse> { 918 + const url = `${API_BASE}/_account.createPasskeyAccount`; 919 const headers: Record<string, string> = { 920 + "Content-Type": "application/json", 921 + }; 922 if (byodToken) { 923 + headers["Authorization"] = `Bearer ${byodToken}`; 924 } 925 const res = await fetch(url, { 926 + method: "POST", 927 headers, 928 body: JSON.stringify(params), 929 + }); 930 if (!res.ok) { 931 const errData = await res.json().catch(() => ({ 932 + error: "Unknown", 933 message: res.statusText, 934 + })); 935 + throw new ApiError(res.status, errData.error, errData.message); 936 } 937 + return res.json(); 938 }, 939 940 startPasskeyRegistrationForSetup( ··· 942 setupToken: string, 943 friendlyName?: string, 944 ): Promise<StartPasskeyRegistrationResponse> { 945 + return xrpc("_account.startPasskeyRegistrationForSetup", { 946 + method: "POST", 947 body: { did, setupToken, friendlyName }, 948 + }); 949 }, 950 951 completePasskeySetup( ··· 954 passkeyCredential: unknown, 955 passkeyFriendlyName?: string, 956 ): Promise<CompletePasskeySetupResponse> { 957 + return xrpc("_account.completePasskeySetup", { 958 + method: "POST", 959 body: { did, setupToken, passkeyCredential, passkeyFriendlyName }, 960 + }); 961 }, 962 963 requestPasskeyRecovery(email: EmailAddress): Promise<SuccessResponse> { 964 + return xrpc("_account.requestPasskeyRecovery", { 965 + method: "POST", 966 body: { email }, 967 + }); 968 }, 969 970 recoverPasskeyAccount( ··· 972 recoveryToken: string, 973 newPassword: string, 974 ): Promise<SuccessResponse> { 975 + return xrpc("_account.recoverPasskeyAccount", { 976 + method: "POST", 977 body: { did, recoveryToken, newPassword }, 978 + }); 979 }, 980 981 + verifyMigrationEmail( 982 + token: string, 983 + email: EmailAddress, 984 + ): Promise<VerifyMigrationEmailResponse> { 985 + return xrpc("com.atproto.server.verifyMigrationEmail", { 986 + method: "POST", 987 body: { token, email }, 988 + }); 989 }, 990 991 + resendMigrationVerification( 992 + email: EmailAddress, 993 + ): Promise<ResendMigrationVerificationResponse> { 994 + return xrpc("com.atproto.server.resendMigrationVerification", { 995 + method: "POST", 996 body: { email }, 997 + }); 998 }, 999 1000 verifyToken( ··· 1002 identifier: string, 1003 accessToken?: AccessToken, 1004 ): Promise<VerifyTokenResponse> { 1005 + return xrpc("_account.verifyToken", { 1006 + method: "POST", 1007 body: { token, identifier }, 1008 token: accessToken, 1009 + }); 1010 }, 1011 1012 getDidDocument(token: AccessToken): Promise<DidDocument> { 1013 + return xrpc("_account.getDidDocument", { token }); 1014 }, 1015 1016 updateDidDocument( 1017 token: AccessToken, 1018 params: { 1019 + verificationMethods?: VerificationMethod[]; 1020 + alsoKnownAs?: string[]; 1021 + serviceEndpoint?: string; 1022 }, 1023 ): Promise<SuccessResponse> { 1024 + return xrpc("_account.updateDidDocument", { 1025 + method: "POST", 1026 token, 1027 body: params, 1028 + }); 1029 }, 1030 1031 + async deactivateAccount( 1032 + token: AccessToken, 1033 + deleteAfter?: string, 1034 + ): Promise<void> { 1035 + await xrpc("com.atproto.server.deactivateAccount", { 1036 + method: "POST", 1037 token, 1038 body: { deleteAfter }, 1039 + }); 1040 }, 1041 1042 async getRepo(token: AccessToken, did: Did): Promise<ArrayBuffer> { 1043 + const url = `${API_BASE}/com.atproto.sync.getRepo?did=${ 1044 + encodeURIComponent(did) 1045 + }`; 1046 const res = await fetch(url, { 1047 headers: { Authorization: `Bearer ${token}` }, 1048 + }); 1049 if (!res.ok) { 1050 const errData = await res.json().catch(() => ({ 1051 + error: "Unknown", 1052 message: res.statusText, 1053 + })); 1054 + throw new ApiError(res.status, errData.error, errData.message); 1055 } 1056 + return res.arrayBuffer(); 1057 }, 1058 1059 listBackups(token: AccessToken): Promise<ListBackupsResponse> { 1060 + return xrpc("_backup.listBackups", { token }); 1061 }, 1062 1063 async getBackup(token: AccessToken, id: string): Promise<Blob> { 1064 + const url = `${API_BASE}/_backup.getBackup?id=${encodeURIComponent(id)}`; 1065 const res = await fetch(url, { 1066 headers: { Authorization: `Bearer ${token}` }, 1067 + }); 1068 if (!res.ok) { 1069 const errData = await res.json().catch(() => ({ 1070 + error: "Unknown", 1071 message: res.statusText, 1072 + })); 1073 + throw new ApiError(res.status, errData.error, errData.message); 1074 } 1075 + return res.blob(); 1076 }, 1077 1078 createBackup(token: AccessToken): Promise<CreateBackupResponse> { 1079 + return xrpc("_backup.createBackup", { 1080 + method: "POST", 1081 token, 1082 + }); 1083 }, 1084 1085 async deleteBackup(token: AccessToken, id: string): Promise<void> { 1086 + await xrpc("_backup.deleteBackup", { 1087 + method: "POST", 1088 token, 1089 params: { id }, 1090 + }); 1091 }, 1092 1093 + setBackupEnabled( 1094 + token: AccessToken, 1095 + enabled: boolean, 1096 + ): Promise<SetBackupEnabledResponse> { 1097 + return xrpc("_backup.setEnabled", { 1098 + method: "POST", 1099 token, 1100 body: { enabled }, 1101 + }); 1102 }, 1103 1104 async importRepo(token: AccessToken, car: Uint8Array): Promise<void> { 1105 + const url = `${API_BASE}/com.atproto.repo.importRepo`; 1106 const res = await fetch(url, { 1107 + method: "POST", 1108 headers: { 1109 Authorization: `Bearer ${token}`, 1110 + "Content-Type": "application/vnd.ipld.car", 1111 }, 1112 + body: car as unknown as BodyInit, 1113 + }); 1114 if (!res.ok) { 1115 const errData = await res.json().catch(() => ({ 1116 + error: "Unknown", 1117 message: res.statusText, 1118 + })); 1119 + throw new ApiError(res.status, errData.error, errData.message); 1120 } 1121 }, 1122 + }; 1123 1124 export const typedApi = { 1125 createSession( 1126 identifier: string, 1127 + password: string, 1128 ): Promise<Result<Session, ApiError>> { 1129 + return xrpcResult<Session>("com.atproto.server.createSession", { 1130 + method: "POST", 1131 body: { identifier, password }, 1132 + }).then((r) => r.ok ? ok(castSession(r.value)) : r); 1133 }, 1134 1135 getSession(token: AccessToken): Promise<Result<Session, ApiError>> { 1136 + return xrpcResult<Session>("com.atproto.server.getSession", { token }) 1137 + .then((r) => r.ok ? ok(castSession(r.value)) : r); 1138 }, 1139 1140 refreshSession(refreshJwt: RefreshToken): Promise<Result<Session, ApiError>> { 1141 + return xrpcResult<Session>("com.atproto.server.refreshSession", { 1142 + method: "POST", 1143 token: refreshJwt, 1144 + }).then((r) => r.ok ? ok(castSession(r.value)) : r); 1145 }, 1146 1147 describeServer(): Promise<Result<ServerDescription, ApiError>> { 1148 + return xrpcResult("com.atproto.server.describeServer"); 1149 }, 1150 1151 + listAppPasswords( 1152 + token: AccessToken, 1153 + ): Promise<Result<{ passwords: AppPassword[] }, ApiError>> { 1154 + return xrpcResult("com.atproto.server.listAppPasswords", { token }); 1155 }, 1156 1157 createAppPassword( 1158 token: AccessToken, 1159 name: string, 1160 + scopes?: string, 1161 ): Promise<Result<CreatedAppPassword, ApiError>> { 1162 + return xrpcResult("com.atproto.server.createAppPassword", { 1163 + method: "POST", 1164 token, 1165 body: { name, scopes }, 1166 + }); 1167 }, 1168 1169 + revokeAppPassword( 1170 + token: AccessToken, 1171 + name: string, 1172 + ): Promise<Result<void, ApiError>> { 1173 + return xrpcResult<void>("com.atproto.server.revokeAppPassword", { 1174 + method: "POST", 1175 token, 1176 body: { name }, 1177 + }); 1178 }, 1179 1180 + listSessions( 1181 + token: AccessToken, 1182 + ): Promise<Result<ListSessionsResponse, ApiError>> { 1183 + return xrpcResult("_account.listSessions", { token }); 1184 }, 1185 1186 + revokeSession( 1187 + token: AccessToken, 1188 + sessionId: string, 1189 + ): Promise<Result<void, ApiError>> { 1190 + return xrpcResult<void>("_account.revokeSession", { 1191 + method: "POST", 1192 token, 1193 body: { sessionId }, 1194 + }); 1195 }, 1196 1197 getTotpStatus(token: AccessToken): Promise<Result<TotpStatus, ApiError>> { 1198 + return xrpcResult("com.atproto.server.getTotpStatus", { token }); 1199 }, 1200 1201 createTotpSecret(token: AccessToken): Promise<Result<TotpSecret, ApiError>> { 1202 + return xrpcResult("com.atproto.server.createTotpSecret", { 1203 + method: "POST", 1204 token, 1205 + }); 1206 }, 1207 1208 + enableTotp( 1209 + token: AccessToken, 1210 + code: string, 1211 + ): Promise<Result<EnableTotpResponse, ApiError>> { 1212 + return xrpcResult("com.atproto.server.enableTotp", { 1213 + method: "POST", 1214 token, 1215 body: { code }, 1216 + }); 1217 }, 1218 1219 disableTotp( 1220 token: AccessToken, 1221 password: string, 1222 + code: string, 1223 ): Promise<Result<SuccessResponse, ApiError>> { 1224 + return xrpcResult("com.atproto.server.disableTotp", { 1225 + method: "POST", 1226 token, 1227 body: { password, code }, 1228 + }); 1229 }, 1230 1231 + listPasskeys( 1232 + token: AccessToken, 1233 + ): Promise<Result<ListPasskeysResponse, ApiError>> { 1234 + return xrpcResult("com.atproto.server.listPasskeys", { token }); 1235 }, 1236 1237 + deletePasskey( 1238 + token: AccessToken, 1239 + id: string, 1240 + ): Promise<Result<void, ApiError>> { 1241 + return xrpcResult<void>("com.atproto.server.deletePasskey", { 1242 + method: "POST", 1243 token, 1244 body: { id }, 1245 + }); 1246 }, 1247 1248 + listTrustedDevices( 1249 + token: AccessToken, 1250 + ): Promise<Result<ListTrustedDevicesResponse, ApiError>> { 1251 + return xrpcResult("_account.listTrustedDevices", { token }); 1252 }, 1253 1254 getReauthStatus(token: AccessToken): Promise<Result<ReauthStatus, ApiError>> { 1255 + return xrpcResult("_account.getReauthStatus", { token }); 1256 }, 1257 1258 + getNotificationPrefs( 1259 + token: AccessToken, 1260 + ): Promise<Result<NotificationPrefs, ApiError>> { 1261 + return xrpcResult("_account.getNotificationPrefs", { token }); 1262 }, 1263 1264 + updateHandle( 1265 + token: AccessToken, 1266 + handle: Handle, 1267 + ): Promise<Result<void, ApiError>> { 1268 + return xrpcResult<void>("com.atproto.identity.updateHandle", { 1269 + method: "POST", 1270 token, 1271 body: { handle }, 1272 + }); 1273 }, 1274 1275 + describeRepo( 1276 + token: AccessToken, 1277 + repo: Did, 1278 + ): Promise<Result<RepoDescription, ApiError>> { 1279 + return xrpcResult("com.atproto.repo.describeRepo", { 1280 token, 1281 params: { repo }, 1282 + }); 1283 }, 1284 1285 listRecords( 1286 token: AccessToken, 1287 repo: Did, 1288 collection: Nsid, 1289 + options?: { limit?: number; cursor?: string; reverse?: boolean }, 1290 ): Promise<Result<ListRecordsResponse, ApiError>> { 1291 + const params: Record<string, string> = { repo, collection }; 1292 + if (options?.limit) params.limit = String(options.limit); 1293 + if (options?.cursor) params.cursor = options.cursor; 1294 + if (options?.reverse) params.reverse = "true"; 1295 + return xrpcResult("com.atproto.repo.listRecords", { token, params }); 1296 }, 1297 1298 getRecord( 1299 token: AccessToken, 1300 repo: Did, 1301 collection: Nsid, 1302 + rkey: Rkey, 1303 ): Promise<Result<RecordResponse, ApiError>> { 1304 + return xrpcResult("com.atproto.repo.getRecord", { 1305 token, 1306 params: { repo, collection, rkey }, 1307 + }); 1308 }, 1309 1310 deleteRecord( 1311 token: AccessToken, 1312 repo: Did, 1313 collection: Nsid, 1314 + rkey: Rkey, 1315 ): Promise<Result<void, ApiError>> { 1316 + return xrpcResult<void>("com.atproto.repo.deleteRecord", { 1317 + method: "POST", 1318 token, 1319 body: { repo, collection, rkey }, 1320 + }); 1321 }, 1322 1323 searchAccounts( 1324 token: AccessToken, 1325 + options?: { handle?: string; cursor?: string; limit?: number }, 1326 ): Promise<Result<SearchAccountsResponse, ApiError>> { 1327 + const params: Record<string, string> = {}; 1328 + if (options?.handle) params.handle = options.handle; 1329 + if (options?.cursor) params.cursor = options.cursor; 1330 + if (options?.limit) params.limit = String(options.limit); 1331 + return xrpcResult("com.atproto.admin.searchAccounts", { token, params }); 1332 }, 1333 1334 + getAccountInfo( 1335 + token: AccessToken, 1336 + did: Did, 1337 + ): Promise<Result<AccountInfo, ApiError>> { 1338 + return xrpcResult("com.atproto.admin.getAccountInfo", { 1339 + token, 1340 + params: { did }, 1341 + }); 1342 }, 1343 1344 getServerStats(token: AccessToken): Promise<Result<ServerStats, ApiError>> { 1345 + return xrpcResult("_admin.getServerStats", { token }); 1346 }, 1347 1348 + listBackups( 1349 + token: AccessToken, 1350 + ): Promise<Result<ListBackupsResponse, ApiError>> { 1351 + return xrpcResult("_backup.listBackups", { token }); 1352 }, 1353 1354 + createBackup( 1355 + token: AccessToken, 1356 + ): Promise<Result<CreateBackupResponse, ApiError>> { 1357 + return xrpcResult("_backup.createBackup", { 1358 + method: "POST", 1359 token, 1360 + }); 1361 }, 1362 1363 getDidDocument(token: AccessToken): Promise<Result<DidDocument, ApiError>> { 1364 + return xrpcResult("_account.getDidDocument", { token }); 1365 }, 1366 1367 deleteSession(token: AccessToken): Promise<Result<void, ApiError>> { 1368 + return xrpcResult<void>("com.atproto.server.deleteSession", { 1369 + method: "POST", 1370 token, 1371 + }); 1372 }, 1373 1374 + revokeAllSessions( 1375 + token: AccessToken, 1376 + ): Promise<Result<{ revokedCount: number }, ApiError>> { 1377 + return xrpcResult("_account.revokeAllSessions", { 1378 + method: "POST", 1379 token, 1380 + }); 1381 }, 1382 1383 + getAccountInviteCodes( 1384 + token: AccessToken, 1385 + ): Promise<Result<{ codes: InviteCodeInfo[] }, ApiError>> { 1386 + return xrpcResult("com.atproto.server.getAccountInviteCodes", { token }); 1387 }, 1388 1389 + createInviteCode( 1390 + token: AccessToken, 1391 + useCount: number = 1, 1392 + ): Promise<Result<{ code: string }, ApiError>> { 1393 + return xrpcResult("com.atproto.server.createInviteCode", { 1394 + method: "POST", 1395 token, 1396 body: { useCount }, 1397 + }); 1398 }, 1399 1400 changePassword( 1401 token: AccessToken, 1402 currentPassword: string, 1403 + newPassword: string, 1404 ): Promise<Result<void, ApiError>> { 1405 + return xrpcResult<void>("_account.changePassword", { 1406 + method: "POST", 1407 token, 1408 body: { currentPassword, newPassword }, 1409 + }); 1410 }, 1411 1412 + getPasswordStatus( 1413 + token: AccessToken, 1414 + ): Promise<Result<PasswordStatus, ApiError>> { 1415 + return xrpcResult("_account.getPasswordStatus", { token }); 1416 }, 1417 1418 getServerConfig(): Promise<Result<ServerConfig, ApiError>> { 1419 + return xrpcResult("_server.getConfig"); 1420 }, 1421 1422 + getLegacyLoginPreference( 1423 + token: AccessToken, 1424 + ): Promise<Result<LegacyLoginPreference, ApiError>> { 1425 + return xrpcResult("_account.getLegacyLoginPreference", { token }); 1426 }, 1427 1428 updateLegacyLoginPreference( 1429 token: AccessToken, 1430 + allowLegacyLogin: boolean, 1431 ): Promise<Result<UpdateLegacyLoginResponse, ApiError>> { 1432 + return xrpcResult("_account.updateLegacyLoginPreference", { 1433 + method: "POST", 1434 token, 1435 body: { allowLegacyLogin }, 1436 + }); 1437 }, 1438 1439 + getNotificationHistory( 1440 + token: AccessToken, 1441 + ): Promise<Result<NotificationHistoryResponse, ApiError>> { 1442 + return xrpcResult("_account.getNotificationHistory", { token }); 1443 }, 1444 1445 updateNotificationPrefs( 1446 token: AccessToken, 1447 prefs: { 1448 + preferredChannel?: string; 1449 + discordId?: string; 1450 + telegramUsername?: string; 1451 + signalNumber?: string; 1452 + }, 1453 ): Promise<Result<SuccessResponse, ApiError>> { 1454 + return xrpcResult("_account.updateNotificationPrefs", { 1455 + method: "POST", 1456 token, 1457 body: prefs, 1458 + }); 1459 }, 1460 1461 + revokeTrustedDevice( 1462 + token: AccessToken, 1463 + deviceId: string, 1464 + ): Promise<Result<SuccessResponse, ApiError>> { 1465 + return xrpcResult("_account.revokeTrustedDevice", { 1466 + method: "POST", 1467 token, 1468 body: { deviceId }, 1469 + }); 1470 }, 1471 1472 updateTrustedDevice( 1473 token: AccessToken, 1474 deviceId: string, 1475 + friendlyName: string, 1476 ): Promise<Result<SuccessResponse, ApiError>> { 1477 + return xrpcResult("_account.updateTrustedDevice", { 1478 + method: "POST", 1479 token, 1480 body: { deviceId, friendlyName }, 1481 + }); 1482 }, 1483 1484 + reauthPassword( 1485 + token: AccessToken, 1486 + password: string, 1487 + ): Promise<Result<ReauthResponse, ApiError>> { 1488 + return xrpcResult("_account.reauthPassword", { 1489 + method: "POST", 1490 token, 1491 body: { password }, 1492 + }); 1493 }, 1494 1495 + reauthTotp( 1496 + token: AccessToken, 1497 + code: string, 1498 + ): Promise<Result<ReauthResponse, ApiError>> { 1499 + return xrpcResult("_account.reauthTotp", { 1500 + method: "POST", 1501 token, 1502 body: { code }, 1503 + }); 1504 }, 1505 1506 + reauthPasskeyStart( 1507 + token: AccessToken, 1508 + ): Promise<Result<ReauthPasskeyStartResponse, ApiError>> { 1509 + return xrpcResult("_account.reauthPasskeyStart", { 1510 + method: "POST", 1511 token, 1512 + }); 1513 }, 1514 1515 + reauthPasskeyFinish( 1516 + token: AccessToken, 1517 + credential: unknown, 1518 + ): Promise<Result<ReauthResponse, ApiError>> { 1519 + return xrpcResult("_account.reauthPasskeyFinish", { 1520 + method: "POST", 1521 token, 1522 body: { credential }, 1523 + }); 1524 }, 1525 1526 + confirmSignup( 1527 + did: Did, 1528 + verificationCode: string, 1529 + ): Promise<Result<ConfirmSignupResult, ApiError>> { 1530 + return xrpcResult("com.atproto.server.confirmSignup", { 1531 + method: "POST", 1532 body: { did, verificationCode }, 1533 + }); 1534 }, 1535 1536 + resendVerification( 1537 + did: Did, 1538 + ): Promise<Result<{ success: boolean }, ApiError>> { 1539 + return xrpcResult("com.atproto.server.resendVerification", { 1540 + method: "POST", 1541 body: { did }, 1542 + }); 1543 }, 1544 1545 + requestEmailUpdate( 1546 + token: AccessToken, 1547 + ): Promise<Result<EmailUpdateResponse, ApiError>> { 1548 + return xrpcResult("com.atproto.server.requestEmailUpdate", { 1549 + method: "POST", 1550 token, 1551 + }); 1552 }, 1553 1554 + updateEmail( 1555 + token: AccessToken, 1556 + email: string, 1557 + emailToken?: string, 1558 + ): Promise<Result<void, ApiError>> { 1559 + return xrpcResult<void>("com.atproto.server.updateEmail", { 1560 + method: "POST", 1561 token, 1562 body: { email, token: emailToken }, 1563 + }); 1564 }, 1565 1566 requestAccountDelete(token: AccessToken): Promise<Result<void, ApiError>> { 1567 + return xrpcResult<void>("com.atproto.server.requestAccountDelete", { 1568 + method: "POST", 1569 token, 1570 + }); 1571 }, 1572 1573 + deleteAccount( 1574 + did: Did, 1575 + password: string, 1576 + deleteToken: string, 1577 + ): Promise<Result<void, ApiError>> { 1578 + return xrpcResult<void>("com.atproto.server.deleteAccount", { 1579 + method: "POST", 1580 body: { did, password, token: deleteToken }, 1581 + }); 1582 }, 1583 1584 updateDidDocument( 1585 token: AccessToken, 1586 params: { 1587 + verificationMethods?: VerificationMethod[]; 1588 + alsoKnownAs?: string[]; 1589 + serviceEndpoint?: string; 1590 + }, 1591 ): Promise<Result<SuccessResponse, ApiError>> { 1592 + return xrpcResult("_account.updateDidDocument", { 1593 + method: "POST", 1594 token, 1595 body: params, 1596 + }); 1597 }, 1598 1599 + deactivateAccount( 1600 + token: AccessToken, 1601 + deleteAfter?: string, 1602 + ): Promise<Result<void, ApiError>> { 1603 + return xrpcResult<void>("com.atproto.server.deactivateAccount", { 1604 + method: "POST", 1605 token, 1606 body: { deleteAfter }, 1607 + }); 1608 }, 1609 1610 activateAccount(token: AccessToken): Promise<Result<void, ApiError>> { 1611 + return xrpcResult<void>("com.atproto.server.activateAccount", { 1612 + method: "POST", 1613 token, 1614 + }); 1615 }, 1616 1617 + setBackupEnabled( 1618 + token: AccessToken, 1619 + enabled: boolean, 1620 + ): Promise<Result<SetBackupEnabledResponse, ApiError>> { 1621 + return xrpcResult("_backup.setEnabled", { 1622 + method: "POST", 1623 token, 1624 body: { enabled }, 1625 + }); 1626 }, 1627 1628 + deleteBackup( 1629 + token: AccessToken, 1630 + id: string, 1631 + ): Promise<Result<void, ApiError>> { 1632 + return xrpcResult<void>("_backup.deleteBackup", { 1633 + method: "POST", 1634 token, 1635 params: { id }, 1636 + }); 1637 }, 1638 1639 createRecord( ··· 1641 repo: Did, 1642 collection: Nsid, 1643 record: unknown, 1644 + rkey?: Rkey, 1645 ): Promise<Result<CreateRecordResponse, ApiError>> { 1646 + return xrpcResult("com.atproto.repo.createRecord", { 1647 + method: "POST", 1648 token, 1649 body: { repo, collection, record, rkey }, 1650 + }); 1651 }, 1652 1653 putRecord( ··· 1655 repo: Did, 1656 collection: Nsid, 1657 rkey: Rkey, 1658 + record: unknown, 1659 ): Promise<Result<CreateRecordResponse, ApiError>> { 1660 + return xrpcResult("com.atproto.repo.putRecord", { 1661 + method: "POST", 1662 token, 1663 body: { repo, collection, rkey, record }, 1664 + }); 1665 }, 1666 1667 getInviteCodes( 1668 token: AccessToken, 1669 + options?: { sort?: "recent" | "usage"; cursor?: string; limit?: number }, 1670 ): Promise<Result<GetInviteCodesResponse, ApiError>> { 1671 + const params: Record<string, string> = {}; 1672 + if (options?.sort) params.sort = options.sort; 1673 + if (options?.cursor) params.cursor = options.cursor; 1674 + if (options?.limit) params.limit = String(options.limit); 1675 + return xrpcResult("com.atproto.admin.getInviteCodes", { token, params }); 1676 }, 1677 1678 + disableAccountInvites( 1679 + token: AccessToken, 1680 + account: Did, 1681 + ): Promise<Result<void, ApiError>> { 1682 + return xrpcResult<void>("com.atproto.admin.disableAccountInvites", { 1683 + method: "POST", 1684 token, 1685 body: { account }, 1686 + }); 1687 }, 1688 1689 + enableAccountInvites( 1690 + token: AccessToken, 1691 + account: Did, 1692 + ): Promise<Result<void, ApiError>> { 1693 + return xrpcResult<void>("com.atproto.admin.enableAccountInvites", { 1694 + method: "POST", 1695 token, 1696 body: { account }, 1697 + }); 1698 }, 1699 1700 + adminDeleteAccount( 1701 + token: AccessToken, 1702 + did: Did, 1703 + ): Promise<Result<void, ApiError>> { 1704 + return xrpcResult<void>("com.atproto.admin.deleteAccount", { 1705 + method: "POST", 1706 token, 1707 body: { did }, 1708 + }); 1709 }, 1710 1711 startPasskeyRegistration( 1712 token: AccessToken, 1713 + friendlyName?: string, 1714 ): Promise<Result<StartPasskeyRegistrationResponse, ApiError>> { 1715 + return xrpcResult("com.atproto.server.startPasskeyRegistration", { 1716 + method: "POST", 1717 token, 1718 body: { friendlyName }, 1719 + }); 1720 }, 1721 1722 finishPasskeyRegistration( 1723 token: AccessToken, 1724 credential: unknown, 1725 + friendlyName?: string, 1726 ): Promise<Result<FinishPasskeyRegistrationResponse, ApiError>> { 1727 + return xrpcResult("com.atproto.server.finishPasskeyRegistration", { 1728 + method: "POST", 1729 token, 1730 body: { credential, friendlyName }, 1731 + }); 1732 }, 1733 1734 updatePasskey( 1735 token: AccessToken, 1736 id: string, 1737 + friendlyName: string, 1738 ): Promise<Result<void, ApiError>> { 1739 + return xrpcResult<void>("com.atproto.server.updatePasskey", { 1740 + method: "POST", 1741 token, 1742 body: { id, friendlyName }, 1743 + }); 1744 }, 1745 1746 regenerateBackupCodes( 1747 token: AccessToken, 1748 password: string, 1749 + code: string, 1750 ): Promise<Result<RegenerateBackupCodesResponse, ApiError>> { 1751 + return xrpcResult("com.atproto.server.regenerateBackupCodes", { 1752 + method: "POST", 1753 token, 1754 body: { password, code }, 1755 + }); 1756 }, 1757 1758 + updateLocale( 1759 + token: AccessToken, 1760 + preferredLocale: string, 1761 + ): Promise<Result<UpdateLocaleResponse, ApiError>> { 1762 + return xrpcResult("_account.updateLocale", { 1763 + method: "POST", 1764 token, 1765 body: { preferredLocale }, 1766 + }); 1767 }, 1768 1769 confirmChannelVerification( 1770 token: AccessToken, 1771 channel: string, 1772 identifier: string, 1773 + code: string, 1774 ): Promise<Result<SuccessResponse, ApiError>> { 1775 + return xrpcResult("_account.confirmChannelVerification", { 1776 + method: "POST", 1777 token, 1778 body: { channel, identifier, code }, 1779 + }); 1780 }, 1781 1782 + removePassword( 1783 + token: AccessToken, 1784 + ): Promise<Result<SuccessResponse, ApiError>> { 1785 + return xrpcResult("_account.removePassword", { 1786 + method: "POST", 1787 token, 1788 + }); 1789 }, 1790 + };
+104 -78
frontend/src/lib/auth.svelte.ts
··· 1 import { 2 api, 3 ApiError, 4 - typedApi, 5 type CreateAccountParams, 6 type CreateAccountResult, 7 - } from "./api"; 8 - import type { Session } from "./types/api"; 9 import { 10 type Did, 11 type Handle, 12 - type AccessToken, 13 type RefreshToken, 14 unsafeAsDid, 15 unsafeAsHandle, 16 - unsafeAsAccessToken, 17 unsafeAsRefreshToken, 18 - } from "./types/branded"; 19 - import { type Result, ok, err, isOk, isErr, map } from "./types/result"; 20 - import { assertNever } from "./types/exhaustive"; 21 import { 22 checkForOAuthCallback, 23 clearOAuthCallbackParams, 24 handleOAuthCallback, 25 refreshOAuthToken, 26 startOAuthLogin, 27 - } from "./oauth"; 28 - import { setLocale, type SupportedLocale } from "./i18n"; 29 30 const STORAGE_KEY = "tranquil_pds_session"; 31 const ACCOUNTS_KEY = "tranquil_pds_accounts"; ··· 64 65 export type AuthState = 66 | { 67 - readonly kind: "unauthenticated"; 68 - readonly savedAccounts: readonly SavedAccount[]; 69 - } 70 | { 71 - readonly kind: "loading"; 72 - readonly savedAccounts: readonly SavedAccount[]; 73 - readonly previousSession: Session | null; 74 - } 75 | { 76 - readonly kind: "authenticated"; 77 - readonly session: Session; 78 - readonly savedAccounts: readonly SavedAccount[]; 79 - } 80 | { 81 - readonly kind: "error"; 82 - readonly error: AuthError; 83 - readonly savedAccounts: readonly SavedAccount[]; 84 - }; 85 86 function createUnauthenticated( 87 savedAccounts: readonly SavedAccount[], ··· 170 } 171 const accounts: SavedAccount[] = parsed 172 .filter( 173 - (a): a is { did: string; handle: string; accessJwt: string; refreshJwt: string } => 174 typeof a === "object" && 175 a !== null && 176 typeof a.did === "string" && ··· 272 const currentSession = state.current.session; 273 try { 274 const tokens = await refreshOAuthToken(currentSession.refreshJwt); 275 - const sessionInfo = await api.getSession(tokens.access_token); 276 const session: Session = { 277 ...sessionInfo, 278 - accessJwt: tokens.access_token, 279 - refreshJwt: tokens.refresh_token || currentSession.refreshJwt, 280 }; 281 setAuthenticated(session); 282 return session.accessJwt; ··· 285 } 286 } 287 288 - import { setTokenRefreshCallback } from "./api"; 289 290 export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> { 291 setTokenRefreshCallback(tryRefreshToken); ··· 300 oauthCallback.code, 301 oauthCallback.state, 302 ); 303 - const sessionInfo = await api.getSession(tokens.access_token); 304 const session: Session = { 305 ...sessionInfo, 306 - accessJwt: tokens.access_token, 307 - refreshJwt: tokens.refresh_token || "", 308 }; 309 setAuthenticated(session); 310 - applyLocaleFromSession(sessionInfo); 311 return { oauthLoginCompleted: true }; 312 } catch (e) { 313 - setError({ type: "oauth", message: e instanceof Error ? e.message : "OAuth login failed" }); 314 return { oauthLoginCompleted: false }; 315 } 316 } ··· 318 const stored = loadSessionFromStorage(); 319 if (stored) { 320 try { 321 - const sessionInfo = await api.getSession(stored.accessJwt); 322 const session: Session = { 323 ...sessionInfo, 324 - accessJwt: stored.accessJwt, 325 - refreshJwt: stored.refreshJwt, 326 }; 327 setAuthenticated(session); 328 - applyLocaleFromSession(sessionInfo); 329 } catch (e) { 330 if (e instanceof ApiError && e.status === 401) { 331 try { 332 const tokens = await refreshOAuthToken(stored.refreshJwt); 333 - const sessionInfo = await api.getSession(tokens.access_token); 334 const session: Session = { 335 ...sessionInfo, 336 - accessJwt: tokens.access_token, 337 - refreshJwt: tokens.refresh_token || stored.refreshJwt, 338 }; 339 setAuthenticated(session); 340 - applyLocaleFromSession(sessionInfo); 341 } catch (refreshError) { 342 console.error("Token refresh failed during init:", refreshError); 343 setUnauthenticated(); ··· 359 password: string, 360 ): Promise<Result<Session, AuthError>> { 361 const currentState = state.current; 362 - const previousSession = 363 - currentState.kind === "authenticated" ? currentState.session : null; 364 setLoading(previousSession); 365 366 const result = await typedApi.createSession(identifier, password); ··· 398 } 399 400 export async function confirmSignup( 401 - did: string, 402 verificationCode: string, 403 ): Promise<Result<Session, AuthError>> { 404 setLoading(); 405 try { 406 const result = await api.confirmSignup(did, verificationCode); 407 - const session: Session = { 408 - did: result.did, 409 - handle: result.handle, 410 - accessJwt: result.accessJwt, 411 - refreshJwt: result.refreshJwt, 412 - email: result.email, 413 - emailConfirmed: result.emailConfirmed, 414 - preferredChannel: result.preferredChannel, 415 - preferredChannelVerified: result.preferredChannelVerified, 416 - }; 417 - setAuthenticated(session); 418 - return ok(session); 419 } catch (e) { 420 const error = toAuthError(e); 421 setError(error); ··· 424 } 425 426 export async function resendVerification( 427 - did: string, 428 ): Promise<Result<void, AuthError>> { 429 try { 430 await api.resendVerification(did); ··· 441 refreshJwt: string; 442 }): void { 443 const newSession: Session = { 444 - did: session.did, 445 - handle: session.handle, 446 - accessJwt: session.accessJwt, 447 - refreshJwt: session.refreshJwt, 448 }; 449 setAuthenticated(newSession); 450 } ··· 483 setLoading(); 484 485 try { 486 - const sessionInfo = await api.getSession(account.accessJwt as string); 487 const session: Session = { 488 ...sessionInfo, 489 - accessJwt: account.accessJwt as string, 490 - refreshJwt: account.refreshJwt as string, 491 }; 492 setAuthenticated(session); 493 return ok(session); 494 } catch (e) { 495 if (e instanceof ApiError && e.status === 401) { 496 try { 497 - const tokens = await refreshOAuthToken(account.refreshJwt as string); 498 - const sessionInfo = await api.getSession(tokens.access_token); 499 const session: Session = { 500 ...sessionInfo, 501 - accessJwt: tokens.access_token, 502 - refreshJwt: tokens.refresh_token || (account.refreshJwt as string), 503 }; 504 setAuthenticated(session); 505 return ok(session); ··· 555 556 export function getToken(): AccessToken | null { 557 if (state.current.kind === "authenticated") { 558 - return unsafeAsAccessToken(state.current.session.accessJwt); 559 } 560 return null; 561 } ··· 565 const currentSession = state.current.session; 566 try { 567 await api.getSession(currentSession.accessJwt); 568 - return unsafeAsAccessToken(currentSession.accessJwt); 569 } catch (e) { 570 if (e instanceof ApiError && e.status === 401) { 571 try { 572 const tokens = await refreshOAuthToken(currentSession.refreshJwt); 573 - const sessionInfo = await api.getSession(tokens.access_token); 574 const session: Session = { 575 ...sessionInfo, 576 - accessJwt: tokens.access_token, 577 - refreshJwt: tokens.refresh_token || currentSession.refreshJwt, 578 }; 579 setAuthenticated(session); 580 - return unsafeAsAccessToken(session.accessJwt); 581 } catch { 582 return null; 583 } ··· 604 605 export function matchAuthState<T>(handlers: { 606 unauthenticated: (accounts: readonly SavedAccount[]) => T; 607 - loading: (accounts: readonly SavedAccount[], previousSession: Session | null) => T; 608 authenticated: (session: Session, accounts: readonly SavedAccount[]) => T; 609 error: (error: AuthError, accounts: readonly SavedAccount[]) => T; 610 }): T { ··· 633 if (newState.loading) { 634 setState(createLoading(accounts, newState.session)); 635 } else if (newState.error) { 636 - setState(createError({ type: "unknown", message: newState.error }, accounts)); 637 } else if (newState.session) { 638 setState(createAuthenticated(newState.session, accounts)); 639 } else {
··· 1 import { 2 api, 3 ApiError, 4 type CreateAccountParams, 5 type CreateAccountResult, 6 + typedApi, 7 + } from "./api.ts"; 8 + import type { Session } from "./types/api.ts"; 9 import { 10 + type AccessToken, 11 type Did, 12 type Handle, 13 type RefreshToken, 14 + unsafeAsAccessToken, 15 unsafeAsDid, 16 unsafeAsHandle, 17 unsafeAsRefreshToken, 18 + } from "./types/branded.ts"; 19 + import { err, isErr, isOk, ok, type Result } from "./types/result.ts"; 20 + import { assertNever } from "./types/exhaustive.ts"; 21 import { 22 checkForOAuthCallback, 23 clearOAuthCallbackParams, 24 handleOAuthCallback, 25 refreshOAuthToken, 26 startOAuthLogin, 27 + } from "./oauth.ts"; 28 + import { setLocale, type SupportedLocale } from "./i18n.ts"; 29 30 const STORAGE_KEY = "tranquil_pds_session"; 31 const ACCOUNTS_KEY = "tranquil_pds_accounts"; ··· 64 65 export type AuthState = 66 | { 67 + readonly kind: "unauthenticated"; 68 + readonly savedAccounts: readonly SavedAccount[]; 69 + } 70 | { 71 + readonly kind: "loading"; 72 + readonly savedAccounts: readonly SavedAccount[]; 73 + readonly previousSession: Session | null; 74 + } 75 | { 76 + readonly kind: "authenticated"; 77 + readonly session: Session; 78 + readonly savedAccounts: readonly SavedAccount[]; 79 + } 80 | { 81 + readonly kind: "error"; 82 + readonly error: AuthError; 83 + readonly savedAccounts: readonly SavedAccount[]; 84 + }; 85 86 function createUnauthenticated( 87 savedAccounts: readonly SavedAccount[], ··· 170 } 171 const accounts: SavedAccount[] = parsed 172 .filter( 173 + ( 174 + a, 175 + ): a is { 176 + did: string; 177 + handle: string; 178 + accessJwt: string; 179 + refreshJwt: string; 180 + } => 181 typeof a === "object" && 182 a !== null && 183 typeof a.did === "string" && ··· 279 const currentSession = state.current.session; 280 try { 281 const tokens = await refreshOAuthToken(currentSession.refreshJwt); 282 + const sessionInfo = await api.getSession( 283 + unsafeAsAccessToken(tokens.access_token), 284 + ); 285 const session: Session = { 286 ...sessionInfo, 287 + accessJwt: unsafeAsAccessToken(tokens.access_token), 288 + refreshJwt: tokens.refresh_token 289 + ? unsafeAsRefreshToken(tokens.refresh_token) 290 + : currentSession.refreshJwt, 291 }; 292 setAuthenticated(session); 293 return session.accessJwt; ··· 296 } 297 } 298 299 + import { setTokenRefreshCallback } from "./api.ts"; 300 301 export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> { 302 setTokenRefreshCallback(tryRefreshToken); ··· 311 oauthCallback.code, 312 oauthCallback.state, 313 ); 314 + const sessionInfo = await api.getSession( 315 + unsafeAsAccessToken(tokens.access_token), 316 + ); 317 const session: Session = { 318 ...sessionInfo, 319 + accessJwt: unsafeAsAccessToken(tokens.access_token), 320 + refreshJwt: unsafeAsRefreshToken(tokens.refresh_token || ""), 321 }; 322 setAuthenticated(session); 323 + applyLocaleFromSession(session); 324 return { oauthLoginCompleted: true }; 325 } catch (e) { 326 + setError({ 327 + type: "oauth", 328 + message: e instanceof Error ? e.message : "OAuth login failed", 329 + }); 330 return { oauthLoginCompleted: false }; 331 } 332 } ··· 334 const stored = loadSessionFromStorage(); 335 if (stored) { 336 try { 337 + const sessionInfo = await api.getSession( 338 + unsafeAsAccessToken(stored.accessJwt), 339 + ); 340 const session: Session = { 341 ...sessionInfo, 342 + accessJwt: unsafeAsAccessToken(stored.accessJwt), 343 + refreshJwt: unsafeAsRefreshToken(stored.refreshJwt), 344 }; 345 setAuthenticated(session); 346 + applyLocaleFromSession(session); 347 } catch (e) { 348 if (e instanceof ApiError && e.status === 401) { 349 try { 350 const tokens = await refreshOAuthToken(stored.refreshJwt); 351 + const sessionInfo = await api.getSession( 352 + unsafeAsAccessToken(tokens.access_token), 353 + ); 354 const session: Session = { 355 ...sessionInfo, 356 + accessJwt: unsafeAsAccessToken(tokens.access_token), 357 + refreshJwt: tokens.refresh_token 358 + ? unsafeAsRefreshToken(tokens.refresh_token) 359 + : unsafeAsRefreshToken(stored.refreshJwt), 360 }; 361 setAuthenticated(session); 362 + applyLocaleFromSession(session); 363 } catch (refreshError) { 364 console.error("Token refresh failed during init:", refreshError); 365 setUnauthenticated(); ··· 381 password: string, 382 ): Promise<Result<Session, AuthError>> { 383 const currentState = state.current; 384 + const previousSession = currentState.kind === "authenticated" 385 + ? currentState.session 386 + : null; 387 setLoading(previousSession); 388 389 const result = await typedApi.createSession(identifier, password); ··· 421 } 422 423 export async function confirmSignup( 424 + did: Did, 425 verificationCode: string, 426 ): Promise<Result<Session, AuthError>> { 427 setLoading(); 428 try { 429 const result = await api.confirmSignup(did, verificationCode); 430 + setAuthenticated(result); 431 + return ok(result); 432 } catch (e) { 433 const error = toAuthError(e); 434 setError(error); ··· 437 } 438 439 export async function resendVerification( 440 + did: Did, 441 ): Promise<Result<void, AuthError>> { 442 try { 443 await api.resendVerification(did); ··· 454 refreshJwt: string; 455 }): void { 456 const newSession: Session = { 457 + did: unsafeAsDid(session.did), 458 + handle: unsafeAsHandle(session.handle), 459 + accessJwt: unsafeAsAccessToken(session.accessJwt), 460 + refreshJwt: unsafeAsRefreshToken(session.refreshJwt), 461 }; 462 setAuthenticated(newSession); 463 } ··· 496 setLoading(); 497 498 try { 499 + const sessionInfo = await api.getSession(account.accessJwt); 500 const session: Session = { 501 ...sessionInfo, 502 + accessJwt: account.accessJwt, 503 + refreshJwt: account.refreshJwt, 504 }; 505 setAuthenticated(session); 506 return ok(session); 507 } catch (e) { 508 if (e instanceof ApiError && e.status === 401) { 509 try { 510 + const tokens = await refreshOAuthToken(account.refreshJwt); 511 + const sessionInfo = await api.getSession( 512 + unsafeAsAccessToken(tokens.access_token), 513 + ); 514 const session: Session = { 515 ...sessionInfo, 516 + accessJwt: unsafeAsAccessToken(tokens.access_token), 517 + refreshJwt: tokens.refresh_token 518 + ? unsafeAsRefreshToken(tokens.refresh_token) 519 + : account.refreshJwt, 520 }; 521 setAuthenticated(session); 522 return ok(session); ··· 572 573 export function getToken(): AccessToken | null { 574 if (state.current.kind === "authenticated") { 575 + return state.current.session.accessJwt; 576 } 577 return null; 578 } ··· 582 const currentSession = state.current.session; 583 try { 584 await api.getSession(currentSession.accessJwt); 585 + return currentSession.accessJwt; 586 } catch (e) { 587 if (e instanceof ApiError && e.status === 401) { 588 try { 589 const tokens = await refreshOAuthToken(currentSession.refreshJwt); 590 + const sessionInfo = await api.getSession( 591 + unsafeAsAccessToken(tokens.access_token), 592 + ); 593 const session: Session = { 594 ...sessionInfo, 595 + accessJwt: unsafeAsAccessToken(tokens.access_token), 596 + refreshJwt: tokens.refresh_token 597 + ? unsafeAsRefreshToken(tokens.refresh_token) 598 + : currentSession.refreshJwt, 599 }; 600 setAuthenticated(session); 601 + return session.accessJwt; 602 } catch { 603 return null; 604 } ··· 625 626 export function matchAuthState<T>(handlers: { 627 unauthenticated: (accounts: readonly SavedAccount[]) => T; 628 + loading: ( 629 + accounts: readonly SavedAccount[], 630 + previousSession: Session | null, 631 + ) => T; 632 authenticated: (session: Session, accounts: readonly SavedAccount[]) => T; 633 error: (error: AuthError, accounts: readonly SavedAccount[]) => T; 634 }): T { ··· 657 if (newState.loading) { 658 setState(createLoading(accounts, newState.session)); 659 } else if (newState.error) { 660 + setState( 661 + createError({ type: "unknown", message: newState.error }, accounts), 662 + ); 663 } else if (newState.session) { 664 setState(createAuthenticated(newState.session, accounts)); 665 } else {
+7 -4
frontend/src/lib/crypto.ts
··· 11 } 12 13 export function generateKeypair(): Keypair { 14 - const privateKey = secp.utils.randomPrivateKey(); 15 const publicKey = secp.getPublicKey(privateKey, true); 16 17 const multicodecKey = new Uint8Array( ··· 35 const bytes = typeof data === "string" 36 ? new TextEncoder().encode(data) 37 : data; 38 - const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('') 39 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); 40 } 41 ··· 67 const msgBytes = new TextEncoder().encode(message); 68 const hashBuffer = await crypto.subtle.digest("SHA-256", msgBytes); 69 const msgHash = new Uint8Array(hashBuffer); 70 - const signature = await secp.signAsync(msgHash, privateKey); 71 - const sigBytes = signature.toCompactRawBytes(); 72 const signatureEncoded = base64UrlEncode(sigBytes); 73 74 return `${message}.${signatureEncoded}`;
··· 11 } 12 13 export function generateKeypair(): Keypair { 14 + const privateKey = secp.utils.randomSecretKey(); 15 const publicKey = secp.getPublicKey(privateKey, true); 16 17 const multicodecKey = new Uint8Array( ··· 35 const bytes = typeof data === "string" 36 ? new TextEncoder().encode(data) 37 : data; 38 + const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join( 39 + "", 40 + ); 41 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); 42 } 43 ··· 69 const msgBytes = new TextEncoder().encode(message); 70 const hashBuffer = await crypto.subtle.digest("SHA-256", msgBytes); 71 const msgHash = new Uint8Array(hashBuffer); 72 + const sigBytes = await secp.signAsync(msgHash, privateKey, { 73 + prehash: false, 74 + }); 75 const signatureEncoded = base64UrlEncode(sigBytes); 76 77 return `${message}.${signatureEncoded}`;
+11 -6
frontend/src/lib/migration/atproto-client.ts
··· 14 ServerDescription, 15 Session, 16 StartPasskeyRegistrationResponse, 17 - } from "./types"; 18 19 function apiLog( 20 method: string, ··· 101 let requestBody: BodyInit | undefined; 102 if (rawBody) { 103 headers["Content-Type"] = contentType ?? "application/octet-stream"; 104 - requestBody = rawBody; 105 } else if (body) { 106 headers["Content-Type"] = "application/json"; 107 requestBody = JSON.stringify(body); ··· 231 did: string, 232 cid: string, 233 ): Promise<{ data: Uint8Array; contentType: string }> { 234 - const url = `${this.baseUrl}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; 235 const headers: Record<string, string> = {}; 236 if (this.accessToken) { 237 headers["Authorization"] = `Bearer ${this.accessToken}`; ··· 244 })); 245 throw new Error(err.message || err.error || res.statusText); 246 } 247 - const contentType = res.headers.get("content-type") || "application/octet-stream"; 248 const data = new Uint8Array(await res.arrayBuffer()); 249 return { data, contentType }; 250 } ··· 600 601 export function base64UrlEncode(buffer: Uint8Array | ArrayBuffer): string { 602 const bytes = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : buffer; 603 - const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('') 604 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace( 605 /=+$/, 606 "", ··· 632 id: base64UrlDecode(cred.id as string), 633 }), 634 ), 635 - } as PublicKeyCredentialCreationOptions; 636 } 637 638 async function computeAccessTokenHash(accessToken: string): Promise<string> {
··· 14 ServerDescription, 15 Session, 16 StartPasskeyRegistrationResponse, 17 + } from "./types.ts"; 18 19 function apiLog( 20 method: string, ··· 101 let requestBody: BodyInit | undefined; 102 if (rawBody) { 103 headers["Content-Type"] = contentType ?? "application/octet-stream"; 104 + requestBody = rawBody as BodyInit; 105 } else if (body) { 106 headers["Content-Type"] = "application/json"; 107 requestBody = JSON.stringify(body); ··· 231 did: string, 232 cid: string, 233 ): Promise<{ data: Uint8Array; contentType: string }> { 234 + const url = `${this.baseUrl}/xrpc/com.atproto.sync.getBlob?did=${ 235 + encodeURIComponent(did) 236 + }&cid=${encodeURIComponent(cid)}`; 237 const headers: Record<string, string> = {}; 238 if (this.accessToken) { 239 headers["Authorization"] = `Bearer ${this.accessToken}`; ··· 246 })); 247 throw new Error(err.message || err.error || res.statusText); 248 } 249 + const contentType = res.headers.get("content-type") || 250 + "application/octet-stream"; 251 const data = new Uint8Array(await res.arrayBuffer()); 252 return { data, contentType }; 253 } ··· 603 604 export function base64UrlEncode(buffer: Uint8Array | ArrayBuffer): string { 605 const bytes = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : buffer; 606 + const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join( 607 + "", 608 + ); 609 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace( 610 /=+$/, 611 "", ··· 637 id: base64UrlDecode(cred.id as string), 638 }), 639 ), 640 + } as unknown as PublicKeyCredentialCreationOptions; 641 } 642 643 async function computeAccessTokenHash(accessToken: string): Promise<string> {
+10 -4
frontend/src/lib/migration/blob-migration.ts
··· 1 - import type { AtprotoClient } from "./atproto-client"; 2 - import type { MigrationProgress } from "./types"; 3 4 export interface BlobMigrationResult { 5 migrated: number; ··· 85 }); 86 87 console.log("[blob-migration] Fetching blob", cid, "from source"); 88 - const { data: blobData, contentType } = await sourceClient.getBlobWithContentType(userDid, cid); 89 console.log( 90 "[blob-migration] Got blob", 91 cid, ··· 95 contentType, 96 ); 97 await localClient.uploadBlob(blobData, contentType); 98 - console.log("[blob-migration] Uploaded blob", cid, "with contentType:", contentType); 99 migrated++; 100 onProgress({ blobsMigrated: migrated }); 101 } catch (e) {
··· 1 + import type { AtprotoClient } from "./atproto-client.ts"; 2 + import type { MigrationProgress } from "./types.ts"; 3 4 export interface BlobMigrationResult { 5 migrated: number; ··· 85 }); 86 87 console.log("[blob-migration] Fetching blob", cid, "from source"); 88 + const { data: blobData, contentType } = await sourceClient 89 + .getBlobWithContentType(userDid, cid); 90 console.log( 91 "[blob-migration] Got blob", 92 cid, ··· 96 contentType, 97 ); 98 await localClient.uploadBlob(blobData, contentType); 99 + console.log( 100 + "[blob-migration] Uploaded blob", 101 + cid, 102 + "with contentType:", 103 + contentType, 104 + ); 105 migrated++; 106 onProgress({ blobsMigrated: migrated }); 107 } catch (e) {
+5 -5
frontend/src/lib/migration/flow.svelte.ts
··· 5 PasskeyAccountSetup, 6 ServerDescription, 7 StoredMigrationState, 8 - } from "./types"; 9 import { 10 AtprotoClient, 11 clearDPoPKey, ··· 21 loadDPoPKey, 22 resolvePdsUrl, 23 saveDPoPKey, 24 - } from "./atproto-client"; 25 import { 26 clearMigrationState, 27 saveMigrationState, 28 updateProgress, 29 updateStep, 30 - } from "./storage"; 31 - import { migrateBlobs as migrateBlobsUtil } from "./blob-migration"; 32 33 function migrationLog(stage: string, data?: Record<string, unknown>) { 34 const timestamp = new Date().toISOString(); ··· 94 } 95 } 96 97 - function setError(error: string) { 98 state.error = error; 99 saveMigrationState(state); 100 }
··· 5 PasskeyAccountSetup, 6 ServerDescription, 7 StoredMigrationState, 8 + } from "./types.ts"; 9 import { 10 AtprotoClient, 11 clearDPoPKey, ··· 21 loadDPoPKey, 22 resolvePdsUrl, 23 saveDPoPKey, 24 + } from "./atproto-client.ts"; 25 import { 26 clearMigrationState, 27 saveMigrationState, 28 updateProgress, 29 updateStep, 30 + } from "./storage.ts"; 31 + import { migrateBlobs as migrateBlobsUtil } from "./blob-migration.ts"; 32 33 function migrationLog(stage: string, data?: Record<string, unknown>) { 34 const timestamp = new Date().toISOString(); ··· 94 } 95 } 96 97 + function setError(error: string | null) { 98 state.error = error; 99 saveMigrationState(state); 100 }
+28 -19
frontend/src/lib/migration/offline-flow.svelte.ts
··· 4 OfflineInboundMigrationState, 5 OfflineInboundStep, 6 ServerDescription, 7 - } from "./types"; 8 import { 9 AtprotoClient, 10 base64UrlEncode, 11 createLocalClient, 12 prepareWebAuthnCreationOptions, 13 - } from "./atproto-client"; 14 - import { api } from "../api"; 15 - import { type KeypairInfo, plcOps, type PrivateKey } from "./plc-ops"; 16 - import { migrateBlobs as migrateBlobsUtil } from "./blob-migration"; 17 import { Secp256k1PrivateKeyExportable } from "@atcute/crypto"; 18 19 const OFFLINE_STORAGE_KEY = "tranquil_offline_migration_state"; 20 const MAX_AGE_MS = 24 * 60 * 60 * 1000; ··· 303 const createResult = await api.createAccountWithServiceAuth( 304 serviceAuthToken, 305 { 306 - did: state.userDid, 307 - handle: fullHandle, 308 - email: state.targetEmail, 309 password: state.targetPassword, 310 inviteCode: state.inviteCode || undefined, 311 }, ··· 326 : `${state.targetHandle}.${serverInfo.availableUserDomains[0]}`; 327 328 const createResult = await api.createPasskeyAccount({ 329 - did: state.userDid, 330 - handle: fullHandle, 331 - email: state.targetEmail, 332 inviteCode: state.inviteCode || undefined, 333 }, serviceAuthToken); 334 ··· 349 const prevCid = base.cid; 350 351 const credentials = await api.getRecommendedDidCredentials( 352 - state.localAccessToken, 353 ); 354 355 await plcOps.signPlcOperationWithCredentials( ··· 374 } 375 376 setProgress({ currentOperation: "Importing repository..." }); 377 - await api.importRepo(state.localAccessToken, state.carFile); 378 setProgress({ repoImported: true }); 379 } 380 ··· 384 } 385 386 const localClient = createLocalClient(); 387 - localClient.setAccessToken(state.localAccessToken); 388 389 if (state.oldPdsUrl) { 390 setProgress({ ··· 436 } 437 438 setProgress({ currentOperation: "Activating account..." }); 439 - await api.activateAccount(state.localAccessToken); 440 setProgress({ activated: true }); 441 } 442 ··· 445 setError(null); 446 447 try { 448 - await api.verifyMigrationEmail(token, state.targetEmail); 449 450 if (state.authMethod === "passkey") { 451 setStep("passkey-setup"); ··· 474 } 475 476 async function resendEmailVerification(): Promise<void> { 477 - await api.resendMigrationVerification(state.targetEmail); 478 } 479 480 let checkingEmailVerification = false; ··· 518 } 519 520 return api.startPasskeyRegistrationForSetup( 521 - state.userDid, 522 state.passkeySetupToken, 523 ); 524 } ··· 560 }; 561 562 const result = await api.completePasskeySetup( 563 - state.userDid, 564 state.passkeySetupToken, 565 credentialData, 566 passkeyName,
··· 4 OfflineInboundMigrationState, 5 OfflineInboundStep, 6 ServerDescription, 7 + } from "./types.ts"; 8 import { 9 AtprotoClient, 10 base64UrlEncode, 11 createLocalClient, 12 prepareWebAuthnCreationOptions, 13 + } from "./atproto-client.ts"; 14 + import { api } from "../api.ts"; 15 + import { type KeypairInfo, plcOps, type PrivateKey } from "./plc-ops.ts"; 16 + import { migrateBlobs as migrateBlobsUtil } from "./blob-migration.ts"; 17 import { Secp256k1PrivateKeyExportable } from "@atcute/crypto"; 18 + import { 19 + unsafeAsAccessToken, 20 + unsafeAsDid, 21 + unsafeAsEmail, 22 + unsafeAsHandle, 23 + } from "../types/branded.ts"; 24 25 const OFFLINE_STORAGE_KEY = "tranquil_offline_migration_state"; 26 const MAX_AGE_MS = 24 * 60 * 60 * 1000; ··· 309 const createResult = await api.createAccountWithServiceAuth( 310 serviceAuthToken, 311 { 312 + did: unsafeAsDid(state.userDid), 313 + handle: unsafeAsHandle(fullHandle), 314 + email: unsafeAsEmail(state.targetEmail), 315 password: state.targetPassword, 316 inviteCode: state.inviteCode || undefined, 317 }, ··· 332 : `${state.targetHandle}.${serverInfo.availableUserDomains[0]}`; 333 334 const createResult = await api.createPasskeyAccount({ 335 + did: unsafeAsDid(state.userDid), 336 + handle: unsafeAsHandle(fullHandle), 337 + email: unsafeAsEmail(state.targetEmail), 338 inviteCode: state.inviteCode || undefined, 339 }, serviceAuthToken); 340 ··· 355 const prevCid = base.cid; 356 357 const credentials = await api.getRecommendedDidCredentials( 358 + unsafeAsAccessToken(state.localAccessToken), 359 ); 360 361 await plcOps.signPlcOperationWithCredentials( ··· 380 } 381 382 setProgress({ currentOperation: "Importing repository..." }); 383 + await api.importRepo( 384 + unsafeAsAccessToken(state.localAccessToken), 385 + state.carFile, 386 + ); 387 setProgress({ repoImported: true }); 388 } 389 ··· 393 } 394 395 const localClient = createLocalClient(); 396 + localClient.setAccessToken(unsafeAsAccessToken(state.localAccessToken)); 397 398 if (state.oldPdsUrl) { 399 setProgress({ ··· 445 } 446 447 setProgress({ currentOperation: "Activating account..." }); 448 + await api.activateAccount(unsafeAsAccessToken(state.localAccessToken)); 449 setProgress({ activated: true }); 450 } 451 ··· 454 setError(null); 455 456 try { 457 + await api.verifyMigrationEmail(token, unsafeAsEmail(state.targetEmail)); 458 459 if (state.authMethod === "passkey") { 460 setStep("passkey-setup"); ··· 483 } 484 485 async function resendEmailVerification(): Promise<void> { 486 + await api.resendMigrationVerification(unsafeAsEmail(state.targetEmail)); 487 } 488 489 let checkingEmailVerification = false; ··· 527 } 528 529 return api.startPasskeyRegistrationForSetup( 530 + unsafeAsDid(state.userDid), 531 state.passkeySetupToken, 532 ); 533 } ··· 569 }; 570 571 const result = await api.completePasskeySetup( 572 + unsafeAsDid(state.userDid), 573 state.passkeySetupToken, 574 credentialData, 575 passkeyName,
+7 -2
frontend/src/lib/migration/plc-ops.ts
··· 28 29 export interface PlcOperationData { 30 type: "plc_operation"; 31 - prev: string; 32 alsoKnownAs: string[]; 33 rotationKeys: string[]; 34 services: Record<string, PlcService>; ··· 65 const lastOp = logs.at(-1); 66 if (!lastOp) { 67 throw new Error("No PLC operations found for this DID"); 68 } 69 return { lastOperation: normalizeOp(lastOp.operation), base: lastOp }; 70 } ··· 108 } else if (match.type === "secp256k1") { 109 keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes); 110 } else { 111 - throw new Error(`Unsupported key type: ${match.type}`); 112 } 113 } else { 114 throw new Error(
··· 28 29 export interface PlcOperationData { 30 type: "plc_operation"; 31 + prev: string | null; 32 alsoKnownAs: string[]; 33 rotationKeys: string[]; 34 services: Record<string, PlcService>; ··· 65 const lastOp = logs.at(-1); 66 if (!lastOp) { 67 throw new Error("No PLC operations found for this DID"); 68 + } 69 + if (lastOp.operation.type === "plc_tombstone") { 70 + throw new Error("DID has been tombstoned"); 71 } 72 return { lastOperation: normalizeOp(lastOp.operation), base: lastOp }; 73 } ··· 111 } else if (match.type === "secp256k1") { 112 keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes); 113 } else { 114 + throw new Error( 115 + `Unsupported key type: ${(match as { type: string }).type}`, 116 + ); 117 } 118 } else { 119 throw new Error(
+9 -15
frontend/src/lib/migration/storage.ts
··· 2 MigrationDirection, 3 MigrationState, 4 StoredMigrationState, 5 - } from "./types"; 6 - import { clearDPoPKey } from "./atproto-client"; 7 8 const STORAGE_KEY = "tranquil_migration_state"; 9 const MAX_AGE_MS = 24 * 60 * 60 * 1000; ··· 12 const storedState: StoredMigrationState = { 13 version: 1, 14 direction: state.direction, 15 - step: state.direction === "inbound" ? state.step : state.step, 16 startedAt: new Date().toISOString(), 17 - sourcePdsUrl: state.direction === "inbound" 18 - ? state.sourcePdsUrl 19 - : globalThis.location.origin, 20 - targetPdsUrl: state.direction === "inbound" 21 - ? globalThis.location.origin 22 - : state.targetPdsUrl, 23 - sourceDid: state.direction === "inbound" ? state.sourceDid : "", 24 - sourceHandle: state.direction === "inbound" ? state.sourceHandle : "", 25 targetHandle: state.targetHandle, 26 targetEmail: state.targetEmail, 27 - authMethod: state.direction === "inbound" ? state.authMethod : undefined, 28 - passkeySetupToken: state.direction === "inbound" 29 - ? state.passkeySetupToken ?? undefined 30 - : undefined, 31 progress: { 32 repoExported: state.progress.repoExported, 33 repoImported: state.progress.repoImported,
··· 2 MigrationDirection, 3 MigrationState, 4 StoredMigrationState, 5 + } from "./types.ts"; 6 + import { clearDPoPKey } from "./atproto-client.ts"; 7 8 const STORAGE_KEY = "tranquil_migration_state"; 9 const MAX_AGE_MS = 24 * 60 * 60 * 1000; ··· 12 const storedState: StoredMigrationState = { 13 version: 1, 14 direction: state.direction, 15 + step: state.step, 16 startedAt: new Date().toISOString(), 17 + sourcePdsUrl: state.sourcePdsUrl, 18 + targetPdsUrl: globalThis.location.origin, 19 + sourceDid: state.sourceDid, 20 + sourceHandle: state.sourceHandle, 21 targetHandle: state.targetHandle, 22 targetEmail: state.targetEmail, 23 + authMethod: state.authMethod, 24 + passkeySetupToken: state.passkeySetupToken ?? undefined, 25 progress: { 26 repoExported: state.progress.repoExported, 27 repoImported: state.progress.repoImported,
+3 -1
frontend/src/lib/oauth.ts
··· 34 35 function base64UrlEncode(buffer: ArrayBuffer): string { 36 const bytes = new Uint8Array(buffer); 37 - const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('') 38 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace( 39 /=+$/, 40 "",
··· 34 35 function base64UrlEncode(buffer: ArrayBuffer): string { 36 const bytes = new Uint8Array(buffer); 37 + const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join( 38 + "", 39 + ); 40 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace( 41 /=+$/, 42 "",
+15 -8
frontend/src/lib/registration/flow.svelte.ts
··· 1 - import { api, ApiError } from "../api"; 2 - import { setSession } from "../auth.svelte"; 3 import { 4 createServiceJwt, 5 generateDidDocument, 6 generateKeypair, 7 - } from "../crypto"; 8 import type { 9 AccountResult, 10 ExternalDidWebState, ··· 12 RegistrationMode, 13 RegistrationStep, 14 SessionState, 15 - } from "./types"; 16 17 export interface RegistrationFlowState { 18 mode: RegistrationMode; ··· 100 101 if (keyMode === "reserved") { 102 const result = await api.reserveSigningKey( 103 - state.info.externalDid!.trim(), 104 ); 105 state.externalDidWeb.reservedSigningKey = result.signingKey; 106 publicKeyMultibase = result.signingKey.replace("did:key:", ""); ··· 207 } 208 209 const result = await api.createPasskeyAccount({ 210 - handle: state.info.handle.trim(), 211 - email: state.info.email?.trim() || undefined, 212 inviteCode: state.info.inviteCode?.trim() || undefined, 213 didType: state.info.didType, 214 did: state.info.didType === "web-external" 215 - ? state.info.externalDid!.trim() 216 : undefined, 217 signingKey: state.info.didType === "web-external" && 218 state.externalDidWeb.keyMode === "reserved"
··· 1 + import { api, ApiError } from "../api.ts"; 2 + import { setSession } from "../auth.svelte.ts"; 3 import { 4 createServiceJwt, 5 generateDidDocument, 6 generateKeypair, 7 + } from "../crypto.ts"; 8 + import { 9 + unsafeAsDid, 10 + unsafeAsEmail, 11 + unsafeAsHandle, 12 + } from "../types/branded.ts"; 13 import type { 14 AccountResult, 15 ExternalDidWebState, ··· 17 RegistrationMode, 18 RegistrationStep, 19 SessionState, 20 + } from "./types.ts"; 21 22 export interface RegistrationFlowState { 23 mode: RegistrationMode; ··· 105 106 if (keyMode === "reserved") { 107 const result = await api.reserveSigningKey( 108 + unsafeAsDid(state.info.externalDid!.trim()), 109 ); 110 state.externalDidWeb.reservedSigningKey = result.signingKey; 111 publicKeyMultibase = result.signingKey.replace("did:key:", ""); ··· 212 } 213 214 const result = await api.createPasskeyAccount({ 215 + handle: unsafeAsHandle(state.info.handle.trim()), 216 + email: state.info.email?.trim() 217 + ? unsafeAsEmail(state.info.email.trim()) 218 + : undefined, 219 inviteCode: state.info.inviteCode?.trim() || undefined, 220 didType: state.info.didType, 221 did: state.info.didType === "web-external" 222 + ? unsafeAsDid(state.info.externalDid!.trim()) 223 : undefined, 224 signingKey: state.info.didType === "web-external" && 225 state.externalDidWeb.keyMode === "reserved"
+11 -5
frontend/src/lib/registration/types.ts
··· 1 - import type { DidType, VerificationChannel } from "../api"; 2 3 export type RegistrationMode = "password" | "passkey"; 4 ··· 37 } 38 39 export interface AccountResult { 40 - did: string; 41 - handle: string; 42 setupToken?: string; 43 appPassword?: string; 44 appPasswordName?: string; 45 } 46 47 export interface SessionState { 48 - accessJwt: string; 49 - refreshJwt: string; 50 }
··· 1 + import type { DidType, VerificationChannel } from "../api.ts"; 2 + import type { 3 + AccessToken, 4 + Did, 5 + Handle, 6 + RefreshToken, 7 + } from "../types/branded.ts"; 8 9 export type RegistrationMode = "password" | "passkey"; 10 ··· 43 } 44 45 export interface AccountResult { 46 + did: Did; 47 + handle: Handle; 48 setupToken?: string; 49 appPassword?: string; 50 appPasswordName?: string; 51 } 52 53 export interface SessionState { 54 + accessJwt: AccessToken; 55 + refreshJwt: RefreshToken; 56 }
+11 -7
frontend/src/lib/router.svelte.ts
··· 1 import { 2 - routes, 3 type Route, 4 type RouteParams, 5 type RoutesWithParams, 6 - buildUrl, 7 - parseRouteParams, 8 - isValidRoute, 9 - } from "./types/routes"; 10 11 const APP_BASE = "/app"; 12 ··· 120 } 121 122 export type RouteMatch = 123 - | { readonly matched: true; readonly route: Route; readonly params: URLSearchParams } 124 | { readonly matched: false }; 125 126 export function match(): RouteMatch { ··· 135 return { matched: false }; 136 } 137 138 - export { routes, type Route, type RouteParams, type RoutesWithParams };
··· 1 import { 2 + buildUrl, 3 + isValidRoute, 4 + parseRouteParams, 5 type Route, 6 type RouteParams, 7 + routes, 8 type RoutesWithParams, 9 + } from "./types/routes.ts"; 10 11 const APP_BASE = "/app"; 12 ··· 120 } 121 122 export type RouteMatch = 123 + | { 124 + readonly matched: true; 125 + readonly route: Route; 126 + readonly params: URLSearchParams; 127 + } 128 | { readonly matched: false }; 129 130 export function match(): RouteMatch { ··· 139 return { matched: false }; 140 } 141 142 + export { type Route, type RouteParams, routes, type RoutesWithParams };
+26 -26
frontend/src/lib/toast.svelte.ts
··· 1 - export type ToastType = 'success' | 'error' | 'warning' | 'info' 2 3 export interface Toast { 4 - id: number 5 - type: ToastType 6 - message: string 7 - duration: number 8 - dismissing?: boolean 9 } 10 11 - let nextId = 0 12 - let toasts = $state<Toast[]>([]) 13 14 export function getToasts(): readonly Toast[] { 15 - return toasts 16 } 17 18 export function showToast( 19 type: ToastType, 20 message: string, 21 - duration = 5000 22 ): number { 23 - const id = nextId++ 24 - toasts = [...toasts, { id, type, message, duration }] 25 26 if (duration > 0) { 27 setTimeout(() => { 28 - dismissToast(id) 29 - }, duration) 30 } 31 32 - return id 33 } 34 35 export function dismissToast(id: number): void { 36 - const toast = toasts.find(t => t.id === id) 37 - if (!toast || toast.dismissing) return 38 39 - toasts = toasts.map(t => t.id === id ? { ...t, dismissing: true } : t) 40 41 setTimeout(() => { 42 - toasts = toasts.filter(t => t.id !== id) 43 - }, 150) 44 } 45 46 export function clearAllToasts(): void { 47 - toasts = [] 48 } 49 50 export function success(message: string, duration?: number): number { 51 - return showToast('success', message, duration) 52 } 53 54 export function error(message: string, duration?: number): number { 55 - return showToast('error', message, duration) 56 } 57 58 export function warning(message: string, duration?: number): number { 59 - return showToast('warning', message, duration) 60 } 61 62 export function info(message: string, duration?: number): number { 63 - return showToast('info', message, duration) 64 } 65 66 export const toast = { ··· 71 info, 72 dismiss: dismissToast, 73 clear: clearAllToasts, 74 - }
··· 1 + export type ToastType = "success" | "error" | "warning" | "info"; 2 3 export interface Toast { 4 + id: number; 5 + type: ToastType; 6 + message: string; 7 + duration: number; 8 + dismissing?: boolean; 9 } 10 11 + let nextId = 0; 12 + let toasts = $state<Toast[]>([]); 13 14 export function getToasts(): readonly Toast[] { 15 + return toasts; 16 } 17 18 export function showToast( 19 type: ToastType, 20 message: string, 21 + duration = 5000, 22 ): number { 23 + const id = nextId++; 24 + toasts = [...toasts, { id, type, message, duration }]; 25 26 if (duration > 0) { 27 setTimeout(() => { 28 + dismissToast(id); 29 + }, duration); 30 } 31 32 + return id; 33 } 34 35 export function dismissToast(id: number): void { 36 + const toast = toasts.find((t) => t.id === id); 37 + if (!toast || toast.dismissing) return; 38 39 + toasts = toasts.map((t) => t.id === id ? { ...t, dismissing: true } : t); 40 41 setTimeout(() => { 42 + toasts = toasts.filter((t) => t.id !== id); 43 + }, 150); 44 } 45 46 export function clearAllToasts(): void { 47 + toasts = []; 48 } 49 50 export function success(message: string, duration?: number): number { 51 + return showToast("success", message, duration); 52 } 53 54 export function error(message: string, duration?: number): number { 55 + return showToast("error", message, duration); 56 } 57 58 export function warning(message: string, duration?: number): number { 59 + return showToast("warning", message, duration); 60 } 61 62 export function info(message: string, duration?: number): number { 63 + return showToast("info", message, duration); 64 } 65 66 export const toast = { ··· 71 info, 72 dismiss: dismissToast, 73 clear: clearAllToasts, 74 + };
+272 -263
frontend/src/lib/types/api.ts
··· 1 import type { 2 - Did, 3 - Handle, 4 AccessToken, 5 - RefreshToken, 6 Cid, 7 - Rkey, 8 - AtUri, 9 - Nsid, 10 - ISODateString, 11 EmailAddress, 12 InviteCode as InviteCodeBrand, 13 PublicKeyMultibase, 14 - } from './branded' 15 16 export type ApiErrorCode = 17 - | 'InvalidRequest' 18 - | 'AuthenticationRequired' 19 - | 'ExpiredToken' 20 - | 'InvalidToken' 21 - | 'AccountNotFound' 22 - | 'HandleNotAvailable' 23 - | 'InvalidHandle' 24 - | 'InvalidPassword' 25 - | 'RateLimitExceeded' 26 - | 'InternalServerError' 27 - | 'AccountTakedown' 28 - | 'AccountDeactivated' 29 - | 'AccountNotVerified' 30 - | 'RepoNotFound' 31 - | 'RecordNotFound' 32 - | 'BlobNotFound' 33 - | 'InvalidInviteCode' 34 - | 'DuplicateCreate' 35 - | 'Unknown' 36 37 - export type AccountStatus = 'active' | 'deactivated' | 'migrated' | 'suspended' | 'deleted' 38 39 - export type SessionType = 'oauth' | 'legacy' | 'app_password' 40 41 - export type VerificationChannel = 'email' | 'discord' | 'telegram' | 'signal' 42 43 - export type DidType = 'plc' | 'web' | 'web-external' 44 45 - export type ReauthMethod = 'password' | 'totp' | 'passkey' 46 47 export interface Session { 48 - did: Did 49 - handle: Handle 50 - email?: EmailAddress 51 - emailConfirmed?: boolean 52 - preferredChannel?: VerificationChannel 53 - preferredChannelVerified?: boolean 54 - isAdmin?: boolean 55 - active?: boolean 56 - status?: AccountStatus 57 - migratedToPds?: string 58 - migratedAt?: ISODateString 59 - accessJwt: AccessToken 60 - refreshJwt: RefreshToken 61 } 62 63 export interface VerificationMethod { 64 - id: string 65 - type: string 66 - controller: string 67 - publicKeyMultibase: PublicKeyMultibase 68 } 69 70 export interface ServiceEndpoint { 71 - id: string 72 - type: string 73 - serviceEndpoint: string 74 } 75 76 export interface DidDocument { 77 - '@context': string[] 78 - id: Did 79 - alsoKnownAs: string[] 80 - verificationMethod: VerificationMethod[] 81 - service: ServiceEndpoint[] 82 } 83 84 export interface AppPassword { 85 - name: string 86 - createdAt: ISODateString 87 - scopes?: string 88 - createdByController?: string 89 } 90 91 export interface CreatedAppPassword { 92 - name: string 93 - password: string 94 - createdAt: ISODateString 95 - scopes?: string 96 } 97 98 export interface InviteCodeUse { 99 - usedBy: Did 100 - usedByHandle?: Handle 101 - usedAt: ISODateString 102 } 103 104 export interface InviteCodeInfo { 105 - code: InviteCodeBrand 106 - available: number 107 - disabled: boolean 108 - forAccount: Did 109 - createdBy: Did 110 - createdAt: ISODateString 111 - uses: InviteCodeUse[] 112 } 113 114 export interface CreateAccountParams { 115 - handle: string 116 - email: string 117 - password: string 118 - inviteCode?: string 119 - didType?: DidType 120 - did?: string 121 - signingKey?: string 122 - verificationChannel?: VerificationChannel 123 - discordId?: string 124 - telegramUsername?: string 125 - signalNumber?: string 126 } 127 128 export interface CreateAccountResult { 129 - handle: Handle 130 - did: Did 131 - verificationRequired: boolean 132 - verificationChannel: VerificationChannel 133 } 134 135 export interface ConfirmSignupResult { 136 - accessJwt: AccessToken 137 - refreshJwt: RefreshToken 138 - handle: Handle 139 - did: Did 140 - email?: EmailAddress 141 - emailConfirmed?: boolean 142 - preferredChannel?: VerificationChannel 143 - preferredChannelVerified?: boolean 144 } 145 146 export interface ListAppPasswordsResponse { 147 - passwords: AppPassword[] 148 } 149 150 export interface AccountInviteCodesResponse { 151 - codes: InviteCodeInfo[] 152 } 153 154 export interface CreateInviteCodeResponse { 155 - code: InviteCodeBrand 156 } 157 158 export interface ServerLinks { 159 - privacyPolicy?: string 160 - termsOfService?: string 161 } 162 163 export interface ServerDescription { 164 - availableUserDomains: string[] 165 - inviteCodeRequired: boolean 166 - links?: ServerLinks 167 - version?: string 168 - availableCommsChannels?: VerificationChannel[] 169 - selfHostedDidWebEnabled?: boolean 170 } 171 172 export interface RepoInfo { 173 - did: Did 174 - head: Cid 175 - rev: string 176 } 177 178 export interface ListReposResponse { 179 - repos: RepoInfo[] 180 - cursor?: string 181 } 182 183 export interface NotificationPrefs { 184 - preferredChannel: VerificationChannel 185 - email: EmailAddress 186 - discordId: string | null 187 - discordVerified: boolean 188 - telegramUsername: string | null 189 - telegramVerified: boolean 190 - signalNumber: string | null 191 - signalVerified: boolean 192 } 193 194 export interface NotificationHistoryItem { 195 - createdAt: ISODateString 196 - channel: VerificationChannel 197 - notificationType: string 198 - status: string 199 - subject: string | null 200 - body: string 201 } 202 203 export interface NotificationHistoryResponse { 204 - notifications: NotificationHistoryItem[] 205 } 206 207 export interface ServerStats { 208 - userCount: number 209 - repoCount: number 210 - recordCount: number 211 - blobStorageBytes: number 212 } 213 214 export interface ServerConfig { 215 - serverName: string 216 - primaryColor: string | null 217 - primaryColorDark: string | null 218 - secondaryColor: string | null 219 - secondaryColorDark: string | null 220 - logoCid: Cid | null 221 } 222 223 export interface BlobRef { 224 - $type: 'blob' 225 - ref: { $link: Cid } 226 - mimeType: string 227 - size: number 228 } 229 230 export interface UploadBlobResponse { 231 - blob: BlobRef 232 } 233 234 export interface SessionInfo { 235 - id: string 236 - sessionType: SessionType 237 - clientName: string | null 238 - createdAt: ISODateString 239 - expiresAt: ISODateString 240 - isCurrent: boolean 241 } 242 243 export interface ListSessionsResponse { 244 - sessions: SessionInfo[] 245 } 246 247 export interface RevokeAllSessionsResponse { 248 - revokedCount: number 249 } 250 251 export interface AccountSearchResult { 252 - did: Did 253 - handle: Handle 254 - email?: EmailAddress 255 - indexedAt: ISODateString 256 - emailConfirmedAt?: ISODateString 257 - deactivatedAt?: ISODateString 258 } 259 260 export interface SearchAccountsResponse { 261 - cursor?: string 262 - accounts: AccountSearchResult[] 263 } 264 265 export interface AdminInviteCodeUse { 266 - usedBy: Did 267 - usedAt: ISODateString 268 } 269 270 export interface AdminInviteCode { 271 - code: InviteCodeBrand 272 - available: number 273 - disabled: boolean 274 - forAccount: Did 275 - createdBy: Did 276 - createdAt: ISODateString 277 - uses: AdminInviteCodeUse[] 278 } 279 280 export interface GetInviteCodesResponse { 281 - cursor?: string 282 - codes: AdminInviteCode[] 283 } 284 285 export interface AccountInfo { 286 - did: Did 287 - handle: Handle 288 - email?: EmailAddress 289 - indexedAt: ISODateString 290 - emailConfirmedAt?: ISODateString 291 - invitesDisabled?: boolean 292 - deactivatedAt?: ISODateString 293 } 294 295 export interface RepoDescription { 296 - handle: Handle 297 - did: Did 298 - didDoc: DidDocument 299 - collections: Nsid[] 300 - handleIsCorrect: boolean 301 } 302 303 export interface RecordInfo { 304 - uri: AtUri 305 - cid: Cid 306 - value: unknown 307 } 308 309 export interface ListRecordsResponse { 310 - records: RecordInfo[] 311 - cursor?: string 312 } 313 314 export interface RecordResponse { 315 - uri: AtUri 316 - cid: Cid 317 - value: unknown 318 } 319 320 export interface CreateRecordResponse { 321 - uri: AtUri 322 - cid: Cid 323 } 324 325 export interface TotpStatus { 326 - enabled: boolean 327 - hasBackupCodes: boolean 328 } 329 330 export interface TotpSecret { 331 - uri: string 332 - qrBase64: string 333 } 334 335 export interface EnableTotpResponse { 336 - success: boolean 337 - backupCodes: string[] 338 } 339 340 export interface RegenerateBackupCodesResponse { 341 - backupCodes: string[] 342 } 343 344 export interface PasskeyInfo { 345 - id: string 346 - credentialId: string 347 - friendlyName: string | null 348 - createdAt: ISODateString 349 - lastUsed: ISODateString | null 350 } 351 352 export interface ListPasskeysResponse { 353 - passkeys: PasskeyInfo[] 354 } 355 356 export interface StartPasskeyRegistrationResponse { 357 - options: PublicKeyCredentialCreationOptions 358 } 359 360 export interface FinishPasskeyRegistrationResponse { 361 - id: string 362 - credentialId: string 363 } 364 365 export interface TrustedDevice { 366 - id: string 367 - userAgent: string | null 368 - friendlyName: string | null 369 - trustedAt: ISODateString | null 370 - trustedUntil: ISODateString | null 371 - lastSeenAt: ISODateString 372 } 373 374 export interface ListTrustedDevicesResponse { 375 - devices: TrustedDevice[] 376 } 377 378 export interface ReauthStatus { 379 - requiresReauth: boolean 380 - lastReauthAt: ISODateString | null 381 - availableMethods: ReauthMethod[] 382 } 383 384 export interface ReauthResponse { 385 - success: boolean 386 - reauthAt: ISODateString 387 } 388 389 export interface ReauthPasskeyStartResponse { 390 - options: PublicKeyCredentialRequestOptions 391 } 392 393 export interface ReserveSigningKeyResponse { 394 - signingKey: PublicKeyMultibase 395 } 396 397 export interface RecommendedDidCredentials { 398 - rotationKeys?: PublicKeyMultibase[] 399 - alsoKnownAs?: string[] 400 - verificationMethods?: { atproto?: PublicKeyMultibase } 401 - services?: { atproto_pds?: { type: string; endpoint: string } } 402 } 403 404 export interface PasskeyAccountCreateResponse { 405 - did: Did 406 - handle: Handle 407 - setupToken: string 408 - setupExpiresAt: ISODateString 409 } 410 411 export interface CompletePasskeySetupResponse { 412 - did: Did 413 - handle: Handle 414 - appPassword: string 415 - appPasswordName: string 416 } 417 418 export interface VerifyTokenResponse { 419 - success: boolean 420 - did: Did 421 - purpose: string 422 - channel: VerificationChannel 423 } 424 425 export interface BackupInfo { 426 - id: string 427 - repoRev: string 428 - repoRootCid: Cid 429 - blockCount: number 430 - sizeBytes: number 431 - createdAt: ISODateString 432 } 433 434 export interface ListBackupsResponse { 435 - backups: BackupInfo[] 436 - backupEnabled: boolean 437 } 438 439 export interface CreateBackupResponse { 440 - id: string 441 - repoRev: string 442 - sizeBytes: number 443 - blockCount: number 444 } 445 446 export interface SetBackupEnabledResponse { 447 - enabled: boolean 448 } 449 450 export interface EmailUpdateResponse { 451 - tokenRequired: boolean 452 } 453 454 export interface LegacyLoginPreference { 455 - allowLegacyLogin: boolean 456 - hasMfa: boolean 457 } 458 459 export interface UpdateLegacyLoginResponse { 460 - allowLegacyLogin: boolean 461 } 462 463 export interface UpdateLocaleResponse { 464 - preferredLocale: string 465 } 466 467 export interface PasswordStatus { 468 - hasPassword: boolean 469 } 470 471 export interface SuccessResponse { 472 - success: boolean 473 } 474 475 export interface CheckEmailVerifiedResponse { 476 - verified: boolean 477 } 478 479 export interface VerifyMigrationEmailResponse { 480 - success: boolean 481 - did: Did 482 } 483 484 export interface ResendMigrationVerificationResponse { 485 - sent: boolean 486 }
··· 1 import type { 2 AccessToken, 3 + AtUri, 4 Cid, 5 + Did, 6 EmailAddress, 7 + Handle, 8 InviteCode as InviteCodeBrand, 9 + ISODateString, 10 + Nsid, 11 PublicKeyMultibase, 12 + RefreshToken, 13 + } from "./branded.ts"; 14 15 export type ApiErrorCode = 16 + | "InvalidRequest" 17 + | "AuthenticationRequired" 18 + | "ExpiredToken" 19 + | "InvalidToken" 20 + | "AccountNotFound" 21 + | "HandleNotAvailable" 22 + | "InvalidHandle" 23 + | "InvalidPassword" 24 + | "RateLimitExceeded" 25 + | "InternalServerError" 26 + | "AccountTakedown" 27 + | "AccountDeactivated" 28 + | "AccountNotVerified" 29 + | "RepoNotFound" 30 + | "RecordNotFound" 31 + | "BlobNotFound" 32 + | "InvalidInviteCode" 33 + | "DuplicateCreate" 34 + | "ReauthRequired" 35 + | "MfaVerificationRequired" 36 + | "RecoveryLinkExpired" 37 + | "InvalidRecoveryLink" 38 + | "Unknown"; 39 40 + export type AccountStatus = 41 + | "active" 42 + | "deactivated" 43 + | "migrated" 44 + | "suspended" 45 + | "deleted"; 46 47 + export type SessionType = "oauth" | "legacy" | "app_password"; 48 49 + export type VerificationChannel = "email" | "discord" | "telegram" | "signal"; 50 51 + export type DidType = "plc" | "web" | "web-external"; 52 53 + export type ReauthMethod = "password" | "totp" | "passkey"; 54 55 export interface Session { 56 + did: Did; 57 + handle: Handle; 58 + email?: EmailAddress; 59 + emailConfirmed?: boolean; 60 + preferredChannel?: VerificationChannel; 61 + preferredChannelVerified?: boolean; 62 + preferredLocale?: string | null; 63 + isAdmin?: boolean; 64 + active?: boolean; 65 + status?: AccountStatus; 66 + migratedToPds?: string; 67 + migratedAt?: ISODateString; 68 + accessJwt: AccessToken; 69 + refreshJwt: RefreshToken; 70 } 71 72 export interface VerificationMethod { 73 + id: string; 74 + type: string; 75 + controller: string; 76 + publicKeyMultibase: PublicKeyMultibase; 77 } 78 79 export interface ServiceEndpoint { 80 + id: string; 81 + type: string; 82 + serviceEndpoint: string; 83 } 84 85 export interface DidDocument { 86 + "@context": string[]; 87 + id: Did; 88 + alsoKnownAs: string[]; 89 + verificationMethod: VerificationMethod[]; 90 + service: ServiceEndpoint[]; 91 } 92 93 export interface AppPassword { 94 + name: string; 95 + createdAt: ISODateString; 96 + scopes?: string; 97 + createdByController?: string; 98 } 99 100 export interface CreatedAppPassword { 101 + name: string; 102 + password: string; 103 + createdAt: ISODateString; 104 + scopes?: string; 105 } 106 107 export interface InviteCodeUse { 108 + usedBy: Did; 109 + usedByHandle?: Handle; 110 + usedAt: ISODateString; 111 } 112 113 export interface InviteCodeInfo { 114 + code: InviteCodeBrand; 115 + available: number; 116 + disabled: boolean; 117 + forAccount: Did; 118 + createdBy: Did; 119 + createdAt: ISODateString; 120 + uses: InviteCodeUse[]; 121 } 122 123 export interface CreateAccountParams { 124 + handle: string; 125 + email: string; 126 + password: string; 127 + inviteCode?: string; 128 + didType?: DidType; 129 + did?: string; 130 + signingKey?: string; 131 + verificationChannel?: VerificationChannel; 132 + discordId?: string; 133 + telegramUsername?: string; 134 + signalNumber?: string; 135 } 136 137 export interface CreateAccountResult { 138 + handle: Handle; 139 + did: Did; 140 + verificationRequired: boolean; 141 + verificationChannel: VerificationChannel; 142 } 143 144 export interface ConfirmSignupResult { 145 + accessJwt: AccessToken; 146 + refreshJwt: RefreshToken; 147 + handle: Handle; 148 + did: Did; 149 + email?: EmailAddress; 150 + emailConfirmed?: boolean; 151 + preferredChannel?: VerificationChannel; 152 + preferredChannelVerified?: boolean; 153 } 154 155 export interface ListAppPasswordsResponse { 156 + passwords: AppPassword[]; 157 } 158 159 export interface AccountInviteCodesResponse { 160 + codes: InviteCodeInfo[]; 161 } 162 163 export interface CreateInviteCodeResponse { 164 + code: InviteCodeBrand; 165 } 166 167 export interface ServerLinks { 168 + privacyPolicy?: string; 169 + termsOfService?: string; 170 } 171 172 export interface ServerDescription { 173 + availableUserDomains: string[]; 174 + inviteCodeRequired: boolean; 175 + links?: ServerLinks; 176 + version?: string; 177 + availableCommsChannels?: VerificationChannel[]; 178 + selfHostedDidWebEnabled?: boolean; 179 } 180 181 export interface RepoInfo { 182 + did: Did; 183 + head: Cid; 184 + rev: string; 185 } 186 187 export interface ListReposResponse { 188 + repos: RepoInfo[]; 189 + cursor?: string; 190 } 191 192 export interface NotificationPrefs { 193 + preferredChannel: VerificationChannel; 194 + email: EmailAddress; 195 + discordId: string | null; 196 + discordVerified: boolean; 197 + telegramUsername: string | null; 198 + telegramVerified: boolean; 199 + signalNumber: string | null; 200 + signalVerified: boolean; 201 } 202 203 export interface NotificationHistoryItem { 204 + createdAt: ISODateString; 205 + channel: VerificationChannel; 206 + notificationType: string; 207 + status: string; 208 + subject: string | null; 209 + body: string; 210 } 211 212 export interface NotificationHistoryResponse { 213 + notifications: NotificationHistoryItem[]; 214 } 215 216 export interface ServerStats { 217 + userCount: number; 218 + repoCount: number; 219 + recordCount: number; 220 + blobStorageBytes: number; 221 } 222 223 export interface ServerConfig { 224 + serverName: string; 225 + primaryColor: string | null; 226 + primaryColorDark: string | null; 227 + secondaryColor: string | null; 228 + secondaryColorDark: string | null; 229 + logoCid: Cid | null; 230 } 231 232 export interface BlobRef { 233 + $type: "blob"; 234 + ref: { $link: Cid }; 235 + mimeType: string; 236 + size: number; 237 } 238 239 export interface UploadBlobResponse { 240 + blob: BlobRef; 241 } 242 243 export interface SessionInfo { 244 + id: string; 245 + sessionType: SessionType; 246 + clientName: string | null; 247 + createdAt: ISODateString; 248 + expiresAt: ISODateString; 249 + isCurrent: boolean; 250 } 251 252 export interface ListSessionsResponse { 253 + sessions: SessionInfo[]; 254 } 255 256 export interface RevokeAllSessionsResponse { 257 + revokedCount: number; 258 } 259 260 export interface AccountSearchResult { 261 + did: Did; 262 + handle: Handle; 263 + email?: EmailAddress; 264 + indexedAt: ISODateString; 265 + emailConfirmedAt?: ISODateString; 266 + deactivatedAt?: ISODateString; 267 } 268 269 export interface SearchAccountsResponse { 270 + cursor?: string; 271 + accounts: AccountSearchResult[]; 272 } 273 274 export interface AdminInviteCodeUse { 275 + usedBy: Did; 276 + usedAt: ISODateString; 277 } 278 279 export interface AdminInviteCode { 280 + code: InviteCodeBrand; 281 + available: number; 282 + disabled: boolean; 283 + forAccount: Did; 284 + createdBy: Did; 285 + createdAt: ISODateString; 286 + uses: AdminInviteCodeUse[]; 287 } 288 289 export interface GetInviteCodesResponse { 290 + cursor?: string; 291 + codes: AdminInviteCode[]; 292 } 293 294 export interface AccountInfo { 295 + did: Did; 296 + handle: Handle; 297 + email?: EmailAddress; 298 + indexedAt: ISODateString; 299 + emailConfirmedAt?: ISODateString; 300 + invitesDisabled?: boolean; 301 + deactivatedAt?: ISODateString; 302 } 303 304 export interface RepoDescription { 305 + handle: Handle; 306 + did: Did; 307 + didDoc: DidDocument; 308 + collections: Nsid[]; 309 + handleIsCorrect: boolean; 310 } 311 312 export interface RecordInfo { 313 + uri: AtUri; 314 + cid: Cid; 315 + value: unknown; 316 } 317 318 export interface ListRecordsResponse { 319 + records: RecordInfo[]; 320 + cursor?: string; 321 } 322 323 export interface RecordResponse { 324 + uri: AtUri; 325 + cid: Cid; 326 + value: unknown; 327 } 328 329 export interface CreateRecordResponse { 330 + uri: AtUri; 331 + cid: Cid; 332 } 333 334 export interface TotpStatus { 335 + enabled: boolean; 336 + hasBackupCodes: boolean; 337 } 338 339 export interface TotpSecret { 340 + uri: string; 341 + qrBase64: string; 342 } 343 344 export interface EnableTotpResponse { 345 + success: boolean; 346 + backupCodes: string[]; 347 } 348 349 export interface RegenerateBackupCodesResponse { 350 + backupCodes: string[]; 351 } 352 353 export interface PasskeyInfo { 354 + id: string; 355 + credentialId: string; 356 + friendlyName: string | null; 357 + createdAt: ISODateString; 358 + lastUsed: ISODateString | null; 359 } 360 361 export interface ListPasskeysResponse { 362 + passkeys: PasskeyInfo[]; 363 } 364 365 export interface StartPasskeyRegistrationResponse { 366 + options: PublicKeyCredentialCreationOptions; 367 } 368 369 export interface FinishPasskeyRegistrationResponse { 370 + id: string; 371 + credentialId: string; 372 } 373 374 export interface TrustedDevice { 375 + id: string; 376 + userAgent: string | null; 377 + friendlyName: string | null; 378 + trustedAt: ISODateString | null; 379 + trustedUntil: ISODateString | null; 380 + lastSeenAt: ISODateString; 381 } 382 383 export interface ListTrustedDevicesResponse { 384 + devices: TrustedDevice[]; 385 } 386 387 export interface ReauthStatus { 388 + requiresReauth: boolean; 389 + lastReauthAt: ISODateString | null; 390 + availableMethods: ReauthMethod[]; 391 } 392 393 export interface ReauthResponse { 394 + success: boolean; 395 + reauthAt: ISODateString; 396 } 397 398 export interface ReauthPasskeyStartResponse { 399 + options: PublicKeyCredentialRequestOptions; 400 } 401 402 export interface ReserveSigningKeyResponse { 403 + signingKey: PublicKeyMultibase; 404 } 405 406 export interface RecommendedDidCredentials { 407 + rotationKeys?: PublicKeyMultibase[]; 408 + alsoKnownAs?: string[]; 409 + verificationMethods?: { atproto?: PublicKeyMultibase }; 410 + services?: { atproto_pds?: { type: string; endpoint: string } }; 411 } 412 413 export interface PasskeyAccountCreateResponse { 414 + did: Did; 415 + handle: Handle; 416 + setupToken: string; 417 + setupExpiresAt: ISODateString; 418 } 419 420 export interface CompletePasskeySetupResponse { 421 + did: Did; 422 + handle: Handle; 423 + appPassword: string; 424 + appPasswordName: string; 425 } 426 427 export interface VerifyTokenResponse { 428 + success: boolean; 429 + did: Did; 430 + purpose: string; 431 + channel: VerificationChannel; 432 } 433 434 export interface BackupInfo { 435 + id: string; 436 + repoRev: string; 437 + repoRootCid: Cid; 438 + blockCount: number; 439 + sizeBytes: number; 440 + createdAt: ISODateString; 441 } 442 443 export interface ListBackupsResponse { 444 + backups: BackupInfo[]; 445 + backupEnabled: boolean; 446 } 447 448 export interface CreateBackupResponse { 449 + id: string; 450 + repoRev: string; 451 + sizeBytes: number; 452 + blockCount: number; 453 } 454 455 export interface SetBackupEnabledResponse { 456 + enabled: boolean; 457 } 458 459 export interface EmailUpdateResponse { 460 + tokenRequired: boolean; 461 } 462 463 export interface LegacyLoginPreference { 464 + allowLegacyLogin: boolean; 465 + hasMfa: boolean; 466 } 467 468 export interface UpdateLegacyLoginResponse { 469 + allowLegacyLogin: boolean; 470 } 471 472 export interface UpdateLocaleResponse { 473 + preferredLocale: string; 474 } 475 476 export interface PasswordStatus { 477 + hasPassword: boolean; 478 } 479 480 export interface SuccessResponse { 481 + success: boolean; 482 } 483 484 export interface CheckEmailVerifiedResponse { 485 + verified: boolean; 486 } 487 488 export interface VerifyMigrationEmailResponse { 489 + success: boolean; 490 + did: Did; 491 } 492 493 export interface ResendMigrationVerificationResponse { 494 + sent: boolean; 495 }
+80 -73
frontend/src/lib/types/branded.ts
··· 1 - declare const __brand: unique symbol 2 3 - type Brand<T, B extends string> = T & { readonly [__brand]: B } 4 5 - export type Did = Brand<string, 'Did'> 6 - export type DidPlc = Brand<Did, 'DidPlc'> 7 - export type DidWeb = Brand<Did, 'DidWeb'> 8 9 - export type Handle = Brand<string, 'Handle'> 10 - export type AccessToken = Brand<string, 'AccessToken'> 11 - export type RefreshToken = Brand<string, 'RefreshToken'> 12 - export type ServiceToken = Brand<string, 'ServiceToken'> 13 - export type SetupToken = Brand<string, 'SetupToken'> 14 15 - export type Cid = Brand<string, 'Cid'> 16 - export type Rkey = Brand<string, 'Rkey'> 17 - export type AtUri = Brand<string, 'AtUri'> 18 - export type Nsid = Brand<string, 'Nsid'> 19 20 - export type ISODateString = Brand<string, 'ISODateString'> 21 - export type EmailAddress = Brand<string, 'EmailAddress'> 22 - export type InviteCode = Brand<string, 'InviteCode'> 23 24 - export type PublicKeyMultibase = Brand<string, 'PublicKeyMultibase'> 25 - export type DidKeyString = Brand<string, 'DidKeyString'> 26 27 - const DID_PLC_REGEX = /^did:plc:[a-z2-7]{24}$/ 28 - const DID_WEB_REGEX = /^did:web:.+$/ 29 - 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])?$/ 30 - const AT_URI_REGEX = /^at:\/\/[^/]+\/[^/]+\/[^/]+$/ 31 - const CID_REGEX = /^[a-z2-7]{59}$|^baf[a-z2-7]+$/ 32 - const NSID_REGEX = /^[a-z]([a-z0-9-]*[a-z0-9])?(\.[a-z]([a-z0-9-]*[a-z0-9])?)+$/ 33 - const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ 34 - const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/ 35 36 export function isDid(s: string): s is Did { 37 - return s.startsWith('did:plc:') || s.startsWith('did:web:') 38 } 39 40 export function isDidPlc(s: string): s is DidPlc { 41 - return DID_PLC_REGEX.test(s) 42 } 43 44 export function isDidWeb(s: string): s is DidWeb { 45 - return DID_WEB_REGEX.test(s) 46 } 47 48 export function isHandle(s: string): s is Handle { 49 - return HANDLE_REGEX.test(s) && s.length <= 253 50 } 51 52 export function isAtUri(s: string): s is AtUri { 53 - return AT_URI_REGEX.test(s) 54 } 55 56 export function isCid(s: string): s is Cid { 57 - return CID_REGEX.test(s) 58 } 59 60 export function isNsid(s: string): s is Nsid { 61 - return NSID_REGEX.test(s) 62 } 63 64 export function isEmail(s: string): s is EmailAddress { 65 - return EMAIL_REGEX.test(s) 66 } 67 68 export function isISODate(s: string): s is ISODateString { 69 - return ISO_DATE_REGEX.test(s) 70 } 71 72 export function asDid(s: string): Did { 73 - if (!isDid(s)) throw new TypeError(`Invalid DID: ${s}`) 74 - return s 75 } 76 77 export function asDidPlc(s: string): DidPlc { 78 - if (!isDidPlc(s)) throw new TypeError(`Invalid DID:PLC: ${s}`) 79 - return s as DidPlc 80 } 81 82 export function asDidWeb(s: string): DidWeb { 83 - if (!isDidWeb(s)) throw new TypeError(`Invalid DID:WEB: ${s}`) 84 - return s as DidWeb 85 } 86 87 export function asHandle(s: string): Handle { 88 - if (!isHandle(s)) throw new TypeError(`Invalid handle: ${s}`) 89 - return s 90 } 91 92 export function asAtUri(s: string): AtUri { 93 - if (!isAtUri(s)) throw new TypeError(`Invalid AT-URI: ${s}`) 94 - return s 95 } 96 97 export function asCid(s: string): Cid { 98 - if (!isCid(s)) throw new TypeError(`Invalid CID: ${s}`) 99 - return s 100 } 101 102 export function asNsid(s: string): Nsid { 103 - if (!isNsid(s)) throw new TypeError(`Invalid NSID: ${s}`) 104 - return s 105 } 106 107 export function asEmail(s: string): EmailAddress { 108 - if (!isEmail(s)) throw new TypeError(`Invalid email: ${s}`) 109 - return s 110 } 111 112 export function asISODate(s: string): ISODateString { 113 - if (!isISODate(s)) throw new TypeError(`Invalid ISO date: ${s}`) 114 - return s 115 } 116 117 export function unsafeAsDid(s: string): Did { 118 - return s as Did 119 } 120 121 export function unsafeAsHandle(s: string): Handle { 122 - return s as Handle 123 } 124 125 export function unsafeAsAccessToken(s: string): AccessToken { 126 - return s as AccessToken 127 } 128 129 export function unsafeAsRefreshToken(s: string): RefreshToken { 130 - return s as RefreshToken 131 } 132 133 export function unsafeAsServiceToken(s: string): ServiceToken { 134 - return s as ServiceToken 135 } 136 137 export function unsafeAsSetupToken(s: string): SetupToken { 138 - return s as SetupToken 139 } 140 141 export function unsafeAsCid(s: string): Cid { 142 - return s as Cid 143 } 144 145 export function unsafeAsRkey(s: string): Rkey { 146 - return s as Rkey 147 } 148 149 export function unsafeAsAtUri(s: string): AtUri { 150 - return s as AtUri 151 } 152 153 export function unsafeAsNsid(s: string): Nsid { 154 - return s as Nsid 155 } 156 157 export function unsafeAsISODate(s: string): ISODateString { 158 - return s as ISODateString 159 } 160 161 export function unsafeAsEmail(s: string): EmailAddress { 162 - return s as EmailAddress 163 } 164 165 export function unsafeAsInviteCode(s: string): InviteCode { 166 - return s as InviteCode 167 } 168 169 export function unsafeAsPublicKeyMultibase(s: string): PublicKeyMultibase { 170 - return s as PublicKeyMultibase 171 } 172 173 export function unsafeAsDidKey(s: string): DidKeyString { 174 - return s as DidKeyString 175 } 176 177 - export function parseAtUri(uri: AtUri): { repo: Did; collection: Nsid; rkey: Rkey } { 178 - const parts = uri.replace('at://', '').split('/') 179 return { 180 repo: unsafeAsDid(parts[0]), 181 collection: unsafeAsNsid(parts[1]), 182 rkey: unsafeAsRkey(parts[2]), 183 - } 184 } 185 186 export function makeAtUri(repo: Did, collection: Nsid, rkey: Rkey): AtUri { 187 - return `at://${repo}/${collection}/${rkey}` as AtUri 188 }
··· 1 + declare const __brand: unique symbol; 2 3 + type Brand<T, B extends string> = T & { readonly [__brand]: B }; 4 5 + export type Did = Brand<string, "Did">; 6 + export type DidPlc = Brand<Did, "DidPlc">; 7 + export type DidWeb = Brand<Did, "DidWeb">; 8 9 + export type Handle = Brand<string, "Handle">; 10 + export type AccessToken = Brand<string, "AccessToken">; 11 + export type RefreshToken = Brand<string, "RefreshToken">; 12 + export type ServiceToken = Brand<string, "ServiceToken">; 13 + export type SetupToken = Brand<string, "SetupToken">; 14 15 + export type Cid = Brand<string, "Cid">; 16 + export type Rkey = Brand<string, "Rkey">; 17 + export type AtUri = Brand<string, "AtUri">; 18 + export type Nsid = Brand<string, "Nsid">; 19 20 + export type ISODateString = Brand<string, "ISODateString">; 21 + export type EmailAddress = Brand<string, "EmailAddress">; 22 + export type InviteCode = Brand<string, "InviteCode">; 23 24 + export type PublicKeyMultibase = Brand<string, "PublicKeyMultibase">; 25 + export type DidKeyString = Brand<string, "DidKeyString">; 26 27 + const DID_PLC_REGEX = /^did:plc:[a-z2-7]{24}$/; 28 + const DID_WEB_REGEX = /^did:web:.+$/; 29 + const HANDLE_REGEX = 30 + /^([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])?$/; 31 + const AT_URI_REGEX = /^at:\/\/[^/]+\/[^/]+\/[^/]+$/; 32 + const CID_REGEX = /^[a-z2-7]{59}$|^baf[a-z2-7]+$/; 33 + const NSID_REGEX = 34 + /^[a-z]([a-z0-9-]*[a-z0-9])?(\.[a-z]([a-z0-9-]*[a-z0-9])?)+$/; 35 + const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 36 + const ISO_DATE_REGEX = 37 + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/; 38 39 export function isDid(s: string): s is Did { 40 + return s.startsWith("did:plc:") || s.startsWith("did:web:"); 41 } 42 43 export function isDidPlc(s: string): s is DidPlc { 44 + return DID_PLC_REGEX.test(s); 45 } 46 47 export function isDidWeb(s: string): s is DidWeb { 48 + return DID_WEB_REGEX.test(s); 49 } 50 51 export function isHandle(s: string): s is Handle { 52 + return HANDLE_REGEX.test(s) && s.length <= 253; 53 } 54 55 export function isAtUri(s: string): s is AtUri { 56 + return AT_URI_REGEX.test(s); 57 } 58 59 export function isCid(s: string): s is Cid { 60 + return CID_REGEX.test(s); 61 } 62 63 export function isNsid(s: string): s is Nsid { 64 + return NSID_REGEX.test(s); 65 } 66 67 export function isEmail(s: string): s is EmailAddress { 68 + return EMAIL_REGEX.test(s); 69 } 70 71 export function isISODate(s: string): s is ISODateString { 72 + return ISO_DATE_REGEX.test(s); 73 } 74 75 export function asDid(s: string): Did { 76 + if (!isDid(s)) throw new TypeError(`Invalid DID: ${s}`); 77 + return s; 78 } 79 80 export function asDidPlc(s: string): DidPlc { 81 + if (!isDidPlc(s)) throw new TypeError(`Invalid DID:PLC: ${s}`); 82 + return s as DidPlc; 83 } 84 85 export function asDidWeb(s: string): DidWeb { 86 + if (!isDidWeb(s)) throw new TypeError(`Invalid DID:WEB: ${s}`); 87 + return s as DidWeb; 88 } 89 90 export function asHandle(s: string): Handle { 91 + if (!isHandle(s)) throw new TypeError(`Invalid handle: ${s}`); 92 + return s; 93 } 94 95 export function asAtUri(s: string): AtUri { 96 + if (!isAtUri(s)) throw new TypeError(`Invalid AT-URI: ${s}`); 97 + return s; 98 } 99 100 export function asCid(s: string): Cid { 101 + if (!isCid(s)) throw new TypeError(`Invalid CID: ${s}`); 102 + return s; 103 } 104 105 export function asNsid(s: string): Nsid { 106 + if (!isNsid(s)) throw new TypeError(`Invalid NSID: ${s}`); 107 + return s; 108 } 109 110 export function asEmail(s: string): EmailAddress { 111 + if (!isEmail(s)) throw new TypeError(`Invalid email: ${s}`); 112 + return s; 113 } 114 115 export function asISODate(s: string): ISODateString { 116 + if (!isISODate(s)) throw new TypeError(`Invalid ISO date: ${s}`); 117 + return s; 118 } 119 120 export function unsafeAsDid(s: string): Did { 121 + return s as Did; 122 } 123 124 export function unsafeAsHandle(s: string): Handle { 125 + return s as Handle; 126 } 127 128 export function unsafeAsAccessToken(s: string): AccessToken { 129 + return s as AccessToken; 130 } 131 132 export function unsafeAsRefreshToken(s: string): RefreshToken { 133 + return s as RefreshToken; 134 } 135 136 export function unsafeAsServiceToken(s: string): ServiceToken { 137 + return s as ServiceToken; 138 } 139 140 export function unsafeAsSetupToken(s: string): SetupToken { 141 + return s as SetupToken; 142 } 143 144 export function unsafeAsCid(s: string): Cid { 145 + return s as Cid; 146 } 147 148 export function unsafeAsRkey(s: string): Rkey { 149 + return s as Rkey; 150 } 151 152 export function unsafeAsAtUri(s: string): AtUri { 153 + return s as AtUri; 154 } 155 156 export function unsafeAsNsid(s: string): Nsid { 157 + return s as Nsid; 158 } 159 160 export function unsafeAsISODate(s: string): ISODateString { 161 + return s as ISODateString; 162 } 163 164 + export const unsafeAsISODateString = unsafeAsISODate; 165 + 166 export function unsafeAsEmail(s: string): EmailAddress { 167 + return s as EmailAddress; 168 } 169 170 export function unsafeAsInviteCode(s: string): InviteCode { 171 + return s as InviteCode; 172 } 173 174 export function unsafeAsPublicKeyMultibase(s: string): PublicKeyMultibase { 175 + return s as PublicKeyMultibase; 176 } 177 178 export function unsafeAsDidKey(s: string): DidKeyString { 179 + return s as DidKeyString; 180 } 181 182 + export function parseAtUri( 183 + uri: AtUri, 184 + ): { repo: Did; collection: Nsid; rkey: Rkey } { 185 + const parts = uri.replace("at://", "").split("/"); 186 return { 187 repo: unsafeAsDid(parts[0]), 188 collection: unsafeAsNsid(parts[1]), 189 rkey: unsafeAsRkey(parts[2]), 190 + }; 191 } 192 193 export function makeAtUri(repo: Did, collection: Nsid, rkey: Rkey): AtUri { 194 + return `at://${repo}/${collection}/${rkey}` as AtUri; 195 }
+17 -17
frontend/src/lib/types/exhaustive.ts
··· 1 export function assertNever(x: never, message?: string): never { 2 - throw new Error(message ?? `Unexpected value: ${JSON.stringify(x)}`) 3 } 4 5 export function exhaustive<T extends string | number | symbol>( 6 value: T, 7 - handlers: Record<T, () => void> 8 ): void { 9 - const handler = handlers[value] 10 if (handler) { 11 - handler() 12 } else { 13 - assertNever(value as never, `Unhandled case: ${String(value)}`) 14 } 15 } 16 17 export function exhaustiveMap<T extends string | number | symbol, R>( 18 value: T, 19 - handlers: Record<T, () => R> 20 ): R { 21 - const handler = handlers[value] 22 if (handler) { 23 - return handler() 24 } 25 - return assertNever(value as never, `Unhandled case: ${String(value)}`) 26 } 27 28 export async function exhaustiveAsync<T extends string | number | symbol>( 29 value: T, 30 - handlers: Record<T, () => Promise<void>> 31 ): Promise<void> { 32 - const handler = handlers[value] 33 if (handler) { 34 - await handler() 35 } else { 36 - assertNever(value as never, `Unhandled case: ${String(value)}`) 37 } 38 } 39 40 export async function exhaustiveMapAsync<T extends string | number | symbol, R>( 41 value: T, 42 - handlers: Record<T, () => Promise<R>> 43 ): Promise<R> { 44 - const handler = handlers[value] 45 if (handler) { 46 - return handler() 47 } 48 - return assertNever(value as never, `Unhandled case: ${String(value)}`) 49 }
··· 1 export function assertNever(x: never, message?: string): never { 2 + throw new Error(message ?? `Unexpected value: ${JSON.stringify(x)}`); 3 } 4 5 export function exhaustive<T extends string | number | symbol>( 6 value: T, 7 + handlers: Record<T, () => void>, 8 ): void { 9 + const handler = handlers[value]; 10 if (handler) { 11 + handler(); 12 } else { 13 + assertNever(value as never, `Unhandled case: ${String(value)}`); 14 } 15 } 16 17 export function exhaustiveMap<T extends string | number | symbol, R>( 18 value: T, 19 + handlers: Record<T, () => R>, 20 ): R { 21 + const handler = handlers[value]; 22 if (handler) { 23 + return handler(); 24 } 25 + return assertNever(value as never, `Unhandled case: ${String(value)}`); 26 } 27 28 export async function exhaustiveAsync<T extends string | number | symbol>( 29 value: T, 30 + handlers: Record<T, () => Promise<void>>, 31 ): Promise<void> { 32 + const handler = handlers[value]; 33 if (handler) { 34 + await handler(); 35 } else { 36 + assertNever(value as never, `Unhandled case: ${String(value)}`); 37 } 38 } 39 40 export async function exhaustiveMapAsync<T extends string | number | symbol, R>( 41 value: T, 42 + handlers: Record<T, () => Promise<R>>, 43 ): Promise<R> { 44 + const handler = handlers[value]; 45 if (handler) { 46 + return handler(); 47 } 48 + return assertNever(value as never, `Unhandled case: ${String(value)}`); 49 }
+5 -5
frontend/src/lib/types/index.ts
··· 1 - export * from './result' 2 - export * from './branded' 3 - export * from './exhaustive' 4 - export * from './api' 5 - export * from './routes'
··· 1 + export * from "./result.ts"; 2 + export * from "./branded.ts"; 3 + export * from "./exhaustive.ts"; 4 + export * from "./api.ts"; 5 + export * from "./routes.ts";
+51 -34
frontend/src/lib/types/result.ts
··· 1 export type Result<T, E = Error> = 2 | { ok: true; value: T } 3 - | { ok: false; error: E } 4 5 export function ok<T>(value: T): Result<T, never> { 6 - return { ok: true, value } 7 } 8 9 export function err<E>(error: E): Result<never, E> { 10 - return { ok: false, error } 11 } 12 13 - export function isOk<T, E>(result: Result<T, E>): result is { ok: true; value: T } { 14 - return result.ok 15 } 16 17 - export function isErr<T, E>(result: Result<T, E>): result is { ok: false; error: E } { 18 - return !result.ok 19 } 20 21 - export function map<T, U, E>(result: Result<T, E>, fn: (t: T) => U): Result<U, E> { 22 - return result.ok ? ok(fn(result.value)) : result 23 } 24 25 - export function mapErr<T, E, F>(result: Result<T, E>, fn: (e: E) => F): Result<T, F> { 26 - return result.ok ? result : err(fn(result.error)) 27 } 28 29 - export function flatMap<T, U, E>(result: Result<T, E>, fn: (t: T) => Result<U, E>): Result<U, E> { 30 - return result.ok ? fn(result.value) : result 31 } 32 33 export function unwrap<T, E>(result: Result<T, E>): T { 34 - if (result.ok) return result.value 35 - throw result.error instanceof Error ? result.error : new Error(String(result.error)) 36 } 37 38 export function unwrapOr<T, E>(result: Result<T, E>, defaultValue: T): T { 39 - return result.ok ? result.value : defaultValue 40 } 41 42 export function unwrapOrElse<T, E>(result: Result<T, E>, fn: (e: E) => T): T { 43 - return result.ok ? result.value : fn(result.error) 44 } 45 46 export function match<T, E, U>( 47 result: Result<T, E>, 48 - handlers: { ok: (t: T) => U; err: (e: E) => U } 49 ): U { 50 - return result.ok ? handlers.ok(result.value) : handlers.err(result.error) 51 } 52 53 - export async function tryAsync<T>(fn: () => Promise<T>): Promise<Result<T, Error>> { 54 try { 55 - return ok(await fn()) 56 } catch (e) { 57 - return err(e instanceof Error ? e : new Error(String(e))) 58 } 59 } 60 61 export async function tryAsyncWith<T, E>( 62 fn: () => Promise<T>, 63 - mapError: (e: unknown) => E 64 ): Promise<Result<T, E>> { 65 try { 66 - return ok(await fn()) 67 } catch (e) { 68 - return err(mapError(e)) 69 } 70 } 71 72 export function fromNullable<T>(value: T | null | undefined): Result<T, null> { 73 - return value != null ? ok(value) : err(null) 74 } 75 76 export function toNullable<T, E>(result: Result<T, E>): T | null { 77 - return result.ok ? result.value : null 78 } 79 80 export function collect<T, E>(results: Result<T, E>[]): Result<T[], E> { 81 - const values: T[] = [] 82 for (const result of results) { 83 - if (!result.ok) return result 84 - values.push(result.value) 85 } 86 - return ok(values) 87 } 88 89 export async function collectAsync<T, E>( 90 - results: Promise<Result<T, E>>[] 91 ): Promise<Result<T[], E>> { 92 - const settled = await Promise.all(results) 93 - return collect(settled) 94 }
··· 1 export type Result<T, E = Error> = 2 | { ok: true; value: T } 3 + | { ok: false; error: E }; 4 5 export function ok<T>(value: T): Result<T, never> { 6 + return { ok: true, value }; 7 } 8 9 export function err<E>(error: E): Result<never, E> { 10 + return { ok: false, error }; 11 } 12 13 + export function isOk<T, E>( 14 + result: Result<T, E>, 15 + ): result is { ok: true; value: T } { 16 + return result.ok; 17 } 18 19 + export function isErr<T, E>( 20 + result: Result<T, E>, 21 + ): result is { ok: false; error: E } { 22 + return !result.ok; 23 } 24 25 + export function map<T, U, E>( 26 + result: Result<T, E>, 27 + fn: (t: T) => U, 28 + ): Result<U, E> { 29 + return result.ok ? ok(fn(result.value)) : result; 30 } 31 32 + export function mapErr<T, E, F>( 33 + result: Result<T, E>, 34 + fn: (e: E) => F, 35 + ): Result<T, F> { 36 + return result.ok ? result : err(fn(result.error)); 37 } 38 39 + export function flatMap<T, U, E>( 40 + result: Result<T, E>, 41 + fn: (t: T) => Result<U, E>, 42 + ): Result<U, E> { 43 + return result.ok ? fn(result.value) : result; 44 } 45 46 export function unwrap<T, E>(result: Result<T, E>): T { 47 + if (result.ok) return result.value; 48 + throw result.error instanceof Error 49 + ? result.error 50 + : new Error(String(result.error)); 51 } 52 53 export function unwrapOr<T, E>(result: Result<T, E>, defaultValue: T): T { 54 + return result.ok ? result.value : defaultValue; 55 } 56 57 export function unwrapOrElse<T, E>(result: Result<T, E>, fn: (e: E) => T): T { 58 + return result.ok ? result.value : fn(result.error); 59 } 60 61 export function match<T, E, U>( 62 result: Result<T, E>, 63 + handlers: { ok: (t: T) => U; err: (e: E) => U }, 64 ): U { 65 + return result.ok ? handlers.ok(result.value) : handlers.err(result.error); 66 } 67 68 + export async function tryAsync<T>( 69 + fn: () => Promise<T>, 70 + ): Promise<Result<T, Error>> { 71 try { 72 + return ok(await fn()); 73 } catch (e) { 74 + return err(e instanceof Error ? e : new Error(String(e))); 75 } 76 } 77 78 export async function tryAsyncWith<T, E>( 79 fn: () => Promise<T>, 80 + mapError: (e: unknown) => E, 81 ): Promise<Result<T, E>> { 82 try { 83 + return ok(await fn()); 84 } catch (e) { 85 + return err(mapError(e)); 86 } 87 } 88 89 export function fromNullable<T>(value: T | null | undefined): Result<T, null> { 90 + return value != null ? ok(value) : err(null); 91 } 92 93 export function toNullable<T, E>(result: Result<T, E>): T | null { 94 + return result.ok ? result.value : null; 95 } 96 97 export function collect<T, E>(results: Result<T, E>[]): Result<T[], E> { 98 + const values: T[] = []; 99 for (const result of results) { 100 + if (!result.ok) return result; 101 + values.push(result.value); 102 } 103 + return ok(values); 104 } 105 106 export async function collectAsync<T, E>( 107 + results: Promise<Result<T, E>>[], 108 ): Promise<Result<T[], E>> { 109 + const settled = await Promise.all(results); 110 + return collect(settled); 111 }
+58 -58
frontend/src/lib/types/routes.ts
··· 1 export const routes = { 2 - login: '/login', 3 - register: '/register', 4 - registerPasskey: '/register-passkey', 5 - dashboard: '/dashboard', 6 - settings: '/settings', 7 - security: '/security', 8 - sessions: '/sessions', 9 - appPasswords: '/app-passwords', 10 - trustedDevices: '/trusted-devices', 11 - inviteCodes: '/invite-codes', 12 - comms: '/comms', 13 - repo: '/repo', 14 - controllers: '/controllers', 15 - delegationAudit: '/delegation-audit', 16 - actAs: '/act-as', 17 - didDocument: '/did-document', 18 - migrate: '/migrate', 19 - admin: '/admin', 20 - verify: '/verify', 21 - resetPassword: '/reset-password', 22 - recoverPasskey: '/recover-passkey', 23 - requestPasskeyRecovery: '/request-passkey-recovery', 24 - oauthLogin: '/oauth/login', 25 - oauthConsent: '/oauth/consent', 26 - oauthAccounts: '/oauth/accounts', 27 - oauth2fa: '/oauth/2fa', 28 - oauthTotp: '/oauth/totp', 29 - oauthPasskey: '/oauth/passkey', 30 - oauthDelegation: '/oauth/delegation', 31 - oauthError: '/oauth/error', 32 - } as const 33 34 - export type Route = (typeof routes)[keyof typeof routes] 35 36 - export type RouteKey = keyof typeof routes 37 38 export function isValidRoute(path: string): path is Route { 39 - return Object.values(routes).includes(path as Route) 40 } 41 42 export interface RouteParams { 43 - [routes.verify]: { token?: string; email?: string } 44 - [routes.resetPassword]: { token?: string } 45 - [routes.recoverPasskey]: { token?: string; did?: string } 46 - [routes.oauthLogin]: { request_uri?: string; error?: string } 47 - [routes.oauthConsent]: { request_uri?: string; client_id?: string } 48 - [routes.oauthAccounts]: { request_uri?: string } 49 - [routes.oauth2fa]: { request_uri?: string; channel?: string } 50 - [routes.oauthTotp]: { request_uri?: string } 51 - [routes.oauthPasskey]: { request_uri?: string } 52 - [routes.oauthDelegation]: { request_uri?: string; delegated_did?: string } 53 - [routes.oauthError]: { error?: string; error_description?: string } 54 - [routes.migrate]: { code?: string; state?: string } 55 } 56 57 - export type RoutesWithParams = keyof RouteParams 58 59 export function buildUrl<R extends Route>( 60 route: R, 61 - params?: R extends RoutesWithParams ? RouteParams[R] : never 62 ): string { 63 - if (!params) return route 64 - const searchParams = new URLSearchParams() 65 for (const [key, value] of Object.entries(params)) { 66 if (value != null) { 67 - searchParams.set(key, String(value)) 68 } 69 } 70 - const queryString = searchParams.toString() 71 - return queryString ? `${route}?${queryString}` : route 72 } 73 74 export function parseRouteParams<R extends RoutesWithParams>( 75 - route: R 76 ): RouteParams[R] { 77 - const params = new URLSearchParams(globalThis.location.search) 78 - const result: Record<string, string> = {} 79 for (const [key, value] of params.entries()) { 80 - result[key] = value 81 } 82 - return result as RouteParams[R] 83 }
··· 1 export const routes = { 2 + login: "/login", 3 + register: "/register", 4 + registerPasskey: "/register-passkey", 5 + dashboard: "/dashboard", 6 + settings: "/settings", 7 + security: "/security", 8 + sessions: "/sessions", 9 + appPasswords: "/app-passwords", 10 + trustedDevices: "/trusted-devices", 11 + inviteCodes: "/invite-codes", 12 + comms: "/comms", 13 + repo: "/repo", 14 + controllers: "/controllers", 15 + delegationAudit: "/delegation-audit", 16 + actAs: "/act-as", 17 + didDocument: "/did-document", 18 + migrate: "/migrate", 19 + admin: "/admin", 20 + verify: "/verify", 21 + resetPassword: "/reset-password", 22 + recoverPasskey: "/recover-passkey", 23 + requestPasskeyRecovery: "/request-passkey-recovery", 24 + oauthLogin: "/oauth/login", 25 + oauthConsent: "/oauth/consent", 26 + oauthAccounts: "/oauth/accounts", 27 + oauth2fa: "/oauth/2fa", 28 + oauthTotp: "/oauth/totp", 29 + oauthPasskey: "/oauth/passkey", 30 + oauthDelegation: "/oauth/delegation", 31 + oauthError: "/oauth/error", 32 + } as const; 33 34 + export type Route = (typeof routes)[keyof typeof routes]; 35 36 + export type RouteKey = keyof typeof routes; 37 38 export function isValidRoute(path: string): path is Route { 39 + return Object.values(routes).includes(path as Route); 40 } 41 42 export interface RouteParams { 43 + [routes.verify]: { token?: string; email?: string }; 44 + [routes.resetPassword]: { token?: string }; 45 + [routes.recoverPasskey]: { token?: string; did?: string }; 46 + [routes.oauthLogin]: { request_uri?: string; error?: string }; 47 + [routes.oauthConsent]: { request_uri?: string; client_id?: string }; 48 + [routes.oauthAccounts]: { request_uri?: string }; 49 + [routes.oauth2fa]: { request_uri?: string; channel?: string }; 50 + [routes.oauthTotp]: { request_uri?: string }; 51 + [routes.oauthPasskey]: { request_uri?: string }; 52 + [routes.oauthDelegation]: { request_uri?: string; delegated_did?: string }; 53 + [routes.oauthError]: { error?: string; error_description?: string }; 54 + [routes.migrate]: { code?: string; state?: string }; 55 } 56 57 + export type RoutesWithParams = keyof RouteParams; 58 59 export function buildUrl<R extends Route>( 60 route: R, 61 + params?: R extends RoutesWithParams ? RouteParams[R] : never, 62 ): string { 63 + if (!params) return route; 64 + const searchParams = new URLSearchParams(); 65 for (const [key, value] of Object.entries(params)) { 66 if (value != null) { 67 + searchParams.set(key, String(value)); 68 } 69 } 70 + const queryString = searchParams.toString(); 71 + return queryString ? `${route}?${queryString}` : route; 72 } 73 74 export function parseRouteParams<R extends RoutesWithParams>( 75 + _route: R, 76 ): RouteParams[R] { 77 + const params = new URLSearchParams(globalThis.location.search); 78 + const result: Record<string, string> = {}; 79 for (const [key, value] of params.entries()) { 80 + result[key] = value; 81 } 82 + return result as RouteParams[R]; 83 }
+135 -110
frontend/src/lib/types/schemas.ts
··· 1 - import { z } from 'zod' 2 - import type { 3 - Did, 4 - Handle, 5 - AccessToken, 6 - RefreshToken, 7 - Cid, 8 - Nsid, 9 - AtUri, 10 - Rkey, 11 - ISODateString, 12 - EmailAddress, 13 - InviteCode, 14 - PublicKeyMultibase, 15 - } from './branded' 16 import { 17 - unsafeAsDid, 18 - unsafeAsHandle, 19 unsafeAsAccessToken, 20 - unsafeAsRefreshToken, 21 - unsafeAsCid, 22 - unsafeAsNsid, 23 unsafeAsAtUri, 24 - unsafeAsRkey, 25 - unsafeAsISODate, 26 unsafeAsEmail, 27 unsafeAsInviteCode, 28 unsafeAsPublicKeyMultibase, 29 - } from './branded' 30 31 - const did = z.string().transform((s) => unsafeAsDid(s)) 32 - const handle = z.string().transform((s) => unsafeAsHandle(s)) 33 - const accessToken = z.string().transform((s) => unsafeAsAccessToken(s)) 34 - const refreshToken = z.string().transform((s) => unsafeAsRefreshToken(s)) 35 - const cid = z.string().transform((s) => unsafeAsCid(s)) 36 - const nsid = z.string().transform((s) => unsafeAsNsid(s)) 37 - const atUri = z.string().transform((s) => unsafeAsAtUri(s)) 38 - const rkey = z.string().transform((s) => unsafeAsRkey(s)) 39 - const isoDate = z.string().transform((s) => unsafeAsISODate(s)) 40 - const email = z.string().transform((s) => unsafeAsEmail(s)) 41 - const inviteCode = z.string().transform((s) => unsafeAsInviteCode(s)) 42 - const publicKeyMultibase = z.string().transform((s) => unsafeAsPublicKeyMultibase(s)) 43 44 - export const verificationChannel = z.enum(['email', 'discord', 'telegram', 'signal']) 45 - export const didType = z.enum(['plc', 'web', 'web-external']) 46 - export const accountStatus = z.enum(['active', 'deactivated', 'migrated', 'suspended', 'deleted']) 47 - export const sessionType = z.enum(['oauth', 'legacy', 'app_password']) 48 - export const reauthMethod = z.enum(['password', 'totp', 'passkey']) 49 50 export const sessionSchema = z.object({ 51 did: did, ··· 61 migratedAt: isoDate.optional(), 62 accessJwt: accessToken, 63 refreshJwt: refreshToken, 64 - }) 65 66 export const serverLinksSchema = z.object({ 67 privacyPolicy: z.string().optional(), 68 termsOfService: z.string().optional(), 69 - }) 70 71 export const serverDescriptionSchema = z.object({ 72 availableUserDomains: z.array(z.string()), ··· 75 version: z.string().optional(), 76 availableCommsChannels: z.array(verificationChannel).optional(), 77 selfHostedDidWebEnabled: z.boolean().optional(), 78 - }) 79 80 export const appPasswordSchema = z.object({ 81 name: z.string(), 82 createdAt: isoDate, 83 scopes: z.string().optional(), 84 createdByController: z.string().optional(), 85 - }) 86 87 export const createdAppPasswordSchema = z.object({ 88 name: z.string(), 89 password: z.string(), 90 createdAt: isoDate, 91 scopes: z.string().optional(), 92 - }) 93 94 export const inviteCodeUseSchema = z.object({ 95 usedBy: did, 96 usedByHandle: handle.optional(), 97 usedAt: isoDate, 98 - }) 99 100 export const inviteCodeInfoSchema = z.object({ 101 code: inviteCode, ··· 105 createdBy: did, 106 createdAt: isoDate, 107 uses: z.array(inviteCodeUseSchema), 108 - }) 109 110 export const sessionInfoSchema = z.object({ 111 id: z.string(), ··· 114 createdAt: isoDate, 115 expiresAt: isoDate, 116 isCurrent: z.boolean(), 117 - }) 118 119 export const listSessionsResponseSchema = z.object({ 120 sessions: z.array(sessionInfoSchema), 121 - }) 122 123 export const totpStatusSchema = z.object({ 124 enabled: z.boolean(), 125 hasBackupCodes: z.boolean(), 126 - }) 127 128 export const totpSecretSchema = z.object({ 129 uri: z.string(), 130 qrBase64: z.string(), 131 - }) 132 133 export const enableTotpResponseSchema = z.object({ 134 success: z.boolean(), 135 backupCodes: z.array(z.string()), 136 - }) 137 138 export const passkeyInfoSchema = z.object({ 139 id: z.string(), ··· 141 friendlyName: z.string().nullable(), 142 createdAt: isoDate, 143 lastUsed: isoDate.nullable(), 144 - }) 145 146 export const listPasskeysResponseSchema = z.object({ 147 passkeys: z.array(passkeyInfoSchema), 148 - }) 149 150 export const trustedDeviceSchema = z.object({ 151 id: z.string(), ··· 154 trustedAt: isoDate.nullable(), 155 trustedUntil: isoDate.nullable(), 156 lastSeenAt: isoDate, 157 - }) 158 159 export const listTrustedDevicesResponseSchema = z.object({ 160 devices: z.array(trustedDeviceSchema), 161 - }) 162 163 export const reauthStatusSchema = z.object({ 164 requiresReauth: z.boolean(), 165 lastReauthAt: isoDate.nullable(), 166 availableMethods: z.array(reauthMethod), 167 - }) 168 169 export const reauthResponseSchema = z.object({ 170 success: z.boolean(), 171 reauthAt: isoDate, 172 - }) 173 174 export const notificationPrefsSchema = z.object({ 175 preferredChannel: verificationChannel, ··· 180 telegramVerified: z.boolean(), 181 signalNumber: z.string().nullable(), 182 signalVerified: z.boolean(), 183 - }) 184 185 export const verificationMethodSchema = z.object({ 186 id: z.string(), 187 type: z.string(), 188 controller: z.string(), 189 publicKeyMultibase: publicKeyMultibase, 190 - }) 191 192 export const serviceEndpointSchema = z.object({ 193 id: z.string(), 194 type: z.string(), 195 serviceEndpoint: z.string(), 196 - }) 197 198 export const didDocumentSchema = z.object({ 199 - '@context': z.array(z.string()), 200 id: did, 201 alsoKnownAs: z.array(z.string()), 202 verificationMethod: z.array(verificationMethodSchema), 203 service: z.array(serviceEndpointSchema), 204 - }) 205 206 export const repoDescriptionSchema = z.object({ 207 handle: handle, ··· 209 didDoc: didDocumentSchema, 210 collections: z.array(nsid), 211 handleIsCorrect: z.boolean(), 212 - }) 213 214 export const recordInfoSchema = z.object({ 215 uri: atUri, 216 cid: cid, 217 value: z.unknown(), 218 - }) 219 220 export const listRecordsResponseSchema = z.object({ 221 records: z.array(recordInfoSchema), 222 cursor: z.string().optional(), 223 - }) 224 225 export const recordResponseSchema = z.object({ 226 uri: atUri, 227 cid: cid, 228 value: z.unknown(), 229 - }) 230 231 export const createRecordResponseSchema = z.object({ 232 uri: atUri, 233 cid: cid, 234 - }) 235 236 export const serverStatsSchema = z.object({ 237 userCount: z.number(), 238 repoCount: z.number(), 239 recordCount: z.number(), 240 blobStorageBytes: z.number(), 241 - }) 242 243 export const serverConfigSchema = z.object({ 244 serverName: z.string(), ··· 247 secondaryColor: z.string().nullable(), 248 secondaryColorDark: z.string().nullable(), 249 logoCid: cid.nullable(), 250 - }) 251 252 export const passwordStatusSchema = z.object({ 253 hasPassword: z.boolean(), 254 - }) 255 256 export const successResponseSchema = z.object({ 257 success: z.boolean(), 258 - }) 259 260 export const legacyLoginPreferenceSchema = z.object({ 261 allowLegacyLogin: z.boolean(), 262 hasMfa: z.boolean(), 263 - }) 264 265 export const accountInfoSchema = z.object({ 266 did: did, ··· 270 emailConfirmedAt: isoDate.optional(), 271 invitesDisabled: z.boolean().optional(), 272 deactivatedAt: isoDate.optional(), 273 - }) 274 275 export const searchAccountsResponseSchema = z.object({ 276 cursor: z.string().optional(), 277 accounts: z.array(accountInfoSchema), 278 - }) 279 280 export const backupInfoSchema = z.object({ 281 id: z.string(), ··· 284 blockCount: z.number(), 285 sizeBytes: z.number(), 286 createdAt: isoDate, 287 - }) 288 289 export const listBackupsResponseSchema = z.object({ 290 backups: z.array(backupInfoSchema), 291 backupEnabled: z.boolean(), 292 - }) 293 294 export const createBackupResponseSchema = z.object({ 295 id: z.string(), 296 repoRev: z.string(), 297 sizeBytes: z.number(), 298 blockCount: z.number(), 299 - }) 300 301 - export type ValidatedSession = z.infer<typeof sessionSchema> 302 - export type ValidatedServerDescription = z.infer<typeof serverDescriptionSchema> 303 - export type ValidatedAppPassword = z.infer<typeof appPasswordSchema> 304 - export type ValidatedCreatedAppPassword = z.infer<typeof createdAppPasswordSchema> 305 - export type ValidatedInviteCodeInfo = z.infer<typeof inviteCodeInfoSchema> 306 - export type ValidatedSessionInfo = z.infer<typeof sessionInfoSchema> 307 - export type ValidatedListSessionsResponse = z.infer<typeof listSessionsResponseSchema> 308 - export type ValidatedTotpStatus = z.infer<typeof totpStatusSchema> 309 - export type ValidatedTotpSecret = z.infer<typeof totpSecretSchema> 310 - export type ValidatedEnableTotpResponse = z.infer<typeof enableTotpResponseSchema> 311 - export type ValidatedPasskeyInfo = z.infer<typeof passkeyInfoSchema> 312 - export type ValidatedListPasskeysResponse = z.infer<typeof listPasskeysResponseSchema> 313 - export type ValidatedTrustedDevice = z.infer<typeof trustedDeviceSchema> 314 - export type ValidatedListTrustedDevicesResponse = z.infer<typeof listTrustedDevicesResponseSchema> 315 - export type ValidatedReauthStatus = z.infer<typeof reauthStatusSchema> 316 - export type ValidatedReauthResponse = z.infer<typeof reauthResponseSchema> 317 - export type ValidatedNotificationPrefs = z.infer<typeof notificationPrefsSchema> 318 - export type ValidatedDidDocument = z.infer<typeof didDocumentSchema> 319 - export type ValidatedRepoDescription = z.infer<typeof repoDescriptionSchema> 320 - export type ValidatedListRecordsResponse = z.infer<typeof listRecordsResponseSchema> 321 - export type ValidatedRecordResponse = z.infer<typeof recordResponseSchema> 322 - export type ValidatedCreateRecordResponse = z.infer<typeof createRecordResponseSchema> 323 - export type ValidatedServerStats = z.infer<typeof serverStatsSchema> 324 - export type ValidatedServerConfig = z.infer<typeof serverConfigSchema> 325 - export type ValidatedPasswordStatus = z.infer<typeof passwordStatusSchema> 326 - export type ValidatedSuccessResponse = z.infer<typeof successResponseSchema> 327 - export type ValidatedLegacyLoginPreference = z.infer<typeof legacyLoginPreferenceSchema> 328 - export type ValidatedAccountInfo = z.infer<typeof accountInfoSchema> 329 - export type ValidatedSearchAccountsResponse = z.infer<typeof searchAccountsResponseSchema> 330 - export type ValidatedBackupInfo = z.infer<typeof backupInfoSchema> 331 - export type ValidatedListBackupsResponse = z.infer<typeof listBackupsResponseSchema> 332 - export type ValidatedCreateBackupResponse = z.infer<typeof createBackupResponseSchema>
··· 1 + import { z } from "zod"; 2 import { 3 unsafeAsAccessToken, 4 unsafeAsAtUri, 5 + unsafeAsCid, 6 + unsafeAsDid, 7 unsafeAsEmail, 8 + unsafeAsHandle, 9 unsafeAsInviteCode, 10 + unsafeAsISODate, 11 + unsafeAsNsid, 12 unsafeAsPublicKeyMultibase, 13 + unsafeAsRefreshToken, 14 + unsafeAsRkey, 15 + } from "./branded.ts"; 16 17 + const did = z.string().transform((s) => unsafeAsDid(s)); 18 + const handle = z.string().transform((s) => unsafeAsHandle(s)); 19 + const accessToken = z.string().transform((s) => unsafeAsAccessToken(s)); 20 + const refreshToken = z.string().transform((s) => unsafeAsRefreshToken(s)); 21 + const cid = z.string().transform((s) => unsafeAsCid(s)); 22 + const nsid = z.string().transform((s) => unsafeAsNsid(s)); 23 + const atUri = z.string().transform((s) => unsafeAsAtUri(s)); 24 + const _rkey = z.string().transform((s) => unsafeAsRkey(s)); 25 + const isoDate = z.string().transform((s) => unsafeAsISODate(s)); 26 + const email = z.string().transform((s) => unsafeAsEmail(s)); 27 + const inviteCode = z.string().transform((s) => unsafeAsInviteCode(s)); 28 + const publicKeyMultibase = z.string().transform((s) => 29 + unsafeAsPublicKeyMultibase(s) 30 + ); 31 32 + export const verificationChannel = z.enum([ 33 + "email", 34 + "discord", 35 + "telegram", 36 + "signal", 37 + ]); 38 + export const didType = z.enum(["plc", "web", "web-external"]); 39 + export const accountStatus = z.enum([ 40 + "active", 41 + "deactivated", 42 + "migrated", 43 + "suspended", 44 + "deleted", 45 + ]); 46 + export const sessionType = z.enum(["oauth", "legacy", "app_password"]); 47 + export const reauthMethod = z.enum(["password", "totp", "passkey"]); 48 49 export const sessionSchema = z.object({ 50 did: did, ··· 60 migratedAt: isoDate.optional(), 61 accessJwt: accessToken, 62 refreshJwt: refreshToken, 63 + }); 64 65 export const serverLinksSchema = z.object({ 66 privacyPolicy: z.string().optional(), 67 termsOfService: z.string().optional(), 68 + }); 69 70 export const serverDescriptionSchema = z.object({ 71 availableUserDomains: z.array(z.string()), ··· 74 version: z.string().optional(), 75 availableCommsChannels: z.array(verificationChannel).optional(), 76 selfHostedDidWebEnabled: z.boolean().optional(), 77 + }); 78 79 export const appPasswordSchema = z.object({ 80 name: z.string(), 81 createdAt: isoDate, 82 scopes: z.string().optional(), 83 createdByController: z.string().optional(), 84 + }); 85 86 export const createdAppPasswordSchema = z.object({ 87 name: z.string(), 88 password: z.string(), 89 createdAt: isoDate, 90 scopes: z.string().optional(), 91 + }); 92 93 export const inviteCodeUseSchema = z.object({ 94 usedBy: did, 95 usedByHandle: handle.optional(), 96 usedAt: isoDate, 97 + }); 98 99 export const inviteCodeInfoSchema = z.object({ 100 code: inviteCode, ··· 104 createdBy: did, 105 createdAt: isoDate, 106 uses: z.array(inviteCodeUseSchema), 107 + }); 108 109 export const sessionInfoSchema = z.object({ 110 id: z.string(), ··· 113 createdAt: isoDate, 114 expiresAt: isoDate, 115 isCurrent: z.boolean(), 116 + }); 117 118 export const listSessionsResponseSchema = z.object({ 119 sessions: z.array(sessionInfoSchema), 120 + }); 121 122 export const totpStatusSchema = z.object({ 123 enabled: z.boolean(), 124 hasBackupCodes: z.boolean(), 125 + }); 126 127 export const totpSecretSchema = z.object({ 128 uri: z.string(), 129 qrBase64: z.string(), 130 + }); 131 132 export const enableTotpResponseSchema = z.object({ 133 success: z.boolean(), 134 backupCodes: z.array(z.string()), 135 + }); 136 137 export const passkeyInfoSchema = z.object({ 138 id: z.string(), ··· 140 friendlyName: z.string().nullable(), 141 createdAt: isoDate, 142 lastUsed: isoDate.nullable(), 143 + }); 144 145 export const listPasskeysResponseSchema = z.object({ 146 passkeys: z.array(passkeyInfoSchema), 147 + }); 148 149 export const trustedDeviceSchema = z.object({ 150 id: z.string(), ··· 153 trustedAt: isoDate.nullable(), 154 trustedUntil: isoDate.nullable(), 155 lastSeenAt: isoDate, 156 + }); 157 158 export const listTrustedDevicesResponseSchema = z.object({ 159 devices: z.array(trustedDeviceSchema), 160 + }); 161 162 export const reauthStatusSchema = z.object({ 163 requiresReauth: z.boolean(), 164 lastReauthAt: isoDate.nullable(), 165 availableMethods: z.array(reauthMethod), 166 + }); 167 168 export const reauthResponseSchema = z.object({ 169 success: z.boolean(), 170 reauthAt: isoDate, 171 + }); 172 173 export const notificationPrefsSchema = z.object({ 174 preferredChannel: verificationChannel, ··· 179 telegramVerified: z.boolean(), 180 signalNumber: z.string().nullable(), 181 signalVerified: z.boolean(), 182 + }); 183 184 export const verificationMethodSchema = z.object({ 185 id: z.string(), 186 type: z.string(), 187 controller: z.string(), 188 publicKeyMultibase: publicKeyMultibase, 189 + }); 190 191 export const serviceEndpointSchema = z.object({ 192 id: z.string(), 193 type: z.string(), 194 serviceEndpoint: z.string(), 195 + }); 196 197 export const didDocumentSchema = z.object({ 198 + "@context": z.array(z.string()), 199 id: did, 200 alsoKnownAs: z.array(z.string()), 201 verificationMethod: z.array(verificationMethodSchema), 202 service: z.array(serviceEndpointSchema), 203 + }); 204 205 export const repoDescriptionSchema = z.object({ 206 handle: handle, ··· 208 didDoc: didDocumentSchema, 209 collections: z.array(nsid), 210 handleIsCorrect: z.boolean(), 211 + }); 212 213 export const recordInfoSchema = z.object({ 214 uri: atUri, 215 cid: cid, 216 value: z.unknown(), 217 + }); 218 219 export const listRecordsResponseSchema = z.object({ 220 records: z.array(recordInfoSchema), 221 cursor: z.string().optional(), 222 + }); 223 224 export const recordResponseSchema = z.object({ 225 uri: atUri, 226 cid: cid, 227 value: z.unknown(), 228 + }); 229 230 export const createRecordResponseSchema = z.object({ 231 uri: atUri, 232 cid: cid, 233 + }); 234 235 export const serverStatsSchema = z.object({ 236 userCount: z.number(), 237 repoCount: z.number(), 238 recordCount: z.number(), 239 blobStorageBytes: z.number(), 240 + }); 241 242 export const serverConfigSchema = z.object({ 243 serverName: z.string(), ··· 246 secondaryColor: z.string().nullable(), 247 secondaryColorDark: z.string().nullable(), 248 logoCid: cid.nullable(), 249 + }); 250 251 export const passwordStatusSchema = z.object({ 252 hasPassword: z.boolean(), 253 + }); 254 255 export const successResponseSchema = z.object({ 256 success: z.boolean(), 257 + }); 258 259 export const legacyLoginPreferenceSchema = z.object({ 260 allowLegacyLogin: z.boolean(), 261 hasMfa: z.boolean(), 262 + }); 263 264 export const accountInfoSchema = z.object({ 265 did: did, ··· 269 emailConfirmedAt: isoDate.optional(), 270 invitesDisabled: z.boolean().optional(), 271 deactivatedAt: isoDate.optional(), 272 + }); 273 274 export const searchAccountsResponseSchema = z.object({ 275 cursor: z.string().optional(), 276 accounts: z.array(accountInfoSchema), 277 + }); 278 279 export const backupInfoSchema = z.object({ 280 id: z.string(), ··· 283 blockCount: z.number(), 284 sizeBytes: z.number(), 285 createdAt: isoDate, 286 + }); 287 288 export const listBackupsResponseSchema = z.object({ 289 backups: z.array(backupInfoSchema), 290 backupEnabled: z.boolean(), 291 + }); 292 293 export const createBackupResponseSchema = z.object({ 294 id: z.string(), 295 repoRev: z.string(), 296 sizeBytes: z.number(), 297 blockCount: z.number(), 298 + }); 299 300 + export type ValidatedSession = z.infer<typeof sessionSchema>; 301 + export type ValidatedServerDescription = z.infer< 302 + typeof serverDescriptionSchema 303 + >; 304 + export type ValidatedAppPassword = z.infer<typeof appPasswordSchema>; 305 + export type ValidatedCreatedAppPassword = z.infer< 306 + typeof createdAppPasswordSchema 307 + >; 308 + export type ValidatedInviteCodeInfo = z.infer<typeof inviteCodeInfoSchema>; 309 + export type ValidatedSessionInfo = z.infer<typeof sessionInfoSchema>; 310 + export type ValidatedListSessionsResponse = z.infer< 311 + typeof listSessionsResponseSchema 312 + >; 313 + export type ValidatedTotpStatus = z.infer<typeof totpStatusSchema>; 314 + export type ValidatedTotpSecret = z.infer<typeof totpSecretSchema>; 315 + export type ValidatedEnableTotpResponse = z.infer< 316 + typeof enableTotpResponseSchema 317 + >; 318 + export type ValidatedPasskeyInfo = z.infer<typeof passkeyInfoSchema>; 319 + export type ValidatedListPasskeysResponse = z.infer< 320 + typeof listPasskeysResponseSchema 321 + >; 322 + export type ValidatedTrustedDevice = z.infer<typeof trustedDeviceSchema>; 323 + export type ValidatedListTrustedDevicesResponse = z.infer< 324 + typeof listTrustedDevicesResponseSchema 325 + >; 326 + export type ValidatedReauthStatus = z.infer<typeof reauthStatusSchema>; 327 + export type ValidatedReauthResponse = z.infer<typeof reauthResponseSchema>; 328 + export type ValidatedNotificationPrefs = z.infer< 329 + typeof notificationPrefsSchema 330 + >; 331 + export type ValidatedDidDocument = z.infer<typeof didDocumentSchema>; 332 + export type ValidatedRepoDescription = z.infer<typeof repoDescriptionSchema>; 333 + export type ValidatedListRecordsResponse = z.infer< 334 + typeof listRecordsResponseSchema 335 + >; 336 + export type ValidatedRecordResponse = z.infer<typeof recordResponseSchema>; 337 + export type ValidatedCreateRecordResponse = z.infer< 338 + typeof createRecordResponseSchema 339 + >; 340 + export type ValidatedServerStats = z.infer<typeof serverStatsSchema>; 341 + export type ValidatedServerConfig = z.infer<typeof serverConfigSchema>; 342 + export type ValidatedPasswordStatus = z.infer<typeof passwordStatusSchema>; 343 + export type ValidatedSuccessResponse = z.infer<typeof successResponseSchema>; 344 + export type ValidatedLegacyLoginPreference = z.infer< 345 + typeof legacyLoginPreferenceSchema 346 + >; 347 + export type ValidatedAccountInfo = z.infer<typeof accountInfoSchema>; 348 + export type ValidatedSearchAccountsResponse = z.infer< 349 + typeof searchAccountsResponseSchema 350 + >; 351 + export type ValidatedBackupInfo = z.infer<typeof backupInfoSchema>; 352 + export type ValidatedListBackupsResponse = z.infer< 353 + typeof listBackupsResponseSchema 354 + >; 355 + export type ValidatedCreateBackupResponse = z.infer< 356 + typeof createBackupResponseSchema 357 + >;
+99 -84
frontend/src/lib/utils/array.ts
··· 1 - import type { Option } from './option' 2 3 export function first<T>(arr: readonly T[]): Option<T> { 4 - return arr[0] ?? null 5 } 6 7 export function last<T>(arr: readonly T[]): Option<T> { 8 - return arr[arr.length - 1] ?? null 9 } 10 11 export function at<T>(arr: readonly T[], index: number): Option<T> { 12 - if (index < 0) index = arr.length + index 13 - return arr[index] ?? null 14 } 15 16 - export function find<T>(arr: readonly T[], predicate: (t: T) => boolean): Option<T> { 17 - return arr.find(predicate) ?? null 18 } 19 20 - export function findMap<T, U>(arr: readonly T[], fn: (t: T) => Option<U>): Option<U> { 21 for (const item of arr) { 22 - const result = fn(item) 23 - if (result != null) return result 24 } 25 - return null 26 } 27 28 - export function findIndex<T>(arr: readonly T[], predicate: (t: T) => boolean): Option<number> { 29 - const index = arr.findIndex(predicate) 30 - return index >= 0 ? index : null 31 } 32 33 export function partition<T>( 34 arr: readonly T[], 35 - predicate: (t: T) => boolean 36 ): [T[], T[]] { 37 - const pass: T[] = [] 38 - const fail: T[] = [] 39 for (const item of arr) { 40 if (predicate(item)) { 41 - pass.push(item) 42 } else { 43 - fail.push(item) 44 } 45 } 46 - return [pass, fail] 47 } 48 49 export function groupBy<T, K extends string | number>( 50 arr: readonly T[], 51 - keyFn: (t: T) => K 52 ): Record<K, T[]> { 53 - const result = {} as Record<K, T[]> 54 for (const item of arr) { 55 - const key = keyFn(item) 56 if (!result[key]) { 57 - result[key] = [] 58 } 59 - result[key].push(item) 60 } 61 - return result 62 } 63 64 export function unique<T>(arr: readonly T[]): T[] { 65 - return [...new Set(arr)] 66 } 67 68 export function uniqueBy<T, K>(arr: readonly T[], keyFn: (t: T) => K): T[] { 69 - const seen = new Set<K>() 70 - const result: T[] = [] 71 for (const item of arr) { 72 - const key = keyFn(item) 73 if (!seen.has(key)) { 74 - seen.add(key) 75 - result.push(item) 76 } 77 } 78 - return result 79 } 80 81 - export function sortBy<T>(arr: readonly T[], keyFn: (t: T) => number | string): T[] { 82 return [...arr].sort((a, b) => { 83 - const ka = keyFn(a) 84 - const kb = keyFn(b) 85 - if (ka < kb) return -1 86 - if (ka > kb) return 1 87 - return 0 88 - }) 89 } 90 91 - export function sortByDesc<T>(arr: readonly T[], keyFn: (t: T) => number | string): T[] { 92 return [...arr].sort((a, b) => { 93 - const ka = keyFn(a) 94 - const kb = keyFn(b) 95 - if (ka > kb) return -1 96 - if (ka < kb) return 1 97 - return 0 98 - }) 99 } 100 101 export function chunk<T>(arr: readonly T[], size: number): T[][] { 102 - const result: T[][] = [] 103 for (let i = 0; i < arr.length; i += size) { 104 - result.push(arr.slice(i, i + size)) 105 } 106 - return result 107 } 108 109 export function zip<T, U>(a: readonly T[], b: readonly U[]): [T, U][] { 110 - const length = Math.min(a.length, b.length) 111 - const result: [T, U][] = [] 112 for (let i = 0; i < length; i++) { 113 - result.push([a[i], b[i]]) 114 } 115 - return result 116 } 117 118 export function zipWith<T, U, R>( 119 a: readonly T[], 120 b: readonly U[], 121 - fn: (t: T, u: U) => R 122 ): R[] { 123 - const length = Math.min(a.length, b.length) 124 - const result: R[] = [] 125 for (let i = 0; i < length; i++) { 126 - result.push(fn(a[i], b[i])) 127 } 128 - return result 129 } 130 131 export function intersperse<T>(arr: readonly T[], separator: T): T[] { 132 - if (arr.length <= 1) return [...arr] 133 - const result: T[] = [arr[0]] 134 for (let i = 1; i < arr.length; i++) { 135 - result.push(separator, arr[i]) 136 } 137 - return result 138 } 139 140 export function range(start: number, end: number): number[] { 141 - const result: number[] = [] 142 for (let i = start; i < end; i++) { 143 - result.push(i) 144 } 145 - return result 146 } 147 148 export function isEmpty<T>(arr: readonly T[]): boolean { 149 - return arr.length === 0 150 } 151 152 export function isNonEmpty<T>(arr: readonly T[]): arr is [T, ...T[]] { 153 - return arr.length > 0 154 } 155 156 export function sum(arr: readonly number[]): number { 157 - return arr.reduce((acc, n) => acc + n, 0) 158 } 159 160 export function sumBy<T>(arr: readonly T[], fn: (t: T) => number): number { 161 - return arr.reduce((acc, t) => acc + fn(t), 0) 162 } 163 164 export function maxBy<T>(arr: readonly T[], fn: (t: T) => number): Option<T> { 165 - if (arr.length === 0) return null 166 - let max = arr[0] 167 - let maxValue = fn(max) 168 for (let i = 1; i < arr.length; i++) { 169 - const value = fn(arr[i]) 170 if (value > maxValue) { 171 - max = arr[i] 172 - maxValue = value 173 } 174 } 175 - return max 176 } 177 178 export function minBy<T>(arr: readonly T[], fn: (t: T) => number): Option<T> { 179 - if (arr.length === 0) return null 180 - let min = arr[0] 181 - let minValue = fn(min) 182 for (let i = 1; i < arr.length; i++) { 183 - const value = fn(arr[i]) 184 if (value < minValue) { 185 - min = arr[i] 186 - minValue = value 187 } 188 } 189 - return min 190 }
··· 1 + import type { Option } from "./option.ts"; 2 3 export function first<T>(arr: readonly T[]): Option<T> { 4 + return arr[0] ?? null; 5 } 6 7 export function last<T>(arr: readonly T[]): Option<T> { 8 + return arr[arr.length - 1] ?? null; 9 } 10 11 export function at<T>(arr: readonly T[], index: number): Option<T> { 12 + if (index < 0) index = arr.length + index; 13 + return arr[index] ?? null; 14 } 15 16 + export function find<T>( 17 + arr: readonly T[], 18 + predicate: (t: T) => boolean, 19 + ): Option<T> { 20 + return arr.find(predicate) ?? null; 21 } 22 23 + export function findMap<T, U>( 24 + arr: readonly T[], 25 + fn: (t: T) => Option<U>, 26 + ): Option<U> { 27 for (const item of arr) { 28 + const result = fn(item); 29 + if (result != null) return result; 30 } 31 + return null; 32 } 33 34 + export function findIndex<T>( 35 + arr: readonly T[], 36 + predicate: (t: T) => boolean, 37 + ): Option<number> { 38 + const index = arr.findIndex(predicate); 39 + return index >= 0 ? index : null; 40 } 41 42 export function partition<T>( 43 arr: readonly T[], 44 + predicate: (t: T) => boolean, 45 ): [T[], T[]] { 46 + const pass: T[] = []; 47 + const fail: T[] = []; 48 for (const item of arr) { 49 if (predicate(item)) { 50 + pass.push(item); 51 } else { 52 + fail.push(item); 53 } 54 } 55 + return [pass, fail]; 56 } 57 58 export function groupBy<T, K extends string | number>( 59 arr: readonly T[], 60 + keyFn: (t: T) => K, 61 ): Record<K, T[]> { 62 + const result = {} as Record<K, T[]>; 63 for (const item of arr) { 64 + const key = keyFn(item); 65 if (!result[key]) { 66 + result[key] = []; 67 } 68 + result[key].push(item); 69 } 70 + return result; 71 } 72 73 export function unique<T>(arr: readonly T[]): T[] { 74 + return [...new Set(arr)]; 75 } 76 77 export function uniqueBy<T, K>(arr: readonly T[], keyFn: (t: T) => K): T[] { 78 + const seen = new Set<K>(); 79 + const result: T[] = []; 80 for (const item of arr) { 81 + const key = keyFn(item); 82 if (!seen.has(key)) { 83 + seen.add(key); 84 + result.push(item); 85 } 86 } 87 + return result; 88 } 89 90 + export function sortBy<T>( 91 + arr: readonly T[], 92 + keyFn: (t: T) => number | string, 93 + ): T[] { 94 return [...arr].sort((a, b) => { 95 + const ka = keyFn(a); 96 + const kb = keyFn(b); 97 + if (ka < kb) return -1; 98 + if (ka > kb) return 1; 99 + return 0; 100 + }); 101 } 102 103 + export function sortByDesc<T>( 104 + arr: readonly T[], 105 + keyFn: (t: T) => number | string, 106 + ): T[] { 107 return [...arr].sort((a, b) => { 108 + const ka = keyFn(a); 109 + const kb = keyFn(b); 110 + if (ka > kb) return -1; 111 + if (ka < kb) return 1; 112 + return 0; 113 + }); 114 } 115 116 export function chunk<T>(arr: readonly T[], size: number): T[][] { 117 + const result: T[][] = []; 118 for (let i = 0; i < arr.length; i += size) { 119 + result.push(arr.slice(i, i + size)); 120 } 121 + return result; 122 } 123 124 export function zip<T, U>(a: readonly T[], b: readonly U[]): [T, U][] { 125 + const length = Math.min(a.length, b.length); 126 + const result: [T, U][] = []; 127 for (let i = 0; i < length; i++) { 128 + result.push([a[i], b[i]]); 129 } 130 + return result; 131 } 132 133 export function zipWith<T, U, R>( 134 a: readonly T[], 135 b: readonly U[], 136 + fn: (t: T, u: U) => R, 137 ): R[] { 138 + const length = Math.min(a.length, b.length); 139 + const result: R[] = []; 140 for (let i = 0; i < length; i++) { 141 + result.push(fn(a[i], b[i])); 142 } 143 + return result; 144 } 145 146 export function intersperse<T>(arr: readonly T[], separator: T): T[] { 147 + if (arr.length <= 1) return [...arr]; 148 + const result: T[] = [arr[0]]; 149 for (let i = 1; i < arr.length; i++) { 150 + result.push(separator, arr[i]); 151 } 152 + return result; 153 } 154 155 export function range(start: number, end: number): number[] { 156 + const result: number[] = []; 157 for (let i = start; i < end; i++) { 158 + result.push(i); 159 } 160 + return result; 161 } 162 163 export function isEmpty<T>(arr: readonly T[]): boolean { 164 + return arr.length === 0; 165 } 166 167 export function isNonEmpty<T>(arr: readonly T[]): arr is [T, ...T[]] { 168 + return arr.length > 0; 169 } 170 171 export function sum(arr: readonly number[]): number { 172 + return arr.reduce((acc, n) => acc + n, 0); 173 } 174 175 export function sumBy<T>(arr: readonly T[], fn: (t: T) => number): number { 176 + return arr.reduce((acc, t) => acc + fn(t), 0); 177 } 178 179 export function maxBy<T>(arr: readonly T[], fn: (t: T) => number): Option<T> { 180 + if (arr.length === 0) return null; 181 + let max = arr[0]; 182 + let maxValue = fn(max); 183 for (let i = 1; i < arr.length; i++) { 184 + const value = fn(arr[i]); 185 if (value > maxValue) { 186 + max = arr[i]; 187 + maxValue = value; 188 } 189 } 190 + return max; 191 } 192 193 export function minBy<T>(arr: readonly T[], fn: (t: T) => number): Option<T> { 194 + if (arr.length === 0) return null; 195 + let min = arr[0]; 196 + let minValue = fn(min); 197 for (let i = 1; i < arr.length; i++) { 198 + const value = fn(arr[i]); 199 if (value < minValue) { 200 + min = arr[i]; 201 + minValue = value; 202 } 203 } 204 + return min; 205 }
+103 -104
frontend/src/lib/utils/async.ts
··· 1 - import { ok, err, type Result } from '../types/result' 2 3 export function debounce<T extends (...args: Parameters<T>) => void>( 4 fn: T, 5 - ms: number 6 ): T & { cancel: () => void } { 7 - let timeoutId: ReturnType<typeof setTimeout> | null = null 8 9 const debounced = ((...args: Parameters<T>) => { 10 - if (timeoutId) clearTimeout(timeoutId) 11 timeoutId = setTimeout(() => { 12 - fn(...args) 13 - timeoutId = null 14 - }, ms) 15 - }) as T & { cancel: () => void } 16 17 debounced.cancel = () => { 18 if (timeoutId) { 19 - clearTimeout(timeoutId) 20 - timeoutId = null 21 } 22 - } 23 24 - return debounced 25 } 26 27 export function throttle<T extends (...args: Parameters<T>) => void>( 28 fn: T, 29 - ms: number 30 ): T { 31 - let lastCall = 0 32 - let timeoutId: ReturnType<typeof setTimeout> | null = null 33 34 return ((...args: Parameters<T>) => { 35 - const now = Date.now() 36 - const remaining = ms - (now - lastCall) 37 38 if (remaining <= 0) { 39 if (timeoutId) { 40 - clearTimeout(timeoutId) 41 - timeoutId = null 42 } 43 - lastCall = now 44 - fn(...args) 45 } else if (!timeoutId) { 46 timeoutId = setTimeout(() => { 47 - lastCall = Date.now() 48 - timeoutId = null 49 - fn(...args) 50 - }, remaining) 51 } 52 - }) as T 53 } 54 55 export function sleep(ms: number): Promise<void> { 56 - return new Promise((resolve) => setTimeout(resolve, ms)) 57 } 58 59 export async function retry<T>( 60 fn: () => Promise<T>, 61 options: { 62 - attempts?: number 63 - delay?: number 64 - backoff?: number 65 - shouldRetry?: (error: unknown, attempt: number) => boolean 66 - } = {} 67 ): Promise<T> { 68 const { 69 attempts = 3, 70 delay = 1000, 71 backoff = 2, 72 shouldRetry = () => true, 73 - } = options 74 75 - let lastError: unknown 76 - let currentDelay = delay 77 78 for (let attempt = 1; attempt <= attempts; attempt++) { 79 try { 80 - return await fn() 81 } catch (error) { 82 - lastError = error 83 if (attempt === attempts || !shouldRetry(error, attempt)) { 84 - throw error 85 } 86 - await sleep(currentDelay) 87 - currentDelay *= backoff 88 } 89 } 90 91 - throw lastError 92 } 93 94 export async function retryResult<T, E>( 95 fn: () => Promise<Result<T, E>>, 96 options: { 97 - attempts?: number 98 - delay?: number 99 - backoff?: number 100 - shouldRetry?: (error: E, attempt: number) => boolean 101 - } = {} 102 ): Promise<Result<T, E>> { 103 const { 104 attempts = 3, 105 delay = 1000, 106 backoff = 2, 107 shouldRetry = () => true, 108 - } = options 109 110 - let lastResult: Result<T, E> | null = null 111 - let currentDelay = delay 112 113 for (let attempt = 1; attempt <= attempts; attempt++) { 114 - const result = await fn() 115 - lastResult = result 116 117 if (result.ok) { 118 - return result 119 } 120 121 if (attempt === attempts || !shouldRetry(result.error, attempt)) { 122 - return result 123 } 124 125 - await sleep(currentDelay) 126 - currentDelay *= backoff 127 } 128 129 - return lastResult! 130 } 131 132 export function timeout<T>(promise: Promise<T>, ms: number): Promise<T> { 133 return new Promise((resolve, reject) => { 134 const timeoutId = setTimeout(() => { 135 - reject(new Error(`Timeout after ${ms}ms`)) 136 - }, ms) 137 138 promise 139 .then((value) => { 140 - clearTimeout(timeoutId) 141 - resolve(value) 142 }) 143 .catch((error) => { 144 - clearTimeout(timeoutId) 145 - reject(error) 146 - }) 147 - }) 148 } 149 150 export async function timeoutResult<T>( 151 promise: Promise<Result<T, Error>>, 152 - ms: number 153 ): Promise<Result<T, Error>> { 154 try { 155 - return await timeout(promise, ms) 156 } catch (e) { 157 - return err(e instanceof Error ? e : new Error(String(e))) 158 } 159 } 160 161 export async function parallel<T>( 162 tasks: (() => Promise<T>)[], 163 - concurrency: number 164 ): Promise<T[]> { 165 - const results: T[] = [] 166 - const executing: Promise<void>[] = [] 167 168 for (const task of tasks) { 169 const p = task().then((result) => { 170 - results.push(result) 171 - }) 172 173 - executing.push(p) 174 175 if (executing.length >= concurrency) { 176 - await Promise.race(executing) 177 executing.splice( 178 executing.findIndex((e) => e === p), 179 - 1 180 - ) 181 } 182 } 183 184 - await Promise.all(executing) 185 - return results 186 } 187 188 export async function mapParallel<T, U>( 189 items: T[], 190 fn: (item: T, index: number) => Promise<U>, 191 - concurrency: number 192 ): Promise<U[]> { 193 - const results: U[] = new Array(items.length) 194 - const executing: Promise<void>[] = [] 195 196 for (let i = 0; i < items.length; i++) { 197 - const index = i 198 const p = fn(items[index], index).then((result) => { 199 - results[index] = result 200 - }) 201 202 - executing.push(p) 203 204 if (executing.length >= concurrency) { 205 - await Promise.race(executing) 206 const doneIndex = executing.findIndex( 207 - (e) => 208 - (e as Promise<void> & { _done?: boolean })._done !== false 209 - ) 210 if (doneIndex >= 0) { 211 - executing.splice(doneIndex, 1) 212 } 213 } 214 } 215 216 - await Promise.all(executing) 217 - return results 218 } 219 220 export function createAbortable<T>( 221 - fn: (signal: AbortSignal) => Promise<T> 222 ): { promise: Promise<T>; abort: () => void } { 223 - const controller = new AbortController() 224 return { 225 promise: fn(controller.signal), 226 abort: () => controller.abort(), 227 - } 228 } 229 230 export interface Deferred<T> { 231 - promise: Promise<T> 232 - resolve: (value: T) => void 233 - reject: (error: unknown) => void 234 } 235 236 export function deferred<T>(): Deferred<T> { 237 - let resolve!: (value: T) => void 238 - let reject!: (error: unknown) => void 239 240 const promise = new Promise<T>((res, rej) => { 241 - resolve = res 242 - reject = rej 243 - }) 244 245 - return { promise, resolve, reject } 246 }
··· 1 + import { err, type Result } from "../types/result.ts"; 2 3 export function debounce<T extends (...args: Parameters<T>) => void>( 4 fn: T, 5 + ms: number, 6 ): T & { cancel: () => void } { 7 + let timeoutId: ReturnType<typeof setTimeout> | null = null; 8 9 const debounced = ((...args: Parameters<T>) => { 10 + if (timeoutId) clearTimeout(timeoutId); 11 timeoutId = setTimeout(() => { 12 + fn(...args); 13 + timeoutId = null; 14 + }, ms); 15 + }) as T & { cancel: () => void }; 16 17 debounced.cancel = () => { 18 if (timeoutId) { 19 + clearTimeout(timeoutId); 20 + timeoutId = null; 21 } 22 + }; 23 24 + return debounced; 25 } 26 27 export function throttle<T extends (...args: Parameters<T>) => void>( 28 fn: T, 29 + ms: number, 30 ): T { 31 + let lastCall = 0; 32 + let timeoutId: ReturnType<typeof setTimeout> | null = null; 33 34 return ((...args: Parameters<T>) => { 35 + const now = Date.now(); 36 + const remaining = ms - (now - lastCall); 37 38 if (remaining <= 0) { 39 if (timeoutId) { 40 + clearTimeout(timeoutId); 41 + timeoutId = null; 42 } 43 + lastCall = now; 44 + fn(...args); 45 } else if (!timeoutId) { 46 timeoutId = setTimeout(() => { 47 + lastCall = Date.now(); 48 + timeoutId = null; 49 + fn(...args); 50 + }, remaining); 51 } 52 + }) as T; 53 } 54 55 export function sleep(ms: number): Promise<void> { 56 + return new Promise((resolve) => setTimeout(resolve, ms)); 57 } 58 59 export async function retry<T>( 60 fn: () => Promise<T>, 61 options: { 62 + attempts?: number; 63 + delay?: number; 64 + backoff?: number; 65 + shouldRetry?: (error: unknown, attempt: number) => boolean; 66 + } = {}, 67 ): Promise<T> { 68 const { 69 attempts = 3, 70 delay = 1000, 71 backoff = 2, 72 shouldRetry = () => true, 73 + } = options; 74 75 + let lastError: unknown; 76 + let currentDelay = delay; 77 78 for (let attempt = 1; attempt <= attempts; attempt++) { 79 try { 80 + return await fn(); 81 } catch (error) { 82 + lastError = error; 83 if (attempt === attempts || !shouldRetry(error, attempt)) { 84 + throw error; 85 } 86 + await sleep(currentDelay); 87 + currentDelay *= backoff; 88 } 89 } 90 91 + throw lastError; 92 } 93 94 export async function retryResult<T, E>( 95 fn: () => Promise<Result<T, E>>, 96 options: { 97 + attempts?: number; 98 + delay?: number; 99 + backoff?: number; 100 + shouldRetry?: (error: E, attempt: number) => boolean; 101 + } = {}, 102 ): Promise<Result<T, E>> { 103 const { 104 attempts = 3, 105 delay = 1000, 106 backoff = 2, 107 shouldRetry = () => true, 108 + } = options; 109 110 + let lastResult: Result<T, E> | null = null; 111 + let currentDelay = delay; 112 113 for (let attempt = 1; attempt <= attempts; attempt++) { 114 + const result = await fn(); 115 + lastResult = result; 116 117 if (result.ok) { 118 + return result; 119 } 120 121 if (attempt === attempts || !shouldRetry(result.error, attempt)) { 122 + return result; 123 } 124 125 + await sleep(currentDelay); 126 + currentDelay *= backoff; 127 } 128 129 + return lastResult!; 130 } 131 132 export function timeout<T>(promise: Promise<T>, ms: number): Promise<T> { 133 return new Promise((resolve, reject) => { 134 const timeoutId = setTimeout(() => { 135 + reject(new Error(`Timeout after ${ms}ms`)); 136 + }, ms); 137 138 promise 139 .then((value) => { 140 + clearTimeout(timeoutId); 141 + resolve(value); 142 }) 143 .catch((error) => { 144 + clearTimeout(timeoutId); 145 + reject(error); 146 + }); 147 + }); 148 } 149 150 export async function timeoutResult<T>( 151 promise: Promise<Result<T, Error>>, 152 + ms: number, 153 ): Promise<Result<T, Error>> { 154 try { 155 + return await timeout(promise, ms); 156 } catch (e) { 157 + return err(e instanceof Error ? e : new Error(String(e))); 158 } 159 } 160 161 export async function parallel<T>( 162 tasks: (() => Promise<T>)[], 163 + concurrency: number, 164 ): Promise<T[]> { 165 + const results: T[] = []; 166 + const executing: Promise<void>[] = []; 167 168 for (const task of tasks) { 169 const p = task().then((result) => { 170 + results.push(result); 171 + }); 172 173 + executing.push(p); 174 175 if (executing.length >= concurrency) { 176 + await Promise.race(executing); 177 executing.splice( 178 executing.findIndex((e) => e === p), 179 + 1, 180 + ); 181 } 182 } 183 184 + await Promise.all(executing); 185 + return results; 186 } 187 188 export async function mapParallel<T, U>( 189 items: T[], 190 fn: (item: T, index: number) => Promise<U>, 191 + concurrency: number, 192 ): Promise<U[]> { 193 + const results: U[] = new Array(items.length); 194 + const executing: Promise<void>[] = []; 195 196 for (let i = 0; i < items.length; i++) { 197 + const index = i; 198 const p = fn(items[index], index).then((result) => { 199 + results[index] = result; 200 + }); 201 202 + executing.push(p); 203 204 if (executing.length >= concurrency) { 205 + await Promise.race(executing); 206 const doneIndex = executing.findIndex( 207 + (e) => (e as Promise<void> & { _done?: boolean })._done !== false, 208 + ); 209 if (doneIndex >= 0) { 210 + executing.splice(doneIndex, 1); 211 } 212 } 213 } 214 215 + await Promise.all(executing); 216 + return results; 217 } 218 219 export function createAbortable<T>( 220 + fn: (signal: AbortSignal) => Promise<T>, 221 ): { promise: Promise<T>; abort: () => void } { 222 + const controller = new AbortController(); 223 return { 224 promise: fn(controller.signal), 225 abort: () => controller.abort(), 226 + }; 227 } 228 229 export interface Deferred<T> { 230 + promise: Promise<T>; 231 + resolve: (value: T) => void; 232 + reject: (error: unknown) => void; 233 } 234 235 export function deferred<T>(): Deferred<T> { 236 + let resolve!: (value: T) => void; 237 + let reject!: (error: unknown) => void; 238 239 const promise = new Promise<T>((res, rej) => { 240 + resolve = res; 241 + reject = rej; 242 + }); 243 244 + return { promise, resolve, reject }; 245 }
+27 -3
frontend/src/lib/utils/index.ts
··· 1 - export * from './option' 2 - export * from './array' 3 - export * from './async'
··· 1 + export * from "./option.ts"; 2 + export { 3 + at, 4 + chunk, 5 + find, 6 + findIndex, 7 + findMap, 8 + first, 9 + groupBy, 10 + intersperse, 11 + isEmpty, 12 + isNonEmpty, 13 + last, 14 + maxBy, 15 + minBy, 16 + partition, 17 + range, 18 + sortBy, 19 + sortByDesc, 20 + sum, 21 + sumBy, 22 + unique, 23 + uniqueBy, 24 + zip as zipArrays, 25 + zipWith as zipArraysWith, 26 + } from "./array.ts"; 27 + export * from "./async.ts";
+31 -25
frontend/src/lib/utils/option.ts
··· 1 - export type Option<T> = T | null | undefined 2 3 export function isSome<T>(opt: Option<T>): opt is T { 4 - return opt != null 5 } 6 7 export function isNone<T>(opt: Option<T>): opt is null | undefined { 8 - return opt == null 9 } 10 11 export function map<T, U>(opt: Option<T>, fn: (t: T) => U): Option<U> { 12 - return isSome(opt) ? fn(opt) : null 13 } 14 15 - export function flatMap<T, U>(opt: Option<T>, fn: (t: T) => Option<U>): Option<U> { 16 - return isSome(opt) ? fn(opt) : null 17 } 18 19 - export function filter<T>(opt: Option<T>, predicate: (t: T) => boolean): Option<T> { 20 - return isSome(opt) && predicate(opt) ? opt : null 21 } 22 23 export function getOrElse<T>(opt: Option<T>, defaultValue: T): T { 24 - return isSome(opt) ? opt : defaultValue 25 } 26 27 export function getOrElseLazy<T>(opt: Option<T>, fn: () => T): T { 28 - return isSome(opt) ? opt : fn() 29 } 30 31 export function getOrThrow<T>(opt: Option<T>, error?: string | Error): T { 32 - if (isSome(opt)) return opt 33 - if (error instanceof Error) throw error 34 - throw new Error(error ?? 'Expected value but got null/undefined') 35 } 36 37 export function tap<T>(opt: Option<T>, fn: (t: T) => void): Option<T> { 38 - if (isSome(opt)) fn(opt) 39 - return opt 40 } 41 42 export function match<T, U>( 43 opt: Option<T>, 44 - handlers: { some: (t: T) => U; none: () => U } 45 ): U { 46 - return isSome(opt) ? handlers.some(opt) : handlers.none() 47 } 48 49 export function toArray<T>(opt: Option<T>): T[] { 50 - return isSome(opt) ? [opt] : [] 51 } 52 53 export function fromArray<T>(arr: T[]): Option<T> { 54 - return arr.length > 0 ? arr[0] : null 55 } 56 57 export function zip<T, U>(a: Option<T>, b: Option<U>): Option<[T, U]> { 58 - return isSome(a) && isSome(b) ? [a, b] : null 59 } 60 61 export function zipWith<T, U, R>( 62 a: Option<T>, 63 b: Option<U>, 64 - fn: (t: T, u: U) => R 65 ): Option<R> { 66 - return isSome(a) && isSome(b) ? fn(a, b) : null 67 } 68 69 export function or<T>(a: Option<T>, b: Option<T>): Option<T> { 70 - return isSome(a) ? a : b 71 } 72 73 export function orLazy<T>(a: Option<T>, fn: () => Option<T>): Option<T> { 74 - return isSome(a) ? a : fn() 75 } 76 77 export function and<T, U>(a: Option<T>, b: Option<U>): Option<U> { 78 - return isSome(a) ? b : null 79 }
··· 1 + export type Option<T> = T | null | undefined; 2 3 export function isSome<T>(opt: Option<T>): opt is T { 4 + return opt != null; 5 } 6 7 export function isNone<T>(opt: Option<T>): opt is null | undefined { 8 + return opt == null; 9 } 10 11 export function map<T, U>(opt: Option<T>, fn: (t: T) => U): Option<U> { 12 + return isSome(opt) ? fn(opt) : null; 13 } 14 15 + export function flatMap<T, U>( 16 + opt: Option<T>, 17 + fn: (t: T) => Option<U>, 18 + ): Option<U> { 19 + return isSome(opt) ? fn(opt) : null; 20 } 21 22 + export function filter<T>( 23 + opt: Option<T>, 24 + predicate: (t: T) => boolean, 25 + ): Option<T> { 26 + return isSome(opt) && predicate(opt) ? opt : null; 27 } 28 29 export function getOrElse<T>(opt: Option<T>, defaultValue: T): T { 30 + return isSome(opt) ? opt : defaultValue; 31 } 32 33 export function getOrElseLazy<T>(opt: Option<T>, fn: () => T): T { 34 + return isSome(opt) ? opt : fn(); 35 } 36 37 export function getOrThrow<T>(opt: Option<T>, error?: string | Error): T { 38 + if (isSome(opt)) return opt; 39 + if (error instanceof Error) throw error; 40 + throw new Error(error ?? "Expected value but got null/undefined"); 41 } 42 43 export function tap<T>(opt: Option<T>, fn: (t: T) => void): Option<T> { 44 + if (isSome(opt)) fn(opt); 45 + return opt; 46 } 47 48 export function match<T, U>( 49 opt: Option<T>, 50 + handlers: { some: (t: T) => U; none: () => U }, 51 ): U { 52 + return isSome(opt) ? handlers.some(opt) : handlers.none(); 53 } 54 55 export function toArray<T>(opt: Option<T>): T[] { 56 + return isSome(opt) ? [opt] : []; 57 } 58 59 export function fromArray<T>(arr: T[]): Option<T> { 60 + return arr.length > 0 ? arr[0] : null; 61 } 62 63 export function zip<T, U>(a: Option<T>, b: Option<U>): Option<[T, U]> { 64 + return isSome(a) && isSome(b) ? [a, b] : null; 65 } 66 67 export function zipWith<T, U, R>( 68 a: Option<T>, 69 b: Option<U>, 70 + fn: (t: T, u: U) => R, 71 ): Option<R> { 72 + return isSome(a) && isSome(b) ? fn(a, b) : null; 73 } 74 75 export function or<T>(a: Option<T>, b: Option<T>): Option<T> { 76 + return isSome(a) ? a : b; 77 } 78 79 export function orLazy<T>(a: Option<T>, fn: () => Option<T>): Option<T> { 80 + return isSome(a) ? a : fn(); 81 } 82 83 export function and<T, U>(a: Option<T>, b: Option<U>): Option<U> { 84 + return isSome(a) ? b : null; 85 }
+125 -99
frontend/src/lib/validation.ts
··· 1 - import { ok, err, type Result } from './types/result' 2 import { 3 type Did, 4 type DidPlc, 5 type DidWeb, 6 - type Handle, 7 type EmailAddress, 8 - type AtUri, 9 - type Cid, 10 - type Nsid, 11 - type ISODateString, 12 isDid, 13 isDidPlc, 14 isDidWeb, 15 - isHandle, 16 isEmail, 17 - isAtUri, 18 - isCid, 19 isNsid, 20 - isISODate, 21 - } from './types/branded' 22 23 export class ValidationError extends Error { 24 constructor( 25 message: string, 26 public readonly field?: string, 27 - public readonly value?: unknown 28 ) { 29 - super(message) 30 - this.name = 'ValidationError' 31 } 32 } 33 34 export function parseDid(s: string): Result<Did, ValidationError> { 35 if (isDid(s)) { 36 - return ok(s) 37 } 38 - return err(new ValidationError(`Invalid DID: ${s}`, 'did', s)) 39 } 40 41 export function parseDidPlc(s: string): Result<DidPlc, ValidationError> { 42 if (isDidPlc(s)) { 43 - return ok(s) 44 } 45 - return err(new ValidationError(`Invalid DID:PLC: ${s}`, 'did', s)) 46 } 47 48 export function parseDidWeb(s: string): Result<DidWeb, ValidationError> { 49 if (isDidWeb(s)) { 50 - return ok(s) 51 } 52 - return err(new ValidationError(`Invalid DID:WEB: ${s}`, 'did', s)) 53 } 54 55 export function parseHandle(s: string): Result<Handle, ValidationError> { 56 - const trimmed = s.trim().toLowerCase() 57 if (isHandle(trimmed)) { 58 - return ok(trimmed) 59 } 60 - return err(new ValidationError(`Invalid handle: ${s}`, 'handle', s)) 61 } 62 63 export function parseEmail(s: string): Result<EmailAddress, ValidationError> { 64 - const trimmed = s.trim().toLowerCase() 65 if (isEmail(trimmed)) { 66 - return ok(trimmed) 67 } 68 - return err(new ValidationError(`Invalid email: ${s}`, 'email', s)) 69 } 70 71 export function parseAtUri(s: string): Result<AtUri, ValidationError> { 72 if (isAtUri(s)) { 73 - return ok(s) 74 } 75 - return err(new ValidationError(`Invalid AT-URI: ${s}`, 'uri', s)) 76 } 77 78 export function parseCid(s: string): Result<Cid, ValidationError> { 79 if (isCid(s)) { 80 - return ok(s) 81 } 82 - return err(new ValidationError(`Invalid CID: ${s}`, 'cid', s)) 83 } 84 85 export function parseNsid(s: string): Result<Nsid, ValidationError> { 86 if (isNsid(s)) { 87 - return ok(s) 88 } 89 - return err(new ValidationError(`Invalid NSID: ${s}`, 'nsid', s)) 90 } 91 92 - export function parseISODate(s: string): Result<ISODateString, ValidationError> { 93 if (isISODate(s)) { 94 - return ok(s) 95 } 96 - return err(new ValidationError(`Invalid ISO date: ${s}`, 'date', s)) 97 } 98 99 export interface PasswordValidationResult { 100 - valid: boolean 101 - errors: string[] 102 - strength: 'weak' | 'fair' | 'good' | 'strong' 103 } 104 105 export function validatePassword(password: string): PasswordValidationResult { 106 - const errors: string[] = [] 107 108 if (password.length < 8) { 109 - errors.push('Password must be at least 8 characters') 110 } 111 if (password.length > 256) { 112 - errors.push('Password must be at most 256 characters') 113 } 114 if (!/[a-z]/.test(password)) { 115 - errors.push('Password must contain a lowercase letter') 116 } 117 if (!/[A-Z]/.test(password)) { 118 - errors.push('Password must contain an uppercase letter') 119 } 120 if (!/\d/.test(password)) { 121 - errors.push('Password must contain a number') 122 } 123 124 - let strength: PasswordValidationResult['strength'] = 'weak' 125 if (errors.length === 0) { 126 - const hasSpecial = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password) 127 - const isLong = password.length >= 12 128 - const isVeryLong = password.length >= 16 129 130 if (isVeryLong && hasSpecial) { 131 - strength = 'strong' 132 } else if (isLong || hasSpecial) { 133 - strength = 'good' 134 } else { 135 - strength = 'fair' 136 } 137 } 138 ··· 140 valid: errors.length === 0, 141 errors, 142 strength, 143 - } 144 } 145 146 - export function validateHandle(handle: string): Result<Handle, ValidationError> { 147 - const trimmed = handle.trim().toLowerCase() 148 149 if (trimmed.length < 3) { 150 - return err(new ValidationError('Handle must be at least 3 characters', 'handle', handle)) 151 } 152 153 if (trimmed.length > 253) { 154 - return err(new ValidationError('Handle must be at most 253 characters', 'handle', handle)) 155 } 156 157 if (!isHandle(trimmed)) { 158 - return err(new ValidationError('Invalid handle format', 'handle', handle)) 159 } 160 161 - return ok(trimmed) 162 } 163 164 - export function validateInviteCode(code: string): Result<string, ValidationError> { 165 - const trimmed = code.trim() 166 167 if (trimmed.length === 0) { 168 - return err(new ValidationError('Invite code is required', 'inviteCode', code)) 169 } 170 171 - const pattern = /^[a-zA-Z0-9-]+$/ 172 if (!pattern.test(trimmed)) { 173 - return err(new ValidationError('Invalid invite code format', 'inviteCode', code)) 174 } 175 176 - return ok(trimmed) 177 } 178 179 - export function validateTotpCode(code: string): Result<string, ValidationError> { 180 - const trimmed = code.trim().replace(/\s/g, '') 181 182 if (!/^\d{6}$/.test(trimmed)) { 183 - return err(new ValidationError('TOTP code must be 6 digits', 'code', code)) 184 } 185 186 - return ok(trimmed) 187 } 188 189 - export function validateBackupCode(code: string): Result<string, ValidationError> { 190 - const trimmed = code.trim().replace(/\s/g, '').toLowerCase() 191 192 if (!/^[a-z0-9]{8}$/.test(trimmed)) { 193 - return err(new ValidationError('Invalid backup code format', 'code', code)) 194 } 195 196 - return ok(trimmed) 197 } 198 199 export interface FormValidation<T> { 200 - validate: () => Result<T, ValidationError[]> 201 field: <K extends keyof T>( 202 key: K, 203 - validator: (value: unknown) => Result<T[K], ValidationError> 204 - ) => FormValidation<T> 205 optional: <K extends keyof T>( 206 key: K, 207 - validator: (value: unknown) => Result<T[K], ValidationError> 208 - ) => FormValidation<T> 209 } 210 211 export function createFormValidation<T extends Record<string, unknown>>( 212 - data: Record<string, unknown> 213 ): FormValidation<T> { 214 const validators: Array<{ 215 - key: string 216 - validator: (value: unknown) => Result<unknown, ValidationError> 217 - optional: boolean 218 - }> = [] 219 220 const builder: FormValidation<T> = { 221 field: (key, validator) => { 222 - validators.push({ key: key as string, validator, optional: false }) 223 - return builder 224 }, 225 optional: (key, validator) => { 226 - validators.push({ key: key as string, validator, optional: true }) 227 - return builder 228 }, 229 validate: () => { 230 - const errors: ValidationError[] = [] 231 - const result: Record<string, unknown> = {} 232 233 for (const { key, validator, optional } of validators) { 234 - const value = data[key] 235 236 - if (value == null || value === '') { 237 if (!optional) { 238 - errors.push(new ValidationError(`${key} is required`, key)) 239 } 240 - continue 241 } 242 243 - const validated = validator(value) 244 if (validated.ok) { 245 - result[key] = validated.value 246 } else { 247 - errors.push(validated.error) 248 } 249 } 250 251 if (errors.length > 0) { 252 - return err(errors) 253 } 254 255 - return ok(result as T) 256 }, 257 - } 258 259 - return builder 260 }
··· 1 + import { err, ok, type Result } from "./types/result.ts"; 2 import { 3 + type AtUri, 4 + type Cid, 5 type Did, 6 type DidPlc, 7 type DidWeb, 8 type EmailAddress, 9 + type Handle, 10 + isAtUri, 11 + isCid, 12 isDid, 13 isDidPlc, 14 isDidWeb, 15 isEmail, 16 + isHandle, 17 + isISODate, 18 isNsid, 19 + type ISODateString, 20 + type Nsid, 21 + } from "./types/branded.ts"; 22 23 export class ValidationError extends Error { 24 constructor( 25 message: string, 26 public readonly field?: string, 27 + public readonly value?: unknown, 28 ) { 29 + super(message); 30 + this.name = "ValidationError"; 31 } 32 } 33 34 export function parseDid(s: string): Result<Did, ValidationError> { 35 if (isDid(s)) { 36 + return ok(s); 37 } 38 + return err(new ValidationError(`Invalid DID: ${s}`, "did", s)); 39 } 40 41 export function parseDidPlc(s: string): Result<DidPlc, ValidationError> { 42 if (isDidPlc(s)) { 43 + return ok(s); 44 } 45 + return err(new ValidationError(`Invalid DID:PLC: ${s}`, "did", s)); 46 } 47 48 export function parseDidWeb(s: string): Result<DidWeb, ValidationError> { 49 if (isDidWeb(s)) { 50 + return ok(s); 51 } 52 + return err(new ValidationError(`Invalid DID:WEB: ${s}`, "did", s)); 53 } 54 55 export function parseHandle(s: string): Result<Handle, ValidationError> { 56 + const trimmed = s.trim().toLowerCase(); 57 if (isHandle(trimmed)) { 58 + return ok(trimmed); 59 } 60 + return err(new ValidationError(`Invalid handle: ${s}`, "handle", s)); 61 } 62 63 export function parseEmail(s: string): Result<EmailAddress, ValidationError> { 64 + const trimmed = s.trim().toLowerCase(); 65 if (isEmail(trimmed)) { 66 + return ok(trimmed); 67 } 68 + return err(new ValidationError(`Invalid email: ${s}`, "email", s)); 69 } 70 71 export function parseAtUri(s: string): Result<AtUri, ValidationError> { 72 if (isAtUri(s)) { 73 + return ok(s); 74 } 75 + return err(new ValidationError(`Invalid AT-URI: ${s}`, "uri", s)); 76 } 77 78 export function parseCid(s: string): Result<Cid, ValidationError> { 79 if (isCid(s)) { 80 + return ok(s); 81 } 82 + return err(new ValidationError(`Invalid CID: ${s}`, "cid", s)); 83 } 84 85 export function parseNsid(s: string): Result<Nsid, ValidationError> { 86 if (isNsid(s)) { 87 + return ok(s); 88 } 89 + return err(new ValidationError(`Invalid NSID: ${s}`, "nsid", s)); 90 } 91 92 + export function parseISODate( 93 + s: string, 94 + ): Result<ISODateString, ValidationError> { 95 if (isISODate(s)) { 96 + return ok(s); 97 } 98 + return err(new ValidationError(`Invalid ISO date: ${s}`, "date", s)); 99 } 100 101 export interface PasswordValidationResult { 102 + valid: boolean; 103 + errors: string[]; 104 + strength: "weak" | "fair" | "good" | "strong"; 105 } 106 107 export function validatePassword(password: string): PasswordValidationResult { 108 + const errors: string[] = []; 109 110 if (password.length < 8) { 111 + errors.push("Password must be at least 8 characters"); 112 } 113 if (password.length > 256) { 114 + errors.push("Password must be at most 256 characters"); 115 } 116 if (!/[a-z]/.test(password)) { 117 + errors.push("Password must contain a lowercase letter"); 118 } 119 if (!/[A-Z]/.test(password)) { 120 + errors.push("Password must contain an uppercase letter"); 121 } 122 if (!/\d/.test(password)) { 123 + errors.push("Password must contain a number"); 124 } 125 126 + let strength: PasswordValidationResult["strength"] = "weak"; 127 if (errors.length === 0) { 128 + const hasSpecial = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password); 129 + const isLong = password.length >= 12; 130 + const isVeryLong = password.length >= 16; 131 132 if (isVeryLong && hasSpecial) { 133 + strength = "strong"; 134 } else if (isLong || hasSpecial) { 135 + strength = "good"; 136 } else { 137 + strength = "fair"; 138 } 139 } 140 ··· 142 valid: errors.length === 0, 143 errors, 144 strength, 145 + }; 146 } 147 148 + export function validateHandle( 149 + handle: string, 150 + ): Result<Handle, ValidationError> { 151 + const trimmed = handle.trim().toLowerCase(); 152 153 if (trimmed.length < 3) { 154 + return err( 155 + new ValidationError( 156 + "Handle must be at least 3 characters", 157 + "handle", 158 + handle, 159 + ), 160 + ); 161 } 162 163 if (trimmed.length > 253) { 164 + return err( 165 + new ValidationError( 166 + "Handle must be at most 253 characters", 167 + "handle", 168 + handle, 169 + ), 170 + ); 171 } 172 173 if (!isHandle(trimmed)) { 174 + return err(new ValidationError("Invalid handle format", "handle", handle)); 175 } 176 177 + return ok(trimmed); 178 } 179 180 + export function validateInviteCode( 181 + code: string, 182 + ): Result<string, ValidationError> { 183 + const trimmed = code.trim(); 184 185 if (trimmed.length === 0) { 186 + return err( 187 + new ValidationError("Invite code is required", "inviteCode", code), 188 + ); 189 } 190 191 + const pattern = /^[a-zA-Z0-9-]+$/; 192 if (!pattern.test(trimmed)) { 193 + return err( 194 + new ValidationError("Invalid invite code format", "inviteCode", code), 195 + ); 196 } 197 198 + return ok(trimmed); 199 } 200 201 + export function validateTotpCode( 202 + code: string, 203 + ): Result<string, ValidationError> { 204 + const trimmed = code.trim().replace(/\s/g, ""); 205 206 if (!/^\d{6}$/.test(trimmed)) { 207 + return err(new ValidationError("TOTP code must be 6 digits", "code", code)); 208 } 209 210 + return ok(trimmed); 211 } 212 213 + export function validateBackupCode( 214 + code: string, 215 + ): Result<string, ValidationError> { 216 + const trimmed = code.trim().replace(/\s/g, "").toLowerCase(); 217 218 if (!/^[a-z0-9]{8}$/.test(trimmed)) { 219 + return err(new ValidationError("Invalid backup code format", "code", code)); 220 } 221 222 + return ok(trimmed); 223 } 224 225 export interface FormValidation<T> { 226 + validate: () => Result<T, ValidationError[]>; 227 field: <K extends keyof T>( 228 key: K, 229 + validator: (value: unknown) => Result<T[K], ValidationError>, 230 + ) => FormValidation<T>; 231 optional: <K extends keyof T>( 232 key: K, 233 + validator: (value: unknown) => Result<T[K], ValidationError>, 234 + ) => FormValidation<T>; 235 } 236 237 export function createFormValidation<T extends Record<string, unknown>>( 238 + data: Record<string, unknown>, 239 ): FormValidation<T> { 240 const validators: Array<{ 241 + key: string; 242 + validator: (value: unknown) => Result<unknown, ValidationError>; 243 + optional: boolean; 244 + }> = []; 245 246 const builder: FormValidation<T> = { 247 field: (key, validator) => { 248 + validators.push({ key: key as string, validator, optional: false }); 249 + return builder; 250 }, 251 optional: (key, validator) => { 252 + validators.push({ key: key as string, validator, optional: true }); 253 + return builder; 254 }, 255 validate: () => { 256 + const errors: ValidationError[] = []; 257 + const result: Record<string, unknown> = {}; 258 259 for (const { key, validator, optional } of validators) { 260 + const value = data[key]; 261 262 + if (value == null || value === "") { 263 if (!optional) { 264 + errors.push(new ValidationError(`${key} is required`, key)); 265 } 266 + continue; 267 } 268 269 + const validated = validator(value); 270 if (validated.ok) { 271 + result[key] = validated.value; 272 } else { 273 + errors.push(validated.error); 274 } 275 } 276 277 if (errors.length > 0) { 278 + return err(errors); 279 } 280 281 + return ok(result as T); 282 }, 283 + }; 284 285 + return builder; 286 }
+64 -62
frontend/src/lib/webauthn.ts
··· 1 export interface PublicKeyCredentialDescriptorJSON { 2 - type: 'public-key' 3 - id: string 4 - transports?: AuthenticatorTransport[] 5 } 6 7 export interface PublicKeyCredentialUserEntityJSON { 8 - id: string 9 - name: string 10 - displayName: string 11 } 12 13 export interface PublicKeyCredentialRpEntityJSON { 14 - name: string 15 - id?: string 16 } 17 18 export interface PublicKeyCredentialParametersJSON { 19 - type: 'public-key' 20 - alg: number 21 } 22 23 export interface AuthenticatorSelectionCriteriaJSON { 24 - authenticatorAttachment?: AuthenticatorAttachment 25 - residentKey?: ResidentKeyRequirement 26 - requireResidentKey?: boolean 27 - userVerification?: UserVerificationRequirement 28 } 29 30 export interface PublicKeyCredentialCreationOptionsJSON { 31 - rp: PublicKeyCredentialRpEntityJSON 32 - user: PublicKeyCredentialUserEntityJSON 33 - challenge: string 34 - pubKeyCredParams: PublicKeyCredentialParametersJSON[] 35 - timeout?: number 36 - excludeCredentials?: PublicKeyCredentialDescriptorJSON[] 37 - authenticatorSelection?: AuthenticatorSelectionCriteriaJSON 38 - attestation?: AttestationConveyancePreference 39 } 40 41 export interface PublicKeyCredentialRequestOptionsJSON { 42 - challenge: string 43 - timeout?: number 44 - rpId?: string 45 - allowCredentials?: PublicKeyCredentialDescriptorJSON[] 46 - userVerification?: UserVerificationRequirement 47 } 48 49 export interface WebAuthnCreationOptionsResponse { 50 - publicKey: PublicKeyCredentialCreationOptionsJSON 51 } 52 53 export interface WebAuthnRequestOptionsResponse { 54 - publicKey: PublicKeyCredentialRequestOptionsJSON 55 } 56 57 export interface CredentialAssertionJSON { 58 - id: string 59 - type: string 60 - rawId: string 61 response: { 62 - clientDataJSON: string 63 - authenticatorData: string 64 - signature: string 65 - userHandle: string | null 66 - } 67 } 68 69 export interface CredentialAttestationJSON { 70 - id: string 71 - type: string 72 - rawId: string 73 response: { 74 - clientDataJSON: string 75 - attestationObject: string 76 - } 77 } 78 79 export function base64UrlToArrayBuffer(base64url: string): ArrayBuffer { 80 - const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') 81 - const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4) 82 - const binary = atob(padded) 83 - return Uint8Array.from(binary, (char) => char.charCodeAt(0)).buffer 84 } 85 86 export function arrayBufferToBase64Url(buffer: ArrayBuffer): string { 87 - const bytes = new Uint8Array(buffer) 88 - const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('') 89 - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') 90 } 91 92 export function prepareCreationOptions( 93 - options: WebAuthnCreationOptionsResponse 94 ): PublicKeyCredentialCreationOptions { 95 - const pk = options.publicKey 96 return { 97 ...pk, 98 challenge: base64UrlToArrayBuffer(pk.challenge), ··· 104 ...cred, 105 id: base64UrlToArrayBuffer(cred.id), 106 })), 107 - } 108 } 109 110 export function prepareRequestOptions( 111 - options: WebAuthnRequestOptionsResponse 112 ): PublicKeyCredentialRequestOptions { 113 - const pk = options.publicKey 114 return { 115 ...pk, 116 challenge: base64UrlToArrayBuffer(pk.challenge), ··· 118 ...cred, 119 id: base64UrlToArrayBuffer(cred.id), 120 })), 121 - } 122 } 123 124 export function serializeAttestationResponse( 125 - credential: PublicKeyCredential 126 ): CredentialAttestationJSON { 127 - const response = credential.response as AuthenticatorAttestationResponse 128 return { 129 id: credential.id, 130 type: credential.type, ··· 133 clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON), 134 attestationObject: arrayBufferToBase64Url(response.attestationObject), 135 }, 136 - } 137 } 138 139 export function serializeAssertionResponse( 140 - credential: PublicKeyCredential 141 ): CredentialAssertionJSON { 142 - const response = credential.response as AuthenticatorAssertionResponse 143 return { 144 id: credential.id, 145 type: credential.type, ··· 152 ? arrayBufferToBase64Url(response.userHandle) 153 : null, 154 }, 155 - } 156 }
··· 1 export interface PublicKeyCredentialDescriptorJSON { 2 + type: "public-key"; 3 + id: string; 4 + transports?: AuthenticatorTransport[]; 5 } 6 7 export interface PublicKeyCredentialUserEntityJSON { 8 + id: string; 9 + name: string; 10 + displayName: string; 11 } 12 13 export interface PublicKeyCredentialRpEntityJSON { 14 + name: string; 15 + id?: string; 16 } 17 18 export interface PublicKeyCredentialParametersJSON { 19 + type: "public-key"; 20 + alg: number; 21 } 22 23 export interface AuthenticatorSelectionCriteriaJSON { 24 + authenticatorAttachment?: AuthenticatorAttachment; 25 + residentKey?: ResidentKeyRequirement; 26 + requireResidentKey?: boolean; 27 + userVerification?: UserVerificationRequirement; 28 } 29 30 export interface PublicKeyCredentialCreationOptionsJSON { 31 + rp: PublicKeyCredentialRpEntityJSON; 32 + user: PublicKeyCredentialUserEntityJSON; 33 + challenge: string; 34 + pubKeyCredParams: PublicKeyCredentialParametersJSON[]; 35 + timeout?: number; 36 + excludeCredentials?: PublicKeyCredentialDescriptorJSON[]; 37 + authenticatorSelection?: AuthenticatorSelectionCriteriaJSON; 38 + attestation?: AttestationConveyancePreference; 39 } 40 41 export interface PublicKeyCredentialRequestOptionsJSON { 42 + challenge: string; 43 + timeout?: number; 44 + rpId?: string; 45 + allowCredentials?: PublicKeyCredentialDescriptorJSON[]; 46 + userVerification?: UserVerificationRequirement; 47 } 48 49 export interface WebAuthnCreationOptionsResponse { 50 + publicKey: PublicKeyCredentialCreationOptionsJSON; 51 } 52 53 export interface WebAuthnRequestOptionsResponse { 54 + publicKey: PublicKeyCredentialRequestOptionsJSON; 55 } 56 57 export interface CredentialAssertionJSON { 58 + id: string; 59 + type: string; 60 + rawId: string; 61 response: { 62 + clientDataJSON: string; 63 + authenticatorData: string; 64 + signature: string; 65 + userHandle: string | null; 66 + }; 67 } 68 69 export interface CredentialAttestationJSON { 70 + id: string; 71 + type: string; 72 + rawId: string; 73 response: { 74 + clientDataJSON: string; 75 + attestationObject: string; 76 + }; 77 } 78 79 export function base64UrlToArrayBuffer(base64url: string): ArrayBuffer { 80 + const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/"); 81 + const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4); 82 + const binary = atob(padded); 83 + return Uint8Array.from(binary, (char) => char.charCodeAt(0)).buffer; 84 } 85 86 export function arrayBufferToBase64Url(buffer: ArrayBuffer): string { 87 + const bytes = new Uint8Array(buffer); 88 + const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join( 89 + "", 90 + ); 91 + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); 92 } 93 94 export function prepareCreationOptions( 95 + options: WebAuthnCreationOptionsResponse, 96 ): PublicKeyCredentialCreationOptions { 97 + const pk = options.publicKey; 98 return { 99 ...pk, 100 challenge: base64UrlToArrayBuffer(pk.challenge), ··· 106 ...cred, 107 id: base64UrlToArrayBuffer(cred.id), 108 })), 109 + }; 110 } 111 112 export function prepareRequestOptions( 113 + options: WebAuthnRequestOptionsResponse, 114 ): PublicKeyCredentialRequestOptions { 115 + const pk = options.publicKey; 116 return { 117 ...pk, 118 challenge: base64UrlToArrayBuffer(pk.challenge), ··· 120 ...cred, 121 id: base64UrlToArrayBuffer(cred.id), 122 })), 123 + }; 124 } 125 126 export function serializeAttestationResponse( 127 + credential: PublicKeyCredential, 128 ): CredentialAttestationJSON { 129 + const response = credential.response as AuthenticatorAttestationResponse; 130 return { 131 id: credential.id, 132 type: credential.type, ··· 135 clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON), 136 attestationObject: arrayBufferToBase64Url(response.attestationObject), 137 }, 138 + }; 139 } 140 141 export function serializeAssertionResponse( 142 + credential: PublicKeyCredential, 143 ): CredentialAssertionJSON { 144 + const response = credential.response as AuthenticatorAssertionResponse; 145 return { 146 id: credential.id, 147 type: credential.type, ··· 154 ? arrayBufferToBase64Url(response.userHandle) 155 : null, 156 }, 157 + }; 158 }
+6 -5
frontend/src/routes/Admin.svelte
··· 5 import { api, ApiError } from '../lib/api' 6 import { _ } from '../lib/i18n' 7 import { formatDate, formatDateTime } from '../lib/date' 8 import type { Session } from '../lib/types/api' 9 import { toast } from '../lib/toast.svelte' 10 ··· 257 if (!session) return 258 userDetailLoading = true 259 try { 260 - selectedUser = await api.getAccountInfo(session.accessJwt, did) 261 } catch (e) { 262 toast.error(e instanceof ApiError ? e.message : $_('admin.failedToLoadUserDetails')) 263 } finally { ··· 272 userActionLoading = true 273 try { 274 if (selectedUser.invitesDisabled) { 275 - await api.enableAccountInvites(session.accessJwt, selectedUser.did) 276 selectedUser = { ...selectedUser, invitesDisabled: false } 277 toast.success($_('admin.invitesEnabled')) 278 } else { 279 - await api.disableAccountInvites(session.accessJwt, selectedUser.did) 280 selectedUser = { ...selectedUser, invitesDisabled: true } 281 toast.success($_('admin.invitesDisabled')) 282 } ··· 291 if (!confirm($_('admin.deleteConfirm', { values: { handle: selectedUser.handle } }))) return 292 userActionLoading = true 293 try { 294 - await api.adminDeleteAccount(session.accessJwt, selectedUser.did) 295 users = users.filter(u => u.did !== selectedUser!.did) 296 selectedUser = null 297 toast.success($_('admin.userDeleted')) ··· 639 </div> 640 </div> 641 {/if} 642 - {:else if auth.loading} 643 <div class="loading">{$_('admin.loading')}</div> 644 {/if} 645 <style>
··· 5 import { api, ApiError } from '../lib/api' 6 import { _ } from '../lib/i18n' 7 import { formatDate, formatDateTime } from '../lib/date' 8 + import { unsafeAsDid } from '../lib/types/branded' 9 import type { Session } from '../lib/types/api' 10 import { toast } from '../lib/toast.svelte' 11 ··· 258 if (!session) return 259 userDetailLoading = true 260 try { 261 + selectedUser = await api.getAccountInfo(session.accessJwt, unsafeAsDid(did)) 262 } catch (e) { 263 toast.error(e instanceof ApiError ? e.message : $_('admin.failedToLoadUserDetails')) 264 } finally { ··· 273 userActionLoading = true 274 try { 275 if (selectedUser.invitesDisabled) { 276 + await api.enableAccountInvites(session.accessJwt, unsafeAsDid(selectedUser.did)) 277 selectedUser = { ...selectedUser, invitesDisabled: false } 278 toast.success($_('admin.invitesEnabled')) 279 } else { 280 + await api.disableAccountInvites(session.accessJwt, unsafeAsDid(selectedUser.did)) 281 selectedUser = { ...selectedUser, invitesDisabled: true } 282 toast.success($_('admin.invitesDisabled')) 283 } ··· 292 if (!confirm($_('admin.deleteConfirm', { values: { handle: selectedUser.handle } }))) return 293 userActionLoading = true 294 try { 295 + await api.adminDeleteAccount(session.accessJwt, unsafeAsDid(selectedUser.did)) 296 users = users.filter(u => u.did !== selectedUser!.did) 297 selectedUser = null 298 toast.success($_('admin.userDeleted')) ··· 640 </div> 641 </div> 642 {/if} 643 + {:else if authLoading} 644 <div class="loading">{$_('admin.loading')}</div> 645 {/if} 646 <style>
+5 -1
frontend/src/routes/Dashboard.svelte
··· 80 $effect(() => { 81 if (dropdownOpen) { 82 document.addEventListener('click', closeDropdown) 83 - return () => document.removeEventListener('click', closeDropdown) 84 } 85 }) 86 </script>
··· 80 $effect(() => { 81 if (dropdownOpen) { 82 document.addEventListener('click', closeDropdown) 83 + } 84 + return () => { 85 + if (dropdownOpen) { 86 + document.removeEventListener('click', closeDropdown) 87 + } 88 } 89 }) 90 </script>
+1 -1
frontend/src/routes/Login.svelte
··· 17 18 type PageState = 19 | { kind: 'login' } 20 - | { kind: 'verification'; did: string } 21 22 let pageState = $state<PageState>({ kind: 'login' }) 23 let submitting = $state(false)
··· 17 18 type PageState = 19 | { kind: 'login' } 20 + | { kind: 'verification'; did: Did } 21 22 let pageState = $state<PageState>({ kind: 'login' }) 23 let submitting = $state(false)
+2 -1
frontend/src/routes/RecoverPasskey.svelte
··· 2 import { navigate, routes } from '../lib/router.svelte' 3 import { api, ApiError } from '../lib/api' 4 import { _ } from '../lib/i18n' 5 6 let newPassword = $state('') 7 let confirmPassword = $state('') ··· 44 error = null 45 46 try { 47 - await api.recoverPasskeyAccount(did, token, newPassword) 48 success = true 49 } catch (err) { 50 if (err instanceof ApiError) {
··· 2 import { navigate, routes } from '../lib/router.svelte' 3 import { api, ApiError } from '../lib/api' 4 import { _ } from '../lib/i18n' 5 + import { unsafeAsDid } from '../lib/types/branded' 6 7 let newPassword = $state('') 8 let confirmPassword = $state('') ··· 45 error = null 46 47 try { 48 + await api.recoverPasskeyAccount(unsafeAsDid(did), token, newPassword) 49 success = true 50 } catch (err) { 51 if (err instanceof ApiError) {
+2 -2
frontend/src/routes/RegisterPasskey.svelte
··· 12 import { 13 prepareCreationOptions, 14 serializeAttestationResponse, 15 - type WebAuthnCreationOptionsResponse, 16 } from '../lib/webauthn' 17 18 let serverInfo = $state<{ ··· 126 passkeyName || undefined 127 ) 128 129 - const publicKeyOptions = prepareCreationOptions(options as WebAuthnCreationOptionsResponse) 130 const credential = await navigator.credentials.create({ 131 publicKey: publicKeyOptions 132 })
··· 12 import { 13 prepareCreationOptions, 14 serializeAttestationResponse, 15 + type PublicKeyCredentialCreationOptionsJSON, 16 } from '../lib/webauthn' 17 18 let serverInfo = $state<{ ··· 126 passkeyName || undefined 127 ) 128 129 + const publicKeyOptions = prepareCreationOptions({ publicKey: options as unknown as PublicKeyCredentialCreationOptionsJSON }) 130 const credential = await navigator.credentials.create({ 131 publicKey: publicKeyOptions 132 })
+11 -10
frontend/src/routes/RepoExplorer.svelte
··· 4 import { api, ApiError } from '../lib/api' 5 import { _, locale } from '../lib/i18n' 6 import type { Session } from '../lib/types/api' 7 8 const auth = $derived(getAuthState()) 9 ··· 75 loading = true 76 error = null 77 try { 78 - const result = await api.listRecords(session.accessJwt, session.did, collection, { limit: 50 }) 79 records = result.records.map(r => ({ 80 ...r, 81 rkey: r.uri.split('/').pop()! ··· 91 if (!session || !selectedCollection || !recordsCursor || loadingMore) return 92 loadingMore = true 93 try { 94 - const result = await api.listRecords(session.accessJwt, session.did, selectedCollection, { 95 limit: 50, 96 cursor: recordsCursor 97 }) ··· 180 const result = await api.createRecord( 181 session.accessJwt, 182 session.did, 183 - newCollection.trim(), 184 record, 185 - newRkey.trim() || undefined 186 ) 187 success = $_('repoExplorer.recordCreated', { values: { uri: result.uri } }) 188 await loadCollections() ··· 204 await api.putRecord( 205 session.accessJwt, 206 session.did, 207 - selectedCollection, 208 - selectedRecord.rkey, 209 record 210 ) 211 success = $_('repoExplorer.recordUpdated') 212 const updated = await api.getRecord( 213 session.accessJwt, 214 session.did, 215 - selectedCollection, 216 - selectedRecord.rkey 217 ) 218 selectedRecord = { ...updated, rkey: selectedRecord.rkey } 219 recordJson = JSON.stringify(updated.value, null, 2) ··· 232 await api.deleteRecord( 233 session.accessJwt, 234 session.did, 235 - selectedCollection, 236 - selectedRecord.rkey 237 ) 238 success = $_('repoExplorer.recordDeleted') 239 selectedRecord = null
··· 4 import { api, ApiError } from '../lib/api' 5 import { _, locale } from '../lib/i18n' 6 import type { Session } from '../lib/types/api' 7 + import { unsafeAsNsid, unsafeAsRkey } from '../lib/types/branded' 8 9 const auth = $derived(getAuthState()) 10 ··· 76 loading = true 77 error = null 78 try { 79 + const result = await api.listRecords(session.accessJwt, session.did, unsafeAsNsid(collection), { limit: 50 }) 80 records = result.records.map(r => ({ 81 ...r, 82 rkey: r.uri.split('/').pop()! ··· 92 if (!session || !selectedCollection || !recordsCursor || loadingMore) return 93 loadingMore = true 94 try { 95 + const result = await api.listRecords(session.accessJwt, session.did, unsafeAsNsid(selectedCollection), { 96 limit: 50, 97 cursor: recordsCursor 98 }) ··· 181 const result = await api.createRecord( 182 session.accessJwt, 183 session.did, 184 + unsafeAsNsid(newCollection.trim()), 185 record, 186 + newRkey.trim() ? unsafeAsRkey(newRkey.trim()) : undefined 187 ) 188 success = $_('repoExplorer.recordCreated', { values: { uri: result.uri } }) 189 await loadCollections() ··· 205 await api.putRecord( 206 session.accessJwt, 207 session.did, 208 + unsafeAsNsid(selectedCollection), 209 + unsafeAsRkey(selectedRecord.rkey), 210 record 211 ) 212 success = $_('repoExplorer.recordUpdated') 213 const updated = await api.getRecord( 214 session.accessJwt, 215 session.did, 216 + unsafeAsNsid(selectedCollection), 217 + unsafeAsRkey(selectedRecord.rkey) 218 ) 219 selectedRecord = { ...updated, rkey: selectedRecord.rkey } 220 recordJson = JSON.stringify(updated.value, null, 2) ··· 233 await api.deleteRecord( 234 session.accessJwt, 235 session.did, 236 + unsafeAsNsid(selectedCollection), 237 + unsafeAsRkey(selectedRecord.rkey) 238 ) 239 success = $_('repoExplorer.recordDeleted') 240 selectedRecord = null
+2 -1
frontend/src/routes/RequestPasskeyRecovery.svelte
··· 2 import { navigate, routes, getFullUrl } from '../lib/router.svelte' 3 import { api, ApiError } from '../lib/api' 4 import { _ } from '../lib/i18n' 5 6 let identifier = $state('') 7 let submitting = $state(false) ··· 14 error = null 15 16 try { 17 - await api.requestPasskeyRecovery(identifier) 18 success = true 19 } catch (err) { 20 if (err instanceof ApiError) {
··· 2 import { navigate, routes, getFullUrl } from '../lib/router.svelte' 3 import { api, ApiError } from '../lib/api' 4 import { _ } from '../lib/i18n' 5 + import { unsafeAsEmail } from '../lib/types/branded' 6 7 let identifier = $state('') 8 let submitting = $state(false) ··· 15 error = null 16 17 try { 18 + await api.requestPasskeyRecovery(unsafeAsEmail(identifier)) 19 success = true 20 } catch (err) { 21 if (err instanceof ApiError) {
+2 -1
frontend/src/routes/ResetPassword.svelte
··· 4 import { getAuthState } from '../lib/auth.svelte' 5 import { _ } from '../lib/i18n' 6 import type { Session } from '../lib/types/api' 7 8 const auth = $derived(getAuthState()) 9 ··· 35 error = null 36 success = null 37 try { 38 - await api.requestPasswordReset(email) 39 tokenSent = true 40 success = $_('resetPassword.codeSent') 41 } catch (e) {
··· 4 import { getAuthState } from '../lib/auth.svelte' 5 import { _ } from '../lib/i18n' 6 import type { Session } from '../lib/types/api' 7 + import { unsafeAsEmail } from '../lib/types/branded' 8 9 const auth = $derived(getAuthState()) 10 ··· 36 error = null 37 success = null 38 try { 39 + await api.requestPasswordReset(unsafeAsEmail(email)) 40 tokenSent = true 41 success = $_('resetPassword.codeSent') 42 } catch (e) {
+1 -1
frontend/src/routes/Security.svelte
··· 303 addingPasskey = true 304 try { 305 const { options } = await api.startPasskeyRegistration(session.accessJwt, newPasskeyName || undefined) 306 - const publicKeyOptions = prepareCreationOptions(options as WebAuthnCreationOptionsResponse) 307 const credential = await navigator.credentials.create({ 308 publicKey: publicKeyOptions 309 })
··· 303 addingPasskey = true 304 try { 305 const { options } = await api.startPasskeyRegistration(session.accessJwt, newPasskeyName || undefined) 306 + const publicKeyOptions = prepareCreationOptions(options as unknown as WebAuthnCreationOptionsResponse) 307 const credential = await navigator.credentials.create({ 308 publicKey: publicKeyOptions 309 })
+2 -1
frontend/src/routes/Settings.svelte
··· 5 import { api, ApiError } from '../lib/api' 6 import { locale, setLocale, getSupportedLocales, localeNames, _, type SupportedLocale } from '../lib/i18n' 7 import { isOk } from '../lib/types/result' 8 import type { Session } from '../lib/types/api' 9 import { toast } from '../lib/toast.svelte' 10 ··· 113 const fullHandle = showBYOHandle 114 ? newHandle 115 : `${newHandle}.${pdsHostname}` 116 - await api.updateHandle(session.accessJwt, fullHandle) 117 await refreshSession() 118 toast.success($_('settings.messages.handleUpdated')) 119 newHandle = ''
··· 5 import { api, ApiError } from '../lib/api' 6 import { locale, setLocale, getSupportedLocales, localeNames, _, type SupportedLocale } from '../lib/i18n' 7 import { isOk } from '../lib/types/result' 8 + import { unsafeAsHandle } from '../lib/types/branded' 9 import type { Session } from '../lib/types/api' 10 import { toast } from '../lib/toast.svelte' 11 ··· 114 const fullHandle = showBYOHandle 115 ? newHandle 116 : `${newHandle}.${pdsHostname}` 117 + await api.updateHandle(session.accessJwt, unsafeAsHandle(fullHandle)) 118 await refreshSession() 119 toast.success($_('settings.messages.handleUpdated')) 120 newHandle = ''
+13 -7
frontend/src/routes/Verify.svelte
··· 5 import { navigate, routes, getFullUrl } from '../lib/router.svelte' 6 import { _ } from '../lib/i18n' 7 import type { Session } from '../lib/types/api' 8 9 const STORAGE_KEY = 'tranquil_pds_pending_verification' 10 11 interface PendingVerification { 12 - did: string 13 handle: string 14 channel: string 15 } ··· 66 const stored = localStorage.getItem(STORAGE_KEY) 67 if (stored) { 68 try { 69 - pendingVerification = JSON.parse(stored) 70 } catch { 71 pendingVerification = null 72 } ··· 114 const result = await api.verifyToken( 115 verificationCode.trim(), 116 identifier.trim(), 117 - auth.session?.accessJwt 118 ) 119 success = true 120 successPurpose = result.purpose ··· 137 async function handleEmailUpdate() { 138 if (!verificationCode.trim() || !newEmail.trim()) return 139 140 - if (!auth.session) { 141 error = $_('verify.emailUpdateRequiresAuth') 142 return 143 } ··· 146 error = null 147 148 try { 149 - await api.updateEmail(auth.session.accessJwt, newEmail.trim(), verificationCode.trim()) 150 success = true 151 successPurpose = 'email-update' 152 successChannel = 'email' ··· 185 error = null 186 187 try { 188 - await api.resendMigrationVerification(identifier.trim()) 189 resendMessage = $_('verify.codeResentDetail') 190 } catch (e) { 191 error = e instanceof Error ? e.message : 'Failed to resend verification' ··· 250 <h1>{$_('verify.emailUpdateTitle')}</h1> 251 <p class="subtitle">{$_('verify.emailUpdateSubtitle')}</p> 252 253 - {#if !auth.session} 254 <div class="message warning">{$_('verify.emailUpdateRequiresAuth')}</div> 255 <div class="actions"> 256 <a href="/app/login" class="btn">{$_('verify.signIn')}</a>
··· 5 import { navigate, routes, getFullUrl } from '../lib/router.svelte' 6 import { _ } from '../lib/i18n' 7 import type { Session } from '../lib/types/api' 8 + import { unsafeAsDid, unsafeAsEmail, type Did } from '../lib/types/branded' 9 10 const STORAGE_KEY = 'tranquil_pds_pending_verification' 11 12 interface PendingVerification { 13 + did: Did 14 handle: string 15 channel: string 16 } ··· 67 const stored = localStorage.getItem(STORAGE_KEY) 68 if (stored) { 69 try { 70 + const parsed = JSON.parse(stored) 71 + pendingVerification = { 72 + did: unsafeAsDid(parsed.did), 73 + handle: parsed.handle, 74 + channel: parsed.channel, 75 + } 76 } catch { 77 pendingVerification = null 78 } ··· 120 const result = await api.verifyToken( 121 verificationCode.trim(), 122 identifier.trim(), 123 + session?.accessJwt 124 ) 125 success = true 126 successPurpose = result.purpose ··· 143 async function handleEmailUpdate() { 144 if (!verificationCode.trim() || !newEmail.trim()) return 145 146 + if (!session) { 147 error = $_('verify.emailUpdateRequiresAuth') 148 return 149 } ··· 152 error = null 153 154 try { 155 + await api.updateEmail(session.accessJwt, newEmail.trim(), verificationCode.trim()) 156 success = true 157 successPurpose = 'email-update' 158 successChannel = 'email' ··· 191 error = null 192 193 try { 194 + await api.resendMigrationVerification(unsafeAsEmail(identifier.trim())) 195 resendMessage = $_('verify.codeResentDetail') 196 } catch (e) { 197 error = e instanceof Error ? e.message : 'Failed to resend verification' ··· 256 <h1>{$_('verify.emailUpdateTitle')}</h1> 257 <p class="subtitle">{$_('verify.emailUpdateSubtitle')}</p> 258 259 + {#if !session} 260 <div class="message warning">{$_('verify.emailUpdateRequiresAuth')}</div> 261 <div class="actions"> 262 <a href="/app/login" class="btn">{$_('verify.signIn')}</a>
+4 -3
frontend/src/tests/AppPasswords.test.ts
··· 10 setupAuthenticatedUser, 11 setupFetchMock, 12 setupUnauthenticatedUser, 13 - } from "./mocks"; 14 describe("AppPasswords", () => { 15 beforeEach(() => { 16 clearMocks(); ··· 81 const testPasswords = [ 82 mockData.appPassword({ 83 name: "Graysky", 84 - createdAt: "2024-01-15T10:00:00Z", 85 }), 86 mockData.appPassword({ 87 name: "Skeets", 88 - createdAt: "2024-02-20T15:30:00Z", 89 }), 90 ]; 91 beforeEach(() => {
··· 10 setupAuthenticatedUser, 11 setupFetchMock, 12 setupUnauthenticatedUser, 13 + } from "./mocks.ts"; 14 + import { unsafeAsISODateString } from "../lib/types/branded.ts"; 15 describe("AppPasswords", () => { 16 beforeEach(() => { 17 clearMocks(); ··· 82 const testPasswords = [ 83 mockData.appPassword({ 84 name: "Graysky", 85 + createdAt: unsafeAsISODateString("2024-01-15T10:00:00Z"), 86 }), 87 mockData.appPassword({ 88 name: "Skeets", 89 + createdAt: unsafeAsISODateString("2024-02-20T15:30:00Z"), 90 }), 91 ]; 92 beforeEach(() => {
+21 -11
frontend/src/tests/Login.test.ts
··· 7 mockData, 8 mockEndpoint, 9 setupFetchMock, 10 - } from "./mocks"; 11 - import { _testSetState, type SavedAccount } from "../lib/auth.svelte"; 12 13 describe("Login", () => { 14 beforeEach(() => { ··· 65 describe("with saved accounts", () => { 66 const savedAccounts: SavedAccount[] = [ 67 { 68 - did: "did:web:test.tranquil.dev:u:alice", 69 - handle: "alice.test.tranquil.dev", 70 - accessJwt: "mock-jwt-alice", 71 - refreshJwt: "mock-refresh-alice", 72 }, 73 { 74 - did: "did:web:test.tranquil.dev:u:bob", 75 - handle: "bob.test.tranquil.dev", 76 - accessJwt: "mock-jwt-bob", 77 - refreshJwt: "mock-refresh-bob", 78 }, 79 ]; 80 ··· 88 mockEndpoint( 89 "com.atproto.server.getSession", 90 () => 91 - jsonResponse(mockData.session({ handle: "alice.test.tranquil.dev" })), 92 ); 93 }); 94
··· 7 mockData, 8 mockEndpoint, 9 setupFetchMock, 10 + } from "./mocks.ts"; 11 + import { _testSetState, type SavedAccount } from "../lib/auth.svelte.ts"; 12 + import { 13 + unsafeAsAccessToken, 14 + unsafeAsDid, 15 + unsafeAsHandle, 16 + unsafeAsRefreshToken, 17 + } from "../lib/types/branded.ts"; 18 19 describe("Login", () => { 20 beforeEach(() => { ··· 71 describe("with saved accounts", () => { 72 const savedAccounts: SavedAccount[] = [ 73 { 74 + did: unsafeAsDid("did:web:test.tranquil.dev:u:alice"), 75 + handle: unsafeAsHandle("alice.test.tranquil.dev"), 76 + accessJwt: unsafeAsAccessToken("mock-jwt-alice"), 77 + refreshJwt: unsafeAsRefreshToken("mock-refresh-alice"), 78 }, 79 { 80 + did: unsafeAsDid("did:web:test.tranquil.dev:u:bob"), 81 + handle: unsafeAsHandle("bob.test.tranquil.dev"), 82 + accessJwt: unsafeAsAccessToken("mock-jwt-bob"), 83 + refreshJwt: unsafeAsRefreshToken("mock-refresh-bob"), 84 }, 85 ]; 86 ··· 94 mockEndpoint( 95 "com.atproto.server.getSession", 96 () => 97 + jsonResponse( 98 + mockData.session({ 99 + handle: unsafeAsHandle("alice.test.tranquil.dev"), 100 + }), 101 + ), 102 ); 103 }); 104
+35 -4
frontend/src/tests/migration/storage.test.ts
··· 8 setError, 9 updateProgress, 10 updateStep, 11 - } from "../../lib/migration/storage"; 12 import type { 13 InboundMigrationState, 14 - OutboundMigrationState, 15 - } from "../../lib/migration/types"; 16 17 const STORAGE_KEY = "tranquil_migration_state"; 18 const DPOP_KEY_STORAGE = "migration_dpop_key"; ··· 140 step: "review", 141 }); 142 143 - saveMigrationState(state); 144 145 const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!); 146 expect(stored.version).toBe(1);
··· 8 setError, 9 updateProgress, 10 updateStep, 11 + } from "../../lib/migration/storage.ts"; 12 import type { 13 InboundMigrationState, 14 + MigrationState, 15 + } from "../../lib/migration/types.ts"; 16 + 17 + interface OutboundMigrationState { 18 + direction: "outbound"; 19 + step: string; 20 + localDid: string; 21 + localHandle: string; 22 + targetPdsUrl: string; 23 + targetPdsDid: string; 24 + targetHandle: string; 25 + targetEmail: string; 26 + targetPassword: string; 27 + inviteCode: string; 28 + targetAccessToken: string | null; 29 + targetRefreshToken: string | null; 30 + serviceAuthToken: string | null; 31 + plcToken: string; 32 + progress: { 33 + repoExported: boolean; 34 + repoImported: boolean; 35 + blobsTotal: number; 36 + blobsMigrated: number; 37 + blobsFailed: string[]; 38 + prefsMigrated: boolean; 39 + plcSigned: boolean; 40 + activated: boolean; 41 + deactivated: boolean; 42 + currentOperation: string; 43 + }; 44 + error: string | null; 45 + targetServerInfo: unknown; 46 + } 47 48 const STORAGE_KEY = "tranquil_migration_state"; 49 const DPOP_KEY_STORAGE = "migration_dpop_key"; ··· 171 step: "review", 172 }); 173 174 + saveMigrationState(state as unknown as MigrationState); 175 176 const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!); 177 expect(stored.version).toBe(1);
+21 -12
frontend/src/tests/mocks.ts
··· 1 import { vi } from "vitest"; 2 - import type { AppPassword, InviteCode, Session } from "../lib/api"; 3 - import { _testSetState } from "../lib/auth.svelte"; 4 5 const originalPushState = globalThis.history.pushState.bind(globalThis.history); 6 const originalReplaceState = globalThis.history.replaceState.bind( ··· 144 } 145 export const mockData = { 146 session: (overrides?: Partial<Session>): Session => ({ 147 - did: "did:web:test.tranquil.dev:u:testuser", 148 - handle: "testuser.test.tranquil.dev", 149 - email: "test@example.com", 150 emailConfirmed: true, 151 - accessJwt: "mock-access-jwt-token", 152 - refreshJwt: "mock-refresh-jwt-token", 153 ...overrides, 154 }), 155 appPassword: (overrides?: Partial<AppPassword>): AppPassword => ({ 156 name: "Test App", 157 - createdAt: new Date().toISOString(), 158 ...overrides, 159 }), 160 inviteCode: (overrides?: Partial<InviteCode>): InviteCode => ({ 161 - code: "test-invite-123", 162 available: 1, 163 disabled: false, 164 - forAccount: "did:web:test.tranquil.dev:u:testuser", 165 - createdBy: "did:web:test.tranquil.dev:u:testuser", 166 - createdAt: new Date().toISOString(), 167 uses: [], 168 ...overrides, 169 }),
··· 1 import { vi } from "vitest"; 2 + import type { AppPassword, InviteCode, Session } from "../lib/api.ts"; 3 + import { _testSetState } from "../lib/auth.svelte.ts"; 4 + import { 5 + unsafeAsAccessToken, 6 + unsafeAsDid, 7 + unsafeAsEmail, 8 + unsafeAsHandle, 9 + unsafeAsInviteCode, 10 + unsafeAsISODateString, 11 + unsafeAsRefreshToken, 12 + } from "../lib/types/branded.ts"; 13 14 const originalPushState = globalThis.history.pushState.bind(globalThis.history); 15 const originalReplaceState = globalThis.history.replaceState.bind( ··· 153 } 154 export const mockData = { 155 session: (overrides?: Partial<Session>): Session => ({ 156 + did: unsafeAsDid("did:web:test.tranquil.dev:u:testuser"), 157 + handle: unsafeAsHandle("testuser.test.tranquil.dev"), 158 + email: unsafeAsEmail("test@example.com"), 159 emailConfirmed: true, 160 + accessJwt: unsafeAsAccessToken("mock-access-jwt-token"), 161 + refreshJwt: unsafeAsRefreshToken("mock-refresh-jwt-token"), 162 ...overrides, 163 }), 164 appPassword: (overrides?: Partial<AppPassword>): AppPassword => ({ 165 name: "Test App", 166 + createdAt: unsafeAsISODateString(new Date().toISOString()), 167 ...overrides, 168 }), 169 inviteCode: (overrides?: Partial<InviteCode>): InviteCode => ({ 170 + code: unsafeAsInviteCode("test-invite-123"), 171 available: 1, 172 disabled: false, 173 + forAccount: unsafeAsDid("did:web:test.tranquil.dev:u:testuser"), 174 + createdBy: unsafeAsDid("did:web:test.tranquil.dev:u:testuser"), 175 + createdAt: unsafeAsISODateString(new Date().toISOString()), 176 uses: [], 177 ...overrides, 178 }),
+4 -4
frontend/src/tests/utils.ts
··· 1 - import { render, type RenderResult } from "@testing-library/svelte"; 2 import { tick } from "svelte"; 3 import type { ComponentType } from "svelte"; 4 5 - export async function renderAndWait<T extends ComponentType>( 6 - component: T, 7 options?: Parameters<typeof render>[1], 8 - ): Promise<RenderResult<T>> { 9 const result = render(component, options); 10 await tick(); 11 await new Promise((resolve) => setTimeout(resolve, 0));
··· 1 + import { render } from "@testing-library/svelte"; 2 import { tick } from "svelte"; 3 import type { ComponentType } from "svelte"; 4 5 + export async function renderAndWait( 6 + component: ComponentType, 7 options?: Parameters<typeof render>[1], 8 + ) { 9 const result = render(component, options); 10 await tick(); 11 await new Promise((resolve) => setTimeout(resolve, 0));
+31
frontend/tsconfig.json
···
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ESNext", 4 + "module": "ESNext", 5 + "moduleResolution": "bundler", 6 + "lib": ["ESNext", "DOM", "DOM.Iterable"], 7 + "types": ["svelte", "vite/client"], 8 + "strict": true, 9 + "noImplicitAny": true, 10 + "strictNullChecks": true, 11 + "strictFunctionTypes": true, 12 + "strictBindCallApply": true, 13 + "strictPropertyInitialization": true, 14 + "noImplicitThis": true, 15 + "useUnknownInCatchVariables": true, 16 + "alwaysStrict": true, 17 + "noUnusedLocals": false, 18 + "noUnusedParameters": false, 19 + "noImplicitReturns": true, 20 + "noFallthroughCasesInSwitch": true, 21 + "noImplicitOverride": true, 22 + "allowImportingTsExtensions": true, 23 + "resolveJsonModule": true, 24 + "isolatedModules": true, 25 + "verbatimModuleSyntax": true, 26 + "skipLibCheck": true, 27 + "noEmit": true 28 + }, 29 + "include": ["src/**/*"], 30 + "exclude": ["node_modules", "dist"] 31 + }
+2
justfile
··· 88 . ~/.deno/env && cd frontend && deno task dev 89 frontend-build: 90 . ~/.deno/env && cd frontend && deno task build 91 frontend-clean: 92 rm -rf frontend/dist frontend/node_modules 93
··· 88 . ~/.deno/env && cd frontend && deno task dev 89 frontend-build: 90 . ~/.deno/env && cd frontend && deno task build 91 + frontend-check: 92 + . ~/.deno/env && cd frontend && deno task check 93 frontend-clean: 94 rm -rf frontend/dist frontend/node_modules 95
+8 -4
src/api/actor/preferences.rs
··· 70 let prefs = match prefs_result { 71 Ok(rows) => rows, 72 Err(_) => { 73 - return ApiError::InternalError(Some("Failed to fetch preferences".into())).into_response(); 74 } 75 }; 76 let mut personal_details_pref: Option<Value> = None; ··· 192 let mut tx = match state.db.begin().await { 193 Ok(tx) => tx, 194 Err(_) => { 195 - return ApiError::InternalError(Some("Failed to start transaction".into())).into_response(); 196 } 197 }; 198 let delete_result = sqlx::query!( ··· 225 .await; 226 if insert_result.is_err() { 227 let _ = tx.rollback().await; 228 - return ApiError::InternalError(Some("Failed to save preference".into())).into_response(); 229 } 230 } 231 if tx.commit().await.is_err() { 232 - return ApiError::InternalError(Some("Failed to commit transaction".into())).into_response(); 233 } 234 StatusCode::OK.into_response() 235 }
··· 70 let prefs = match prefs_result { 71 Ok(rows) => rows, 72 Err(_) => { 73 + return ApiError::InternalError(Some("Failed to fetch preferences".into())) 74 + .into_response(); 75 } 76 }; 77 let mut personal_details_pref: Option<Value> = None; ··· 193 let mut tx = match state.db.begin().await { 194 Ok(tx) => tx, 195 Err(_) => { 196 + return ApiError::InternalError(Some("Failed to start transaction".into())) 197 + .into_response(); 198 } 199 }; 200 let delete_result = sqlx::query!( ··· 227 .await; 228 if insert_result.is_err() { 229 let _ = tx.rollback().await; 230 + return ApiError::InternalError(Some("Failed to save preference".into())) 231 + .into_response(); 232 } 233 } 234 if tx.commit().await.is_err() { 235 + return ApiError::InternalError(Some("Failed to commit transaction".into())) 236 + .into_response(); 237 } 238 StatusCode::OK.into_response() 239 }
+12 -5
src/api/admin/account/delete.rs
··· 1 - use crate::api::error::ApiError; 2 use crate::api::EmptyResponse; 3 use crate::auth::BearerAuthAdmin; 4 use crate::state::AppState; 5 use crate::types::Did; ··· 47 .await 48 { 49 error!("Failed to delete session tokens for {}: {:?}", did, e); 50 - return ApiError::InternalError(Some("Failed to delete session tokens".into())).into_response(); 51 } 52 if let Err(e) = sqlx::query!("DELETE FROM used_refresh_tokens WHERE session_id IN (SELECT id FROM session_tokens WHERE did = $1)", did.as_str()) 53 .execute(&mut *tx) ··· 84 "Failed to delete app passwords for user {}: {:?}", 85 user_id, e 86 ); 87 - return ApiError::InternalError(Some("Failed to delete app passwords".into())).into_response(); 88 } 89 if let Err(e) = sqlx::query!( 90 "DELETE FROM invite_code_uses WHERE used_by_user = $1", ··· 128 error!("Failed to commit account deletion transaction: {:?}", e); 129 return ApiError::InternalError(Some("Failed to commit deletion".into())).into_response(); 130 } 131 - if let Err(e) = 132 - crate::api::repo::record::sequence_account_event(&state, did.as_str(), false, Some("deleted")).await 133 { 134 warn!( 135 "Failed to sequence account deletion event for {}: {}",
··· 1 use crate::api::EmptyResponse; 2 + use crate::api::error::ApiError; 3 use crate::auth::BearerAuthAdmin; 4 use crate::state::AppState; 5 use crate::types::Did; ··· 47 .await 48 { 49 error!("Failed to delete session tokens for {}: {:?}", did, e); 50 + return ApiError::InternalError(Some("Failed to delete session tokens".into())) 51 + .into_response(); 52 } 53 if let Err(e) = sqlx::query!("DELETE FROM used_refresh_tokens WHERE session_id IN (SELECT id FROM session_tokens WHERE did = $1)", did.as_str()) 54 .execute(&mut *tx) ··· 85 "Failed to delete app passwords for user {}: {:?}", 86 user_id, e 87 ); 88 + return ApiError::InternalError(Some("Failed to delete app passwords".into())) 89 + .into_response(); 90 } 91 if let Err(e) = sqlx::query!( 92 "DELETE FROM invite_code_uses WHERE used_by_user = $1", ··· 130 error!("Failed to commit account deletion transaction: {:?}", e); 131 return ApiError::InternalError(Some("Failed to commit deletion".into())).into_response(); 132 } 133 + if let Err(e) = crate::api::repo::record::sequence_account_event( 134 + &state, 135 + did.as_str(), 136 + false, 137 + Some("deleted"), 138 + ) 139 + .await 140 { 141 warn!( 142 "Failed to sequence account deletion event for {}: {}",
+5 -1
src/api/admin/account/email.rs
··· 74 let result = crate::comms::enqueue_comms(&state.db, item).await; 75 match result { 76 Ok(_) => { 77 - tracing::info!("Admin email queued for {} ({})", handle, input.recipient_did); 78 (StatusCode::OK, Json(SendEmailOutput { sent: true })).into_response() 79 } 80 Err(e) => {
··· 74 let result = crate::comms::enqueue_comms(&state.db, item).await; 75 match result { 76 Ok(_) => { 77 + tracing::info!( 78 + "Admin email queued for {} ({})", 79 + handle, 80 + input.recipient_did 81 + ); 82 (StatusCode::OK, Json(SendEmailOutput { sent: true })).into_response() 83 } 84 Err(e) => {
+16 -7
src/api/admin/account/update.rs
··· 1 - use crate::api::error::ApiError; 2 use crate::api::EmptyResponse; 3 use crate::auth::BearerAuthAdmin; 4 use crate::state::AppState; 5 use crate::types::{Did, PlainPassword}; ··· 87 if let Ok(Some(_)) = existing { 88 return ApiError::HandleTaken.into_response(); 89 } 90 - let result = sqlx::query!("UPDATE users SET handle = $1 WHERE did = $2", handle, did.as_str()) 91 - .execute(&state.db) 92 - .await; 93 match result { 94 Ok(r) => { 95 if r.rows_affected() == 0 { ··· 99 let _ = state.cache.delete(&format!("handle:{}", old)).await; 100 } 101 let _ = state.cache.delete(&format!("handle:{}", handle)).await; 102 - if let Err(e) = 103 - crate::api::repo::record::sequence_identity_event(&state, did.as_str(), Some(&handle)).await 104 { 105 warn!( 106 "Failed to sequence identity event for admin handle update: {}", 107 e 108 ); 109 } 110 - if let Err(e) = crate::api::identity::did::update_plc_handle(&state, did.as_str(), &handle).await 111 { 112 warn!("Failed to update PLC handle for admin handle update: {}", e); 113 }
··· 1 use crate::api::EmptyResponse; 2 + use crate::api::error::ApiError; 3 use crate::auth::BearerAuthAdmin; 4 use crate::state::AppState; 5 use crate::types::{Did, PlainPassword}; ··· 87 if let Ok(Some(_)) = existing { 88 return ApiError::HandleTaken.into_response(); 89 } 90 + let result = sqlx::query!( 91 + "UPDATE users SET handle = $1 WHERE did = $2", 92 + handle, 93 + did.as_str() 94 + ) 95 + .execute(&state.db) 96 + .await; 97 match result { 98 Ok(r) => { 99 if r.rows_affected() == 0 { ··· 103 let _ = state.cache.delete(&format!("handle:{}", old)).await; 104 } 105 let _ = state.cache.delete(&format!("handle:{}", handle)).await; 106 + if let Err(e) = crate::api::repo::record::sequence_identity_event( 107 + &state, 108 + did.as_str(), 109 + Some(&handle), 110 + ) 111 + .await 112 { 113 warn!( 114 "Failed to sequence identity event for admin handle update: {}", 115 e 116 ); 117 } 118 + if let Err(e) = 119 + crate::api::identity::did::update_plc_handle(&state, did.as_str(), &handle).await 120 { 121 warn!("Failed to update PLC handle for admin handle update: {}", e); 122 }
+2 -4
src/api/admin/status.rs
··· 119 let did = match &params.did { 120 Some(d) => d, 121 None => { 122 - return ApiError::InvalidRequest( 123 - "Must provide a did to request blob state".into(), 124 - ) 125 - .into_response(); 126 } 127 }; 128 let blob = sqlx::query!(
··· 119 let did = match &params.did { 120 Some(d) => d, 121 None => { 122 + return ApiError::InvalidRequest("Must provide a did to request blob state".into()) 123 + .into_response(); 124 } 125 }; 126 let blob = sqlx::query!(
+6 -3
src/api/age_assurance.rs
··· 50 } 51 }; 52 53 - let row = match sqlx::query!("SELECT created_at FROM users WHERE did = $1", &auth_user.did) 54 - .fetch_optional(&state.db) 55 - .await 56 { 57 Ok(r) => { 58 tracing::debug!(?r, "age assurance: query result");
··· 50 } 51 }; 52 53 + let row = match sqlx::query!( 54 + "SELECT created_at FROM users WHERE did = $1", 55 + &auth_user.did 56 + ) 57 + .fetch_optional(&state.db) 58 + .await 59 { 60 Ok(r) => { 61 tracing::debug!(?r, "age assurance: query result");
+7 -4
src/api/backup.rs
··· 144 Ok(bytes) => bytes, 145 Err(e) => { 146 error!("Failed to fetch backup from storage: {:?}", e); 147 - return ApiError::InternalError(Some("Failed to retrieve backup".into())).into_response(); 148 } 149 }; 150 ··· 223 Ok(bytes) => bytes, 224 Err(e) => { 225 error!("Failed to generate CAR: {:?}", e); 226 - return ApiError::InternalError(Some("Failed to generate backup".into())).into_response(); 227 } 228 }; 229 ··· 448 449 info!(did = %auth.0.did, enabled = input.enabled, "Updated backup_enabled setting"); 450 451 - EnabledResponse::new(input.enabled).into_response() 452 } 453 454 pub async fn export_blobs(State(state): State<AppState>, auth: BearerAuth) -> Response { ··· 575 576 if let Err(e) = zip.finish() { 577 error!("Failed to finish zip: {:?}", e); 578 - return ApiError::InternalError(Some("Failed to create zip file".into())).into_response(); 579 } 580 } 581
··· 144 Ok(bytes) => bytes, 145 Err(e) => { 146 error!("Failed to fetch backup from storage: {:?}", e); 147 + return ApiError::InternalError(Some("Failed to retrieve backup".into())) 148 + .into_response(); 149 } 150 }; 151 ··· 224 Ok(bytes) => bytes, 225 Err(e) => { 226 error!("Failed to generate CAR: {:?}", e); 227 + return ApiError::InternalError(Some("Failed to generate backup".into())) 228 + .into_response(); 229 } 230 }; 231 ··· 450 451 info!(did = %auth.0.did, enabled = input.enabled, "Updated backup_enabled setting"); 452 453 + EnabledResponse::response(input.enabled).into_response() 454 } 455 456 pub async fn export_blobs(State(state): State<AppState>, auth: BearerAuth) -> Response { ··· 577 578 if let Err(e) = zip.finish() { 579 error!("Failed to finish zip: {:?}", e); 580 + return ApiError::InternalError(Some("Failed to create zip file".into())) 581 + .into_response(); 582 } 583 } 584
+11 -4
src/api/delegation.rs
··· 39 Ok(c) => c, 40 Err(e) => { 41 tracing::error!("Failed to list controllers: {:?}", e); 42 - return ApiError::InternalError(Some("Failed to list controllers".into())).into_response(); 43 } 44 }; 45 ··· 269 Ok(false) => ApiError::DelegationNotFound.into_response(), 270 Err(e) => { 271 tracing::error!("Failed to update controller scopes: {:?}", e); 272 - ApiError::InternalError(Some("Failed to update controller scopes".into())).into_response() 273 } 274 } 275 } ··· 357 Ok(e) => e, 358 Err(e) => { 359 tracing::error!("Failed to get audit log: {:?}", e); 360 - return ApiError::InternalError(Some("Failed to get audit log".into())).into_response(); 361 } 362 }; 363 ··· 762 763 info!(did = %did, handle = %handle, controller = %&auth.0.did, "Delegated account created"); 764 765 - Json(CreateDelegatedAccountResponse { did: did.into(), handle: handle.into() }).into_response() 766 }
··· 39 Ok(c) => c, 40 Err(e) => { 41 tracing::error!("Failed to list controllers: {:?}", e); 42 + return ApiError::InternalError(Some("Failed to list controllers".into())) 43 + .into_response(); 44 } 45 }; 46 ··· 270 Ok(false) => ApiError::DelegationNotFound.into_response(), 271 Err(e) => { 272 tracing::error!("Failed to update controller scopes: {:?}", e); 273 + ApiError::InternalError(Some("Failed to update controller scopes".into())) 274 + .into_response() 275 } 276 } 277 } ··· 359 Ok(e) => e, 360 Err(e) => { 361 tracing::error!("Failed to get audit log: {:?}", e); 362 + return ApiError::InternalError(Some("Failed to get audit log".into())) 363 + .into_response(); 364 } 365 }; 366 ··· 765 766 info!(did = %did, handle = %handle, controller = %&auth.0.did, "Delegated account created"); 767 768 + Json(CreateDelegatedAccountResponse { 769 + did: did.into(), 770 + handle: handle.into(), 771 + }) 772 + .into_response() 773 }
+10 -15
src/api/error.rs
··· 115 Self::UpstreamFailure | Self::UpstreamUnavailable(_) | Self::UpstreamErrorMsg(_) => { 116 StatusCode::BAD_GATEWAY 117 } 118 - Self::ServiceUnavailable(_) | Self::BackupsDisabled => { 119 - StatusCode::SERVICE_UNAVAILABLE 120 - } 121 Self::UpstreamTimeout => StatusCode::GATEWAY_TIMEOUT, 122 Self::UpstreamError { status, .. } => { 123 StatusCode::from_u16(*status).unwrap_or(StatusCode::BAD_GATEWAY) ··· 155 | Self::SubjectNotFound 156 | Self::BlobNotFound(_) 157 | Self::NotFoundMsg(_) => StatusCode::NOT_FOUND, 158 - Self::RepoTakendown 159 - | Self::RepoDeactivated 160 - | Self::RepoNotFound(_) => StatusCode::BAD_REQUEST, 161 - Self::InvalidSwap(_) | Self::TotpAlreadyEnabled => { 162 - StatusCode::CONFLICT 163 } 164 Self::InvalidRequest(_) 165 | Self::InvalidHandle(_) 166 | Self::HandleNotAvailable(_) ··· 435 } 436 } 437 438 - 439 impl From<sqlx::Error> for ApiError { 440 fn from(e: sqlx::Error) -> Self { 441 tracing::error!("Database error: {:?}", e); ··· 522 VerifyError::UnsupportedVersion => { 523 Self::InvalidRequest("This verification code version is not supported".to_string()) 524 } 525 - VerifyError::Expired => { 526 - Self::InvalidRequest("The verification code has expired. Please request a new one.".to_string()) 527 - } 528 VerifyError::InvalidSignature => { 529 Self::InvalidRequest("The verification code is invalid".to_string()) 530 } ··· 565 PlcError::NotFound => Self::NotFoundMsg("DID not found in PLC directory".into()), 566 PlcError::Tombstoned => Self::InvalidRequest("DID is tombstoned".into()), 567 PlcError::Timeout => Self::UpstreamTimeout, 568 - PlcError::CircuitBreakerOpen => { 569 - Self::ServiceUnavailable(Some("PLC directory service temporarily unavailable".into())) 570 - } 571 PlcError::Http(err) => { 572 tracing::error!("PLC HTTP error: {:?}", err); 573 Self::UpstreamErrorMsg("Failed to communicate with PLC directory".into())
··· 115 Self::UpstreamFailure | Self::UpstreamUnavailable(_) | Self::UpstreamErrorMsg(_) => { 116 StatusCode::BAD_GATEWAY 117 } 118 + Self::ServiceUnavailable(_) | Self::BackupsDisabled => StatusCode::SERVICE_UNAVAILABLE, 119 Self::UpstreamTimeout => StatusCode::GATEWAY_TIMEOUT, 120 Self::UpstreamError { status, .. } => { 121 StatusCode::from_u16(*status).unwrap_or(StatusCode::BAD_GATEWAY) ··· 153 | Self::SubjectNotFound 154 | Self::BlobNotFound(_) 155 | Self::NotFoundMsg(_) => StatusCode::NOT_FOUND, 156 + Self::RepoTakendown | Self::RepoDeactivated | Self::RepoNotFound(_) => { 157 + StatusCode::BAD_REQUEST 158 } 159 + Self::InvalidSwap(_) | Self::TotpAlreadyEnabled => StatusCode::CONFLICT, 160 Self::InvalidRequest(_) 161 | Self::InvalidHandle(_) 162 | Self::HandleNotAvailable(_) ··· 431 } 432 } 433 434 impl From<sqlx::Error> for ApiError { 435 fn from(e: sqlx::Error) -> Self { 436 tracing::error!("Database error: {:?}", e); ··· 517 VerifyError::UnsupportedVersion => { 518 Self::InvalidRequest("This verification code version is not supported".to_string()) 519 } 520 + VerifyError::Expired => Self::InvalidRequest( 521 + "The verification code has expired. Please request a new one.".to_string(), 522 + ), 523 VerifyError::InvalidSignature => { 524 Self::InvalidRequest("The verification code is invalid".to_string()) 525 } ··· 560 PlcError::NotFound => Self::NotFoundMsg("DID not found in PLC directory".into()), 561 PlcError::Tombstoned => Self::InvalidRequest("DID is tombstoned".into()), 562 PlcError::Timeout => Self::UpstreamTimeout, 563 + PlcError::CircuitBreakerOpen => Self::ServiceUnavailable(Some( 564 + "PLC directory service temporarily unavailable".into(), 565 + )), 566 PlcError::Http(err) => { 567 tracing::error!("PLC HTTP error: {:?}", err); 568 Self::UpstreamErrorMsg("Failed to communicate with PLC directory".into())
+4 -2
src/api/identity/account.rs
··· 12 http::{HeaderMap, StatusCode}, 13 response::{IntoResponse, Response}, 14 }; 15 - use serde_json::json; 16 use bcrypt::{DEFAULT_COST, hash}; 17 use jacquard::types::{integer::LimitedU32, string::Tid}; 18 use jacquard_repo::{mst::Mst, storage::BlockStore}; 19 use k256::{SecretKey, ecdsa::SigningKey}; 20 use rand::rngs::OsRng; 21 use serde::{Deserialize, Serialize}; 22 use std::sync::Arc; 23 use tracing::{debug, error, info, warn}; 24 ··· 90 .await 91 { 92 warn!(ip = %client_ip, "Account creation rate limit exceeded"); 93 - return ApiError::RateLimitExceeded(Some("Too many account creation attempts. Please try again later.".into(),)) 94 .into_response(); 95 } 96
··· 12 http::{HeaderMap, StatusCode}, 13 response::{IntoResponse, Response}, 14 }; 15 use bcrypt::{DEFAULT_COST, hash}; 16 use jacquard::types::{integer::LimitedU32, string::Tid}; 17 use jacquard_repo::{mst::Mst, storage::BlockStore}; 18 use k256::{SecretKey, ecdsa::SigningKey}; 19 use rand::rngs::OsRng; 20 use serde::{Deserialize, Serialize}; 21 + use serde_json::json; 22 use std::sync::Arc; 23 use tracing::{debug, error, info, warn}; 24 ··· 90 .await 91 { 92 warn!(ip = %client_ip, "Account creation rate limit exceeded"); 93 + return ApiError::RateLimitExceeded(Some( 94 + "Too many account creation attempts. Please try again later.".into(), 95 + )) 96 .into_response(); 97 } 98
+18 -11
src/api/identity/did.rs
··· 38 } 39 let cache_key = format!("handle:{}", handle); 40 if let Some(did) = state.cache.get(&cache_key).await { 41 - return DidResponse::new(did).into_response(); 42 } 43 let user = sqlx::query!("SELECT did FROM users WHERE handle = $1", handle) 44 .fetch_optional(&state.db) ··· 49 .cache 50 .set(&cache_key, &row.did, std::time::Duration::from_secs(300)) 51 .await; 52 - DidResponse::new(row.did).into_response() 53 } 54 Ok(None) => match crate::handle::resolve_handle(handle).await { 55 Ok(did) => { ··· 57 .cache 58 .set(&cache_key, &did, std::time::Duration::from_secs(300)) 59 .await; 60 - DidResponse::new(did).into_response() 61 } 62 Err(_) => ApiError::HandleNotFound.into_response(), 63 }, ··· 627 .check_rate_limit(crate::state::RateLimitKind::HandleUpdate, &did) 628 .await 629 { 630 - return ApiError::RateLimitExceeded(Some("Too many handle updates. Try again later.".into(),)) 631 .into_response(); 632 } 633 if !state ··· 663 .into_response(); 664 } 665 if segment.starts_with('-') || segment.ends_with('-') { 666 - return ApiError::InvalidHandle(Some("Handle segment cannot start or end with hyphen".into(),)) 667 - .into_response(); 668 } 669 } 670 if crate::moderation::has_explicit_slur(&new_handle) { ··· 695 return EmptyResponse::ok().into_response(); 696 } 697 if short_part.contains('.') { 698 - return ApiError::InvalidHandle(Some("Nested subdomains are not allowed. Use a simple handle without dots.".into(),)) 699 - .into_response(); 700 } 701 if short_part.len() < 3 { 702 return ApiError::InvalidHandle(Some("Handle too short".into())).into_response(); ··· 721 return ApiError::HandleNotAvailable(None).into_response(); 722 } 723 Err(crate::handle::HandleResolutionError::DidMismatch { expected, actual }) => { 724 - return ApiError::HandleNotAvailable(Some( 725 - format!("Handle points to different DID. Expected {}, got {}", expected, actual), 726 - )) 727 .into_response(); 728 } 729 Err(e) => {
··· 38 } 39 let cache_key = format!("handle:{}", handle); 40 if let Some(did) = state.cache.get(&cache_key).await { 41 + return DidResponse::response(did).into_response(); 42 } 43 let user = sqlx::query!("SELECT did FROM users WHERE handle = $1", handle) 44 .fetch_optional(&state.db) ··· 49 .cache 50 .set(&cache_key, &row.did, std::time::Duration::from_secs(300)) 51 .await; 52 + DidResponse::response(row.did).into_response() 53 } 54 Ok(None) => match crate::handle::resolve_handle(handle).await { 55 Ok(did) => { ··· 57 .cache 58 .set(&cache_key, &did, std::time::Duration::from_secs(300)) 59 .await; 60 + DidResponse::response(did).into_response() 61 } 62 Err(_) => ApiError::HandleNotFound.into_response(), 63 }, ··· 627 .check_rate_limit(crate::state::RateLimitKind::HandleUpdate, &did) 628 .await 629 { 630 + return ApiError::RateLimitExceeded(Some( 631 + "Too many handle updates. Try again later.".into(), 632 + )) 633 .into_response(); 634 } 635 if !state ··· 665 .into_response(); 666 } 667 if segment.starts_with('-') || segment.ends_with('-') { 668 + return ApiError::InvalidHandle(Some( 669 + "Handle segment cannot start or end with hyphen".into(), 670 + )) 671 + .into_response(); 672 } 673 } 674 if crate::moderation::has_explicit_slur(&new_handle) { ··· 699 return EmptyResponse::ok().into_response(); 700 } 701 if short_part.contains('.') { 702 + return ApiError::InvalidHandle(Some( 703 + "Nested subdomains are not allowed. Use a simple handle without dots.".into(), 704 + )) 705 + .into_response(); 706 } 707 if short_part.len() < 3 { 708 return ApiError::InvalidHandle(Some("Handle too short".into())).into_response(); ··· 727 return ApiError::HandleNotAvailable(None).into_response(); 728 } 729 Err(crate::handle::HandleResolutionError::DidMismatch { expected, actual }) => { 730 + return ApiError::HandleNotAvailable(Some(format!( 731 + "Handle points to different DID. Expected {}, got {}", 732 + expected, actual 733 + ))) 734 .into_response(); 735 } 736 Err(e) => {
+2 -1
src/api/identity/plc/sign.rs
··· 120 { 121 Ok(Some(row)) => row, 122 _ => { 123 - return ApiError::InternalError(Some("User signing key not found".into())).into_response(); 124 } 125 }; 126 let key_bytes = match crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version)
··· 120 { 121 Ok(Some(row)) => row, 122 _ => { 123 + return ApiError::InternalError(Some("User signing key not found".into())) 124 + .into_response(); 125 } 126 }; 127 let key_bytes = match crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version)
+2 -1
src/api/identity/plc/submit.rs
··· 75 { 76 Ok(Some(row)) => row, 77 _ => { 78 - return ApiError::InternalError(Some("User signing key not found".into())).into_response(); 79 } 80 }; 81 let key_bytes = match crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version)
··· 75 { 76 Ok(Some(row)) => row, 77 _ => { 78 + return ApiError::InternalError(Some("User signing key not found".into())) 79 + .into_response(); 80 } 81 }; 82 let key_bytes = match crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version)
+1 -1
src/api/mod.rs
··· 17 pub mod verification; 18 19 pub use error::ApiError; 20 pub use responses::{ 21 DidResponse, EmptyResponse, EnabledResponse, HasPasswordResponse, OptionsResponse, 22 StatusResponse, SuccessResponse, TokenRequiredResponse, VerifiedResponse, 23 }; 24 - pub use proxy_client::{AtUriParts, proxy_client, validate_at_uri, validate_did, validate_limit};
··· 17 pub mod verification; 18 19 pub use error::ApiError; 20 + pub use proxy_client::{AtUriParts, proxy_client, validate_at_uri, validate_did, validate_limit}; 21 pub use responses::{ 22 DidResponse, EmptyResponse, EnabledResponse, HasPasswordResponse, OptionsResponse, 23 StatusResponse, SuccessResponse, TokenRequiredResponse, VerifiedResponse, 24 };
+4 -2
src/api/moderation/mod.rs
··· 111 } 112 Err(e) => { 113 error!(error = ?e, "DB error fetching user key for report"); 114 - return ApiError::AuthenticationFailed(Some("Failed to get signing key".into())) 115 - .into_response(); 116 } 117 } 118 }
··· 111 } 112 Err(e) => { 113 error!(error = ?e, "DB error fetching user key for report"); 114 + return ApiError::AuthenticationFailed(Some( 115 + "Failed to get signing key".into(), 116 + )) 117 + .into_response(); 118 } 119 } 120 }
+38 -40
src/api/notification_prefs.rs
··· 38 return ApiError::AuthenticationFailed(None).into_response(); 39 } 40 }; 41 - let row = 42 - match sqlx::query( 43 - r#" 44 SELECT 45 email, 46 preferred_comms_channel::text as channel, ··· 53 FROM users 54 WHERE did = $1 55 "#, 56 - ) 57 - .bind(&user.did) 58 - .fetch_one(&state.db) 59 - .await 60 - { 61 - Ok(r) => r, 62 - Err(e) => { 63 - return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response() 64 - } 65 - }; 66 let email: String = row.get("email"); 67 let channel: String = row.get("channel"); 68 let discord_id: Option<String> = row.get("discord_id"); ··· 125 { 126 Ok(id) => id, 127 Err(e) => { 128 - return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response() 129 } 130 }; 131 132 - let rows = 133 - match sqlx::query!( 134 - r#" 135 SELECT 136 created_at, 137 channel as "channel: String", ··· 144 ORDER BY created_at DESC 145 LIMIT 50 146 "#, 147 - user_id 148 - ) 149 - .fetch_all(&state.db) 150 - .await 151 - { 152 - Ok(r) => r, 153 - Err(e) => { 154 - return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response() 155 - } 156 - }; 157 158 let sensitive_types = [ 159 "email_verification", ··· 270 } 271 }; 272 273 - let user_row = 274 - match sqlx::query!( 275 - "SELECT id, handle, email FROM users WHERE did = $1", 276 - &user.did 277 - ) 278 - .fetch_one(&state.db) 279 - .await 280 - { 281 - Ok(row) => row, 282 - Err(e) => { 283 - return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response() 284 - } 285 - }; 286 287 let user_id = user_row.id; 288 let handle = user_row.handle;
··· 38 return ApiError::AuthenticationFailed(None).into_response(); 39 } 40 }; 41 + let row = match sqlx::query( 42 + r#" 43 SELECT 44 email, 45 preferred_comms_channel::text as channel, ··· 52 FROM users 53 WHERE did = $1 54 "#, 55 + ) 56 + .bind(&user.did) 57 + .fetch_one(&state.db) 58 + .await 59 + { 60 + Ok(r) => r, 61 + Err(e) => { 62 + return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response(); 63 + } 64 + }; 65 let email: String = row.get("email"); 66 let channel: String = row.get("channel"); 67 let discord_id: Option<String> = row.get("discord_id"); ··· 124 { 125 Ok(id) => id, 126 Err(e) => { 127 + return ApiError::InternalError(Some(format!("Database error: {}", e))) 128 + .into_response(); 129 } 130 }; 131 132 + let rows = match sqlx::query!( 133 + r#" 134 SELECT 135 created_at, 136 channel as "channel: String", ··· 143 ORDER BY created_at DESC 144 LIMIT 50 145 "#, 146 + user_id 147 + ) 148 + .fetch_all(&state.db) 149 + .await 150 + { 151 + Ok(r) => r, 152 + Err(e) => { 153 + return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response(); 154 + } 155 + }; 156 157 let sensitive_types = [ 158 "email_verification", ··· 269 } 270 }; 271 272 + let user_row = match sqlx::query!( 273 + "SELECT id, handle, email FROM users WHERE did = $1", 274 + &user.did 275 + ) 276 + .fetch_one(&state.db) 277 + .await 278 + { 279 + Ok(row) => row, 280 + Err(e) => { 281 + return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response(); 282 + } 283 + }; 284 285 let user_id = user_row.id; 286 let handle = user_row.handle;
+3 -3
src/api/proxy.rs
··· 186 ) -> Response { 187 // This layer is nested under /xrpc in an axum router so the extracted uri will look like /<method> and thus we can just strip the / 188 let method = uri.path().trim_start_matches("/"); 189 - if is_protected_method(&method) { 190 warn!(method = %method, "Attempted to proxy protected method"); 191 return ApiError::InvalidRequest(format!("Cannot proxy protected method: {}", method)) 192 .into_response(); ··· 226 auth_user.is_oauth, 227 auth_user.scope.as_deref(), 228 &resolved.did, 229 - &method, 230 ) { 231 return e; 232 } ··· 235 match crate::auth::create_service_token( 236 &auth_user.did, 237 &resolved.did, 238 - &method, 239 &key_bytes, 240 ) { 241 Ok(new_token) => {
··· 186 ) -> Response { 187 // This layer is nested under /xrpc in an axum router so the extracted uri will look like /<method> and thus we can just strip the / 188 let method = uri.path().trim_start_matches("/"); 189 + if is_protected_method(method) { 190 warn!(method = %method, "Attempted to proxy protected method"); 191 return ApiError::InvalidRequest(format!("Cannot proxy protected method: {}", method)) 192 .into_response(); ··· 226 auth_user.is_oauth, 227 auth_user.scope.as_deref(), 228 &resolved.did, 229 + method, 230 ) { 231 return e; 232 } ··· 235 match crate::auth::create_service_token( 236 &auth_user.did, 237 &resolved.did, 238 + method, 239 &key_bytes, 240 ) { 241 Ok(new_token) => {
+7 -6
src/api/repo/blob.rs
··· 29 ); 30 } 31 detected 32 } else { 33 - if client_hint == "*/*" || client_hint.is_empty() { 34 - warn!("Could not detect MIME type and client sent invalid hint: '{}'", client_hint); 35 - "application/octet-stream".to_string() 36 - } else { 37 - client_hint.to_string() 38 - } 39 } 40 } 41
··· 29 ); 30 } 31 detected 32 + } else if client_hint == "*/*" || client_hint.is_empty() { 33 + warn!( 34 + "Could not detect MIME type and client sent invalid hint: '{}'", 35 + client_hint 36 + ); 37 + "application/octet-stream".to_string() 38 } else { 39 + client_hint.to_string() 40 } 41 } 42
+9 -9
src/api/repo/import.rs
··· 1 use crate::api::error::ApiError; 2 use crate::api::repo::record::create_signed_commit; 3 - use crate::api::EmptyResponse; 4 use crate::state::AppState; 5 use crate::sync::import::{ImportError, apply_import, parse_car}; 6 use crate::sync::verify::CarVerifier; ··· 371 ApiError::InvalidRequest(format!("Referenced block not found in CAR: {}", cid)) 372 .into_response() 373 } 374 - Err(ImportError::ConcurrentModification) => ApiError::InvalidSwap(Some("Repository is being modified by another operation, please retry".into(),)) 375 .into_response(), 376 Err(ImportError::VerificationFailed(ve)) => { 377 ApiError::InvalidRequest(format!("CAR verification failed: {}", ve)).into_response() 378 } 379 - Err(ImportError::DidMismatch { car_did, auth_did }) => { 380 - ApiError::InvalidRequest(format!( 381 - "CAR is for {} but authenticated as {}", 382 - car_did, auth_did 383 - )) 384 - .into_response() 385 - } 386 Err(e) => { 387 error!("Import error: {:?}", e); 388 ApiError::InternalError(None).into_response()
··· 1 + use crate::api::EmptyResponse; 2 use crate::api::error::ApiError; 3 use crate::api::repo::record::create_signed_commit; 4 use crate::state::AppState; 5 use crate::sync::import::{ImportError, apply_import, parse_car}; 6 use crate::sync::verify::CarVerifier; ··· 371 ApiError::InvalidRequest(format!("Referenced block not found in CAR: {}", cid)) 372 .into_response() 373 } 374 + Err(ImportError::ConcurrentModification) => ApiError::InvalidSwap(Some( 375 + "Repository is being modified by another operation, please retry".into(), 376 + )) 377 .into_response(), 378 Err(ImportError::VerificationFailed(ve)) => { 379 ApiError::InvalidRequest(format!("CAR verification failed: {}", ve)).into_response() 380 } 381 + Err(ImportError::DidMismatch { car_did, auth_did }) => ApiError::InvalidRequest(format!( 382 + "CAR is for {} but authenticated as {}", 383 + car_did, auth_did 384 + )) 385 + .into_response(), 386 Err(e) => { 387 error!("Import error: {:?}", e); 388 ApiError::InternalError(None).into_response()
+19 -15
src/api/repo/record/batch.rs
··· 205 } 206 } 207 208 - let user_id: uuid::Uuid = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did.as_str()) 209 - .fetch_optional(&state.db) 210 - .await 211 - { 212 - Ok(Some(id)) => id, 213 - _ => return ApiError::InternalError(Some("User not found".into())).into_response(), 214 - }; 215 let root_cid_str: String = match sqlx::query_scalar!( 216 "SELECT repo_root_cid FROM repos WHERE user_id = $1", 217 user_id ··· 225 let current_root_cid = match Cid::from_str(&root_cid_str) { 226 Ok(c) => c, 227 Err(_) => { 228 - return ApiError::InternalError(Some("Invalid repo root CID".into())).into_response() 229 } 230 }; 231 if let Some(swap_commit) = &input.swap_commit ··· 281 Ok(c) => c, 282 Err(_) => { 283 return ApiError::InternalError(Some("Failed to store record".into())) 284 - .into_response() 285 } 286 }; 287 let key = format!("{}/{}", collection, rkey); ··· 290 Ok(m) => m, 291 Err(_) => { 292 return ApiError::InternalError(Some("Failed to add to MST".into())) 293 - .into_response() 294 } 295 }; 296 let uri = AtUri::from_parts(&did, collection, &rkey); ··· 335 Ok(c) => c, 336 Err(_) => { 337 return ApiError::InternalError(Some("Failed to store record".into())) 338 - .into_response() 339 } 340 }; 341 let key = format!("{}/{}", collection, rkey); ··· 345 Ok(m) => m, 346 Err(_) => { 347 return ApiError::InternalError(Some("Failed to update MST".into())) 348 - .into_response() 349 } 350 }; 351 let uri = AtUri::from_parts(&did, collection, rkey); ··· 369 Ok(m) => m, 370 Err(_) => { 371 return ApiError::InternalError(Some("Failed to delete from MST".into())) 372 - .into_response() 373 } 374 }; 375 results.push(WriteResult::DeleteResult {}); ··· 383 } 384 let new_mst_root = match mst.persist().await { 385 Ok(c) => c, 386 - Err(_) => return ApiError::InternalError(Some("Failed to persist MST".into())).into_response(), 387 }; 388 let mut relevant_blocks = std::collections::BTreeMap::new(); 389 for key in &modified_keys { ··· 432 Ok(res) => res, 433 Err(e) => { 434 error!("Commit failed: {}", e); 435 - return ApiError::InternalError(Some("Failed to commit changes".into())).into_response(); 436 } 437 }; 438
··· 205 } 206 } 207 208 + let user_id: uuid::Uuid = 209 + match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did.as_str()) 210 + .fetch_optional(&state.db) 211 + .await 212 + { 213 + Ok(Some(id)) => id, 214 + _ => return ApiError::InternalError(Some("User not found".into())).into_response(), 215 + }; 216 let root_cid_str: String = match sqlx::query_scalar!( 217 "SELECT repo_root_cid FROM repos WHERE user_id = $1", 218 user_id ··· 226 let current_root_cid = match Cid::from_str(&root_cid_str) { 227 Ok(c) => c, 228 Err(_) => { 229 + return ApiError::InternalError(Some("Invalid repo root CID".into())).into_response(); 230 } 231 }; 232 if let Some(swap_commit) = &input.swap_commit ··· 282 Ok(c) => c, 283 Err(_) => { 284 return ApiError::InternalError(Some("Failed to store record".into())) 285 + .into_response(); 286 } 287 }; 288 let key = format!("{}/{}", collection, rkey); ··· 291 Ok(m) => m, 292 Err(_) => { 293 return ApiError::InternalError(Some("Failed to add to MST".into())) 294 + .into_response(); 295 } 296 }; 297 let uri = AtUri::from_parts(&did, collection, &rkey); ··· 336 Ok(c) => c, 337 Err(_) => { 338 return ApiError::InternalError(Some("Failed to store record".into())) 339 + .into_response(); 340 } 341 }; 342 let key = format!("{}/{}", collection, rkey); ··· 346 Ok(m) => m, 347 Err(_) => { 348 return ApiError::InternalError(Some("Failed to update MST".into())) 349 + .into_response(); 350 } 351 }; 352 let uri = AtUri::from_parts(&did, collection, rkey); ··· 370 Ok(m) => m, 371 Err(_) => { 372 return ApiError::InternalError(Some("Failed to delete from MST".into())) 373 + .into_response(); 374 } 375 }; 376 results.push(WriteResult::DeleteResult {}); ··· 384 } 385 let new_mst_root = match mst.persist().await { 386 Ok(c) => c, 387 + Err(_) => { 388 + return ApiError::InternalError(Some("Failed to persist MST".into())).into_response(); 389 + } 390 }; 391 let mut relevant_blocks = std::collections::BTreeMap::new(); 392 for key in &modified_keys { ··· 435 Ok(res) => res, 436 Err(e) => { 437 error!("Commit failed: {}", e); 438 + return ApiError::InternalError(Some("Failed to commit changes".into())) 439 + .into_response(); 440 } 441 }; 442
+4 -2
src/api/repo/record/delete.rs
··· 97 let expected_cid = Cid::from_str(swap_record_str).ok(); 98 let actual_cid = mst.get(&key).await.ok().flatten(); 99 if expected_cid != actual_cid { 100 - return ApiError::InvalidSwap(Some("Record has been modified or does not exist".into())) 101 - .into_response(); 102 } 103 } 104 let prev_record_cid = mst.get(&key).await.ok().flatten();
··· 97 let expected_cid = Cid::from_str(swap_record_str).ok(); 98 let actual_cid = mst.get(&key).await.ok().flatten(); 99 if expected_cid != actual_cid { 100 + return ApiError::InvalidSwap(Some( 101 + "Record has been modified or does not exist".into(), 102 + )) 103 + .into_response(); 104 } 105 } 106 let prev_record_cid = mst.get(&key).await.ok().flatten();
+16 -9
src/api/repo/record/write.rs
··· 138 ApiError::InternalError(None).into_response() 139 })? 140 .ok_or_else(|| ApiError::InternalError(Some("Repo root not found".into())).into_response())?; 141 - let current_root_cid = Cid::from_str(&root_cid_str) 142 - .map_err(|_| ApiError::InternalError(Some("Invalid repo root CID".into())).into_response())?; 143 Ok(RepoWriteAuth { 144 did: auth_user.did.clone(), 145 user_id, ··· 247 let record_cid = match tracking_store.put(&record_bytes).await { 248 Ok(c) => c, 249 _ => { 250 - return ApiError::InternalError(Some("Failed to save record block".into())).into_response() 251 } 252 }; 253 let key = format!("{}/{}", input.collection, rkey); ··· 442 let expected_cid = Cid::from_str(swap_record_str).ok(); 443 let actual_cid = mst.get(&key).await.ok().flatten(); 444 if expected_cid != actual_cid { 445 - return ApiError::InvalidSwap(Some("Record has been modified or does not exist".into())) 446 - .into_response(); 447 } 448 } 449 let existing_cid = mst.get(&key).await.ok().flatten(); ··· 455 let record_cid = match tracking_store.put(&record_bytes).await { 456 Ok(c) => c, 457 _ => { 458 - return ApiError::InternalError(Some("Failed to save record block".into())).into_response() 459 } 460 }; 461 if existing_cid == Some(record_cid) { ··· 474 match mst.update(&key, record_cid).await { 475 Ok(m) => m, 476 Err(_) => { 477 - return ApiError::InternalError(Some("Failed to update MST".into())).into_response() 478 } 479 } 480 } else { 481 match mst.add(&key, record_cid).await { 482 Ok(m) => m, 483 Err(_) => { 484 - return ApiError::InternalError(Some("Failed to add to MST".into())).into_response() 485 } 486 } 487 }; 488 let new_mst_root = match new_mst.persist().await { 489 Ok(c) => c, 490 Err(_) => { 491 - return ApiError::InternalError(Some("Failed to persist MST".into())).into_response() 492 } 493 }; 494 let op = if existing_cid.is_some() {
··· 138 ApiError::InternalError(None).into_response() 139 })? 140 .ok_or_else(|| ApiError::InternalError(Some("Repo root not found".into())).into_response())?; 141 + let current_root_cid = Cid::from_str(&root_cid_str).map_err(|_| { 142 + ApiError::InternalError(Some("Invalid repo root CID".into())).into_response() 143 + })?; 144 Ok(RepoWriteAuth { 145 did: auth_user.did.clone(), 146 user_id, ··· 248 let record_cid = match tracking_store.put(&record_bytes).await { 249 Ok(c) => c, 250 _ => { 251 + return ApiError::InternalError(Some("Failed to save record block".into())) 252 + .into_response(); 253 } 254 }; 255 let key = format!("{}/{}", input.collection, rkey); ··· 444 let expected_cid = Cid::from_str(swap_record_str).ok(); 445 let actual_cid = mst.get(&key).await.ok().flatten(); 446 if expected_cid != actual_cid { 447 + return ApiError::InvalidSwap(Some( 448 + "Record has been modified or does not exist".into(), 449 + )) 450 + .into_response(); 451 } 452 } 453 let existing_cid = mst.get(&key).await.ok().flatten(); ··· 459 let record_cid = match tracking_store.put(&record_bytes).await { 460 Ok(c) => c, 461 _ => { 462 + return ApiError::InternalError(Some("Failed to save record block".into())) 463 + .into_response(); 464 } 465 }; 466 if existing_cid == Some(record_cid) { ··· 479 match mst.update(&key, record_cid).await { 480 Ok(m) => m, 481 Err(_) => { 482 + return ApiError::InternalError(Some("Failed to update MST".into())) 483 + .into_response(); 484 } 485 } 486 } else { 487 match mst.add(&key, record_cid).await { 488 Ok(m) => m, 489 Err(_) => { 490 + return ApiError::InternalError(Some("Failed to add to MST".into())) 491 + .into_response(); 492 } 493 } 494 }; 495 let new_mst_root = match new_mst.persist().await { 496 Ok(c) => c, 497 Err(_) => { 498 + return ApiError::InternalError(Some("Failed to persist MST".into())).into_response(); 499 } 500 }; 501 let op = if existing_cid.is_some() {
+13 -9
src/api/responses.rs
··· 28 } 29 30 impl DidResponse { 31 - pub fn new(did: impl Into<Did>) -> impl IntoResponse { 32 Json(Self { did: did.into() }) 33 } 34 } ··· 40 } 41 42 impl TokenRequiredResponse { 43 - pub fn new(required: bool) -> impl IntoResponse { 44 - Json(Self { token_required: required }) 45 } 46 } 47 ··· 52 } 53 54 impl HasPasswordResponse { 55 - pub fn new(has_password: bool) -> impl IntoResponse { 56 Json(Self { has_password }) 57 } 58 } ··· 63 } 64 65 impl VerifiedResponse { 66 - pub fn new(verified: bool) -> impl IntoResponse { 67 Json(Self { verified }) 68 } 69 } ··· 74 } 75 76 impl EnabledResponse { 77 - pub fn new(enabled: bool) -> impl IntoResponse { 78 Json(Self { enabled }) 79 } 80 } ··· 85 } 86 87 impl StatusResponse { 88 - pub fn new(status: impl Into<String>) -> impl IntoResponse { 89 - Json(Self { status: status.into() }) 90 } 91 } 92 ··· 97 } 98 99 impl DidDocumentResponse { 100 - pub fn new(did_document: serde_json::Value) -> impl IntoResponse { 101 Json(Self { did_document }) 102 } 103 }
··· 28 } 29 30 impl DidResponse { 31 + pub fn response(did: impl Into<Did>) -> impl IntoResponse { 32 Json(Self { did: did.into() }) 33 } 34 } ··· 40 } 41 42 impl TokenRequiredResponse { 43 + pub fn response(required: bool) -> impl IntoResponse { 44 + Json(Self { 45 + token_required: required, 46 + }) 47 } 48 } 49 ··· 54 } 55 56 impl HasPasswordResponse { 57 + pub fn response(has_password: bool) -> impl IntoResponse { 58 Json(Self { has_password }) 59 } 60 } ··· 65 } 66 67 impl VerifiedResponse { 68 + pub fn response(verified: bool) -> impl IntoResponse { 69 Json(Self { verified }) 70 } 71 } ··· 76 } 77 78 impl EnabledResponse { 79 + pub fn response(enabled: bool) -> impl IntoResponse { 80 Json(Self { enabled }) 81 } 82 } ··· 87 } 88 89 impl StatusResponse { 90 + pub fn response(status: impl Into<String>) -> impl IntoResponse { 91 + Json(Self { 92 + status: status.into(), 93 + }) 94 } 95 } 96 ··· 101 } 102 103 impl DidDocumentResponse { 104 + pub fn response(did_document: serde_json::Value) -> impl IntoResponse { 105 Json(Self { did_document }) 106 } 107 }
+25 -13
src/api/server/account_status.rs
··· 1 - use crate::api::error::ApiError; 2 use crate::api::EmptyResponse; 3 use crate::cache::Cache; 4 use crate::plc::PlcClient; 5 use crate::state::AppState; ··· 74 return ApiError::InternalError(None).into_response(); 75 } 76 }; 77 - let user_status = sqlx::query!("SELECT deactivated_at FROM users WHERE did = $1", did.as_str()) 78 - .fetch_optional(&state.db) 79 - .await; 80 let deactivated_at = match user_status { 81 Ok(Some(row)) => row.deactivated_at, 82 _ => None, ··· 399 ); 400 let did_validation_start = std::time::Instant::now(); 401 if let Err(e) = 402 - assert_valid_did_document_for_service(&state.db, state.cache.clone(), did.as_str(), true).await 403 { 404 info!( 405 "[MIGRATION] activateAccount: DID document validation FAILED for {} (took {:?})", ··· 423 "[MIGRATION] activateAccount: Activating account did={} handle={:?}", 424 did, handle 425 ); 426 - let result = sqlx::query!("UPDATE users SET deactivated_at = NULL WHERE did = $1", did.as_str()) 427 - .execute(&state.db) 428 - .await; 429 match result { 430 Ok(_) => { 431 info!( ··· 440 did 441 ); 442 if let Err(e) = 443 - crate::api::repo::record::sequence_account_event(&state, did.as_str(), true, None).await 444 { 445 warn!( 446 "[MIGRATION] activateAccount: Failed to sequence account activation event: {}", ··· 453 "[MIGRATION] activateAccount: Sequencing identity event for did={} handle={:?}", 454 did, handle 455 ); 456 - if let Err(e) = 457 - crate::api::repo::record::sequence_identity_event(&state, did.as_str(), handle.as_deref()) 458 - .await 459 { 460 warn!( 461 "[MIGRATION] activateAccount: Failed to sequence identity event for activation: {}", ··· 644 let did = validated.did.clone(); 645 646 if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, did.as_str()).await { 647 - return crate::api::server::reauth::legacy_mfa_required_response(&state.db, did.as_str()).await; 648 } 649 650 let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did.as_str())
··· 1 use crate::api::EmptyResponse; 2 + use crate::api::error::ApiError; 3 use crate::cache::Cache; 4 use crate::plc::PlcClient; 5 use crate::state::AppState; ··· 74 return ApiError::InternalError(None).into_response(); 75 } 76 }; 77 + let user_status = sqlx::query!( 78 + "SELECT deactivated_at FROM users WHERE did = $1", 79 + did.as_str() 80 + ) 81 + .fetch_optional(&state.db) 82 + .await; 83 let deactivated_at = match user_status { 84 Ok(Some(row)) => row.deactivated_at, 85 _ => None, ··· 402 ); 403 let did_validation_start = std::time::Instant::now(); 404 if let Err(e) = 405 + assert_valid_did_document_for_service(&state.db, state.cache.clone(), did.as_str(), true) 406 + .await 407 { 408 info!( 409 "[MIGRATION] activateAccount: DID document validation FAILED for {} (took {:?})", ··· 427 "[MIGRATION] activateAccount: Activating account did={} handle={:?}", 428 did, handle 429 ); 430 + let result = sqlx::query!( 431 + "UPDATE users SET deactivated_at = NULL WHERE did = $1", 432 + did.as_str() 433 + ) 434 + .execute(&state.db) 435 + .await; 436 match result { 437 Ok(_) => { 438 info!( ··· 447 did 448 ); 449 if let Err(e) = 450 + crate::api::repo::record::sequence_account_event(&state, did.as_str(), true, None) 451 + .await 452 { 453 warn!( 454 "[MIGRATION] activateAccount: Failed to sequence account activation event: {}", ··· 461 "[MIGRATION] activateAccount: Sequencing identity event for did={} handle={:?}", 462 did, handle 463 ); 464 + if let Err(e) = crate::api::repo::record::sequence_identity_event( 465 + &state, 466 + did.as_str(), 467 + handle.as_deref(), 468 + ) 469 + .await 470 { 471 warn!( 472 "[MIGRATION] activateAccount: Failed to sequence identity event for activation: {}", ··· 655 let did = validated.did.clone(); 656 657 if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, did.as_str()).await { 658 + return crate::api::server::reauth::legacy_mfa_required_response(&state.db, did.as_str()) 659 + .await; 660 } 661 662 let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did.as_str())
+1 -1
src/api/server/app_password.rs
··· 1 - use crate::api::error::ApiError; 2 use crate::api::EmptyResponse; 3 use crate::auth::BearerAuth; 4 use crate::delegation::{self, DelegationActionType}; 5 use crate::state::{AppState, RateLimitKind};
··· 1 use crate::api::EmptyResponse; 2 + use crate::api::error::ApiError; 3 use crate::auth::BearerAuth; 4 use crate::delegation::{self, DelegationActionType}; 5 use crate::state::{AppState, RateLimitKind};
+2 -2
src/api/server/email.rs
··· 77 } 78 79 info!("Email update requested for user {}", user.id); 80 - TokenRequiredResponse::new(token_required).into_response() 81 } 82 83 #[derive(Deserialize)] ··· 375 .await; 376 377 match user { 378 - Ok(Some(row)) => VerifiedResponse::new(row.email_verified).into_response(), 379 Ok(None) => ApiError::AccountNotFound.into_response(), 380 Err(e) => { 381 error!("DB error checking email verified: {:?}", e);
··· 77 } 78 79 info!("Email update requested for user {}", user.id); 80 + TokenRequiredResponse::response(token_required).into_response() 81 } 82 83 #[derive(Deserialize)] ··· 375 .await; 376 377 match user { 378 + Ok(Some(row)) => VerifiedResponse::response(row.email_verified).into_response(), 379 Ok(None) => ApiError::AccountNotFound.into_response(), 380 Err(e) => { 381 error!("DB error checking email verified: {:?}", e);
+3 -1
src/api/server/invite.rs
··· 53 return ApiError::InvalidRequest("useCount must be at least 1".into()).into_response(); 54 } 55 56 - let for_account = input.for_account.unwrap_or_else(|| auth_user.did.to_string()); 57 let code = gen_invite_code(); 58 59 match sqlx::query!(
··· 53 return ApiError::InvalidRequest("useCount must be at least 1".into()).into_response(); 54 } 55 56 + let for_account = input 57 + .for_account 58 + .unwrap_or_else(|| auth_user.did.to_string()); 59 let code = gen_invite_code(); 60 61 match sqlx::query!(
+18 -15
src/api/server/passkey_account.rs
··· 102 .await 103 { 104 warn!(ip = %client_ip, "Account creation rate limit exceeded"); 105 - return ApiError::RateLimitExceeded(Some("Too many account creation attempts. Please try again later.".into(),)) 106 .into_response(); 107 } 108 ··· 352 Ok(r) => r, 353 Err(e) => { 354 error!("Error creating PLC genesis operation: {:?}", e); 355 - return ApiError::InternalError(Some("Failed to create PLC operation".into())) 356 - .into_response(); 357 } 358 }; 359 ··· 759 } 760 }; 761 762 - let reg_state = match crate::auth::webauthn::load_registration_state(&state.db, &input.did) 763 - .await 764 - { 765 - Ok(Some(s)) => s, 766 - Ok(None) => { 767 - return ApiError::NoChallengeInProgress.into_response(); 768 - } 769 - Err(e) => { 770 - error!("Error loading registration state: {:?}", e); 771 - return ApiError::InternalError(None).into_response(); 772 - } 773 - }; 774 775 let credential: webauthn_rs::prelude::RegisterPublicKeyCredential = 776 match serde_json::from_value(input.passkey_credential) {
··· 102 .await 103 { 104 warn!(ip = %client_ip, "Account creation rate limit exceeded"); 105 + return ApiError::RateLimitExceeded(Some( 106 + "Too many account creation attempts. Please try again later.".into(), 107 + )) 108 .into_response(); 109 } 110 ··· 354 Ok(r) => r, 355 Err(e) => { 356 error!("Error creating PLC genesis operation: {:?}", e); 357 + return ApiError::InternalError(Some( 358 + "Failed to create PLC operation".into(), 359 + )) 360 + .into_response(); 361 } 362 }; 363 ··· 763 } 764 }; 765 766 + let reg_state = 767 + match crate::auth::webauthn::load_registration_state(&state.db, &input.did).await { 768 + Ok(Some(s)) => s, 769 + Ok(None) => { 770 + return ApiError::NoChallengeInProgress.into_response(); 771 + } 772 + Err(e) => { 773 + error!("Error loading registration state: {:?}", e); 774 + return ApiError::InternalError(None).into_response(); 775 + } 776 + }; 777 778 let credential: webauthn_rs::prelude::RegisterPublicKeyCredential = 779 match serde_json::from_value(input.passkey_credential) {
+3 -1
src/api/server/password.rs
··· 340 .await; 341 342 match user { 343 - Ok(Some(row)) => HasPasswordResponse::new(row.has_password.unwrap_or(false)).into_response(), 344 Ok(None) => ApiError::AccountNotFound.into_response(), 345 Err(e) => { 346 error!("DB error: {:?}", e);
··· 340 .await; 341 342 match user { 343 + Ok(Some(row)) => { 344 + HasPasswordResponse::response(row.has_password.unwrap_or(false)).into_response() 345 + } 346 Ok(None) => ApiError::AccountNotFound.into_response(), 347 Err(e) => { 348 error!("DB error: {:?}", e);
+9 -4
src/api/server/reauth.rs
··· 69 auth: BearerAuth, 70 Json(input): Json<PasswordReauthInput>, 71 ) -> Response { 72 - let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", &*&auth.0.did) 73 - .fetch_optional(&state.db) 74 - .await; 75 76 let password_hash = match user { 77 Ok(Some(row)) => row.password_hash, ··· 138 .await 139 { 140 warn!(did = %&auth.0.did, "TOTP verification rate limit exceeded"); 141 - return ApiError::RateLimitExceeded(Some("Too many verification attempts. Please try again in a few minutes.".into(),)) 142 .into_response(); 143 } 144
··· 69 auth: BearerAuth, 70 Json(input): Json<PasswordReauthInput>, 71 ) -> Response { 72 + let user = sqlx::query!( 73 + "SELECT password_hash FROM users WHERE did = $1", 74 + &*&auth.0.did 75 + ) 76 + .fetch_optional(&state.db) 77 + .await; 78 79 let password_hash = match user { 80 Ok(Some(row)) => row.password_hash, ··· 141 .await 142 { 143 warn!(did = %&auth.0.did, "TOTP verification rate limit exceeded"); 144 + return ApiError::RateLimitExceeded(Some( 145 + "Too many verification attempts. Please try again in a few minutes.".into(), 146 + )) 147 .into_response(); 148 } 149
+5 -3
src/api/server/service_auth.rs
··· 1 - use crate::types::Did; 2 use crate::AccountStatus; 3 use crate::api::error::ApiError; 4 use crate::state::AppState; 5 use axum::{ 6 Json, 7 extract::{Query, State}, ··· 165 } 166 Err(e) => { 167 error!(error = ?e, "DB error fetching user key"); 168 - return ApiError::AuthenticationFailed(Some("Failed to get signing key".into())) 169 - .into_response(); 170 } 171 } 172 }
··· 1 use crate::AccountStatus; 2 use crate::api::error::ApiError; 3 use crate::state::AppState; 4 + use crate::types::Did; 5 use axum::{ 6 Json, 7 extract::{Query, State}, ··· 165 } 166 Err(e) => { 167 error!(error = ?e, "DB error fetching user key"); 168 + return ApiError::AuthenticationFailed(Some( 169 + "Failed to get signing key".into(), 170 + )) 171 + .into_response(); 172 } 173 } 174 }
+4 -7
src/api/server/session.rs
··· 479 } 480 }; 481 if crate::auth::verify_refresh_token(&refresh_token, &key_bytes).is_err() { 482 - return ApiError::AuthenticationFailed(Some("Invalid refresh token".into())).into_response(); 483 } 484 let new_access_meta = match crate::auth::create_access_token_with_delegation( 485 &session_row.did, ··· 566 let pds_hostname = 567 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 568 let handle = full_handle(&u.handle, &pds_hostname); 569 - let account_state = AccountState::from_db_fields( 570 - u.deactivated_at, 571 - u.takedown_ref.clone(), 572 - None, 573 - None, 574 - ); 575 let mut response = json!({ 576 "accessJwt": new_access_meta.token, 577 "refreshJwt": new_refresh_meta.token,
··· 479 } 480 }; 481 if crate::auth::verify_refresh_token(&refresh_token, &key_bytes).is_err() { 482 + return ApiError::AuthenticationFailed(Some("Invalid refresh token".into())) 483 + .into_response(); 484 } 485 let new_access_meta = match crate::auth::create_access_token_with_delegation( 486 &session_row.did, ··· 567 let pds_hostname = 568 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 569 let handle = full_handle(&u.handle, &pds_hostname); 570 + let account_state = 571 + AccountState::from_db_fields(u.deactivated_at, u.takedown_ref.clone(), None, None); 572 let mut response = json!({ 573 "accessJwt": new_access_meta.token, 574 "refreshJwt": new_refresh_meta.token,
+26 -13
src/api/server/totp.rs
··· 28 } 29 30 pub async fn create_totp_secret(State(state): State<AppState>, auth: BearerAuth) -> Response { 31 - let existing = sqlx::query_scalar!("SELECT verified FROM user_totp WHERE did = $1", &*&auth.0.did) 32 - .fetch_optional(&state.db) 33 - .await; 34 35 if let Ok(Some(true)) = existing { 36 return ApiError::TotpAlreadyEnabled.into_response(); ··· 58 Ok(qr) => qr, 59 Err(e) => { 60 error!("Failed to generate QR code: {:?}", e); 61 - return ApiError::InternalError(Some("Failed to generate QR code".into())).into_response(); 62 } 63 }; 64 ··· 247 return ApiError::RateLimitExceeded(None).into_response(); 248 } 249 250 - let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", &*&auth.0.did) 251 - .fetch_optional(&state.db) 252 - .await; 253 254 let password_hash = match user { 255 Ok(Some(row)) => row.password_hash, ··· 346 } 347 348 pub async fn get_totp_status(State(state): State<AppState>, auth: BearerAuth) -> Response { 349 - let totp_row = sqlx::query!("SELECT verified FROM user_totp WHERE did = $1", &*&auth.0.did) 350 - .fetch_optional(&state.db) 351 - .await; 352 353 let enabled = match totp_row { 354 Ok(Some(row)) => row.verified, ··· 401 return ApiError::RateLimitExceeded(None).into_response(); 402 } 403 404 - let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", &*&auth.0.did) 405 - .fetch_optional(&state.db) 406 - .await; 407 408 let password_hash = match user { 409 Ok(Some(row)) => row.password_hash,
··· 28 } 29 30 pub async fn create_totp_secret(State(state): State<AppState>, auth: BearerAuth) -> Response { 31 + let existing = sqlx::query_scalar!( 32 + "SELECT verified FROM user_totp WHERE did = $1", 33 + &*&auth.0.did 34 + ) 35 + .fetch_optional(&state.db) 36 + .await; 37 38 if let Ok(Some(true)) = existing { 39 return ApiError::TotpAlreadyEnabled.into_response(); ··· 61 Ok(qr) => qr, 62 Err(e) => { 63 error!("Failed to generate QR code: {:?}", e); 64 + return ApiError::InternalError(Some("Failed to generate QR code".into())) 65 + .into_response(); 66 } 67 }; 68 ··· 251 return ApiError::RateLimitExceeded(None).into_response(); 252 } 253 254 + let user = sqlx::query!( 255 + "SELECT password_hash FROM users WHERE did = $1", 256 + &*&auth.0.did 257 + ) 258 + .fetch_optional(&state.db) 259 + .await; 260 261 let password_hash = match user { 262 Ok(Some(row)) => row.password_hash, ··· 353 } 354 355 pub async fn get_totp_status(State(state): State<AppState>, auth: BearerAuth) -> Response { 356 + let totp_row = sqlx::query!( 357 + "SELECT verified FROM user_totp WHERE did = $1", 358 + &*&auth.0.did 359 + ) 360 + .fetch_optional(&state.db) 361 + .await; 362 363 let enabled = match totp_row { 364 Ok(Some(row)) => row.verified, ··· 411 return ApiError::RateLimitExceeded(None).into_response(); 412 } 413 414 + let user = sqlx::query!( 415 + "SELECT password_hash FROM users WHERE did = $1", 416 + &*&auth.0.did 417 + ) 418 + .fetch_optional(&state.db) 419 + .await; 420 421 let password_hash = match user { 422 Ok(Some(row)) => row.password_hash,
+6 -3
src/api/server/trusted_devices.rs
··· 1 - use crate::api::error::ApiError; 2 use crate::api::SuccessResponse; 3 use axum::{ 4 Json, 5 extract::State, ··· 87 let devices = rows 88 .into_iter() 89 .map(|row| { 90 - let trust_state = DeviceTrustState::from_timestamps(row.trusted_at, row.trusted_until); 91 TrustedDevice { 92 id: row.id, 93 user_agent: row.user_agent, ··· 230 } 231 232 pub async fn is_device_trusted(db: &PgPool, device_id: &str, did: &str) -> bool { 233 - get_device_trust_state(db, device_id, did).await.is_trusted() 234 } 235 236 pub async fn trust_device(db: &PgPool, device_id: &str) -> Result<(), sqlx::Error> {
··· 1 use crate::api::SuccessResponse; 2 + use crate::api::error::ApiError; 3 use axum::{ 4 Json, 5 extract::State, ··· 87 let devices = rows 88 .into_iter() 89 .map(|row| { 90 + let trust_state = 91 + DeviceTrustState::from_timestamps(row.trusted_at, row.trusted_until); 92 TrustedDevice { 93 id: row.id, 94 user_agent: row.user_agent, ··· 231 } 232 233 pub async fn is_device_trusted(db: &PgPool, device_id: &str, did: &str) -> bool { 234 + get_device_trust_state(db, device_id, did) 235 + .await 236 + .is_trusted() 237 } 238 239 pub async fn trust_device(db: &PgPool, device_id: &str) -> Result<(), sqlx::Error> {
+1 -1
src/api/server/verify_email.rs
··· 33 34 Ok(Json(VerifyMigrationEmailOutput { 35 success: result.success, 36 - did: result.did.clone().into(), 37 })) 38 } 39
··· 33 34 Ok(Json(VerifyMigrationEmailOutput { 35 success: result.success, 36 + did: result.did.clone(), 37 })) 38 } 39
+13 -3
src/api/validation.rs
··· 80 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 81 match self { 82 Self::Empty => write!(f, "Email cannot be empty"), 83 - Self::TooLong => write!(f, "Email exceeds maximum length of {} characters", MAX_EMAIL_LENGTH), 84 Self::MissingAtSign => write!(f, "Email must contain @"), 85 Self::EmptyLocalPart => write!(f, "Email local part cannot be empty"), 86 Self::LocalPartTooLong => write!(f, "Email local part exceeds maximum length"), ··· 115 } 116 117 pub fn local_part(&self) -> &str { 118 - self.0.rsplitn(2, '@').nth(1).unwrap_or("") 119 } 120 121 pub fn domain(&self) -> &str { 122 - self.0.rsplitn(2, '@').next().unwrap_or("") 123 } 124 } 125
··· 80 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 81 match self { 82 Self::Empty => write!(f, "Email cannot be empty"), 83 + Self::TooLong => write!( 84 + f, 85 + "Email exceeds maximum length of {} characters", 86 + MAX_EMAIL_LENGTH 87 + ), 88 Self::MissingAtSign => write!(f, "Email must contain @"), 89 Self::EmptyLocalPart => write!(f, "Email local part cannot be empty"), 90 Self::LocalPartTooLong => write!(f, "Email local part exceeds maximum length"), ··· 119 } 120 121 pub fn local_part(&self) -> &str { 122 + self.0 123 + .rsplit_once('@') 124 + .map(|(local, _)| local) 125 + .unwrap_or("") 126 } 127 128 pub fn domain(&self) -> &str { 129 + self.0 130 + .rsplit_once('@') 131 + .map(|(_, domain)| domain) 132 + .unwrap_or("") 133 } 134 } 135
+6 -2
src/auth/extractor.rs
··· 146 Err(_) => Err(AuthError::AuthenticationFailed), 147 } 148 } else { 149 - match validate_bearer_token_cached(&state.db, state.cache.as_ref(), &extracted.token).await { 150 Ok(user) => Ok(BearerAuth(user)), 151 Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated), 152 Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown), ··· 262 Err(_) => return Err(AuthError::AuthenticationFailed), 263 } 264 } else { 265 - match validate_bearer_token_cached(&state.db, state.cache.as_ref(), &extracted.token).await { 266 Ok(user) => user, 267 Err(TokenValidationError::AccountDeactivated) => { 268 return Err(AuthError::AccountDeactivated);
··· 146 Err(_) => Err(AuthError::AuthenticationFailed), 147 } 148 } else { 149 + match validate_bearer_token_cached(&state.db, state.cache.as_ref(), &extracted.token) 150 + .await 151 + { 152 Ok(user) => Ok(BearerAuth(user)), 153 Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated), 154 Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown), ··· 264 Err(_) => return Err(AuthError::AuthenticationFailed), 265 } 266 } else { 267 + match validate_bearer_token_cached(&state.db, state.cache.as_ref(), &extracted.token) 268 + .await 269 + { 270 Ok(user) => user, 271 Err(TokenValidationError::AccountDeactivated) => { 272 return Err(AuthError::AccountDeactivated);
+3 -5
src/auth/mod.rs
··· 3 use std::fmt; 4 use std::time::Duration; 5 6 - use crate::types::Did; 7 use crate::AccountStatus; 8 use crate::cache::Cache; 9 use crate::oauth::scopes::ScopePermissions; 10 11 pub mod extractor; 12 pub mod scope_check; ··· 334 .act 335 .as_ref() 336 .map(|a| Did::new_unchecked(a.sub.clone())); 337 - let status = AccountStatus::from_db_fields( 338 - takedown_ref.as_deref(), 339 - deactivated_at, 340 - ); 341 return Ok(AuthenticatedUser { 342 did: Did::new_unchecked(did.clone()), 343 key_bytes: Some(decrypted_key),
··· 3 use std::fmt; 4 use std::time::Duration; 5 6 use crate::AccountStatus; 7 use crate::cache::Cache; 8 use crate::oauth::scopes::ScopePermissions; 9 + use crate::types::Did; 10 11 pub mod extractor; 12 pub mod scope_check; ··· 334 .act 335 .as_ref() 336 .map(|a| Did::new_unchecked(a.sub.clone())); 337 + let status = 338 + AccountStatus::from_db_fields(takedown_ref.as_deref(), deactivated_at); 339 return Ok(AuthenticatedUser { 340 did: Did::new_unchecked(did.clone()), 341 key_bytes: Some(decrypted_key),
+2 -2
src/lib.rs
··· 24 pub mod validation; 25 26 use api::proxy::XrpcProxyLayer; 27 - pub use sync::util::AccountStatus; 28 - pub use types::{AccountState, AtIdentifier, AtUri, Did, Handle, Nsid, Rkey}; 29 use axum::{ 30 Json, Router, 31 extract::DefaultBodyLimit, ··· 36 use http::StatusCode; 37 use serde_json::json; 38 use state::AppState; 39 use tower::ServiceBuilder; 40 use tower_http::cors::{Any, CorsLayer}; 41 use tower_http::services::{ServeDir, ServeFile}; 42 43 pub fn app(state: AppState) -> Router { 44 let xrpc_router = Router::new()
··· 24 pub mod validation; 25 26 use api::proxy::XrpcProxyLayer; 27 use axum::{ 28 Json, Router, 29 extract::DefaultBodyLimit, ··· 34 use http::StatusCode; 35 use serde_json::json; 36 use state::AppState; 37 + pub use sync::util::AccountStatus; 38 use tower::ServiceBuilder; 39 use tower_http::cors::{Any, CorsLayer}; 40 use tower_http::services::{ServeDir, ServeFile}; 41 + pub use types::{AccountState, AtIdentifier, AtUri, Did, Handle, Nsid, Rkey}; 42 43 pub fn app(state: AppState) -> Router { 44 let xrpc_router = Router::new()
+3 -1
src/oauth/db/request.rs
··· 1 - use super::super::{AuthFlowState, AuthorizationRequestParameters, ClientAuth, OAuthError, RequestData}; 2 use super::helpers::{from_json, to_json}; 3 use sqlx::PgPool; 4
··· 1 + use super::super::{ 2 + AuthFlowState, AuthorizationRequestParameters, ClientAuth, OAuthError, RequestData, 3 + }; 4 use super::helpers::{from_json, to_json}; 5 use sqlx::PgPool; 6
+29 -8
src/oauth/db/token.rs
··· 4 use sqlx::PgPool; 5 6 pub enum RefreshTokenLookup { 7 - Valid { db_id: i32, token_data: TokenData }, 8 - InGracePeriod { db_id: i32, token_data: TokenData, rotated_at: DateTime<Utc> }, 9 - Used { original_token_id: i32 }, 10 - Expired { db_id: i32 }, 11 NotFound, 12 } 13 ··· 16 match self { 17 RefreshTokenLookup::Valid { .. } => RefreshTokenState::Valid, 18 RefreshTokenLookup::InGracePeriod { rotated_at, .. } => { 19 - RefreshTokenState::InGracePeriod { rotated_at: *rotated_at } 20 } 21 RefreshTokenLookup::Used { .. } => RefreshTokenState::Used { at: Utc::now() }, 22 RefreshTokenLookup::Expired { .. } => RefreshTokenState::Expired, ··· 30 refresh_token: &str, 31 ) -> Result<RefreshTokenLookup, OAuthError> { 32 if let Some(token_id) = check_refresh_token_used(pool, refresh_token).await? { 33 - if let Some((db_id, token_data)) = get_token_by_previous_refresh_token(pool, refresh_token).await? { 34 let rotated_at = token_data.updated_at; 35 - return Ok(RefreshTokenLookup::InGracePeriod { db_id, token_data, rotated_at }); 36 } 37 - return Ok(RefreshTokenLookup::Used { original_token_id: token_id }); 38 } 39 40 match get_token_by_refresh_token(pool, refresh_token).await? {
··· 4 use sqlx::PgPool; 5 6 pub enum RefreshTokenLookup { 7 + Valid { 8 + db_id: i32, 9 + token_data: TokenData, 10 + }, 11 + InGracePeriod { 12 + db_id: i32, 13 + token_data: TokenData, 14 + rotated_at: DateTime<Utc>, 15 + }, 16 + Used { 17 + original_token_id: i32, 18 + }, 19 + Expired { 20 + db_id: i32, 21 + }, 22 NotFound, 23 } 24 ··· 27 match self { 28 RefreshTokenLookup::Valid { .. } => RefreshTokenState::Valid, 29 RefreshTokenLookup::InGracePeriod { rotated_at, .. } => { 30 + RefreshTokenState::InGracePeriod { 31 + rotated_at: *rotated_at, 32 + } 33 } 34 RefreshTokenLookup::Used { .. } => RefreshTokenState::Used { at: Utc::now() }, 35 RefreshTokenLookup::Expired { .. } => RefreshTokenState::Expired, ··· 43 refresh_token: &str, 44 ) -> Result<RefreshTokenLookup, OAuthError> { 45 if let Some(token_id) = check_refresh_token_used(pool, refresh_token).await? { 46 + if let Some((db_id, token_data)) = 47 + get_token_by_previous_refresh_token(pool, refresh_token).await? 48 + { 49 let rotated_at = token_data.updated_at; 50 + return Ok(RefreshTokenLookup::InGracePeriod { 51 + db_id, 52 + token_data, 53 + rotated_at, 54 + }); 55 } 56 + return Ok(RefreshTokenLookup::Used { 57 + original_token_id: token_id, 58 + }); 59 } 60 61 match get_token_by_refresh_token(pool, refresh_token).await? {
+26 -9
src/oauth/endpoints/token/grants.rs
··· 24 dpop_proof: Option<String>, 25 ) -> Result<(HeaderMap, Json<TokenResponse>), OAuthError> { 26 let (code, code_verifier, redirect_uri) = match request.grant { 27 - TokenGrant::AuthorizationCode { code, code_verifier, redirect_uri } => { 28 - (code, code_verifier, redirect_uri) 29 } 30 - _ => return Err(OAuthError::InvalidRequest("Expected authorization_code grant".to_string())), 31 }; 32 let auth_request = db::consume_authorization_request_by_code(&state.db, &code) 33 .await? ··· 53 let did = flow_state.did().unwrap().to_string(); 54 let client_metadata_cache = ClientMetadataCache::new(3600); 55 let client_metadata = client_metadata_cache.get(&auth_request.client_id).await?; 56 - let client_auth = if let (Some(assertion), Some(assertion_type)) = 57 - (&request.client_auth.client_assertion, &request.client_auth.client_assertion_type) 58 - { 59 if assertion_type != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" { 60 return Err(OAuthError::InvalidClient( 61 "Unsupported client_assertion_type".to_string(), ··· 198 ) -> Result<(HeaderMap, Json<TokenResponse>), OAuthError> { 199 let refresh_token_str = match request.grant { 200 TokenGrant::RefreshToken { refresh_token } => refresh_token, 201 - _ => return Err(OAuthError::InvalidRequest("Expected refresh_token grant".to_string())), 202 }; 203 let token_prefix = &refresh_token_str[..std::cmp::min(16, refresh_token_str.len())]; 204 tracing::info!( ··· 213 214 let (db_id, token_data) = match lookup { 215 RefreshTokenLookup::Valid { db_id, token_data } => (db_id, token_data), 216 - RefreshTokenLookup::InGracePeriod { db_id: _, token_data, rotated_at } => { 217 tracing::info!( 218 refresh_token_prefix = %token_prefix, 219 rotated_at = %rotated_at, ··· 262 } 263 RefreshTokenLookup::NotFound => { 264 tracing::warn!(refresh_token_prefix = %token_prefix, "Refresh token not found"); 265 - return Err(OAuthError::InvalidGrant("Invalid refresh token".to_string())); 266 } 267 }; 268 let dpop_jkt = if let Some(proof) = &dpop_proof {
··· 24 dpop_proof: Option<String>, 25 ) -> Result<(HeaderMap, Json<TokenResponse>), OAuthError> { 26 let (code, code_verifier, redirect_uri) = match request.grant { 27 + TokenGrant::AuthorizationCode { 28 + code, 29 + code_verifier, 30 + redirect_uri, 31 + } => (code, code_verifier, redirect_uri), 32 + _ => { 33 + return Err(OAuthError::InvalidRequest( 34 + "Expected authorization_code grant".to_string(), 35 + )); 36 } 37 }; 38 let auth_request = db::consume_authorization_request_by_code(&state.db, &code) 39 .await? ··· 59 let did = flow_state.did().unwrap().to_string(); 60 let client_metadata_cache = ClientMetadataCache::new(3600); 61 let client_metadata = client_metadata_cache.get(&auth_request.client_id).await?; 62 + let client_auth = if let (Some(assertion), Some(assertion_type)) = ( 63 + &request.client_auth.client_assertion, 64 + &request.client_auth.client_assertion_type, 65 + ) { 66 if assertion_type != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" { 67 return Err(OAuthError::InvalidClient( 68 "Unsupported client_assertion_type".to_string(), ··· 205 ) -> Result<(HeaderMap, Json<TokenResponse>), OAuthError> { 206 let refresh_token_str = match request.grant { 207 TokenGrant::RefreshToken { refresh_token } => refresh_token, 208 + _ => { 209 + return Err(OAuthError::InvalidRequest( 210 + "Expected refresh_token grant".to_string(), 211 + )); 212 + } 213 }; 214 let token_prefix = &refresh_token_str[..std::cmp::min(16, refresh_token_str.len())]; 215 tracing::info!( ··· 224 225 let (db_id, token_data) = match lookup { 226 RefreshTokenLookup::Valid { db_id, token_data } => (db_id, token_data), 227 + RefreshTokenLookup::InGracePeriod { 228 + db_id: _, 229 + token_data, 230 + rotated_at, 231 + } => { 232 tracing::info!( 233 refresh_token_prefix = %token_prefix, 234 rotated_at = %rotated_at, ··· 277 } 278 RefreshTokenLookup::NotFound => { 279 tracing::warn!(refresh_token_prefix = %token_prefix, "Refresh token not found"); 280 + return Err(OAuthError::InvalidGrant( 281 + "Invalid refresh token".to_string(), 282 + )); 283 } 284 }; 285 let dpop_jkt = if let Some(proof) = &dpop_proof {
+3 -1
src/oauth/endpoints/token/mod.rs
··· 13 pub use introspect::{ 14 IntrospectRequest, IntrospectResponse, RevokeRequest, introspect_token, revoke_token, 15 }; 16 - pub use types::{ClientAuthParams, GrantType, TokenGrant, TokenRequest, TokenResponse, ValidatedTokenRequest}; 17 18 fn extract_client_ip(headers: &HeaderMap) -> String { 19 if let Some(forwarded) = headers.get("x-forwarded-for")
··· 13 pub use introspect::{ 14 IntrospectRequest, IntrospectResponse, RevokeRequest, introspect_token, revoke_token, 15 }; 16 + pub use types::{ 17 + ClientAuthParams, GrantType, TokenGrant, TokenRequest, TokenResponse, ValidatedTokenRequest, 18 + }; 19 20 fn extract_client_ip(headers: &HeaderMap) -> String { 21 if let Some(forwarded) = headers.get("x-forwarded-for")
+9 -3
src/oauth/endpoints/token/types.rs
··· 101 let grant = match self.grant_type { 102 GrantType::AuthorizationCode => { 103 let code = self.code.ok_or_else(|| { 104 - OAuthError::InvalidRequest("code is required for authorization_code grant".to_string()) 105 })?; 106 let code_verifier = self.code_verifier.ok_or_else(|| { 107 - OAuthError::InvalidRequest("code_verifier is required for authorization_code grant".to_string()) 108 })?; 109 TokenGrant::AuthorizationCode { 110 code, ··· 114 } 115 GrantType::RefreshToken => { 116 let refresh_token = self.refresh_token.ok_or_else(|| { 117 - OAuthError::InvalidRequest("refresh_token is required for refresh_token grant".to_string()) 118 })?; 119 TokenGrant::RefreshToken { refresh_token } 120 }
··· 101 let grant = match self.grant_type { 102 GrantType::AuthorizationCode => { 103 let code = self.code.ok_or_else(|| { 104 + OAuthError::InvalidRequest( 105 + "code is required for authorization_code grant".to_string(), 106 + ) 107 })?; 108 let code_verifier = self.code_verifier.ok_or_else(|| { 109 + OAuthError::InvalidRequest( 110 + "code_verifier is required for authorization_code grant".to_string(), 111 + ) 112 })?; 113 TokenGrant::AuthorizationCode { 114 code, ··· 118 } 119 GrantType::RefreshToken => { 120 let refresh_token = self.refresh_token.ok_or_else(|| { 121 + OAuthError::InvalidRequest( 122 + "refresh_token is required for refresh_token grant".to_string(), 123 + ) 124 })?; 125 TokenGrant::RefreshToken { refresh_token } 126 }
+3 -1
src/oauth/scopes/parser.rs
··· 144 .split('&') 145 .filter_map(|part| part.split_once('=')) 146 .fold(HashMap::new(), |mut acc, (key, value)| { 147 - acc.entry(key.to_string()).or_default().push(value.to_string()); 148 acc 149 }) 150 }
··· 144 .split('&') 145 .filter_map(|part| part.split_once('=')) 146 .fold(HashMap::new(), |mut acc, (key, value)| { 147 + acc.entry(key.to_string()) 148 + .or_default() 149 + .push(value.to_string()); 150 acc 151 }) 152 }
+21 -5
src/oauth/types.rs
··· 249 #[derive(Debug, Clone, PartialEq, Eq)] 250 pub enum AuthFlowState { 251 Pending, 252 - Authenticated { did: String, device_id: Option<String> }, 253 - Authorized { did: String, device_id: Option<String>, code: String }, 254 Expired, 255 } 256 ··· 324 AuthFlowState::Pending => write!(f, "pending"), 325 AuthFlowState::Authenticated { did, .. } => write!(f, "authenticated ({})", did), 326 AuthFlowState::Authorized { did, code, .. } => { 327 - write!(f, "authorized ({}, code={}...)", did, &code[..8.min(code.len())]) 328 } 329 AuthFlowState::Expired => write!(f, "expired"), 330 } ··· 334 #[derive(Debug, Clone, PartialEq, Eq)] 335 pub enum RefreshTokenState { 336 Valid, 337 - Used { at: chrono::DateTime<chrono::Utc> }, 338 - InGracePeriod { rotated_at: chrono::DateTime<chrono::Utc> }, 339 Expired, 340 Revoked, 341 }
··· 249 #[derive(Debug, Clone, PartialEq, Eq)] 250 pub enum AuthFlowState { 251 Pending, 252 + Authenticated { 253 + did: String, 254 + device_id: Option<String>, 255 + }, 256 + Authorized { 257 + did: String, 258 + device_id: Option<String>, 259 + code: String, 260 + }, 261 Expired, 262 } 263 ··· 331 AuthFlowState::Pending => write!(f, "pending"), 332 AuthFlowState::Authenticated { did, .. } => write!(f, "authenticated ({})", did), 333 AuthFlowState::Authorized { did, code, .. } => { 334 + write!( 335 + f, 336 + "authorized ({}, code={}...)", 337 + did, 338 + &code[..8.min(code.len())] 339 + ) 340 } 341 AuthFlowState::Expired => write!(f, "expired"), 342 } ··· 346 #[derive(Debug, Clone, PartialEq, Eq)] 347 pub enum RefreshTokenState { 348 Valid, 349 + Used { 350 + at: chrono::DateTime<chrono::Utc>, 351 + }, 352 + InGracePeriod { 353 + rotated_at: chrono::DateTime<chrono::Utc>, 354 + }, 355 Expired, 356 Revoked, 357 }
+3 -1
src/oauth/verify.rs
··· 374 375 async fn try_legacy_auth(pool: &PgPool, token: &str) -> Result<LegacyAuthResult, ()> { 376 match crate::auth::validate_bearer_token(pool, token).await { 377 - Ok(user) if !user.is_oauth => Ok(LegacyAuthResult { did: user.did.to_string() }), 378 _ => Err(()), 379 } 380 }
··· 374 375 async fn try_legacy_auth(pool: &PgPool, token: &str) -> Result<LegacyAuthResult, ()> { 376 match crate::auth::validate_bearer_token(pool, token).await { 377 + Ok(user) if !user.is_oauth => Ok(LegacyAuthResult { 378 + did: user.did.to_string(), 379 + }), 380 _ => Err(()), 381 } 382 }
+1 -1
src/sync/commit.rs
··· 196 Ok(Some(a)) => a, 197 Ok(None) => { 198 return ApiError::RepoNotFound(Some(format!("Could not find repo for DID: {}", did))) 199 - .into_response() 200 } 201 Err(e) => { 202 error!("DB error in get_repo_status: {:?}", e);
··· 196 Ok(Some(a)) => a, 197 Ok(None) => { 198 return ApiError::RepoNotFound(Some(format!("Could not find repo for DID: {}", did))) 199 + .into_response(); 200 } 201 Err(e) => { 202 error!("DB error in get_repo_status: {:?}", e);
+2 -4
src/sync/deprecated.rs
··· 57 }; 58 match account.repo_root_cid { 59 Some(root) => (StatusCode::OK, Json(GetHeadOutput { root })).into_response(), 60 - None => { 61 - ApiError::RepoNotFound(Some(format!("Could not find root for DID: {}", did))) 62 - .into_response() 63 - } 64 } 65 } 66
··· 57 }; 58 match account.repo_root_cid { 59 Some(root) => (StatusCode::OK, Json(GetHeadOutput { root })).into_response(), 60 + None => ApiError::RepoNotFound(Some(format!("Could not find root for DID: {}", did))) 61 + .into_response(), 62 } 63 } 64
+4 -4
src/sync/frame.rs
··· 122 } 123 124 impl CommitFrameBuilder { 125 pub fn new( 126 seq: i64, 127 did: String, ··· 134 ) -> Result<Self, CommitFrameError> { 135 let commit_cid = Cid::from_str(commit_cid_str) 136 .map_err(|_| CommitFrameError::InvalidCommitCid(commit_cid_str.to_string()))?; 137 - let prev_cid = prev_cid_str 138 - .map(|s| Cid::from_str(s)) 139 - .transpose() 140 - .map_err(|_| CommitFrameError::InvalidCommitCid(prev_cid_str.unwrap_or("").to_string()))?; 141 let blob_cids: Vec<Cid> = blob_strs 142 .iter() 143 .filter_map(|s| Cid::from_str(s).ok())
··· 122 } 123 124 impl CommitFrameBuilder { 125 + #[allow(clippy::too_many_arguments)] 126 pub fn new( 127 seq: i64, 128 did: String, ··· 135 ) -> Result<Self, CommitFrameError> { 136 let commit_cid = Cid::from_str(commit_cid_str) 137 .map_err(|_| CommitFrameError::InvalidCommitCid(commit_cid_str.to_string()))?; 138 + let prev_cid = prev_cid_str.map(Cid::from_str).transpose().map_err(|_| { 139 + CommitFrameError::InvalidCommitCid(prev_cid_str.unwrap_or("").to_string()) 140 + })?; 141 let blob_cids: Vec<Cid> = blob_strs 142 .iter() 143 .filter_map(|s| Cid::from_str(s).ok())
+3 -2
src/sync/repo.rs
··· 48 { 49 Ok(cids) => cids, 50 Err(invalid) => { 51 - return ApiError::InvalidRequest(format!("Invalid CID: {}", invalid)).into_response() 52 } 53 }; 54 ··· 67 let missing_cids: Vec<String> = blocks 68 .iter() 69 .zip(&cids) 70 - .filter_map(|(block_opt, cid)| block_opt.is_none().then(|| cid.to_string())) 71 .collect(); 72 if !missing_cids.is_empty() { 73 return ApiError::InvalidRequest(format!(
··· 48 { 49 Ok(cids) => cids, 50 Err(invalid) => { 51 + return ApiError::InvalidRequest(format!("Invalid CID: {}", invalid)).into_response(); 52 } 53 }; 54 ··· 67 let missing_cids: Vec<String> = blocks 68 .iter() 69 .zip(&cids) 70 + .filter(|(block_opt, _)| block_opt.is_none()) 71 + .map(|(_, cid)| cid.to_string()) 72 .collect(); 73 if !missing_cids.is_empty() { 74 return ApiError::InvalidRequest(format!(
+4 -1
src/sync/util.rs
··· 67 matches!(self, Self::Active) 68 } 69 70 - pub fn from_db_fields(takedown_ref: Option<&str>, deactivated_at: Option<chrono::DateTime<chrono::Utc>>) -> Self { 71 if takedown_ref.is_some() { 72 Self::Takendown 73 } else if deactivated_at.is_some() {
··· 67 matches!(self, Self::Active) 68 } 69 70 + pub fn from_db_fields( 71 + takedown_ref: Option<&str>, 72 + deactivated_at: Option<chrono::DateTime<chrono::Utc>>, 73 + ) -> Self { 74 if takedown_ref.is_some() { 75 Self::Takendown 76 } else if deactivated_at.is_some() {
+19 -5
src/types.rs
··· 916 } 917 918 pub fn now() -> Self { 919 - Self(chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()) 920 } 921 922 pub fn as_str(&self) -> &str { ··· 1296 } 1297 1298 pub fn can_access_repo(&self) -> bool { 1299 - matches!(self, AccountState::Active | AccountState::Deactivated { .. }) 1300 } 1301 1302 pub fn status_string(&self) -> &'static str { ··· 1405 #[derive(Debug, Clone, PartialEq, Eq)] 1406 pub enum TokenSource { 1407 Session, 1408 - OAuth { client_id: Option<String> }, 1409 - ServiceAuth { lxm: Option<String>, aud: Option<String> }, 1410 } 1411 1412 impl TokenSource { ··· 1642 1643 #[test] 1644 fn test_cidlink_validation() { 1645 - assert!(CidLink::new("bafyreib74ckyq525l3y6an5txykwwtb3dgex6ofzakml53di77oxwr5pfe").is_ok()); 1646 assert!(CidLink::new("not-a-cid").is_err()); 1647 } 1648
··· 916 } 917 918 pub fn now() -> Self { 919 + Self( 920 + chrono::Utc::now() 921 + .format("%Y-%m-%dT%H:%M:%S%.3fZ") 922 + .to_string(), 923 + ) 924 } 925 926 pub fn as_str(&self) -> &str { ··· 1300 } 1301 1302 pub fn can_access_repo(&self) -> bool { 1303 + matches!( 1304 + self, 1305 + AccountState::Active | AccountState::Deactivated { .. } 1306 + ) 1307 } 1308 1309 pub fn status_string(&self) -> &'static str { ··· 1412 #[derive(Debug, Clone, PartialEq, Eq)] 1413 pub enum TokenSource { 1414 Session, 1415 + OAuth { 1416 + client_id: Option<String>, 1417 + }, 1418 + ServiceAuth { 1419 + lxm: Option<String>, 1420 + aud: Option<String>, 1421 + }, 1422 } 1423 1424 impl TokenSource { ··· 1654 1655 #[test] 1656 fn test_cidlink_validation() { 1657 + assert!( 1658 + CidLink::new("bafyreib74ckyq525l3y6an5txykwwtb3dgex6ofzakml53di77oxwr5pfe").is_ok() 1659 + ); 1660 assert!(CidLink::new("not-a-cid").is_err()); 1661 } 1662
+3 -1
tests/import_verification.rs
··· 102 assert_eq!(import_res.status(), StatusCode::FORBIDDEN); 103 let body: serde_json::Value = import_res.json().await.unwrap(); 104 assert!( 105 - body["error"] == "InvalidRepo" || body["error"] == "InvalidRequest" || body["error"] == "DidMismatch", 106 "Expected InvalidRepo, DidMismatch, or InvalidRequest error, got: {:?}", 107 body 108 );
··· 102 assert_eq!(import_res.status(), StatusCode::FORBIDDEN); 103 let body: serde_json::Value = import_res.json().await.unwrap(); 104 assert!( 105 + body["error"] == "InvalidRepo" 106 + || body["error"] == "InvalidRequest" 107 + || body["error"] == "DidMismatch", 108 "Expected InvalidRepo, DidMismatch, or InvalidRequest error, got: {:?}", 109 body 110 );