-1
Cargo.toml
-1
Cargo.toml
+1
frontend/deno.json
+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
+
"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
+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
+2
frontend/package.json
+3
-3
frontend/src/App.svelte
+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
+1
-1
frontend/src/components/ReauthModal.svelte
+1
frontend/src/components/migration/InboundWizard.svelte
+1
frontend/src/components/migration/InboundWizard.svelte
+1
frontend/src/components/migration/OfflineInboundWizard.svelte
+1
frontend/src/components/migration/OfflineInboundWizard.svelte
+293
-176
frontend/src/lib/api-validated.ts
+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
+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
+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
+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
+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
+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
-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
+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
+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
+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
+3
-1
frontend/src/lib/oauth.ts
+15
-8
frontend/src/lib/registration/flow.svelte.ts
+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
+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
+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
+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
+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
+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
+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
+5
-5
frontend/src/lib/types/index.ts
+51
-34
frontend/src/lib/types/result.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
+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
+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
+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
+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
+27
-3
frontend/src/lib/utils/index.ts
···
1
+
export * from "./option.ts";
2
+
export {
3
+
at,
4
+
chunk,
5
+
find,
6
+
findIndex,
7
+
findMap,
8
+
first,
9
+
groupBy,
10
+
intersperse,
11
+
isEmpty,
12
+
isNonEmpty,
13
+
last,
14
+
maxBy,
15
+
minBy,
16
+
partition,
17
+
range,
18
+
sortBy,
19
+
sortByDesc,
20
+
sum,
21
+
sumBy,
22
+
unique,
23
+
uniqueBy,
24
+
zip as zipArrays,
25
+
zipWith as zipArraysWith,
26
+
} from "./array.ts";
27
+
export * from "./async.ts";
+31
-25
frontend/src/lib/utils/option.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
+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
+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
+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
+5
-1
frontend/src/routes/Dashboard.svelte
+1
-1
frontend/src/routes/Login.svelte
+1
-1
frontend/src/routes/Login.svelte
+2
-1
frontend/src/routes/RecoverPasskey.svelte
+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
+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
+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
-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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+2
justfile
+8
-4
src/api/actor/preferences.rs
+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
+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
+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!(
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
+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
+2
-4
src/api/admin/status.rs
+6
-3
src/api/age_assurance.rs
+6
-3
src/api/age_assurance.rs
+7
-4
src/api/backup.rs
+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
+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
+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
+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
+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
+2
-1
src/api/identity/plc/sign.rs
+2
-1
src/api/identity/plc/submit.rs
+2
-1
src/api/identity/plc/submit.rs
+1
-1
src/api/mod.rs
+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
+4
-2
src/api/moderation/mod.rs
+38
-40
src/api/notification_prefs.rs
+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
+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
+7
-6
src/api/repo/blob.rs
+9
-9
src/api/repo/import.rs
+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
+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
+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
+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
+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
+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
-1
src/api/server/app_password.rs
+2
-2
src/api/server/email.rs
+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
+3
-1
src/api/server/invite.rs
+18
-15
src/api/server/passkey_account.rs
+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
+3
-1
src/api/server/password.rs
+9
-4
src/api/server/reauth.rs
+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
+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
+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
+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
+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
+1
-1
src/api/server/verify_email.rs
+13
-3
src/api/validation.rs
+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
+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
-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
+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
+3
-1
src/oauth/db/request.rs
+29
-8
src/oauth/db/token.rs
+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
+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
+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
+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
+3
-1
src/oauth/scopes/parser.rs
+21
-5
src/oauth/types.rs
+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
+3
-1
src/oauth/verify.rs
+1
-1
src/sync/commit.rs
+1
-1
src/sync/commit.rs
+2
-4
src/sync/deprecated.rs
+2
-4
src/sync/deprecated.rs
+4
-4
src/sync/frame.rs
+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
+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
+4
-1
src/sync/util.rs
+19
-5
src/types.rs
+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
+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
);