mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-04 01:06:55 +00:00
Typechecks and linting
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+31
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+104
-78
@@ -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 {
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
/=+$/,
|
||||
"",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
>;
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -80,7 +80,11 @@
|
||||
$effect(() => {
|
||||
if (dropdownOpen) {
|
||||
document.addEventListener('click', closeDropdown)
|
||||
return () => document.removeEventListener('click', closeDropdown)
|
||||
}
|
||||
return () => {
|
||||
if (dropdownOpen) {
|
||||
document.removeEventListener('click', closeDropdown)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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"),
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {}: {}",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -119,10 +119,8 @@ pub async fn get_subject_status(
|
||||
let did = match ¶ms.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!(
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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())
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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};
|
||||
|
||||
@@ -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,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
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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("")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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? {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user