Files
bsky-social-app/src/state/session/create-account.ts
T
2026-08-13 22:13:45 +03:00

250 lines
7.2 KiB
TypeScript

import {type AppBskyActorProfile, type Un$Typed} from '@atproto/api'
import {TID} from '@atproto/common-web'
import {type Client} from '@atproto/lex'
import {PasswordSession} from '@atproto/lex-password-session'
import {networkRetry} from '#/lib/async/retry'
import {
BLUESKY_PROXY_HEADER,
DISCOVER_SAVED_FEED,
IS_PROD_SERVICE,
TIMELINE_SAVED_FEED,
} from '#/lib/constants'
import {logger} from '#/logger'
import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate'
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings'
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
import {
prefetchAgeAssuranceServerData,
setBirthdateForDid,
setCreatedAtForDid,
} from '#/ageAssurance/data'
import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state'
import {features} from '#/analytics'
import {type BskyAppAgent} from './bridge-agent'
import {agentToPdsClient} from './clients'
import {configureModerationForAccount} from './moderation'
import {
buildBundle,
finishPreparation,
makeSessionHooks,
type OnSessionChange,
registerBundleKillSwitch,
type SessionBundle,
} from './session-core'
import {sessionDataToSessionAccount} from './session-data'
import {type SessionAccount} from './types'
/** Create an account, prepare its session, and start post-signup writes. */
export async function createSessionBundleAndCreateAccount(
{
service,
email,
password,
handle,
birthDate,
inviteCode,
verificationPhone,
verificationCode,
}: {
service: string
email: string
password: string
handle: string
birthDate: Date
inviteCode?: string
verificationPhone?: string
verificationCode?: string
},
onSessionChange: OnSessionChange,
): Promise<{account: SessionAccount; bundle: SessionBundle}> {
let bundle!: SessionBundle
let accountDid = ''
const hooks = makeSessionHooks({
onSessionChange,
getBundle: () => bundle,
getDid: () => accountDid,
})
const session = await PasswordSession.createAccount(
{
email,
password,
/* the lexicon types handle as `${string}.${string}`; user input is a plain string */
handle: handle as `${string}.${string}`,
inviteCode,
verificationPhone,
verificationCode,
},
{...hooks, service},
)
bundle = buildBundle(session)
registerBundleKillSwitch(bundle, hooks.kill)
// Seed the hook and the deferred writes with refresh-stable account fields.
const earlyAccount = snapshotNewAccount(session, email)
accountDid = earlyAccount.did
const gates = features.refresh({strategy: 'prefer-fresh-gates'})
configureModerationForAccount(bundle.agent, earlyAccount)
const createdAt = new Date().toISOString()
const birthdate = birthDate.toISOString()
/*
* Since we have a race with account creation, profile creation, and AA
* state, set these values locally to ensure sync reads. Values are written
* to the server in the next step, so on subsequent reloads, the server will
* be the source of truth.
*/
setCreatedAtForDid({did: earlyAccount.did, createdAt})
setBirthdateForDid({did: earlyAccount.did, birthdate})
snoozeBirthdateUpdateAllowedForDid(earlyAccount.did)
// Start the prefetch after seeding its synchronous birthdate inputs.
const aa = prefetchAgeAssuranceServerData({agent: bundle.agent})
const isProd = Boolean(IS_PROD_SERVICE(service))
const postSignupTasks: Promise<unknown>[] = [
savePersonalDetails(bundle.agent, birthdate),
initializeProfile(bundle.agent, {handle, createdAt, isProd}),
]
if (isProd) {
postSignupTasks.push(
initializeSavedFeeds(bundle.agent),
restrictChatAfterAgeAssurance(
aa,
agentToPdsClient(bundle.agent),
earlyAccount.did,
),
)
}
// Post-signup writes are not required to enter onboarding.
void reportPostSignupFailures(postSignupTasks)
try {
// snooze first prompt after signup, defer to next prompt
snoozeEmailConfirmationPrompt()
} catch (e) {
logger.error(e instanceof Error ? e : String(e), {
message: `session: failed snoozeEmailConfirmationPrompt`,
})
}
bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get())
// Preparation may auto-refresh the session while hooks are still disarmed.
const account = await finishPreparation(
bundle,
Promise.all([gates, aa]),
() => snapshotNewAccount(session, email),
)
hooks.arm()
return {account, bundle}
}
/**
* Snapshot a just-created session as a `SessionAccount`.
*
* `com.atproto.server.createAccount` returns only tokens, handle, did and
* didDoc, so the session carries no email state or `active` flag until the
* first refresh. Synthesize those fields from the creation input so the
* persisted account does not briefly claim the email is unknown.
*/
function snapshotNewAccount(
session: PasswordSession,
email: string,
): SessionAccount {
const account = sessionDataToSessionAccount(
session.session,
session.session.service,
)
if (!account) {
throw Error('Expected an active session')
}
return {
...account,
email: account.email ?? email,
emailConfirmed: account.emailConfirmed ?? false,
emailAuthFactor: account.emailAuthFactor ?? false,
active: account.active ?? true,
}
}
function savePersonalDetails(agent: BskyAppAgent, birthDate: string) {
return retryPostSignupTask('set birthDate', 3, () =>
agent.setPersonalDetails({birthDate}),
)
}
function initializeProfile(
agent: BskyAppAgent,
{
handle,
createdAt,
isProd,
}: {
handle: string
createdAt: string
isProd: boolean
},
) {
return retryPostSignupTask('set initial profile', 3, () =>
agent.upsertProfile(prev => {
const next: Un$Typed<AppBskyActorProfile.Record> = prev || {}
if (isProd) {
next.displayName = handle
next.createdAt = createdAt
} else {
next.createdAt = prev?.createdAt || new Date().toISOString()
}
return next
}),
)
}
function initializeSavedFeeds(agent: BskyAppAgent) {
return retryPostSignupTask('set initial feeds', 1, () =>
agent.overwriteSavedFeeds([
{...DISCOVER_SAVED_FEED, id: TID.nextStr()},
{...TIMELINE_SAVED_FEED, id: TID.nextStr()},
]),
)
}
function restrictChatAfterAgeAssurance(
ageAssurance: Promise<unknown>,
client: Client,
did: string,
) {
return ageAssurance.then(() => {
const {flags} = unsafeGetAndComputeAgeAssurance({did})
if (flags?.chatDisabled || flags?.groupChatDisabled) {
void restrictChatSettings({
client,
restrictIncoming: flags.chatDisabled,
restrictGroupInvites: flags.groupChatDisabled,
})
}
})
}
function retryPostSignupTask<T>(
description: string,
retries: number,
task: () => Promise<T>,
) {
return networkRetry(retries, task).catch(e => {
logger.info(`createSessionBundleAndCreateAccount: failed to ${description}`)
throw e
})
}
async function reportPostSignupFailures(tasks: Promise<unknown>[]) {
const results = await Promise.allSettled(tasks)
if (results.some(result => result.status === 'rejected')) {
logger.error(
`session: createSessionBundleAndCreateAccount failed to save post-signup settings`,
)
}
}