fix session refresh persistence, 2fa login, bundle disposal, cross-tab labelers
Review fixes (PR #11182 round 1): - PasswordSession fires onUpdated/onDeleted BEFORE committing its session data; thread the hook payload through to the provider instead of reading the live getter. Fixes rotated tokens never persisting (eventual forced logout) and expiry not logging out. - getErrorName now gates on LexError, so LexAuthFactorError (a sibling of XrpcError) surfaces AuthFactorTokenRequired and email-2fa users get the code input. - disposeBundle was a no-op; add a kill-switch closure around the session's injected fetch (covers the internal auto-refresh path) so a replaced session can't consume rotated refresh tokens. kill() also disarms the hooks so stale bundles can't dispatch into the reducer. - cross-tab same-did rebuild now reapplies subscribed labelers to the fresh appview client (was built with an empty per-instance set). - isAppLabeler reads Client.appLabelers instead of the hard-coded prod did, restoring test-env and regional-authority classification. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import {useMemo} from 'react'
|
import {useMemo} from 'react'
|
||||||
import {api} from '@bsky.app/sdk'
|
import {Client} from '@atproto/lex-client'
|
||||||
import {
|
import {
|
||||||
type InterpretedLabelValueDefinition,
|
type InterpretedLabelValueDefinition,
|
||||||
LABELS,
|
LABELS,
|
||||||
@@ -104,10 +104,11 @@ export function isAppLabeler(
|
|||||||
| app.bsky.labeler.defs.LabelerView
|
| app.bsky.labeler.defs.LabelerView
|
||||||
| app.bsky.labeler.defs.LabelerViewDetailed,
|
| app.bsky.labeler.defs.LabelerViewDetailed,
|
||||||
): boolean {
|
): boolean {
|
||||||
|
const appLabelers = Client.appLabelers as readonly string[]
|
||||||
if (typeof labeler === 'string') {
|
if (typeof labeler === 'string') {
|
||||||
return labeler === api.moderation.did
|
return appLabelers.includes(labeler)
|
||||||
}
|
}
|
||||||
return labeler.creator.did === api.moderation.did
|
return appLabelers.includes(labeler.creator.did)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLabelerSubscribed(
|
export function isLabelerSubscribed(
|
||||||
|
|||||||
+10
-3
@@ -1,4 +1,4 @@
|
|||||||
import {XrpcError, XrpcResponseError} from '@atproto/lex-client'
|
import {LexError, XrpcError, XrpcResponseError} from '@atproto/lex-client'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True for an XRPC error from a lex `Client` (`@atproto/lex-client` `XrpcError`,
|
* True for an XRPC error from a lex `Client` (`@atproto/lex-client` `XrpcError`,
|
||||||
@@ -18,9 +18,16 @@ export function getErrorStatus(e: unknown): number | undefined {
|
|||||||
return e instanceof XrpcResponseError ? e.status : undefined
|
return e instanceof XrpcResponseError ? e.status : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The lexicon error code (`err.error`). */
|
/**
|
||||||
|
* The lexicon error code (`err.error`). Gated on `LexError` (the base of the
|
||||||
|
* lex error hierarchy) rather than `XrpcError` so sibling `LexError` subclasses
|
||||||
|
* that are NOT `XrpcError` also surface their `.error` - notably
|
||||||
|
* `LexAuthFactorError` (`'AuthFactorTokenRequired'`), which `PasswordSession`
|
||||||
|
* throws for email-2FA logins. Every `XrpcError` is a `LexError`, so all
|
||||||
|
* existing call sites keep working.
|
||||||
|
*/
|
||||||
export function getErrorName(e: unknown): string | undefined {
|
export function getErrorName(e: unknown): string | undefined {
|
||||||
return isXrpcError(e) ? (e as {error?: string}).error : undefined
|
return e instanceof LexError ? e.error : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -38,8 +38,10 @@ jest.mock('jwt-decode', () => ({
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type AtpSessionEvent,
|
type AtpSessionEvent,
|
||||||
|
disposeBundle,
|
||||||
extractPdsUrl,
|
extractPdsUrl,
|
||||||
makeSessionHooks,
|
makeSessionHooks,
|
||||||
|
registerBundleKillSwitch,
|
||||||
sessionAccountToSessionData,
|
sessionAccountToSessionData,
|
||||||
type SessionBundle,
|
type SessionBundle,
|
||||||
sessionDataToSessionAccount,
|
sessionDataToSessionAccount,
|
||||||
@@ -425,7 +427,12 @@ describe('makeSessionHooks arm-latch + event mapping', () => {
|
|||||||
function setup() {
|
function setup() {
|
||||||
const onSessionChange =
|
const onSessionChange =
|
||||||
jest.fn<
|
jest.fn<
|
||||||
(bundle: SessionBundle, did: string, event: AtpSessionEvent) => void
|
(
|
||||||
|
bundle: SessionBundle,
|
||||||
|
did: string,
|
||||||
|
event: AtpSessionEvent,
|
||||||
|
sessionData?: SessionData,
|
||||||
|
) => void
|
||||||
>()
|
>()
|
||||||
/* the hook only passes this through by identity; a stub bundle suffices */
|
/* the hook only passes this through by identity; a stub bundle suffices */
|
||||||
const bundle = {} as SessionBundle
|
const bundle = {} as SessionBundle
|
||||||
@@ -443,7 +450,7 @@ describe('makeSessionHooks arm-latch + event mapping', () => {
|
|||||||
expect(onSessionChange).not.toHaveBeenCalled()
|
expect(onSessionChange).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("maps onUpdated -> 'update' after arm(), passing the bundle through", () => {
|
it("maps onUpdated -> 'update' after arm(), passing the bundle + payload through", () => {
|
||||||
const {onSessionChange, bundle, hooks} = setup()
|
const {onSessionChange, bundle, hooks} = setup()
|
||||||
hooks.arm()
|
hooks.arm()
|
||||||
void hooks.onUpdated?.call(fakeSession, fakeData)
|
void hooks.onUpdated?.call(fakeSession, fakeData)
|
||||||
@@ -451,6 +458,8 @@ describe('makeSessionHooks arm-latch + event mapping', () => {
|
|||||||
expect(onSessionChange.mock.calls[0][0]).toBe(bundle)
|
expect(onSessionChange.mock.calls[0][0]).toBe(bundle)
|
||||||
expect(onSessionChange.mock.calls[0][1]).toBe(DID)
|
expect(onSessionChange.mock.calls[0][1]).toBe(DID)
|
||||||
expect(onSessionChange.mock.calls[0][2]).toBe('update')
|
expect(onSessionChange.mock.calls[0][2]).toBe('update')
|
||||||
|
/* the fresh SessionData the library delivers is threaded through verbatim */
|
||||||
|
expect(onSessionChange.mock.calls[0][3]).toBe(fakeData)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("maps onDeleted -> 'expired' after arm()", () => {
|
it("maps onDeleted -> 'expired' after arm()", () => {
|
||||||
@@ -470,6 +479,169 @@ describe('makeSessionHooks arm-latch + event mapping', () => {
|
|||||||
)
|
)
|
||||||
expect(onSessionChange.mock.calls[0][2]).toBe('network-error')
|
expect(onSessionChange.mock.calls[0][2]).toBe('network-error')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("does NOT thread a payload on the 'expired' path", () => {
|
||||||
|
/*
|
||||||
|
* onDeleted maps to 'expired' with no sessionData. The provider guards on
|
||||||
|
* `event === 'update' && sessionData`, so a missing payload here is what
|
||||||
|
* forces refreshedAccount === undefined (reducer clears tokens + logs out).
|
||||||
|
*/
|
||||||
|
const {onSessionChange, hooks} = setup()
|
||||||
|
hooks.arm()
|
||||||
|
void hooks.onDeleted?.call(fakeSession, fakeData)
|
||||||
|
expect(onSessionChange.mock.calls[0][2]).toBe('expired')
|
||||||
|
expect(onSessionChange.mock.calls[0][3]).toBe(undefined)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The exact derivation from the provider's onSessionChange (index.tsx). Pinned
|
||||||
|
* here because the payload threading (session-core) and this mapping together
|
||||||
|
* are the fix: read tokens from the delivered payload on 'update', and force
|
||||||
|
* undefined on the drop paths so the reducer logs the user out.
|
||||||
|
*/
|
||||||
|
function deriveRefreshedAccount(
|
||||||
|
event: AtpSessionEvent,
|
||||||
|
sessionData?: SessionData,
|
||||||
|
): SessionAccount | undefined {
|
||||||
|
return event === 'update' && sessionData
|
||||||
|
? sessionDataToSessionAccount(sessionData, sessionData.service)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Pins the pre-commit ordering bug fix. `PasswordSession` fires onUpdated with
|
||||||
|
* the fresh session BEFORE committing it internally, so the live getter is
|
||||||
|
* still stale at hook time. Driven through the real library (not a hand-rolled
|
||||||
|
* fixture) so the ordering is authentic.
|
||||||
|
*/
|
||||||
|
describe('session-hook payload threading (pre-commit ordering)', () => {
|
||||||
|
it('delivers the NEW tokens via the payload even though the live getter is still pre-commit stale', async () => {
|
||||||
|
const fetchMock = makeMockFetch()
|
||||||
|
let session!: PasswordSession
|
||||||
|
let liveGetterAtHookTime: SessionAccount | undefined
|
||||||
|
let refreshedAccountAtHookTime: SessionAccount | undefined
|
||||||
|
const onSessionChange = jest.fn(
|
||||||
|
(
|
||||||
|
_bundle: SessionBundle,
|
||||||
|
_did: string,
|
||||||
|
event: AtpSessionEvent,
|
||||||
|
sessionData?: SessionData,
|
||||||
|
) => {
|
||||||
|
/* what the OLD code did: snapshot the live (mutable) getter */
|
||||||
|
liveGetterAtHookTime = sessionDataToSessionAccount(
|
||||||
|
session.session,
|
||||||
|
session.session.service,
|
||||||
|
)
|
||||||
|
/* what the fix does: derive from the delivered payload */
|
||||||
|
refreshedAccountAtHookTime = deriveRefreshedAccount(event, sessionData)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const hooks = makeSessionHooks(
|
||||||
|
onSessionChange,
|
||||||
|
() => ({}) as SessionBundle,
|
||||||
|
() => DID,
|
||||||
|
)
|
||||||
|
session = new PasswordSession(sessionAccountToSessionData(makeAccount()), {
|
||||||
|
...hooks,
|
||||||
|
fetch: asFetch(fetchMock),
|
||||||
|
})
|
||||||
|
hooks.arm()
|
||||||
|
|
||||||
|
await session.refresh()
|
||||||
|
|
||||||
|
/* pre-commit ordering: at hook time the live getter still held OLD tokens */
|
||||||
|
expect(liveGetterAtHookTime?.accessJwt).toBe('access-jwt')
|
||||||
|
/* the fix reads the fresh tokens from the payload the hook delivered */
|
||||||
|
expect(refreshedAccountAtHookTime?.accessJwt).toBe('access-jwt-2')
|
||||||
|
expect(refreshedAccountAtHookTime?.refreshJwt).toBe('refresh-jwt-2')
|
||||||
|
/* and the session does eventually commit those same tokens */
|
||||||
|
expect(session.session.accessJwt).toBe('access-jwt-2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("yields refreshedAccount === undefined on the 'expired' path (forces logout)", async () => {
|
||||||
|
const fetchMock = makeMockFetch({
|
||||||
|
'com.atproto.server.refreshSession': () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({error: 'ExpiredToken', message: 'Token expired'}),
|
||||||
|
{status: 400, headers: {'content-type': 'application/json'}},
|
||||||
|
),
|
||||||
|
})
|
||||||
|
let refreshedAccountAtHookTime: SessionAccount | undefined = makeAccount()
|
||||||
|
let observedEvent: AtpSessionEvent | undefined
|
||||||
|
const onSessionChange = jest.fn(
|
||||||
|
(
|
||||||
|
_bundle: SessionBundle,
|
||||||
|
_did: string,
|
||||||
|
event: AtpSessionEvent,
|
||||||
|
sessionData?: SessionData,
|
||||||
|
) => {
|
||||||
|
observedEvent = event
|
||||||
|
refreshedAccountAtHookTime = deriveRefreshedAccount(event, sessionData)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const hooks = makeSessionHooks(
|
||||||
|
onSessionChange,
|
||||||
|
() => ({}) as SessionBundle,
|
||||||
|
() => DID,
|
||||||
|
)
|
||||||
|
const session = new PasswordSession(
|
||||||
|
sessionAccountToSessionData(makeAccount()),
|
||||||
|
{...hooks, fetch: asFetch(fetchMock)},
|
||||||
|
)
|
||||||
|
hooks.arm()
|
||||||
|
|
||||||
|
await expect(session.refresh()).rejects.toBeDefined()
|
||||||
|
|
||||||
|
expect(observedEvent).toBe('expired')
|
||||||
|
expect(refreshedAccountAtHookTime).toBe(undefined)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Pins the disposal kill-switch (fix 3). `PasswordSession` exposes no local
|
||||||
|
* destroy, so disposeBundle neutralizes the session by tripping the flag inside
|
||||||
|
* the injected fetch - after disposal every request (direct or auto-refresh,
|
||||||
|
* which shares this same captured fetch) throws before touching the network.
|
||||||
|
*/
|
||||||
|
describe('disposeBundle kill-switch', () => {
|
||||||
|
it('the injected fetch throws after disposeBundle', () => {
|
||||||
|
const hooks = makeSessionHooks(
|
||||||
|
jest.fn(),
|
||||||
|
() => ({}) as SessionBundle,
|
||||||
|
() => DID,
|
||||||
|
)
|
||||||
|
/* the injected fetch is the kill-switch wrapper makeSessionHooks bakes in */
|
||||||
|
const injectedFetch = hooks.fetch!
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A live session is required for disposeBundle to act (it early-returns on
|
||||||
|
* a null/destroyed session).
|
||||||
|
*/
|
||||||
|
const session = new PasswordSession(
|
||||||
|
sessionAccountToSessionData(makeAccount()),
|
||||||
|
{...hooks},
|
||||||
|
)
|
||||||
|
const bundle = {session} as unknown as SessionBundle
|
||||||
|
registerBundleKillSwitch(bundle, hooks.kill)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Before disposal the wrapper does NOT throw synchronously - it delegates
|
||||||
|
* to the async networkAwareFetch and returns a promise. Swallow that
|
||||||
|
* promise's rejection (the real network is unavailable under jest); we only
|
||||||
|
* care that no synchronous throw happened here.
|
||||||
|
*/
|
||||||
|
const pending = injectedFetch('https://bsky.social/xrpc/x')
|
||||||
|
expect(pending).toBeInstanceOf(Promise)
|
||||||
|
void pending.catch(() => {})
|
||||||
|
|
||||||
|
disposeBundle(bundle)
|
||||||
|
|
||||||
|
/* after disposal every call through the injected fetch throws */
|
||||||
|
expect(() => injectedFetch('https://bsky.social/xrpc/x')).toThrow(
|
||||||
|
'session disposed',
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
useSyncExternalStore,
|
useSyncExternalStore,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import {type Client} from '@atproto/lex-client'
|
import {type Client} from '@atproto/lex-client'
|
||||||
import {PasswordSession} from '@atproto/lex-password-session'
|
import {PasswordSession, type SessionData} from '@atproto/lex-password-session'
|
||||||
|
|
||||||
import * as persisted from '#/state/persisted'
|
import * as persisted from '#/state/persisted'
|
||||||
import {useCloseAllActiveElements} from '#/state/util'
|
import {useCloseAllActiveElements} from '#/state/util'
|
||||||
@@ -19,6 +19,7 @@ import {IS_WEB} from '#/env'
|
|||||||
import {com} from '#/lexicons'
|
import {com} from '#/lexicons'
|
||||||
import {emitSessionDropped} from '../events'
|
import {emitSessionDropped} from '../events'
|
||||||
import {getPublicLexClient, getUnauthenticatedClient} from './clients'
|
import {getPublicLexClient, getUnauthenticatedClient} from './clients'
|
||||||
|
import {configureModerationForAccount} from './moderation'
|
||||||
import {type Action, getInitialState, reducer, type State} from './reducer'
|
import {type Action, getInitialState, reducer, type State} from './reducer'
|
||||||
import {
|
import {
|
||||||
type AtpSessionEvent,
|
type AtpSessionEvent,
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
disposeBundle,
|
disposeBundle,
|
||||||
makeSessionHooks,
|
makeSessionHooks,
|
||||||
type PublicSessionBundle,
|
type PublicSessionBundle,
|
||||||
|
registerBundleKillSwitch,
|
||||||
sessionAccountToSessionData,
|
sessionAccountToSessionData,
|
||||||
type SessionBundle,
|
type SessionBundle,
|
||||||
sessionDataToSessionAccount,
|
sessionDataToSessionAccount,
|
||||||
@@ -131,14 +133,23 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
bundle: SessionBundle,
|
bundle: SessionBundle,
|
||||||
accountDid: string,
|
accountDid: string,
|
||||||
sessionEvent: AtpSessionEvent,
|
sessionEvent: AtpSessionEvent,
|
||||||
|
sessionData?: SessionData,
|
||||||
) => {
|
) => {
|
||||||
// Snapshot the (mutable) live session data right away.
|
/*
|
||||||
|
* Build the refreshed account from the payload the hook delivers, NOT the
|
||||||
|
* live session getter. `PasswordSession` fires onUpdated/onDeleted BEFORE
|
||||||
|
* it commits its internal `#sessionData` (see password-session.js), so at
|
||||||
|
* hook time `bundle.session.session` still holds the OLD tokens (and, on
|
||||||
|
* the expiry path, `destroyed` is still false). Reading the live getter
|
||||||
|
* here would (a) persist stale tokens on 'update' -> eventual forced
|
||||||
|
* logout once the real refresh token expires, and (b) keep the user
|
||||||
|
* signed in on 'expired'. On 'update' the payload carries the new session;
|
||||||
|
* on 'expired'/'create-failed' we force it undefined so the reducer clears
|
||||||
|
* tokens and logs out (it treats undefined as "session gone").
|
||||||
|
*/
|
||||||
const refreshedAccount =
|
const refreshedAccount =
|
||||||
bundle.session && !bundle.session.destroyed
|
sessionEvent === 'update' && sessionData
|
||||||
? sessionDataToSessionAccount(
|
? sessionDataToSessionAccount(sessionData, sessionData.service)
|
||||||
bundle.session.session,
|
|
||||||
bundle.session.session.service,
|
|
||||||
)
|
|
||||||
: undefined
|
: undefined
|
||||||
if (sessionEvent === 'expired' || sessionEvent === 'create-failed') {
|
if (sessionEvent === 'expired' || sessionEvent === 'create-failed') {
|
||||||
emitSessionDropped()
|
emitSessionDropped()
|
||||||
@@ -420,7 +431,20 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
hooks,
|
hooks,
|
||||||
)
|
)
|
||||||
newBundle = buildBundle(newSession)
|
newBundle = buildBundle(newSession)
|
||||||
|
registerBundleKillSwitch(newBundle, hooks.kill)
|
||||||
hooks.arm()
|
hooks.arm()
|
||||||
|
/*
|
||||||
|
* Reapply this account's subscribed labelers to the freshly built
|
||||||
|
* appview client. buildBundle starts with an empty per-instance
|
||||||
|
* labeler set, and unlike login/resume/createAccount this rebuild path
|
||||||
|
* never ran configureModerationForAccount - so without this, subscribed
|
||||||
|
* labelers would silently drop from appview requests in follower tabs
|
||||||
|
* until the next full resume. readLabelers is a local storage read (no
|
||||||
|
* network), preserving the branch's no-network intent; setLabelers
|
||||||
|
* mutates the client's Set in place so labels reappear when it
|
||||||
|
* resolves.
|
||||||
|
*/
|
||||||
|
void configureModerationForAccount(newBundle, syncedAccount)
|
||||||
addSessionDebugLog({
|
addSessionDebugLog({
|
||||||
type: 'agent:patch',
|
type: 'agent:patch',
|
||||||
agent: newBundle,
|
agent: newBundle,
|
||||||
|
|||||||
@@ -295,6 +295,31 @@ export type SessionBundle = {
|
|||||||
readonly service: URL
|
readonly service: URL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill-switches for live bundles, keyed by bundle identity.
|
||||||
|
*
|
||||||
|
* `PasswordSession` exposes no local (logout-free) destroy, so disposal is
|
||||||
|
* implemented via a closure flag inside the session's injected `fetch` (see
|
||||||
|
* {@link makeSessionHooks}). The `kill()` that trips that flag is produced next
|
||||||
|
* to the hooks - before the bundle exists - so we stash it here once the bundle
|
||||||
|
* is built and look it up in {@link disposeBundle}. A `WeakMap` keeps this off
|
||||||
|
* the {@link SessionBundle} type (the reducer's opaque view must not see it) and
|
||||||
|
* lets the entry be GC'd with the bundle.
|
||||||
|
*/
|
||||||
|
const bundleKillSwitches = new WeakMap<SessionBundle, () => void>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Associate a bundle with the `kill()` from its {@link makeSessionHooks}, so
|
||||||
|
* {@link disposeBundle} can neutralize the underlying session. Call once, right
|
||||||
|
* after the bundle is built, at every session-construction site.
|
||||||
|
*/
|
||||||
|
export function registerBundleKillSwitch(
|
||||||
|
bundle: SessionBundle,
|
||||||
|
kill: () => void,
|
||||||
|
) {
|
||||||
|
bundleKillSwitches.set(bundle, kill)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assemble a {@link SessionBundle} from a live session: the account, appview,
|
* Assemble a {@link SessionBundle} from a live session: the account, appview,
|
||||||
* and chat clients, all read-through views over the one session. The appview
|
* and chat clients, all read-through views over the one session. The appview
|
||||||
@@ -331,11 +356,20 @@ export function buildBundle(session: PasswordSession): SessionBundle {
|
|||||||
* -> `'expired'`, transient failure -> `'network-error'`. The whole
|
* -> `'expired'`, transient failure -> `'network-error'`. The whole
|
||||||
* {@link SessionBundle} is handed through so the provider can snapshot the live
|
* {@link SessionBundle} is handed through so the provider can snapshot the live
|
||||||
* session and use the bundle itself as the reducer's identity token.
|
* session and use the bundle itself as the reducer's identity token.
|
||||||
|
*
|
||||||
|
* `sessionData` is the fresh payload the library hands the hook. It matters
|
||||||
|
* because `PasswordSession` fires `onUpdated`/`onDeleted` BEFORE committing
|
||||||
|
* `#sessionData` (see `refresh()`/`logout()` in password-session.js), so the
|
||||||
|
* live getter (`bundle.session.session`) still returns the OLD tokens at hook
|
||||||
|
* time. The provider must build the refreshed account from this argument, not
|
||||||
|
* from the live getter. Present on the `'update'` path (the new session) and
|
||||||
|
* absent on the error paths.
|
||||||
*/
|
*/
|
||||||
type OnSessionChange = (
|
type OnSessionChange = (
|
||||||
bundle: SessionBundle,
|
bundle: SessionBundle,
|
||||||
did: string,
|
did: string,
|
||||||
event: AtpSessionEvent,
|
event: AtpSessionEvent,
|
||||||
|
sessionData?: SessionData,
|
||||||
) => void
|
) => void
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -352,6 +386,20 @@ type OnSessionChange = (
|
|||||||
* are constructed (the session is created first, then the bundle is built over
|
* are constructed (the session is created first, then the bundle is built over
|
||||||
* it).
|
* it).
|
||||||
*
|
*
|
||||||
|
* The hooks thread the fresh `SessionData` the library delivers straight
|
||||||
|
* through to `onSessionChange` (the `'update'` payload). The library fires the
|
||||||
|
* hook BEFORE committing that data internally, so the provider must read tokens
|
||||||
|
* from this argument rather than the (still-stale) live session getter.
|
||||||
|
*
|
||||||
|
* The `fetch` option is wrapped in a kill-switch: `kill()` (returned alongside
|
||||||
|
* `arm()`) sets a closure flag so every subsequent request through this
|
||||||
|
* session - direct fetches AND the internal auto-refresh, which
|
||||||
|
* `PasswordSession` routes through the same `options.fetch` captured at
|
||||||
|
* construction - throws instead of hitting the network. `kill()` also disarms
|
||||||
|
* the hooks so a disposed session can never dispatch into the reducer. This is
|
||||||
|
* the disposal mechanism {@link disposeBundle} relies on (`PasswordSession`
|
||||||
|
* exposes no local destroy).
|
||||||
|
*
|
||||||
* Exported for testing (the arm-latch + event mapping is the core semantics).
|
* Exported for testing (the arm-latch + event mapping is the core semantics).
|
||||||
*/
|
*/
|
||||||
export function makeSessionHooks(
|
export function makeSessionHooks(
|
||||||
@@ -360,12 +408,13 @@ export function makeSessionHooks(
|
|||||||
getDid: () => string,
|
getDid: () => string,
|
||||||
) {
|
) {
|
||||||
let armed = false
|
let armed = false
|
||||||
const dispatch = (event: AtpSessionEvent) => {
|
let killed = false
|
||||||
|
const dispatch = (event: AtpSessionEvent, sessionData?: SessionData) => {
|
||||||
if (!armed) {
|
if (!armed) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const did = getDid()
|
const did = getDid()
|
||||||
onSessionChange(getBundle(), did, event)
|
onSessionChange(getBundle(), did, event, sessionData)
|
||||||
/*
|
/*
|
||||||
* Mirror the old BskyAppAgent.prepare wiring: log any non-create/update
|
* Mirror the old BskyAppAgent.prepare wiring: log any non-create/update
|
||||||
* session event. In practice we only emit 'update'/'expired'/'network-error'
|
* session event. In practice we only emit 'update'/'expired'/'network-error'
|
||||||
@@ -376,9 +425,14 @@ export function makeSessionHooks(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const hooks: PasswordSessionOptions = {
|
const hooks: PasswordSessionOptions = {
|
||||||
fetch: networkAwareFetch,
|
fetch: (input, init) => {
|
||||||
onUpdated() {
|
if (killed) {
|
||||||
dispatch('update')
|
throw new Error('session disposed')
|
||||||
|
}
|
||||||
|
return networkAwareFetch(input, init)
|
||||||
|
},
|
||||||
|
onUpdated(data) {
|
||||||
|
dispatch('update', data)
|
||||||
},
|
},
|
||||||
onDeleted() {
|
onDeleted() {
|
||||||
dispatch('expired')
|
dispatch('expired')
|
||||||
@@ -391,6 +445,10 @@ export function makeSessionHooks(
|
|||||||
arm() {
|
arm() {
|
||||||
armed = true
|
armed = true
|
||||||
},
|
},
|
||||||
|
kill() {
|
||||||
|
killed = true
|
||||||
|
armed = false
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,6 +533,7 @@ export async function createSessionBundleAndResume(
|
|||||||
}
|
}
|
||||||
|
|
||||||
bundle = buildBundle(session)
|
bundle = buildBundle(session)
|
||||||
|
registerBundleKillSwitch(bundle, hooks.kill)
|
||||||
const account =
|
const account =
|
||||||
sessionDataToSessionAccount(session.session, session.session.service) ??
|
sessionDataToSessionAccount(session.session, session.session.service) ??
|
||||||
storedAccount
|
storedAccount
|
||||||
@@ -527,6 +586,7 @@ export async function createSessionBundleAndLogin(
|
|||||||
})
|
})
|
||||||
|
|
||||||
bundle = buildBundle(session)
|
bundle = buildBundle(session)
|
||||||
|
registerBundleKillSwitch(bundle, hooks.kill)
|
||||||
const account = sessionDataToSessionAccountOrThrow(session)
|
const account = sessionDataToSessionAccountOrThrow(session)
|
||||||
accountDid = account.did
|
accountDid = account.did
|
||||||
|
|
||||||
@@ -594,6 +654,7 @@ export async function createSessionBundleAndCreateAccount(
|
|||||||
)
|
)
|
||||||
|
|
||||||
bundle = buildBundle(session)
|
bundle = buildBundle(session)
|
||||||
|
registerBundleKillSwitch(bundle, hooks.kill)
|
||||||
const account = sessionDataToSessionAccountOrThrow(session)
|
const account = sessionDataToSessionAccountOrThrow(session)
|
||||||
accountDid = account.did
|
accountDid = account.did
|
||||||
|
|
||||||
@@ -749,26 +810,25 @@ function sessionDataToSessionAccountOrThrow(
|
|||||||
/**
|
/**
|
||||||
* Neutralize a bundle's session so it can never refresh again.
|
* Neutralize a bundle's session so it can never refresh again.
|
||||||
*
|
*
|
||||||
* Called when switching away from / disposing an account. We null out the
|
* Called when switching away from / disposing an account. `PasswordSession`
|
||||||
* session locally (constructing a fresh destroyed-state marker is not exposed,
|
* exposes no synchronous, hook-free way to mark itself destroyed without a
|
||||||
* so we rely on the reducer dropping all references) - the important guarantee
|
* network logout (and `logout()`/`delete()` would revoke on the server, which
|
||||||
* is that this session's tokens are no longer reachable by any live client. We
|
* we do NOT want for a local switch - revocation is handled separately via the
|
||||||
* do NOT call `logout()` here: disposal is a local switch, not a server-side
|
* push-token unregister temporary sessions). So we trip the kill-switch
|
||||||
* revocation (revocation is handled separately via the push-token unregister
|
* installed in the session's injected `fetch` (see {@link makeSessionHooks} /
|
||||||
* temporary sessions). The bundle's clients stay usable enough not to crash
|
* {@link registerBundleKillSwitch}): every subsequent request through this
|
||||||
* late readers.
|
* session - direct fetch AND the internal auto-refresh, which shares the same
|
||||||
|
* captured `options.fetch` - throws before touching the network. A tripped
|
||||||
|
* refresh routes into the `onUpdateFailure` path (session preserved locally,
|
||||||
|
* refresh token NOT consumed server-side). `kill()` also disarms the hooks so
|
||||||
|
* the stale bundle can no longer dispatch into the reducer. The important
|
||||||
|
* guarantee - matching the old `dispose()` - is that this session's tokens are
|
||||||
|
* no longer reachable by any live network path.
|
||||||
*/
|
*/
|
||||||
export function disposeBundle(bundle: SessionBundle | PublicSessionBundle) {
|
export function disposeBundle(bundle: SessionBundle | PublicSessionBundle) {
|
||||||
const session = bundle.session
|
const session = bundle.session
|
||||||
if (!session || session.destroyed) {
|
if (!session || session.destroyed) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
/*
|
bundleKillSwitches.get(bundle)?.()
|
||||||
* There is no synchronous, hook-free way to mark a PasswordSession destroyed
|
|
||||||
* without a network logout. PasswordSession.delete() would revoke on the
|
|
||||||
* server, which we do NOT want for a local switch. So we fire-and-forget a
|
|
||||||
* logout-free neutralization by dropping our reference; GC reclaims the
|
|
||||||
* session. Any late fetchHandler call still uses valid tokens until the
|
|
||||||
* bundle is dereferenced by the reducer, which is the pre-existing behavior.
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user