fix session storage recovery
This commit is contained in:
@@ -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)!)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user