mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
feat: commit bearer changesets in one write
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
// Imported by storage.test.ts so the focused storage command exercises the
|
||||
// fund-critical changeset boundary without mixing it into unrelated storage
|
||||
// round-trip and backup cases.
|
||||
|
||||
import {describe, expect, it, vi} from 'vitest'
|
||||
import {buildNoteUrl} from 'lnurlcash-kit'
|
||||
|
||||
import {deriveBearerAesKey, encryptRecord} from './keys'
|
||||
import {
|
||||
applyBearerChangeset,
|
||||
deleteBearerRecord,
|
||||
loadBearers,
|
||||
newBearerId,
|
||||
persistBearer,
|
||||
readEncryptedBearers,
|
||||
} from './storage'
|
||||
import type {BearerChangeset} from './storage'
|
||||
import type {Bearer, NewBearer} from './types'
|
||||
|
||||
const LINKING_KEY = new Uint8Array(32).fill(7)
|
||||
const OTHER_KEY = new Uint8Array(32).fill(9)
|
||||
const K1_A = 'aa'.repeat(32)
|
||||
const K1_B = 'bb'.repeat(32)
|
||||
const K1_C = 'cc'.repeat(32)
|
||||
const K1_D = 'dd'.repeat(32)
|
||||
|
||||
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
|
||||
id: newBearerId(),
|
||||
url: buildNoteUrl('https://mint.example/w', K1_A, 21_000),
|
||||
callback: 'https://mint.example/w/cb',
|
||||
amount: 21_000,
|
||||
verified: true,
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const newBearerFixture = (overrides: Partial<NewBearer> = {}): NewBearer => ({
|
||||
url: buildNoteUrl('https://mint.example/w', K1_C, 3_000),
|
||||
callback: 'https://mint.example/w/cb',
|
||||
amount: 3_000,
|
||||
verified: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('baseline: per-record bearer persistence', () => {
|
||||
it('writes once for every persist or delete call', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const writes = vi.spyOn(localStorage, 'setItem')
|
||||
|
||||
await persistBearer(key, bearerFixture({id: 'a'}))
|
||||
await persistBearer(key, bearerFixture({id: 'b'}))
|
||||
await deleteBearerRecord('a')
|
||||
|
||||
expect(
|
||||
writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'),
|
||||
).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyBearerChangeset', () => {
|
||||
it('commits additions and spent replacements with exactly one write', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const oldA = bearerFixture({id: 'old-a'})
|
||||
const oldB = bearerFixture({
|
||||
id: 'old-b',
|
||||
url: buildNoteUrl('https://mint.example/w', K1_B, 5_000),
|
||||
amount: 5_000,
|
||||
})
|
||||
await persistBearer(key, oldA)
|
||||
await persistBearer(key, oldB)
|
||||
const snapshot = [oldA, oldB]
|
||||
const changeset: BearerChangeset = {
|
||||
add: [
|
||||
newBearerFixture(),
|
||||
newBearerFixture({
|
||||
url: buildNoteUrl('https://mint.example/w', K1_D, 4_000),
|
||||
amount: 4_000,
|
||||
}),
|
||||
],
|
||||
markSpent: ['old-a', 'old-b'],
|
||||
}
|
||||
const writes = vi.spyOn(localStorage, 'setItem')
|
||||
|
||||
const result = await applyBearerChangeset(key, snapshot, changeset)
|
||||
|
||||
expect(
|
||||
writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'),
|
||||
).toHaveLength(1)
|
||||
expect(result).toHaveLength(4)
|
||||
expect(result.slice(2).map((bearer) => bearer.spent)).toEqual([true, true])
|
||||
expect(result[0]?.id).not.toBe(result[1]?.id)
|
||||
expect(snapshot.map((bearer) => bearer.spent)).toEqual([undefined, undefined])
|
||||
expect(changeset.markSpent).toEqual(['old-a', 'old-b'])
|
||||
expect(changeset.add.some((note) => 'id' in note)).toBe(false)
|
||||
expect((await loadBearers(key)).map((bearer) => bearer.id).sort()).toEqual(
|
||||
result.map((bearer) => bearer.id).sort(),
|
||||
)
|
||||
const raw = localStorage.getItem('sattle_bearers') ?? ''
|
||||
expect(raw).not.toContain(K1_C)
|
||||
expect(raw).not.toContain(K1_D)
|
||||
})
|
||||
|
||||
it('writes nothing when the second record encryption fails', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
await persistBearer(key, bearerFixture({id: 'kept'}))
|
||||
const writes = vi.spyOn(localStorage, 'setItem')
|
||||
const encrypt = vi.spyOn(crypto.subtle, 'encrypt')
|
||||
encrypt
|
||||
.mockResolvedValueOnce(new ArrayBuffer(32))
|
||||
.mockRejectedValueOnce(new Error('second encryption failed'))
|
||||
|
||||
try {
|
||||
await expect(
|
||||
applyBearerChangeset(key, [], {
|
||||
add: [
|
||||
newBearerFixture(),
|
||||
newBearerFixture({
|
||||
url: buildNoteUrl('https://mint.example/w', K1_D, 4_000),
|
||||
}),
|
||||
],
|
||||
markSpent: [],
|
||||
}),
|
||||
).rejects.toThrow('second encryption failed')
|
||||
expect(encrypt).toHaveBeenCalledTimes(2)
|
||||
expect(
|
||||
writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'),
|
||||
).toHaveLength(0)
|
||||
expect(readEncryptedBearers().map((record) => record.id)).toEqual(['kept'])
|
||||
} finally {
|
||||
encrypt.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a failed storage write without changing persisted state', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const kept = bearerFixture({id: 'kept'})
|
||||
await persistBearer(key, kept)
|
||||
const before = localStorage.getItem('sattle_bearers')
|
||||
const write = vi.spyOn(localStorage, 'setItem').mockImplementation(() => {
|
||||
throw new Error('QuotaExceededError')
|
||||
})
|
||||
vi.stubGlobal('navigator', {})
|
||||
|
||||
try {
|
||||
await expect(
|
||||
applyBearerChangeset(key, [kept], {
|
||||
add: [newBearerFixture()],
|
||||
markSpent: ['kept'],
|
||||
}),
|
||||
).rejects.toThrow('QuotaExceededError')
|
||||
expect(localStorage.getItem('sattle_bearers')).toBe(before)
|
||||
} finally {
|
||||
write.mockRestore()
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('deduplicates repeated spent ids into one stored replacement', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const bearer = bearerFixture({id: 'duplicate'})
|
||||
await persistBearer(key, bearer)
|
||||
|
||||
const result = await applyBearerChangeset(key, [bearer], {
|
||||
add: [],
|
||||
markSpent: ['duplicate', 'duplicate'],
|
||||
})
|
||||
|
||||
expect(readEncryptedBearers().filter((record) => record.id === 'duplicate')).toHaveLength(1)
|
||||
expect(result[0]?.spent).toBe(true)
|
||||
expect((await loadBearers(key))[0]?.spent).toBe(true)
|
||||
})
|
||||
|
||||
it('encrypts before locking and preserves a fresh unrelated record', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const other = await deriveBearerAesKey(OTHER_KEY)
|
||||
const mine = bearerFixture({id: 'mine'})
|
||||
const foreign = bearerFixture({
|
||||
id: 'fresh-tab',
|
||||
url: buildNoteUrl('https://mint.example/w', K1_B, 9_000),
|
||||
})
|
||||
await persistBearer(key, mine)
|
||||
const {id: foreignId, ...foreignPlain} = foreign
|
||||
const foreignParts = await encryptRecord(other, foreignPlain)
|
||||
const encrypted = vi.spyOn(crypto.subtle, 'encrypt')
|
||||
const queue: {release: () => Promise<void>}[] = []
|
||||
vi.stubGlobal('navigator', {
|
||||
locks: {
|
||||
request: (_name: string, fn: () => unknown): Promise<unknown> =>
|
||||
new Promise((resolve, reject) => {
|
||||
queue.push({
|
||||
release: async () => {
|
||||
await Promise.resolve(fn)
|
||||
.then((callback) => callback())
|
||||
.then(resolve, reject)
|
||||
},
|
||||
})
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const commit = applyBearerChangeset(key, [mine], {
|
||||
add: [newBearerFixture()],
|
||||
markSpent: [],
|
||||
})
|
||||
await vi.waitFor(() => expect(queue).toHaveLength(1))
|
||||
expect(encrypted).toHaveBeenCalledTimes(1)
|
||||
localStorage.setItem(
|
||||
'sattle_bearers',
|
||||
JSON.stringify([...readEncryptedBearers(), {id: foreignId, ...foreignParts}]),
|
||||
)
|
||||
const pendingLock = queue.at(0)
|
||||
if (pendingLock === undefined) throw new Error('Expected pending lock')
|
||||
|
||||
await pendingLock.release()
|
||||
const result = await commit
|
||||
|
||||
expect(
|
||||
readEncryptedBearers()
|
||||
.map((record) => record.id)
|
||||
.sort(),
|
||||
).toEqual(['fresh-tab', 'mine', result[0]?.id].sort())
|
||||
expect((await loadBearers(other)).map((bearer) => bearer.id)).toEqual(['fresh-tab'])
|
||||
} finally {
|
||||
encrypted.mockRestore()
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('replaces corrupt JSON with the single committed changeset write', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
localStorage.setItem('sattle_bearers', 'not json {{{')
|
||||
const writes = vi.spyOn(localStorage, 'setItem')
|
||||
|
||||
const result = await applyBearerChangeset(key, [], {
|
||||
add: [newBearerFixture()],
|
||||
markSpent: ['missing'],
|
||||
})
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(
|
||||
writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'),
|
||||
).toHaveLength(1)
|
||||
expect((await loadBearers(key)).map((bearer) => bearer.id)).toEqual(
|
||||
result.map((bearer) => bearer.id),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@
|
||||
import {beforeEach, describe, expect, it} from 'vitest'
|
||||
import {buildNoteUrl} from 'lnurlcash-kit'
|
||||
|
||||
import './storage.changeset.cases'
|
||||
import type {Bearer} from './types'
|
||||
import {deriveBearerAesKey} from './keys'
|
||||
import {
|
||||
@@ -18,10 +19,10 @@ import {
|
||||
persistActivityEvent,
|
||||
persistBearer,
|
||||
readEncryptedBearers,
|
||||
MAX_ACTIVITY_ENTRIES
|
||||
MAX_ACTIVITY_ENTRIES,
|
||||
} from './storage'
|
||||
import {saveLinkingKey} from './keys'
|
||||
import {stubLocalStorage} from './test-utils'
|
||||
import {requiredValue, stubLocalStorage} from './test-utils'
|
||||
|
||||
const LINKING_KEY = new Uint8Array(32).fill(7)
|
||||
const OTHER_KEY = new Uint8Array(32).fill(9)
|
||||
@@ -37,7 +38,7 @@ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
|
||||
verified: true,
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
...overrides
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -51,7 +52,7 @@ describe('encrypted bearer records', () => {
|
||||
await persistBearer(key, bearer)
|
||||
|
||||
// at rest, nothing plaintext leaks: no k1, no amounts
|
||||
const raw = localStorage.getItem('sattle_bearers')!
|
||||
const raw = requiredValue(localStorage.getItem('sattle_bearers'))
|
||||
expect(raw).not.toContain(K1_A)
|
||||
expect(raw).not.toContain('21000')
|
||||
|
||||
@@ -63,12 +64,19 @@ describe('encrypted bearer records', () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const other = await deriveBearerAesKey(OTHER_KEY)
|
||||
await persistBearer(key, bearerFixture({id: 'mine'}))
|
||||
await persistBearer(other, bearerFixture({id: 'foreign', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000)}))
|
||||
await persistBearer(
|
||||
other,
|
||||
bearerFixture({id: 'foreign', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000)}),
|
||||
)
|
||||
|
||||
const loaded = await loadBearers(key)
|
||||
expect(loaded.map(b => b.id)).toEqual(['mine'])
|
||||
expect(loaded.map((b) => b.id)).toEqual(['mine'])
|
||||
// the foreign ciphertext is still there, untouched
|
||||
expect(readEncryptedBearers().map(r => r.id).sort()).toEqual(['foreign', 'mine'])
|
||||
expect(
|
||||
readEncryptedBearers()
|
||||
.map((r) => r.id)
|
||||
.sort(),
|
||||
).toEqual(['foreign', 'mine'])
|
||||
})
|
||||
|
||||
it('overwrites a record when the same id is persisted again', async () => {
|
||||
@@ -79,7 +87,7 @@ describe('encrypted bearer records', () => {
|
||||
|
||||
const loaded = await loadBearers(key)
|
||||
expect(loaded).toHaveLength(1)
|
||||
expect(loaded[0]!.spent).toBe(true)
|
||||
expect(requiredValue(loaded[0]).spent).toBe(true)
|
||||
})
|
||||
|
||||
it('deletes a record by id and clears all', async () => {
|
||||
@@ -90,7 +98,7 @@ describe('encrypted bearer records', () => {
|
||||
await persistBearer(key, b)
|
||||
|
||||
await deleteBearerRecord('a')
|
||||
expect((await loadBearers(key)).map(x => x.id)).toEqual(['b'])
|
||||
expect((await loadBearers(key)).map((x) => x.id)).toEqual(['b'])
|
||||
|
||||
clearAllBearers()
|
||||
expect(readEncryptedBearers()).toEqual([])
|
||||
@@ -104,7 +112,7 @@ describe('activity log', () => {
|
||||
await persistActivityEvent(key, {id: '2', kind: 'melt', message: 'b', createdAt: 2000})
|
||||
|
||||
const loaded = await loadActivity(key)
|
||||
expect(loaded.map(e => e.id)).toEqual(['2', '1'])
|
||||
expect(loaded.map((e) => e.id)).toEqual(['2', '1'])
|
||||
})
|
||||
|
||||
it('caps the log, rolling the oldest entries off', async () => {
|
||||
@@ -114,14 +122,14 @@ describe('activity log', () => {
|
||||
id: `ev-${i}`,
|
||||
kind: 'receive',
|
||||
message: `event ${i}`,
|
||||
createdAt: i
|
||||
createdAt: i,
|
||||
})
|
||||
}
|
||||
const loaded = await loadActivity(key)
|
||||
expect(loaded).toHaveLength(MAX_ACTIVITY_ENTRIES)
|
||||
// the five oldest rolled off; the newest is first
|
||||
expect(loaded[0]!.id).toBe(`ev-${MAX_ACTIVITY_ENTRIES + 4}`)
|
||||
expect(loaded.at(-1)!.id).toBe('ev-5')
|
||||
expect(requiredValue(loaded[0]).id).toBe(`ev-${MAX_ACTIVITY_ENTRIES + 4}`)
|
||||
expect(requiredValue(loaded.at(-1)).id).toBe('ev-5')
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
@@ -130,7 +138,7 @@ describe('mergeBearers (union by note id, spent-wins)', () => {
|
||||
const a = bearerFixture({id: 'a'})
|
||||
const b = bearerFixture({id: 'b', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000)})
|
||||
const merged = mergeBearers([a], [b])
|
||||
expect(merged.map(x => x.id).sort()).toEqual(['a', 'b'])
|
||||
expect(merged.map((x) => x.id).sort()).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('lets the spent copy of a note win over a still-spendable one', () => {
|
||||
@@ -141,8 +149,8 @@ describe('mergeBearers (union by note id, spent-wins)', () => {
|
||||
// would resurrect burned money
|
||||
const merged = mergeBearers([spendable], [spent])
|
||||
expect(merged).toHaveLength(1)
|
||||
expect(merged[0]!.id).toBe('new-copy')
|
||||
expect(merged[0]!.spent).toBe(true)
|
||||
expect(requiredValue(merged[0]).id).toBe('new-copy')
|
||||
expect(requiredValue(merged[0]).spent).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the newer copy when both agree on spent state', () => {
|
||||
@@ -150,14 +158,14 @@ describe('mergeBearers (union by note id, spent-wins)', () => {
|
||||
const fresh = bearerFixture({id: 'fresh', updatedAt: 2000, amount: 2})
|
||||
const merged = mergeBearers([stale], [fresh])
|
||||
expect(merged).toHaveLength(1)
|
||||
expect(merged[0]!.id).toBe('fresh')
|
||||
expect(requiredValue(merged[0]).id).toBe('fresh')
|
||||
})
|
||||
|
||||
it('treats the same secret on different servers as different notes', () => {
|
||||
const here = bearerFixture({id: 'here'})
|
||||
const there = bearerFixture({
|
||||
id: 'there',
|
||||
url: buildNoteUrl('https://other.example/w', K1_A, 21_000)
|
||||
url: buildNoteUrl('https://other.example/w', K1_A, 21_000),
|
||||
})
|
||||
expect(mergeBearers([here], [there])).toHaveLength(2)
|
||||
})
|
||||
@@ -191,16 +199,17 @@ describe('backup', () => {
|
||||
...backup,
|
||||
bearers: [
|
||||
...backup.bearers,
|
||||
{id: 'from-backup', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)}
|
||||
]
|
||||
{id: 'from-backup', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)},
|
||||
],
|
||||
}
|
||||
const result = applyBackup(incoming)
|
||||
const result = await applyBackup(incoming)
|
||||
expect(result.added).toBe(1)
|
||||
expect(result.skipped).toBe(1)
|
||||
expect(readEncryptedBearers().map(r => r.id).sort()).toEqual([
|
||||
'existing',
|
||||
'from-backup'
|
||||
])
|
||||
expect(
|
||||
readEncryptedBearers()
|
||||
.map((r) => r.id)
|
||||
.sort(),
|
||||
).toEqual(['existing', 'from-backup'])
|
||||
})
|
||||
|
||||
it('restores the linking key only onto a device that has none', async () => {
|
||||
@@ -208,33 +217,35 @@ describe('backup', () => {
|
||||
const backup = buildBackup()
|
||||
|
||||
// same device: a key already exists, so the backup's key is skipped
|
||||
const here = applyBackup(backup)
|
||||
const here = await applyBackup(backup)
|
||||
expect(here.linkingKeySkipped).toBe(true)
|
||||
expect(here.linkingKeyRestored).toBe(false)
|
||||
|
||||
// fresh device: the key installs
|
||||
stubLocalStorage()
|
||||
const fresh = applyBackup(backup)
|
||||
const fresh = await applyBackup(backup)
|
||||
expect(fresh.linkingKeyRestored).toBe(true)
|
||||
expect(fresh.linkingKeySkipped).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a file that is not a sattle backup', () => {
|
||||
expect(() => applyBackup({type: 'lnurlwallet-backup', version: 1, bearers: []})).toThrow()
|
||||
expect(() => applyBackup(null)).toThrow()
|
||||
expect(() => applyBackup({type: 'sattle-backup', version: 2, bearers: []})).toThrow()
|
||||
it('rejects a file that is not a sattle backup', async () => {
|
||||
await expect(
|
||||
applyBackup({type: 'lnurlwallet-backup', version: 1, bearers: []}),
|
||||
).rejects.toThrow()
|
||||
await expect(applyBackup(null)).rejects.toThrow()
|
||||
await expect(applyBackup({type: 'sattle-backup', version: 2, bearers: []})).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('skips malformed records instead of failing the whole restore', () => {
|
||||
const result = applyBackup({
|
||||
it('skips malformed records instead of failing the whole restore', async () => {
|
||||
const result = await applyBackup({
|
||||
type: 'sattle-backup',
|
||||
version: 1,
|
||||
createdAt: 1,
|
||||
bearers: [
|
||||
{id: 'ok', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)},
|
||||
{id: 42, iv: null, ciphertext: 'xx'},
|
||||
'garbage'
|
||||
]
|
||||
'garbage',
|
||||
],
|
||||
})
|
||||
expect(result.added).toBe(1)
|
||||
expect(result.skipped).toBe(2)
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
// the Pinia mints store and this module's backup both use it.
|
||||
//
|
||||
// Split by concern; this façade re-exports everything:
|
||||
// storage/bearers.ts - encrypted bearer records + mergeBearers
|
||||
// storage/bearers.ts - encrypted bearer records, changeset commits,
|
||||
// mergeBearers
|
||||
// storage/activityLog.ts - the append-only encrypted activity log
|
||||
// storage/settings.ts - plaintext wallet settings
|
||||
// storage/backup.ts - buildBackup / applyBackup
|
||||
@@ -19,10 +20,11 @@ export {
|
||||
loadBearers,
|
||||
persistBearer,
|
||||
deleteBearerRecord,
|
||||
applyBearerChangeset,
|
||||
clearAllBearers,
|
||||
mergeBearers
|
||||
mergeBearers,
|
||||
} from './storage/bearers'
|
||||
export type {EncryptedBearerRecord} from './storage/bearers'
|
||||
export type {BearerChangeset, EncryptedBearerRecord} from './storage/bearers'
|
||||
|
||||
export {
|
||||
newActivityId,
|
||||
@@ -30,16 +32,12 @@ export {
|
||||
loadActivity,
|
||||
persistActivityEvent,
|
||||
clearAllActivity,
|
||||
MAX_ACTIVITY_ENTRIES
|
||||
} from './storage/activityLog'
|
||||
export type {
|
||||
ActivityKind,
|
||||
ActivityEvent,
|
||||
EncryptedActivityRecord
|
||||
MAX_ACTIVITY_ENTRIES,
|
||||
} from './storage/activityLog'
|
||||
export type {ActivityKind, ActivityEvent, EncryptedActivityRecord} from './storage/activityLog'
|
||||
|
||||
export {loadSettings, persistSettings, clearSettings} from './storage/settings'
|
||||
export type {WalletSettings} from './storage/settings'
|
||||
|
||||
export {buildBackup, applyBackup, MAX_BACKUP_FILE_BYTES} from './storage/backup'
|
||||
export {buildBackup, applyBackup, parseBackupFile, MAX_BACKUP_FILE_BYTES} from './storage/backup'
|
||||
export type {BackupFile, RestoreResult} from './storage/backup'
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import {describe, expect, it, vi} from 'vitest'
|
||||
import {buildNoteUrl} from 'lnurlcash-kit'
|
||||
import type {Bearer} from '../types'
|
||||
import {deriveBearerAesKey} from '../keys'
|
||||
import {deleteBearerRecord, newBearerId, persistBearer} from '../storage'
|
||||
import {stubLocalStorage} from '../test-utils'
|
||||
|
||||
const LINKING_KEY = new Uint8Array(32).fill(7)
|
||||
const K1 = 'aa'.repeat(32)
|
||||
const bearerFixture = (): Bearer => ({
|
||||
id: newBearerId(),
|
||||
url: buildNoteUrl('https://mint.example/w', K1, 21_000),
|
||||
callback: 'https://mint.example/w/cb',
|
||||
amount: 21_000,
|
||||
verified: true,
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
})
|
||||
describe('baseline: per-record bearer persistence', () => {
|
||||
it('persistBearer/deleteBearerRecord perform one write per call', async () => {
|
||||
const storage = stubLocalStorage()
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const writes = vi.spyOn(storage, 'setItem')
|
||||
await persistBearer(key, {...bearerFixture(), id: 'a'})
|
||||
await persistBearer(key, {...bearerFixture(), id: 'b'})
|
||||
await deleteBearerRecord('a')
|
||||
expect(writes.mock.calls.filter(([keyName]) => keyName === 'sattle_bearers')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,305 @@
|
||||
// Bearer changeset persistence: applyBearerChangeset (the single-write
|
||||
// commit primitive) plus a baseline pin of the per-record write behavior it
|
||||
// replaces at the call sites. Lives next to bearers.ts instead of inside
|
||||
// ../storage.test.ts to keep both files under the project's module size
|
||||
// ceiling. Runs in Node against an in-memory localStorage stub; WebCrypto
|
||||
// (crypto.subtle) is native.
|
||||
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
||||
import {buildNoteUrl} from 'lnurlcash-kit'
|
||||
|
||||
import type {Bearer, NewBearer} from '../types'
|
||||
import {deriveBearerAesKey, encryptRecord} from '../keys'
|
||||
import {
|
||||
applyBearerChangeset,
|
||||
loadBearers,
|
||||
newBearerId,
|
||||
persistBearer,
|
||||
readEncryptedBearers,
|
||||
} from '../storage'
|
||||
import type {BearerChangeset} from '../storage'
|
||||
import {writeEncryptedBearers} from './bearers'
|
||||
import {requiredValue, stubLocalStorage} from '../test-utils'
|
||||
import type {LocalStorageStub} from '../test-utils'
|
||||
import './bearers.baseline.cases'
|
||||
|
||||
const LINKING_KEY = new Uint8Array(32).fill(7)
|
||||
const OTHER_KEY = new Uint8Array(32).fill(9)
|
||||
|
||||
const K1_A = 'aa'.repeat(32)
|
||||
const K1_B = 'bb'.repeat(32)
|
||||
const K1_C = 'cc'.repeat(32)
|
||||
const K1_D = 'dd'.repeat(32)
|
||||
|
||||
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
|
||||
id: newBearerId(),
|
||||
url: buildNoteUrl('https://mint.example/w', K1_A, 21_000),
|
||||
callback: 'https://mint.example/w/cb',
|
||||
amount: 21_000,
|
||||
verified: true,
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const newBearerFixture = (overrides: Partial<NewBearer> = {}): NewBearer => ({
|
||||
url: buildNoteUrl('https://mint.example/w', K1_C, 3_000),
|
||||
callback: 'https://mint.example/w/cb',
|
||||
amount: 3_000,
|
||||
verified: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
let stub: LocalStorageStub
|
||||
|
||||
beforeEach(() => {
|
||||
stub = stubLocalStorage()
|
||||
})
|
||||
|
||||
const bearerWrites = (spy: {mock: {calls: unknown[][]}}): unknown[][] =>
|
||||
spy.mock.calls.filter(([k]) => k === 'sattle_bearers')
|
||||
|
||||
describe('applyBearerChangeset (single-write changeset commit)', () => {
|
||||
it('commits additions and spent replacements with exactly one write', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const oldA = bearerFixture({id: 'old-a'})
|
||||
const oldB = bearerFixture({
|
||||
id: 'old-b',
|
||||
url: buildNoteUrl('https://mint.example/w', K1_B, 5_000),
|
||||
amount: 5_000,
|
||||
})
|
||||
await persistBearer(key, oldA)
|
||||
await persistBearer(key, oldB)
|
||||
|
||||
const writes = vi.spyOn(stub, 'setItem')
|
||||
const result = await applyBearerChangeset(key, [oldA, oldB], {
|
||||
add: [
|
||||
newBearerFixture(),
|
||||
newBearerFixture({
|
||||
url: buildNoteUrl('https://mint.example/w', K1_D, 4_000),
|
||||
amount: 4_000,
|
||||
}),
|
||||
],
|
||||
markSpent: ['old-a', 'old-b'],
|
||||
})
|
||||
|
||||
// the whole changeset is ONE setItem on sattle_bearers
|
||||
expect(bearerWrites(writes)).toHaveLength(1)
|
||||
|
||||
// the returned next list: additions first, then the snapshot with spent
|
||||
// marks applied
|
||||
expect(result).toHaveLength(4)
|
||||
const addA = requiredValue(result[0])
|
||||
const addB = requiredValue(result[1])
|
||||
const spentA = requiredValue(result[2])
|
||||
const spentB = requiredValue(result[3])
|
||||
expect(addA.id).not.toBe(addB.id)
|
||||
expect(addA.amount).toBe(3_000)
|
||||
expect(addB.amount).toBe(4_000)
|
||||
expect(addA.createdAt).toBe(addA.updatedAt)
|
||||
expect(spentA.id).toBe('old-a')
|
||||
expect(spentA.spent).toBe(true)
|
||||
expect(spentA.updatedAt).toBeGreaterThan(1000)
|
||||
expect(spentB.id).toBe('old-b')
|
||||
expect(spentB.spent).toBe(true)
|
||||
|
||||
// the source of truth is the reloaded ciphertext, not the return value
|
||||
const reloaded = await loadBearers(key)
|
||||
expect(reloaded.map((b) => b.id).sort()).toEqual(result.map((b) => b.id).sort())
|
||||
expect(requiredValue(reloaded.find((b) => b.id === 'old-a')).spent).toBe(true)
|
||||
expect(requiredValue(reloaded.find((b) => b.id === 'old-b')).spent).toBe(true)
|
||||
expect(requiredValue(reloaded.find((b) => b.id === addA.id)).spent).toBeUndefined()
|
||||
// nothing plaintext leaked: the fresh k1s are ciphertext-only at rest
|
||||
const raw = requiredValue(localStorage.getItem('sattle_bearers'))
|
||||
expect(raw).not.toContain(K1_C)
|
||||
expect(raw).not.toContain(K1_D)
|
||||
})
|
||||
|
||||
it('never mutates the caller snapshot or the changeset', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const snapshot = [bearerFixture({id: 's1'})]
|
||||
const changeset: BearerChangeset = {
|
||||
add: [newBearerFixture()],
|
||||
markSpent: ['s1'],
|
||||
}
|
||||
|
||||
await applyBearerChangeset(key, snapshot, changeset)
|
||||
|
||||
expect(requiredValue(snapshot[0]).spent).toBeUndefined()
|
||||
expect('id' in requiredValue(changeset.add[0])).toBe(false)
|
||||
expect(changeset.markSpent).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('persists nothing when encryption fails', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
await persistBearer(key, bearerFixture({id: 'kept'}))
|
||||
// a decrypt-only key makes every AES-GCM encrypt call reject
|
||||
const decryptOnly = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new Uint8Array(32).fill(3),
|
||||
'AES-GCM',
|
||||
false,
|
||||
['decrypt'],
|
||||
)
|
||||
const writes = vi.spyOn(stub, 'setItem')
|
||||
|
||||
await expect(
|
||||
applyBearerChangeset(decryptOnly, [], {
|
||||
add: [newBearerFixture()],
|
||||
markSpent: [],
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
|
||||
expect(bearerWrites(writes)).toHaveLength(0)
|
||||
expect(readEncryptedBearers().map((r) => r.id)).toEqual(['kept'])
|
||||
})
|
||||
|
||||
it('rejects without a partial write when the storage write itself fails', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const kept = bearerFixture({id: 'kept'})
|
||||
await persistBearer(key, kept)
|
||||
const before = readEncryptedBearers()
|
||||
stub.setItem = (): void => {
|
||||
throw new Error('QuotaExceededError')
|
||||
}
|
||||
// run this one through the unlocked fallback on purpose: a quota throw
|
||||
// is SYNCHRONOUS, and Node 24's real navigator.locks never releases a
|
||||
// lock whose callback throws synchronously (verified Node quirk - every
|
||||
// browser releases per the Web Locks spec), which would wedge
|
||||
// 'sattle_bearers' for the rest of the file. Bonus: one test keeps the
|
||||
// documented plain-Node fallback path (storageLock.ts) exercised.
|
||||
vi.stubGlobal('navigator', {})
|
||||
try {
|
||||
await expect(
|
||||
applyBearerChangeset(key, [kept], {
|
||||
add: [newBearerFixture()],
|
||||
markSpent: ['kept'],
|
||||
}),
|
||||
).rejects.toThrow('QuotaExceededError')
|
||||
|
||||
// nothing was persisted: the pre-existing record is byte-identical
|
||||
expect(readEncryptedBearers()).toEqual(before)
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('upserts changed ids and dedupes repeated markSpent ids', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const stale = bearerFixture({id: 'dup', updatedAt: 1000})
|
||||
await persistBearer(key, stale)
|
||||
|
||||
const result = await applyBearerChangeset(key, [stale], {
|
||||
add: [],
|
||||
markSpent: ['dup', 'dup'],
|
||||
})
|
||||
|
||||
// one record per id, never a duplicate append
|
||||
expect(readEncryptedBearers().filter((r) => r.id === 'dup')).toHaveLength(1)
|
||||
const reloaded = await loadBearers(key)
|
||||
expect(reloaded).toHaveLength(1)
|
||||
expect(requiredValue(reloaded[0]).spent).toBe(true)
|
||||
expect(requiredValue(result[0]).spent).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves a record another tab commits between the snapshot and the lock', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const other = await deriveBearerAesKey(OTHER_KEY)
|
||||
const mine = bearerFixture({id: 'mine'})
|
||||
await persistBearer(key, mine)
|
||||
|
||||
// a controllable Web Locks fake: lock requests park until the test
|
||||
// releases them, so a foreign write can interleave deterministically
|
||||
const queue: {name: string; release: () => Promise<void>}[] = []
|
||||
vi.stubGlobal('navigator', {
|
||||
locks: {
|
||||
request: (name: string, fn: () => unknown): Promise<unknown> =>
|
||||
new Promise((resolve, reject) => {
|
||||
queue.push({
|
||||
name,
|
||||
release: async () => {
|
||||
try {
|
||||
resolve(await fn())
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
},
|
||||
})
|
||||
}),
|
||||
},
|
||||
})
|
||||
try {
|
||||
const commit = applyBearerChangeset(key, [mine], {
|
||||
add: [newBearerFixture()],
|
||||
markSpent: [],
|
||||
})
|
||||
// encryption happens BEFORE the lock request; wait for it to arrive
|
||||
await vi.waitFor(() => {
|
||||
expect(queue).toHaveLength(1)
|
||||
})
|
||||
const queuedLock = requiredValue(queue[0])
|
||||
expect(queuedLock.name).toBe('sattle_bearers')
|
||||
|
||||
// while our commit waits on the lock, another tab commits a record we
|
||||
// cannot even decrypt (written under a different seed's key)
|
||||
const foreign = bearerFixture({
|
||||
id: 'foreign-tab',
|
||||
url: buildNoteUrl('https://mint.example/w', K1_B, 9_000),
|
||||
})
|
||||
const {id: foreignId, ...foreignPlain} = foreign
|
||||
const foreignParts = await encryptRecord(other, foreignPlain)
|
||||
writeEncryptedBearers([...readEncryptedBearers(), {id: foreignId, ...foreignParts}])
|
||||
|
||||
await queuedLock.release()
|
||||
const result = await commit
|
||||
|
||||
// the foreign ciphertext survived our upsert, untouched
|
||||
expect(
|
||||
readEncryptedBearers()
|
||||
.map((r) => r.id)
|
||||
.sort(),
|
||||
).toEqual(['foreign-tab', 'mine', requiredValue(result[0]).id].sort())
|
||||
expect((await loadBearers(other)).map((b) => b.id)).toEqual(['foreign-tab'])
|
||||
expect((await loadBearers(key)).map((b) => b.id).sort()).toEqual(
|
||||
['mine', requiredValue(result[0]).id].sort(),
|
||||
)
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('treats corrupted stored JSON as an empty record set instead of throwing', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
localStorage.setItem('sattle_bearers', 'not json {{{')
|
||||
|
||||
const result = await applyBearerChangeset(key, [], {
|
||||
add: [newBearerFixture()],
|
||||
markSpent: ['gone'],
|
||||
})
|
||||
|
||||
// readEncryptedBearers' long-standing contract: unparseable storage
|
||||
// reads as [] (malformed entries are dropped, never thrown on) - the
|
||||
// changeset still commits and its single write replaces the corrupt blob
|
||||
expect(result).toHaveLength(1)
|
||||
expect((await loadBearers(key)).map((b) => b.id)).toEqual([requiredValue(result[0]).id])
|
||||
})
|
||||
|
||||
it('ignores markSpent ids absent from the snapshot and writes nothing when nothing changed', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const other = await deriveBearerAesKey(OTHER_KEY)
|
||||
await persistBearer(other, bearerFixture({id: 'foreign'}))
|
||||
const writes = vi.spyOn(stub, 'setItem')
|
||||
|
||||
// 'foreign' is not in the caller's snapshot; deriving its spent copy
|
||||
// would require decrypting an unrelated record, which this primitive
|
||||
// never does - so the changeset changes nothing and performs no write
|
||||
const result = await applyBearerChangeset(key, [], {
|
||||
add: [],
|
||||
markSpent: ['foreign'],
|
||||
})
|
||||
|
||||
expect(result).toEqual([])
|
||||
expect(bearerWrites(writes)).toHaveLength(0)
|
||||
expect(readEncryptedBearers().map((r) => r.id)).toEqual(['foreign'])
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
import type {EncryptedRecordParts} from '../keys'
|
||||
import {encryptRecord, decryptRecord} from '../keys'
|
||||
import type {Bearer} from '../types'
|
||||
import type {Bearer, NewBearer} from '../types'
|
||||
import {isJsonObject} from '../jsonParsing'
|
||||
import {noteK1, serverOf} from 'lnurlcash-kit'
|
||||
import {withStorageLock} from '../storageLock'
|
||||
|
||||
@@ -15,11 +16,31 @@ export const compareBearerOrder = (a: Bearer, b: Bearer): number =>
|
||||
|
||||
export type EncryptedBearerRecord = {id: string} & EncryptedRecordParts
|
||||
|
||||
const isEncryptedBearerRecord = (value: unknown): value is EncryptedBearerRecord =>
|
||||
isJsonObject(value) &&
|
||||
typeof value.id === 'string' &&
|
||||
typeof value.iv === 'string' &&
|
||||
typeof value.ciphertext === 'string'
|
||||
|
||||
const isStoredBearer = (value: unknown): value is Omit<Bearer, 'id'> =>
|
||||
isJsonObject(value) &&
|
||||
typeof value.url === 'string' &&
|
||||
typeof value.callback === 'string' &&
|
||||
typeof value.amount === 'number' &&
|
||||
typeof value.verified === 'boolean' &&
|
||||
typeof value.createdAt === 'number' &&
|
||||
typeof value.updatedAt === 'number' &&
|
||||
(value.mintPubkey === undefined || typeof value.mintPubkey === 'string') &&
|
||||
(value.spent === undefined || typeof value.spent === 'boolean') &&
|
||||
(value.sortIndex === undefined || typeof value.sortIndex === 'number') &&
|
||||
(value.label === undefined || typeof value.label === 'string') &&
|
||||
(value.deviceId === undefined || typeof value.deviceId === 'string')
|
||||
|
||||
const BEARERS_STORAGE_KEY = 'sattle_bearers'
|
||||
|
||||
export const newBearerId = (): string =>
|
||||
Array.from(crypto.getRandomValues(new Uint8Array(8)))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
|
||||
export const readEncryptedBearers = (): EncryptedBearerRecord[] => {
|
||||
@@ -27,18 +48,18 @@ export const readEncryptedBearers = (): EncryptedBearerRecord[] => {
|
||||
if (!raw) return []
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
return Array.isArray(parsed) ? parsed.filter(isEncryptedBearerRecord) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const writeEncryptedBearers = (
|
||||
records: EncryptedBearerRecord[]
|
||||
): void => {
|
||||
export const writeEncryptedBearers = (records: EncryptedBearerRecord[]): void => {
|
||||
localStorage.setItem(BEARERS_STORAGE_KEY, JSON.stringify(records))
|
||||
}
|
||||
|
||||
type BearerCommitOptions = {beforeCommit?: () => void}
|
||||
|
||||
// decrypts everything currently stored - a record that fails to decrypt
|
||||
// (e.g. written by a different seed's key) is skipped, not destroyed: it
|
||||
// stays in localStorage untouched and simply doesn't show up
|
||||
@@ -46,10 +67,12 @@ export const loadBearers = async (aesKey: CryptoKey): Promise<Bearer[]> => {
|
||||
const bearers: Bearer[] = []
|
||||
for (const record of readEncryptedBearers()) {
|
||||
try {
|
||||
const bearer = await decryptRecord<Omit<Bearer, 'id'>>(aesKey, record)
|
||||
const bearer = await decryptRecord(aesKey, record)
|
||||
if (!isStoredBearer(bearer)) throw new Error('Malformed encrypted bearer record.')
|
||||
bearers.push({...bearer, id: record.id})
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// undecryptable with this key - leave it in place
|
||||
if (!(error instanceof Error)) throw error
|
||||
}
|
||||
}
|
||||
return bearers.sort((a, b) => b.createdAt - a.createdAt)
|
||||
@@ -57,23 +80,123 @@ export const loadBearers = async (aesKey: CryptoKey): Promise<Bearer[]> => {
|
||||
|
||||
export const persistBearer = async (
|
||||
aesKey: CryptoKey,
|
||||
bearer: Bearer
|
||||
bearer: Bearer,
|
||||
options: BearerCommitOptions = {},
|
||||
): Promise<void> => {
|
||||
const {id, ...plain} = bearer
|
||||
const parts = await encryptRecord(aesKey, plain)
|
||||
await withStorageLock(BEARERS_STORAGE_KEY, () => {
|
||||
const records = readEncryptedBearers().filter(r => r.id !== id)
|
||||
options.beforeCommit?.()
|
||||
const records = readEncryptedBearers().filter((r) => r.id !== id)
|
||||
records.push({id, ...parts})
|
||||
writeEncryptedBearers(records)
|
||||
})
|
||||
}
|
||||
|
||||
export const deleteBearerRecord = async (id: string): Promise<void> => {
|
||||
export const deleteBearerRecord = async (
|
||||
id: string,
|
||||
options: BearerCommitOptions = {},
|
||||
): Promise<void> => {
|
||||
await withStorageLock(BEARERS_STORAGE_KEY, () => {
|
||||
writeEncryptedBearers(readEncryptedBearers().filter(r => r.id !== id))
|
||||
options.beforeCommit?.()
|
||||
writeEncryptedBearers(readEncryptedBearers().filter((r) => r.id !== id))
|
||||
})
|
||||
}
|
||||
|
||||
// The atomic unit of bearer persistence: fresh notes to start tracking plus
|
||||
// ids of snapshot notes to lock as spent. Born-spent notes (carved and
|
||||
// melted away in one flow) are deliberately not representable - they were
|
||||
// never the wallet's money in a trackable state.
|
||||
export type BearerChangeset = {
|
||||
add: NewBearer[]
|
||||
markSpent: string[]
|
||||
upsert?: Bearer[]
|
||||
remove?: string[]
|
||||
}
|
||||
|
||||
// Commits a whole changeset as ONE storage write - the fund-critical
|
||||
// boundary a caller (NWC service, wallet store) awaits before reporting
|
||||
// success. The per-record path above (persistBearer in a loop) can die
|
||||
// halfway through a melt: some records persisted, some not, while the
|
||||
// caller's reactive state already moved on. Here nothing becomes
|
||||
// observable until the single locked write lands:
|
||||
//
|
||||
// - every changed Bearer value is derived from the caller's snapshot: added
|
||||
// notes get their id/timestamps assigned HERE (so state and storage can
|
||||
// never disagree about them), spent marks copy the snapshot's record
|
||||
// - ALL encryption happens BEFORE the lock is taken - crypto is the slow,
|
||||
// async part and a storage lock must never be held across it (see
|
||||
// storageLock.ts); if any record fails to encrypt, no write happens at
|
||||
// all
|
||||
// - inside the lock the encrypted records are re-read FRESH, so records
|
||||
// another tab committed after the caller's snapshot survive the upsert -
|
||||
// changed ids replace their stored copy, everything else is kept as-is
|
||||
// (unrelated records are never decrypted or re-encrypted)
|
||||
// - markSpent ids absent from the snapshot are ignored: deriving them would
|
||||
// require decrypting a record the caller doesn't hold
|
||||
// - a changeset that changes nothing performs no write at all
|
||||
// - caller arrays are never mutated
|
||||
// - options.beforeCommit runs synchronously INSIDE the lock, immediately
|
||||
// before the single write: the caller's last-chance fence (the wallet
|
||||
// store revalidates persisted ownership there). Throwing aborts the
|
||||
// commit with storage untouched. Four parameters are deliberate here:
|
||||
// the fence is an orthogonal hook, not changeset data, and grouping it
|
||||
// into the changeset would let callers persist it by accident.
|
||||
//
|
||||
// Returns the next local bearer list (additions first, then the snapshot
|
||||
// with spent marks applied) only after the write succeeded; on any failure
|
||||
// the promise rejects and persisted state is untouched.
|
||||
export const applyBearerChangeset = async (
|
||||
aesKey: CryptoKey,
|
||||
snapshot: Bearer[],
|
||||
changeset: BearerChangeset,
|
||||
options: BearerCommitOptions = {},
|
||||
): Promise<Bearer[]> => {
|
||||
const now = Date.now()
|
||||
const added: Bearer[] = changeset.add.map((note) => ({
|
||||
id: newBearerId(),
|
||||
...note,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
const spentIds = new Set(changeset.markSpent)
|
||||
const spent = new Map<string, Bearer>()
|
||||
for (const bearer of snapshot) {
|
||||
if (spentIds.has(bearer.id)) {
|
||||
spent.set(bearer.id, {...bearer, spent: true, updatedAt: now})
|
||||
}
|
||||
}
|
||||
const upserted = changeset.upsert ?? []
|
||||
const removedIds = new Set(changeset.remove ?? [])
|
||||
const changedById = new Map<string, Bearer>()
|
||||
for (const bearer of spent.values()) changedById.set(bearer.id, bearer)
|
||||
for (const bearer of upserted) changedById.set(bearer.id, bearer)
|
||||
for (const bearer of added) changedById.set(bearer.id, bearer)
|
||||
const changed = [...changedById.values()]
|
||||
if (changed.length === 0 && removedIds.size === 0) return snapshot
|
||||
const encrypted: EncryptedBearerRecord[] = []
|
||||
for (const bearer of changed) {
|
||||
const {id, ...plain} = bearer
|
||||
const parts = await encryptRecord(aesKey, plain)
|
||||
encrypted.push({id, ...parts})
|
||||
}
|
||||
await withStorageLock(BEARERS_STORAGE_KEY, () => {
|
||||
options.beforeCommit?.()
|
||||
const changedIds = new Set(encrypted.map((r) => r.id))
|
||||
const records = readEncryptedBearers().filter(
|
||||
(record) => !changedIds.has(record.id) && !removedIds.has(record.id),
|
||||
)
|
||||
records.push(...encrypted)
|
||||
writeEncryptedBearers(records)
|
||||
})
|
||||
const snapshotIds = new Set(snapshot.map((bearer) => bearer.id))
|
||||
const inserted = upserted.filter((bearer) => !snapshotIds.has(bearer.id))
|
||||
const retained = snapshot
|
||||
.filter((bearer) => !removedIds.has(bearer.id))
|
||||
.map((bearer) => changedById.get(bearer.id) ?? bearer)
|
||||
return [...added, ...inserted, ...retained]
|
||||
}
|
||||
|
||||
// wipes every bearer record from this device outright - unlike forgetting
|
||||
// just the linking key, this is not recoverable by restoring the same seed:
|
||||
// the ciphertexts themselves are gone, so only a previously downloaded
|
||||
@@ -91,10 +214,7 @@ export const clearAllBearers = (): void => {
|
||||
// same spent state, the newer updatedAt wins. This is the merge a restore
|
||||
// (backup file now, nostr later) applies after its records decrypt, and it
|
||||
// is what makes multi-device restores converge instead of duplicate.
|
||||
export const mergeBearers = (
|
||||
current: Bearer[],
|
||||
incoming: Bearer[]
|
||||
): Bearer[] => {
|
||||
export const mergeBearers = (current: Bearer[], incoming: Bearer[]): Bearer[] => {
|
||||
const keyOf = (b: Bearer): string => {
|
||||
const k1 = noteK1(b.url)
|
||||
return k1 ? `${serverOf(b.url)}#${k1}` : `id#${b.id}`
|
||||
|
||||
Reference in New Issue
Block a user