Typechecks and linting

This commit is contained in:
lewis
2026-01-05 00:48:18 +02:00
parent a331f5e56f
commit df30e0253f
108 changed files with 3137 additions and 2352 deletions
-1
View File
@@ -3,7 +3,6 @@ name = "tranquil-pds"
version = "0.1.0"
edition = "2024"
license = "AGPL-3.0-or-later"
license-file = "LICENSE-AGPL-3.0-or-later"
[dependencies]
anyhow = "1.0.100"
async-trait = "0.1.89"
+1
View File
@@ -3,6 +3,7 @@
"dev": "deno run -A npm:vite",
"build": "deno run -A npm:vite build",
"preview": "deno run -A npm:vite preview",
"check": "deno run -A npm:svelte-check --tsconfig ./tsconfig.json",
"test": "deno run -A npm:vitest",
"test:run": "deno run -A npm:vitest run",
"test:watch": "deno run -A npm:vitest watch",
+31
View File
@@ -12,8 +12,11 @@
"npm:@testing-library/user-event@^14.6.1": "14.6.1_@testing-library+dom@10.4.1",
"npm:jsdom@^25.0.1": "25.0.1",
"npm:multiformats@^13.4.2": "13.4.2",
"npm:svelte-check@*": "4.3.5_svelte@5.46.1__acorn@8.15.0_typescript@5.9.3",
"npm:svelte-check@^4.3.5": "4.3.5_svelte@5.46.1__acorn@8.15.0_typescript@5.9.3",
"npm:svelte-i18n@^4.0.1": "4.0.1_svelte@5.46.1__acorn@8.15.0",
"npm:svelte@^5.46.1": "5.46.1_acorn@8.15.0",
"npm:typescript@^5.9.3": "5.9.3",
"npm:vite@*": "7.3.0_picomatch@4.0.3",
"npm:vite@^7.3.0": "7.3.0_picomatch@4.0.3",
"npm:vitest@*": "4.0.16_jsdom@25.0.1_vite@7.3.0__picomatch@4.0.3",
@@ -765,6 +768,12 @@
"chai@6.2.2": {
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="
},
"chokidar@4.0.3": {
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dependencies": [
"readdirp"
]
},
"cli-color@2.0.4": {
"integrity": "sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA==",
"dependencies": [
@@ -1271,6 +1280,9 @@
"react-is@17.0.2": {
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
},
"readdirp@4.1.2": {
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="
},
"redent@3.0.0": {
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
"dependencies": [
@@ -1349,6 +1361,19 @@
"min-indent"
]
},
"svelte-check@4.3.5_svelte@5.46.1__acorn@8.15.0_typescript@5.9.3": {
"integrity": "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==",
"dependencies": [
"@jridgewell/trace-mapping",
"chokidar",
"fdir",
"picocolors",
"sade",
"svelte",
"typescript"
],
"bin": true
},
"svelte-i18n@4.0.1_svelte@5.46.1__acorn@8.15.0": {
"integrity": "sha512-jaykGlGT5PUaaq04JWbJREvivlCnALtT+m87Kbm0fxyYHynkQaxQMnIKHLm2WeIuBRoljzwgyvz0Z6/CMwfdmQ==",
"dependencies": [
@@ -1444,6 +1469,10 @@
"type@2.7.3": {
"integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="
},
"typescript@5.9.3": {
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"bin": true
},
"unicode-segmenter@0.14.5": {
"integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g=="
},
@@ -1565,8 +1594,10 @@
"npm:@testing-library/user-event@^14.6.1",
"npm:jsdom@^25.0.1",
"npm:multiformats@^13.4.2",
"npm:svelte-check@^4.3.5",
"npm:svelte-i18n@^4.0.1",
"npm:svelte@^5.46.1",
"npm:typescript@^5.9.3",
"npm:vite@^7.3.0",
"npm:vitest@^4.0.16",
"npm:zod@^4.3.5"
+2
View File
@@ -28,6 +28,8 @@
"@testing-library/user-event": "^14.6.1",
"jsdom": "^25.0.1",
"svelte": "^5.46.1",
"svelte-check": "^4.3.5",
"typescript": "^5.9.3",
"vite": "^7.3.0",
"vitest": "^4.0.16"
}
+3 -3
View File
@@ -53,7 +53,7 @@
initServerConfig()
initAuth().then(({ oauthLoginCompleted }) => {
if (oauthLoginCompleted) {
navigate('/dashboard', true)
navigate('/dashboard', { replace: true })
}
oauthCallbackPending = false
})
@@ -64,9 +64,9 @@
const path = getCurrentPath()
if (path === '/') {
if (auth.kind === 'authenticated') {
navigate('/dashboard', true)
navigate('/dashboard', { replace: true })
} else {
navigate('/login', true)
navigate('/login', { replace: true })
}
}
})
+1 -1
View File
@@ -106,7 +106,7 @@
return
}
const { options } = await api.reauthPasskeyStart(token)
const publicKeyOptions = prepareRequestOptions(options as WebAuthnRequestOptionsResponse)
const publicKeyOptions = prepareRequestOptions(options as unknown as WebAuthnRequestOptionsResponse)
const credential = await navigator.credentials.get({
publicKey: publicKeyOptions
})
@@ -81,6 +81,7 @@
}, 3000)
return () => clearInterval(interval)
}
return undefined
})
async function loadServerInfo() {
@@ -62,6 +62,7 @@
}, 3000)
return () => clearInterval(interval)
}
return undefined
})
async function loadServerInfo() {
+312 -195
View File
@@ -1,98 +1,104 @@
import { z } from 'zod'
import { ok, err, type Result } from './types/result'
import { ApiError } from './api'
import type { AccessToken, RefreshToken, Did, Handle, Nsid, Rkey } from './types/branded'
import { z } from "zod";
import { err, ok, type Result } from "./types/result.ts";
import { ApiError } from "./api.ts";
import type {
AccessToken,
Did,
Nsid,
RefreshToken,
Rkey,
} from "./types/branded.ts";
import {
sessionSchema,
serverDescriptionSchema,
appPasswordSchema,
createdAppPasswordSchema,
listSessionsResponseSchema,
totpStatusSchema,
totpSecretSchema,
enableTotpResponseSchema,
listPasskeysResponseSchema,
listTrustedDevicesResponseSchema,
reauthStatusSchema,
notificationPrefsSchema,
didDocumentSchema,
repoDescriptionSchema,
listRecordsResponseSchema,
recordResponseSchema,
createRecordResponseSchema,
serverStatsSchema,
serverConfigSchema,
passwordStatusSchema,
successResponseSchema,
legacyLoginPreferenceSchema,
accountInfoSchema,
searchAccountsResponseSchema,
listBackupsResponseSchema,
appPasswordSchema,
createBackupResponseSchema,
type ValidatedSession,
type ValidatedServerDescription,
type ValidatedListSessionsResponse,
type ValidatedTotpStatus,
type ValidatedTotpSecret,
type ValidatedEnableTotpResponse,
type ValidatedListPasskeysResponse,
type ValidatedListTrustedDevicesResponse,
type ValidatedReauthStatus,
type ValidatedNotificationPrefs,
type ValidatedDidDocument,
type ValidatedRepoDescription,
type ValidatedListRecordsResponse,
type ValidatedRecordResponse,
type ValidatedCreateRecordResponse,
type ValidatedServerStats,
type ValidatedServerConfig,
type ValidatedPasswordStatus,
type ValidatedSuccessResponse,
type ValidatedLegacyLoginPreference,
createdAppPasswordSchema,
createRecordResponseSchema,
didDocumentSchema,
enableTotpResponseSchema,
legacyLoginPreferenceSchema,
listBackupsResponseSchema,
listPasskeysResponseSchema,
listRecordsResponseSchema,
listSessionsResponseSchema,
listTrustedDevicesResponseSchema,
notificationPrefsSchema,
passwordStatusSchema,
reauthStatusSchema,
recordResponseSchema,
repoDescriptionSchema,
searchAccountsResponseSchema,
serverConfigSchema,
serverDescriptionSchema,
serverStatsSchema,
sessionSchema,
successResponseSchema,
totpSecretSchema,
totpStatusSchema,
type ValidatedAccountInfo,
type ValidatedSearchAccountsResponse,
type ValidatedListBackupsResponse,
type ValidatedAppPassword,
type ValidatedCreateBackupResponse,
type ValidatedCreatedAppPassword,
type ValidatedAppPassword,
} from './types/schemas'
type ValidatedCreateRecordResponse,
type ValidatedDidDocument,
type ValidatedEnableTotpResponse,
type ValidatedLegacyLoginPreference,
type ValidatedListBackupsResponse,
type ValidatedListPasskeysResponse,
type ValidatedListRecordsResponse,
type ValidatedListSessionsResponse,
type ValidatedListTrustedDevicesResponse,
type ValidatedNotificationPrefs,
type ValidatedPasswordStatus,
type ValidatedReauthStatus,
type ValidatedRecordResponse,
type ValidatedRepoDescription,
type ValidatedSearchAccountsResponse,
type ValidatedServerConfig,
type ValidatedServerDescription,
type ValidatedServerStats,
type ValidatedSession,
type ValidatedSuccessResponse,
type ValidatedTotpSecret,
type ValidatedTotpStatus,
} from "./types/schemas.ts";
const API_BASE = '/xrpc'
const API_BASE = "/xrpc";
interface XrpcOptions {
method?: 'GET' | 'POST'
params?: Record<string, string>
body?: unknown
token?: string
method?: "GET" | "POST";
params?: Record<string, string>;
body?: unknown;
token?: string;
}
class ValidationError extends Error {
constructor(
public issues: z.ZodIssue[],
message: string = 'API response validation failed'
message: string = "API response validation failed",
) {
super(message)
this.name = 'ValidationError'
super(message);
this.name = "ValidationError";
}
}
async function xrpcValidated<T>(
method: string,
schema: z.ZodType<T>,
options?: XrpcOptions
options?: XrpcOptions,
): Promise<Result<T, ApiError | ValidationError>> {
const { method: httpMethod = 'GET', params, body, token } = options ?? {}
let url = `${API_BASE}/${method}`
const { method: httpMethod = "GET", params, body, token } = options ?? {};
let url = `${API_BASE}/${method}`;
if (params) {
const searchParams = new URLSearchParams(params)
url += `?${searchParams}`
const searchParams = new URLSearchParams(params);
url += `?${searchParams}`;
}
const headers: Record<string, string> = {}
const headers: Record<string, string> = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`
headers["Authorization"] = `Bearer ${token}`;
}
if (body) {
headers['Content-Type'] = 'application/json'
headers["Content-Type"] = "application/json";
}
try {
@@ -100,166 +106,239 @@ async function xrpcValidated<T>(
method: httpMethod,
headers,
body: body ? JSON.stringify(body) : undefined,
})
});
if (!res.ok) {
const errData = await res.json().catch(() => ({
error: 'Unknown',
error: "Unknown",
message: res.statusText,
}))
return err(new ApiError(res.status, errData.error, errData.message))
}));
return err(new ApiError(res.status, errData.error, errData.message));
}
const data = await res.json()
const parsed = schema.safeParse(data)
const data = await res.json();
const parsed = schema.safeParse(data);
if (!parsed.success) {
return err(new ValidationError(parsed.error.issues))
return err(new ValidationError(parsed.error.issues));
}
return ok(parsed.data)
return ok(parsed.data);
} catch (e) {
if (e instanceof ApiError || e instanceof ValidationError) {
return err(e)
return err(e);
}
return err(new ApiError(0, 'Unknown', e instanceof Error ? e.message : String(e)))
return err(
new ApiError(0, "Unknown", e instanceof Error ? e.message : String(e)),
);
}
}
export const validatedApi = {
getSession(token: AccessToken): Promise<Result<ValidatedSession, ApiError | ValidationError>> {
return xrpcValidated('com.atproto.server.getSession', sessionSchema, { token })
getSession(
token: AccessToken,
): Promise<Result<ValidatedSession, ApiError | ValidationError>> {
return xrpcValidated("com.atproto.server.getSession", sessionSchema, {
token,
});
},
refreshSession(refreshJwt: RefreshToken): Promise<Result<ValidatedSession, ApiError | ValidationError>> {
return xrpcValidated('com.atproto.server.refreshSession', sessionSchema, {
method: 'POST',
refreshSession(
refreshJwt: RefreshToken,
): Promise<Result<ValidatedSession, ApiError | ValidationError>> {
return xrpcValidated("com.atproto.server.refreshSession", sessionSchema, {
method: "POST",
token: refreshJwt,
})
});
},
createSession(
identifier: string,
password: string
password: string,
): Promise<Result<ValidatedSession, ApiError | ValidationError>> {
return xrpcValidated('com.atproto.server.createSession', sessionSchema, {
method: 'POST',
return xrpcValidated("com.atproto.server.createSession", sessionSchema, {
method: "POST",
body: { identifier, password },
})
});
},
describeServer(): Promise<Result<ValidatedServerDescription, ApiError | ValidationError>> {
return xrpcValidated('com.atproto.server.describeServer', serverDescriptionSchema)
describeServer(): Promise<
Result<ValidatedServerDescription, ApiError | ValidationError>
> {
return xrpcValidated(
"com.atproto.server.describeServer",
serverDescriptionSchema,
);
},
listAppPasswords(
token: AccessToken
): Promise<Result<{ passwords: ValidatedAppPassword[] }, ApiError | ValidationError>> {
token: AccessToken,
): Promise<
Result<{ passwords: ValidatedAppPassword[] }, ApiError | ValidationError>
> {
return xrpcValidated(
'com.atproto.server.listAppPasswords',
"com.atproto.server.listAppPasswords",
z.object({ passwords: z.array(appPasswordSchema) }),
{ token }
)
{ token },
);
},
createAppPassword(
token: AccessToken,
name: string,
scopes?: string
scopes?: string,
): Promise<Result<ValidatedCreatedAppPassword, ApiError | ValidationError>> {
return xrpcValidated('com.atproto.server.createAppPassword', createdAppPasswordSchema, {
method: 'POST',
return xrpcValidated(
"com.atproto.server.createAppPassword",
createdAppPasswordSchema,
{
method: "POST",
token,
body: { name, scopes },
},
);
},
listSessions(
token: AccessToken,
): Promise<
Result<ValidatedListSessionsResponse, ApiError | ValidationError>
> {
return xrpcValidated("_account.listSessions", listSessionsResponseSchema, {
token,
body: { name, scopes },
})
});
},
listSessions(token: AccessToken): Promise<Result<ValidatedListSessionsResponse, ApiError | ValidationError>> {
return xrpcValidated('_account.listSessions', listSessionsResponseSchema, { token })
},
getTotpStatus(token: AccessToken): Promise<Result<ValidatedTotpStatus, ApiError | ValidationError>> {
return xrpcValidated('com.atproto.server.getTotpStatus', totpStatusSchema, { token })
},
createTotpSecret(token: AccessToken): Promise<Result<ValidatedTotpSecret, ApiError | ValidationError>> {
return xrpcValidated('com.atproto.server.createTotpSecret', totpSecretSchema, {
method: 'POST',
getTotpStatus(
token: AccessToken,
): Promise<Result<ValidatedTotpStatus, ApiError | ValidationError>> {
return xrpcValidated("com.atproto.server.getTotpStatus", totpStatusSchema, {
token,
})
});
},
createTotpSecret(
token: AccessToken,
): Promise<Result<ValidatedTotpSecret, ApiError | ValidationError>> {
return xrpcValidated(
"com.atproto.server.createTotpSecret",
totpSecretSchema,
{
method: "POST",
token,
},
);
},
enableTotp(
token: AccessToken,
code: string
code: string,
): Promise<Result<ValidatedEnableTotpResponse, ApiError | ValidationError>> {
return xrpcValidated('com.atproto.server.enableTotp', enableTotpResponseSchema, {
method: 'POST',
token,
body: { code },
})
return xrpcValidated(
"com.atproto.server.enableTotp",
enableTotpResponseSchema,
{
method: "POST",
token,
body: { code },
},
);
},
listPasskeys(token: AccessToken): Promise<Result<ValidatedListPasskeysResponse, ApiError | ValidationError>> {
return xrpcValidated('com.atproto.server.listPasskeys', listPasskeysResponseSchema, { token })
listPasskeys(
token: AccessToken,
): Promise<
Result<ValidatedListPasskeysResponse, ApiError | ValidationError>
> {
return xrpcValidated(
"com.atproto.server.listPasskeys",
listPasskeysResponseSchema,
{ token },
);
},
listTrustedDevices(
token: AccessToken
): Promise<Result<ValidatedListTrustedDevicesResponse, ApiError | ValidationError>> {
return xrpcValidated('_account.listTrustedDevices', listTrustedDevicesResponseSchema, { token })
token: AccessToken,
): Promise<
Result<ValidatedListTrustedDevicesResponse, ApiError | ValidationError>
> {
return xrpcValidated(
"_account.listTrustedDevices",
listTrustedDevicesResponseSchema,
{ token },
);
},
getReauthStatus(token: AccessToken): Promise<Result<ValidatedReauthStatus, ApiError | ValidationError>> {
return xrpcValidated('_account.getReauthStatus', reauthStatusSchema, { token })
getReauthStatus(
token: AccessToken,
): Promise<Result<ValidatedReauthStatus, ApiError | ValidationError>> {
return xrpcValidated("_account.getReauthStatus", reauthStatusSchema, {
token,
});
},
getNotificationPrefs(
token: AccessToken
token: AccessToken,
): Promise<Result<ValidatedNotificationPrefs, ApiError | ValidationError>> {
return xrpcValidated('_account.getNotificationPrefs', notificationPrefsSchema, { token })
return xrpcValidated(
"_account.getNotificationPrefs",
notificationPrefsSchema,
{ token },
);
},
getDidDocument(token: AccessToken): Promise<Result<ValidatedDidDocument, ApiError | ValidationError>> {
return xrpcValidated('_account.getDidDocument', didDocumentSchema, { token })
getDidDocument(
token: AccessToken,
): Promise<Result<ValidatedDidDocument, ApiError | ValidationError>> {
return xrpcValidated("_account.getDidDocument", didDocumentSchema, {
token,
});
},
describeRepo(
token: AccessToken,
repo: Did
repo: Did,
): Promise<Result<ValidatedRepoDescription, ApiError | ValidationError>> {
return xrpcValidated('com.atproto.repo.describeRepo', repoDescriptionSchema, {
token,
params: { repo },
})
return xrpcValidated(
"com.atproto.repo.describeRepo",
repoDescriptionSchema,
{
token,
params: { repo },
},
);
},
listRecords(
token: AccessToken,
repo: Did,
collection: Nsid,
options?: { limit?: number; cursor?: string; reverse?: boolean }
options?: { limit?: number; cursor?: string; reverse?: boolean },
): Promise<Result<ValidatedListRecordsResponse, ApiError | ValidationError>> {
const params: Record<string, string> = { repo, collection }
if (options?.limit) params.limit = String(options.limit)
if (options?.cursor) params.cursor = options.cursor
if (options?.reverse) params.reverse = 'true'
return xrpcValidated('com.atproto.repo.listRecords', listRecordsResponseSchema, {
token,
params,
})
const params: Record<string, string> = { repo, collection };
if (options?.limit) params.limit = String(options.limit);
if (options?.cursor) params.cursor = options.cursor;
if (options?.reverse) params.reverse = "true";
return xrpcValidated(
"com.atproto.repo.listRecords",
listRecordsResponseSchema,
{
token,
params,
},
);
},
getRecord(
token: AccessToken,
repo: Did,
collection: Nsid,
rkey: Rkey
rkey: Rkey,
): Promise<Result<ValidatedRecordResponse, ApiError | ValidationError>> {
return xrpcValidated('com.atproto.repo.getRecord', recordResponseSchema, {
return xrpcValidated("com.atproto.repo.getRecord", recordResponseSchema, {
token,
params: { repo, collection, rkey },
})
});
},
createRecord(
@@ -267,79 +346,117 @@ export const validatedApi = {
repo: Did,
collection: Nsid,
record: unknown,
rkey?: Rkey
): Promise<Result<ValidatedCreateRecordResponse, ApiError | ValidationError>> {
return xrpcValidated('com.atproto.repo.createRecord', createRecordResponseSchema, {
method: 'POST',
rkey?: Rkey,
): Promise<
Result<ValidatedCreateRecordResponse, ApiError | ValidationError>
> {
return xrpcValidated(
"com.atproto.repo.createRecord",
createRecordResponseSchema,
{
method: "POST",
token,
body: { repo, collection, record, rkey },
},
);
},
getServerStats(
token: AccessToken,
): Promise<Result<ValidatedServerStats, ApiError | ValidationError>> {
return xrpcValidated("_admin.getServerStats", serverStatsSchema, { token });
},
getServerConfig(): Promise<
Result<ValidatedServerConfig, ApiError | ValidationError>
> {
return xrpcValidated("_server.getConfig", serverConfigSchema);
},
getPasswordStatus(
token: AccessToken,
): Promise<Result<ValidatedPasswordStatus, ApiError | ValidationError>> {
return xrpcValidated("_account.getPasswordStatus", passwordStatusSchema, {
token,
body: { repo, collection, record, rkey },
})
},
getServerStats(token: AccessToken): Promise<Result<ValidatedServerStats, ApiError | ValidationError>> {
return xrpcValidated('_admin.getServerStats', serverStatsSchema, { token })
},
getServerConfig(): Promise<Result<ValidatedServerConfig, ApiError | ValidationError>> {
return xrpcValidated('_server.getConfig', serverConfigSchema)
},
getPasswordStatus(token: AccessToken): Promise<Result<ValidatedPasswordStatus, ApiError | ValidationError>> {
return xrpcValidated('_account.getPasswordStatus', passwordStatusSchema, { token })
});
},
changePassword(
token: AccessToken,
currentPassword: string,
newPassword: string
newPassword: string,
): Promise<Result<ValidatedSuccessResponse, ApiError | ValidationError>> {
return xrpcValidated('_account.changePassword', successResponseSchema, {
method: 'POST',
return xrpcValidated("_account.changePassword", successResponseSchema, {
method: "POST",
token,
body: { currentPassword, newPassword },
})
});
},
getLegacyLoginPreference(
token: AccessToken
): Promise<Result<ValidatedLegacyLoginPreference, ApiError | ValidationError>> {
return xrpcValidated('_account.getLegacyLoginPreference', legacyLoginPreferenceSchema, { token })
token: AccessToken,
): Promise<
Result<ValidatedLegacyLoginPreference, ApiError | ValidationError>
> {
return xrpcValidated(
"_account.getLegacyLoginPreference",
legacyLoginPreferenceSchema,
{ token },
);
},
getAccountInfo(
token: AccessToken,
did: Did
did: Did,
): Promise<Result<ValidatedAccountInfo, ApiError | ValidationError>> {
return xrpcValidated('com.atproto.admin.getAccountInfo', accountInfoSchema, {
token,
params: { did },
})
return xrpcValidated(
"com.atproto.admin.getAccountInfo",
accountInfoSchema,
{
token,
params: { did },
},
);
},
searchAccounts(
token: AccessToken,
options?: { handle?: string; cursor?: string; limit?: number }
): Promise<Result<ValidatedSearchAccountsResponse, ApiError | ValidationError>> {
const params: Record<string, string> = {}
if (options?.handle) params.handle = options.handle
if (options?.cursor) params.cursor = options.cursor
if (options?.limit) params.limit = String(options.limit)
return xrpcValidated('com.atproto.admin.searchAccounts', searchAccountsResponseSchema, {
options?: { handle?: string; cursor?: string; limit?: number },
): Promise<
Result<ValidatedSearchAccountsResponse, ApiError | ValidationError>
> {
const params: Record<string, string> = {};
if (options?.handle) params.handle = options.handle;
if (options?.cursor) params.cursor = options.cursor;
if (options?.limit) params.limit = String(options.limit);
return xrpcValidated(
"com.atproto.admin.searchAccounts",
searchAccountsResponseSchema,
{
token,
params,
},
);
},
listBackups(
token: AccessToken,
): Promise<Result<ValidatedListBackupsResponse, ApiError | ValidationError>> {
return xrpcValidated("_backup.listBackups", listBackupsResponseSchema, {
token,
params,
})
});
},
listBackups(token: AccessToken): Promise<Result<ValidatedListBackupsResponse, ApiError | ValidationError>> {
return xrpcValidated('_backup.listBackups', listBackupsResponseSchema, { token })
},
createBackup(token: AccessToken): Promise<Result<ValidatedCreateBackupResponse, ApiError | ValidationError>> {
return xrpcValidated('_backup.createBackup', createBackupResponseSchema, {
method: 'POST',
createBackup(
token: AccessToken,
): Promise<
Result<ValidatedCreateBackupResponse, ApiError | ValidationError>
> {
return xrpcValidated("_backup.createBackup", createBackupResponseSchema, {
method: "POST",
token,
})
});
},
}
};
export { ValidationError }
export { ValidationError };
+829 -689
View File
File diff suppressed because it is too large Load Diff
+104 -78
View File
@@ -1,31 +1,31 @@
import {
api,
ApiError,
typedApi,
type CreateAccountParams,
type CreateAccountResult,
} from "./api";
import type { Session } from "./types/api";
typedApi,
} from "./api.ts";
import type { Session } from "./types/api.ts";
import {
type AccessToken,
type Did,
type Handle,
type AccessToken,
type RefreshToken,
unsafeAsAccessToken,
unsafeAsDid,
unsafeAsHandle,
unsafeAsAccessToken,
unsafeAsRefreshToken,
} from "./types/branded";
import { type Result, ok, err, isOk, isErr, map } from "./types/result";
import { assertNever } from "./types/exhaustive";
} from "./types/branded.ts";
import { err, isErr, isOk, ok, type Result } from "./types/result.ts";
import { assertNever } from "./types/exhaustive.ts";
import {
checkForOAuthCallback,
clearOAuthCallbackParams,
handleOAuthCallback,
refreshOAuthToken,
startOAuthLogin,
} from "./oauth";
import { setLocale, type SupportedLocale } from "./i18n";
} from "./oauth.ts";
import { setLocale, type SupportedLocale } from "./i18n.ts";
const STORAGE_KEY = "tranquil_pds_session";
const ACCOUNTS_KEY = "tranquil_pds_accounts";
@@ -64,24 +64,24 @@ type AuthStateKind = "unauthenticated" | "loading" | "authenticated" | "error";
export type AuthState =
| {
readonly kind: "unauthenticated";
readonly savedAccounts: readonly SavedAccount[];
}
readonly kind: "unauthenticated";
readonly savedAccounts: readonly SavedAccount[];
}
| {
readonly kind: "loading";
readonly savedAccounts: readonly SavedAccount[];
readonly previousSession: Session | null;
}
readonly kind: "loading";
readonly savedAccounts: readonly SavedAccount[];
readonly previousSession: Session | null;
}
| {
readonly kind: "authenticated";
readonly session: Session;
readonly savedAccounts: readonly SavedAccount[];
}
readonly kind: "authenticated";
readonly session: Session;
readonly savedAccounts: readonly SavedAccount[];
}
| {
readonly kind: "error";
readonly error: AuthError;
readonly savedAccounts: readonly SavedAccount[];
};
readonly kind: "error";
readonly error: AuthError;
readonly savedAccounts: readonly SavedAccount[];
};
function createUnauthenticated(
savedAccounts: readonly SavedAccount[],
@@ -170,7 +170,14 @@ function parseStoredAccounts(json: string): Result<SavedAccount[], Error> {
}
const accounts: SavedAccount[] = parsed
.filter(
(a): a is { did: string; handle: string; accessJwt: string; refreshJwt: string } =>
(
a,
): a is {
did: string;
handle: string;
accessJwt: string;
refreshJwt: string;
} =>
typeof a === "object" &&
a !== null &&
typeof a.did === "string" &&
@@ -272,11 +279,15 @@ async function tryRefreshToken(): Promise<string | null> {
const currentSession = state.current.session;
try {
const tokens = await refreshOAuthToken(currentSession.refreshJwt);
const sessionInfo = await api.getSession(tokens.access_token);
const sessionInfo = await api.getSession(
unsafeAsAccessToken(tokens.access_token),
);
const session: Session = {
...sessionInfo,
accessJwt: tokens.access_token,
refreshJwt: tokens.refresh_token || currentSession.refreshJwt,
accessJwt: unsafeAsAccessToken(tokens.access_token),
refreshJwt: tokens.refresh_token
? unsafeAsRefreshToken(tokens.refresh_token)
: currentSession.refreshJwt,
};
setAuthenticated(session);
return session.accessJwt;
@@ -285,7 +296,7 @@ async function tryRefreshToken(): Promise<string | null> {
}
}
import { setTokenRefreshCallback } from "./api";
import { setTokenRefreshCallback } from "./api.ts";
export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> {
setTokenRefreshCallback(tryRefreshToken);
@@ -300,17 +311,22 @@ export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> {
oauthCallback.code,
oauthCallback.state,
);
const sessionInfo = await api.getSession(tokens.access_token);
const sessionInfo = await api.getSession(
unsafeAsAccessToken(tokens.access_token),
);
const session: Session = {
...sessionInfo,
accessJwt: tokens.access_token,
refreshJwt: tokens.refresh_token || "",
accessJwt: unsafeAsAccessToken(tokens.access_token),
refreshJwt: unsafeAsRefreshToken(tokens.refresh_token || ""),
};
setAuthenticated(session);
applyLocaleFromSession(sessionInfo);
applyLocaleFromSession(session);
return { oauthLoginCompleted: true };
} catch (e) {
setError({ type: "oauth", message: e instanceof Error ? e.message : "OAuth login failed" });
setError({
type: "oauth",
message: e instanceof Error ? e.message : "OAuth login failed",
});
return { oauthLoginCompleted: false };
}
}
@@ -318,26 +334,32 @@ export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> {
const stored = loadSessionFromStorage();
if (stored) {
try {
const sessionInfo = await api.getSession(stored.accessJwt);
const sessionInfo = await api.getSession(
unsafeAsAccessToken(stored.accessJwt),
);
const session: Session = {
...sessionInfo,
accessJwt: stored.accessJwt,
refreshJwt: stored.refreshJwt,
accessJwt: unsafeAsAccessToken(stored.accessJwt),
refreshJwt: unsafeAsRefreshToken(stored.refreshJwt),
};
setAuthenticated(session);
applyLocaleFromSession(sessionInfo);
applyLocaleFromSession(session);
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
try {
const tokens = await refreshOAuthToken(stored.refreshJwt);
const sessionInfo = await api.getSession(tokens.access_token);
const sessionInfo = await api.getSession(
unsafeAsAccessToken(tokens.access_token),
);
const session: Session = {
...sessionInfo,
accessJwt: tokens.access_token,
refreshJwt: tokens.refresh_token || stored.refreshJwt,
accessJwt: unsafeAsAccessToken(tokens.access_token),
refreshJwt: tokens.refresh_token
? unsafeAsRefreshToken(tokens.refresh_token)
: unsafeAsRefreshToken(stored.refreshJwt),
};
setAuthenticated(session);
applyLocaleFromSession(sessionInfo);
applyLocaleFromSession(session);
} catch (refreshError) {
console.error("Token refresh failed during init:", refreshError);
setUnauthenticated();
@@ -359,8 +381,9 @@ export async function login(
password: string,
): Promise<Result<Session, AuthError>> {
const currentState = state.current;
const previousSession =
currentState.kind === "authenticated" ? currentState.session : null;
const previousSession = currentState.kind === "authenticated"
? currentState.session
: null;
setLoading(previousSession);
const result = await typedApi.createSession(identifier, password);
@@ -398,24 +421,14 @@ export async function register(
}
export async function confirmSignup(
did: string,
did: Did,
verificationCode: string,
): Promise<Result<Session, AuthError>> {
setLoading();
try {
const result = await api.confirmSignup(did, verificationCode);
const session: Session = {
did: result.did,
handle: result.handle,
accessJwt: result.accessJwt,
refreshJwt: result.refreshJwt,
email: result.email,
emailConfirmed: result.emailConfirmed,
preferredChannel: result.preferredChannel,
preferredChannelVerified: result.preferredChannelVerified,
};
setAuthenticated(session);
return ok(session);
setAuthenticated(result);
return ok(result);
} catch (e) {
const error = toAuthError(e);
setError(error);
@@ -424,7 +437,7 @@ export async function confirmSignup(
}
export async function resendVerification(
did: string,
did: Did,
): Promise<Result<void, AuthError>> {
try {
await api.resendVerification(did);
@@ -441,10 +454,10 @@ export function setSession(session: {
refreshJwt: string;
}): void {
const newSession: Session = {
did: session.did,
handle: session.handle,
accessJwt: session.accessJwt,
refreshJwt: session.refreshJwt,
did: unsafeAsDid(session.did),
handle: unsafeAsHandle(session.handle),
accessJwt: unsafeAsAccessToken(session.accessJwt),
refreshJwt: unsafeAsRefreshToken(session.refreshJwt),
};
setAuthenticated(newSession);
}
@@ -483,23 +496,27 @@ export async function switchAccount(
setLoading();
try {
const sessionInfo = await api.getSession(account.accessJwt as string);
const sessionInfo = await api.getSession(account.accessJwt);
const session: Session = {
...sessionInfo,
accessJwt: account.accessJwt as string,
refreshJwt: account.refreshJwt as string,
accessJwt: account.accessJwt,
refreshJwt: account.refreshJwt,
};
setAuthenticated(session);
return ok(session);
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
try {
const tokens = await refreshOAuthToken(account.refreshJwt as string);
const sessionInfo = await api.getSession(tokens.access_token);
const tokens = await refreshOAuthToken(account.refreshJwt);
const sessionInfo = await api.getSession(
unsafeAsAccessToken(tokens.access_token),
);
const session: Session = {
...sessionInfo,
accessJwt: tokens.access_token,
refreshJwt: tokens.refresh_token || (account.refreshJwt as string),
accessJwt: unsafeAsAccessToken(tokens.access_token),
refreshJwt: tokens.refresh_token
? unsafeAsRefreshToken(tokens.refresh_token)
: account.refreshJwt,
};
setAuthenticated(session);
return ok(session);
@@ -555,7 +572,7 @@ export async function refreshSession(): Promise<Result<Session, AuthError>> {
export function getToken(): AccessToken | null {
if (state.current.kind === "authenticated") {
return unsafeAsAccessToken(state.current.session.accessJwt);
return state.current.session.accessJwt;
}
return null;
}
@@ -565,19 +582,23 @@ export async function getValidToken(): Promise<AccessToken | null> {
const currentSession = state.current.session;
try {
await api.getSession(currentSession.accessJwt);
return unsafeAsAccessToken(currentSession.accessJwt);
return currentSession.accessJwt;
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
try {
const tokens = await refreshOAuthToken(currentSession.refreshJwt);
const sessionInfo = await api.getSession(tokens.access_token);
const sessionInfo = await api.getSession(
unsafeAsAccessToken(tokens.access_token),
);
const session: Session = {
...sessionInfo,
accessJwt: tokens.access_token,
refreshJwt: tokens.refresh_token || currentSession.refreshJwt,
accessJwt: unsafeAsAccessToken(tokens.access_token),
refreshJwt: tokens.refresh_token
? unsafeAsRefreshToken(tokens.refresh_token)
: currentSession.refreshJwt,
};
setAuthenticated(session);
return unsafeAsAccessToken(session.accessJwt);
return session.accessJwt;
} catch {
return null;
}
@@ -604,7 +625,10 @@ export function getSession(): Session | null {
export function matchAuthState<T>(handlers: {
unauthenticated: (accounts: readonly SavedAccount[]) => T;
loading: (accounts: readonly SavedAccount[], previousSession: Session | null) => T;
loading: (
accounts: readonly SavedAccount[],
previousSession: Session | null,
) => T;
authenticated: (session: Session, accounts: readonly SavedAccount[]) => T;
error: (error: AuthError, accounts: readonly SavedAccount[]) => T;
}): T {
@@ -633,7 +657,9 @@ export function _testSetState(newState: {
if (newState.loading) {
setState(createLoading(accounts, newState.session));
} else if (newState.error) {
setState(createError({ type: "unknown", message: newState.error }, accounts));
setState(
createError({ type: "unknown", message: newState.error }, accounts),
);
} else if (newState.session) {
setState(createAuthenticated(newState.session, accounts));
} else {
+7 -4
View File
@@ -11,7 +11,7 @@ export interface Keypair {
}
export function generateKeypair(): Keypair {
const privateKey = secp.utils.randomPrivateKey();
const privateKey = secp.utils.randomSecretKey();
const publicKey = secp.getPublicKey(privateKey, true);
const multicodecKey = new Uint8Array(
@@ -35,7 +35,9 @@ function base64UrlEncode(data: Uint8Array | string): string {
const bytes = typeof data === "string"
? new TextEncoder().encode(data)
: data;
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('')
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join(
"",
);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
@@ -67,8 +69,9 @@ export async function createServiceJwt(
const msgBytes = new TextEncoder().encode(message);
const hashBuffer = await crypto.subtle.digest("SHA-256", msgBytes);
const msgHash = new Uint8Array(hashBuffer);
const signature = await secp.signAsync(msgHash, privateKey);
const sigBytes = signature.toCompactRawBytes();
const sigBytes = await secp.signAsync(msgHash, privateKey, {
prehash: false,
});
const signatureEncoded = base64UrlEncode(sigBytes);
return `${message}.${signatureEncoded}`;
+11 -6
View File
@@ -14,7 +14,7 @@ import type {
ServerDescription,
Session,
StartPasskeyRegistrationResponse,
} from "./types";
} from "./types.ts";
function apiLog(
method: string,
@@ -101,7 +101,7 @@ export class AtprotoClient {
let requestBody: BodyInit | undefined;
if (rawBody) {
headers["Content-Type"] = contentType ?? "application/octet-stream";
requestBody = rawBody;
requestBody = rawBody as BodyInit;
} else if (body) {
headers["Content-Type"] = "application/json";
requestBody = JSON.stringify(body);
@@ -231,7 +231,9 @@ export class AtprotoClient {
did: string,
cid: string,
): Promise<{ data: Uint8Array; contentType: string }> {
const url = `${this.baseUrl}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`;
const url = `${this.baseUrl}/xrpc/com.atproto.sync.getBlob?did=${
encodeURIComponent(did)
}&cid=${encodeURIComponent(cid)}`;
const headers: Record<string, string> = {};
if (this.accessToken) {
headers["Authorization"] = `Bearer ${this.accessToken}`;
@@ -244,7 +246,8 @@ export class AtprotoClient {
}));
throw new Error(err.message || err.error || res.statusText);
}
const contentType = res.headers.get("content-type") || "application/octet-stream";
const contentType = res.headers.get("content-type") ||
"application/octet-stream";
const data = new Uint8Array(await res.arrayBuffer());
return { data, contentType };
}
@@ -600,7 +603,9 @@ export async function generatePKCE(): Promise<{
export function base64UrlEncode(buffer: Uint8Array | ArrayBuffer): string {
const bytes = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : buffer;
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('')
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join(
"",
);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(
/=+$/,
"",
@@ -632,7 +637,7 @@ export function prepareWebAuthnCreationOptions(
id: base64UrlDecode(cred.id as string),
}),
),
} as PublicKeyCredentialCreationOptions;
} as unknown as PublicKeyCredentialCreationOptions;
}
async function computeAccessTokenHash(accessToken: string): Promise<string> {
+10 -4
View File
@@ -1,5 +1,5 @@
import type { AtprotoClient } from "./atproto-client";
import type { MigrationProgress } from "./types";
import type { AtprotoClient } from "./atproto-client.ts";
import type { MigrationProgress } from "./types.ts";
export interface BlobMigrationResult {
migrated: number;
@@ -85,7 +85,8 @@ export async function migrateBlobs(
});
console.log("[blob-migration] Fetching blob", cid, "from source");
const { data: blobData, contentType } = await sourceClient.getBlobWithContentType(userDid, cid);
const { data: blobData, contentType } = await sourceClient
.getBlobWithContentType(userDid, cid);
console.log(
"[blob-migration] Got blob",
cid,
@@ -95,7 +96,12 @@ export async function migrateBlobs(
contentType,
);
await localClient.uploadBlob(blobData, contentType);
console.log("[blob-migration] Uploaded blob", cid, "with contentType:", contentType);
console.log(
"[blob-migration] Uploaded blob",
cid,
"with contentType:",
contentType,
);
migrated++;
onProgress({ blobsMigrated: migrated });
} catch (e) {
+5 -5
View File
@@ -5,7 +5,7 @@ import type {
PasskeyAccountSetup,
ServerDescription,
StoredMigrationState,
} from "./types";
} from "./types.ts";
import {
AtprotoClient,
clearDPoPKey,
@@ -21,14 +21,14 @@ import {
loadDPoPKey,
resolvePdsUrl,
saveDPoPKey,
} from "./atproto-client";
} from "./atproto-client.ts";
import {
clearMigrationState,
saveMigrationState,
updateProgress,
updateStep,
} from "./storage";
import { migrateBlobs as migrateBlobsUtil } from "./blob-migration";
} from "./storage.ts";
import { migrateBlobs as migrateBlobsUtil } from "./blob-migration.ts";
function migrationLog(stage: string, data?: Record<string, unknown>) {
const timestamp = new Date().toISOString();
@@ -94,7 +94,7 @@ export function createInboundMigrationFlow() {
}
}
function setError(error: string) {
function setError(error: string | null) {
state.error = error;
saveMigrationState(state);
}
@@ -4,17 +4,23 @@ import type {
OfflineInboundMigrationState,
OfflineInboundStep,
ServerDescription,
} from "./types";
} from "./types.ts";
import {
AtprotoClient,
base64UrlEncode,
createLocalClient,
prepareWebAuthnCreationOptions,
} from "./atproto-client";
import { api } from "../api";
import { type KeypairInfo, plcOps, type PrivateKey } from "./plc-ops";
import { migrateBlobs as migrateBlobsUtil } from "./blob-migration";
} from "./atproto-client.ts";
import { api } from "../api.ts";
import { type KeypairInfo, plcOps, type PrivateKey } from "./plc-ops.ts";
import { migrateBlobs as migrateBlobsUtil } from "./blob-migration.ts";
import { Secp256k1PrivateKeyExportable } from "@atcute/crypto";
import {
unsafeAsAccessToken,
unsafeAsDid,
unsafeAsEmail,
unsafeAsHandle,
} from "../types/branded.ts";
const OFFLINE_STORAGE_KEY = "tranquil_offline_migration_state";
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
@@ -303,9 +309,9 @@ export function createOfflineInboundMigrationFlow() {
const createResult = await api.createAccountWithServiceAuth(
serviceAuthToken,
{
did: state.userDid,
handle: fullHandle,
email: state.targetEmail,
did: unsafeAsDid(state.userDid),
handle: unsafeAsHandle(fullHandle),
email: unsafeAsEmail(state.targetEmail),
password: state.targetPassword,
inviteCode: state.inviteCode || undefined,
},
@@ -326,9 +332,9 @@ export function createOfflineInboundMigrationFlow() {
: `${state.targetHandle}.${serverInfo.availableUserDomains[0]}`;
const createResult = await api.createPasskeyAccount({
did: state.userDid,
handle: fullHandle,
email: state.targetEmail,
did: unsafeAsDid(state.userDid),
handle: unsafeAsHandle(fullHandle),
email: unsafeAsEmail(state.targetEmail),
inviteCode: state.inviteCode || undefined,
}, serviceAuthToken);
@@ -349,7 +355,7 @@ export function createOfflineInboundMigrationFlow() {
const prevCid = base.cid;
const credentials = await api.getRecommendedDidCredentials(
state.localAccessToken,
unsafeAsAccessToken(state.localAccessToken),
);
await plcOps.signPlcOperationWithCredentials(
@@ -374,7 +380,10 @@ export function createOfflineInboundMigrationFlow() {
}
setProgress({ currentOperation: "Importing repository..." });
await api.importRepo(state.localAccessToken, state.carFile);
await api.importRepo(
unsafeAsAccessToken(state.localAccessToken),
state.carFile,
);
setProgress({ repoImported: true });
}
@@ -384,7 +393,7 @@ export function createOfflineInboundMigrationFlow() {
}
const localClient = createLocalClient();
localClient.setAccessToken(state.localAccessToken);
localClient.setAccessToken(unsafeAsAccessToken(state.localAccessToken));
if (state.oldPdsUrl) {
setProgress({
@@ -436,7 +445,7 @@ export function createOfflineInboundMigrationFlow() {
}
setProgress({ currentOperation: "Activating account..." });
await api.activateAccount(state.localAccessToken);
await api.activateAccount(unsafeAsAccessToken(state.localAccessToken));
setProgress({ activated: true });
}
@@ -445,7 +454,7 @@ export function createOfflineInboundMigrationFlow() {
setError(null);
try {
await api.verifyMigrationEmail(token, state.targetEmail);
await api.verifyMigrationEmail(token, unsafeAsEmail(state.targetEmail));
if (state.authMethod === "passkey") {
setStep("passkey-setup");
@@ -474,7 +483,7 @@ export function createOfflineInboundMigrationFlow() {
}
async function resendEmailVerification(): Promise<void> {
await api.resendMigrationVerification(state.targetEmail);
await api.resendMigrationVerification(unsafeAsEmail(state.targetEmail));
}
let checkingEmailVerification = false;
@@ -518,7 +527,7 @@ export function createOfflineInboundMigrationFlow() {
}
return api.startPasskeyRegistrationForSetup(
state.userDid,
unsafeAsDid(state.userDid),
state.passkeySetupToken,
);
}
@@ -560,7 +569,7 @@ export function createOfflineInboundMigrationFlow() {
};
const result = await api.completePasskeySetup(
state.userDid,
unsafeAsDid(state.userDid),
state.passkeySetupToken,
credentialData,
passkeyName,
+7 -2
View File
@@ -28,7 +28,7 @@ export interface PlcService {
export interface PlcOperationData {
type: "plc_operation";
prev: string;
prev: string | null;
alsoKnownAs: string[];
rotationKeys: string[];
services: Record<string, PlcService>;
@@ -66,6 +66,9 @@ export class PlcOps {
if (!lastOp) {
throw new Error("No PLC operations found for this DID");
}
if (lastOp.operation.type === "plc_tombstone") {
throw new Error("DID has been tombstoned");
}
return { lastOperation: normalizeOp(lastOp.operation), base: lastOp };
}
@@ -108,7 +111,9 @@ export class PlcOps {
} else if (match.type === "secp256k1") {
keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes);
} else {
throw new Error(`Unsupported key type: ${match.type}`);
throw new Error(
`Unsupported key type: ${(match as { type: string }).type}`,
);
}
} else {
throw new Error(
+9 -15
View File
@@ -2,8 +2,8 @@ import type {
MigrationDirection,
MigrationState,
StoredMigrationState,
} from "./types";
import { clearDPoPKey } from "./atproto-client";
} from "./types.ts";
import { clearDPoPKey } from "./atproto-client.ts";
const STORAGE_KEY = "tranquil_migration_state";
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
@@ -12,22 +12,16 @@ export function saveMigrationState(state: MigrationState): void {
const storedState: StoredMigrationState = {
version: 1,
direction: state.direction,
step: state.direction === "inbound" ? state.step : state.step,
step: state.step,
startedAt: new Date().toISOString(),
sourcePdsUrl: state.direction === "inbound"
? state.sourcePdsUrl
: globalThis.location.origin,
targetPdsUrl: state.direction === "inbound"
? globalThis.location.origin
: state.targetPdsUrl,
sourceDid: state.direction === "inbound" ? state.sourceDid : "",
sourceHandle: state.direction === "inbound" ? state.sourceHandle : "",
sourcePdsUrl: state.sourcePdsUrl,
targetPdsUrl: globalThis.location.origin,
sourceDid: state.sourceDid,
sourceHandle: state.sourceHandle,
targetHandle: state.targetHandle,
targetEmail: state.targetEmail,
authMethod: state.direction === "inbound" ? state.authMethod : undefined,
passkeySetupToken: state.direction === "inbound"
? state.passkeySetupToken ?? undefined
: undefined,
authMethod: state.authMethod,
passkeySetupToken: state.passkeySetupToken ?? undefined,
progress: {
repoExported: state.progress.repoExported,
repoImported: state.progress.repoImported,
+3 -1
View File
@@ -34,7 +34,9 @@ function sha256(plain: string): Promise<ArrayBuffer> {
function base64UrlEncode(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('')
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join(
"",
);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(
/=+$/,
"",
+15 -8
View File
@@ -1,10 +1,15 @@
import { api, ApiError } from "../api";
import { setSession } from "../auth.svelte";
import { api, ApiError } from "../api.ts";
import { setSession } from "../auth.svelte.ts";
import {
createServiceJwt,
generateDidDocument,
generateKeypair,
} from "../crypto";
} from "../crypto.ts";
import {
unsafeAsDid,
unsafeAsEmail,
unsafeAsHandle,
} from "../types/branded.ts";
import type {
AccountResult,
ExternalDidWebState,
@@ -12,7 +17,7 @@ import type {
RegistrationMode,
RegistrationStep,
SessionState,
} from "./types";
} from "./types.ts";
export interface RegistrationFlowState {
mode: RegistrationMode;
@@ -100,7 +105,7 @@ export function createRegistrationFlow(
if (keyMode === "reserved") {
const result = await api.reserveSigningKey(
state.info.externalDid!.trim(),
unsafeAsDid(state.info.externalDid!.trim()),
);
state.externalDidWeb.reservedSigningKey = result.signingKey;
publicKeyMultibase = result.signingKey.replace("did:key:", "");
@@ -207,12 +212,14 @@ export function createRegistrationFlow(
}
const result = await api.createPasskeyAccount({
handle: state.info.handle.trim(),
email: state.info.email?.trim() || undefined,
handle: unsafeAsHandle(state.info.handle.trim()),
email: state.info.email?.trim()
? unsafeAsEmail(state.info.email.trim())
: undefined,
inviteCode: state.info.inviteCode?.trim() || undefined,
didType: state.info.didType,
did: state.info.didType === "web-external"
? state.info.externalDid!.trim()
? unsafeAsDid(state.info.externalDid!.trim())
: undefined,
signingKey: state.info.didType === "web-external" &&
state.externalDidWeb.keyMode === "reserved"
+11 -5
View File
@@ -1,4 +1,10 @@
import type { DidType, VerificationChannel } from "../api";
import type { DidType, VerificationChannel } from "../api.ts";
import type {
AccessToken,
Did,
Handle,
RefreshToken,
} from "../types/branded.ts";
export type RegistrationMode = "password" | "passkey";
@@ -37,14 +43,14 @@ export interface ExternalDidWebState {
}
export interface AccountResult {
did: string;
handle: string;
did: Did;
handle: Handle;
setupToken?: string;
appPassword?: string;
appPasswordName?: string;
}
export interface SessionState {
accessJwt: string;
refreshJwt: string;
accessJwt: AccessToken;
refreshJwt: RefreshToken;
}
+11 -7
View File
@@ -1,12 +1,12 @@
import {
routes,
buildUrl,
isValidRoute,
parseRouteParams,
type Route,
type RouteParams,
routes,
type RoutesWithParams,
buildUrl,
parseRouteParams,
isValidRoute,
} from "./types/routes";
} from "./types/routes.ts";
const APP_BASE = "/app";
@@ -120,7 +120,11 @@ export function getRouteParams<R extends RoutesWithParams>(
}
export type RouteMatch =
| { readonly matched: true; readonly route: Route; readonly params: URLSearchParams }
| {
readonly matched: true;
readonly route: Route;
readonly params: URLSearchParams;
}
| { readonly matched: false };
export function match(): RouteMatch {
@@ -135,4 +139,4 @@ export function match(): RouteMatch {
return { matched: false };
}
export { routes, type Route, type RouteParams, type RoutesWithParams };
export { type Route, type RouteParams, routes, type RoutesWithParams };
+26 -26
View File
@@ -1,66 +1,66 @@
export type ToastType = 'success' | 'error' | 'warning' | 'info'
export type ToastType = "success" | "error" | "warning" | "info";
export interface Toast {
id: number
type: ToastType
message: string
duration: number
dismissing?: boolean
id: number;
type: ToastType;
message: string;
duration: number;
dismissing?: boolean;
}
let nextId = 0
let toasts = $state<Toast[]>([])
let nextId = 0;
let toasts = $state<Toast[]>([]);
export function getToasts(): readonly Toast[] {
return toasts
return toasts;
}
export function showToast(
type: ToastType,
message: string,
duration = 5000
duration = 5000,
): number {
const id = nextId++
toasts = [...toasts, { id, type, message, duration }]
const id = nextId++;
toasts = [...toasts, { id, type, message, duration }];
if (duration > 0) {
setTimeout(() => {
dismissToast(id)
}, duration)
dismissToast(id);
}, duration);
}
return id
return id;
}
export function dismissToast(id: number): void {
const toast = toasts.find(t => t.id === id)
if (!toast || toast.dismissing) return
const toast = toasts.find((t) => t.id === id);
if (!toast || toast.dismissing) return;
toasts = toasts.map(t => t.id === id ? { ...t, dismissing: true } : t)
toasts = toasts.map((t) => t.id === id ? { ...t, dismissing: true } : t);
setTimeout(() => {
toasts = toasts.filter(t => t.id !== id)
}, 150)
toasts = toasts.filter((t) => t.id !== id);
}, 150);
}
export function clearAllToasts(): void {
toasts = []
toasts = [];
}
export function success(message: string, duration?: number): number {
return showToast('success', message, duration)
return showToast("success", message, duration);
}
export function error(message: string, duration?: number): number {
return showToast('error', message, duration)
return showToast("error", message, duration);
}
export function warning(message: string, duration?: number): number {
return showToast('warning', message, duration)
return showToast("warning", message, duration);
}
export function info(message: string, duration?: number): number {
return showToast('info', message, duration)
return showToast("info", message, duration);
}
export const toast = {
@@ -71,4 +71,4 @@ export const toast = {
info,
dismiss: dismissToast,
clear: clearAllToasts,
}
};
+272 -263
View File
@@ -1,486 +1,495 @@
import type {
Did,
Handle,
AccessToken,
RefreshToken,
Cid,
Rkey,
AtUri,
Nsid,
ISODateString,
Cid,
Did,
EmailAddress,
Handle,
InviteCode as InviteCodeBrand,
ISODateString,
Nsid,
PublicKeyMultibase,
} from './branded'
RefreshToken,
} from "./branded.ts";
export type ApiErrorCode =
| 'InvalidRequest'
| 'AuthenticationRequired'
| 'ExpiredToken'
| 'InvalidToken'
| 'AccountNotFound'
| 'HandleNotAvailable'
| 'InvalidHandle'
| 'InvalidPassword'
| 'RateLimitExceeded'
| 'InternalServerError'
| 'AccountTakedown'
| 'AccountDeactivated'
| 'AccountNotVerified'
| 'RepoNotFound'
| 'RecordNotFound'
| 'BlobNotFound'
| 'InvalidInviteCode'
| 'DuplicateCreate'
| 'Unknown'
| "InvalidRequest"
| "AuthenticationRequired"
| "ExpiredToken"
| "InvalidToken"
| "AccountNotFound"
| "HandleNotAvailable"
| "InvalidHandle"
| "InvalidPassword"
| "RateLimitExceeded"
| "InternalServerError"
| "AccountTakedown"
| "AccountDeactivated"
| "AccountNotVerified"
| "RepoNotFound"
| "RecordNotFound"
| "BlobNotFound"
| "InvalidInviteCode"
| "DuplicateCreate"
| "ReauthRequired"
| "MfaVerificationRequired"
| "RecoveryLinkExpired"
| "InvalidRecoveryLink"
| "Unknown";
export type AccountStatus = 'active' | 'deactivated' | 'migrated' | 'suspended' | 'deleted'
export type AccountStatus =
| "active"
| "deactivated"
| "migrated"
| "suspended"
| "deleted";
export type SessionType = 'oauth' | 'legacy' | 'app_password'
export type SessionType = "oauth" | "legacy" | "app_password";
export type VerificationChannel = 'email' | 'discord' | 'telegram' | 'signal'
export type VerificationChannel = "email" | "discord" | "telegram" | "signal";
export type DidType = 'plc' | 'web' | 'web-external'
export type DidType = "plc" | "web" | "web-external";
export type ReauthMethod = 'password' | 'totp' | 'passkey'
export type ReauthMethod = "password" | "totp" | "passkey";
export interface Session {
did: Did
handle: Handle
email?: EmailAddress
emailConfirmed?: boolean
preferredChannel?: VerificationChannel
preferredChannelVerified?: boolean
isAdmin?: boolean
active?: boolean
status?: AccountStatus
migratedToPds?: string
migratedAt?: ISODateString
accessJwt: AccessToken
refreshJwt: RefreshToken
did: Did;
handle: Handle;
email?: EmailAddress;
emailConfirmed?: boolean;
preferredChannel?: VerificationChannel;
preferredChannelVerified?: boolean;
preferredLocale?: string | null;
isAdmin?: boolean;
active?: boolean;
status?: AccountStatus;
migratedToPds?: string;
migratedAt?: ISODateString;
accessJwt: AccessToken;
refreshJwt: RefreshToken;
}
export interface VerificationMethod {
id: string
type: string
controller: string
publicKeyMultibase: PublicKeyMultibase
id: string;
type: string;
controller: string;
publicKeyMultibase: PublicKeyMultibase;
}
export interface ServiceEndpoint {
id: string
type: string
serviceEndpoint: string
id: string;
type: string;
serviceEndpoint: string;
}
export interface DidDocument {
'@context': string[]
id: Did
alsoKnownAs: string[]
verificationMethod: VerificationMethod[]
service: ServiceEndpoint[]
"@context": string[];
id: Did;
alsoKnownAs: string[];
verificationMethod: VerificationMethod[];
service: ServiceEndpoint[];
}
export interface AppPassword {
name: string
createdAt: ISODateString
scopes?: string
createdByController?: string
name: string;
createdAt: ISODateString;
scopes?: string;
createdByController?: string;
}
export interface CreatedAppPassword {
name: string
password: string
createdAt: ISODateString
scopes?: string
name: string;
password: string;
createdAt: ISODateString;
scopes?: string;
}
export interface InviteCodeUse {
usedBy: Did
usedByHandle?: Handle
usedAt: ISODateString
usedBy: Did;
usedByHandle?: Handle;
usedAt: ISODateString;
}
export interface InviteCodeInfo {
code: InviteCodeBrand
available: number
disabled: boolean
forAccount: Did
createdBy: Did
createdAt: ISODateString
uses: InviteCodeUse[]
code: InviteCodeBrand;
available: number;
disabled: boolean;
forAccount: Did;
createdBy: Did;
createdAt: ISODateString;
uses: InviteCodeUse[];
}
export interface CreateAccountParams {
handle: string
email: string
password: string
inviteCode?: string
didType?: DidType
did?: string
signingKey?: string
verificationChannel?: VerificationChannel
discordId?: string
telegramUsername?: string
signalNumber?: string
handle: string;
email: string;
password: string;
inviteCode?: string;
didType?: DidType;
did?: string;
signingKey?: string;
verificationChannel?: VerificationChannel;
discordId?: string;
telegramUsername?: string;
signalNumber?: string;
}
export interface CreateAccountResult {
handle: Handle
did: Did
verificationRequired: boolean
verificationChannel: VerificationChannel
handle: Handle;
did: Did;
verificationRequired: boolean;
verificationChannel: VerificationChannel;
}
export interface ConfirmSignupResult {
accessJwt: AccessToken
refreshJwt: RefreshToken
handle: Handle
did: Did
email?: EmailAddress
emailConfirmed?: boolean
preferredChannel?: VerificationChannel
preferredChannelVerified?: boolean
accessJwt: AccessToken;
refreshJwt: RefreshToken;
handle: Handle;
did: Did;
email?: EmailAddress;
emailConfirmed?: boolean;
preferredChannel?: VerificationChannel;
preferredChannelVerified?: boolean;
}
export interface ListAppPasswordsResponse {
passwords: AppPassword[]
passwords: AppPassword[];
}
export interface AccountInviteCodesResponse {
codes: InviteCodeInfo[]
codes: InviteCodeInfo[];
}
export interface CreateInviteCodeResponse {
code: InviteCodeBrand
code: InviteCodeBrand;
}
export interface ServerLinks {
privacyPolicy?: string
termsOfService?: string
privacyPolicy?: string;
termsOfService?: string;
}
export interface ServerDescription {
availableUserDomains: string[]
inviteCodeRequired: boolean
links?: ServerLinks
version?: string
availableCommsChannels?: VerificationChannel[]
selfHostedDidWebEnabled?: boolean
availableUserDomains: string[];
inviteCodeRequired: boolean;
links?: ServerLinks;
version?: string;
availableCommsChannels?: VerificationChannel[];
selfHostedDidWebEnabled?: boolean;
}
export interface RepoInfo {
did: Did
head: Cid
rev: string
did: Did;
head: Cid;
rev: string;
}
export interface ListReposResponse {
repos: RepoInfo[]
cursor?: string
repos: RepoInfo[];
cursor?: string;
}
export interface NotificationPrefs {
preferredChannel: VerificationChannel
email: EmailAddress
discordId: string | null
discordVerified: boolean
telegramUsername: string | null
telegramVerified: boolean
signalNumber: string | null
signalVerified: boolean
preferredChannel: VerificationChannel;
email: EmailAddress;
discordId: string | null;
discordVerified: boolean;
telegramUsername: string | null;
telegramVerified: boolean;
signalNumber: string | null;
signalVerified: boolean;
}
export interface NotificationHistoryItem {
createdAt: ISODateString
channel: VerificationChannel
notificationType: string
status: string
subject: string | null
body: string
createdAt: ISODateString;
channel: VerificationChannel;
notificationType: string;
status: string;
subject: string | null;
body: string;
}
export interface NotificationHistoryResponse {
notifications: NotificationHistoryItem[]
notifications: NotificationHistoryItem[];
}
export interface ServerStats {
userCount: number
repoCount: number
recordCount: number
blobStorageBytes: number
userCount: number;
repoCount: number;
recordCount: number;
blobStorageBytes: number;
}
export interface ServerConfig {
serverName: string
primaryColor: string | null
primaryColorDark: string | null
secondaryColor: string | null
secondaryColorDark: string | null
logoCid: Cid | null
serverName: string;
primaryColor: string | null;
primaryColorDark: string | null;
secondaryColor: string | null;
secondaryColorDark: string | null;
logoCid: Cid | null;
}
export interface BlobRef {
$type: 'blob'
ref: { $link: Cid }
mimeType: string
size: number
$type: "blob";
ref: { $link: Cid };
mimeType: string;
size: number;
}
export interface UploadBlobResponse {
blob: BlobRef
blob: BlobRef;
}
export interface SessionInfo {
id: string
sessionType: SessionType
clientName: string | null
createdAt: ISODateString
expiresAt: ISODateString
isCurrent: boolean
id: string;
sessionType: SessionType;
clientName: string | null;
createdAt: ISODateString;
expiresAt: ISODateString;
isCurrent: boolean;
}
export interface ListSessionsResponse {
sessions: SessionInfo[]
sessions: SessionInfo[];
}
export interface RevokeAllSessionsResponse {
revokedCount: number
revokedCount: number;
}
export interface AccountSearchResult {
did: Did
handle: Handle
email?: EmailAddress
indexedAt: ISODateString
emailConfirmedAt?: ISODateString
deactivatedAt?: ISODateString
did: Did;
handle: Handle;
email?: EmailAddress;
indexedAt: ISODateString;
emailConfirmedAt?: ISODateString;
deactivatedAt?: ISODateString;
}
export interface SearchAccountsResponse {
cursor?: string
accounts: AccountSearchResult[]
cursor?: string;
accounts: AccountSearchResult[];
}
export interface AdminInviteCodeUse {
usedBy: Did
usedAt: ISODateString
usedBy: Did;
usedAt: ISODateString;
}
export interface AdminInviteCode {
code: InviteCodeBrand
available: number
disabled: boolean
forAccount: Did
createdBy: Did
createdAt: ISODateString
uses: AdminInviteCodeUse[]
code: InviteCodeBrand;
available: number;
disabled: boolean;
forAccount: Did;
createdBy: Did;
createdAt: ISODateString;
uses: AdminInviteCodeUse[];
}
export interface GetInviteCodesResponse {
cursor?: string
codes: AdminInviteCode[]
cursor?: string;
codes: AdminInviteCode[];
}
export interface AccountInfo {
did: Did
handle: Handle
email?: EmailAddress
indexedAt: ISODateString
emailConfirmedAt?: ISODateString
invitesDisabled?: boolean
deactivatedAt?: ISODateString
did: Did;
handle: Handle;
email?: EmailAddress;
indexedAt: ISODateString;
emailConfirmedAt?: ISODateString;
invitesDisabled?: boolean;
deactivatedAt?: ISODateString;
}
export interface RepoDescription {
handle: Handle
did: Did
didDoc: DidDocument
collections: Nsid[]
handleIsCorrect: boolean
handle: Handle;
did: Did;
didDoc: DidDocument;
collections: Nsid[];
handleIsCorrect: boolean;
}
export interface RecordInfo {
uri: AtUri
cid: Cid
value: unknown
uri: AtUri;
cid: Cid;
value: unknown;
}
export interface ListRecordsResponse {
records: RecordInfo[]
cursor?: string
records: RecordInfo[];
cursor?: string;
}
export interface RecordResponse {
uri: AtUri
cid: Cid
value: unknown
uri: AtUri;
cid: Cid;
value: unknown;
}
export interface CreateRecordResponse {
uri: AtUri
cid: Cid
uri: AtUri;
cid: Cid;
}
export interface TotpStatus {
enabled: boolean
hasBackupCodes: boolean
enabled: boolean;
hasBackupCodes: boolean;
}
export interface TotpSecret {
uri: string
qrBase64: string
uri: string;
qrBase64: string;
}
export interface EnableTotpResponse {
success: boolean
backupCodes: string[]
success: boolean;
backupCodes: string[];
}
export interface RegenerateBackupCodesResponse {
backupCodes: string[]
backupCodes: string[];
}
export interface PasskeyInfo {
id: string
credentialId: string
friendlyName: string | null
createdAt: ISODateString
lastUsed: ISODateString | null
id: string;
credentialId: string;
friendlyName: string | null;
createdAt: ISODateString;
lastUsed: ISODateString | null;
}
export interface ListPasskeysResponse {
passkeys: PasskeyInfo[]
passkeys: PasskeyInfo[];
}
export interface StartPasskeyRegistrationResponse {
options: PublicKeyCredentialCreationOptions
options: PublicKeyCredentialCreationOptions;
}
export interface FinishPasskeyRegistrationResponse {
id: string
credentialId: string
id: string;
credentialId: string;
}
export interface TrustedDevice {
id: string
userAgent: string | null
friendlyName: string | null
trustedAt: ISODateString | null
trustedUntil: ISODateString | null
lastSeenAt: ISODateString
id: string;
userAgent: string | null;
friendlyName: string | null;
trustedAt: ISODateString | null;
trustedUntil: ISODateString | null;
lastSeenAt: ISODateString;
}
export interface ListTrustedDevicesResponse {
devices: TrustedDevice[]
devices: TrustedDevice[];
}
export interface ReauthStatus {
requiresReauth: boolean
lastReauthAt: ISODateString | null
availableMethods: ReauthMethod[]
requiresReauth: boolean;
lastReauthAt: ISODateString | null;
availableMethods: ReauthMethod[];
}
export interface ReauthResponse {
success: boolean
reauthAt: ISODateString
success: boolean;
reauthAt: ISODateString;
}
export interface ReauthPasskeyStartResponse {
options: PublicKeyCredentialRequestOptions
options: PublicKeyCredentialRequestOptions;
}
export interface ReserveSigningKeyResponse {
signingKey: PublicKeyMultibase
signingKey: PublicKeyMultibase;
}
export interface RecommendedDidCredentials {
rotationKeys?: PublicKeyMultibase[]
alsoKnownAs?: string[]
verificationMethods?: { atproto?: PublicKeyMultibase }
services?: { atproto_pds?: { type: string; endpoint: string } }
rotationKeys?: PublicKeyMultibase[];
alsoKnownAs?: string[];
verificationMethods?: { atproto?: PublicKeyMultibase };
services?: { atproto_pds?: { type: string; endpoint: string } };
}
export interface PasskeyAccountCreateResponse {
did: Did
handle: Handle
setupToken: string
setupExpiresAt: ISODateString
did: Did;
handle: Handle;
setupToken: string;
setupExpiresAt: ISODateString;
}
export interface CompletePasskeySetupResponse {
did: Did
handle: Handle
appPassword: string
appPasswordName: string
did: Did;
handle: Handle;
appPassword: string;
appPasswordName: string;
}
export interface VerifyTokenResponse {
success: boolean
did: Did
purpose: string
channel: VerificationChannel
success: boolean;
did: Did;
purpose: string;
channel: VerificationChannel;
}
export interface BackupInfo {
id: string
repoRev: string
repoRootCid: Cid
blockCount: number
sizeBytes: number
createdAt: ISODateString
id: string;
repoRev: string;
repoRootCid: Cid;
blockCount: number;
sizeBytes: number;
createdAt: ISODateString;
}
export interface ListBackupsResponse {
backups: BackupInfo[]
backupEnabled: boolean
backups: BackupInfo[];
backupEnabled: boolean;
}
export interface CreateBackupResponse {
id: string
repoRev: string
sizeBytes: number
blockCount: number
id: string;
repoRev: string;
sizeBytes: number;
blockCount: number;
}
export interface SetBackupEnabledResponse {
enabled: boolean
enabled: boolean;
}
export interface EmailUpdateResponse {
tokenRequired: boolean
tokenRequired: boolean;
}
export interface LegacyLoginPreference {
allowLegacyLogin: boolean
hasMfa: boolean
allowLegacyLogin: boolean;
hasMfa: boolean;
}
export interface UpdateLegacyLoginResponse {
allowLegacyLogin: boolean
allowLegacyLogin: boolean;
}
export interface UpdateLocaleResponse {
preferredLocale: string
preferredLocale: string;
}
export interface PasswordStatus {
hasPassword: boolean
hasPassword: boolean;
}
export interface SuccessResponse {
success: boolean
success: boolean;
}
export interface CheckEmailVerifiedResponse {
verified: boolean
verified: boolean;
}
export interface VerifyMigrationEmailResponse {
success: boolean
did: Did
success: boolean;
did: Did;
}
export interface ResendMigrationVerificationResponse {
sent: boolean
sent: boolean;
}
+80 -73
View File
@@ -1,188 +1,195 @@
declare const __brand: unique symbol
declare const __brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [__brand]: B }
type Brand<T, B extends string> = T & { readonly [__brand]: B };
export type Did = Brand<string, 'Did'>
export type DidPlc = Brand<Did, 'DidPlc'>
export type DidWeb = Brand<Did, 'DidWeb'>
export type Did = Brand<string, "Did">;
export type DidPlc = Brand<Did, "DidPlc">;
export type DidWeb = Brand<Did, "DidWeb">;
export type Handle = Brand<string, 'Handle'>
export type AccessToken = Brand<string, 'AccessToken'>
export type RefreshToken = Brand<string, 'RefreshToken'>
export type ServiceToken = Brand<string, 'ServiceToken'>
export type SetupToken = Brand<string, 'SetupToken'>
export type Handle = Brand<string, "Handle">;
export type AccessToken = Brand<string, "AccessToken">;
export type RefreshToken = Brand<string, "RefreshToken">;
export type ServiceToken = Brand<string, "ServiceToken">;
export type SetupToken = Brand<string, "SetupToken">;
export type Cid = Brand<string, 'Cid'>
export type Rkey = Brand<string, 'Rkey'>
export type AtUri = Brand<string, 'AtUri'>
export type Nsid = Brand<string, 'Nsid'>
export type Cid = Brand<string, "Cid">;
export type Rkey = Brand<string, "Rkey">;
export type AtUri = Brand<string, "AtUri">;
export type Nsid = Brand<string, "Nsid">;
export type ISODateString = Brand<string, 'ISODateString'>
export type EmailAddress = Brand<string, 'EmailAddress'>
export type InviteCode = Brand<string, 'InviteCode'>
export type ISODateString = Brand<string, "ISODateString">;
export type EmailAddress = Brand<string, "EmailAddress">;
export type InviteCode = Brand<string, "InviteCode">;
export type PublicKeyMultibase = Brand<string, 'PublicKeyMultibase'>
export type DidKeyString = Brand<string, 'DidKeyString'>
export type PublicKeyMultibase = Brand<string, "PublicKeyMultibase">;
export type DidKeyString = Brand<string, "DidKeyString">;
const DID_PLC_REGEX = /^did:plc:[a-z2-7]{24}$/
const DID_WEB_REGEX = /^did:web:.+$/
const HANDLE_REGEX = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
const AT_URI_REGEX = /^at:\/\/[^/]+\/[^/]+\/[^/]+$/
const CID_REGEX = /^[a-z2-7]{59}$|^baf[a-z2-7]+$/
const NSID_REGEX = /^[a-z]([a-z0-9-]*[a-z0-9])?(\.[a-z]([a-z0-9-]*[a-z0-9])?)+$/
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/
const DID_PLC_REGEX = /^did:plc:[a-z2-7]{24}$/;
const DID_WEB_REGEX = /^did:web:.+$/;
const HANDLE_REGEX =
/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
const AT_URI_REGEX = /^at:\/\/[^/]+\/[^/]+\/[^/]+$/;
const CID_REGEX = /^[a-z2-7]{59}$|^baf[a-z2-7]+$/;
const NSID_REGEX =
/^[a-z]([a-z0-9-]*[a-z0-9])?(\.[a-z]([a-z0-9-]*[a-z0-9])?)+$/;
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const ISO_DATE_REGEX =
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
export function isDid(s: string): s is Did {
return s.startsWith('did:plc:') || s.startsWith('did:web:')
return s.startsWith("did:plc:") || s.startsWith("did:web:");
}
export function isDidPlc(s: string): s is DidPlc {
return DID_PLC_REGEX.test(s)
return DID_PLC_REGEX.test(s);
}
export function isDidWeb(s: string): s is DidWeb {
return DID_WEB_REGEX.test(s)
return DID_WEB_REGEX.test(s);
}
export function isHandle(s: string): s is Handle {
return HANDLE_REGEX.test(s) && s.length <= 253
return HANDLE_REGEX.test(s) && s.length <= 253;
}
export function isAtUri(s: string): s is AtUri {
return AT_URI_REGEX.test(s)
return AT_URI_REGEX.test(s);
}
export function isCid(s: string): s is Cid {
return CID_REGEX.test(s)
return CID_REGEX.test(s);
}
export function isNsid(s: string): s is Nsid {
return NSID_REGEX.test(s)
return NSID_REGEX.test(s);
}
export function isEmail(s: string): s is EmailAddress {
return EMAIL_REGEX.test(s)
return EMAIL_REGEX.test(s);
}
export function isISODate(s: string): s is ISODateString {
return ISO_DATE_REGEX.test(s)
return ISO_DATE_REGEX.test(s);
}
export function asDid(s: string): Did {
if (!isDid(s)) throw new TypeError(`Invalid DID: ${s}`)
return s
if (!isDid(s)) throw new TypeError(`Invalid DID: ${s}`);
return s;
}
export function asDidPlc(s: string): DidPlc {
if (!isDidPlc(s)) throw new TypeError(`Invalid DID:PLC: ${s}`)
return s as DidPlc
if (!isDidPlc(s)) throw new TypeError(`Invalid DID:PLC: ${s}`);
return s as DidPlc;
}
export function asDidWeb(s: string): DidWeb {
if (!isDidWeb(s)) throw new TypeError(`Invalid DID:WEB: ${s}`)
return s as DidWeb
if (!isDidWeb(s)) throw new TypeError(`Invalid DID:WEB: ${s}`);
return s as DidWeb;
}
export function asHandle(s: string): Handle {
if (!isHandle(s)) throw new TypeError(`Invalid handle: ${s}`)
return s
if (!isHandle(s)) throw new TypeError(`Invalid handle: ${s}`);
return s;
}
export function asAtUri(s: string): AtUri {
if (!isAtUri(s)) throw new TypeError(`Invalid AT-URI: ${s}`)
return s
if (!isAtUri(s)) throw new TypeError(`Invalid AT-URI: ${s}`);
return s;
}
export function asCid(s: string): Cid {
if (!isCid(s)) throw new TypeError(`Invalid CID: ${s}`)
return s
if (!isCid(s)) throw new TypeError(`Invalid CID: ${s}`);
return s;
}
export function asNsid(s: string): Nsid {
if (!isNsid(s)) throw new TypeError(`Invalid NSID: ${s}`)
return s
if (!isNsid(s)) throw new TypeError(`Invalid NSID: ${s}`);
return s;
}
export function asEmail(s: string): EmailAddress {
if (!isEmail(s)) throw new TypeError(`Invalid email: ${s}`)
return s
if (!isEmail(s)) throw new TypeError(`Invalid email: ${s}`);
return s;
}
export function asISODate(s: string): ISODateString {
if (!isISODate(s)) throw new TypeError(`Invalid ISO date: ${s}`)
return s
if (!isISODate(s)) throw new TypeError(`Invalid ISO date: ${s}`);
return s;
}
export function unsafeAsDid(s: string): Did {
return s as Did
return s as Did;
}
export function unsafeAsHandle(s: string): Handle {
return s as Handle
return s as Handle;
}
export function unsafeAsAccessToken(s: string): AccessToken {
return s as AccessToken
return s as AccessToken;
}
export function unsafeAsRefreshToken(s: string): RefreshToken {
return s as RefreshToken
return s as RefreshToken;
}
export function unsafeAsServiceToken(s: string): ServiceToken {
return s as ServiceToken
return s as ServiceToken;
}
export function unsafeAsSetupToken(s: string): SetupToken {
return s as SetupToken
return s as SetupToken;
}
export function unsafeAsCid(s: string): Cid {
return s as Cid
return s as Cid;
}
export function unsafeAsRkey(s: string): Rkey {
return s as Rkey
return s as Rkey;
}
export function unsafeAsAtUri(s: string): AtUri {
return s as AtUri
return s as AtUri;
}
export function unsafeAsNsid(s: string): Nsid {
return s as Nsid
return s as Nsid;
}
export function unsafeAsISODate(s: string): ISODateString {
return s as ISODateString
return s as ISODateString;
}
export const unsafeAsISODateString = unsafeAsISODate;
export function unsafeAsEmail(s: string): EmailAddress {
return s as EmailAddress
return s as EmailAddress;
}
export function unsafeAsInviteCode(s: string): InviteCode {
return s as InviteCode
return s as InviteCode;
}
export function unsafeAsPublicKeyMultibase(s: string): PublicKeyMultibase {
return s as PublicKeyMultibase
return s as PublicKeyMultibase;
}
export function unsafeAsDidKey(s: string): DidKeyString {
return s as DidKeyString
return s as DidKeyString;
}
export function parseAtUri(uri: AtUri): { repo: Did; collection: Nsid; rkey: Rkey } {
const parts = uri.replace('at://', '').split('/')
export function parseAtUri(
uri: AtUri,
): { repo: Did; collection: Nsid; rkey: Rkey } {
const parts = uri.replace("at://", "").split("/");
return {
repo: unsafeAsDid(parts[0]),
collection: unsafeAsNsid(parts[1]),
rkey: unsafeAsRkey(parts[2]),
}
};
}
export function makeAtUri(repo: Did, collection: Nsid, rkey: Rkey): AtUri {
return `at://${repo}/${collection}/${rkey}` as AtUri
return `at://${repo}/${collection}/${rkey}` as AtUri;
}
+17 -17
View File
@@ -1,49 +1,49 @@
export function assertNever(x: never, message?: string): never {
throw new Error(message ?? `Unexpected value: ${JSON.stringify(x)}`)
throw new Error(message ?? `Unexpected value: ${JSON.stringify(x)}`);
}
export function exhaustive<T extends string | number | symbol>(
value: T,
handlers: Record<T, () => void>
handlers: Record<T, () => void>,
): void {
const handler = handlers[value]
const handler = handlers[value];
if (handler) {
handler()
handler();
} else {
assertNever(value as never, `Unhandled case: ${String(value)}`)
assertNever(value as never, `Unhandled case: ${String(value)}`);
}
}
export function exhaustiveMap<T extends string | number | symbol, R>(
value: T,
handlers: Record<T, () => R>
handlers: Record<T, () => R>,
): R {
const handler = handlers[value]
const handler = handlers[value];
if (handler) {
return handler()
return handler();
}
return assertNever(value as never, `Unhandled case: ${String(value)}`)
return assertNever(value as never, `Unhandled case: ${String(value)}`);
}
export async function exhaustiveAsync<T extends string | number | symbol>(
value: T,
handlers: Record<T, () => Promise<void>>
handlers: Record<T, () => Promise<void>>,
): Promise<void> {
const handler = handlers[value]
const handler = handlers[value];
if (handler) {
await handler()
await handler();
} else {
assertNever(value as never, `Unhandled case: ${String(value)}`)
assertNever(value as never, `Unhandled case: ${String(value)}`);
}
}
export async function exhaustiveMapAsync<T extends string | number | symbol, R>(
value: T,
handlers: Record<T, () => Promise<R>>
handlers: Record<T, () => Promise<R>>,
): Promise<R> {
const handler = handlers[value]
const handler = handlers[value];
if (handler) {
return handler()
return handler();
}
return assertNever(value as never, `Unhandled case: ${String(value)}`)
return assertNever(value as never, `Unhandled case: ${String(value)}`);
}
+5 -5
View File
@@ -1,5 +1,5 @@
export * from './result'
export * from './branded'
export * from './exhaustive'
export * from './api'
export * from './routes'
export * from "./result.ts";
export * from "./branded.ts";
export * from "./exhaustive.ts";
export * from "./api.ts";
export * from "./routes.ts";
+51 -34
View File
@@ -1,94 +1,111 @@
export type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E }
| { ok: false; error: E };
export function ok<T>(value: T): Result<T, never> {
return { ok: true, value }
return { ok: true, value };
}
export function err<E>(error: E): Result<never, E> {
return { ok: false, error }
return { ok: false, error };
}
export function isOk<T, E>(result: Result<T, E>): result is { ok: true; value: T } {
return result.ok
export function isOk<T, E>(
result: Result<T, E>,
): result is { ok: true; value: T } {
return result.ok;
}
export function isErr<T, E>(result: Result<T, E>): result is { ok: false; error: E } {
return !result.ok
export function isErr<T, E>(
result: Result<T, E>,
): result is { ok: false; error: E } {
return !result.ok;
}
export function map<T, U, E>(result: Result<T, E>, fn: (t: T) => U): Result<U, E> {
return result.ok ? ok(fn(result.value)) : result
export function map<T, U, E>(
result: Result<T, E>,
fn: (t: T) => U,
): Result<U, E> {
return result.ok ? ok(fn(result.value)) : result;
}
export function mapErr<T, E, F>(result: Result<T, E>, fn: (e: E) => F): Result<T, F> {
return result.ok ? result : err(fn(result.error))
export function mapErr<T, E, F>(
result: Result<T, E>,
fn: (e: E) => F,
): Result<T, F> {
return result.ok ? result : err(fn(result.error));
}
export function flatMap<T, U, E>(result: Result<T, E>, fn: (t: T) => Result<U, E>): Result<U, E> {
return result.ok ? fn(result.value) : result
export function flatMap<T, U, E>(
result: Result<T, E>,
fn: (t: T) => Result<U, E>,
): Result<U, E> {
return result.ok ? fn(result.value) : result;
}
export function unwrap<T, E>(result: Result<T, E>): T {
if (result.ok) return result.value
throw result.error instanceof Error ? result.error : new Error(String(result.error))
if (result.ok) return result.value;
throw result.error instanceof Error
? result.error
: new Error(String(result.error));
}
export function unwrapOr<T, E>(result: Result<T, E>, defaultValue: T): T {
return result.ok ? result.value : defaultValue
return result.ok ? result.value : defaultValue;
}
export function unwrapOrElse<T, E>(result: Result<T, E>, fn: (e: E) => T): T {
return result.ok ? result.value : fn(result.error)
return result.ok ? result.value : fn(result.error);
}
export function match<T, E, U>(
result: Result<T, E>,
handlers: { ok: (t: T) => U; err: (e: E) => U }
handlers: { ok: (t: T) => U; err: (e: E) => U },
): U {
return result.ok ? handlers.ok(result.value) : handlers.err(result.error)
return result.ok ? handlers.ok(result.value) : handlers.err(result.error);
}
export async function tryAsync<T>(fn: () => Promise<T>): Promise<Result<T, Error>> {
export async function tryAsync<T>(
fn: () => Promise<T>,
): Promise<Result<T, Error>> {
try {
return ok(await fn())
return ok(await fn());
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)))
return err(e instanceof Error ? e : new Error(String(e)));
}
}
export async function tryAsyncWith<T, E>(
fn: () => Promise<T>,
mapError: (e: unknown) => E
mapError: (e: unknown) => E,
): Promise<Result<T, E>> {
try {
return ok(await fn())
return ok(await fn());
} catch (e) {
return err(mapError(e))
return err(mapError(e));
}
}
export function fromNullable<T>(value: T | null | undefined): Result<T, null> {
return value != null ? ok(value) : err(null)
return value != null ? ok(value) : err(null);
}
export function toNullable<T, E>(result: Result<T, E>): T | null {
return result.ok ? result.value : null
return result.ok ? result.value : null;
}
export function collect<T, E>(results: Result<T, E>[]): Result<T[], E> {
const values: T[] = []
const values: T[] = [];
for (const result of results) {
if (!result.ok) return result
values.push(result.value)
if (!result.ok) return result;
values.push(result.value);
}
return ok(values)
return ok(values);
}
export async function collectAsync<T, E>(
results: Promise<Result<T, E>>[]
results: Promise<Result<T, E>>[],
): Promise<Result<T[], E>> {
const settled = await Promise.all(results)
return collect(settled)
const settled = await Promise.all(results);
return collect(settled);
}
+58 -58
View File
@@ -1,83 +1,83 @@
export const routes = {
login: '/login',
register: '/register',
registerPasskey: '/register-passkey',
dashboard: '/dashboard',
settings: '/settings',
security: '/security',
sessions: '/sessions',
appPasswords: '/app-passwords',
trustedDevices: '/trusted-devices',
inviteCodes: '/invite-codes',
comms: '/comms',
repo: '/repo',
controllers: '/controllers',
delegationAudit: '/delegation-audit',
actAs: '/act-as',
didDocument: '/did-document',
migrate: '/migrate',
admin: '/admin',
verify: '/verify',
resetPassword: '/reset-password',
recoverPasskey: '/recover-passkey',
requestPasskeyRecovery: '/request-passkey-recovery',
oauthLogin: '/oauth/login',
oauthConsent: '/oauth/consent',
oauthAccounts: '/oauth/accounts',
oauth2fa: '/oauth/2fa',
oauthTotp: '/oauth/totp',
oauthPasskey: '/oauth/passkey',
oauthDelegation: '/oauth/delegation',
oauthError: '/oauth/error',
} as const
login: "/login",
register: "/register",
registerPasskey: "/register-passkey",
dashboard: "/dashboard",
settings: "/settings",
security: "/security",
sessions: "/sessions",
appPasswords: "/app-passwords",
trustedDevices: "/trusted-devices",
inviteCodes: "/invite-codes",
comms: "/comms",
repo: "/repo",
controllers: "/controllers",
delegationAudit: "/delegation-audit",
actAs: "/act-as",
didDocument: "/did-document",
migrate: "/migrate",
admin: "/admin",
verify: "/verify",
resetPassword: "/reset-password",
recoverPasskey: "/recover-passkey",
requestPasskeyRecovery: "/request-passkey-recovery",
oauthLogin: "/oauth/login",
oauthConsent: "/oauth/consent",
oauthAccounts: "/oauth/accounts",
oauth2fa: "/oauth/2fa",
oauthTotp: "/oauth/totp",
oauthPasskey: "/oauth/passkey",
oauthDelegation: "/oauth/delegation",
oauthError: "/oauth/error",
} as const;
export type Route = (typeof routes)[keyof typeof routes]
export type Route = (typeof routes)[keyof typeof routes];
export type RouteKey = keyof typeof routes
export type RouteKey = keyof typeof routes;
export function isValidRoute(path: string): path is Route {
return Object.values(routes).includes(path as Route)
return Object.values(routes).includes(path as Route);
}
export interface RouteParams {
[routes.verify]: { token?: string; email?: string }
[routes.resetPassword]: { token?: string }
[routes.recoverPasskey]: { token?: string; did?: string }
[routes.oauthLogin]: { request_uri?: string; error?: string }
[routes.oauthConsent]: { request_uri?: string; client_id?: string }
[routes.oauthAccounts]: { request_uri?: string }
[routes.oauth2fa]: { request_uri?: string; channel?: string }
[routes.oauthTotp]: { request_uri?: string }
[routes.oauthPasskey]: { request_uri?: string }
[routes.oauthDelegation]: { request_uri?: string; delegated_did?: string }
[routes.oauthError]: { error?: string; error_description?: string }
[routes.migrate]: { code?: string; state?: string }
[routes.verify]: { token?: string; email?: string };
[routes.resetPassword]: { token?: string };
[routes.recoverPasskey]: { token?: string; did?: string };
[routes.oauthLogin]: { request_uri?: string; error?: string };
[routes.oauthConsent]: { request_uri?: string; client_id?: string };
[routes.oauthAccounts]: { request_uri?: string };
[routes.oauth2fa]: { request_uri?: string; channel?: string };
[routes.oauthTotp]: { request_uri?: string };
[routes.oauthPasskey]: { request_uri?: string };
[routes.oauthDelegation]: { request_uri?: string; delegated_did?: string };
[routes.oauthError]: { error?: string; error_description?: string };
[routes.migrate]: { code?: string; state?: string };
}
export type RoutesWithParams = keyof RouteParams
export type RoutesWithParams = keyof RouteParams;
export function buildUrl<R extends Route>(
route: R,
params?: R extends RoutesWithParams ? RouteParams[R] : never
params?: R extends RoutesWithParams ? RouteParams[R] : never,
): string {
if (!params) return route
const searchParams = new URLSearchParams()
if (!params) return route;
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value != null) {
searchParams.set(key, String(value))
searchParams.set(key, String(value));
}
}
const queryString = searchParams.toString()
return queryString ? `${route}?${queryString}` : route
const queryString = searchParams.toString();
return queryString ? `${route}?${queryString}` : route;
}
export function parseRouteParams<R extends RoutesWithParams>(
route: R
_route: R,
): RouteParams[R] {
const params = new URLSearchParams(globalThis.location.search)
const result: Record<string, string> = {}
const params = new URLSearchParams(globalThis.location.search);
const result: Record<string, string> = {};
for (const [key, value] of params.entries()) {
result[key] = value
result[key] = value;
}
return result as RouteParams[R]
return result as RouteParams[R];
}
+135 -110
View File
@@ -1,51 +1,50 @@
import { z } from 'zod'
import type {
Did,
Handle,
AccessToken,
RefreshToken,
Cid,
Nsid,
AtUri,
Rkey,
ISODateString,
EmailAddress,
InviteCode,
PublicKeyMultibase,
} from './branded'
import { z } from "zod";
import {
unsafeAsDid,
unsafeAsHandle,
unsafeAsAccessToken,
unsafeAsRefreshToken,
unsafeAsCid,
unsafeAsNsid,
unsafeAsAtUri,
unsafeAsRkey,
unsafeAsISODate,
unsafeAsCid,
unsafeAsDid,
unsafeAsEmail,
unsafeAsHandle,
unsafeAsInviteCode,
unsafeAsISODate,
unsafeAsNsid,
unsafeAsPublicKeyMultibase,
} from './branded'
unsafeAsRefreshToken,
unsafeAsRkey,
} from "./branded.ts";
const did = z.string().transform((s) => unsafeAsDid(s))
const handle = z.string().transform((s) => unsafeAsHandle(s))
const accessToken = z.string().transform((s) => unsafeAsAccessToken(s))
const refreshToken = z.string().transform((s) => unsafeAsRefreshToken(s))
const cid = z.string().transform((s) => unsafeAsCid(s))
const nsid = z.string().transform((s) => unsafeAsNsid(s))
const atUri = z.string().transform((s) => unsafeAsAtUri(s))
const rkey = z.string().transform((s) => unsafeAsRkey(s))
const isoDate = z.string().transform((s) => unsafeAsISODate(s))
const email = z.string().transform((s) => unsafeAsEmail(s))
const inviteCode = z.string().transform((s) => unsafeAsInviteCode(s))
const publicKeyMultibase = z.string().transform((s) => unsafeAsPublicKeyMultibase(s))
const did = z.string().transform((s) => unsafeAsDid(s));
const handle = z.string().transform((s) => unsafeAsHandle(s));
const accessToken = z.string().transform((s) => unsafeAsAccessToken(s));
const refreshToken = z.string().transform((s) => unsafeAsRefreshToken(s));
const cid = z.string().transform((s) => unsafeAsCid(s));
const nsid = z.string().transform((s) => unsafeAsNsid(s));
const atUri = z.string().transform((s) => unsafeAsAtUri(s));
const _rkey = z.string().transform((s) => unsafeAsRkey(s));
const isoDate = z.string().transform((s) => unsafeAsISODate(s));
const email = z.string().transform((s) => unsafeAsEmail(s));
const inviteCode = z.string().transform((s) => unsafeAsInviteCode(s));
const publicKeyMultibase = z.string().transform((s) =>
unsafeAsPublicKeyMultibase(s)
);
export const verificationChannel = z.enum(['email', 'discord', 'telegram', 'signal'])
export const didType = z.enum(['plc', 'web', 'web-external'])
export const accountStatus = z.enum(['active', 'deactivated', 'migrated', 'suspended', 'deleted'])
export const sessionType = z.enum(['oauth', 'legacy', 'app_password'])
export const reauthMethod = z.enum(['password', 'totp', 'passkey'])
export const verificationChannel = z.enum([
"email",
"discord",
"telegram",
"signal",
]);
export const didType = z.enum(["plc", "web", "web-external"]);
export const accountStatus = z.enum([
"active",
"deactivated",
"migrated",
"suspended",
"deleted",
]);
export const sessionType = z.enum(["oauth", "legacy", "app_password"]);
export const reauthMethod = z.enum(["password", "totp", "passkey"]);
export const sessionSchema = z.object({
did: did,
@@ -61,12 +60,12 @@ export const sessionSchema = z.object({
migratedAt: isoDate.optional(),
accessJwt: accessToken,
refreshJwt: refreshToken,
})
});
export const serverLinksSchema = z.object({
privacyPolicy: z.string().optional(),
termsOfService: z.string().optional(),
})
});
export const serverDescriptionSchema = z.object({
availableUserDomains: z.array(z.string()),
@@ -75,27 +74,27 @@ export const serverDescriptionSchema = z.object({
version: z.string().optional(),
availableCommsChannels: z.array(verificationChannel).optional(),
selfHostedDidWebEnabled: z.boolean().optional(),
})
});
export const appPasswordSchema = z.object({
name: z.string(),
createdAt: isoDate,
scopes: z.string().optional(),
createdByController: z.string().optional(),
})
});
export const createdAppPasswordSchema = z.object({
name: z.string(),
password: z.string(),
createdAt: isoDate,
scopes: z.string().optional(),
})
});
export const inviteCodeUseSchema = z.object({
usedBy: did,
usedByHandle: handle.optional(),
usedAt: isoDate,
})
});
export const inviteCodeInfoSchema = z.object({
code: inviteCode,
@@ -105,7 +104,7 @@ export const inviteCodeInfoSchema = z.object({
createdBy: did,
createdAt: isoDate,
uses: z.array(inviteCodeUseSchema),
})
});
export const sessionInfoSchema = z.object({
id: z.string(),
@@ -114,26 +113,26 @@ export const sessionInfoSchema = z.object({
createdAt: isoDate,
expiresAt: isoDate,
isCurrent: z.boolean(),
})
});
export const listSessionsResponseSchema = z.object({
sessions: z.array(sessionInfoSchema),
})
});
export const totpStatusSchema = z.object({
enabled: z.boolean(),
hasBackupCodes: z.boolean(),
})
});
export const totpSecretSchema = z.object({
uri: z.string(),
qrBase64: z.string(),
})
});
export const enableTotpResponseSchema = z.object({
success: z.boolean(),
backupCodes: z.array(z.string()),
})
});
export const passkeyInfoSchema = z.object({
id: z.string(),
@@ -141,11 +140,11 @@ export const passkeyInfoSchema = z.object({
friendlyName: z.string().nullable(),
createdAt: isoDate,
lastUsed: isoDate.nullable(),
})
});
export const listPasskeysResponseSchema = z.object({
passkeys: z.array(passkeyInfoSchema),
})
});
export const trustedDeviceSchema = z.object({
id: z.string(),
@@ -154,22 +153,22 @@ export const trustedDeviceSchema = z.object({
trustedAt: isoDate.nullable(),
trustedUntil: isoDate.nullable(),
lastSeenAt: isoDate,
})
});
export const listTrustedDevicesResponseSchema = z.object({
devices: z.array(trustedDeviceSchema),
})
});
export const reauthStatusSchema = z.object({
requiresReauth: z.boolean(),
lastReauthAt: isoDate.nullable(),
availableMethods: z.array(reauthMethod),
})
});
export const reauthResponseSchema = z.object({
success: z.boolean(),
reauthAt: isoDate,
})
});
export const notificationPrefsSchema = z.object({
preferredChannel: verificationChannel,
@@ -180,28 +179,28 @@ export const notificationPrefsSchema = z.object({
telegramVerified: z.boolean(),
signalNumber: z.string().nullable(),
signalVerified: z.boolean(),
})
});
export const verificationMethodSchema = z.object({
id: z.string(),
type: z.string(),
controller: z.string(),
publicKeyMultibase: publicKeyMultibase,
})
});
export const serviceEndpointSchema = z.object({
id: z.string(),
type: z.string(),
serviceEndpoint: z.string(),
})
});
export const didDocumentSchema = z.object({
'@context': z.array(z.string()),
"@context": z.array(z.string()),
id: did,
alsoKnownAs: z.array(z.string()),
verificationMethod: z.array(verificationMethodSchema),
service: z.array(serviceEndpointSchema),
})
});
export const repoDescriptionSchema = z.object({
handle: handle,
@@ -209,36 +208,36 @@ export const repoDescriptionSchema = z.object({
didDoc: didDocumentSchema,
collections: z.array(nsid),
handleIsCorrect: z.boolean(),
})
});
export const recordInfoSchema = z.object({
uri: atUri,
cid: cid,
value: z.unknown(),
})
});
export const listRecordsResponseSchema = z.object({
records: z.array(recordInfoSchema),
cursor: z.string().optional(),
})
});
export const recordResponseSchema = z.object({
uri: atUri,
cid: cid,
value: z.unknown(),
})
});
export const createRecordResponseSchema = z.object({
uri: atUri,
cid: cid,
})
});
export const serverStatsSchema = z.object({
userCount: z.number(),
repoCount: z.number(),
recordCount: z.number(),
blobStorageBytes: z.number(),
})
});
export const serverConfigSchema = z.object({
serverName: z.string(),
@@ -247,20 +246,20 @@ export const serverConfigSchema = z.object({
secondaryColor: z.string().nullable(),
secondaryColorDark: z.string().nullable(),
logoCid: cid.nullable(),
})
});
export const passwordStatusSchema = z.object({
hasPassword: z.boolean(),
})
});
export const successResponseSchema = z.object({
success: z.boolean(),
})
});
export const legacyLoginPreferenceSchema = z.object({
allowLegacyLogin: z.boolean(),
hasMfa: z.boolean(),
})
});
export const accountInfoSchema = z.object({
did: did,
@@ -270,12 +269,12 @@ export const accountInfoSchema = z.object({
emailConfirmedAt: isoDate.optional(),
invitesDisabled: z.boolean().optional(),
deactivatedAt: isoDate.optional(),
})
});
export const searchAccountsResponseSchema = z.object({
cursor: z.string().optional(),
accounts: z.array(accountInfoSchema),
})
});
export const backupInfoSchema = z.object({
id: z.string(),
@@ -284,49 +283,75 @@ export const backupInfoSchema = z.object({
blockCount: z.number(),
sizeBytes: z.number(),
createdAt: isoDate,
})
});
export const listBackupsResponseSchema = z.object({
backups: z.array(backupInfoSchema),
backupEnabled: z.boolean(),
})
});
export const createBackupResponseSchema = z.object({
id: z.string(),
repoRev: z.string(),
sizeBytes: z.number(),
blockCount: z.number(),
})
});
export type ValidatedSession = z.infer<typeof sessionSchema>
export type ValidatedServerDescription = z.infer<typeof serverDescriptionSchema>
export type ValidatedAppPassword = z.infer<typeof appPasswordSchema>
export type ValidatedCreatedAppPassword = z.infer<typeof createdAppPasswordSchema>
export type ValidatedInviteCodeInfo = z.infer<typeof inviteCodeInfoSchema>
export type ValidatedSessionInfo = z.infer<typeof sessionInfoSchema>
export type ValidatedListSessionsResponse = z.infer<typeof listSessionsResponseSchema>
export type ValidatedTotpStatus = z.infer<typeof totpStatusSchema>
export type ValidatedTotpSecret = z.infer<typeof totpSecretSchema>
export type ValidatedEnableTotpResponse = z.infer<typeof enableTotpResponseSchema>
export type ValidatedPasskeyInfo = z.infer<typeof passkeyInfoSchema>
export type ValidatedListPasskeysResponse = z.infer<typeof listPasskeysResponseSchema>
export type ValidatedTrustedDevice = z.infer<typeof trustedDeviceSchema>
export type ValidatedListTrustedDevicesResponse = z.infer<typeof listTrustedDevicesResponseSchema>
export type ValidatedReauthStatus = z.infer<typeof reauthStatusSchema>
export type ValidatedReauthResponse = z.infer<typeof reauthResponseSchema>
export type ValidatedNotificationPrefs = z.infer<typeof notificationPrefsSchema>
export type ValidatedDidDocument = z.infer<typeof didDocumentSchema>
export type ValidatedRepoDescription = z.infer<typeof repoDescriptionSchema>
export type ValidatedListRecordsResponse = z.infer<typeof listRecordsResponseSchema>
export type ValidatedRecordResponse = z.infer<typeof recordResponseSchema>
export type ValidatedCreateRecordResponse = z.infer<typeof createRecordResponseSchema>
export type ValidatedServerStats = z.infer<typeof serverStatsSchema>
export type ValidatedServerConfig = z.infer<typeof serverConfigSchema>
export type ValidatedPasswordStatus = z.infer<typeof passwordStatusSchema>
export type ValidatedSuccessResponse = z.infer<typeof successResponseSchema>
export type ValidatedLegacyLoginPreference = z.infer<typeof legacyLoginPreferenceSchema>
export type ValidatedAccountInfo = z.infer<typeof accountInfoSchema>
export type ValidatedSearchAccountsResponse = z.infer<typeof searchAccountsResponseSchema>
export type ValidatedBackupInfo = z.infer<typeof backupInfoSchema>
export type ValidatedListBackupsResponse = z.infer<typeof listBackupsResponseSchema>
export type ValidatedCreateBackupResponse = z.infer<typeof createBackupResponseSchema>
export type ValidatedSession = z.infer<typeof sessionSchema>;
export type ValidatedServerDescription = z.infer<
typeof serverDescriptionSchema
>;
export type ValidatedAppPassword = z.infer<typeof appPasswordSchema>;
export type ValidatedCreatedAppPassword = z.infer<
typeof createdAppPasswordSchema
>;
export type ValidatedInviteCodeInfo = z.infer<typeof inviteCodeInfoSchema>;
export type ValidatedSessionInfo = z.infer<typeof sessionInfoSchema>;
export type ValidatedListSessionsResponse = z.infer<
typeof listSessionsResponseSchema
>;
export type ValidatedTotpStatus = z.infer<typeof totpStatusSchema>;
export type ValidatedTotpSecret = z.infer<typeof totpSecretSchema>;
export type ValidatedEnableTotpResponse = z.infer<
typeof enableTotpResponseSchema
>;
export type ValidatedPasskeyInfo = z.infer<typeof passkeyInfoSchema>;
export type ValidatedListPasskeysResponse = z.infer<
typeof listPasskeysResponseSchema
>;
export type ValidatedTrustedDevice = z.infer<typeof trustedDeviceSchema>;
export type ValidatedListTrustedDevicesResponse = z.infer<
typeof listTrustedDevicesResponseSchema
>;
export type ValidatedReauthStatus = z.infer<typeof reauthStatusSchema>;
export type ValidatedReauthResponse = z.infer<typeof reauthResponseSchema>;
export type ValidatedNotificationPrefs = z.infer<
typeof notificationPrefsSchema
>;
export type ValidatedDidDocument = z.infer<typeof didDocumentSchema>;
export type ValidatedRepoDescription = z.infer<typeof repoDescriptionSchema>;
export type ValidatedListRecordsResponse = z.infer<
typeof listRecordsResponseSchema
>;
export type ValidatedRecordResponse = z.infer<typeof recordResponseSchema>;
export type ValidatedCreateRecordResponse = z.infer<
typeof createRecordResponseSchema
>;
export type ValidatedServerStats = z.infer<typeof serverStatsSchema>;
export type ValidatedServerConfig = z.infer<typeof serverConfigSchema>;
export type ValidatedPasswordStatus = z.infer<typeof passwordStatusSchema>;
export type ValidatedSuccessResponse = z.infer<typeof successResponseSchema>;
export type ValidatedLegacyLoginPreference = z.infer<
typeof legacyLoginPreferenceSchema
>;
export type ValidatedAccountInfo = z.infer<typeof accountInfoSchema>;
export type ValidatedSearchAccountsResponse = z.infer<
typeof searchAccountsResponseSchema
>;
export type ValidatedBackupInfo = z.infer<typeof backupInfoSchema>;
export type ValidatedListBackupsResponse = z.infer<
typeof listBackupsResponseSchema
>;
export type ValidatedCreateBackupResponse = z.infer<
typeof createBackupResponseSchema
>;
+99 -84
View File
@@ -1,190 +1,205 @@
import type { Option } from './option'
import type { Option } from "./option.ts";
export function first<T>(arr: readonly T[]): Option<T> {
return arr[0] ?? null
return arr[0] ?? null;
}
export function last<T>(arr: readonly T[]): Option<T> {
return arr[arr.length - 1] ?? null
return arr[arr.length - 1] ?? null;
}
export function at<T>(arr: readonly T[], index: number): Option<T> {
if (index < 0) index = arr.length + index
return arr[index] ?? null
if (index < 0) index = arr.length + index;
return arr[index] ?? null;
}
export function find<T>(arr: readonly T[], predicate: (t: T) => boolean): Option<T> {
return arr.find(predicate) ?? null
export function find<T>(
arr: readonly T[],
predicate: (t: T) => boolean,
): Option<T> {
return arr.find(predicate) ?? null;
}
export function findMap<T, U>(arr: readonly T[], fn: (t: T) => Option<U>): Option<U> {
export function findMap<T, U>(
arr: readonly T[],
fn: (t: T) => Option<U>,
): Option<U> {
for (const item of arr) {
const result = fn(item)
if (result != null) return result
const result = fn(item);
if (result != null) return result;
}
return null
return null;
}
export function findIndex<T>(arr: readonly T[], predicate: (t: T) => boolean): Option<number> {
const index = arr.findIndex(predicate)
return index >= 0 ? index : null
export function findIndex<T>(
arr: readonly T[],
predicate: (t: T) => boolean,
): Option<number> {
const index = arr.findIndex(predicate);
return index >= 0 ? index : null;
}
export function partition<T>(
arr: readonly T[],
predicate: (t: T) => boolean
predicate: (t: T) => boolean,
): [T[], T[]] {
const pass: T[] = []
const fail: T[] = []
const pass: T[] = [];
const fail: T[] = [];
for (const item of arr) {
if (predicate(item)) {
pass.push(item)
pass.push(item);
} else {
fail.push(item)
fail.push(item);
}
}
return [pass, fail]
return [pass, fail];
}
export function groupBy<T, K extends string | number>(
arr: readonly T[],
keyFn: (t: T) => K
keyFn: (t: T) => K,
): Record<K, T[]> {
const result = {} as Record<K, T[]>
const result = {} as Record<K, T[]>;
for (const item of arr) {
const key = keyFn(item)
const key = keyFn(item);
if (!result[key]) {
result[key] = []
result[key] = [];
}
result[key].push(item)
result[key].push(item);
}
return result
return result;
}
export function unique<T>(arr: readonly T[]): T[] {
return [...new Set(arr)]
return [...new Set(arr)];
}
export function uniqueBy<T, K>(arr: readonly T[], keyFn: (t: T) => K): T[] {
const seen = new Set<K>()
const result: T[] = []
const seen = new Set<K>();
const result: T[] = [];
for (const item of arr) {
const key = keyFn(item)
const key = keyFn(item);
if (!seen.has(key)) {
seen.add(key)
result.push(item)
seen.add(key);
result.push(item);
}
}
return result
return result;
}
export function sortBy<T>(arr: readonly T[], keyFn: (t: T) => number | string): T[] {
export function sortBy<T>(
arr: readonly T[],
keyFn: (t: T) => number | string,
): T[] {
return [...arr].sort((a, b) => {
const ka = keyFn(a)
const kb = keyFn(b)
if (ka < kb) return -1
if (ka > kb) return 1
return 0
})
const ka = keyFn(a);
const kb = keyFn(b);
if (ka < kb) return -1;
if (ka > kb) return 1;
return 0;
});
}
export function sortByDesc<T>(arr: readonly T[], keyFn: (t: T) => number | string): T[] {
export function sortByDesc<T>(
arr: readonly T[],
keyFn: (t: T) => number | string,
): T[] {
return [...arr].sort((a, b) => {
const ka = keyFn(a)
const kb = keyFn(b)
if (ka > kb) return -1
if (ka < kb) return 1
return 0
})
const ka = keyFn(a);
const kb = keyFn(b);
if (ka > kb) return -1;
if (ka < kb) return 1;
return 0;
});
}
export function chunk<T>(arr: readonly T[], size: number): T[][] {
const result: T[][] = []
const result: T[][] = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size))
result.push(arr.slice(i, i + size));
}
return result
return result;
}
export function zip<T, U>(a: readonly T[], b: readonly U[]): [T, U][] {
const length = Math.min(a.length, b.length)
const result: [T, U][] = []
const length = Math.min(a.length, b.length);
const result: [T, U][] = [];
for (let i = 0; i < length; i++) {
result.push([a[i], b[i]])
result.push([a[i], b[i]]);
}
return result
return result;
}
export function zipWith<T, U, R>(
a: readonly T[],
b: readonly U[],
fn: (t: T, u: U) => R
fn: (t: T, u: U) => R,
): R[] {
const length = Math.min(a.length, b.length)
const result: R[] = []
const length = Math.min(a.length, b.length);
const result: R[] = [];
for (let i = 0; i < length; i++) {
result.push(fn(a[i], b[i]))
result.push(fn(a[i], b[i]));
}
return result
return result;
}
export function intersperse<T>(arr: readonly T[], separator: T): T[] {
if (arr.length <= 1) return [...arr]
const result: T[] = [arr[0]]
if (arr.length <= 1) return [...arr];
const result: T[] = [arr[0]];
for (let i = 1; i < arr.length; i++) {
result.push(separator, arr[i])
result.push(separator, arr[i]);
}
return result
return result;
}
export function range(start: number, end: number): number[] {
const result: number[] = []
const result: number[] = [];
for (let i = start; i < end; i++) {
result.push(i)
result.push(i);
}
return result
return result;
}
export function isEmpty<T>(arr: readonly T[]): boolean {
return arr.length === 0
return arr.length === 0;
}
export function isNonEmpty<T>(arr: readonly T[]): arr is [T, ...T[]] {
return arr.length > 0
return arr.length > 0;
}
export function sum(arr: readonly number[]): number {
return arr.reduce((acc, n) => acc + n, 0)
return arr.reduce((acc, n) => acc + n, 0);
}
export function sumBy<T>(arr: readonly T[], fn: (t: T) => number): number {
return arr.reduce((acc, t) => acc + fn(t), 0)
return arr.reduce((acc, t) => acc + fn(t), 0);
}
export function maxBy<T>(arr: readonly T[], fn: (t: T) => number): Option<T> {
if (arr.length === 0) return null
let max = arr[0]
let maxValue = fn(max)
if (arr.length === 0) return null;
let max = arr[0];
let maxValue = fn(max);
for (let i = 1; i < arr.length; i++) {
const value = fn(arr[i])
const value = fn(arr[i]);
if (value > maxValue) {
max = arr[i]
maxValue = value
max = arr[i];
maxValue = value;
}
}
return max
return max;
}
export function minBy<T>(arr: readonly T[], fn: (t: T) => number): Option<T> {
if (arr.length === 0) return null
let min = arr[0]
let minValue = fn(min)
if (arr.length === 0) return null;
let min = arr[0];
let minValue = fn(min);
for (let i = 1; i < arr.length; i++) {
const value = fn(arr[i])
const value = fn(arr[i]);
if (value < minValue) {
min = arr[i]
minValue = value
min = arr[i];
minValue = value;
}
}
return min
return min;
}
+103 -104
View File
@@ -1,246 +1,245 @@
import { ok, err, type Result } from '../types/result'
import { err, type Result } from "../types/result.ts";
export function debounce<T extends (...args: Parameters<T>) => void>(
fn: T,
ms: number
ms: number,
): T & { cancel: () => void } {
let timeoutId: ReturnType<typeof setTimeout> | null = null
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const debounced = ((...args: Parameters<T>) => {
if (timeoutId) clearTimeout(timeoutId)
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
fn(...args)
timeoutId = null
}, ms)
}) as T & { cancel: () => void }
fn(...args);
timeoutId = null;
}, ms);
}) as T & { cancel: () => void };
debounced.cancel = () => {
if (timeoutId) {
clearTimeout(timeoutId)
timeoutId = null
clearTimeout(timeoutId);
timeoutId = null;
}
}
};
return debounced
return debounced;
}
export function throttle<T extends (...args: Parameters<T>) => void>(
fn: T,
ms: number
ms: number,
): T {
let lastCall = 0
let timeoutId: ReturnType<typeof setTimeout> | null = null
let lastCall = 0;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return ((...args: Parameters<T>) => {
const now = Date.now()
const remaining = ms - (now - lastCall)
const now = Date.now();
const remaining = ms - (now - lastCall);
if (remaining <= 0) {
if (timeoutId) {
clearTimeout(timeoutId)
timeoutId = null
clearTimeout(timeoutId);
timeoutId = null;
}
lastCall = now
fn(...args)
lastCall = now;
fn(...args);
} else if (!timeoutId) {
timeoutId = setTimeout(() => {
lastCall = Date.now()
timeoutId = null
fn(...args)
}, remaining)
lastCall = Date.now();
timeoutId = null;
fn(...args);
}, remaining);
}
}) as T
}) as T;
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function retry<T>(
fn: () => Promise<T>,
options: {
attempts?: number
delay?: number
backoff?: number
shouldRetry?: (error: unknown, attempt: number) => boolean
} = {}
attempts?: number;
delay?: number;
backoff?: number;
shouldRetry?: (error: unknown, attempt: number) => boolean;
} = {},
): Promise<T> {
const {
attempts = 3,
delay = 1000,
backoff = 2,
shouldRetry = () => true,
} = options
} = options;
let lastError: unknown
let currentDelay = delay
let lastError: unknown;
let currentDelay = delay;
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
return await fn()
return await fn();
} catch (error) {
lastError = error
lastError = error;
if (attempt === attempts || !shouldRetry(error, attempt)) {
throw error
throw error;
}
await sleep(currentDelay)
currentDelay *= backoff
await sleep(currentDelay);
currentDelay *= backoff;
}
}
throw lastError
throw lastError;
}
export async function retryResult<T, E>(
fn: () => Promise<Result<T, E>>,
options: {
attempts?: number
delay?: number
backoff?: number
shouldRetry?: (error: E, attempt: number) => boolean
} = {}
attempts?: number;
delay?: number;
backoff?: number;
shouldRetry?: (error: E, attempt: number) => boolean;
} = {},
): Promise<Result<T, E>> {
const {
attempts = 3,
delay = 1000,
backoff = 2,
shouldRetry = () => true,
} = options
} = options;
let lastResult: Result<T, E> | null = null
let currentDelay = delay
let lastResult: Result<T, E> | null = null;
let currentDelay = delay;
for (let attempt = 1; attempt <= attempts; attempt++) {
const result = await fn()
lastResult = result
const result = await fn();
lastResult = result;
if (result.ok) {
return result
return result;
}
if (attempt === attempts || !shouldRetry(result.error, attempt)) {
return result
return result;
}
await sleep(currentDelay)
currentDelay *= backoff
await sleep(currentDelay);
currentDelay *= backoff;
}
return lastResult!
return lastResult!;
}
export function timeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error(`Timeout after ${ms}ms`))
}, ms)
reject(new Error(`Timeout after ${ms}ms`));
}, ms);
promise
.then((value) => {
clearTimeout(timeoutId)
resolve(value)
clearTimeout(timeoutId);
resolve(value);
})
.catch((error) => {
clearTimeout(timeoutId)
reject(error)
})
})
clearTimeout(timeoutId);
reject(error);
});
});
}
export async function timeoutResult<T>(
promise: Promise<Result<T, Error>>,
ms: number
ms: number,
): Promise<Result<T, Error>> {
try {
return await timeout(promise, ms)
return await timeout(promise, ms);
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)))
return err(e instanceof Error ? e : new Error(String(e)));
}
}
export async function parallel<T>(
tasks: (() => Promise<T>)[],
concurrency: number
concurrency: number,
): Promise<T[]> {
const results: T[] = []
const executing: Promise<void>[] = []
const results: T[] = [];
const executing: Promise<void>[] = [];
for (const task of tasks) {
const p = task().then((result) => {
results.push(result)
})
results.push(result);
});
executing.push(p)
executing.push(p);
if (executing.length >= concurrency) {
await Promise.race(executing)
await Promise.race(executing);
executing.splice(
executing.findIndex((e) => e === p),
1
)
1,
);
}
}
await Promise.all(executing)
return results
await Promise.all(executing);
return results;
}
export async function mapParallel<T, U>(
items: T[],
fn: (item: T, index: number) => Promise<U>,
concurrency: number
concurrency: number,
): Promise<U[]> {
const results: U[] = new Array(items.length)
const executing: Promise<void>[] = []
const results: U[] = new Array(items.length);
const executing: Promise<void>[] = [];
for (let i = 0; i < items.length; i++) {
const index = i
const index = i;
const p = fn(items[index], index).then((result) => {
results[index] = result
})
results[index] = result;
});
executing.push(p)
executing.push(p);
if (executing.length >= concurrency) {
await Promise.race(executing)
await Promise.race(executing);
const doneIndex = executing.findIndex(
(e) =>
(e as Promise<void> & { _done?: boolean })._done !== false
)
(e) => (e as Promise<void> & { _done?: boolean })._done !== false,
);
if (doneIndex >= 0) {
executing.splice(doneIndex, 1)
executing.splice(doneIndex, 1);
}
}
}
await Promise.all(executing)
return results
await Promise.all(executing);
return results;
}
export function createAbortable<T>(
fn: (signal: AbortSignal) => Promise<T>
fn: (signal: AbortSignal) => Promise<T>,
): { promise: Promise<T>; abort: () => void } {
const controller = new AbortController()
const controller = new AbortController();
return {
promise: fn(controller.signal),
abort: () => controller.abort(),
}
};
}
export interface Deferred<T> {
promise: Promise<T>
resolve: (value: T) => void
reject: (error: unknown) => void
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
}
export function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
resolve = res;
reject = rej;
});
return { promise, resolve, reject }
return { promise, resolve, reject };
}
+27 -3
View File
@@ -1,3 +1,27 @@
export * from './option'
export * from './array'
export * from './async'
export * from "./option.ts";
export {
at,
chunk,
find,
findIndex,
findMap,
first,
groupBy,
intersperse,
isEmpty,
isNonEmpty,
last,
maxBy,
minBy,
partition,
range,
sortBy,
sortByDesc,
sum,
sumBy,
unique,
uniqueBy,
zip as zipArrays,
zipWith as zipArraysWith,
} from "./array.ts";
export * from "./async.ts";
+31 -25
View File
@@ -1,79 +1,85 @@
export type Option<T> = T | null | undefined
export type Option<T> = T | null | undefined;
export function isSome<T>(opt: Option<T>): opt is T {
return opt != null
return opt != null;
}
export function isNone<T>(opt: Option<T>): opt is null | undefined {
return opt == null
return opt == null;
}
export function map<T, U>(opt: Option<T>, fn: (t: T) => U): Option<U> {
return isSome(opt) ? fn(opt) : null
return isSome(opt) ? fn(opt) : null;
}
export function flatMap<T, U>(opt: Option<T>, fn: (t: T) => Option<U>): Option<U> {
return isSome(opt) ? fn(opt) : null
export function flatMap<T, U>(
opt: Option<T>,
fn: (t: T) => Option<U>,
): Option<U> {
return isSome(opt) ? fn(opt) : null;
}
export function filter<T>(opt: Option<T>, predicate: (t: T) => boolean): Option<T> {
return isSome(opt) && predicate(opt) ? opt : null
export function filter<T>(
opt: Option<T>,
predicate: (t: T) => boolean,
): Option<T> {
return isSome(opt) && predicate(opt) ? opt : null;
}
export function getOrElse<T>(opt: Option<T>, defaultValue: T): T {
return isSome(opt) ? opt : defaultValue
return isSome(opt) ? opt : defaultValue;
}
export function getOrElseLazy<T>(opt: Option<T>, fn: () => T): T {
return isSome(opt) ? opt : fn()
return isSome(opt) ? opt : fn();
}
export function getOrThrow<T>(opt: Option<T>, error?: string | Error): T {
if (isSome(opt)) return opt
if (error instanceof Error) throw error
throw new Error(error ?? 'Expected value but got null/undefined')
if (isSome(opt)) return opt;
if (error instanceof Error) throw error;
throw new Error(error ?? "Expected value but got null/undefined");
}
export function tap<T>(opt: Option<T>, fn: (t: T) => void): Option<T> {
if (isSome(opt)) fn(opt)
return opt
if (isSome(opt)) fn(opt);
return opt;
}
export function match<T, U>(
opt: Option<T>,
handlers: { some: (t: T) => U; none: () => U }
handlers: { some: (t: T) => U; none: () => U },
): U {
return isSome(opt) ? handlers.some(opt) : handlers.none()
return isSome(opt) ? handlers.some(opt) : handlers.none();
}
export function toArray<T>(opt: Option<T>): T[] {
return isSome(opt) ? [opt] : []
return isSome(opt) ? [opt] : [];
}
export function fromArray<T>(arr: T[]): Option<T> {
return arr.length > 0 ? arr[0] : null
return arr.length > 0 ? arr[0] : null;
}
export function zip<T, U>(a: Option<T>, b: Option<U>): Option<[T, U]> {
return isSome(a) && isSome(b) ? [a, b] : null
return isSome(a) && isSome(b) ? [a, b] : null;
}
export function zipWith<T, U, R>(
a: Option<T>,
b: Option<U>,
fn: (t: T, u: U) => R
fn: (t: T, u: U) => R,
): Option<R> {
return isSome(a) && isSome(b) ? fn(a, b) : null
return isSome(a) && isSome(b) ? fn(a, b) : null;
}
export function or<T>(a: Option<T>, b: Option<T>): Option<T> {
return isSome(a) ? a : b
return isSome(a) ? a : b;
}
export function orLazy<T>(a: Option<T>, fn: () => Option<T>): Option<T> {
return isSome(a) ? a : fn()
return isSome(a) ? a : fn();
}
export function and<T, U>(a: Option<T>, b: Option<U>): Option<U> {
return isSome(a) ? b : null
return isSome(a) ? b : null;
}
+125 -99
View File
@@ -1,138 +1,140 @@
import { ok, err, type Result } from './types/result'
import { err, ok, type Result } from "./types/result.ts";
import {
type AtUri,
type Cid,
type Did,
type DidPlc,
type DidWeb,
type Handle,
type EmailAddress,
type AtUri,
type Cid,
type Nsid,
type ISODateString,
type Handle,
isAtUri,
isCid,
isDid,
isDidPlc,
isDidWeb,
isHandle,
isEmail,
isAtUri,
isCid,
isNsid,
isHandle,
isISODate,
} from './types/branded'
isNsid,
type ISODateString,
type Nsid,
} from "./types/branded.ts";
export class ValidationError extends Error {
constructor(
message: string,
public readonly field?: string,
public readonly value?: unknown
public readonly value?: unknown,
) {
super(message)
this.name = 'ValidationError'
super(message);
this.name = "ValidationError";
}
}
export function parseDid(s: string): Result<Did, ValidationError> {
if (isDid(s)) {
return ok(s)
return ok(s);
}
return err(new ValidationError(`Invalid DID: ${s}`, 'did', s))
return err(new ValidationError(`Invalid DID: ${s}`, "did", s));
}
export function parseDidPlc(s: string): Result<DidPlc, ValidationError> {
if (isDidPlc(s)) {
return ok(s)
return ok(s);
}
return err(new ValidationError(`Invalid DID:PLC: ${s}`, 'did', s))
return err(new ValidationError(`Invalid DID:PLC: ${s}`, "did", s));
}
export function parseDidWeb(s: string): Result<DidWeb, ValidationError> {
if (isDidWeb(s)) {
return ok(s)
return ok(s);
}
return err(new ValidationError(`Invalid DID:WEB: ${s}`, 'did', s))
return err(new ValidationError(`Invalid DID:WEB: ${s}`, "did", s));
}
export function parseHandle(s: string): Result<Handle, ValidationError> {
const trimmed = s.trim().toLowerCase()
const trimmed = s.trim().toLowerCase();
if (isHandle(trimmed)) {
return ok(trimmed)
return ok(trimmed);
}
return err(new ValidationError(`Invalid handle: ${s}`, 'handle', s))
return err(new ValidationError(`Invalid handle: ${s}`, "handle", s));
}
export function parseEmail(s: string): Result<EmailAddress, ValidationError> {
const trimmed = s.trim().toLowerCase()
const trimmed = s.trim().toLowerCase();
if (isEmail(trimmed)) {
return ok(trimmed)
return ok(trimmed);
}
return err(new ValidationError(`Invalid email: ${s}`, 'email', s))
return err(new ValidationError(`Invalid email: ${s}`, "email", s));
}
export function parseAtUri(s: string): Result<AtUri, ValidationError> {
if (isAtUri(s)) {
return ok(s)
return ok(s);
}
return err(new ValidationError(`Invalid AT-URI: ${s}`, 'uri', s))
return err(new ValidationError(`Invalid AT-URI: ${s}`, "uri", s));
}
export function parseCid(s: string): Result<Cid, ValidationError> {
if (isCid(s)) {
return ok(s)
return ok(s);
}
return err(new ValidationError(`Invalid CID: ${s}`, 'cid', s))
return err(new ValidationError(`Invalid CID: ${s}`, "cid", s));
}
export function parseNsid(s: string): Result<Nsid, ValidationError> {
if (isNsid(s)) {
return ok(s)
return ok(s);
}
return err(new ValidationError(`Invalid NSID: ${s}`, 'nsid', s))
return err(new ValidationError(`Invalid NSID: ${s}`, "nsid", s));
}
export function parseISODate(s: string): Result<ISODateString, ValidationError> {
export function parseISODate(
s: string,
): Result<ISODateString, ValidationError> {
if (isISODate(s)) {
return ok(s)
return ok(s);
}
return err(new ValidationError(`Invalid ISO date: ${s}`, 'date', s))
return err(new ValidationError(`Invalid ISO date: ${s}`, "date", s));
}
export interface PasswordValidationResult {
valid: boolean
errors: string[]
strength: 'weak' | 'fair' | 'good' | 'strong'
valid: boolean;
errors: string[];
strength: "weak" | "fair" | "good" | "strong";
}
export function validatePassword(password: string): PasswordValidationResult {
const errors: string[] = []
const errors: string[] = [];
if (password.length < 8) {
errors.push('Password must be at least 8 characters')
errors.push("Password must be at least 8 characters");
}
if (password.length > 256) {
errors.push('Password must be at most 256 characters')
errors.push("Password must be at most 256 characters");
}
if (!/[a-z]/.test(password)) {
errors.push('Password must contain a lowercase letter')
errors.push("Password must contain a lowercase letter");
}
if (!/[A-Z]/.test(password)) {
errors.push('Password must contain an uppercase letter')
errors.push("Password must contain an uppercase letter");
}
if (!/\d/.test(password)) {
errors.push('Password must contain a number')
errors.push("Password must contain a number");
}
let strength: PasswordValidationResult['strength'] = 'weak'
let strength: PasswordValidationResult["strength"] = "weak";
if (errors.length === 0) {
const hasSpecial = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)
const isLong = password.length >= 12
const isVeryLong = password.length >= 16
const hasSpecial = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password);
const isLong = password.length >= 12;
const isVeryLong = password.length >= 16;
if (isVeryLong && hasSpecial) {
strength = 'strong'
strength = "strong";
} else if (isLong || hasSpecial) {
strength = 'good'
strength = "good";
} else {
strength = 'fair'
strength = "fair";
}
}
@@ -140,121 +142,145 @@ export function validatePassword(password: string): PasswordValidationResult {
valid: errors.length === 0,
errors,
strength,
}
};
}
export function validateHandle(handle: string): Result<Handle, ValidationError> {
const trimmed = handle.trim().toLowerCase()
export function validateHandle(
handle: string,
): Result<Handle, ValidationError> {
const trimmed = handle.trim().toLowerCase();
if (trimmed.length < 3) {
return err(new ValidationError('Handle must be at least 3 characters', 'handle', handle))
return err(
new ValidationError(
"Handle must be at least 3 characters",
"handle",
handle,
),
);
}
if (trimmed.length > 253) {
return err(new ValidationError('Handle must be at most 253 characters', 'handle', handle))
return err(
new ValidationError(
"Handle must be at most 253 characters",
"handle",
handle,
),
);
}
if (!isHandle(trimmed)) {
return err(new ValidationError('Invalid handle format', 'handle', handle))
return err(new ValidationError("Invalid handle format", "handle", handle));
}
return ok(trimmed)
return ok(trimmed);
}
export function validateInviteCode(code: string): Result<string, ValidationError> {
const trimmed = code.trim()
export function validateInviteCode(
code: string,
): Result<string, ValidationError> {
const trimmed = code.trim();
if (trimmed.length === 0) {
return err(new ValidationError('Invite code is required', 'inviteCode', code))
return err(
new ValidationError("Invite code is required", "inviteCode", code),
);
}
const pattern = /^[a-zA-Z0-9-]+$/
const pattern = /^[a-zA-Z0-9-]+$/;
if (!pattern.test(trimmed)) {
return err(new ValidationError('Invalid invite code format', 'inviteCode', code))
return err(
new ValidationError("Invalid invite code format", "inviteCode", code),
);
}
return ok(trimmed)
return ok(trimmed);
}
export function validateTotpCode(code: string): Result<string, ValidationError> {
const trimmed = code.trim().replace(/\s/g, '')
export function validateTotpCode(
code: string,
): Result<string, ValidationError> {
const trimmed = code.trim().replace(/\s/g, "");
if (!/^\d{6}$/.test(trimmed)) {
return err(new ValidationError('TOTP code must be 6 digits', 'code', code))
return err(new ValidationError("TOTP code must be 6 digits", "code", code));
}
return ok(trimmed)
return ok(trimmed);
}
export function validateBackupCode(code: string): Result<string, ValidationError> {
const trimmed = code.trim().replace(/\s/g, '').toLowerCase()
export function validateBackupCode(
code: string,
): Result<string, ValidationError> {
const trimmed = code.trim().replace(/\s/g, "").toLowerCase();
if (!/^[a-z0-9]{8}$/.test(trimmed)) {
return err(new ValidationError('Invalid backup code format', 'code', code))
return err(new ValidationError("Invalid backup code format", "code", code));
}
return ok(trimmed)
return ok(trimmed);
}
export interface FormValidation<T> {
validate: () => Result<T, ValidationError[]>
validate: () => Result<T, ValidationError[]>;
field: <K extends keyof T>(
key: K,
validator: (value: unknown) => Result<T[K], ValidationError>
) => FormValidation<T>
validator: (value: unknown) => Result<T[K], ValidationError>,
) => FormValidation<T>;
optional: <K extends keyof T>(
key: K,
validator: (value: unknown) => Result<T[K], ValidationError>
) => FormValidation<T>
validator: (value: unknown) => Result<T[K], ValidationError>,
) => FormValidation<T>;
}
export function createFormValidation<T extends Record<string, unknown>>(
data: Record<string, unknown>
data: Record<string, unknown>,
): FormValidation<T> {
const validators: Array<{
key: string
validator: (value: unknown) => Result<unknown, ValidationError>
optional: boolean
}> = []
key: string;
validator: (value: unknown) => Result<unknown, ValidationError>;
optional: boolean;
}> = [];
const builder: FormValidation<T> = {
field: (key, validator) => {
validators.push({ key: key as string, validator, optional: false })
return builder
validators.push({ key: key as string, validator, optional: false });
return builder;
},
optional: (key, validator) => {
validators.push({ key: key as string, validator, optional: true })
return builder
validators.push({ key: key as string, validator, optional: true });
return builder;
},
validate: () => {
const errors: ValidationError[] = []
const result: Record<string, unknown> = {}
const errors: ValidationError[] = [];
const result: Record<string, unknown> = {};
for (const { key, validator, optional } of validators) {
const value = data[key]
const value = data[key];
if (value == null || value === '') {
if (value == null || value === "") {
if (!optional) {
errors.push(new ValidationError(`${key} is required`, key))
errors.push(new ValidationError(`${key} is required`, key));
}
continue
continue;
}
const validated = validator(value)
const validated = validator(value);
if (validated.ok) {
result[key] = validated.value
result[key] = validated.value;
} else {
errors.push(validated.error)
errors.push(validated.error);
}
}
if (errors.length > 0) {
return err(errors)
return err(errors);
}
return ok(result as T)
return ok(result as T);
},
}
};
return builder
return builder;
}
+64 -62
View File
@@ -1,98 +1,100 @@
export interface PublicKeyCredentialDescriptorJSON {
type: 'public-key'
id: string
transports?: AuthenticatorTransport[]
type: "public-key";
id: string;
transports?: AuthenticatorTransport[];
}
export interface PublicKeyCredentialUserEntityJSON {
id: string
name: string
displayName: string
id: string;
name: string;
displayName: string;
}
export interface PublicKeyCredentialRpEntityJSON {
name: string
id?: string
name: string;
id?: string;
}
export interface PublicKeyCredentialParametersJSON {
type: 'public-key'
alg: number
type: "public-key";
alg: number;
}
export interface AuthenticatorSelectionCriteriaJSON {
authenticatorAttachment?: AuthenticatorAttachment
residentKey?: ResidentKeyRequirement
requireResidentKey?: boolean
userVerification?: UserVerificationRequirement
authenticatorAttachment?: AuthenticatorAttachment;
residentKey?: ResidentKeyRequirement;
requireResidentKey?: boolean;
userVerification?: UserVerificationRequirement;
}
export interface PublicKeyCredentialCreationOptionsJSON {
rp: PublicKeyCredentialRpEntityJSON
user: PublicKeyCredentialUserEntityJSON
challenge: string
pubKeyCredParams: PublicKeyCredentialParametersJSON[]
timeout?: number
excludeCredentials?: PublicKeyCredentialDescriptorJSON[]
authenticatorSelection?: AuthenticatorSelectionCriteriaJSON
attestation?: AttestationConveyancePreference
rp: PublicKeyCredentialRpEntityJSON;
user: PublicKeyCredentialUserEntityJSON;
challenge: string;
pubKeyCredParams: PublicKeyCredentialParametersJSON[];
timeout?: number;
excludeCredentials?: PublicKeyCredentialDescriptorJSON[];
authenticatorSelection?: AuthenticatorSelectionCriteriaJSON;
attestation?: AttestationConveyancePreference;
}
export interface PublicKeyCredentialRequestOptionsJSON {
challenge: string
timeout?: number
rpId?: string
allowCredentials?: PublicKeyCredentialDescriptorJSON[]
userVerification?: UserVerificationRequirement
challenge: string;
timeout?: number;
rpId?: string;
allowCredentials?: PublicKeyCredentialDescriptorJSON[];
userVerification?: UserVerificationRequirement;
}
export interface WebAuthnCreationOptionsResponse {
publicKey: PublicKeyCredentialCreationOptionsJSON
publicKey: PublicKeyCredentialCreationOptionsJSON;
}
export interface WebAuthnRequestOptionsResponse {
publicKey: PublicKeyCredentialRequestOptionsJSON
publicKey: PublicKeyCredentialRequestOptionsJSON;
}
export interface CredentialAssertionJSON {
id: string
type: string
rawId: string
id: string;
type: string;
rawId: string;
response: {
clientDataJSON: string
authenticatorData: string
signature: string
userHandle: string | null
}
clientDataJSON: string;
authenticatorData: string;
signature: string;
userHandle: string | null;
};
}
export interface CredentialAttestationJSON {
id: string
type: string
rawId: string
id: string;
type: string;
rawId: string;
response: {
clientDataJSON: string
attestationObject: string
}
clientDataJSON: string;
attestationObject: string;
};
}
export function base64UrlToArrayBuffer(base64url: string): ArrayBuffer {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4)
const binary = atob(padded)
return Uint8Array.from(binary, (char) => char.charCodeAt(0)).buffer
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
const binary = atob(padded);
return Uint8Array.from(binary, (char) => char.charCodeAt(0)).buffer;
}
export function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('')
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
const bytes = new Uint8Array(buffer);
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join(
"",
);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
export function prepareCreationOptions(
options: WebAuthnCreationOptionsResponse
options: WebAuthnCreationOptionsResponse,
): PublicKeyCredentialCreationOptions {
const pk = options.publicKey
const pk = options.publicKey;
return {
...pk,
challenge: base64UrlToArrayBuffer(pk.challenge),
@@ -104,13 +106,13 @@ export function prepareCreationOptions(
...cred,
id: base64UrlToArrayBuffer(cred.id),
})),
}
};
}
export function prepareRequestOptions(
options: WebAuthnRequestOptionsResponse
options: WebAuthnRequestOptionsResponse,
): PublicKeyCredentialRequestOptions {
const pk = options.publicKey
const pk = options.publicKey;
return {
...pk,
challenge: base64UrlToArrayBuffer(pk.challenge),
@@ -118,13 +120,13 @@ export function prepareRequestOptions(
...cred,
id: base64UrlToArrayBuffer(cred.id),
})),
}
};
}
export function serializeAttestationResponse(
credential: PublicKeyCredential
credential: PublicKeyCredential,
): CredentialAttestationJSON {
const response = credential.response as AuthenticatorAttestationResponse
const response = credential.response as AuthenticatorAttestationResponse;
return {
id: credential.id,
type: credential.type,
@@ -133,13 +135,13 @@ export function serializeAttestationResponse(
clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
attestationObject: arrayBufferToBase64Url(response.attestationObject),
},
}
};
}
export function serializeAssertionResponse(
credential: PublicKeyCredential
credential: PublicKeyCredential,
): CredentialAssertionJSON {
const response = credential.response as AuthenticatorAssertionResponse
const response = credential.response as AuthenticatorAssertionResponse;
return {
id: credential.id,
type: credential.type,
@@ -152,5 +154,5 @@ export function serializeAssertionResponse(
? arrayBufferToBase64Url(response.userHandle)
: null,
},
}
};
}
+6 -5
View File
@@ -5,6 +5,7 @@
import { api, ApiError } from '../lib/api'
import { _ } from '../lib/i18n'
import { formatDate, formatDateTime } from '../lib/date'
import { unsafeAsDid } from '../lib/types/branded'
import type { Session } from '../lib/types/api'
import { toast } from '../lib/toast.svelte'
@@ -257,7 +258,7 @@
if (!session) return
userDetailLoading = true
try {
selectedUser = await api.getAccountInfo(session.accessJwt, did)
selectedUser = await api.getAccountInfo(session.accessJwt, unsafeAsDid(did))
} catch (e) {
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToLoadUserDetails'))
} finally {
@@ -272,11 +273,11 @@
userActionLoading = true
try {
if (selectedUser.invitesDisabled) {
await api.enableAccountInvites(session.accessJwt, selectedUser.did)
await api.enableAccountInvites(session.accessJwt, unsafeAsDid(selectedUser.did))
selectedUser = { ...selectedUser, invitesDisabled: false }
toast.success($_('admin.invitesEnabled'))
} else {
await api.disableAccountInvites(session.accessJwt, selectedUser.did)
await api.disableAccountInvites(session.accessJwt, unsafeAsDid(selectedUser.did))
selectedUser = { ...selectedUser, invitesDisabled: true }
toast.success($_('admin.invitesDisabled'))
}
@@ -291,7 +292,7 @@
if (!confirm($_('admin.deleteConfirm', { values: { handle: selectedUser.handle } }))) return
userActionLoading = true
try {
await api.adminDeleteAccount(session.accessJwt, selectedUser.did)
await api.adminDeleteAccount(session.accessJwt, unsafeAsDid(selectedUser.did))
users = users.filter(u => u.did !== selectedUser!.did)
selectedUser = null
toast.success($_('admin.userDeleted'))
@@ -639,7 +640,7 @@
</div>
</div>
{/if}
{:else if auth.loading}
{:else if authLoading}
<div class="loading">{$_('admin.loading')}</div>
{/if}
<style>
+5 -1
View File
@@ -80,7 +80,11 @@
$effect(() => {
if (dropdownOpen) {
document.addEventListener('click', closeDropdown)
return () => document.removeEventListener('click', closeDropdown)
}
return () => {
if (dropdownOpen) {
document.removeEventListener('click', closeDropdown)
}
}
})
</script>
+1 -1
View File
@@ -17,7 +17,7 @@
type PageState =
| { kind: 'login' }
| { kind: 'verification'; did: string }
| { kind: 'verification'; did: Did }
let pageState = $state<PageState>({ kind: 'login' })
let submitting = $state(false)
+2 -1
View File
@@ -2,6 +2,7 @@
import { navigate, routes } from '../lib/router.svelte'
import { api, ApiError } from '../lib/api'
import { _ } from '../lib/i18n'
import { unsafeAsDid } from '../lib/types/branded'
let newPassword = $state('')
let confirmPassword = $state('')
@@ -44,7 +45,7 @@
error = null
try {
await api.recoverPasskeyAccount(did, token, newPassword)
await api.recoverPasskeyAccount(unsafeAsDid(did), token, newPassword)
success = true
} catch (err) {
if (err instanceof ApiError) {
+2 -2
View File
@@ -12,7 +12,7 @@
import {
prepareCreationOptions,
serializeAttestationResponse,
type WebAuthnCreationOptionsResponse,
type PublicKeyCredentialCreationOptionsJSON,
} from '../lib/webauthn'
let serverInfo = $state<{
@@ -126,7 +126,7 @@
passkeyName || undefined
)
const publicKeyOptions = prepareCreationOptions(options as WebAuthnCreationOptionsResponse)
const publicKeyOptions = prepareCreationOptions({ publicKey: options as unknown as PublicKeyCredentialCreationOptionsJSON })
const credential = await navigator.credentials.create({
publicKey: publicKeyOptions
})
+11 -10
View File
@@ -4,6 +4,7 @@
import { api, ApiError } from '../lib/api'
import { _, locale } from '../lib/i18n'
import type { Session } from '../lib/types/api'
import { unsafeAsNsid, unsafeAsRkey } from '../lib/types/branded'
const auth = $derived(getAuthState())
@@ -75,7 +76,7 @@
loading = true
error = null
try {
const result = await api.listRecords(session.accessJwt, session.did, collection, { limit: 50 })
const result = await api.listRecords(session.accessJwt, session.did, unsafeAsNsid(collection), { limit: 50 })
records = result.records.map(r => ({
...r,
rkey: r.uri.split('/').pop()!
@@ -91,7 +92,7 @@
if (!session || !selectedCollection || !recordsCursor || loadingMore) return
loadingMore = true
try {
const result = await api.listRecords(session.accessJwt, session.did, selectedCollection, {
const result = await api.listRecords(session.accessJwt, session.did, unsafeAsNsid(selectedCollection), {
limit: 50,
cursor: recordsCursor
})
@@ -180,9 +181,9 @@
const result = await api.createRecord(
session.accessJwt,
session.did,
newCollection.trim(),
unsafeAsNsid(newCollection.trim()),
record,
newRkey.trim() || undefined
newRkey.trim() ? unsafeAsRkey(newRkey.trim()) : undefined
)
success = $_('repoExplorer.recordCreated', { values: { uri: result.uri } })
await loadCollections()
@@ -204,16 +205,16 @@
await api.putRecord(
session.accessJwt,
session.did,
selectedCollection,
selectedRecord.rkey,
unsafeAsNsid(selectedCollection),
unsafeAsRkey(selectedRecord.rkey),
record
)
success = $_('repoExplorer.recordUpdated')
const updated = await api.getRecord(
session.accessJwt,
session.did,
selectedCollection,
selectedRecord.rkey
unsafeAsNsid(selectedCollection),
unsafeAsRkey(selectedRecord.rkey)
)
selectedRecord = { ...updated, rkey: selectedRecord.rkey }
recordJson = JSON.stringify(updated.value, null, 2)
@@ -232,8 +233,8 @@
await api.deleteRecord(
session.accessJwt,
session.did,
selectedCollection,
selectedRecord.rkey
unsafeAsNsid(selectedCollection),
unsafeAsRkey(selectedRecord.rkey)
)
success = $_('repoExplorer.recordDeleted')
selectedRecord = null
@@ -2,6 +2,7 @@
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { api, ApiError } from '../lib/api'
import { _ } from '../lib/i18n'
import { unsafeAsEmail } from '../lib/types/branded'
let identifier = $state('')
let submitting = $state(false)
@@ -14,7 +15,7 @@
error = null
try {
await api.requestPasskeyRecovery(identifier)
await api.requestPasskeyRecovery(unsafeAsEmail(identifier))
success = true
} catch (err) {
if (err instanceof ApiError) {
+2 -1
View File
@@ -4,6 +4,7 @@
import { getAuthState } from '../lib/auth.svelte'
import { _ } from '../lib/i18n'
import type { Session } from '../lib/types/api'
import { unsafeAsEmail } from '../lib/types/branded'
const auth = $derived(getAuthState())
@@ -35,7 +36,7 @@
error = null
success = null
try {
await api.requestPasswordReset(email)
await api.requestPasswordReset(unsafeAsEmail(email))
tokenSent = true
success = $_('resetPassword.codeSent')
} catch (e) {
+1 -1
View File
@@ -303,7 +303,7 @@
addingPasskey = true
try {
const { options } = await api.startPasskeyRegistration(session.accessJwt, newPasskeyName || undefined)
const publicKeyOptions = prepareCreationOptions(options as WebAuthnCreationOptionsResponse)
const publicKeyOptions = prepareCreationOptions(options as unknown as WebAuthnCreationOptionsResponse)
const credential = await navigator.credentials.create({
publicKey: publicKeyOptions
})
+2 -1
View File
@@ -5,6 +5,7 @@
import { api, ApiError } from '../lib/api'
import { locale, setLocale, getSupportedLocales, localeNames, _, type SupportedLocale } from '../lib/i18n'
import { isOk } from '../lib/types/result'
import { unsafeAsHandle } from '../lib/types/branded'
import type { Session } from '../lib/types/api'
import { toast } from '../lib/toast.svelte'
@@ -113,7 +114,7 @@
const fullHandle = showBYOHandle
? newHandle
: `${newHandle}.${pdsHostname}`
await api.updateHandle(session.accessJwt, fullHandle)
await api.updateHandle(session.accessJwt, unsafeAsHandle(fullHandle))
await refreshSession()
toast.success($_('settings.messages.handleUpdated'))
newHandle = ''
+13 -7
View File
@@ -5,11 +5,12 @@
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { _ } from '../lib/i18n'
import type { Session } from '../lib/types/api'
import { unsafeAsDid, unsafeAsEmail, type Did } from '../lib/types/branded'
const STORAGE_KEY = 'tranquil_pds_pending_verification'
interface PendingVerification {
did: string
did: Did
handle: string
channel: string
}
@@ -66,7 +67,12 @@
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
try {
pendingVerification = JSON.parse(stored)
const parsed = JSON.parse(stored)
pendingVerification = {
did: unsafeAsDid(parsed.did),
handle: parsed.handle,
channel: parsed.channel,
}
} catch {
pendingVerification = null
}
@@ -114,7 +120,7 @@
const result = await api.verifyToken(
verificationCode.trim(),
identifier.trim(),
auth.session?.accessJwt
session?.accessJwt
)
success = true
successPurpose = result.purpose
@@ -137,7 +143,7 @@
async function handleEmailUpdate() {
if (!verificationCode.trim() || !newEmail.trim()) return
if (!auth.session) {
if (!session) {
error = $_('verify.emailUpdateRequiresAuth')
return
}
@@ -146,7 +152,7 @@
error = null
try {
await api.updateEmail(auth.session.accessJwt, newEmail.trim(), verificationCode.trim())
await api.updateEmail(session.accessJwt, newEmail.trim(), verificationCode.trim())
success = true
successPurpose = 'email-update'
successChannel = 'email'
@@ -185,7 +191,7 @@
error = null
try {
await api.resendMigrationVerification(identifier.trim())
await api.resendMigrationVerification(unsafeAsEmail(identifier.trim()))
resendMessage = $_('verify.codeResentDetail')
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to resend verification'
@@ -250,7 +256,7 @@
<h1>{$_('verify.emailUpdateTitle')}</h1>
<p class="subtitle">{$_('verify.emailUpdateSubtitle')}</p>
{#if !auth.session}
{#if !session}
<div class="message warning">{$_('verify.emailUpdateRequiresAuth')}</div>
<div class="actions">
<a href="/app/login" class="btn">{$_('verify.signIn')}</a>
+4 -3
View File
@@ -10,7 +10,8 @@ import {
setupAuthenticatedUser,
setupFetchMock,
setupUnauthenticatedUser,
} from "./mocks";
} from "./mocks.ts";
import { unsafeAsISODateString } from "../lib/types/branded.ts";
describe("AppPasswords", () => {
beforeEach(() => {
clearMocks();
@@ -81,11 +82,11 @@ describe("AppPasswords", () => {
const testPasswords = [
mockData.appPassword({
name: "Graysky",
createdAt: "2024-01-15T10:00:00Z",
createdAt: unsafeAsISODateString("2024-01-15T10:00:00Z"),
}),
mockData.appPassword({
name: "Skeets",
createdAt: "2024-02-20T15:30:00Z",
createdAt: unsafeAsISODateString("2024-02-20T15:30:00Z"),
}),
];
beforeEach(() => {
+21 -11
View File
@@ -7,8 +7,14 @@ import {
mockData,
mockEndpoint,
setupFetchMock,
} from "./mocks";
import { _testSetState, type SavedAccount } from "../lib/auth.svelte";
} from "./mocks.ts";
import { _testSetState, type SavedAccount } from "../lib/auth.svelte.ts";
import {
unsafeAsAccessToken,
unsafeAsDid,
unsafeAsHandle,
unsafeAsRefreshToken,
} from "../lib/types/branded.ts";
describe("Login", () => {
beforeEach(() => {
@@ -65,16 +71,16 @@ describe("Login", () => {
describe("with saved accounts", () => {
const savedAccounts: SavedAccount[] = [
{
did: "did:web:test.tranquil.dev:u:alice",
handle: "alice.test.tranquil.dev",
accessJwt: "mock-jwt-alice",
refreshJwt: "mock-refresh-alice",
did: unsafeAsDid("did:web:test.tranquil.dev:u:alice"),
handle: unsafeAsHandle("alice.test.tranquil.dev"),
accessJwt: unsafeAsAccessToken("mock-jwt-alice"),
refreshJwt: unsafeAsRefreshToken("mock-refresh-alice"),
},
{
did: "did:web:test.tranquil.dev:u:bob",
handle: "bob.test.tranquil.dev",
accessJwt: "mock-jwt-bob",
refreshJwt: "mock-refresh-bob",
did: unsafeAsDid("did:web:test.tranquil.dev:u:bob"),
handle: unsafeAsHandle("bob.test.tranquil.dev"),
accessJwt: unsafeAsAccessToken("mock-jwt-bob"),
refreshJwt: unsafeAsRefreshToken("mock-refresh-bob"),
},
];
@@ -88,7 +94,11 @@ describe("Login", () => {
mockEndpoint(
"com.atproto.server.getSession",
() =>
jsonResponse(mockData.session({ handle: "alice.test.tranquil.dev" })),
jsonResponse(
mockData.session({
handle: unsafeAsHandle("alice.test.tranquil.dev"),
}),
),
);
});
+35 -4
View File
@@ -8,11 +8,42 @@ import {
setError,
updateProgress,
updateStep,
} from "../../lib/migration/storage";
} from "../../lib/migration/storage.ts";
import type {
InboundMigrationState,
OutboundMigrationState,
} from "../../lib/migration/types";
MigrationState,
} from "../../lib/migration/types.ts";
interface OutboundMigrationState {
direction: "outbound";
step: string;
localDid: string;
localHandle: string;
targetPdsUrl: string;
targetPdsDid: string;
targetHandle: string;
targetEmail: string;
targetPassword: string;
inviteCode: string;
targetAccessToken: string | null;
targetRefreshToken: string | null;
serviceAuthToken: string | null;
plcToken: string;
progress: {
repoExported: boolean;
repoImported: boolean;
blobsTotal: number;
blobsMigrated: number;
blobsFailed: string[];
prefsMigrated: boolean;
plcSigned: boolean;
activated: boolean;
deactivated: boolean;
currentOperation: string;
};
error: string | null;
targetServerInfo: unknown;
}
const STORAGE_KEY = "tranquil_migration_state";
const DPOP_KEY_STORAGE = "migration_dpop_key";
@@ -140,7 +171,7 @@ describe("migration/storage", () => {
step: "review",
});
saveMigrationState(state);
saveMigrationState(state as unknown as MigrationState);
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
expect(stored.version).toBe(1);
+21 -12
View File
@@ -1,6 +1,15 @@
import { vi } from "vitest";
import type { AppPassword, InviteCode, Session } from "../lib/api";
import { _testSetState } from "../lib/auth.svelte";
import type { AppPassword, InviteCode, Session } from "../lib/api.ts";
import { _testSetState } from "../lib/auth.svelte.ts";
import {
unsafeAsAccessToken,
unsafeAsDid,
unsafeAsEmail,
unsafeAsHandle,
unsafeAsInviteCode,
unsafeAsISODateString,
unsafeAsRefreshToken,
} from "../lib/types/branded.ts";
const originalPushState = globalThis.history.pushState.bind(globalThis.history);
const originalReplaceState = globalThis.history.replaceState.bind(
@@ -144,26 +153,26 @@ export function errorResponse(
}
export const mockData = {
session: (overrides?: Partial<Session>): Session => ({
did: "did:web:test.tranquil.dev:u:testuser",
handle: "testuser.test.tranquil.dev",
email: "test@example.com",
did: unsafeAsDid("did:web:test.tranquil.dev:u:testuser"),
handle: unsafeAsHandle("testuser.test.tranquil.dev"),
email: unsafeAsEmail("test@example.com"),
emailConfirmed: true,
accessJwt: "mock-access-jwt-token",
refreshJwt: "mock-refresh-jwt-token",
accessJwt: unsafeAsAccessToken("mock-access-jwt-token"),
refreshJwt: unsafeAsRefreshToken("mock-refresh-jwt-token"),
...overrides,
}),
appPassword: (overrides?: Partial<AppPassword>): AppPassword => ({
name: "Test App",
createdAt: new Date().toISOString(),
createdAt: unsafeAsISODateString(new Date().toISOString()),
...overrides,
}),
inviteCode: (overrides?: Partial<InviteCode>): InviteCode => ({
code: "test-invite-123",
code: unsafeAsInviteCode("test-invite-123"),
available: 1,
disabled: false,
forAccount: "did:web:test.tranquil.dev:u:testuser",
createdBy: "did:web:test.tranquil.dev:u:testuser",
createdAt: new Date().toISOString(),
forAccount: unsafeAsDid("did:web:test.tranquil.dev:u:testuser"),
createdBy: unsafeAsDid("did:web:test.tranquil.dev:u:testuser"),
createdAt: unsafeAsISODateString(new Date().toISOString()),
uses: [],
...overrides,
}),
+4 -4
View File
@@ -1,11 +1,11 @@
import { render, type RenderResult } from "@testing-library/svelte";
import { render } from "@testing-library/svelte";
import { tick } from "svelte";
import type { ComponentType } from "svelte";
export async function renderAndWait<T extends ComponentType>(
component: T,
export async function renderAndWait(
component: ComponentType,
options?: Parameters<typeof render>[1],
): Promise<RenderResult<T>> {
) {
const result = render(component, options);
await tick();
await new Promise((resolve) => setTimeout(resolve, 0));
+31
View File
@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": ["svelte", "vite/client"],
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"useUnknownInCatchVariables": true,
"alwaysStrict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+2
View File
@@ -88,6 +88,8 @@ frontend-dev:
. ~/.deno/env && cd frontend && deno task dev
frontend-build:
. ~/.deno/env && cd frontend && deno task build
frontend-check:
. ~/.deno/env && cd frontend && deno task check
frontend-clean:
rm -rf frontend/dist frontend/node_modules
+8 -4
View File
@@ -70,7 +70,8 @@ pub async fn get_preferences(
let prefs = match prefs_result {
Ok(rows) => rows,
Err(_) => {
return ApiError::InternalError(Some("Failed to fetch preferences".into())).into_response();
return ApiError::InternalError(Some("Failed to fetch preferences".into()))
.into_response();
}
};
let mut personal_details_pref: Option<Value> = None;
@@ -192,7 +193,8 @@ pub async fn put_preferences(
let mut tx = match state.db.begin().await {
Ok(tx) => tx,
Err(_) => {
return ApiError::InternalError(Some("Failed to start transaction".into())).into_response();
return ApiError::InternalError(Some("Failed to start transaction".into()))
.into_response();
}
};
let delete_result = sqlx::query!(
@@ -225,11 +227,13 @@ pub async fn put_preferences(
.await;
if insert_result.is_err() {
let _ = tx.rollback().await;
return ApiError::InternalError(Some("Failed to save preference".into())).into_response();
return ApiError::InternalError(Some("Failed to save preference".into()))
.into_response();
}
}
if tx.commit().await.is_err() {
return ApiError::InternalError(Some("Failed to commit transaction".into())).into_response();
return ApiError::InternalError(Some("Failed to commit transaction".into()))
.into_response();
}
StatusCode::OK.into_response()
}
+12 -5
View File
@@ -1,5 +1,5 @@
use crate::api::error::ApiError;
use crate::api::EmptyResponse;
use crate::api::error::ApiError;
use crate::auth::BearerAuthAdmin;
use crate::state::AppState;
use crate::types::Did;
@@ -47,7 +47,8 @@ pub async fn delete_account(
.await
{
error!("Failed to delete session tokens for {}: {:?}", did, e);
return ApiError::InternalError(Some("Failed to delete session tokens".into())).into_response();
return ApiError::InternalError(Some("Failed to delete session tokens".into()))
.into_response();
}
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())
.execute(&mut *tx)
@@ -84,7 +85,8 @@ pub async fn delete_account(
"Failed to delete app passwords for user {}: {:?}",
user_id, e
);
return ApiError::InternalError(Some("Failed to delete app passwords".into())).into_response();
return ApiError::InternalError(Some("Failed to delete app passwords".into()))
.into_response();
}
if let Err(e) = sqlx::query!(
"DELETE FROM invite_code_uses WHERE used_by_user = $1",
@@ -128,8 +130,13 @@ pub async fn delete_account(
error!("Failed to commit account deletion transaction: {:?}", e);
return ApiError::InternalError(Some("Failed to commit deletion".into())).into_response();
}
if let Err(e) =
crate::api::repo::record::sequence_account_event(&state, did.as_str(), false, Some("deleted")).await
if let Err(e) = crate::api::repo::record::sequence_account_event(
&state,
did.as_str(),
false,
Some("deleted"),
)
.await
{
warn!(
"Failed to sequence account deletion event for {}: {}",
+5 -1
View File
@@ -74,7 +74,11 @@ pub async fn send_email(
let result = crate::comms::enqueue_comms(&state.db, item).await;
match result {
Ok(_) => {
tracing::info!("Admin email queued for {} ({})", handle, input.recipient_did);
tracing::info!(
"Admin email queued for {} ({})",
handle,
input.recipient_did
);
(StatusCode::OK, Json(SendEmailOutput { sent: true })).into_response()
}
Err(e) => {
+16 -7
View File
@@ -1,5 +1,5 @@
use crate::api::error::ApiError;
use crate::api::EmptyResponse;
use crate::api::error::ApiError;
use crate::auth::BearerAuthAdmin;
use crate::state::AppState;
use crate::types::{Did, PlainPassword};
@@ -87,9 +87,13 @@ pub async fn update_account_handle(
if let Ok(Some(_)) = existing {
return ApiError::HandleTaken.into_response();
}
let result = sqlx::query!("UPDATE users SET handle = $1 WHERE did = $2", handle, did.as_str())
.execute(&state.db)
.await;
let result = sqlx::query!(
"UPDATE users SET handle = $1 WHERE did = $2",
handle,
did.as_str()
)
.execute(&state.db)
.await;
match result {
Ok(r) => {
if r.rows_affected() == 0 {
@@ -99,15 +103,20 @@ pub async fn update_account_handle(
let _ = state.cache.delete(&format!("handle:{}", old)).await;
}
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
if let Err(e) =
crate::api::repo::record::sequence_identity_event(&state, did.as_str(), Some(&handle)).await
if let Err(e) = crate::api::repo::record::sequence_identity_event(
&state,
did.as_str(),
Some(&handle),
)
.await
{
warn!(
"Failed to sequence identity event for admin handle update: {}",
e
);
}
if let Err(e) = crate::api::identity::did::update_plc_handle(&state, did.as_str(), &handle).await
if let Err(e) =
crate::api::identity::did::update_plc_handle(&state, did.as_str(), &handle).await
{
warn!("Failed to update PLC handle for admin handle update: {}", e);
}
+2 -4
View File
@@ -119,10 +119,8 @@ pub async fn get_subject_status(
let did = match &params.did {
Some(d) => d,
None => {
return ApiError::InvalidRequest(
"Must provide a did to request blob state".into(),
)
.into_response();
return ApiError::InvalidRequest("Must provide a did to request blob state".into())
.into_response();
}
};
let blob = sqlx::query!(
+6 -3
View File
@@ -50,9 +50,12 @@ async fn get_account_created_at(state: &AppState, headers: &HeaderMap) -> Option
}
};
let row = match sqlx::query!("SELECT created_at FROM users WHERE did = $1", &auth_user.did)
.fetch_optional(&state.db)
.await
let row = match sqlx::query!(
"SELECT created_at FROM users WHERE did = $1",
&auth_user.did
)
.fetch_optional(&state.db)
.await
{
Ok(r) => {
tracing::debug!(?r, "age assurance: query result");
+7 -4
View File
@@ -144,7 +144,8 @@ pub async fn get_backup(
Ok(bytes) => bytes,
Err(e) => {
error!("Failed to fetch backup from storage: {:?}", e);
return ApiError::InternalError(Some("Failed to retrieve backup".into())).into_response();
return ApiError::InternalError(Some("Failed to retrieve backup".into()))
.into_response();
}
};
@@ -223,7 +224,8 @@ pub async fn create_backup(State(state): State<AppState>, auth: BearerAuth) -> R
Ok(bytes) => bytes,
Err(e) => {
error!("Failed to generate CAR: {:?}", e);
return ApiError::InternalError(Some("Failed to generate backup".into())).into_response();
return ApiError::InternalError(Some("Failed to generate backup".into()))
.into_response();
}
};
@@ -448,7 +450,7 @@ pub async fn set_backup_enabled(
info!(did = %auth.0.did, enabled = input.enabled, "Updated backup_enabled setting");
EnabledResponse::new(input.enabled).into_response()
EnabledResponse::response(input.enabled).into_response()
}
pub async fn export_blobs(State(state): State<AppState>, auth: BearerAuth) -> Response {
@@ -575,7 +577,8 @@ pub async fn export_blobs(State(state): State<AppState>, auth: BearerAuth) -> Re
if let Err(e) = zip.finish() {
error!("Failed to finish zip: {:?}", e);
return ApiError::InternalError(Some("Failed to create zip file".into())).into_response();
return ApiError::InternalError(Some("Failed to create zip file".into()))
.into_response();
}
}
+11 -4
View File
@@ -39,7 +39,8 @@ pub async fn list_controllers(State(state): State<AppState>, auth: BearerAuth) -
Ok(c) => c,
Err(e) => {
tracing::error!("Failed to list controllers: {:?}", e);
return ApiError::InternalError(Some("Failed to list controllers".into())).into_response();
return ApiError::InternalError(Some("Failed to list controllers".into()))
.into_response();
}
};
@@ -269,7 +270,8 @@ pub async fn update_controller_scopes(
Ok(false) => ApiError::DelegationNotFound.into_response(),
Err(e) => {
tracing::error!("Failed to update controller scopes: {:?}", e);
ApiError::InternalError(Some("Failed to update controller scopes".into())).into_response()
ApiError::InternalError(Some("Failed to update controller scopes".into()))
.into_response()
}
}
}
@@ -357,7 +359,8 @@ pub async fn get_audit_log(
Ok(e) => e,
Err(e) => {
tracing::error!("Failed to get audit log: {:?}", e);
return ApiError::InternalError(Some("Failed to get audit log".into())).into_response();
return ApiError::InternalError(Some("Failed to get audit log".into()))
.into_response();
}
};
@@ -762,5 +765,9 @@ pub async fn create_delegated_account(
info!(did = %did, handle = %handle, controller = %&auth.0.did, "Delegated account created");
Json(CreateDelegatedAccountResponse { did: did.into(), handle: handle.into() }).into_response()
Json(CreateDelegatedAccountResponse {
did: did.into(),
handle: handle.into(),
})
.into_response()
}
+10 -15
View File
@@ -115,9 +115,7 @@ impl ApiError {
Self::UpstreamFailure | Self::UpstreamUnavailable(_) | Self::UpstreamErrorMsg(_) => {
StatusCode::BAD_GATEWAY
}
Self::ServiceUnavailable(_) | Self::BackupsDisabled => {
StatusCode::SERVICE_UNAVAILABLE
}
Self::ServiceUnavailable(_) | Self::BackupsDisabled => StatusCode::SERVICE_UNAVAILABLE,
Self::UpstreamTimeout => StatusCode::GATEWAY_TIMEOUT,
Self::UpstreamError { status, .. } => {
StatusCode::from_u16(*status).unwrap_or(StatusCode::BAD_GATEWAY)
@@ -155,12 +153,10 @@ impl ApiError {
| Self::SubjectNotFound
| Self::BlobNotFound(_)
| Self::NotFoundMsg(_) => StatusCode::NOT_FOUND,
Self::RepoTakendown
| Self::RepoDeactivated
| Self::RepoNotFound(_) => StatusCode::BAD_REQUEST,
Self::InvalidSwap(_) | Self::TotpAlreadyEnabled => {
StatusCode::CONFLICT
Self::RepoTakendown | Self::RepoDeactivated | Self::RepoNotFound(_) => {
StatusCode::BAD_REQUEST
}
Self::InvalidSwap(_) | Self::TotpAlreadyEnabled => StatusCode::CONFLICT,
Self::InvalidRequest(_)
| Self::InvalidHandle(_)
| Self::HandleNotAvailable(_)
@@ -435,7 +431,6 @@ impl IntoResponse for ApiError {
}
}
impl From<sqlx::Error> for ApiError {
fn from(e: sqlx::Error) -> Self {
tracing::error!("Database error: {:?}", e);
@@ -522,9 +517,9 @@ impl From<crate::auth::verification_token::VerifyError> for ApiError {
VerifyError::UnsupportedVersion => {
Self::InvalidRequest("This verification code version is not supported".to_string())
}
VerifyError::Expired => {
Self::InvalidRequest("The verification code has expired. Please request a new one.".to_string())
}
VerifyError::Expired => Self::InvalidRequest(
"The verification code has expired. Please request a new one.".to_string(),
),
VerifyError::InvalidSignature => {
Self::InvalidRequest("The verification code is invalid".to_string())
}
@@ -565,9 +560,9 @@ impl From<crate::plc::PlcError> for ApiError {
PlcError::NotFound => Self::NotFoundMsg("DID not found in PLC directory".into()),
PlcError::Tombstoned => Self::InvalidRequest("DID is tombstoned".into()),
PlcError::Timeout => Self::UpstreamTimeout,
PlcError::CircuitBreakerOpen => {
Self::ServiceUnavailable(Some("PLC directory service temporarily unavailable".into()))
}
PlcError::CircuitBreakerOpen => Self::ServiceUnavailable(Some(
"PLC directory service temporarily unavailable".into(),
)),
PlcError::Http(err) => {
tracing::error!("PLC HTTP error: {:?}", err);
Self::UpstreamErrorMsg("Failed to communicate with PLC directory".into())
+4 -2
View File
@@ -12,13 +12,13 @@ use axum::{
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use serde_json::json;
use bcrypt::{DEFAULT_COST, hash};
use jacquard::types::{integer::LimitedU32, string::Tid};
use jacquard_repo::{mst::Mst, storage::BlockStore};
use k256::{SecretKey, ecdsa::SigningKey};
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::sync::Arc;
use tracing::{debug, error, info, warn};
@@ -90,7 +90,9 @@ pub async fn create_account(
.await
{
warn!(ip = %client_ip, "Account creation rate limit exceeded");
return ApiError::RateLimitExceeded(Some("Too many account creation attempts. Please try again later.".into(),))
return ApiError::RateLimitExceeded(Some(
"Too many account creation attempts. Please try again later.".into(),
))
.into_response();
}
+18 -11
View File
@@ -38,7 +38,7 @@ pub async fn resolve_handle(
}
let cache_key = format!("handle:{}", handle);
if let Some(did) = state.cache.get(&cache_key).await {
return DidResponse::new(did).into_response();
return DidResponse::response(did).into_response();
}
let user = sqlx::query!("SELECT did FROM users WHERE handle = $1", handle)
.fetch_optional(&state.db)
@@ -49,7 +49,7 @@ pub async fn resolve_handle(
.cache
.set(&cache_key, &row.did, std::time::Duration::from_secs(300))
.await;
DidResponse::new(row.did).into_response()
DidResponse::response(row.did).into_response()
}
Ok(None) => match crate::handle::resolve_handle(handle).await {
Ok(did) => {
@@ -57,7 +57,7 @@ pub async fn resolve_handle(
.cache
.set(&cache_key, &did, std::time::Duration::from_secs(300))
.await;
DidResponse::new(did).into_response()
DidResponse::response(did).into_response()
}
Err(_) => ApiError::HandleNotFound.into_response(),
},
@@ -627,7 +627,9 @@ pub async fn update_handle(
.check_rate_limit(crate::state::RateLimitKind::HandleUpdate, &did)
.await
{
return ApiError::RateLimitExceeded(Some("Too many handle updates. Try again later.".into(),))
return ApiError::RateLimitExceeded(Some(
"Too many handle updates. Try again later.".into(),
))
.into_response();
}
if !state
@@ -663,8 +665,10 @@ pub async fn update_handle(
.into_response();
}
if segment.starts_with('-') || segment.ends_with('-') {
return ApiError::InvalidHandle(Some("Handle segment cannot start or end with hyphen".into(),))
.into_response();
return ApiError::InvalidHandle(Some(
"Handle segment cannot start or end with hyphen".into(),
))
.into_response();
}
}
if crate::moderation::has_explicit_slur(&new_handle) {
@@ -695,8 +699,10 @@ pub async fn update_handle(
return EmptyResponse::ok().into_response();
}
if short_part.contains('.') {
return ApiError::InvalidHandle(Some("Nested subdomains are not allowed. Use a simple handle without dots.".into(),))
.into_response();
return ApiError::InvalidHandle(Some(
"Nested subdomains are not allowed. Use a simple handle without dots.".into(),
))
.into_response();
}
if short_part.len() < 3 {
return ApiError::InvalidHandle(Some("Handle too short".into())).into_response();
@@ -721,9 +727,10 @@ pub async fn update_handle(
return ApiError::HandleNotAvailable(None).into_response();
}
Err(crate::handle::HandleResolutionError::DidMismatch { expected, actual }) => {
return ApiError::HandleNotAvailable(Some(
format!("Handle points to different DID. Expected {}, got {}", expected, actual),
))
return ApiError::HandleNotAvailable(Some(format!(
"Handle points to different DID. Expected {}, got {}",
expected, actual
)))
.into_response();
}
Err(e) => {
+2 -1
View File
@@ -120,7 +120,8 @@ pub async fn sign_plc_operation(
{
Ok(Some(row)) => row,
_ => {
return ApiError::InternalError(Some("User signing key not found".into())).into_response();
return ApiError::InternalError(Some("User signing key not found".into()))
.into_response();
}
};
let key_bytes = match crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version)
+2 -1
View File
@@ -75,7 +75,8 @@ pub async fn submit_plc_operation(
{
Ok(Some(row)) => row,
_ => {
return ApiError::InternalError(Some("User signing key not found".into())).into_response();
return ApiError::InternalError(Some("User signing key not found".into()))
.into_response();
}
};
let key_bytes = match crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version)
+1 -1
View File
@@ -17,8 +17,8 @@ pub mod validation;
pub mod verification;
pub use error::ApiError;
pub use proxy_client::{AtUriParts, proxy_client, validate_at_uri, validate_did, validate_limit};
pub use responses::{
DidResponse, EmptyResponse, EnabledResponse, HasPasswordResponse, OptionsResponse,
StatusResponse, SuccessResponse, TokenRequiredResponse, VerifiedResponse,
};
pub use proxy_client::{AtUriParts, proxy_client, validate_at_uri, validate_did, validate_limit};
+4 -2
View File
@@ -111,8 +111,10 @@ async fn proxy_to_report_service(
}
Err(e) => {
error!(error = ?e, "DB error fetching user key for report");
return ApiError::AuthenticationFailed(Some("Failed to get signing key".into()))
.into_response();
return ApiError::AuthenticationFailed(Some(
"Failed to get signing key".into(),
))
.into_response();
}
}
}
+38 -40
View File
@@ -38,9 +38,8 @@ pub async fn get_notification_prefs(State(state): State<AppState>, headers: Head
return ApiError::AuthenticationFailed(None).into_response();
}
};
let row =
match sqlx::query(
r#"
let row = match sqlx::query(
r#"
SELECT
email,
preferred_comms_channel::text as channel,
@@ -53,16 +52,16 @@ pub async fn get_notification_prefs(State(state): State<AppState>, headers: Head
FROM users
WHERE did = $1
"#,
)
.bind(&user.did)
.fetch_one(&state.db)
.await
{
Ok(r) => r,
Err(e) => {
return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response()
}
};
)
.bind(&user.did)
.fetch_one(&state.db)
.await
{
Ok(r) => r,
Err(e) => {
return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response();
}
};
let email: String = row.get("email");
let channel: String = row.get("channel");
let discord_id: Option<String> = row.get("discord_id");
@@ -125,13 +124,13 @@ pub async fn get_notification_history(
{
Ok(id) => id,
Err(e) => {
return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response()
return ApiError::InternalError(Some(format!("Database error: {}", e)))
.into_response();
}
};
let rows =
match sqlx::query!(
r#"
let rows = match sqlx::query!(
r#"
SELECT
created_at,
channel as "channel: String",
@@ -144,16 +143,16 @@ pub async fn get_notification_history(
ORDER BY created_at DESC
LIMIT 50
"#,
user_id
)
.fetch_all(&state.db)
.await
{
Ok(r) => r,
Err(e) => {
return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response()
}
};
user_id
)
.fetch_all(&state.db)
.await
{
Ok(r) => r,
Err(e) => {
return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response();
}
};
let sensitive_types = [
"email_verification",
@@ -270,19 +269,18 @@ pub async fn update_notification_prefs(
}
};
let user_row =
match sqlx::query!(
"SELECT id, handle, email FROM users WHERE did = $1",
&user.did
)
.fetch_one(&state.db)
.await
{
Ok(row) => row,
Err(e) => {
return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response()
}
};
let user_row = match sqlx::query!(
"SELECT id, handle, email FROM users WHERE did = $1",
&user.did
)
.fetch_one(&state.db)
.await
{
Ok(row) => row,
Err(e) => {
return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response();
}
};
let user_id = user_row.id;
let handle = user_row.handle;
+3 -3
View File
@@ -186,7 +186,7 @@ async fn proxy_handler(
) -> Response {
// 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 /
let method = uri.path().trim_start_matches("/");
if is_protected_method(&method) {
if is_protected_method(method) {
warn!(method = %method, "Attempted to proxy protected method");
return ApiError::InvalidRequest(format!("Cannot proxy protected method: {}", method))
.into_response();
@@ -226,7 +226,7 @@ async fn proxy_handler(
auth_user.is_oauth,
auth_user.scope.as_deref(),
&resolved.did,
&method,
method,
) {
return e;
}
@@ -235,7 +235,7 @@ async fn proxy_handler(
match crate::auth::create_service_token(
&auth_user.did,
&resolved.did,
&method,
method,
&key_bytes,
) {
Ok(new_token) => {
+7 -6
View File
@@ -29,13 +29,14 @@ fn detect_mime_type(data: &[u8], client_hint: &str) -> String {
);
}
detected
} else if client_hint == "*/*" || client_hint.is_empty() {
warn!(
"Could not detect MIME type and client sent invalid hint: '{}'",
client_hint
);
"application/octet-stream".to_string()
} else {
if client_hint == "*/*" || client_hint.is_empty() {
warn!("Could not detect MIME type and client sent invalid hint: '{}'", client_hint);
"application/octet-stream".to_string()
} else {
client_hint.to_string()
}
client_hint.to_string()
}
}
+9 -9
View File
@@ -1,6 +1,6 @@
use crate::api::EmptyResponse;
use crate::api::error::ApiError;
use crate::api::repo::record::create_signed_commit;
use crate::api::EmptyResponse;
use crate::state::AppState;
use crate::sync::import::{ImportError, apply_import, parse_car};
use crate::sync::verify::CarVerifier;
@@ -371,18 +371,18 @@ pub async fn import_repo(
ApiError::InvalidRequest(format!("Referenced block not found in CAR: {}", cid))
.into_response()
}
Err(ImportError::ConcurrentModification) => ApiError::InvalidSwap(Some("Repository is being modified by another operation, please retry".into(),))
Err(ImportError::ConcurrentModification) => ApiError::InvalidSwap(Some(
"Repository is being modified by another operation, please retry".into(),
))
.into_response(),
Err(ImportError::VerificationFailed(ve)) => {
ApiError::InvalidRequest(format!("CAR verification failed: {}", ve)).into_response()
}
Err(ImportError::DidMismatch { car_did, auth_did }) => {
ApiError::InvalidRequest(format!(
"CAR is for {} but authenticated as {}",
car_did, auth_did
))
.into_response()
}
Err(ImportError::DidMismatch { car_did, auth_did }) => ApiError::InvalidRequest(format!(
"CAR is for {} but authenticated as {}",
car_did, auth_did
))
.into_response(),
Err(e) => {
error!("Import error: {:?}", e);
ApiError::InternalError(None).into_response()
+19 -15
View File
@@ -205,13 +205,14 @@ pub async fn apply_writes(
}
}
let user_id: uuid::Uuid = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did.as_str())
.fetch_optional(&state.db)
.await
{
Ok(Some(id)) => id,
_ => return ApiError::InternalError(Some("User not found".into())).into_response(),
};
let user_id: uuid::Uuid =
match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did.as_str())
.fetch_optional(&state.db)
.await
{
Ok(Some(id)) => id,
_ => return ApiError::InternalError(Some("User not found".into())).into_response(),
};
let root_cid_str: String = match sqlx::query_scalar!(
"SELECT repo_root_cid FROM repos WHERE user_id = $1",
user_id
@@ -225,7 +226,7 @@ pub async fn apply_writes(
let current_root_cid = match Cid::from_str(&root_cid_str) {
Ok(c) => c,
Err(_) => {
return ApiError::InternalError(Some("Invalid repo root CID".into())).into_response()
return ApiError::InternalError(Some("Invalid repo root CID".into())).into_response();
}
};
if let Some(swap_commit) = &input.swap_commit
@@ -281,7 +282,7 @@ pub async fn apply_writes(
Ok(c) => c,
Err(_) => {
return ApiError::InternalError(Some("Failed to store record".into()))
.into_response()
.into_response();
}
};
let key = format!("{}/{}", collection, rkey);
@@ -290,7 +291,7 @@ pub async fn apply_writes(
Ok(m) => m,
Err(_) => {
return ApiError::InternalError(Some("Failed to add to MST".into()))
.into_response()
.into_response();
}
};
let uri = AtUri::from_parts(&did, collection, &rkey);
@@ -335,7 +336,7 @@ pub async fn apply_writes(
Ok(c) => c,
Err(_) => {
return ApiError::InternalError(Some("Failed to store record".into()))
.into_response()
.into_response();
}
};
let key = format!("{}/{}", collection, rkey);
@@ -345,7 +346,7 @@ pub async fn apply_writes(
Ok(m) => m,
Err(_) => {
return ApiError::InternalError(Some("Failed to update MST".into()))
.into_response()
.into_response();
}
};
let uri = AtUri::from_parts(&did, collection, rkey);
@@ -369,7 +370,7 @@ pub async fn apply_writes(
Ok(m) => m,
Err(_) => {
return ApiError::InternalError(Some("Failed to delete from MST".into()))
.into_response()
.into_response();
}
};
results.push(WriteResult::DeleteResult {});
@@ -383,7 +384,9 @@ pub async fn apply_writes(
}
let new_mst_root = match mst.persist().await {
Ok(c) => c,
Err(_) => return ApiError::InternalError(Some("Failed to persist MST".into())).into_response(),
Err(_) => {
return ApiError::InternalError(Some("Failed to persist MST".into())).into_response();
}
};
let mut relevant_blocks = std::collections::BTreeMap::new();
for key in &modified_keys {
@@ -432,7 +435,8 @@ pub async fn apply_writes(
Ok(res) => res,
Err(e) => {
error!("Commit failed: {}", e);
return ApiError::InternalError(Some("Failed to commit changes".into())).into_response();
return ApiError::InternalError(Some("Failed to commit changes".into()))
.into_response();
}
};
+4 -2
View File
@@ -97,8 +97,10 @@ pub async fn delete_record(
let expected_cid = Cid::from_str(swap_record_str).ok();
let actual_cid = mst.get(&key).await.ok().flatten();
if expected_cid != actual_cid {
return ApiError::InvalidSwap(Some("Record has been modified or does not exist".into()))
.into_response();
return ApiError::InvalidSwap(Some(
"Record has been modified or does not exist".into(),
))
.into_response();
}
}
let prev_record_cid = mst.get(&key).await.ok().flatten();
+16 -9
View File
@@ -138,8 +138,9 @@ pub async fn prepare_repo_write(
ApiError::InternalError(None).into_response()
})?
.ok_or_else(|| ApiError::InternalError(Some("Repo root not found".into())).into_response())?;
let current_root_cid = Cid::from_str(&root_cid_str)
.map_err(|_| ApiError::InternalError(Some("Invalid repo root CID".into())).into_response())?;
let current_root_cid = Cid::from_str(&root_cid_str).map_err(|_| {
ApiError::InternalError(Some("Invalid repo root CID".into())).into_response()
})?;
Ok(RepoWriteAuth {
did: auth_user.did.clone(),
user_id,
@@ -247,7 +248,8 @@ pub async fn create_record(
let record_cid = match tracking_store.put(&record_bytes).await {
Ok(c) => c,
_ => {
return ApiError::InternalError(Some("Failed to save record block".into())).into_response()
return ApiError::InternalError(Some("Failed to save record block".into()))
.into_response();
}
};
let key = format!("{}/{}", input.collection, rkey);
@@ -442,8 +444,10 @@ pub async fn put_record(
let expected_cid = Cid::from_str(swap_record_str).ok();
let actual_cid = mst.get(&key).await.ok().flatten();
if expected_cid != actual_cid {
return ApiError::InvalidSwap(Some("Record has been modified or does not exist".into()))
.into_response();
return ApiError::InvalidSwap(Some(
"Record has been modified or does not exist".into(),
))
.into_response();
}
}
let existing_cid = mst.get(&key).await.ok().flatten();
@@ -455,7 +459,8 @@ pub async fn put_record(
let record_cid = match tracking_store.put(&record_bytes).await {
Ok(c) => c,
_ => {
return ApiError::InternalError(Some("Failed to save record block".into())).into_response()
return ApiError::InternalError(Some("Failed to save record block".into()))
.into_response();
}
};
if existing_cid == Some(record_cid) {
@@ -474,21 +479,23 @@ pub async fn put_record(
match mst.update(&key, record_cid).await {
Ok(m) => m,
Err(_) => {
return ApiError::InternalError(Some("Failed to update MST".into())).into_response()
return ApiError::InternalError(Some("Failed to update MST".into()))
.into_response();
}
}
} else {
match mst.add(&key, record_cid).await {
Ok(m) => m,
Err(_) => {
return ApiError::InternalError(Some("Failed to add to MST".into())).into_response()
return ApiError::InternalError(Some("Failed to add to MST".into()))
.into_response();
}
}
};
let new_mst_root = match new_mst.persist().await {
Ok(c) => c,
Err(_) => {
return ApiError::InternalError(Some("Failed to persist MST".into())).into_response()
return ApiError::InternalError(Some("Failed to persist MST".into())).into_response();
}
};
let op = if existing_cid.is_some() {
+13 -9
View File
@@ -28,7 +28,7 @@ pub struct DidResponse {
}
impl DidResponse {
pub fn new(did: impl Into<Did>) -> impl IntoResponse {
pub fn response(did: impl Into<Did>) -> impl IntoResponse {
Json(Self { did: did.into() })
}
}
@@ -40,8 +40,10 @@ pub struct TokenRequiredResponse {
}
impl TokenRequiredResponse {
pub fn new(required: bool) -> impl IntoResponse {
Json(Self { token_required: required })
pub fn response(required: bool) -> impl IntoResponse {
Json(Self {
token_required: required,
})
}
}
@@ -52,7 +54,7 @@ pub struct HasPasswordResponse {
}
impl HasPasswordResponse {
pub fn new(has_password: bool) -> impl IntoResponse {
pub fn response(has_password: bool) -> impl IntoResponse {
Json(Self { has_password })
}
}
@@ -63,7 +65,7 @@ pub struct VerifiedResponse {
}
impl VerifiedResponse {
pub fn new(verified: bool) -> impl IntoResponse {
pub fn response(verified: bool) -> impl IntoResponse {
Json(Self { verified })
}
}
@@ -74,7 +76,7 @@ pub struct EnabledResponse {
}
impl EnabledResponse {
pub fn new(enabled: bool) -> impl IntoResponse {
pub fn response(enabled: bool) -> impl IntoResponse {
Json(Self { enabled })
}
}
@@ -85,8 +87,10 @@ pub struct StatusResponse {
}
impl StatusResponse {
pub fn new(status: impl Into<String>) -> impl IntoResponse {
Json(Self { status: status.into() })
pub fn response(status: impl Into<String>) -> impl IntoResponse {
Json(Self {
status: status.into(),
})
}
}
@@ -97,7 +101,7 @@ pub struct DidDocumentResponse {
}
impl DidDocumentResponse {
pub fn new(did_document: serde_json::Value) -> impl IntoResponse {
pub fn response(did_document: serde_json::Value) -> impl IntoResponse {
Json(Self { did_document })
}
}
+25 -13
View File
@@ -1,5 +1,5 @@
use crate::api::error::ApiError;
use crate::api::EmptyResponse;
use crate::api::error::ApiError;
use crate::cache::Cache;
use crate::plc::PlcClient;
use crate::state::AppState;
@@ -74,9 +74,12 @@ pub async fn check_account_status(
return ApiError::InternalError(None).into_response();
}
};
let user_status = sqlx::query!("SELECT deactivated_at FROM users WHERE did = $1", did.as_str())
.fetch_optional(&state.db)
.await;
let user_status = sqlx::query!(
"SELECT deactivated_at FROM users WHERE did = $1",
did.as_str()
)
.fetch_optional(&state.db)
.await;
let deactivated_at = match user_status {
Ok(Some(row)) => row.deactivated_at,
_ => None,
@@ -399,7 +402,8 @@ pub async fn activate_account(
);
let did_validation_start = std::time::Instant::now();
if let Err(e) =
assert_valid_did_document_for_service(&state.db, state.cache.clone(), did.as_str(), true).await
assert_valid_did_document_for_service(&state.db, state.cache.clone(), did.as_str(), true)
.await
{
info!(
"[MIGRATION] activateAccount: DID document validation FAILED for {} (took {:?})",
@@ -423,9 +427,12 @@ pub async fn activate_account(
"[MIGRATION] activateAccount: Activating account did={} handle={:?}",
did, handle
);
let result = sqlx::query!("UPDATE users SET deactivated_at = NULL WHERE did = $1", did.as_str())
.execute(&state.db)
.await;
let result = sqlx::query!(
"UPDATE users SET deactivated_at = NULL WHERE did = $1",
did.as_str()
)
.execute(&state.db)
.await;
match result {
Ok(_) => {
info!(
@@ -440,7 +447,8 @@ pub async fn activate_account(
did
);
if let Err(e) =
crate::api::repo::record::sequence_account_event(&state, did.as_str(), true, None).await
crate::api::repo::record::sequence_account_event(&state, did.as_str(), true, None)
.await
{
warn!(
"[MIGRATION] activateAccount: Failed to sequence account activation event: {}",
@@ -453,9 +461,12 @@ pub async fn activate_account(
"[MIGRATION] activateAccount: Sequencing identity event for did={} handle={:?}",
did, handle
);
if let Err(e) =
crate::api::repo::record::sequence_identity_event(&state, did.as_str(), handle.as_deref())
.await
if let Err(e) = crate::api::repo::record::sequence_identity_event(
&state,
did.as_str(),
handle.as_deref(),
)
.await
{
warn!(
"[MIGRATION] activateAccount: Failed to sequence identity event for activation: {}",
@@ -644,7 +655,8 @@ pub async fn request_account_delete(
let did = validated.did.clone();
if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, did.as_str()).await {
return crate::api::server::reauth::legacy_mfa_required_response(&state.db, did.as_str()).await;
return crate::api::server::reauth::legacy_mfa_required_response(&state.db, did.as_str())
.await;
}
let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did.as_str())
+1 -1
View File
@@ -1,5 +1,5 @@
use crate::api::error::ApiError;
use crate::api::EmptyResponse;
use crate::api::error::ApiError;
use crate::auth::BearerAuth;
use crate::delegation::{self, DelegationActionType};
use crate::state::{AppState, RateLimitKind};
+2 -2
View File
@@ -77,7 +77,7 @@ pub async fn request_email_update(
}
info!("Email update requested for user {}", user.id);
TokenRequiredResponse::new(token_required).into_response()
TokenRequiredResponse::response(token_required).into_response()
}
#[derive(Deserialize)]
@@ -375,7 +375,7 @@ pub async fn check_email_verified(
.await;
match user {
Ok(Some(row)) => VerifiedResponse::new(row.email_verified).into_response(),
Ok(Some(row)) => VerifiedResponse::response(row.email_verified).into_response(),
Ok(None) => ApiError::AccountNotFound.into_response(),
Err(e) => {
error!("DB error checking email verified: {:?}", e);
+3 -1
View File
@@ -53,7 +53,9 @@ pub async fn create_invite_code(
return ApiError::InvalidRequest("useCount must be at least 1".into()).into_response();
}
let for_account = input.for_account.unwrap_or_else(|| auth_user.did.to_string());
let for_account = input
.for_account
.unwrap_or_else(|| auth_user.did.to_string());
let code = gen_invite_code();
match sqlx::query!(
+18 -15
View File
@@ -102,7 +102,9 @@ pub async fn create_passkey_account(
.await
{
warn!(ip = %client_ip, "Account creation rate limit exceeded");
return ApiError::RateLimitExceeded(Some("Too many account creation attempts. Please try again later.".into(),))
return ApiError::RateLimitExceeded(Some(
"Too many account creation attempts. Please try again later.".into(),
))
.into_response();
}
@@ -352,8 +354,10 @@ pub async fn create_passkey_account(
Ok(r) => r,
Err(e) => {
error!("Error creating PLC genesis operation: {:?}", e);
return ApiError::InternalError(Some("Failed to create PLC operation".into()))
.into_response();
return ApiError::InternalError(Some(
"Failed to create PLC operation".into(),
))
.into_response();
}
};
@@ -759,18 +763,17 @@ pub async fn complete_passkey_setup(
}
};
let reg_state = match crate::auth::webauthn::load_registration_state(&state.db, &input.did)
.await
{
Ok(Some(s)) => s,
Ok(None) => {
return ApiError::NoChallengeInProgress.into_response();
}
Err(e) => {
error!("Error loading registration state: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
let reg_state =
match crate::auth::webauthn::load_registration_state(&state.db, &input.did).await {
Ok(Some(s)) => s,
Ok(None) => {
return ApiError::NoChallengeInProgress.into_response();
}
Err(e) => {
error!("Error loading registration state: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
let credential: webauthn_rs::prelude::RegisterPublicKeyCredential =
match serde_json::from_value(input.passkey_credential) {
+3 -1
View File
@@ -340,7 +340,9 @@ pub async fn get_password_status(State(state): State<AppState>, auth: BearerAuth
.await;
match user {
Ok(Some(row)) => HasPasswordResponse::new(row.has_password.unwrap_or(false)).into_response(),
Ok(Some(row)) => {
HasPasswordResponse::response(row.has_password.unwrap_or(false)).into_response()
}
Ok(None) => ApiError::AccountNotFound.into_response(),
Err(e) => {
error!("DB error: {:?}", e);
+9 -4
View File
@@ -69,9 +69,12 @@ pub async fn reauth_password(
auth: BearerAuth,
Json(input): Json<PasswordReauthInput>,
) -> Response {
let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", &*&auth.0.did)
.fetch_optional(&state.db)
.await;
let user = sqlx::query!(
"SELECT password_hash FROM users WHERE did = $1",
&*&auth.0.did
)
.fetch_optional(&state.db)
.await;
let password_hash = match user {
Ok(Some(row)) => row.password_hash,
@@ -138,7 +141,9 @@ pub async fn reauth_totp(
.await
{
warn!(did = %&auth.0.did, "TOTP verification rate limit exceeded");
return ApiError::RateLimitExceeded(Some("Too many verification attempts. Please try again in a few minutes.".into(),))
return ApiError::RateLimitExceeded(Some(
"Too many verification attempts. Please try again in a few minutes.".into(),
))
.into_response();
}
+5 -3
View File
@@ -1,7 +1,7 @@
use crate::types::Did;
use crate::AccountStatus;
use crate::api::error::ApiError;
use crate::state::AppState;
use crate::types::Did;
use axum::{
Json,
extract::{Query, State},
@@ -165,8 +165,10 @@ pub async fn get_service_auth(
}
Err(e) => {
error!(error = ?e, "DB error fetching user key");
return ApiError::AuthenticationFailed(Some("Failed to get signing key".into()))
.into_response();
return ApiError::AuthenticationFailed(Some(
"Failed to get signing key".into(),
))
.into_response();
}
}
}
+4 -7
View File
@@ -479,7 +479,8 @@ pub async fn refresh_session(
}
};
if crate::auth::verify_refresh_token(&refresh_token, &key_bytes).is_err() {
return ApiError::AuthenticationFailed(Some("Invalid refresh token".into())).into_response();
return ApiError::AuthenticationFailed(Some("Invalid refresh token".into()))
.into_response();
}
let new_access_meta = match crate::auth::create_access_token_with_delegation(
&session_row.did,
@@ -566,12 +567,8 @@ pub async fn refresh_session(
let pds_hostname =
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let handle = full_handle(&u.handle, &pds_hostname);
let account_state = AccountState::from_db_fields(
u.deactivated_at,
u.takedown_ref.clone(),
None,
None,
);
let account_state =
AccountState::from_db_fields(u.deactivated_at, u.takedown_ref.clone(), None, None);
let mut response = json!({
"accessJwt": new_access_meta.token,
"refreshJwt": new_refresh_meta.token,
+26 -13
View File
@@ -28,9 +28,12 @@ pub struct CreateTotpSecretResponse {
}
pub async fn create_totp_secret(State(state): State<AppState>, auth: BearerAuth) -> Response {
let existing = sqlx::query_scalar!("SELECT verified FROM user_totp WHERE did = $1", &*&auth.0.did)
.fetch_optional(&state.db)
.await;
let existing = sqlx::query_scalar!(
"SELECT verified FROM user_totp WHERE did = $1",
&*&auth.0.did
)
.fetch_optional(&state.db)
.await;
if let Ok(Some(true)) = existing {
return ApiError::TotpAlreadyEnabled.into_response();
@@ -58,7 +61,8 @@ pub async fn create_totp_secret(State(state): State<AppState>, auth: BearerAuth)
Ok(qr) => qr,
Err(e) => {
error!("Failed to generate QR code: {:?}", e);
return ApiError::InternalError(Some("Failed to generate QR code".into())).into_response();
return ApiError::InternalError(Some("Failed to generate QR code".into()))
.into_response();
}
};
@@ -247,9 +251,12 @@ pub async fn disable_totp(
return ApiError::RateLimitExceeded(None).into_response();
}
let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", &*&auth.0.did)
.fetch_optional(&state.db)
.await;
let user = sqlx::query!(
"SELECT password_hash FROM users WHERE did = $1",
&*&auth.0.did
)
.fetch_optional(&state.db)
.await;
let password_hash = match user {
Ok(Some(row)) => row.password_hash,
@@ -346,9 +353,12 @@ pub struct GetTotpStatusResponse {
}
pub async fn get_totp_status(State(state): State<AppState>, auth: BearerAuth) -> Response {
let totp_row = sqlx::query!("SELECT verified FROM user_totp WHERE did = $1", &*&auth.0.did)
.fetch_optional(&state.db)
.await;
let totp_row = sqlx::query!(
"SELECT verified FROM user_totp WHERE did = $1",
&*&auth.0.did
)
.fetch_optional(&state.db)
.await;
let enabled = match totp_row {
Ok(Some(row)) => row.verified,
@@ -401,9 +411,12 @@ pub async fn regenerate_backup_codes(
return ApiError::RateLimitExceeded(None).into_response();
}
let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", &*&auth.0.did)
.fetch_optional(&state.db)
.await;
let user = sqlx::query!(
"SELECT password_hash FROM users WHERE did = $1",
&*&auth.0.did
)
.fetch_optional(&state.db)
.await;
let password_hash = match user {
Ok(Some(row)) => row.password_hash,
+6 -3
View File
@@ -1,5 +1,5 @@
use crate::api::error::ApiError;
use crate::api::SuccessResponse;
use crate::api::error::ApiError;
use axum::{
Json,
extract::State,
@@ -87,7 +87,8 @@ pub async fn list_trusted_devices(State(state): State<AppState>, auth: BearerAut
let devices = rows
.into_iter()
.map(|row| {
let trust_state = DeviceTrustState::from_timestamps(row.trusted_at, row.trusted_until);
let trust_state =
DeviceTrustState::from_timestamps(row.trusted_at, row.trusted_until);
TrustedDevice {
id: row.id,
user_agent: row.user_agent,
@@ -230,7 +231,9 @@ pub async fn get_device_trust_state(db: &PgPool, device_id: &str, did: &str) ->
}
pub async fn is_device_trusted(db: &PgPool, device_id: &str, did: &str) -> bool {
get_device_trust_state(db, device_id, did).await.is_trusted()
get_device_trust_state(db, device_id, did)
.await
.is_trusted()
}
pub async fn trust_device(db: &PgPool, device_id: &str) -> Result<(), sqlx::Error> {
+1 -1
View File
@@ -33,7 +33,7 @@ pub async fn verify_migration_email(
Ok(Json(VerifyMigrationEmailOutput {
success: result.success,
did: result.did.clone().into(),
did: result.did.clone(),
}))
}
+13 -3
View File
@@ -80,7 +80,11 @@ impl fmt::Display for EmailValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => write!(f, "Email cannot be empty"),
Self::TooLong => write!(f, "Email exceeds maximum length of {} characters", MAX_EMAIL_LENGTH),
Self::TooLong => write!(
f,
"Email exceeds maximum length of {} characters",
MAX_EMAIL_LENGTH
),
Self::MissingAtSign => write!(f, "Email must contain @"),
Self::EmptyLocalPart => write!(f, "Email local part cannot be empty"),
Self::LocalPartTooLong => write!(f, "Email local part exceeds maximum length"),
@@ -115,11 +119,17 @@ impl ValidatedEmail {
}
pub fn local_part(&self) -> &str {
self.0.rsplitn(2, '@').nth(1).unwrap_or("")
self.0
.rsplit_once('@')
.map(|(local, _)| local)
.unwrap_or("")
}
pub fn domain(&self) -> &str {
self.0.rsplitn(2, '@').next().unwrap_or("")
self.0
.rsplit_once('@')
.map(|(_, domain)| domain)
.unwrap_or("")
}
}
+6 -2
View File
@@ -146,7 +146,9 @@ impl FromRequestParts<AppState> for BearerAuth {
Err(_) => Err(AuthError::AuthenticationFailed),
}
} else {
match validate_bearer_token_cached(&state.db, state.cache.as_ref(), &extracted.token).await {
match validate_bearer_token_cached(&state.db, state.cache.as_ref(), &extracted.token)
.await
{
Ok(user) => Ok(BearerAuth(user)),
Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated),
Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown),
@@ -262,7 +264,9 @@ impl FromRequestParts<AppState> for BearerAuthAdmin {
Err(_) => return Err(AuthError::AuthenticationFailed),
}
} else {
match validate_bearer_token_cached(&state.db, state.cache.as_ref(), &extracted.token).await {
match validate_bearer_token_cached(&state.db, state.cache.as_ref(), &extracted.token)
.await
{
Ok(user) => user,
Err(TokenValidationError::AccountDeactivated) => {
return Err(AuthError::AccountDeactivated);
+3 -5
View File
@@ -3,10 +3,10 @@ use sqlx::PgPool;
use std::fmt;
use std::time::Duration;
use crate::types::Did;
use crate::AccountStatus;
use crate::cache::Cache;
use crate::oauth::scopes::ScopePermissions;
use crate::types::Did;
pub mod extractor;
pub mod scope_check;
@@ -334,10 +334,8 @@ async fn validate_bearer_token_with_options_internal(
.act
.as_ref()
.map(|a| Did::new_unchecked(a.sub.clone()));
let status = AccountStatus::from_db_fields(
takedown_ref.as_deref(),
deactivated_at,
);
let status =
AccountStatus::from_db_fields(takedown_ref.as_deref(), deactivated_at);
return Ok(AuthenticatedUser {
did: Did::new_unchecked(did.clone()),
key_bytes: Some(decrypted_key),
+2 -2
View File
@@ -24,8 +24,6 @@ pub mod util;
pub mod validation;
use api::proxy::XrpcProxyLayer;
pub use sync::util::AccountStatus;
pub use types::{AccountState, AtIdentifier, AtUri, Did, Handle, Nsid, Rkey};
use axum::{
Json, Router,
extract::DefaultBodyLimit,
@@ -36,9 +34,11 @@ use axum::{
use http::StatusCode;
use serde_json::json;
use state::AppState;
pub use sync::util::AccountStatus;
use tower::ServiceBuilder;
use tower_http::cors::{Any, CorsLayer};
use tower_http::services::{ServeDir, ServeFile};
pub use types::{AccountState, AtIdentifier, AtUri, Did, Handle, Nsid, Rkey};
pub fn app(state: AppState) -> Router {
let xrpc_router = Router::new()
+3 -1
View File
@@ -1,4 +1,6 @@
use super::super::{AuthFlowState, AuthorizationRequestParameters, ClientAuth, OAuthError, RequestData};
use super::super::{
AuthFlowState, AuthorizationRequestParameters, ClientAuth, OAuthError, RequestData,
};
use super::helpers::{from_json, to_json};
use sqlx::PgPool;
+29 -8
View File
@@ -4,10 +4,21 @@ use chrono::{DateTime, Utc};
use sqlx::PgPool;
pub enum RefreshTokenLookup {
Valid { db_id: i32, token_data: TokenData },
InGracePeriod { db_id: i32, token_data: TokenData, rotated_at: DateTime<Utc> },
Used { original_token_id: i32 },
Expired { db_id: i32 },
Valid {
db_id: i32,
token_data: TokenData,
},
InGracePeriod {
db_id: i32,
token_data: TokenData,
rotated_at: DateTime<Utc>,
},
Used {
original_token_id: i32,
},
Expired {
db_id: i32,
},
NotFound,
}
@@ -16,7 +27,9 @@ impl RefreshTokenLookup {
match self {
RefreshTokenLookup::Valid { .. } => RefreshTokenState::Valid,
RefreshTokenLookup::InGracePeriod { rotated_at, .. } => {
RefreshTokenState::InGracePeriod { rotated_at: *rotated_at }
RefreshTokenState::InGracePeriod {
rotated_at: *rotated_at,
}
}
RefreshTokenLookup::Used { .. } => RefreshTokenState::Used { at: Utc::now() },
RefreshTokenLookup::Expired { .. } => RefreshTokenState::Expired,
@@ -30,11 +43,19 @@ pub async fn lookup_refresh_token(
refresh_token: &str,
) -> Result<RefreshTokenLookup, OAuthError> {
if let Some(token_id) = check_refresh_token_used(pool, refresh_token).await? {
if let Some((db_id, token_data)) = get_token_by_previous_refresh_token(pool, refresh_token).await? {
if let Some((db_id, token_data)) =
get_token_by_previous_refresh_token(pool, refresh_token).await?
{
let rotated_at = token_data.updated_at;
return Ok(RefreshTokenLookup::InGracePeriod { db_id, token_data, rotated_at });
return Ok(RefreshTokenLookup::InGracePeriod {
db_id,
token_data,
rotated_at,
});
}
return Ok(RefreshTokenLookup::Used { original_token_id: token_id });
return Ok(RefreshTokenLookup::Used {
original_token_id: token_id,
});
}
match get_token_by_refresh_token(pool, refresh_token).await? {
+26 -9
View File
@@ -24,10 +24,16 @@ pub async fn handle_authorization_code_grant(
dpop_proof: Option<String>,
) -> Result<(HeaderMap, Json<TokenResponse>), OAuthError> {
let (code, code_verifier, redirect_uri) = match request.grant {
TokenGrant::AuthorizationCode { code, code_verifier, redirect_uri } => {
(code, code_verifier, redirect_uri)
TokenGrant::AuthorizationCode {
code,
code_verifier,
redirect_uri,
} => (code, code_verifier, redirect_uri),
_ => {
return Err(OAuthError::InvalidRequest(
"Expected authorization_code grant".to_string(),
));
}
_ => return Err(OAuthError::InvalidRequest("Expected authorization_code grant".to_string())),
};
let auth_request = db::consume_authorization_request_by_code(&state.db, &code)
.await?
@@ -53,9 +59,10 @@ pub async fn handle_authorization_code_grant(
let did = flow_state.did().unwrap().to_string();
let client_metadata_cache = ClientMetadataCache::new(3600);
let client_metadata = client_metadata_cache.get(&auth_request.client_id).await?;
let client_auth = if let (Some(assertion), Some(assertion_type)) =
(&request.client_auth.client_assertion, &request.client_auth.client_assertion_type)
{
let client_auth = if let (Some(assertion), Some(assertion_type)) = (
&request.client_auth.client_assertion,
&request.client_auth.client_assertion_type,
) {
if assertion_type != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
return Err(OAuthError::InvalidClient(
"Unsupported client_assertion_type".to_string(),
@@ -198,7 +205,11 @@ pub async fn handle_refresh_token_grant(
) -> Result<(HeaderMap, Json<TokenResponse>), OAuthError> {
let refresh_token_str = match request.grant {
TokenGrant::RefreshToken { refresh_token } => refresh_token,
_ => return Err(OAuthError::InvalidRequest("Expected refresh_token grant".to_string())),
_ => {
return Err(OAuthError::InvalidRequest(
"Expected refresh_token grant".to_string(),
));
}
};
let token_prefix = &refresh_token_str[..std::cmp::min(16, refresh_token_str.len())];
tracing::info!(
@@ -213,7 +224,11 @@ pub async fn handle_refresh_token_grant(
let (db_id, token_data) = match lookup {
RefreshTokenLookup::Valid { db_id, token_data } => (db_id, token_data),
RefreshTokenLookup::InGracePeriod { db_id: _, token_data, rotated_at } => {
RefreshTokenLookup::InGracePeriod {
db_id: _,
token_data,
rotated_at,
} => {
tracing::info!(
refresh_token_prefix = %token_prefix,
rotated_at = %rotated_at,
@@ -262,7 +277,9 @@ pub async fn handle_refresh_token_grant(
}
RefreshTokenLookup::NotFound => {
tracing::warn!(refresh_token_prefix = %token_prefix, "Refresh token not found");
return Err(OAuthError::InvalidGrant("Invalid refresh token".to_string()));
return Err(OAuthError::InvalidGrant(
"Invalid refresh token".to_string(),
));
}
};
let dpop_jkt = if let Some(proof) = &dpop_proof {
+3 -1
View File
@@ -13,7 +13,9 @@ pub use helpers::{TokenClaims, create_access_token, extract_token_claims, verify
pub use introspect::{
IntrospectRequest, IntrospectResponse, RevokeRequest, introspect_token, revoke_token,
};
pub use types::{ClientAuthParams, GrantType, TokenGrant, TokenRequest, TokenResponse, ValidatedTokenRequest};
pub use types::{
ClientAuthParams, GrantType, TokenGrant, TokenRequest, TokenResponse, ValidatedTokenRequest,
};
fn extract_client_ip(headers: &HeaderMap) -> String {
if let Some(forwarded) = headers.get("x-forwarded-for")
+9 -3
View File
@@ -101,10 +101,14 @@ impl TokenRequest {
let grant = match self.grant_type {
GrantType::AuthorizationCode => {
let code = self.code.ok_or_else(|| {
OAuthError::InvalidRequest("code is required for authorization_code grant".to_string())
OAuthError::InvalidRequest(
"code is required for authorization_code grant".to_string(),
)
})?;
let code_verifier = self.code_verifier.ok_or_else(|| {
OAuthError::InvalidRequest("code_verifier is required for authorization_code grant".to_string())
OAuthError::InvalidRequest(
"code_verifier is required for authorization_code grant".to_string(),
)
})?;
TokenGrant::AuthorizationCode {
code,
@@ -114,7 +118,9 @@ impl TokenRequest {
}
GrantType::RefreshToken => {
let refresh_token = self.refresh_token.ok_or_else(|| {
OAuthError::InvalidRequest("refresh_token is required for refresh_token grant".to_string())
OAuthError::InvalidRequest(
"refresh_token is required for refresh_token grant".to_string(),
)
})?;
TokenGrant::RefreshToken { refresh_token }
}
+3 -1
View File
@@ -144,7 +144,9 @@ fn parse_query_params(query: &str) -> HashMap<String, Vec<String>> {
.split('&')
.filter_map(|part| part.split_once('='))
.fold(HashMap::new(), |mut acc, (key, value)| {
acc.entry(key.to_string()).or_default().push(value.to_string());
acc.entry(key.to_string())
.or_default()
.push(value.to_string());
acc
})
}
+21 -5
View File
@@ -249,8 +249,15 @@ pub struct Jwks {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthFlowState {
Pending,
Authenticated { did: String, device_id: Option<String> },
Authorized { did: String, device_id: Option<String>, code: String },
Authenticated {
did: String,
device_id: Option<String>,
},
Authorized {
did: String,
device_id: Option<String>,
code: String,
},
Expired,
}
@@ -324,7 +331,12 @@ impl std::fmt::Display for AuthFlowState {
AuthFlowState::Pending => write!(f, "pending"),
AuthFlowState::Authenticated { did, .. } => write!(f, "authenticated ({})", did),
AuthFlowState::Authorized { did, code, .. } => {
write!(f, "authorized ({}, code={}...)", did, &code[..8.min(code.len())])
write!(
f,
"authorized ({}, code={}...)",
did,
&code[..8.min(code.len())]
)
}
AuthFlowState::Expired => write!(f, "expired"),
}
@@ -334,8 +346,12 @@ impl std::fmt::Display for AuthFlowState {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RefreshTokenState {
Valid,
Used { at: chrono::DateTime<chrono::Utc> },
InGracePeriod { rotated_at: chrono::DateTime<chrono::Utc> },
Used {
at: chrono::DateTime<chrono::Utc>,
},
InGracePeriod {
rotated_at: chrono::DateTime<chrono::Utc>,
},
Expired,
Revoked,
}

Some files were not shown because too many files have changed in this diff Show More