Age Assurance V2 (#9479)

* Age Assurance V2

* Tighten up test

* Add todos for sdk migration

* Align RQ versions

* Use useEffect for side effect

* Improve effects, memoize

* Standarize on birthdate

* Copy feedback

* Copilot

* Add support link

* Reove double ..

* Cleanup

* Remove redirect dialog

* Cleanup todos, add comments

* Update splash in main template too

* Mock some stuff

* Exhaustive checks

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

* Exhaustive checks

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

* Small fix to bday handling

* Add comment

* onboarding style tweak

sneaking this in sorry!

* rm unreachable breaks

* Put useIntentHandler back on web

* Remove misleading success set

* Align on birthdate

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
Eric Bailey
2025-12-04 15:20:00 -06:00
committed by GitHub
parent 7735183af4
commit c4aef9f668
91 changed files with 3016 additions and 1799 deletions
@@ -10,6 +10,9 @@ jest.mock('jwt-decode', () => ({
},
}))
jest.mock('../../birthdate')
jest.mock('../../../ageAssurance/data')
describe('session', () => {
it('can log in and out', () => {
let state = getInitialState([])
+143 -40
View File
@@ -1,10 +1,12 @@
import {
Agent as BaseAgent,
type AppBskyActorProfile,
type AtprotoServiceType,
type AtpSessionData,
type AtpSessionEvent,
BskyAgent,
type Did,
type Un$Typed,
} from '@atproto/api'
import {type FetchHandler} from '@atproto/api/dist/agent'
import {type SessionManager} from '@atproto/api/dist/session-manager'
@@ -23,7 +25,13 @@ import {
import {tryFetchGates} from '#/lib/statsig/statsig'
import {getAge} from '#/lib/strings/time'
import {logger} from '#/logger'
import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate'
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
import {
prefetchAgeAssuranceData,
setBirthdateForDid,
setCreatedAtForDid,
} from '#/ageAssurance/data'
import {emitNetworkConfirmed, emitNetworkLost} from '../events'
import {addSessionErrorLog} from './logging'
import {
@@ -77,9 +85,15 @@ export async function createAgentAndResume(
}
}
// after session is attached
const aa = prefetchAgeAssuranceData({agent})
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
return agent.prepare(gates, moderation, onSessionChange)
return agent.prepare({
resolvers: [gates, moderation, aa],
onSessionChange,
})
}
export async function createAgentAndLogin(
@@ -111,10 +125,14 @@ export async function createAgentAndLogin(
const account = agentToSessionAccountOrThrow(agent)
const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
const moderation = configureModerationForAccount(agent, account)
const aa = prefetchAgeAssuranceData({agent})
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
return agent.prepare(gates, moderation, onSessionChange)
return agent.prepare({
resolvers: [gates, moderation, aa],
onSessionChange,
})
}
export async function createAgentAndCreateAccount(
@@ -156,42 +174,122 @@ export async function createAgentAndCreateAccount(
const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
const moderation = configureModerationForAccount(agent, account)
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: account.did, createdAt})
setBirthdateForDid({did: account.did, birthdate})
snoozeBirthdateUpdateAllowedForDid(account.did)
// do this last
const aa = prefetchAgeAssuranceData({agent})
// Not awaited so that we can still get into onboarding.
// This is OK because we won't let you toggle adult stuff until you set the date.
if (IS_PROD_SERVICE(service)) {
try {
networkRetry(1, async () => {
await agent.setPersonalDetails({birthDate: birthDate.toISOString()})
await agent.overwriteSavedFeeds([
{
...DISCOVER_SAVED_FEED,
id: TID.nextStr(),
},
{
...TIMELINE_SAVED_FEED,
id: TID.nextStr(),
},
])
if (getAge(birthDate) < 18) {
await agent.api.com.atproto.repo.putRecord({
repo: account.did,
collection: 'chat.bsky.actor.declaration',
rkey: 'self',
record: {
$type: 'chat.bsky.actor.declaration',
allowIncoming: 'none',
},
Promise.allSettled(
[
networkRetry(3, () => {
return agent.setPersonalDetails({
birthDate: birthdate,
})
}
})
} catch (e: any) {
logger.error(e, {
message: `session: createAgentAndCreateAccount failed to save personal details and feeds`,
})
}
}).catch(e => {
logger.info(`createAgentAndCreateAccount: failed to set birthDate`)
throw e
}),
networkRetry(3, () => {
return agent.upsertProfile(prev => {
const next: Un$Typed<AppBskyActorProfile.Record> = prev || {}
next.displayName = handle
next.createdAt = createdAt
return next
})
}).catch(e => {
logger.info(
`createAgentAndCreateAccount: failed to set initial profile`,
)
throw e
}),
networkRetry(1, () => {
return agent.overwriteSavedFeeds([
{
...DISCOVER_SAVED_FEED,
id: TID.nextStr(),
},
{
...TIMELINE_SAVED_FEED,
id: TID.nextStr(),
},
])
}).catch(e => {
logger.info(
`createAgentAndCreateAccount: failed to set initial feeds`,
)
throw e
}),
getAge(birthDate) < 18 &&
networkRetry(3, () => {
return agent.com.atproto.repo.putRecord({
repo: account.did,
collection: 'chat.bsky.actor.declaration',
rkey: 'self',
record: {
$type: 'chat.bsky.actor.declaration',
allowIncoming: 'none',
},
})
}).catch(e => {
logger.info(
`createAgentAndCreateAccount: failed to set chat declaration`,
)
throw e
}),
].filter(Boolean),
).then(promises => {
const rejected = promises.filter(p => p.status === 'rejected')
if (rejected.length > 0) {
logger.error(
`session: createAgentAndCreateAccount failed to save personal details and feeds`,
)
}
})
} else {
agent.setPersonalDetails({birthDate: birthDate.toISOString()})
Promise.allSettled(
[
networkRetry(3, () => {
return agent.setPersonalDetails({
birthDate: birthDate.toISOString(),
})
}).catch(e => {
logger.info(`createAgentAndCreateAccount: failed to set birthDate`)
throw e
}),
networkRetry(3, () => {
return agent.upsertProfile(prev => {
const next: Un$Typed<AppBskyActorProfile.Record> = prev || {}
next.createdAt = prev?.createdAt || new Date().toISOString()
return next
})
}).catch(e => {
logger.info(
`createAgentAndCreateAccount: failed to set initial profile`,
)
throw e
}),
].filter(Boolean),
).then(promises => {
const rejected = promises.filter(p => p.status === 'rejected')
if (rejected.length > 0) {
logger.error(
`session: createAgentAndCreateAccount failed to save personal details and feeds`,
)
}
})
}
try {
@@ -203,7 +301,10 @@ export async function createAgentAndCreateAccount(
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
return agent.prepare(gates, moderation, onSessionChange)
return agent.prepare({
resolvers: [gates, moderation, aa],
onSessionChange,
})
}
export function agentToSessionAccountOrThrow(agent: BskyAgent): SessionAccount {
@@ -306,18 +407,20 @@ class BskyAppAgent extends BskyAgent {
})
}
async prepare(
async prepare({
resolvers,
onSessionChange,
}: {
// Not awaited in the calling code so we can delay blocking on them.
gates: Promise<void>,
moderation: Promise<void>,
resolvers: Promise<unknown>[]
onSessionChange: (
agent: BskyAgent,
did: string,
event: AtpSessionEvent,
) => void,
) {
) => void
}) {
// There's nothing else left to do, so block on them here.
await Promise.all([gates, moderation])
await Promise.all(resolvers)
// Now the agent is ready.
const account = agentToSessionAccountOrThrow(this)
+24 -4
View File
@@ -24,6 +24,11 @@ import {
type SessionApiContext,
type SessionStateContext,
} from '#/state/session/types'
import {useOnboardingDispatch} from '#/state/shell/onboarding'
import {
clearAgeAssuranceData,
clearAgeAssuranceDataForDid,
} from '#/ageAssurance/data'
const StateContext = React.createContext<SessionStateContext>({
accounts: [],
@@ -91,6 +96,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const cancelPendingTask = useOneTaskAtATime()
const [store] = React.useState(() => new SessionStore())
const state = React.useSyncExternalStore(store.subscribe, store.getState)
const onboardingDispatch = useOnboardingDispatch()
const onAgentSessionChange = React.useCallback(
(agent: BskyAgent, accountDid: string, sessionEvent: AtpSessionEvent) => {
@@ -166,6 +172,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
logContext => {
addSessionDebugLog({type: 'method:start', method: 'logout'})
cancelPendingTask()
const prevState = store.getState()
store.dispatch({
type: 'logged-out-current-account',
})
@@ -175,8 +182,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
{statsig: true},
)
addSessionDebugLog({type: 'method:end', method: 'logout'})
if (prevState.currentAgentState.did) {
clearAgeAssuranceDataForDid({did: prevState.currentAgentState.did})
}
// reset onboarding flow on logout
onboardingDispatch({type: 'skip'})
},
[store, cancelPendingTask],
[store, cancelPendingTask, onboardingDispatch],
)
const logoutEveryAccount = React.useCallback<
@@ -194,12 +206,15 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
{statsig: true},
)
addSessionDebugLog({type: 'method:end', method: 'logout'})
clearAgeAssuranceData()
// reset onboarding flow on logout
onboardingDispatch({type: 'skip'})
},
[store, cancelPendingTask],
[store, cancelPendingTask, onboardingDispatch],
)
const resumeSession = React.useCallback<SessionApiContext['resumeSession']>(
async storedAccount => {
async (storedAccount, isSwitchingAccounts = false) => {
addSessionDebugLog({
type: 'method:start',
method: 'resumeSession',
@@ -220,8 +235,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
newAccount: account,
})
addSessionDebugLog({type: 'method:end', method: 'resumeSession', account})
if (isSwitchingAccounts) {
// reset onboarding flow on switch account
onboardingDispatch({type: 'skip'})
}
},
[store, onAgentSessionChange, cancelPendingTask],
[store, onAgentSessionChange, cancelPendingTask, onboardingDispatch],
)
const partialRefreshSession = React.useCallback<
@@ -254,6 +273,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
accountDid: account.did,
})
addSessionDebugLog({type: 'method:end', method: 'removeAccount', account})
clearAgeAssuranceDataForDid({did: account.did})
},
[store, cancelPendingTask],
)
+4 -1
View File
@@ -38,7 +38,10 @@ export type SessionApiContext = {
logoutEveryAccount: (
logContext: LogEvents['account:loggedOut']['logContext'],
) => void
resumeSession: (account: SessionAccount) => Promise<void>
resumeSession: (
account: SessionAccount,
isSwitchingAccounts?: boolean,
) => Promise<void>
removeAccount: (account: SessionAccount) => void
/**
* Calls `getSession` and updates select fields on the current account and