redact session debug logs, fix stale bundle read and render-phase ref write
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+66
-19
@@ -3,6 +3,7 @@ import {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useInsertionEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
@@ -31,7 +32,14 @@ import {
|
|||||||
sessionDataToSessionAccount,
|
sessionDataToSessionAccount,
|
||||||
} from './session-core'
|
} from './session-core'
|
||||||
export {isSignupQueued} from './session-data'
|
export {isSignupQueued} from './session-data'
|
||||||
import {addSessionDebugLog} from './logging'
|
import {
|
||||||
|
addSessionDebugLog,
|
||||||
|
getBundleId,
|
||||||
|
redactAccount,
|
||||||
|
redactPersistedSession,
|
||||||
|
redactSessionData,
|
||||||
|
redactState,
|
||||||
|
} from './logging'
|
||||||
export type {SessionAccount} from '#/state/session/types'
|
export type {SessionAccount} from '#/state/session/types'
|
||||||
|
|
||||||
import {clearPersistedQueryStorage} from '#/lib/persisted-query-storage'
|
import {clearPersistedQueryStorage} from '#/lib/persisted-query-storage'
|
||||||
@@ -76,7 +84,7 @@ class SessionStore {
|
|||||||
constructor() {
|
constructor() {
|
||||||
// Careful: By the time this runs, `persisted` needs to already be filled.
|
// Careful: By the time this runs, `persisted` needs to already be filled.
|
||||||
const initialState = getInitialState(persisted.get('session').accounts)
|
const initialState = getInitialState(persisted.get('session').accounts)
|
||||||
addSessionDebugLog({type: 'reducer:init', state: initialState})
|
addSessionDebugLog({type: 'reducer:init', state: redactState(initialState)})
|
||||||
this.state = initialState
|
this.state = initialState
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +111,10 @@ class SessionStore {
|
|||||||
a => a.did === nextState.currentBundleState.did,
|
a => a.did === nextState.currentBundleState.did,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
addSessionDebugLog({type: 'persisted:broadcast', data: persistedData})
|
addSessionDebugLog({
|
||||||
|
type: 'persisted:broadcast',
|
||||||
|
data: redactPersistedSession(persistedData),
|
||||||
|
})
|
||||||
void persisted.write('session', persistedData)
|
void persisted.write('session', persistedData)
|
||||||
}
|
}
|
||||||
this.listeners.forEach(listener => listener())
|
this.listeners.forEach(listener => listener())
|
||||||
@@ -122,7 +133,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
const failedExpiryTokensRef = useRef<Map<string, Set<string>>>(new Map())
|
const failedExpiryTokensRef = useRef<Map<string, Set<string>>>(new Map())
|
||||||
/*
|
/*
|
||||||
* Rescued bundles need this callback for their own events. A ref avoids a
|
* Rescued bundles need this callback for their own events. A ref avoids a
|
||||||
* self-reference in the callback's dependency list.
|
* self-reference in the callback's dependency list. It is filled by the
|
||||||
|
* insertion effect below, which commits well before any session hook can
|
||||||
|
* fire: hooks are armed only after an asynchronous session factory resolves.
|
||||||
*/
|
*/
|
||||||
const onSessionChangeRef = useRef<
|
const onSessionChangeRef = useRef<
|
||||||
| ((
|
| ((
|
||||||
@@ -226,7 +239,15 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
},
|
},
|
||||||
[store],
|
[store],
|
||||||
)
|
)
|
||||||
onSessionChangeRef.current = onSessionChange
|
/*
|
||||||
|
* Writing the ref during render is forbidden under React Compiler. An
|
||||||
|
* insertion effect is the earliest commit-time slot, and the only reader
|
||||||
|
* (`onSessionChange`'s expiry-rescue path) runs from armed session hooks,
|
||||||
|
* which cannot fire before the first commit.
|
||||||
|
*/
|
||||||
|
useInsertionEffect(() => {
|
||||||
|
onSessionChangeRef.current = onSessionChange
|
||||||
|
}, [onSessionChange])
|
||||||
|
|
||||||
const createAccount = useCallback<SessionApiContext['createAccount']>(
|
const createAccount = useCallback<SessionApiContext['createAccount']>(
|
||||||
async (params, metrics) => {
|
async (params, metrics) => {
|
||||||
@@ -251,7 +272,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
ax.metric('account:create:success', metrics, {
|
ax.metric('account:create:success', metrics, {
|
||||||
session: utils.accountToSessionMetadata(account),
|
session: utils.accountToSessionMetadata(account),
|
||||||
})
|
})
|
||||||
addSessionDebugLog({type: 'method:end', method: 'createAccount', account})
|
addSessionDebugLog({
|
||||||
|
type: 'method:end',
|
||||||
|
method: 'createAccount',
|
||||||
|
account: redactAccount(account),
|
||||||
|
})
|
||||||
},
|
},
|
||||||
[ax, store, onSessionChange, cancelPendingTask],
|
[ax, store, onSessionChange, cancelPendingTask],
|
||||||
)
|
)
|
||||||
@@ -280,7 +305,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
{logContext, withPassword: true},
|
{logContext, withPassword: true},
|
||||||
{session: utils.accountToSessionMetadata(account)},
|
{session: utils.accountToSessionMetadata(account)},
|
||||||
)
|
)
|
||||||
addSessionDebugLog({type: 'method:end', method: 'login', account})
|
addSessionDebugLog({
|
||||||
|
type: 'method:end',
|
||||||
|
method: 'login',
|
||||||
|
account: redactAccount(account),
|
||||||
|
})
|
||||||
},
|
},
|
||||||
[ax, store, onSessionChange, cancelPendingTask],
|
[ax, store, onSessionChange, cancelPendingTask],
|
||||||
)
|
)
|
||||||
@@ -356,7 +385,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
addSessionDebugLog({
|
addSessionDebugLog({
|
||||||
type: 'method:start',
|
type: 'method:start',
|
||||||
method: 'resumeSession',
|
method: 'resumeSession',
|
||||||
account: storedAccount,
|
account: redactAccount(storedAccount),
|
||||||
})
|
})
|
||||||
const signal = cancelPendingTask()
|
const signal = cancelPendingTask()
|
||||||
const {bundle, account} = await createSessionBundleAndResume(
|
const {bundle, account} = await createSessionBundleAndResume(
|
||||||
@@ -385,7 +414,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
newBundle: bundle,
|
newBundle: bundle,
|
||||||
newAccount: account,
|
newAccount: account,
|
||||||
})
|
})
|
||||||
addSessionDebugLog({type: 'method:end', method: 'resumeSession', account})
|
addSessionDebugLog({
|
||||||
|
type: 'method:end',
|
||||||
|
method: 'resumeSession',
|
||||||
|
account: redactAccount(account),
|
||||||
|
})
|
||||||
if (isSwitchingAccounts) {
|
if (isSwitchingAccounts) {
|
||||||
// reset onboarding flow on switch account
|
// reset onboarding flow on switch account
|
||||||
onboardingDispatch({type: 'skip'})
|
onboardingDispatch({type: 'skip'})
|
||||||
@@ -397,7 +430,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
const partialRefreshSession = useCallback<
|
const partialRefreshSession = useCallback<
|
||||||
SessionApiContext['partialRefreshSession']
|
SessionApiContext['partialRefreshSession']
|
||||||
>(async () => {
|
>(async () => {
|
||||||
const bundle = state.currentBundleState.bundle as unknown as SessionBundle
|
/*
|
||||||
|
* Read the live bundle rather than the one captured by this render: a
|
||||||
|
* dispatch that lands before the next render would otherwise leave this
|
||||||
|
* holding a disposed bundle, whose agent dispatches unauthenticated.
|
||||||
|
*/
|
||||||
|
const bundle = store.getState().currentBundleState
|
||||||
|
.bundle as unknown as SessionBundle
|
||||||
const signal = cancelPendingTask()
|
const signal = cancelPendingTask()
|
||||||
/* getSession targets the PDS; only the persisted account fields are patched. */
|
/* getSession targets the PDS; only the persisted account fields are patched. */
|
||||||
const {data} = await bundle.agent.com.atproto.server.getSession()
|
const {data} = await bundle.agent.com.atproto.server.getSession()
|
||||||
@@ -415,21 +454,25 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
emailAuthFactor: data.emailAuthFactor,
|
emailAuthFactor: data.emailAuthFactor,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}, [store, state, cancelPendingTask])
|
}, [store, cancelPendingTask])
|
||||||
|
|
||||||
const removeAccount = useCallback<SessionApiContext['removeAccount']>(
|
const removeAccount = useCallback<SessionApiContext['removeAccount']>(
|
||||||
account => {
|
account => {
|
||||||
addSessionDebugLog({
|
addSessionDebugLog({
|
||||||
type: 'method:start',
|
type: 'method:start',
|
||||||
method: 'removeAccount',
|
method: 'removeAccount',
|
||||||
account,
|
account: redactAccount(account),
|
||||||
})
|
})
|
||||||
cancelPendingTask()
|
cancelPendingTask()
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
type: 'removed-account',
|
type: 'removed-account',
|
||||||
accountDid: account.did,
|
accountDid: account.did,
|
||||||
})
|
})
|
||||||
addSessionDebugLog({type: 'method:end', method: 'removeAccount', account})
|
addSessionDebugLog({
|
||||||
|
type: 'method:end',
|
||||||
|
method: 'removeAccount',
|
||||||
|
account: redactAccount(account),
|
||||||
|
})
|
||||||
clearAgeAssuranceServerDataForDid({did: account.did})
|
clearAgeAssuranceServerDataForDid({did: account.did})
|
||||||
},
|
},
|
||||||
[store, cancelPendingTask],
|
[store, cancelPendingTask],
|
||||||
@@ -437,7 +480,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return persisted.onUpdate('session', nextSession => {
|
return persisted.onUpdate('session', nextSession => {
|
||||||
const synced = nextSession
|
const synced = nextSession
|
||||||
addSessionDebugLog({type: 'persisted:receive', data: synced})
|
addSessionDebugLog({
|
||||||
|
type: 'persisted:receive',
|
||||||
|
data: redactPersistedSession(synced),
|
||||||
|
})
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
type: 'synced-accounts',
|
type: 'synced-accounts',
|
||||||
syncedAccounts: synced.accounts,
|
syncedAccounts: synced.accounts,
|
||||||
@@ -498,12 +544,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
if (isCurrent) {
|
if (isCurrent) {
|
||||||
addSessionDebugLog({
|
addSessionDebugLog({
|
||||||
type: 'bundle:patch',
|
type: 'bundle:patch',
|
||||||
bundle: newBundle,
|
bundleId: getBundleId(newBundle),
|
||||||
prevSession:
|
prevSession: redactSessionData(
|
||||||
prevBundle.session && !prevBundle.session.destroyed
|
prevBundle.session && !prevBundle.session.destroyed
|
||||||
? prevBundle.session.session
|
? prevBundle.session.session
|
||||||
: undefined,
|
: undefined,
|
||||||
nextSession: newBundle.session.session,
|
),
|
||||||
|
nextSession: redactSessionData(newBundle.session.session),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return isCurrent
|
return isCurrent
|
||||||
@@ -577,8 +624,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
currentBundleRef.current = bundle
|
currentBundleRef.current = bundle
|
||||||
addSessionDebugLog({
|
addSessionDebugLog({
|
||||||
type: 'bundle:switch',
|
type: 'bundle:switch',
|
||||||
prevBundle,
|
prevBundleId: getBundleId(prevBundle),
|
||||||
nextBundle: bundle,
|
nextBundleId: getBundleId(bundle),
|
||||||
})
|
})
|
||||||
// Replaced bundles must never consume another refresh token.
|
// Replaced bundles must never consume another refresh token.
|
||||||
disposeBundle(prevBundle)
|
disposeBundle(prevBundle)
|
||||||
|
|||||||
+142
-17
@@ -1,19 +1,140 @@
|
|||||||
|
import {type SessionData} from '@atproto/lex-password-session'
|
||||||
|
|
||||||
import {type Schema} from '../persisted'
|
import {type Schema} from '../persisted'
|
||||||
import {type Action, type State} from './reducer'
|
import {type Action, type State} from './reducer'
|
||||||
import {type AtpSessionEvent, type SessionAccount} from './types'
|
import {type AtpSessionEvent, type SessionAccount} from './types'
|
||||||
|
|
||||||
type Reducer = (state: State, action: Action) => State
|
type Reducer = (state: State, action: Action) => State
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An account reduced to the fields that are safe to ship off-device.
|
||||||
|
*
|
||||||
|
* Credentials become presence booleans and PII (email, service, pdsUrl) is
|
||||||
|
* dropped entirely. The log only ever needs to answer "which account was live,
|
||||||
|
* and did it still hold credentials?". The stubs at the bottom of this file are
|
||||||
|
* expected to be revived against Sentry or Bitdrift, so no payload type may be
|
||||||
|
* capable of carrying a live JWT in the first place.
|
||||||
|
*/
|
||||||
|
export type RedactedAccount = {
|
||||||
|
did: string
|
||||||
|
handle: string
|
||||||
|
active: boolean | undefined
|
||||||
|
status: string | undefined
|
||||||
|
signupQueued: boolean | undefined
|
||||||
|
hasAccessJwt: boolean
|
||||||
|
hasRefreshJwt: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The account list plus the current did, in redacted form. */
|
||||||
|
export type RedactedSessionSnapshot = {
|
||||||
|
accounts: RedactedAccount[]
|
||||||
|
currentDid: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live session data reduced to identity plus credential presence. */
|
||||||
|
export type RedactedSessionData = {
|
||||||
|
did: string
|
||||||
|
handle: string
|
||||||
|
hasAccessJwt: boolean
|
||||||
|
hasRefreshJwt: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function redact(account: SessionAccount): RedactedAccount {
|
||||||
|
return {
|
||||||
|
did: account.did,
|
||||||
|
handle: account.handle,
|
||||||
|
active: account.active,
|
||||||
|
status: account.status,
|
||||||
|
signupQueued: account.signupQueued,
|
||||||
|
hasAccessJwt: !!account.accessJwt,
|
||||||
|
hasRefreshJwt: !!account.refreshJwt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redactAccount(
|
||||||
|
account: SessionAccount | undefined,
|
||||||
|
): RedactedAccount | undefined {
|
||||||
|
return account ? redact(account) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redactState(state: State): RedactedSessionSnapshot {
|
||||||
|
return {
|
||||||
|
accounts: state.accounts.map(redact),
|
||||||
|
currentDid: state.currentBundleState.did,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redactPersistedSession(
|
||||||
|
data: Schema['session'],
|
||||||
|
): RedactedSessionSnapshot {
|
||||||
|
return {
|
||||||
|
accounts: data.accounts.map(redact),
|
||||||
|
currentDid: data.currentAccount?.did,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redactSessionData(
|
||||||
|
data: SessionData | undefined,
|
||||||
|
): RedactedSessionData | undefined {
|
||||||
|
if (!data) return undefined
|
||||||
|
return {
|
||||||
|
did: data.did,
|
||||||
|
handle: data.handle,
|
||||||
|
hasAccessJwt: !!data.accessJwt,
|
||||||
|
hasRefreshJwt: !!data.refreshJwt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Bundles are logged for identity only - which one was live, and which one
|
||||||
|
* replaced it. Logging the object itself would reach its session, and through
|
||||||
|
* it the tokens, so each is mapped to an opaque per-run id instead.
|
||||||
|
*/
|
||||||
|
const bundleIds = new WeakMap<object, string>()
|
||||||
|
const runId = Math.random().toString(36).slice(2)
|
||||||
|
let nextBundleId = 1
|
||||||
|
|
||||||
|
export function getBundleId(bundle: object): string {
|
||||||
|
let id = bundleIds.get(bundle)
|
||||||
|
if (id === undefined) {
|
||||||
|
id = runId + '::' + nextBundleId++
|
||||||
|
bundleIds.set(bundle, id)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An action reduced to its discriminant plus the did it targets, if any. */
|
||||||
|
type RedactedAction = {
|
||||||
|
type: Action['type']
|
||||||
|
accountDid?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactAction(action: Action): RedactedAction {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'received-session-event':
|
||||||
|
case 'removed-account':
|
||||||
|
case 'partial-refresh-session':
|
||||||
|
return {type: action.type, accountDid: action.accountDid}
|
||||||
|
case 'switched-to-account':
|
||||||
|
case 'replaced-current-bundle':
|
||||||
|
return {type: action.type, accountDid: action.newAccount.did}
|
||||||
|
case 'synced-accounts':
|
||||||
|
return {type: action.type, accountDid: action.syncedCurrentDid}
|
||||||
|
default:
|
||||||
|
return {type: action.type}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Log =
|
type Log =
|
||||||
| {
|
| {
|
||||||
type: 'reducer:init'
|
type: 'reducer:init'
|
||||||
state: State
|
state: RedactedSessionSnapshot
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'reducer:call'
|
type: 'reducer:call'
|
||||||
action: Action
|
action: RedactedAction
|
||||||
prevState: State
|
prevState: RedactedSessionSnapshot
|
||||||
nextState: State
|
nextState: RedactedSessionSnapshot
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'method:start'
|
type: 'method:start'
|
||||||
@@ -23,7 +144,7 @@ type Log =
|
|||||||
| 'logout'
|
| 'logout'
|
||||||
| 'resumeSession'
|
| 'resumeSession'
|
||||||
| 'removeAccount'
|
| 'removeAccount'
|
||||||
account?: SessionAccount
|
account?: RedactedAccount
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'method:end'
|
type: 'method:end'
|
||||||
@@ -33,37 +154,41 @@ type Log =
|
|||||||
| 'logout'
|
| 'logout'
|
||||||
| 'resumeSession'
|
| 'resumeSession'
|
||||||
| 'removeAccount'
|
| 'removeAccount'
|
||||||
account?: SessionAccount
|
account?: RedactedAccount
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'persisted:broadcast'
|
type: 'persisted:broadcast'
|
||||||
data: Schema['session']
|
data: RedactedSessionSnapshot
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'persisted:receive'
|
type: 'persisted:receive'
|
||||||
data: Schema['session']
|
data: RedactedSessionSnapshot
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'bundle:switch'
|
type: 'bundle:switch'
|
||||||
prevBundle: object
|
prevBundleId: string
|
||||||
nextBundle: object
|
nextBundleId: string
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
/*
|
/*
|
||||||
* Dev-only bundle-swap log. The bundle is treated as an opaque object
|
* Dev-only bundle-swap log. Bundles are identified by their opaque ids;
|
||||||
* (the reducer never reads its internals); the session snapshots are
|
* the session snapshots record only identity and credential presence.
|
||||||
* plain objects captured for debugging.
|
|
||||||
*/
|
*/
|
||||||
type: 'bundle:patch'
|
type: 'bundle:patch'
|
||||||
bundle: object
|
bundleId: string
|
||||||
prevSession: object | undefined
|
prevSession: RedactedSessionData | undefined
|
||||||
nextSession: object | undefined
|
nextSession: RedactedSessionData | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function wrapSessionReducerForLogging(reducer: Reducer): Reducer {
|
export function wrapSessionReducerForLogging(reducer: Reducer): Reducer {
|
||||||
return function loggingWrapper(prevState: State, action: Action): State {
|
return function loggingWrapper(prevState: State, action: Action): State {
|
||||||
const nextState = reducer(prevState, action)
|
const nextState = reducer(prevState, action)
|
||||||
addSessionDebugLog({type: 'reducer:call', prevState, action, nextState})
|
addSessionDebugLog({
|
||||||
|
type: 'reducer:call',
|
||||||
|
prevState: redactState(prevState),
|
||||||
|
action: redactAction(action),
|
||||||
|
nextState: redactState(nextState),
|
||||||
|
})
|
||||||
return nextState
|
return nextState
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user