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 {beforeEach, describe, expect, it} from 'vitest'
|
||||||
import {buildNoteUrl} from 'lnurlcash-kit'
|
import {buildNoteUrl} from 'lnurlcash-kit'
|
||||||
|
|
||||||
|
import './storage.changeset.cases'
|
||||||
import type {Bearer} from './types'
|
import type {Bearer} from './types'
|
||||||
import {deriveBearerAesKey} from './keys'
|
import {deriveBearerAesKey} from './keys'
|
||||||
import {
|
import {
|
||||||
@@ -18,10 +19,10 @@ import {
|
|||||||
persistActivityEvent,
|
persistActivityEvent,
|
||||||
persistBearer,
|
persistBearer,
|
||||||
readEncryptedBearers,
|
readEncryptedBearers,
|
||||||
MAX_ACTIVITY_ENTRIES
|
MAX_ACTIVITY_ENTRIES,
|
||||||
} from './storage'
|
} from './storage'
|
||||||
import {saveLinkingKey} from './keys'
|
import {saveLinkingKey} from './keys'
|
||||||
import {stubLocalStorage} from './test-utils'
|
import {requiredValue, stubLocalStorage} from './test-utils'
|
||||||
|
|
||||||
const LINKING_KEY = new Uint8Array(32).fill(7)
|
const LINKING_KEY = new Uint8Array(32).fill(7)
|
||||||
const OTHER_KEY = new Uint8Array(32).fill(9)
|
const OTHER_KEY = new Uint8Array(32).fill(9)
|
||||||
@@ -37,7 +38,7 @@ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
|
|||||||
verified: true,
|
verified: true,
|
||||||
createdAt: 1000,
|
createdAt: 1000,
|
||||||
updatedAt: 1000,
|
updatedAt: 1000,
|
||||||
...overrides
|
...overrides,
|
||||||
})
|
})
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -51,7 +52,7 @@ describe('encrypted bearer records', () => {
|
|||||||
await persistBearer(key, bearer)
|
await persistBearer(key, bearer)
|
||||||
|
|
||||||
// at rest, nothing plaintext leaks: no k1, no amounts
|
// 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(K1_A)
|
||||||
expect(raw).not.toContain('21000')
|
expect(raw).not.toContain('21000')
|
||||||
|
|
||||||
@@ -63,12 +64,19 @@ describe('encrypted bearer records', () => {
|
|||||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||||
const other = await deriveBearerAesKey(OTHER_KEY)
|
const other = await deriveBearerAesKey(OTHER_KEY)
|
||||||
await persistBearer(key, bearerFixture({id: 'mine'}))
|
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)
|
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
|
// 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 () => {
|
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)
|
const loaded = await loadBearers(key)
|
||||||
expect(loaded).toHaveLength(1)
|
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 () => {
|
it('deletes a record by id and clears all', async () => {
|
||||||
@@ -90,7 +98,7 @@ describe('encrypted bearer records', () => {
|
|||||||
await persistBearer(key, b)
|
await persistBearer(key, b)
|
||||||
|
|
||||||
await deleteBearerRecord('a')
|
await deleteBearerRecord('a')
|
||||||
expect((await loadBearers(key)).map(x => x.id)).toEqual(['b'])
|
expect((await loadBearers(key)).map((x) => x.id)).toEqual(['b'])
|
||||||
|
|
||||||
clearAllBearers()
|
clearAllBearers()
|
||||||
expect(readEncryptedBearers()).toEqual([])
|
expect(readEncryptedBearers()).toEqual([])
|
||||||
@@ -104,7 +112,7 @@ describe('activity log', () => {
|
|||||||
await persistActivityEvent(key, {id: '2', kind: 'melt', message: 'b', createdAt: 2000})
|
await persistActivityEvent(key, {id: '2', kind: 'melt', message: 'b', createdAt: 2000})
|
||||||
|
|
||||||
const loaded = await loadActivity(key)
|
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 () => {
|
it('caps the log, rolling the oldest entries off', async () => {
|
||||||
@@ -114,14 +122,14 @@ describe('activity log', () => {
|
|||||||
id: `ev-${i}`,
|
id: `ev-${i}`,
|
||||||
kind: 'receive',
|
kind: 'receive',
|
||||||
message: `event ${i}`,
|
message: `event ${i}`,
|
||||||
createdAt: i
|
createdAt: i,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const loaded = await loadActivity(key)
|
const loaded = await loadActivity(key)
|
||||||
expect(loaded).toHaveLength(MAX_ACTIVITY_ENTRIES)
|
expect(loaded).toHaveLength(MAX_ACTIVITY_ENTRIES)
|
||||||
// the five oldest rolled off; the newest is first
|
// the five oldest rolled off; the newest is first
|
||||||
expect(loaded[0]!.id).toBe(`ev-${MAX_ACTIVITY_ENTRIES + 4}`)
|
expect(requiredValue(loaded[0]).id).toBe(`ev-${MAX_ACTIVITY_ENTRIES + 4}`)
|
||||||
expect(loaded.at(-1)!.id).toBe('ev-5')
|
expect(requiredValue(loaded.at(-1)).id).toBe('ev-5')
|
||||||
}, 30_000)
|
}, 30_000)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -130,7 +138,7 @@ describe('mergeBearers (union by note id, spent-wins)', () => {
|
|||||||
const a = bearerFixture({id: 'a'})
|
const a = bearerFixture({id: 'a'})
|
||||||
const b = bearerFixture({id: 'b', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000)})
|
const b = bearerFixture({id: 'b', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000)})
|
||||||
const merged = mergeBearers([a], [b])
|
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', () => {
|
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
|
// would resurrect burned money
|
||||||
const merged = mergeBearers([spendable], [spent])
|
const merged = mergeBearers([spendable], [spent])
|
||||||
expect(merged).toHaveLength(1)
|
expect(merged).toHaveLength(1)
|
||||||
expect(merged[0]!.id).toBe('new-copy')
|
expect(requiredValue(merged[0]).id).toBe('new-copy')
|
||||||
expect(merged[0]!.spent).toBe(true)
|
expect(requiredValue(merged[0]).spent).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps the newer copy when both agree on spent state', () => {
|
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 fresh = bearerFixture({id: 'fresh', updatedAt: 2000, amount: 2})
|
||||||
const merged = mergeBearers([stale], [fresh])
|
const merged = mergeBearers([stale], [fresh])
|
||||||
expect(merged).toHaveLength(1)
|
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', () => {
|
it('treats the same secret on different servers as different notes', () => {
|
||||||
const here = bearerFixture({id: 'here'})
|
const here = bearerFixture({id: 'here'})
|
||||||
const there = bearerFixture({
|
const there = bearerFixture({
|
||||||
id: 'there',
|
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)
|
expect(mergeBearers([here], [there])).toHaveLength(2)
|
||||||
})
|
})
|
||||||
@@ -191,16 +199,17 @@ describe('backup', () => {
|
|||||||
...backup,
|
...backup,
|
||||||
bearers: [
|
bearers: [
|
||||||
...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.added).toBe(1)
|
||||||
expect(result.skipped).toBe(1)
|
expect(result.skipped).toBe(1)
|
||||||
expect(readEncryptedBearers().map(r => r.id).sort()).toEqual([
|
expect(
|
||||||
'existing',
|
readEncryptedBearers()
|
||||||
'from-backup'
|
.map((r) => r.id)
|
||||||
])
|
.sort(),
|
||||||
|
).toEqual(['existing', 'from-backup'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('restores the linking key only onto a device that has none', async () => {
|
it('restores the linking key only onto a device that has none', async () => {
|
||||||
@@ -208,33 +217,35 @@ describe('backup', () => {
|
|||||||
const backup = buildBackup()
|
const backup = buildBackup()
|
||||||
|
|
||||||
// same device: a key already exists, so the backup's key is skipped
|
// 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.linkingKeySkipped).toBe(true)
|
||||||
expect(here.linkingKeyRestored).toBe(false)
|
expect(here.linkingKeyRestored).toBe(false)
|
||||||
|
|
||||||
// fresh device: the key installs
|
// fresh device: the key installs
|
||||||
stubLocalStorage()
|
stubLocalStorage()
|
||||||
const fresh = applyBackup(backup)
|
const fresh = await applyBackup(backup)
|
||||||
expect(fresh.linkingKeyRestored).toBe(true)
|
expect(fresh.linkingKeyRestored).toBe(true)
|
||||||
expect(fresh.linkingKeySkipped).toBe(false)
|
expect(fresh.linkingKeySkipped).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects a file that is not a sattle backup', () => {
|
it('rejects a file that is not a sattle backup', async () => {
|
||||||
expect(() => applyBackup({type: 'lnurlwallet-backup', version: 1, bearers: []})).toThrow()
|
await expect(
|
||||||
expect(() => applyBackup(null)).toThrow()
|
applyBackup({type: 'lnurlwallet-backup', version: 1, bearers: []}),
|
||||||
expect(() => applyBackup({type: 'sattle-backup', version: 2, bearers: []})).toThrow()
|
).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', () => {
|
it('skips malformed records instead of failing the whole restore', async () => {
|
||||||
const result = applyBackup({
|
const result = await applyBackup({
|
||||||
type: 'sattle-backup',
|
type: 'sattle-backup',
|
||||||
version: 1,
|
version: 1,
|
||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
bearers: [
|
bearers: [
|
||||||
{id: 'ok', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)},
|
{id: 'ok', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)},
|
||||||
{id: 42, iv: null, ciphertext: 'xx'},
|
{id: 42, iv: null, ciphertext: 'xx'},
|
||||||
'garbage'
|
'garbage',
|
||||||
]
|
],
|
||||||
})
|
})
|
||||||
expect(result.added).toBe(1)
|
expect(result.added).toBe(1)
|
||||||
expect(result.skipped).toBe(2)
|
expect(result.skipped).toBe(2)
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
// the Pinia mints store and this module's backup both use it.
|
// the Pinia mints store and this module's backup both use it.
|
||||||
//
|
//
|
||||||
// Split by concern; this façade re-exports everything:
|
// 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/activityLog.ts - the append-only encrypted activity log
|
||||||
// storage/settings.ts - plaintext wallet settings
|
// storage/settings.ts - plaintext wallet settings
|
||||||
// storage/backup.ts - buildBackup / applyBackup
|
// storage/backup.ts - buildBackup / applyBackup
|
||||||
@@ -19,10 +20,11 @@ export {
|
|||||||
loadBearers,
|
loadBearers,
|
||||||
persistBearer,
|
persistBearer,
|
||||||
deleteBearerRecord,
|
deleteBearerRecord,
|
||||||
|
applyBearerChangeset,
|
||||||
clearAllBearers,
|
clearAllBearers,
|
||||||
mergeBearers
|
mergeBearers,
|
||||||
} from './storage/bearers'
|
} from './storage/bearers'
|
||||||
export type {EncryptedBearerRecord} from './storage/bearers'
|
export type {BearerChangeset, EncryptedBearerRecord} from './storage/bearers'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
newActivityId,
|
newActivityId,
|
||||||
@@ -30,16 +32,12 @@ export {
|
|||||||
loadActivity,
|
loadActivity,
|
||||||
persistActivityEvent,
|
persistActivityEvent,
|
||||||
clearAllActivity,
|
clearAllActivity,
|
||||||
MAX_ACTIVITY_ENTRIES
|
MAX_ACTIVITY_ENTRIES,
|
||||||
} from './storage/activityLog'
|
|
||||||
export type {
|
|
||||||
ActivityKind,
|
|
||||||
ActivityEvent,
|
|
||||||
EncryptedActivityRecord
|
|
||||||
} from './storage/activityLog'
|
} from './storage/activityLog'
|
||||||
|
export type {ActivityKind, ActivityEvent, EncryptedActivityRecord} from './storage/activityLog'
|
||||||
|
|
||||||
export {loadSettings, persistSettings, clearSettings} from './storage/settings'
|
export {loadSettings, persistSettings, clearSettings} from './storage/settings'
|
||||||
export type {WalletSettings} 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'
|
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 type {EncryptedRecordParts} from '../keys'
|
||||||
import {encryptRecord, decryptRecord} 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 {noteK1, serverOf} from 'lnurlcash-kit'
|
||||||
import {withStorageLock} from '../storageLock'
|
import {withStorageLock} from '../storageLock'
|
||||||
|
|
||||||
@@ -15,11 +16,31 @@ export const compareBearerOrder = (a: Bearer, b: Bearer): number =>
|
|||||||
|
|
||||||
export type EncryptedBearerRecord = {id: string} & EncryptedRecordParts
|
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'
|
const BEARERS_STORAGE_KEY = 'sattle_bearers'
|
||||||
|
|
||||||
export const newBearerId = (): string =>
|
export const newBearerId = (): string =>
|
||||||
Array.from(crypto.getRandomValues(new Uint8Array(8)))
|
Array.from(crypto.getRandomValues(new Uint8Array(8)))
|
||||||
.map(b => b.toString(16).padStart(2, '0'))
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
.join('')
|
.join('')
|
||||||
|
|
||||||
export const readEncryptedBearers = (): EncryptedBearerRecord[] => {
|
export const readEncryptedBearers = (): EncryptedBearerRecord[] => {
|
||||||
@@ -27,18 +48,18 @@ export const readEncryptedBearers = (): EncryptedBearerRecord[] => {
|
|||||||
if (!raw) return []
|
if (!raw) return []
|
||||||
try {
|
try {
|
||||||
const parsed: unknown = JSON.parse(raw)
|
const parsed: unknown = JSON.parse(raw)
|
||||||
return Array.isArray(parsed) ? parsed : []
|
return Array.isArray(parsed) ? parsed.filter(isEncryptedBearerRecord) : []
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const writeEncryptedBearers = (
|
export const writeEncryptedBearers = (records: EncryptedBearerRecord[]): void => {
|
||||||
records: EncryptedBearerRecord[]
|
|
||||||
): void => {
|
|
||||||
localStorage.setItem(BEARERS_STORAGE_KEY, JSON.stringify(records))
|
localStorage.setItem(BEARERS_STORAGE_KEY, JSON.stringify(records))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BearerCommitOptions = {beforeCommit?: () => void}
|
||||||
|
|
||||||
// decrypts everything currently stored - a record that fails to decrypt
|
// decrypts everything currently stored - a record that fails to decrypt
|
||||||
// (e.g. written by a different seed's key) is skipped, not destroyed: it
|
// (e.g. written by a different seed's key) is skipped, not destroyed: it
|
||||||
// stays in localStorage untouched and simply doesn't show up
|
// 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[] = []
|
const bearers: Bearer[] = []
|
||||||
for (const record of readEncryptedBearers()) {
|
for (const record of readEncryptedBearers()) {
|
||||||
try {
|
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})
|
bearers.push({...bearer, id: record.id})
|
||||||
} catch {
|
} catch (error) {
|
||||||
// undecryptable with this key - leave it in place
|
// undecryptable with this key - leave it in place
|
||||||
|
if (!(error instanceof Error)) throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return bearers.sort((a, b) => b.createdAt - a.createdAt)
|
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 (
|
export const persistBearer = async (
|
||||||
aesKey: CryptoKey,
|
aesKey: CryptoKey,
|
||||||
bearer: Bearer
|
bearer: Bearer,
|
||||||
|
options: BearerCommitOptions = {},
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const {id, ...plain} = bearer
|
const {id, ...plain} = bearer
|
||||||
const parts = await encryptRecord(aesKey, plain)
|
const parts = await encryptRecord(aesKey, plain)
|
||||||
await withStorageLock(BEARERS_STORAGE_KEY, () => {
|
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})
|
records.push({id, ...parts})
|
||||||
writeEncryptedBearers(records)
|
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, () => {
|
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
|
// wipes every bearer record from this device outright - unlike forgetting
|
||||||
// just the linking key, this is not recoverable by restoring the same seed:
|
// just the linking key, this is not recoverable by restoring the same seed:
|
||||||
// the ciphertexts themselves are gone, so only a previously downloaded
|
// 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
|
// 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
|
// (backup file now, nostr later) applies after its records decrypt, and it
|
||||||
// is what makes multi-device restores converge instead of duplicate.
|
// is what makes multi-device restores converge instead of duplicate.
|
||||||
export const mergeBearers = (
|
export const mergeBearers = (current: Bearer[], incoming: Bearer[]): Bearer[] => {
|
||||||
current: Bearer[],
|
|
||||||
incoming: Bearer[]
|
|
||||||
): Bearer[] => {
|
|
||||||
const keyOf = (b: Bearer): string => {
|
const keyOf = (b: Bearer): string => {
|
||||||
const k1 = noteK1(b.url)
|
const k1 = noteK1(b.url)
|
||||||
return k1 ? `${serverOf(b.url)}#${k1}` : `id#${b.id}`
|
return k1 ? `${serverOf(b.url)}#${k1}` : `id#${b.id}`
|
||||||
|
|||||||
Reference in New Issue
Block a user