Fixed up did:web account creation

This commit is contained in:
lewis
2025-12-23 20:27:17 +02:00
parent 217a3f1197
commit 014d4b57f0
16 changed files with 1537 additions and 518 deletions

10
frontend/deno.lock generated
View File

@@ -1,11 +1,13 @@
{
"version": "5",
"specifiers": {
"npm:@noble/secp256k1@^2.1.0": "2.3.0",
"npm:@sveltejs/vite-plugin-svelte@5": "5.1.1_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3",
"npm:@testing-library/jest-dom@^6.6.3": "6.9.1",
"npm:@testing-library/svelte@^5.2.6": "5.2.9_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3_vitest@2.1.9__jsdom@25.0.1__vite@5.4.21_jsdom@25.0.1",
"npm:@testing-library/user-event@^14.5.2": "14.6.1_@testing-library+dom@10.4.1",
"npm:jsdom@^25.0.1": "25.0.1",
"npm:multiformats@^13.3.1": "13.4.2",
"npm:svelte-i18n@^4.0.1": "4.0.1_svelte@5.45.10__acorn@8.15.0",
"npm:svelte@5": "5.45.10_acorn@8.15.0",
"npm:vite@*": "6.4.1_picomatch@4.0.3",
@@ -492,6 +494,9 @@
"@jridgewell/sourcemap-codec"
]
},
"@noble/secp256k1@2.3.0": {
"integrity": "sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw=="
},
"@rollup/rollup-android-arm-eabi@4.53.3": {
"integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
"os": ["android"],
@@ -1281,6 +1286,9 @@
"ms@2.1.3": {
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"multiformats@13.4.2": {
"integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ=="
},
"nanoid@3.3.11": {
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"bin": true
@@ -1636,11 +1644,13 @@
"workspace": {
"packageJson": {
"dependencies": [
"npm:@noble/secp256k1@^2.1.0",
"npm:@sveltejs/vite-plugin-svelte@5",
"npm:@testing-library/jest-dom@^6.6.3",
"npm:@testing-library/svelte@^5.2.6",
"npm:@testing-library/user-event@^14.5.2",
"npm:jsdom@^25.0.1",
"npm:multiformats@^13.3.1",
"npm:svelte-i18n@^4.0.1",
"npm:svelte@5",
"npm:vite@6",

View File

@@ -12,6 +12,8 @@
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@noble/secp256k1": "^2.1.0",
"multiformats": "^13.3.1",
"svelte-i18n": "^4.0.1"
},
"devDependencies": {

View File

@@ -95,6 +95,7 @@ export interface CreateAccountParams {
inviteCode?: string
didType?: DidType
did?: string
signingKey?: string
verificationChannel?: VerificationChannel
discordId?: string
telegramUsername?: string
@@ -120,22 +121,34 @@ export interface ConfirmSignupResult {
}
export const api = {
async createAccount(params: CreateAccountParams): Promise<CreateAccountResult> {
return xrpc('com.atproto.server.createAccount', {
async createAccount(params: CreateAccountParams, byodToken?: string): Promise<CreateAccountResult> {
const url = `${API_BASE}/com.atproto.server.createAccount`
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (byodToken) {
headers['Authorization'] = `Bearer ${byodToken}`
}
const response = await fetch(url, {
method: 'POST',
body: {
headers,
body: JSON.stringify({
handle: params.handle,
email: params.email,
password: params.password,
inviteCode: params.inviteCode,
didType: params.didType,
did: params.did,
signingKey: params.signingKey,
verificationChannel: params.verificationChannel,
discordId: params.discordId,
telegramUsername: params.telegramUsername,
signalNumber: params.signalNumber,
},
}),
})
const data = await response.json()
if (!response.ok) {
throw new ApiError(data.error, data.message, response.status)
}
return data
},
async confirmSignup(did: string, verificationCode: string): Promise<ConfirmSignupResult> {
@@ -750,6 +763,29 @@ export const api = {
})
},
async reserveSigningKey(did?: string): Promise<{ signingKey: string }> {
return xrpc('com.atproto.server.reserveSigningKey', {
method: 'POST',
body: { did },
})
},
async getRecommendedDidCredentials(token: string): Promise<{
rotationKeys?: string[]
alsoKnownAs?: string[]
verificationMethods?: { atproto?: string }
services?: { atproto_pds?: { type: string; endpoint: string } }
}> {
return xrpc('com.atproto.identity.getRecommendedDidCredentials', { token })
},
async activateAccount(token: string): Promise<void> {
await xrpc('com.atproto.server.activateAccount', {
method: 'POST',
token,
})
},
async createPasskeyAccount(params: {
handle: string
email?: string
@@ -761,16 +797,29 @@ export const api = {
discordId?: string
telegramUsername?: string
signalNumber?: string
}): Promise<{
}, byodToken?: string): Promise<{
did: string
handle: string
setupToken: string
setupExpiresAt: string
}> {
return xrpc('com.tranquil.account.createPasskeyAccount', {
const url = `${API_BASE}/com.tranquil.account.createPasskeyAccount`
const headers: Record<string, string> = {
'Content-Type': 'application/json'
}
if (byodToken) {
headers['Authorization'] = `Bearer ${byodToken}`
}
const res = await fetch(url, {
method: 'POST',
body: params,
headers,
body: JSON.stringify(params),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText }))
throw new ApiError(res.status, err.error, err.message)
}
return res.json()
},
async startPasskeyRegistrationForSetup(did: string, setupToken: string, friendlyName?: string): Promise<{ options: unknown }> {

View File

@@ -265,6 +265,18 @@ export async function resendVerification(did: string): Promise<void> {
}
}
export function setSession(session: { did: string; handle: string; accessJwt: string; refreshJwt: string }): void {
const newSession: Session = {
did: session.did,
handle: session.handle,
accessJwt: session.accessJwt,
refreshJwt: session.refreshJwt,
}
state.session = newSession
saveSession(newSession)
addOrUpdateSavedAccount(newSession)
}
export async function logout(): Promise<void> {
if (state.session) {
try {

106
frontend/src/lib/crypto.ts Normal file
View File

@@ -0,0 +1,106 @@
import * as secp from '@noble/secp256k1'
import { base58btc } from 'multiformats/bases/base58'
const SECP256K1_MULTICODEC_PREFIX = new Uint8Array([0xe7, 0x01])
export interface Keypair {
privateKey: Uint8Array
publicKey: Uint8Array
publicKeyMultibase: string
publicKeyDidKey: string
}
export async function generateKeypair(): Promise<Keypair> {
const privateKey = secp.utils.randomPrivateKey()
const publicKey = secp.getPublicKey(privateKey, true)
const multicodecKey = new Uint8Array(SECP256K1_MULTICODEC_PREFIX.length + publicKey.length)
multicodecKey.set(SECP256K1_MULTICODEC_PREFIX, 0)
multicodecKey.set(publicKey, SECP256K1_MULTICODEC_PREFIX.length)
const publicKeyMultibase = base58btc.encode(multicodecKey)
const publicKeyDidKey = `did:key:${publicKeyMultibase}`
return {
privateKey,
publicKey,
publicKeyMultibase,
publicKeyDidKey,
}
}
function base64UrlEncode(data: Uint8Array | string): string {
const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data
let binary = ''
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
}
export async function createServiceJwt(
privateKey: Uint8Array,
issuerDid: string,
audienceDid: string,
lxm: string
): Promise<string> {
const header = {
alg: 'ES256K',
typ: 'JWT',
}
const now = Math.floor(Date.now() / 1000)
const payload = {
iss: issuerDid,
sub: issuerDid,
aud: audienceDid,
exp: now + 180,
iat: now,
lxm: lxm,
}
const headerEncoded = base64UrlEncode(JSON.stringify(header))
const payloadEncoded = base64UrlEncode(JSON.stringify(payload))
const message = `${headerEncoded}.${payloadEncoded}`
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 signatureEncoded = base64UrlEncode(sigBytes)
return `${message}.${signatureEncoded}`
}
export function generateDidDocument(
did: string,
publicKeyMultibase: string,
handle: string,
pdsEndpoint: string
): object {
return {
'@context': [
'https://www.w3.org/ns/did/v1',
'https://w3id.org/security/multikey/v1',
'https://w3id.org/security/suites/secp256k1-2019/v1',
],
id: did,
alsoKnownAs: [`at://${handle}`],
verificationMethod: [
{
id: `${did}#atproto`,
type: 'Multikey',
controller: did,
publicKeyMultibase: publicKeyMultibase,
},
],
service: [
{
id: '#atproto_pds',
type: 'AtprotoPersonalDataServer',
serviceEndpoint: pdsEndpoint,
},
],
}
}

View File

@@ -0,0 +1,121 @@
<script lang="ts">
import type { RegistrationFlow } from './flow.svelte'
interface Props {
flow: RegistrationFlow
}
let { flow }: Props = $props()
let copied = $state(false)
let acknowledged = $state(false)
function copyToClipboard() {
if (flow.account?.appPassword) {
navigator.clipboard.writeText(flow.account.appPassword)
copied = true
}
}
</script>
<div class="app-password-step">
<div class="warning-box">
<strong>Important: Save this app password!</strong>
<p>
This app password is required to sign into apps that don't support passkeys yet (like bsky.app).
You will only see this password once.
</p>
</div>
<div class="app-password-display">
<div class="app-password-label">
App Password for: <strong>{flow.account?.appPasswordName}</strong>
</div>
<code class="app-password-code">{flow.account?.appPassword}</code>
<button type="button" class="copy-btn" onclick={copyToClipboard}>
{copied ? 'Copied!' : 'Copy to Clipboard'}
</button>
</div>
<div class="field">
<label class="checkbox-label">
<input type="checkbox" bind:checked={acknowledged} />
<span>I have saved my app password in a secure location</span>
</label>
</div>
<button onclick={() => flow.proceedFromAppPassword()} disabled={!acknowledged}>
Continue
</button>
</div>
<style>
.app-password-step {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.warning-box {
padding: var(--space-5);
background: var(--warning-bg);
border: 1px solid var(--warning-border);
border-radius: var(--radius-lg);
font-size: var(--text-sm);
}
.warning-box strong {
display: block;
margin-bottom: var(--space-3);
color: var(--warning-text);
}
.warning-box p {
margin: 0;
color: var(--warning-text);
}
.app-password-display {
background: var(--bg-card);
border: 2px solid var(--accent);
border-radius: var(--radius-xl);
padding: var(--space-6);
text-align: center;
}
.app-password-label {
font-size: var(--text-sm);
color: var(--text-secondary);
margin-bottom: var(--space-4);
}
.app-password-code {
display: block;
font-size: var(--text-xl);
font-family: ui-monospace, monospace;
letter-spacing: 0.1em;
padding: var(--space-5);
background: var(--bg-input);
border-radius: var(--radius-md);
margin-bottom: var(--space-4);
user-select: all;
}
.copy-btn {
padding: var(--space-3) var(--space-5);
font-size: var(--text-sm);
}
.checkbox-label {
display: flex;
align-items: center;
gap: var(--space-3);
cursor: pointer;
font-weight: var(--font-normal);
}
.checkbox-label input[type="checkbox"] {
width: auto;
padding: 0;
}
</style>

View File

@@ -0,0 +1,166 @@
<script lang="ts">
import type { RegistrationFlow } from './flow.svelte'
interface Props {
flow: RegistrationFlow
type: 'initial' | 'updated'
onConfirm: () => void
onBack?: () => void
}
let { flow, type, onConfirm, onBack }: Props = $props()
let copied = $state(false)
let confirmed = $state(false)
const didDocument = $derived(
type === 'initial'
? flow.externalDidWeb.initialDidDocument
: flow.externalDidWeb.updatedDidDocument
)
const title = $derived(
type === 'initial'
? 'Step 1: Upload your DID document'
: 'Step 2: Update your DID document'
)
const description = $derived(
type === 'initial'
? 'Copy the JSON below and save it at:'
: 'The PDS has assigned a new signing key for your account. Update your DID document with this new key:'
)
const confirmLabel = $derived(
type === 'initial'
? 'I have uploaded the DID document to my domain'
: 'I have updated the DID document on my domain'
)
const buttonLabel = $derived(
type === 'initial' ? 'Continue' : 'Activate Account'
)
function copyToClipboard() {
if (didDocument) {
navigator.clipboard.writeText(didDocument)
copied = true
}
}
function handleConfirm() {
if (!confirmed) {
flow.setError(`Please confirm you have ${type === 'initial' ? 'uploaded' : 'updated'} the DID document`)
return
}
onConfirm()
}
</script>
<div class="did-doc-step">
<div class="warning-box">
<strong>{title}</strong>
<p>{description}</p>
<code class="did-url">https://{flow.extractDomain(flow.info.externalDid || '')}/.well-known/did.json</code>
</div>
<div class="did-doc-display">
<pre class="did-doc-code">{didDocument}</pre>
<button type="button" class="copy-btn" onclick={copyToClipboard}>
{copied ? 'Copied!' : 'Copy to Clipboard'}
</button>
</div>
<div class="field">
<label class="checkbox-label">
<input type="checkbox" bind:checked={confirmed} />
<span>{confirmLabel}</span>
</label>
</div>
<button onclick={handleConfirm} disabled={flow.state.submitting || !confirmed}>
{flow.state.submitting ? (type === 'initial' ? 'Creating account...' : 'Activating...') : buttonLabel}
</button>
{#if onBack}
<button type="button" class="secondary" onclick={onBack} disabled={flow.state.submitting}>
Back
</button>
{/if}
</div>
<style>
.did-doc-step {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.warning-box {
padding: var(--space-5);
background: var(--warning-bg);
border: 1px solid var(--warning-border);
border-radius: var(--radius-lg);
font-size: var(--text-sm);
}
.warning-box strong {
display: block;
margin-bottom: var(--space-3);
color: var(--warning-text);
}
.warning-box p {
margin: 0;
color: var(--warning-text);
}
.did-url {
display: block;
margin-top: var(--space-3);
padding: var(--space-3);
background: var(--bg-input);
border-radius: var(--radius-md);
font-size: var(--text-sm);
word-break: break-all;
}
.did-doc-display {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
overflow: hidden;
}
.did-doc-code {
margin: 0;
padding: var(--space-4);
background: var(--bg-input);
font-size: var(--text-xs);
overflow-x: auto;
white-space: pre;
max-height: 300px;
overflow-y: auto;
}
.copy-btn {
width: 100%;
border-radius: 0;
margin: 0;
padding: var(--space-3) var(--space-5);
font-size: var(--text-sm);
}
.checkbox-label {
display: flex;
align-items: center;
gap: var(--space-3);
cursor: pointer;
font-weight: var(--font-normal);
}
.checkbox-label input[type="checkbox"] {
width: auto;
padding: 0;
}
</style>

View File

@@ -0,0 +1,117 @@
<script lang="ts">
import type { RegistrationFlow } from './flow.svelte'
interface Props {
flow: RegistrationFlow
}
let { flow }: Props = $props()
</script>
<div class="key-choice-step">
<div class="info-box">
<strong>External did:web Setup</strong>
<p>
To use your own domain ({flow.extractDomain(flow.info.externalDid || '')}) as your identity,
you'll need to host a DID document. Choose how you'd like to set up the signing key:
</p>
</div>
<div class="key-choice-options">
<button
class="key-choice-btn"
onclick={() => flow.selectKeyMode('reserved')}
disabled={flow.state.submitting}
>
<span class="key-choice-title">Let the PDS generate a key</span>
<span class="key-choice-desc">Simpler setup - we'll provide the public key for your DID document</span>
</button>
<button
class="key-choice-btn"
onclick={() => flow.selectKeyMode('byod')}
disabled={flow.state.submitting}
>
<span class="key-choice-title">I'll provide my own key</span>
<span class="key-choice-desc">Advanced - generate a key in your browser for initial authentication</span>
</button>
</div>
{#if flow.state.submitting}
<p class="loading">Generating key...</p>
{/if}
<button type="button" class="secondary" onclick={() => flow.goBack()} disabled={flow.state.submitting}>
Back
</button>
</div>
<style>
.key-choice-step {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.info-box {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--space-5);
font-size: var(--text-sm);
}
.info-box strong {
display: block;
margin-bottom: var(--space-3);
}
.info-box p {
margin: 0;
color: var(--text-secondary);
}
.key-choice-options {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.key-choice-btn {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-5);
background: var(--bg-card);
border: 2px solid var(--border-color);
border-radius: var(--radius-lg);
text-align: left;
cursor: pointer;
transition: border-color 0.2s;
}
.key-choice-btn:hover:not(:disabled) {
border-color: var(--accent);
}
.key-choice-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.key-choice-title {
font-weight: var(--font-semibold);
color: var(--text-primary);
}
.key-choice-desc {
font-size: var(--text-sm);
color: var(--text-secondary);
}
.loading {
text-align: center;
color: var(--text-secondary);
}
</style>

View File

@@ -0,0 +1,103 @@
<script lang="ts">
import { api, ApiError } from '../api'
import type { RegistrationFlow } from './flow.svelte'
interface Props {
flow: RegistrationFlow
}
let { flow }: Props = $props()
let verificationCode = $state('')
let resending = $state(false)
let resendMessage = $state<string | null>(null)
function channelLabel(ch: string): string {
switch (ch) {
case 'email': return 'email'
case 'discord': return 'Discord'
case 'telegram': return 'Telegram'
case 'signal': return 'Signal'
default: return ch
}
}
async function handleSubmit(e: Event) {
e.preventDefault()
if (!verificationCode.trim()) return
resendMessage = null
await flow.verifyAccount(verificationCode)
}
async function handleResend() {
if (resending || !flow.account) return
resending = true
resendMessage = null
flow.clearError()
try {
const { resendVerification } = await import('../auth.svelte')
await resendVerification(flow.account.did)
resendMessage = 'Verification code resent!'
} catch (err) {
if (err instanceof ApiError) {
flow.setError(err.message || 'Failed to resend code')
} else if (err instanceof Error) {
flow.setError(err.message || 'Failed to resend code')
} else {
flow.setError('Failed to resend code')
}
} finally {
resending = false
}
}
</script>
<div class="verification-step">
<p class="info-text">
We've sent a verification code to your {channelLabel(flow.info.verificationChannel)}.
Enter it below to continue.
</p>
{#if resendMessage}
<div class="message success">{resendMessage}</div>
{/if}
<form onsubmit={handleSubmit}>
<div class="field">
<label for="verification-code">Verification Code</label>
<input
id="verification-code"
type="text"
bind:value={verificationCode}
placeholder="Enter 6-digit code"
disabled={flow.state.submitting}
required
maxlength="6"
inputmode="numeric"
autocomplete="one-time-code"
/>
</div>
<button type="submit" disabled={flow.state.submitting || !verificationCode.trim()}>
{flow.state.submitting ? 'Verifying...' : 'Verify'}
</button>
<button type="button" class="secondary" onclick={handleResend} disabled={resending}>
{resending ? 'Resending...' : 'Resend Code'}
</button>
</form>
</div>
<style>
.verification-step {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.info-text {
color: var(--text-secondary);
margin: 0;
}
</style>

View File

@@ -0,0 +1,340 @@
import { api, ApiError } from '../api'
import { generateKeypair, createServiceJwt, generateDidDocument } from '../crypto'
import type {
RegistrationMode,
RegistrationStep,
RegistrationInfo,
ExternalDidWebState,
AccountResult,
SessionState,
} from './types'
export interface RegistrationFlowState {
mode: RegistrationMode
step: RegistrationStep
info: RegistrationInfo
externalDidWeb: ExternalDidWebState
account: AccountResult | null
session: SessionState | null
error: string | null
submitting: boolean
pdsHostname: string
}
export function createRegistrationFlow(mode: RegistrationMode, pdsHostname: string) {
let state = $state<RegistrationFlowState>({
mode,
step: 'info',
info: {
handle: '',
email: '',
password: '',
inviteCode: '',
didType: 'plc',
externalDid: '',
verificationChannel: 'email',
discordId: '',
telegramUsername: '',
signalNumber: '',
},
externalDidWeb: {
keyMode: 'reserved',
},
account: null,
session: null,
error: null,
submitting: false,
pdsHostname,
})
function getPdsEndpoint(): string {
return `https://${state.pdsHostname}`
}
function getPdsDid(): string {
return `did:web:${state.pdsHostname}`
}
function getFullHandle(): string {
return `${state.info.handle.trim()}.${state.pdsHostname}`
}
function extractDomain(did: string): string {
return did.replace('did:web:', '').replace(/%3A/g, ':')
}
function setError(err: unknown) {
if (err instanceof ApiError) {
state.error = err.message || 'An error occurred'
} else if (err instanceof Error) {
state.error = err.message || 'An error occurred'
} else {
state.error = 'An error occurred'
}
}
async function proceedFromInfo() {
state.error = null
if (state.info.didType === 'web-external') {
state.step = 'key-choice'
} else {
state.step = 'creating'
}
}
async function selectKeyMode(keyMode: 'reserved' | 'byod') {
state.submitting = true
state.error = null
state.externalDidWeb.keyMode = keyMode
try {
let publicKeyMultibase: string
if (keyMode === 'reserved') {
const result = await api.reserveSigningKey(state.info.externalDid!.trim())
state.externalDidWeb.reservedSigningKey = result.signingKey
publicKeyMultibase = result.signingKey.replace('did:key:', '')
} else {
const keypair = await generateKeypair()
state.externalDidWeb.byodPrivateKey = keypair.privateKey
state.externalDidWeb.byodPublicKeyMultibase = keypair.publicKeyMultibase
publicKeyMultibase = keypair.publicKeyMultibase
}
const didDoc = generateDidDocument(
state.info.externalDid!.trim(),
publicKeyMultibase,
getFullHandle(),
getPdsEndpoint()
)
state.externalDidWeb.initialDidDocument = JSON.stringify(didDoc, null, '\t')
state.step = 'initial-did-doc'
} catch (err) {
setError(err)
} finally {
state.submitting = false
}
}
async function confirmInitialDidDoc() {
state.step = 'creating'
}
async function createPasswordAccount() {
state.submitting = true
state.error = null
try {
let byodToken: string | undefined
if (state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'byod' && state.externalDidWeb.byodPrivateKey) {
byodToken = await createServiceJwt(
state.externalDidWeb.byodPrivateKey,
state.info.externalDid!.trim(),
getPdsDid(),
'com.atproto.server.createAccount'
)
}
const result = await api.createAccount({
handle: state.info.handle.trim(),
email: state.info.email.trim(),
password: state.info.password!,
inviteCode: state.info.inviteCode?.trim() || undefined,
didType: state.info.didType,
did: state.info.didType === 'web-external' ? state.info.externalDid!.trim() : undefined,
signingKey: state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'reserved'
? state.externalDidWeb.reservedSigningKey
: undefined,
verificationChannel: state.info.verificationChannel,
discordId: state.info.discordId?.trim() || undefined,
telegramUsername: state.info.telegramUsername?.trim() || undefined,
signalNumber: state.info.signalNumber?.trim() || undefined,
}, byodToken)
state.account = {
did: result.did,
handle: result.handle,
}
state.step = 'verify'
} catch (err) {
setError(err)
} finally {
state.submitting = false
}
}
async function createPasskeyAccount() {
state.submitting = true
state.error = null
try {
let byodToken: string | undefined
if (state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'byod' && state.externalDidWeb.byodPrivateKey) {
byodToken = await createServiceJwt(
state.externalDidWeb.byodPrivateKey,
state.info.externalDid!.trim(),
getPdsDid(),
'com.atproto.server.createAccount'
)
}
const result = await api.createPasskeyAccount({
handle: state.info.handle.trim(),
email: 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() : undefined,
signingKey: state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'reserved'
? state.externalDidWeb.reservedSigningKey
: undefined,
verificationChannel: state.info.verificationChannel,
discordId: state.info.discordId?.trim() || undefined,
telegramUsername: state.info.telegramUsername?.trim() || undefined,
signalNumber: state.info.signalNumber?.trim() || undefined,
}, byodToken)
state.account = {
did: result.did,
handle: result.handle,
setupToken: result.setupToken,
}
state.step = 'passkey'
} catch (err) {
setError(err)
} finally {
state.submitting = false
}
}
function setPasskeyComplete(appPassword: string, appPasswordName: string) {
if (state.account) {
state.account.appPassword = appPassword
state.account.appPasswordName = appPasswordName
}
state.step = 'app-password'
}
function proceedFromAppPassword() {
state.step = 'verify'
}
async function verifyAccount(code: string) {
state.submitting = true
state.error = null
try {
const confirmResult = await api.confirmSignup(state.account!.did, code.trim())
if (state.info.didType === 'web-external') {
const password = state.mode === 'passkey' ? state.account!.appPassword! : state.info.password!
const session = await api.createSession(state.account!.did, password)
state.session = {
accessJwt: session.accessJwt,
refreshJwt: session.refreshJwt,
}
if (state.externalDidWeb.keyMode === 'byod') {
const credentials = await api.getRecommendedDidCredentials(session.accessJwt)
const newPublicKeyMultibase = credentials.verificationMethods?.atproto?.replace('did:key:', '') || ''
const didDoc = generateDidDocument(
state.info.externalDid!.trim(),
newPublicKeyMultibase,
state.account!.handle,
getPdsEndpoint()
)
state.externalDidWeb.updatedDidDocument = JSON.stringify(didDoc, null, '\t')
state.step = 'updated-did-doc'
} else {
await api.activateAccount(session.accessJwt)
await finalizeSession()
state.step = 'redirect-to-dashboard'
}
} else {
state.session = {
accessJwt: confirmResult.accessJwt,
refreshJwt: confirmResult.refreshJwt,
}
await finalizeSession()
state.step = 'redirect-to-dashboard'
}
} catch (err) {
setError(err)
} finally {
state.submitting = false
}
}
async function activateAccount() {
state.submitting = true
state.error = null
try {
await api.activateAccount(state.session!.accessJwt)
await finalizeSession()
state.step = 'redirect-to-dashboard'
} catch (err) {
setError(err)
} finally {
state.submitting = false
}
}
function goBack() {
switch (state.step) {
case 'key-choice':
state.step = 'info'
break
case 'initial-did-doc':
state.step = 'key-choice'
break
case 'passkey':
state.step = state.info.didType === 'web-external' ? 'initial-did-doc' : 'info'
break
}
}
async function finalizeSession() {
if (!state.session || !state.account) return
const { setSession } = await import('../auth.svelte')
setSession({
did: state.account.did,
handle: state.account.handle,
accessJwt: state.session.accessJwt,
refreshJwt: state.session.refreshJwt,
})
}
return {
get state() { return state },
get info() { return state.info },
get externalDidWeb() { return state.externalDidWeb },
get account() { return state.account },
get session() { return state.session },
getPdsEndpoint,
getPdsDid,
getFullHandle,
extractDomain,
proceedFromInfo,
selectKeyMode,
confirmInitialDidDoc,
createPasswordAccount,
createPasskeyAccount,
setPasskeyComplete,
proceedFromAppPassword,
verifyAccount,
activateAccount,
finalizeSession,
goBack,
setError(msg: string) { state.error = msg },
clearError() { state.error = null },
setSubmitting(val: boolean) { state.submitting = val },
}
}
export type RegistrationFlow = ReturnType<typeof createRegistrationFlow>

View File

@@ -0,0 +1,6 @@
export * from './types'
export * from './flow.svelte'
export { default as VerificationStep } from './VerificationStep.svelte'
export { default as KeyChoiceStep } from './KeyChoiceStep.svelte'
export { default as DidDocStep } from './DidDocStep.svelte'
export { default as AppPasswordStep } from './AppPasswordStep.svelte'

View File

@@ -0,0 +1,50 @@
import type { VerificationChannel, DidType } from '../api'
export type RegistrationMode = 'password' | 'passkey'
export type RegistrationStep =
| 'info'
| 'key-choice'
| 'initial-did-doc'
| 'creating'
| 'passkey'
| 'app-password'
| 'verify'
| 'updated-did-doc'
| 'activating'
| 'redirect-to-dashboard'
export interface RegistrationInfo {
handle: string
email: string
password?: string
inviteCode?: string
didType: DidType
externalDid?: string
verificationChannel: VerificationChannel
discordId?: string
telegramUsername?: string
signalNumber?: string
}
export interface ExternalDidWebState {
keyMode: 'reserved' | 'byod'
reservedSigningKey?: string
byodPrivateKey?: Uint8Array
byodPublicKeyMultibase?: string
initialDidDocument?: string
updatedDidDocument?: string
}
export interface AccountResult {
did: string
handle: string
setupToken?: string
appPassword?: string
appPasswordName?: string
}
export interface SessionState {
accessJwt: string
refreshJwt: string
}

View File

@@ -1,24 +1,14 @@
<script lang="ts">
import { register, getAuthState } from '../lib/auth.svelte'
import { navigate } from '../lib/router.svelte'
import { api, ApiError, type VerificationChannel, type DidType } from '../lib/api'
import { api, ApiError } from '../lib/api'
import { _ } from '../lib/i18n'
import {
createRegistrationFlow,
VerificationStep,
KeyChoiceStep,
DidDocStep,
} from '../lib/registration'
const STORAGE_KEY = 'tranquil_pds_pending_verification'
let handle = $state('')
let email = $state('')
let password = $state('')
let confirmPassword = $state('')
let inviteCode = $state('')
let verificationChannel = $state<VerificationChannel>('email')
let discordId = $state('')
let telegramUsername = $state('')
let signalNumber = $state('')
let didType = $state<DidType>('plc')
let externalDid = $state('')
let submitting = $state(false)
let error = $state<string | null>(null)
let serverInfo = $state<{
availableUserDomains: string[]
inviteCodeRequired: boolean
@@ -27,7 +17,8 @@
let loadingServerInfo = $state(true)
let serverInfoLoaded = false
const auth = getAuthState()
let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null)
let confirmPassword = $state('')
$effect(() => {
if (!serverInfoLoaded) {
@@ -36,9 +27,17 @@
}
})
$effect(() => {
if (flow?.state.step === 'redirect-to-dashboard') {
navigate('/dashboard')
}
})
async function loadServerInfo() {
try {
serverInfo = await api.describeServer()
const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname
flow = createRegistrationFlow('password', hostname)
} catch (e) {
console.error('Failed to load server info:', e)
} finally {
@@ -46,131 +45,144 @@
}
}
let handleHasDot = $derived(handle.includes('.'))
function isChannelAvailable(channel: string): boolean {
const available = serverInfo?.availableCommsChannels ?? ['email']
return available.includes(channel)
}
function validateForm(): string | null {
if (!handle.trim()) return $_('register.validation.handleRequired')
if (handle.includes('.')) return $_('register.validation.handleNoDots')
if (!password) return $_('register.validation.passwordRequired')
if (password.length < 8) return $_('register.validation.passwordLength')
if (password !== confirmPassword) return $_('register.validation.passwordsMismatch')
if (serverInfo?.inviteCodeRequired && !inviteCode.trim()) {
function validateInfoStep(): string | null {
if (!flow) return 'Flow not initialized'
const info = flow.info
if (!info.handle.trim()) return $_('register.validation.handleRequired')
if (info.handle.includes('.')) return $_('register.validation.handleNoDots')
if (!info.password) return $_('register.validation.passwordRequired')
if (info.password.length < 8) return $_('register.validation.passwordLength')
if (info.password !== confirmPassword) return $_('register.validation.passwordsMismatch')
if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) {
return $_('register.validation.inviteCodeRequired')
}
if (didType === 'web-external') {
if (!externalDid.trim()) return $_('register.validation.externalDidRequired')
if (!externalDid.trim().startsWith('did:web:')) return $_('register.validation.externalDidFormat')
if (info.didType === 'web-external') {
if (!info.externalDid?.trim()) return $_('register.validation.externalDidRequired')
if (!info.externalDid.trim().startsWith('did:web:')) return $_('register.validation.externalDidFormat')
}
switch (verificationChannel) {
switch (info.verificationChannel) {
case 'email':
if (!email.trim()) return $_('register.validation.emailRequired')
if (!info.email.trim()) return $_('register.validation.emailRequired')
break
case 'discord':
if (!discordId.trim()) return $_('register.validation.discordIdRequired')
if (!info.discordId?.trim()) return $_('register.validation.discordIdRequired')
break
case 'telegram':
if (!telegramUsername.trim()) return $_('register.validation.telegramRequired')
if (!info.telegramUsername?.trim()) return $_('register.validation.telegramRequired')
break
case 'signal':
if (!signalNumber.trim()) return $_('register.validation.signalRequired')
if (!info.signalNumber?.trim()) return $_('register.validation.signalRequired')
break
}
return null
}
async function handleSubmit(e: Event) {
async function handleInfoSubmit(e: Event) {
e.preventDefault()
const validationError = validateForm()
if (!flow) return
const validationError = validateInfoStep()
if (validationError) {
error = validationError
flow.setError(validationError)
return
}
submitting = true
error = null
try {
const result = await register({
handle: handle.trim(),
email: email.trim(),
password,
inviteCode: inviteCode.trim() || undefined,
didType,
did: didType === 'web-external' ? externalDid.trim() : undefined,
verificationChannel,
discordId: discordId.trim() || undefined,
telegramUsername: telegramUsername.trim() || undefined,
signalNumber: signalNumber.trim() || undefined,
})
if (result.verificationRequired) {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
did: result.did,
handle: result.handle,
channel: result.verificationChannel,
}))
navigate('/verify')
} else {
navigate('/dashboard')
}
} catch (err: any) {
if (err instanceof ApiError) {
error = err.message || 'Registration failed'
} else if (err instanceof Error) {
error = err.message || 'Registration failed'
} else {
error = 'Registration failed'
}
} finally {
submitting = false
flow.clearError()
flow.proceedFromInfo()
}
async function handleCreateAccount() {
if (!flow) return
await flow.createPasswordAccount()
}
async function handleComplete() {
if (flow) {
await flow.finalizeSession()
}
navigate('/dashboard')
}
function isChannelAvailable(ch: string): boolean {
const available = serverInfo?.availableCommsChannels ?? ['email']
return available.includes(ch)
}
function channelLabel(ch: string): string {
switch (ch) {
case 'email': return $_('register.email')
case 'discord': return $_('register.discord')
case 'telegram': return $_('register.telegram')
case 'signal': return $_('register.signal')
default: return ch
}
}
let fullHandle = $derived(() => {
if (!handle.trim()) return ''
if (handle.includes('.')) return handle.trim()
if (!flow?.info.handle.trim()) return ''
if (flow.info.handle.includes('.')) return flow.info.handle.trim()
const domain = serverInfo?.availableUserDomains?.[0]
if (domain) return `${handle.trim()}.${domain}`
return handle.trim()
if (domain) return `${flow.info.handle.trim()}.${domain}`
return flow.info.handle.trim()
})
function extractDomain(did: string): string {
return did.replace('did:web:', '').replace(/%3A/g, ':')
}
function getSubtitle(): string {
if (!flow) return ''
switch (flow.state.step) {
case 'info': return $_('register.subtitle')
case 'key-choice': return 'Choose how to set up your external did:web identity.'
case 'initial-did-doc': return 'Upload your DID document to continue.'
case 'creating': return $_('register.creating')
case 'verify': return `Verify your ${channelLabel(flow.info.verificationChannel)} to continue.`
case 'updated-did-doc': return 'Update your DID document with the PDS signing key.'
case 'activating': return 'Activating your account...'
case 'complete': return 'Your account has been created successfully!'
default: return ''
}
}
</script>
<div class="register-page">
<div class="migrate-callout">
<div class="migrate-icon"></div>
<div class="migrate-content">
<strong>{$_('register.migrateTitle')}</strong>
<p>{$_('register.migrateDescription')}</p>
<a href="https://pdsmoover.com/moover" target="_blank" rel="noopener" class="migrate-link">
{$_('register.migrateLink')}
</a>
{#if flow?.state.step === 'info'}
<div class="migrate-callout">
<div class="migrate-icon"></div>
<div class="migrate-content">
<strong>{$_('register.migrateTitle')}</strong>
<p>{$_('register.migrateDescription')}</p>
<a href="https://pdsmoover.com/moover" target="_blank" rel="noopener" class="migrate-link">
{$_('register.migrateLink')}
</a>
</div>
</div>
</div>
{#if error}
<div class="message error">{error}</div>
{/if}
<h1>{$_('register.title')}</h1>
<p class="subtitle">{$_('register.subtitle')}</p>
<p class="subtitle">{getSubtitle()}</p>
{#if loadingServerInfo}
{#if flow?.state.error}
<div class="message error">{flow.state.error}</div>
{/if}
{#if loadingServerInfo || !flow}
<p class="loading">{$_('common.loading')}</p>
{:else}
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(e); }}>
{:else if flow.state.step === 'info'}
<form onsubmit={handleInfoSubmit}>
<div class="field">
<label for="handle">{$_('register.handle')}</label>
<input
id="handle"
type="text"
bind:value={handle}
bind:value={flow.info.handle}
placeholder={$_('register.handlePlaceholder')}
disabled={submitting}
disabled={flow.state.submitting}
required
/>
{#if handleHasDot}
{#if flow.info.handle.includes('.')}
<p class="hint warning">{$_('register.handleDotWarning')}</p>
{:else if fullHandle()}
<p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
@@ -182,9 +194,9 @@
<input
id="password"
type="password"
bind:value={password}
bind:value={flow.info.password}
placeholder={$_('register.passwordPlaceholder')}
disabled={submitting}
disabled={flow.state.submitting}
required
minlength="8"
/>
@@ -197,7 +209,7 @@
type="password"
bind:value={confirmPassword}
placeholder={$_('register.confirmPasswordPlaceholder')}
disabled={submitting}
disabled={flow.state.submitting}
required
/>
</div>
@@ -208,7 +220,7 @@
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="didType" value="plc" bind:group={didType} disabled={submitting} />
<input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
<span class="radio-content">
<strong>{$_('register.didPlc')}</strong> {$_('register.didPlcRecommended')}
<span class="radio-hint">{$_('register.didPlcHint')}</span>
@@ -216,7 +228,7 @@
</label>
<label class="radio-label">
<input type="radio" name="didType" value="web" bind:group={didType} disabled={submitting} />
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting} />
<span class="radio-content">
<strong>{$_('register.didWeb')}</strong>
<span class="radio-hint">{$_('register.didWebHint')}</span>
@@ -224,7 +236,7 @@
</label>
<label class="radio-label">
<input type="radio" name="didType" value="web-external" bind:group={didType} disabled={submitting} />
<input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
<span class="radio-content">
<strong>{$_('register.didWebBYOD')}</strong>
<span class="radio-hint">{$_('register.didWebBYODHint')}</span>
@@ -232,7 +244,7 @@
</label>
</div>
{#if didType === 'web'}
{#if flow.info.didType === 'web'}
<div class="warning-box">
<strong>{$_('register.didWebWarningTitle')}</strong>
<ul>
@@ -244,15 +256,15 @@
</div>
{/if}
{#if didType === 'web-external'}
{#if flow.info.didType === 'web-external'}
<div class="field">
<label for="external-did">{$_('register.externalDid')}</label>
<input
id="external-did"
type="text"
bind:value={externalDid}
bind:value={flow.info.externalDid}
placeholder={$_('register.externalDidPlaceholder')}
disabled={submitting}
disabled={flow.state.submitting}
required
/>
<p class="hint">{$_('register.externalDidHint')}</p>
@@ -266,7 +278,7 @@
<div class="field">
<label for="verification-channel">{$_('register.verificationMethod')}</label>
<select id="verification-channel" bind:value={verificationChannel} disabled={submitting}>
<select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
<option value="email">{$_('register.email')}</option>
<option value="discord" disabled={!isChannelAvailable('discord')}>
{$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
@@ -280,52 +292,52 @@
</select>
</div>
{#if verificationChannel === 'email'}
{#if flow.info.verificationChannel === 'email'}
<div class="field">
<label for="email">{$_('register.emailAddress')}</label>
<input
id="email"
type="email"
bind:value={email}
bind:value={flow.info.email}
placeholder={$_('register.emailPlaceholder')}
disabled={submitting}
disabled={flow.state.submitting}
required
/>
</div>
{:else if verificationChannel === 'discord'}
{:else if flow.info.verificationChannel === 'discord'}
<div class="field">
<label for="discord-id">{$_('register.discordId')}</label>
<input
id="discord-id"
type="text"
bind:value={discordId}
bind:value={flow.info.discordId}
placeholder={$_('register.discordIdPlaceholder')}
disabled={submitting}
disabled={flow.state.submitting}
required
/>
<p class="hint">{$_('register.discordIdHint')}</p>
</div>
{:else if verificationChannel === 'telegram'}
{:else if flow.info.verificationChannel === 'telegram'}
<div class="field">
<label for="telegram-username">{$_('register.telegramUsername')}</label>
<input
id="telegram-username"
type="text"
bind:value={telegramUsername}
bind:value={flow.info.telegramUsername}
placeholder={$_('register.telegramUsernamePlaceholder')}
disabled={submitting}
disabled={flow.state.submitting}
required
/>
</div>
{:else if verificationChannel === 'signal'}
{:else if flow.info.verificationChannel === 'signal'}
<div class="field">
<label for="signal-number">{$_('register.signalNumber')}</label>
<input
id="signal-number"
type="tel"
bind:value={signalNumber}
bind:value={flow.info.signalNumber}
placeholder={$_('register.signalNumberPlaceholder')}
disabled={submitting}
disabled={flow.state.submitting}
required
/>
<p class="hint">{$_('register.signalNumberHint')}</p>
@@ -339,16 +351,16 @@
<input
id="invite-code"
type="text"
bind:value={inviteCode}
bind:value={flow.info.inviteCode}
placeholder={$_('register.inviteCodePlaceholder')}
disabled={submitting}
disabled={flow.state.submitting}
required
/>
</div>
{/if}
<button type="submit" disabled={submitting}>
{submitting ? $_('register.creating') : $_('register.createButton')}
<button type="submit" disabled={flow.state.submitting}>
{flow.state.submitting ? $_('register.creating') : $_('register.createButton')}
</button>
</form>
@@ -358,6 +370,35 @@
<p class="link-text">
{$_('register.wantPasswordless')} <a href="#/register-passkey">{$_('register.createPasskeyAccount')}</a>
</p>
{:else if flow.state.step === 'key-choice'}
<KeyChoiceStep {flow} />
{:else if flow.state.step === 'initial-did-doc'}
<DidDocStep
{flow}
type="initial"
onConfirm={handleCreateAccount}
onBack={() => flow?.goBack()}
/>
{:else if flow.state.step === 'creating'}
{#await flow.createPasswordAccount()}
<p class="loading">{$_('register.creating')}</p>
{/await}
{:else if flow.state.step === 'verify'}
<VerificationStep {flow} />
{:else if flow.state.step === 'updated-did-doc'}
<DidDocStep
{flow}
type="updated"
onConfirm={() => flow?.activateAccount()}
/>
{:else if flow.state.step === 'redirect-to-dashboard'}
<p class="loading">Redirecting to dashboard...</p>
{/if}
</div>

View File

@@ -1,35 +1,25 @@
<script lang="ts">
import { navigate } from '../lib/router.svelte'
import { api, ApiError, type VerificationChannel, type DidType } from '../lib/api'
import { getAuthState, confirmSignup, resendVerification } from '../lib/auth.svelte'
import { api, ApiError } from '../lib/api'
import { _ } from '../lib/i18n'
import {
createRegistrationFlow,
VerificationStep,
KeyChoiceStep,
DidDocStep,
AppPasswordStep,
} from '../lib/registration'
const auth = getAuthState()
let step = $state<'info' | 'passkey' | 'app-password' | 'verify' | 'success'>('info')
let handle = $state('')
let email = $state('')
let inviteCode = $state('')
let didType = $state<DidType>('plc')
let externalDid = $state('')
let verificationChannel = $state<VerificationChannel>('email')
let discordId = $state('')
let telegramUsername = $state('')
let signalNumber = $state('')
let passkeyName = $state('')
let submitting = $state(false)
let error = $state<string | null>(null)
let serverInfo = $state<{ availableUserDomains: string[]; inviteCodeRequired: boolean; availableCommsChannels?: string[] } | null>(null)
let serverInfo = $state<{
availableUserDomains: string[]
inviteCodeRequired: boolean
availableCommsChannels?: string[]
} | null>(null)
let loadingServerInfo = $state(true)
let serverInfoLoaded = false
let setupData = $state<{ did: string; handle: string; setupToken: string } | null>(null)
let appPasswordResult = $state<{ appPassword: string; appPasswordName: string } | null>(null)
let appPasswordAcknowledged = $state(false)
let appPasswordCopied = $state(false)
let verificationCode = $state('')
let resendingCode = $state(false)
let resendMessage = $state<string | null>(null)
let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null)
let passkeyName = $state('')
$effect(() => {
if (!serverInfoLoaded) {
@@ -38,9 +28,17 @@
}
})
$effect(() => {
if (flow?.state.step === 'redirect-to-dashboard') {
navigate('/dashboard')
}
})
async function loadServerInfo() {
try {
serverInfo = await api.describeServer()
const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname
flow = createRegistrationFlow('passkey', hostname)
} catch (e) {
console.error('Failed to load server info:', e)
} finally {
@@ -49,27 +47,29 @@
}
function validateInfoStep(): string | null {
if (!handle.trim()) return 'Handle is required'
if (handle.includes('.')) return 'Handle cannot contain dots. You can set up a custom domain handle after creating your account.'
if (serverInfo?.inviteCodeRequired && !inviteCode.trim()) {
if (!flow) return 'Flow not initialized'
const info = flow.info
if (!info.handle.trim()) return 'Handle is required'
if (info.handle.includes('.')) return 'Handle cannot contain dots. You can set up a custom domain handle after creating your account.'
if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) {
return 'Invite code is required'
}
if (didType === 'web-external') {
if (!externalDid.trim()) return 'External did:web is required'
if (!externalDid.trim().startsWith('did:web:')) return 'External DID must start with did:web:'
if (info.didType === 'web-external') {
if (!info.externalDid?.trim()) return 'External did:web is required'
if (!info.externalDid.trim().startsWith('did:web:')) return 'External DID must start with did:web:'
}
switch (verificationChannel) {
switch (info.verificationChannel) {
case 'email':
if (!email.trim()) return 'Email is required for email verification'
if (!info.email.trim()) return 'Email is required for email verification'
break
case 'discord':
if (!discordId.trim()) return 'Discord ID is required for Discord verification'
if (!info.discordId?.trim()) return 'Discord ID is required for Discord verification'
break
case 'telegram':
if (!telegramUsername.trim()) return 'Telegram username is required for Telegram verification'
if (!info.telegramUsername?.trim()) return 'Telegram username is required for Telegram verification'
break
case 'signal':
if (!signalNumber.trim()) return 'Phone number is required for Signal verification'
if (!info.signalNumber?.trim()) return 'Phone number is required for Signal verification'
break
}
return null
@@ -112,63 +112,38 @@
async function handleInfoSubmit(e: Event) {
e.preventDefault()
if (!flow) return
const validationError = validateInfoStep()
if (validationError) {
error = validationError
flow.setError(validationError)
return
}
if (!window.PublicKeyCredential) {
error = 'Passkeys are not supported in this browser. Please use a different browser or register with a password instead.'
flow.setError('Passkeys are not supported in this browser. Please use a different browser or register with a password instead.')
return
}
submitting = true
error = null
flow.clearError()
flow.proceedFromInfo()
}
try {
const result = await api.createPasskeyAccount({
handle: handle.trim(),
email: email.trim() || undefined,
inviteCode: inviteCode.trim() || undefined,
didType,
did: didType === 'web-external' ? externalDid.trim() : undefined,
verificationChannel,
discordId: discordId.trim() || undefined,
telegramUsername: telegramUsername.trim() || undefined,
signalNumber: signalNumber.trim() || undefined,
})
setupData = {
did: result.did,
handle: result.handle,
setupToken: result.setupToken,
}
step = 'passkey'
} catch (err) {
if (err instanceof ApiError) {
error = err.message || 'Registration failed'
} else if (err instanceof Error) {
error = err.message || 'Registration failed'
} else {
error = 'Registration failed'
}
} finally {
submitting = false
}
async function handleCreateAccount() {
if (!flow) return
await flow.createPasskeyAccount()
}
async function handlePasskeyRegistration() {
if (!setupData) return
if (!flow || !flow.account) return
submitting = true
error = null
flow.setSubmitting(true)
flow.clearError()
try {
const { options } = await api.startPasskeyRegistrationForSetup(
setupData.did,
setupData.setupToken,
flow.account.did,
flow.account.setupToken!,
passkeyName || undefined
)
@@ -178,8 +153,8 @@
})
if (!credential) {
error = 'Passkey creation was cancelled'
submitting = false
flow.setError('Passkey creation was cancelled')
flow.setSubmitting(false)
return
}
@@ -196,87 +171,38 @@
}
const result = await api.completePasskeySetup(
setupData.did,
setupData.setupToken,
flow.account.did,
flow.account.setupToken!,
credentialResponse,
passkeyName || undefined
)
appPasswordResult = {
appPassword: result.appPassword,
appPasswordName: result.appPasswordName,
}
step = 'app-password'
flow.setPasskeyComplete(result.appPassword, result.appPasswordName)
} catch (err) {
if (err instanceof DOMException && err.name === 'NotAllowedError') {
error = 'Passkey creation was cancelled'
flow.setError('Passkey creation was cancelled')
} else if (err instanceof ApiError) {
error = err.message || 'Passkey registration failed'
flow.setError(err.message || 'Passkey registration failed')
} else if (err instanceof Error) {
error = err.message || 'Passkey registration failed'
flow.setError(err.message || 'Passkey registration failed')
} else {
error = 'Passkey registration failed'
flow.setError('Passkey registration failed')
}
} finally {
submitting = false
flow.setSubmitting(false)
}
}
function copyAppPassword() {
if (appPasswordResult) {
navigator.clipboard.writeText(appPasswordResult.appPassword)
appPasswordCopied = true
async function handleComplete() {
if (flow) {
await flow.finalizeSession()
}
navigate('/dashboard')
}
function handleFinish() {
step = 'verify'
}
async function handleVerification() {
if (!setupData || !verificationCode.trim()) return
submitting = true
error = null
try {
await confirmSignup(setupData.did, verificationCode.trim())
navigate('/dashboard')
} catch (err) {
if (err instanceof ApiError) {
error = err.message || 'Verification failed'
} else if (err instanceof Error) {
error = err.message || 'Verification failed'
} else {
error = 'Verification failed'
}
} finally {
submitting = false
}
}
async function handleResendCode() {
if (!setupData || resendingCode) return
resendingCode = true
resendMessage = null
error = null
try {
await resendVerification(setupData.did)
resendMessage = 'Verification code resent!'
} catch (err) {
if (err instanceof ApiError) {
error = err.message || 'Failed to resend code'
} else if (err instanceof Error) {
error = err.message || 'Failed to resend code'
} else {
error = 'Failed to resend code'
}
} finally {
resendingCode = false
}
function isChannelAvailable(ch: string): boolean {
const available = serverInfo?.availableCommsChannels ?? ['email']
return available.includes(ch)
}
function channelLabel(ch: string): string {
@@ -289,26 +215,38 @@
}
}
function isChannelAvailable(ch: string): boolean {
const available = serverInfo?.availableCommsChannels ?? ['email']
return available.includes(ch)
}
function goToLogin() {
navigate('/login')
}
let fullHandle = $derived(() => {
if (!handle.trim()) return ''
if (handle.includes('.')) return handle.trim()
if (!flow?.info.handle.trim()) return ''
if (flow.info.handle.includes('.')) return flow.info.handle.trim()
const domain = serverInfo?.availableUserDomains?.[0]
if (domain) return `${handle.trim()}.${domain}`
return handle.trim()
if (domain) return `${flow.info.handle.trim()}.${domain}`
return flow.info.handle.trim()
})
function extractDomain(did: string): string {
return did.replace('did:web:', '').replace(/%3A/g, ':')
}
function getSubtitle(): string {
if (!flow) return ''
switch (flow.state.step) {
case 'info': return 'Create an ultra-secure account using a passkey instead of a password.'
case 'key-choice': return 'Choose how to set up your external did:web identity.'
case 'initial-did-doc': return 'Upload your DID document to continue.'
case 'creating': return 'Creating your account...'
case 'passkey': return 'Register your passkey to secure your account.'
case 'app-password': return 'Save your app password for third-party apps.'
case 'verify': return `Verify your ${channelLabel(flow.info.verificationChannel)} to continue.`
case 'updated-did-doc': return 'Update your DID document with the PDS signing key.'
case 'activating': return 'Activating your account...'
case 'complete': return 'Your account has been created successfully!'
default: return ''
}
}
</script>
<div class="register-page">
{#if step === 'info'}
{#if flow?.state.step === 'info'}
<div class="migrate-callout">
<div class="migrate-icon"></div>
<div class="migrate-content">
@@ -322,39 +260,28 @@
{/if}
<h1>Create Passkey Account</h1>
<p class="subtitle">
{#if step === 'info'}
Create an ultra-secure account using a passkey instead of a password.
{:else if step === 'passkey'}
Register your passkey to secure your account.
{:else if step === 'app-password'}
Save your app password for third-party apps.
{:else if step === 'verify'}
Verify your {channelLabel(verificationChannel)} to complete registration.
{:else}
Your account has been created successfully!
{/if}
</p>
<p class="subtitle">{getSubtitle()}</p>
{#if error}
<div class="message error">{error}</div>
{#if flow?.state.error}
<div class="message error">{flow.state.error}</div>
{/if}
{#if loadingServerInfo}
{#if loadingServerInfo || !flow}
<p class="loading">Loading...</p>
{:else if step === 'info'}
{:else if flow.state.step === 'info'}
<form onsubmit={handleInfoSubmit}>
<div class="field">
<label for="handle">Handle</label>
<input
id="handle"
type="text"
bind:value={handle}
bind:value={flow.info.handle}
placeholder="yourname"
disabled={submitting}
disabled={flow.state.submitting}
required
/>
{#if handle.includes('.')}
{#if flow.info.handle.includes('.')}
<p class="hint warning">Custom domain handles can be set up after account creation.</p>
{:else if fullHandle()}
<p class="hint">Your full handle will be: @{fullHandle()}</p>
@@ -366,7 +293,7 @@
<p class="section-hint">Choose how you'd like to verify your account and receive notifications.</p>
<div class="field">
<label for="verification-channel">Verification Method</label>
<select id="verification-channel" bind:value={verificationChannel} disabled={submitting}>
<select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
<option value="email">Email</option>
<option value="discord" disabled={!isChannelAvailable('discord')}>
Discord{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
@@ -379,26 +306,26 @@
</option>
</select>
</div>
{#if verificationChannel === 'email'}
{#if flow.info.verificationChannel === 'email'}
<div class="field">
<label for="email">Email Address</label>
<input id="email" type="email" bind:value={email} placeholder="you@example.com" disabled={submitting} required />
<input id="email" type="email" bind:value={flow.info.email} placeholder="you@example.com" disabled={flow.state.submitting} required />
</div>
{:else if verificationChannel === 'discord'}
{:else if flow.info.verificationChannel === 'discord'}
<div class="field">
<label for="discord-id">Discord User ID</label>
<input id="discord-id" type="text" bind:value={discordId} placeholder="Your Discord user ID" disabled={submitting} required />
<input id="discord-id" type="text" bind:value={flow.info.discordId} placeholder="Your Discord user ID" disabled={flow.state.submitting} required />
<p class="hint">Your numeric Discord user ID (enable Developer Mode to find it)</p>
</div>
{:else if verificationChannel === 'telegram'}
{:else if flow.info.verificationChannel === 'telegram'}
<div class="field">
<label for="telegram-username">Telegram Username</label>
<input id="telegram-username" type="text" bind:value={telegramUsername} placeholder="@yourusername" disabled={submitting} required />
<input id="telegram-username" type="text" bind:value={flow.info.telegramUsername} placeholder="@yourusername" disabled={flow.state.submitting} required />
</div>
{:else if verificationChannel === 'signal'}
{:else if flow.info.verificationChannel === 'signal'}
<div class="field">
<label for="signal-number">Signal Phone Number</label>
<input id="signal-number" type="tel" bind:value={signalNumber} placeholder="+1234567890" disabled={submitting} required />
<input id="signal-number" type="tel" bind:value={flow.info.signalNumber} placeholder="+1234567890" disabled={flow.state.submitting} required />
<p class="hint">Include country code (e.g., +1 for US)</p>
</div>
{/if}
@@ -409,28 +336,28 @@
<p class="section-hint">Choose how your decentralized identity will be managed.</p>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="didType" value="plc" bind:group={didType} disabled={submitting} />
<input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
<span class="radio-content">
<strong>did:plc</strong> (Recommended)
<span class="radio-hint">Portable identity managed by PLC Directory</span>
</span>
</label>
<label class="radio-label">
<input type="radio" name="didType" value="web" bind:group={didType} disabled={submitting} />
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting} />
<span class="radio-content">
<strong>did:web</strong>
<span class="radio-hint">Identity hosted on this PDS (read warning below)</span>
</span>
</label>
<label class="radio-label">
<input type="radio" name="didType" value="web-external" bind:group={didType} disabled={submitting} />
<input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
<span class="radio-content">
<strong>did:web (BYOD)</strong>
<span class="radio-hint">Bring your own domain</span>
</span>
</label>
</div>
{#if didType === 'web'}
{#if flow.info.didType === 'web'}
<div class="warning-box">
<strong>Important: Understand the trade-offs</strong>
<ul>
@@ -441,11 +368,11 @@
</ul>
</div>
{/if}
{#if didType === 'web-external'}
{#if flow.info.didType === 'web-external'}
<div class="field">
<label for="external-did">Your did:web</label>
<input id="external-did" type="text" bind:value={externalDid} placeholder="did:web:yourdomain.com" disabled={submitting} required />
<p class="hint">Your domain must serve a valid DID document at /.well-known/did.json pointing to this PDS</p>
<input id="external-did" type="text" bind:value={flow.info.externalDid} placeholder="did:web:yourdomain.com" disabled={flow.state.submitting} required />
<p class="hint">You'll need to serve a DID document at <code>https://{flow.info.externalDid ? extractDomain(flow.info.externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
</div>
{/if}
</fieldset>
@@ -453,7 +380,7 @@
{#if serverInfo?.inviteCodeRequired}
<div class="field">
<label for="invite-code">Invite Code <span class="required">*</span></label>
<input id="invite-code" type="text" bind:value={inviteCode} placeholder="Enter your invite code" disabled={submitting} required />
<input id="invite-code" type="text" bind:value={flow.info.inviteCode} placeholder="Enter your invite code" disabled={flow.state.submitting} required />
</div>
{/if}
@@ -467,19 +394,36 @@
</ul>
</div>
<button type="submit" disabled={submitting}>
{submitting ? 'Creating account...' : 'Continue'}
<button type="submit" disabled={flow.state.submitting}>
{flow.state.submitting ? 'Creating account...' : 'Continue'}
</button>
</form>
<p class="link-text">
Want a traditional password? <a href="#/register">Register with password</a>
</p>
{:else if step === 'passkey'}
{:else if flow.state.step === 'key-choice'}
<KeyChoiceStep {flow} />
{:else if flow.state.step === 'initial-did-doc'}
<DidDocStep
{flow}
type="initial"
onConfirm={handleCreateAccount}
onBack={() => flow?.goBack()}
/>
{:else if flow.state.step === 'creating'}
{#await flow.createPasskeyAccount()}
<p class="loading">Creating your account...</p>
{/await}
{:else if flow.state.step === 'passkey'}
<div class="step-content">
<div class="field">
<label for="passkey-name">Passkey Name (optional)</label>
<input id="passkey-name" type="text" bind:value={passkeyName} placeholder="e.g., MacBook Touch ID" disabled={submitting} />
<input id="passkey-name" type="text" bind:value={passkeyName} placeholder="e.g., MacBook Touch ID" disabled={flow.state.submitting} />
<p class="hint">A friendly name to identify this passkey</p>
</div>
@@ -492,69 +436,30 @@
</ul>
</div>
<button onclick={handlePasskeyRegistration} disabled={submitting} class="passkey-btn">
{submitting ? 'Creating Passkey...' : 'Create Passkey'}
<button onclick={handlePasskeyRegistration} disabled={flow.state.submitting} class="passkey-btn">
{flow.state.submitting ? 'Creating Passkey...' : 'Create Passkey'}
</button>
<button type="button" class="secondary" onclick={() => step = 'info'} disabled={submitting}>
<button type="button" class="secondary" onclick={() => flow?.goBack()} disabled={flow.state.submitting}>
Back
</button>
</div>
{:else if step === 'app-password'}
<div class="step-content">
<div class="warning-box">
<strong>Important: Save this app password!</strong>
<p>This app password is required to sign into apps that don't support passkeys yet (like bsky.app). You will only see this password once.</p>
</div>
<div class="app-password-display">
<div class="app-password-label">App Password for: <strong>{appPasswordResult?.appPasswordName}</strong></div>
<code class="app-password-code">{appPasswordResult?.appPassword}</code>
<button type="button" class="copy-btn" onclick={copyAppPassword}>
{appPasswordCopied ? 'Copied!' : 'Copy to Clipboard'}
</button>
</div>
{:else if flow.state.step === 'app-password'}
<AppPasswordStep {flow} />
<div class="field">
<label class="checkbox-label">
<input type="checkbox" bind:checked={appPasswordAcknowledged} />
<span>I have saved my app password in a secure location</span>
</label>
</div>
{:else if flow.state.step === 'verify'}
<VerificationStep {flow} />
<button onclick={handleFinish} disabled={!appPasswordAcknowledged}>Continue</button>
</div>
{:else if step === 'verify'}
<div class="step-content">
<p class="info-text">We've sent a verification code to your {channelLabel(verificationChannel)}. Enter it below to complete your account setup.</p>
{:else if flow.state.step === 'updated-did-doc'}
<DidDocStep
{flow}
type="updated"
onConfirm={() => flow?.activateAccount()}
/>
{#if resendMessage}
<div class="message success">{resendMessage}</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleVerification(); }}>
<div class="field">
<label for="verification-code">Verification Code</label>
<input id="verification-code" type="text" bind:value={verificationCode} placeholder="Enter 6-digit code" disabled={submitting} required maxlength="6" inputmode="numeric" autocomplete="one-time-code" />
</div>
<button type="submit" disabled={submitting || !verificationCode.trim()}>
{submitting ? 'Verifying...' : 'Verify Account'}
</button>
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}>
{resendingCode ? 'Resending...' : 'Resend Code'}
</button>
</form>
</div>
{:else if step === 'success'}
<div class="success-content">
<div class="success-icon">&#x2714;</div>
<h2>Account Created!</h2>
<p>Your passkey-only account has been created successfully.</p>
<p class="handle-display">@{setupData?.handle}</p>
<button onclick={goToLogin}>Sign In</button>
</div>
{:else if flow.state.step === 'redirect-to-dashboard'}
<p class="loading">Redirecting to dashboard...</p>
{/if}
</div>
@@ -609,7 +514,7 @@
text-decoration: underline;
}
h1, h2 {
h1 {
margin: 0 0 var(--space-3) 0;
}
@@ -697,11 +602,6 @@
color: var(--warning-text);
}
.warning-box p {
margin: 0;
color: var(--warning-text);
}
.warning-box ul {
margin: var(--space-4) 0 0 0;
padding-left: var(--space-5);
@@ -749,77 +649,6 @@
font-size: var(--text-lg);
}
.app-password-display {
background: var(--bg-card);
border: 2px solid var(--accent);
border-radius: var(--radius-xl);
padding: var(--space-6);
text-align: center;
}
.app-password-label {
font-size: var(--text-sm);
color: var(--text-secondary);
margin-bottom: var(--space-4);
}
.app-password-code {
display: block;
font-size: var(--text-xl);
font-family: ui-monospace, monospace;
letter-spacing: 0.1em;
padding: var(--space-5);
background: var(--bg-input);
border-radius: var(--radius-md);
margin-bottom: var(--space-4);
user-select: all;
}
.copy-btn {
margin-top: 0;
padding: var(--space-3) var(--space-5);
font-size: var(--text-sm);
}
.checkbox-label {
display: flex;
align-items: center;
gap: var(--space-3);
cursor: pointer;
font-weight: var(--font-normal);
}
.checkbox-label input[type="checkbox"] {
width: auto;
padding: 0;
}
.success-content {
text-align: center;
}
.success-icon {
font-size: var(--text-4xl);
color: var(--success-text);
margin-bottom: var(--space-4);
}
.success-content p {
color: var(--text-secondary);
}
.handle-display {
font-size: var(--text-xl);
font-weight: var(--font-semibold);
color: var(--text-primary);
margin: var(--space-4) 0;
}
.info-text {
color: var(--text-secondary);
margin: 0;
}
.link-text {
text-align: center;
margin-top: var(--space-6);

View File

@@ -118,13 +118,6 @@ pub async fn create_account(
None
};
let is_migration = migration_auth.is_some()
&& input
.did
.as_ref()
.map(|d| d.starts_with("did:plc:") || d.starts_with("did:web:"))
.unwrap_or(false);
let is_did_web_byod = migration_auth.is_some()
&& input
.did
@@ -132,23 +125,30 @@ pub async fn create_account(
.map(|d| d.starts_with("did:web:"))
.unwrap_or(false);
if is_migration {
if let (Some(migration_did), Some(auth_did)) = (input.did.as_ref(), migration_auth.as_ref())
let is_migration = migration_auth.is_some()
&& input
.did
.as_ref()
.map(|d| d.starts_with("did:plc:"))
.unwrap_or(false);
if is_migration || is_did_web_byod {
if let (Some(provided_did), Some(auth_did)) = (input.did.as_ref(), migration_auth.as_ref())
{
if migration_did != auth_did {
if provided_did != auth_did {
return (
StatusCode::FORBIDDEN,
Json(json!({
"error": "AuthorizationError",
"message": format!("Service token issuer {} does not match DID {}", auth_did, migration_did)
"message": format!("Service token issuer {} does not match DID {}", auth_did, provided_did)
})),
)
.into_response();
}
if is_did_web_byod {
info!(did = %migration_did, "Processing did:web BYOD account creation");
info!(did = %provided_did, "Processing did:web BYOD account creation");
} else {
info!(did = %migration_did, "Processing account migration");
info!(did = %provided_did, "Processing account migration");
}
}
}
@@ -717,7 +717,7 @@ pub async fn create_account(
.await
.map(|c| c.unwrap_or(0) == 0)
.unwrap_or(false);
let deactivated_at: Option<chrono::DateTime<chrono::Utc>> = if is_migration {
let deactivated_at: Option<chrono::DateTime<chrono::Utc>> = if is_migration || is_did_web_byod {
Some(chrono::Utc::now())
} else {
None
@@ -946,7 +946,7 @@ pub async fn create_account(
)
.into_response();
}
if !is_migration {
if !is_migration && !is_did_web_byod {
if let Err(e) =
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await
{
@@ -972,6 +972,8 @@ pub async fn create_account(
{
warn!("Failed to create default profile for {}: {}", did, e);
}
}
if !is_migration {
if let Some(ref recipient) = verification_recipient
&& let Err(e) = crate::comms::enqueue_signup_verification(
&state.db,

View File

@@ -12,10 +12,11 @@ use rand::Rng;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::sync::Arc;
use tracing::{error, info, warn};
use tracing::{debug, error, info, warn};
use uuid::Uuid;
use crate::api::repo::record::utils::create_signed_commit;
use crate::auth::{ServiceTokenVerifier, extract_bearer_token_from_header, is_service_token};
use crate::state::{AppState, RateLimitKind};
use crate::validation::validate_password;
@@ -106,6 +107,45 @@ pub async fn create_passkey_account(
.into_response();
}
let byod_auth = if let Some(token) =
extract_bearer_token_from_header(headers.get("Authorization").and_then(|h| h.to_str().ok()))
{
if is_service_token(&token) {
let verifier = ServiceTokenVerifier::new();
match verifier
.verify_service_token(&token, Some("com.atproto.server.createAccount"))
.await
{
Ok(claims) => {
debug!("Service token verified for BYOD did:web: iss={}", claims.iss);
Some(claims.iss)
}
Err(e) => {
error!("Service token verification failed: {:?}", e);
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "AuthenticationFailed",
"message": format!("Service token verification failed: {}", e)
})),
)
.into_response();
}
}
} else {
None
}
} else {
None
};
let is_byod_did_web = byod_auth.is_some()
&& input
.did
.as_ref()
.map(|d| d.starts_with("did:web:"))
.unwrap_or(false);
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let pds_suffix = format!(".{}", hostname);
@@ -301,21 +341,37 @@ pub async fn create_passkey_account(
)
.into_response();
}
if let Err(e) = crate::api::identity::did::verify_did_web(
d,
&hostname,
&input.handle,
input.signing_key.as_deref(),
)
.await
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidDid", "message": e})),
if is_byod_did_web {
if let Some(ref auth_did) = byod_auth {
if d != auth_did {
return (
StatusCode::FORBIDDEN,
Json(json!({
"error": "AuthorizationError",
"message": format!("Service token issuer {} does not match DID {}", auth_did, d)
})),
)
.into_response();
}
}
info!(did = %d, "Creating external did:web passkey account (BYOD key)");
} else {
if let Err(e) = crate::api::identity::did::verify_did_web(
d,
&hostname,
&input.handle,
input.signing_key.as_deref(),
)
.into_response();
.await
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidDid", "message": e})),
)
.into_response();
}
info!(did = %d, "Creating external did:web passkey account (reserved key)");
}
info!(did = %d, "Creating external did:web passkey account");
d.to_string()
}
_ => {
@@ -398,14 +454,20 @@ pub async fn create_passkey_account(
.map(|c| c.unwrap_or(0) == 0)
.unwrap_or(false);
let deactivated_at: Option<chrono::DateTime<Utc>> = if is_byod_did_web {
Some(Utc::now())
} else {
None
};
let user_insert: Result<(Uuid,), _> = sqlx::query_as(
r#"INSERT INTO users (
handle, email, did, password_hash, password_required,
preferred_comms_channel,
discord_id, telegram_username, signal_number,
recovery_token, recovery_token_expires_at,
is_admin
) VALUES ($1, $2, $3, NULL, FALSE, $4::comms_channel, $5, $6, $7, $8, $9, $10) RETURNING id"#,
is_admin, deactivated_at
) VALUES ($1, $2, $3, NULL, FALSE, $4::comms_channel, $5, $6, $7, $8, $9, $10, $11) RETURNING id"#,
)
.bind(&handle)
.bind(&email)
@@ -435,6 +497,7 @@ pub async fn create_passkey_account(
.bind(&setup_token_hash)
.bind(setup_expires_at)
.bind(is_first_user)
.bind(deactivated_at)
.fetch_one(&mut *tx)
.await;
@@ -612,30 +675,32 @@ pub async fn create_passkey_account(
.into_response();
}
if let Err(e) =
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await
{
warn!("Failed to sequence identity event for {}: {}", did, e);
}
if let Err(e) =
crate::api::repo::record::sequence_account_event(&state, &did, true, None).await
{
warn!("Failed to sequence account event for {}: {}", did, e);
}
let profile_record = serde_json::json!({
"$type": "app.bsky.actor.profile",
"displayName": handle
});
if let Err(e) = crate::api::repo::record::create_record_internal(
&state,
&did,
"app.bsky.actor.profile",
"self",
&profile_record,
)
.await
{
warn!("Failed to create default profile for {}: {}", did, e);
if !is_byod_did_web {
if let Err(e) =
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await
{
warn!("Failed to sequence identity event for {}: {}", did, e);
}
if let Err(e) =
crate::api::repo::record::sequence_account_event(&state, &did, true, None).await
{
warn!("Failed to sequence account event for {}: {}", did, e);
}
let profile_record = serde_json::json!({
"$type": "app.bsky.actor.profile",
"displayName": handle
});
if let Err(e) = crate::api::repo::record::create_record_internal(
&state,
&did,
"app.bsky.actor.profile",
"self",
&profile_record,
)
.await
{
warn!("Failed to create default profile for {}: {}", did, e);
}
}
if let Err(e) = crate::comms::enqueue_signup_verification(