fix session storage recovery

This commit is contained in:
Samuel Newman
2026-07-26 17:20:08 +03:00
parent 54a12c1740
commit 767e9f1f51
7 changed files with 797 additions and 101 deletions
+7 -5
View File
@@ -106,11 +106,13 @@ class SessionStore {
this.repository.getSnapshot(), this.repository.getSnapshot(),
nextSnapshot, nextSnapshot,
) )
if (result.status === 'pending') { void Promise.resolve(result).then(commitResult => {
this.storageErrorListeners.forEach(listener => if (commitResult.status === 'pending') {
listener(result.error.kind), this.storageErrorListeners.forEach(listener =>
) listener(commitResult.error.kind),
} )
}
})
this.listeners.forEach(listener => listener()) this.listeners.forEach(listener => listener())
} }
@@ -275,4 +275,69 @@ describe('NativeSessionRepository', () => {
dids: [], dids: [],
}) })
}) })
it('cancels an older pending credential write when clear fails', async () => {
const repository = new NativeSessionRepository()
const active = {accounts: [alice], currentDid: alice.did}
await repository.open(active)
const keys = accountKeys(alice.did)
mockFailKey = keys.access
expect(
repository.commit(active, {
accounts: [{...alice, accessJwt: 'new-access'}],
currentDid: alice.did,
}).status,
).toBe('pending')
mockFailKey = SESSION_INDEX_KEY
await expect(repository.clear()).rejects.toThrow('disk full')
expect(repository.getSnapshot()).toEqual({
accounts: [],
currentDid: undefined,
})
expect(repository.commit(active, active).status).toBe('pending')
expect(repository.getSnapshot()).toEqual({
accounts: [],
currentDid: undefined,
})
mockFailKey = undefined
expect(repository.retryPending()).toEqual({status: 'committed'})
expect(mockValues.get(keys.refresh)).toBe('')
expect(mockValues.get(keys.access)).toBe('')
expect(mockValues.get(keys.descriptor)).toBe('')
expect(JSON.parse(mockValues.get(SESSION_INDEX_KEY)!)).toEqual({
version: 1,
dids: [],
})
})
it('tombstones credentials from a superseded partial account write', async () => {
const repository = new NativeSessionRepository()
await repository.open()
const keys = accountKeys(alice.did)
mockFailKey = SESSION_INDEX_KEY
expect(
repository.commit(
{accounts: []},
{accounts: [alice], currentDid: alice.did},
).status,
).toBe('pending')
expect(mockValues.get(keys.refresh)).toBe(alice.refreshJwt)
expect(mockValues.get(keys.access)).toBe(alice.accessJwt)
mockFailKey = undefined
expect(
repository.commit(
{accounts: [alice], currentDid: alice.did},
{accounts: [], currentDid: undefined},
),
).toEqual({status: 'committed'})
expect(mockValues.get(keys.refresh)).toBe('')
expect(mockValues.get(keys.access)).toBe('')
expect(mockValues.get(keys.descriptor)).toBe('')
})
}) })
@@ -0,0 +1,344 @@
import {afterEach, beforeEach, describe, expect, it, jest} from '@jest/globals'
import {WebSessionRepository} from '../repository.web'
import {type SessionAccount, type SessionSnapshot} from '../schema'
const STORAGE_KEY = 'BSKY_SESSION_STORAGE_V1'
const values = new Map<string, string>()
let failRead = false
let failWrite = false
let lockQueue = Promise.resolve<unknown>(undefined)
const storageLocks = {
request: jest.fn((_name: string, callback: () => unknown) => {
const result = lockQueue.then(callback)
lockQueue = result.then(
() => undefined,
() => undefined,
)
return result
}),
}
const storage = {
getItem: jest.fn((key: string) => {
if (failRead) throw new Error('localStorage unavailable')
return values.get(key) ?? null
}),
setItem: jest.fn((key: string, value: string) => {
if (failWrite) throw new Error('localStorage unavailable')
values.set(key, value)
}),
}
const alice: SessionAccount = {
service: 'https://bsky.social',
did: 'did:plc:alice',
handle: 'alice.test',
refreshJwt: 'alice-refresh',
accessJwt: 'alice-access',
emailConfirmed: false,
}
const bob: SessionAccount = {
service: 'https://bsky.social',
did: 'did:plc:bob',
handle: 'bob.test',
refreshJwt: 'bob-refresh',
accessJwt: 'bob-access',
}
const charlie: SessionAccount = {
service: 'https://bsky.social',
did: 'did:plc:charlie',
handle: 'charlie.test',
refreshJwt: 'charlie-refresh',
accessJwt: 'charlie-access',
}
beforeEach(() => {
jest.useFakeTimers()
values.clear()
failRead = false
failWrite = false
lockQueue = Promise.resolve(undefined)
storageLocks.request.mockClear()
storage.getItem.mockClear()
storage.setItem.mockClear()
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: storage,
})
Object.defineProperty(globalThis.navigator, 'locks', {
configurable: true,
value: storageLocks,
})
if (!globalThis.window) {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {},
})
}
Object.defineProperty(globalThis.window, 'addEventListener', {
configurable: true,
value: jest.fn(),
})
})
afterEach(() => {
jest.clearAllTimers()
jest.useRealTimers()
})
describe('WebSessionRepository', () => {
it('repairs corrupt storage and starts logged out', async () => {
values.set(STORAGE_KEY, '{invalid json')
const repository = new WebSessionRepository()
await expect(
repository.open({accounts: [alice], currentDid: alice.did}),
).resolves.toEqual({
status: 'ready',
snapshot: {accounts: [], currentDid: undefined},
shouldScrubLegacy: true,
})
expect(readStoredSnapshot()).toEqual({accounts: []})
})
it('keeps a legacy session in memory when localStorage is unavailable', async () => {
failRead = true
failWrite = true
const repository = new WebSessionRepository()
const legacy = {accounts: [alice], currentDid: alice.did}
await expect(repository.open(legacy)).resolves.toEqual({
status: 'ready',
snapshot: legacy,
shouldScrubLegacy: false,
})
expect(repository.getSnapshot()).toEqual(legacy)
})
it('rebases local changes onto a newer snapshot from another tab', async () => {
const previous: SessionSnapshot = {
accounts: [alice, bob],
currentDid: alice.did,
}
const repository = new WebSessionRepository()
await repository.open(previous)
const latest: SessionSnapshot = {
accounts: [
{...alice, refreshJwt: 'fresh-refresh', accessJwt: 'fresh-access'},
bob,
charlie,
],
currentDid: charlie.did,
}
values.set(STORAGE_KEY, JSON.stringify(latest))
const next: SessionSnapshot = {
accounts: [{...alice, emailConfirmed: true}],
currentDid: alice.did,
}
await expect(repository.commit(previous, next)).resolves.toEqual({
status: 'committed',
})
expect(readStoredSnapshot()).toEqual({
accounts: [
{
...alice,
refreshJwt: 'fresh-refresh',
accessJwt: 'fresh-access',
emailConfirmed: true,
},
charlie,
],
currentDid: charlie.did,
})
})
it('serializes concurrent commits from multiple tabs', async () => {
const previous: SessionSnapshot = {
accounts: [alice, bob],
currentDid: alice.did,
}
const firstTab = new WebSessionRepository()
const secondTab = new WebSessionRepository()
await firstTab.open(previous)
await secondTab.open(previous)
await Promise.all([
firstTab.commit(previous, {
accounts: [{...alice, emailConfirmed: true}, bob],
currentDid: alice.did,
}),
secondTab.commit(previous, {
accounts: [alice, {...bob, handle: 'new-bob.test'}],
currentDid: alice.did,
}),
])
expect(readStoredSnapshot()).toEqual({
accounts: [
{...alice, emailConfirmed: true},
{...bob, handle: 'new-bob.test'},
],
currentDid: alice.did,
})
expect(storageLocks.request).toHaveBeenCalledWith(
'bsky-session:BSKY_SESSION_STORAGE_V1',
expect.any(Function),
)
})
it('does not restore credentials revoked by another tab', async () => {
const previous: SessionSnapshot = {
accounts: [alice],
currentDid: alice.did,
}
const repository = new WebSessionRepository()
await repository.open(previous)
values.set(
STORAGE_KEY,
JSON.stringify({
accounts: [{...alice, refreshJwt: undefined, accessJwt: undefined}],
currentDid: undefined,
}),
)
await repository.commit(previous, {
accounts: [
{...alice, refreshJwt: 'fresh-refresh', accessJwt: 'fresh-access'},
],
currentDid: alice.did,
})
const {
refreshJwt: _refreshJwt,
accessJwt: _accessJwt,
...loggedOutAlice
} = alice
expect(readStoredSnapshot()).toEqual({
accounts: [loggedOutAlice],
})
})
it('preserves credential revocation during concurrent account adds', async () => {
const repository = new WebSessionRepository()
const previous: SessionSnapshot = {accounts: [], currentDid: undefined}
await repository.open(previous)
const {
refreshJwt: _refreshJwt,
accessJwt: _accessJwt,
...loggedOutAlice
} = alice
values.set(
STORAGE_KEY,
JSON.stringify({accounts: [loggedOutAlice], currentDid: undefined}),
)
await repository.commit(previous, {
accounts: [alice],
currentDid: alice.did,
})
expect(readStoredSnapshot()).toEqual({
accounts: [loggedOutAlice],
currentDid: alice.did,
})
})
it('does not restore an account deleted by another tab', async () => {
const previous: SessionSnapshot = {
accounts: [alice, bob],
currentDid: alice.did,
}
const repository = new WebSessionRepository()
await repository.open(previous)
values.set(
STORAGE_KEY,
JSON.stringify({accounts: [bob], currentDid: bob.did}),
)
const editedAlice = {...alice, emailConfirmed: true}
const next: SessionSnapshot = {
accounts: [editedAlice, bob],
currentDid: alice.did,
}
await expect(repository.commit(previous, next)).resolves.toEqual({
status: 'committed',
})
expect(readStoredSnapshot()).toEqual({
accounts: [bob],
currentDid: bob.did,
})
})
it('replaces an older pending commit with a failed clear', async () => {
const previous: SessionSnapshot = {
accounts: [alice],
currentDid: alice.did,
}
const repository = new WebSessionRepository()
await repository.open(previous)
failWrite = true
await expect(
repository.commit(previous, {
accounts: [{...alice, accessJwt: 'new-access'}],
currentDid: alice.did,
}),
).resolves.toMatchObject({status: 'pending'})
await expect(repository.clear()).rejects.toThrow(
'session storage clear failed: write-failed',
)
expect(repository.getSnapshot()).toEqual({
accounts: [],
currentDid: undefined,
})
await expect(repository.commit(previous, previous)).resolves.toMatchObject({
status: 'pending',
})
expect(repository.getSnapshot()).toEqual({
accounts: [],
currentDid: undefined,
})
failWrite = false
await expect(repository.retryPending()).resolves.toEqual({
status: 'committed',
})
expect(readStoredSnapshot()).toEqual({accounts: []})
expect(repository.getSnapshot()).toEqual({accounts: []})
})
it('does not overwrite newer storage when initial persistence retries', async () => {
failWrite = true
const repository = new WebSessionRepository()
const legacy = {accounts: [alice], currentDid: alice.did}
const onLegacyMigrationComplete = jest.fn()
await repository.open(legacy, onLegacyMigrationComplete)
expect(onLegacyMigrationComplete).not.toHaveBeenCalled()
const refreshed = {
accounts: [{...alice, accessJwt: 'fresh-access'}],
currentDid: alice.did,
}
failWrite = false
values.set(STORAGE_KEY, JSON.stringify(refreshed))
await expect(repository.retryPending()).resolves.toEqual({
status: 'committed',
})
expect(readStoredSnapshot()).toEqual(refreshed)
expect(repository.getSnapshot()).toEqual(refreshed)
expect(onLegacyMigrationComplete).toHaveBeenCalledTimes(1)
})
})
function readStoredSnapshot() {
return JSON.parse(values.get(STORAGE_KEY)!)
}
+9 -5
View File
@@ -18,7 +18,14 @@ export async function initSessionRepository() {
? legacyCurrentDid ? legacyCurrentDid
: undefined, : undefined,
} }
const result = await repository.open(legacySnapshot) const scrubLegacy = () =>
persisted.write('session', {
accounts: [],
currentAccount: undefined,
})
const result = await repository.open(legacySnapshot, () => {
void scrubLegacy()
})
if (result.status === 'unavailable') { if (result.status === 'unavailable') {
throw new Error(`session storage unavailable: ${result.error.kind}`) throw new Error(`session storage unavailable: ${result.error.kind}`)
} }
@@ -26,10 +33,7 @@ export async function initSessionRepository() {
if (result.shouldScrubLegacy) { if (result.shouldScrubLegacy) {
// The new repository has been read back successfully. Scrub the old blob // The new repository has been read back successfully. Scrub the old blob
// so future preference writes cannot keep rewriting bearer credentials. // so future preference writes cannot keep rewriting bearer credentials.
await persisted.write('session', { await scrubLegacy()
accounts: [],
currentAccount: undefined,
})
} }
initialized = true initialized = true
return repository return repository
+66 -19
View File
@@ -39,12 +39,17 @@ export class NativeSessionRepository implements SessionRepository {
private persistedSnapshot: SessionSnapshot = EMPTY_SNAPSHOT private persistedSnapshot: SessionSnapshot = EMPTY_SNAPSHOT
private hasPersistedIndex = false private hasPersistedIndex = false
private pendingSnapshot: SessionSnapshot | undefined private pendingSnapshot: SessionSnapshot | undefined
private pendingClearDids: string[] | undefined
private possiblyWrittenDids = new Set<string>()
private retryTimer: ReturnType<typeof setTimeout> | undefined private retryTimer: ReturnType<typeof setTimeout> | undefined
private listeners = new Set<(snapshot: SessionSnapshot) => void>() private listeners = new Set<(snapshot: SessionSnapshot) => void>()
constructor() { constructor() {
onAppStateChange(state => { onAppStateChange(state => {
if (state === 'active' && this.pendingSnapshot) { if (
state === 'active' &&
(this.pendingSnapshot || this.pendingClearDids)
) {
this.retryPending() this.retryPending()
} }
}) })
@@ -101,12 +106,18 @@ export class NativeSessionRepository implements SessionRepository {
_previous: SessionSnapshot, _previous: SessionSnapshot,
next: SessionSnapshot, next: SessionSnapshot,
): SessionStorageCommitResult { ): SessionStorageCommitResult {
if (this.pendingClearDids) {
this.snapshot = EMPTY_SNAPSHOT
return this.retryPending()
}
this.snapshot = next this.snapshot = next
this.trackPossiblyWrittenAccounts(next)
try { try {
this.writeSnapshot(this.persistedSnapshot, next) this.writeSnapshot(this.persistedSnapshot, next)
this.persistedSnapshot = next this.persistedSnapshot = next
this.hasPersistedIndex = true this.hasPersistedIndex = true
this.pendingSnapshot = undefined this.pendingSnapshot = undefined
this.possiblyWrittenDids.clear()
this.cancelRetry() this.cancelRetry()
return {status: 'committed'} return {status: 'committed'}
} catch (cause) { } catch (cause) {
@@ -119,15 +130,24 @@ export class NativeSessionRepository implements SessionRepository {
} }
retryPending(): SessionStorageCommitResult { retryPending(): SessionStorageCommitResult {
if (!this.pendingSnapshot) { if (!this.pendingSnapshot && !this.pendingClearDids) {
return {status: 'committed'} return {status: 'committed'}
} }
const next = this.pendingSnapshot
try { try {
this.writeSnapshot(this.persistedSnapshot, next) if (this.pendingClearDids) {
this.persistedSnapshot = next this.writeClear(this.pendingClearDids)
this.snapshot = EMPTY_SNAPSHOT
this.persistedSnapshot = EMPTY_SNAPSHOT
this.pendingClearDids = undefined
} else {
const next = this.pendingSnapshot!
this.trackPossiblyWrittenAccounts(next)
this.writeSnapshot(this.persistedSnapshot, next)
this.persistedSnapshot = next
}
this.hasPersistedIndex = true this.hasPersistedIndex = true
this.pendingSnapshot = undefined this.pendingSnapshot = undefined
this.possiblyWrittenDids.clear()
this.cancelRetry() this.cancelRetry()
return {status: 'committed'} return {status: 'committed'}
} catch (cause) { } catch (cause) {
@@ -151,26 +171,24 @@ export class NativeSessionRepository implements SessionRepository {
...new Set([ ...new Set([
...this.persistedSnapshot.accounts.map(account => account.did), ...this.persistedSnapshot.accounts.map(account => account.did),
...this.snapshot.accounts.map(account => account.did), ...this.snapshot.accounts.map(account => account.did),
...this.possiblyWrittenDids,
]), ]),
] ]
this.snapshot = EMPTY_SNAPSHOT
this.pendingSnapshot = undefined
this.pendingClearDids = dids
this.cancelRetry()
try { try {
SecureStore.setItem( this.writeClear(dids)
SESSION_INDEX_KEY,
JSON.stringify(toStoredIndex(EMPTY_SNAPSHOT, dids)),
)
dids.forEach(tombstoneAccount)
SecureStore.setItem(
SESSION_INDEX_KEY,
JSON.stringify(toStoredIndex(EMPTY_SNAPSHOT)),
)
this.snapshot = EMPTY_SNAPSHOT
this.persistedSnapshot = EMPTY_SNAPSHOT this.persistedSnapshot = EMPTY_SNAPSHOT
this.hasPersistedIndex = true this.hasPersistedIndex = true
this.pendingSnapshot = undefined this.pendingClearDids = undefined
this.possiblyWrittenDids.clear()
this.cancelRetry() this.cancelRetry()
} catch (cause) { } catch (cause) {
const error = storageError('clear', cause) const error = storageError('clear', cause)
logStorageError(error) logStorageError(error)
this.scheduleRetry()
throw cause throw cause
} }
} }
@@ -233,9 +251,14 @@ export class NativeSessionRepository implements SessionRepository {
private writeSnapshot(previous: SessionSnapshot, next: SessionSnapshot) { private writeSnapshot(previous: SessionSnapshot, next: SessionSnapshot) {
const previousByDid = new Map(previous.accounts.map(a => [a.did, a])) const previousByDid = new Map(previous.accounts.map(a => [a.did, a]))
const nextDids = new Set(next.accounts.map(a => a.did)) const nextDids = new Set(next.accounts.map(a => a.did))
const retiredDids = previous.accounts const retiredDids = [
.filter(account => !nextDids.has(account.did)) ...new Set([
.map(account => account.did) ...previous.accounts
.filter(account => !nextDids.has(account.did))
.map(account => account.did),
...[...this.possiblyWrittenDids].filter(did => !nextDids.has(did)),
]),
]
const revokedDids = next.accounts const revokedDids = next.accounts
.filter(account => { .filter(account => {
const prior = previousByDid.get(account.did) const prior = previousByDid.get(account.did)
@@ -274,6 +297,7 @@ export class NativeSessionRepository implements SessionRepository {
if ( if (
!this.hasPersistedIndex || !this.hasPersistedIndex ||
retiredDids.length > 0 ||
JSON.stringify(previous) !== JSON.stringify(next) JSON.stringify(previous) !== JSON.stringify(next)
) { ) {
// Publishing the index is the commit point. `retiredDids` makes token // Publishing the index is the commit point. `retiredDids` makes token
@@ -293,6 +317,29 @@ export class NativeSessionRepository implements SessionRepository {
} }
} }
private writeClear(dids: string[]) {
SecureStore.setItem(
SESSION_INDEX_KEY,
JSON.stringify(toStoredIndex(EMPTY_SNAPSHOT, dids)),
)
dids.forEach(tombstoneAccount)
SecureStore.setItem(
SESSION_INDEX_KEY,
JSON.stringify(toStoredIndex(EMPTY_SNAPSHOT)),
)
}
private trackPossiblyWrittenAccounts(next: SessionSnapshot) {
const persistedDids = new Set(
this.persistedSnapshot.accounts.map(account => account.did),
)
for (const account of next.accounts) {
if (!persistedDids.has(account.did)) {
this.possiblyWrittenDids.add(account.did)
}
}
}
private scheduleRetry() { private scheduleRetry() {
if (this.retryTimer) return if (this.retryTimer) return
this.retryTimer = setTimeout(() => { this.retryTimer = setTimeout(() => {
+298 -69
View File
@@ -1,6 +1,13 @@
import {createStore, update} from 'idb-keyval'
import BroadcastChannel from '#/lib/broadcast' import BroadcastChannel from '#/lib/broadcast'
import {logger} from '#/logger' import {logger} from '#/logger'
import {type SessionSnapshot, sessionSnapshotSchema} from './schema' import {
type SessionAccount,
sessionAccountSchema,
type SessionSnapshot,
sessionSnapshotSchema,
} from './schema'
import { import {
type SessionRepository, type SessionRepository,
type SessionStorageCommitResult, type SessionStorageCommitResult,
@@ -9,86 +16,94 @@ import {
} from './types' } from './types'
const STORAGE_KEY = 'BSKY_SESSION_STORAGE_V1' const STORAGE_KEY = 'BSKY_SESSION_STORAGE_V1'
const STORAGE_LOCK_NAME = `bsky-session:${STORAGE_KEY}`
const fallbackLockStore = createStore('BSKY_SESSION_STORAGE_LOCKS', 'locks')
const CHANNEL_NAME = 'BSKY_SESSION_BROADCAST_CHANNEL' const CHANNEL_NAME = 'BSKY_SESSION_BROADCAST_CHANNEL'
const UPDATE_EVENT = 'session-update-v1' const UPDATE_EVENT = 'session-update-v1'
const EMPTY_SNAPSHOT: SessionSnapshot = {accounts: [], currentDid: undefined} const EMPTY_SNAPSHOT: SessionSnapshot = {accounts: [], currentDid: undefined}
const RETRY_DELAY = 5_000 const RETRY_DELAY = 5_000
type PendingCommit = {
previous: SessionSnapshot
next: SessionSnapshot
replace?: boolean
}
export class WebSessionRepository implements SessionRepository { export class WebSessionRepository implements SessionRepository {
private snapshot: SessionSnapshot = EMPTY_SNAPSHOT private snapshot: SessionSnapshot = EMPTY_SNAPSHOT
private pendingSnapshot: SessionSnapshot | undefined private pendingCommit: PendingCommit | undefined
private activeOperations = new Map<
PendingCommit,
Promise<SessionStorageCommitResult>
>()
private listeners = new Set<(snapshot: SessionSnapshot) => void>() private listeners = new Set<(snapshot: SessionSnapshot) => void>()
private retryTimer: ReturnType<typeof setTimeout> | undefined private retryTimer: ReturnType<typeof setTimeout> | undefined
private opened = false private opened = false
private legacyMigrationPending = false
private onLegacyMigrationComplete: (() => void) | undefined
private broadcast = new BroadcastChannel(CHANNEL_NAME) private broadcast = new BroadcastChannel(CHANNEL_NAME)
// async to keep one repository contract across native and web. async open(
// eslint-disable-next-line @typescript-eslint/require-await legacy?: SessionSnapshot,
async open(legacy?: SessionSnapshot): Promise<SessionStorageLoadResult> { onLegacyMigrationComplete?: () => void,
): Promise<SessionStorageLoadResult> {
this.onLegacyMigrationComplete = onLegacyMigrationComplete
let shouldScrubLegacy = false
try { try {
const stored = readFromStorage() const stored = readFromStorage()
if (stored) { if (stored) {
this.snapshot = stored this.snapshot = stored
shouldScrubLegacy = Boolean(legacy?.accounts.length)
} else { } else {
this.snapshot = legacy ?? EMPTY_SNAPSHOT this.snapshot = legacy ?? EMPTY_SNAPSHOT
writeToStorage(this.snapshot) shouldScrubLegacy =
} (await this.persistInitialSnapshot(this.snapshot)) &&
if (!this.opened) { Boolean(legacy?.accounts.length)
this.opened = true this.legacyMigrationPending =
this.broadcast.onmessage = this.onBroadcastMessage !shouldScrubLegacy && Boolean(legacy?.accounts.length)
window.addEventListener('storage', this.onStorage)
}
return {
status: 'ready',
snapshot: this.snapshot,
shouldScrubLegacy: Boolean(legacy?.accounts.length),
} }
} catch (cause) { } catch (cause) {
const error = storageError('open', cause) logStorageError(storageError('open', cause))
logStorageError(error) // A corrupt dedicated session key proves migration already committed;
return {status: 'unavailable', error} // do not resurrect legacy credentials. If localStorage itself is
// unavailable, keep the legacy snapshot alive in memory for this tab.
this.snapshot =
cause instanceof InvalidWebSessionStorageDataError
? EMPTY_SNAPSHOT
: (legacy ?? EMPTY_SNAPSHOT)
shouldScrubLegacy =
(await this.persistInitialSnapshot(this.snapshot)) &&
Boolean(legacy?.accounts.length)
this.legacyMigrationPending =
!shouldScrubLegacy && Boolean(legacy?.accounts.length)
} }
this.attachListeners()
return {status: 'ready', snapshot: this.snapshot, shouldScrubLegacy}
} }
getSnapshot(): SessionSnapshot { getSnapshot(): SessionSnapshot {
return this.snapshot return this.snapshot
} }
commit( async commit(
_previous: SessionSnapshot, previous: SessionSnapshot,
next: SessionSnapshot, next: SessionSnapshot,
): SessionStorageCommitResult { ): Promise<SessionStorageCommitResult> {
this.snapshot = next if (this.pendingCommit?.replace) {
try { this.snapshot = this.pendingCommit.next
writeToStorage(next) return this.retryPending()
this.pendingSnapshot = undefined
this.cancelRetry()
this.broadcast.postMessage({event: UPDATE_EVENT})
return {status: 'committed'}
} catch (cause) {
this.pendingSnapshot = next
const error = storageError('commit', cause)
logStorageError(error)
this.scheduleRetry()
return {status: 'pending', error}
} }
this.snapshot = next
const base = this.pendingCommit?.previous ?? previous
const operation = {previous: base, next}
this.pendingCommit = operation
this.cancelRetry()
return this.persistOperation(operation, 'commit')
} }
retryPending(): SessionStorageCommitResult { async retryPending(): Promise<SessionStorageCommitResult> {
if (!this.pendingSnapshot) return {status: 'committed'} if (!this.pendingCommit) return {status: 'committed'}
const next = this.pendingSnapshot return this.persistOperation(this.pendingCommit, 'retry')
try {
writeToStorage(next)
this.pendingSnapshot = undefined
this.cancelRetry()
this.broadcast.postMessage({event: UPDATE_EVENT})
return {status: 'committed'}
} catch (cause) {
const error = storageError('retry', cause)
logStorageError(error)
this.scheduleRetry()
return {status: 'pending', error}
}
} }
subscribe(listener: (snapshot: SessionSnapshot) => void): () => void { subscribe(listener: (snapshot: SessionSnapshot) => void): () => void {
@@ -98,18 +113,17 @@ export class WebSessionRepository implements SessionRepository {
} }
} }
// eslint-disable-next-line @typescript-eslint/require-await
async clear(): Promise<void> { async clear(): Promise<void> {
try { this.snapshot = EMPTY_SNAPSHOT
writeToStorage(EMPTY_SNAPSHOT) this.pendingCommit = {
this.snapshot = EMPTY_SNAPSHOT previous: EMPTY_SNAPSHOT,
this.pendingSnapshot = undefined next: EMPTY_SNAPSHOT,
this.cancelRetry() replace: true,
this.broadcast.postMessage({event: UPDATE_EVENT}) }
} catch (cause) { this.cancelRetry()
const error = storageError('clear', cause) const result = await this.persistOperation(this.pendingCommit, 'clear')
logStorageError(error) if (result.status === 'pending') {
throw cause throw new Error(`session storage clear failed: ${result.error.kind}`)
} }
} }
@@ -131,11 +145,16 @@ export class WebSessionRepository implements SessionRepository {
private receiveExternalUpdate() { private receiveExternalUpdate() {
try { try {
const next = readFromStorage() const next = readFromStorage()
if (this.pendingCommit) {
if (!this.activeOperations.has(this.pendingCommit)) {
void this.retryPending()
}
return
}
if (!next || JSON.stringify(next) === JSON.stringify(this.snapshot)) { if (!next || JSON.stringify(next) === JSON.stringify(this.snapshot)) {
return return
} }
this.snapshot = next this.snapshot = next
this.pendingSnapshot = undefined
this.cancelRetry() this.cancelRetry()
this.listeners.forEach(listener => listener(next)) this.listeners.forEach(listener => listener(next))
} catch (cause) { } catch (cause) {
@@ -147,7 +166,7 @@ export class WebSessionRepository implements SessionRepository {
if (this.retryTimer) return if (this.retryTimer) return
this.retryTimer = setTimeout(() => { this.retryTimer = setTimeout(() => {
this.retryTimer = undefined this.retryTimer = undefined
this.retryPending() void this.retryPending()
}, RETRY_DELAY) }, RETRY_DELAY)
} }
@@ -155,27 +174,237 @@ export class WebSessionRepository implements SessionRepository {
if (this.retryTimer) clearTimeout(this.retryTimer) if (this.retryTimer) clearTimeout(this.retryTimer)
this.retryTimer = undefined this.retryTimer = undefined
} }
private attachListeners() {
if (this.opened) return
this.opened = true
this.broadcast.onmessage = this.onBroadcastMessage
window.addEventListener('storage', this.onStorage)
}
private async persistInitialSnapshot(
snapshot: SessionSnapshot,
): Promise<boolean> {
const operation = {previous: snapshot, next: snapshot}
this.pendingCommit = operation
const result = await this.persistOperation(operation, 'open')
return result.status === 'committed'
}
private async persistOperation(
operation: PendingCommit,
errorOperation: SessionStorageError['operation'],
): Promise<SessionStorageCommitResult> {
const active = this.activeOperations.get(operation)
if (active) return active
const promise = (async (): Promise<SessionStorageCommitResult> => {
try {
const committed = operation.replace
? await this.persistReplacement(operation.next)
: await this.persistCommit(
operation.previous,
operation.next,
errorOperation,
)
this.broadcast.postMessage({event: UPDATE_EVENT})
if (this.pendingCommit === operation) {
this.snapshot = committed
this.pendingCommit = undefined
this.cancelRetry()
if (JSON.stringify(committed) !== JSON.stringify(operation.next)) {
this.listeners.forEach(listener => listener(committed))
}
if (this.legacyMigrationPending) {
this.legacyMigrationPending = false
this.onLegacyMigrationComplete?.()
}
}
return {status: 'committed'}
} catch (cause) {
const error = storageError(errorOperation, cause)
logStorageError(error)
if (this.pendingCommit === operation) this.scheduleRetry()
return {status: 'pending', error}
} finally {
this.activeOperations.delete(operation)
}
})()
this.activeOperations.set(operation, promise)
return promise
}
private persistCommit(
previous: SessionSnapshot,
next: SessionSnapshot,
errorOperation: SessionStorageError['operation'],
): Promise<SessionSnapshot> {
return withStorageLock(() => {
let latest: SessionSnapshot
try {
latest = readFromStorage() ?? previous
} catch (cause) {
if (!(cause instanceof InvalidWebSessionStorageDataError)) throw cause
logStorageError(storageError(errorOperation, cause))
latest = previous
}
const committed = rebaseSessionSnapshot(previous, next, latest)
writeToStorage(committed)
return committed
})
}
private persistReplacement(next: SessionSnapshot): Promise<SessionSnapshot> {
return withStorageLock(() => {
writeToStorage(next)
return next
})
}
}
function withStorageLock<T>(callback: () => T): Promise<T> {
const locks = globalThis.navigator?.locks
if (locks) return locks.request(STORAGE_LOCK_NAME, callback)
let result: T
return update<boolean>(
STORAGE_LOCK_NAME,
previous => {
result = callback()
return !previous
},
fallbackLockStore,
).then(() => result!)
} }
function readFromStorage(): SessionSnapshot | undefined { function readFromStorage(): SessionSnapshot | undefined {
const raw = localStorage.getItem(STORAGE_KEY) const raw = localStorage.getItem(STORAGE_KEY)
return raw ? sessionSnapshotSchema.parse(JSON.parse(raw)) : undefined if (!raw) return undefined
try {
return sessionSnapshotSchema.parse(JSON.parse(raw))
} catch {
throw new InvalidWebSessionStorageDataError()
}
} }
function writeToStorage(snapshot: SessionSnapshot) { function writeToStorage(snapshot: SessionSnapshot) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)) localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot))
} }
class InvalidWebSessionStorageDataError extends Error {}
const ACCOUNT_KEYS = [
'service',
'did',
'handle',
'email',
'emailConfirmed',
'emailAuthFactor',
'refreshJwt',
'accessJwt',
'signupQueued',
'active',
'status',
'pdsUrl',
'isSelfHosted',
] as const satisfies readonly (keyof SessionAccount)[]
export function rebaseSessionSnapshot(
previous: SessionSnapshot,
next: SessionSnapshot,
latest: SessionSnapshot,
): SessionSnapshot {
const previousByDid = new Map(
previous.accounts.map(account => [account.did, account]),
)
const nextByDid = new Map(
next.accounts.map(account => [account.did, account]),
)
const resultByDid = new Map(
latest.accounts.map(account => [account.did, account]),
)
for (const did of previousByDid.keys()) {
if (!nextByDid.has(did)) resultByDid.delete(did)
}
for (const account of next.accounts) {
const prior = previousByDid.get(account.did)
if (!prior) {
const latestAccount = resultByDid.get(account.did)
const mergedAccount = latestAccount
? {
...latestAccount,
...account,
refreshJwt:
latestAccount.refreshJwt && account.refreshJwt
? account.refreshJwt
: undefined,
accessJwt:
latestAccount.accessJwt && account.accessJwt
? account.accessJwt
: undefined,
}
: account
resultByDid.set(account.did, sessionAccountSchema.parse(mergedAccount))
continue
}
if (JSON.stringify(prior) === JSON.stringify(account)) continue
const latestAccount = resultByDid.get(account.did)
if (!latestAccount) {
// A concurrent removal wins over an edit based on the removed account.
continue
}
let rebasedAccount = latestAccount
for (const key of ACCOUNT_KEYS) {
if (prior[key] !== account[key]) {
const isCredential = key === 'refreshJwt' || key === 'accessJwt'
const remotelyRevoked =
isCredential &&
prior[key] !== latestAccount[key] &&
!latestAccount[key]
if (!remotelyRevoked) {
rebasedAccount = {...rebasedAccount, [key]: account[key]}
}
}
}
resultByDid.set(account.did, sessionAccountSchema.parse(rebasedAccount))
}
const previousOrder = previous.accounts.map(account => account.did)
const nextOrder = next.accounts.map(account => account.did)
const latestOrder = latest.accounts.map(account => account.did)
const latestDids = new Set(latestOrder)
const orderChanged =
JSON.stringify(previousOrder) !== JSON.stringify(nextOrder)
const order = orderChanged
? [...nextOrder, ...latestOrder.filter(did => !nextByDid.has(did))]
: [...latestOrder, ...nextOrder.filter(did => !latestDids.has(did))]
const accounts = order.flatMap(did => {
const account = resultByDid.get(did)
return account ? [account] : []
})
let currentDid =
previous.currentDid === next.currentDid
? latest.currentDid
: next.currentDid
if (currentDid && !resultByDid.has(currentDid)) currentDid = undefined
return sessionSnapshotSchema.parse({accounts, currentDid})
}
function storageError( function storageError(
operation: SessionStorageError['operation'], operation: SessionStorageError['operation'],
cause: unknown, cause: unknown,
): SessionStorageError { ): SessionStorageError {
const message = cause instanceof Error ? cause.message : String(cause) const message = cause instanceof Error ? cause.message : String(cause)
const kind = /quota|disk.*full|storage.*full|no space/i.test(message) const kind =
? 'storage-full' cause instanceof InvalidWebSessionStorageDataError
: operation === 'open' ? 'invalid-data'
? 'unavailable' : /quota|disk.*full|storage.*full|no space/i.test(message)
: 'write-failed' ? 'storage-full'
: operation === 'open'
? 'unavailable'
: 'write-failed'
return {kind, operation} return {kind, operation}
} }
+8 -3
View File
@@ -26,14 +26,19 @@ export type SessionStorageCommitResult =
| {status: 'committed'} | {status: 'committed'}
| {status: 'pending'; error: SessionStorageError} | {status: 'pending'; error: SessionStorageError}
export type MaybePromise<T> = T | Promise<T>
export interface SessionRepository { export interface SessionRepository {
open(legacy?: SessionSnapshot): Promise<SessionStorageLoadResult> open(
legacy?: SessionSnapshot,
onLegacyMigrationComplete?: () => void,
): Promise<SessionStorageLoadResult>
getSnapshot(): SessionSnapshot getSnapshot(): SessionSnapshot
commit( commit(
previous: SessionSnapshot, previous: SessionSnapshot,
next: SessionSnapshot, next: SessionSnapshot,
): SessionStorageCommitResult ): MaybePromise<SessionStorageCommitResult>
retryPending(): SessionStorageCommitResult retryPending(): MaybePromise<SessionStorageCommitResult>
subscribe(listener: (snapshot: SessionSnapshot) => void): () => void subscribe(listener: (snapshot: SessionSnapshot) => void): () => void
clear(): Promise<void> clear(): Promise<void>
} }