Add encryption, Chromium tests and a README
CI / Node 24 (push) Successful in 15s
CI / Node 26 (push) Successful in 16s
CI / Node 22 (pull_request) Successful in 13s
CI / Node 24 (pull_request) Failing after 3s
CI / Node 26 (pull_request) Failing after 4s
CI / Chromium (push) Failing after 1m8s
CI / Publish to staging registry (push) Skipped
CI / Chromium (pull_request) Failing after 1m3s
CI / Publish to staging registry (pull_request) Skipped
CI / Node 22 (push) Failing after 4s

- KVStorageEncrypted: AES-GCM per value (fresh IV, storage key as AAD), keys stay
  readable; decrypts and re-emits pushed changes and passes subscriptions through, so it
  can be a composite source. generateKey / exportKey / importKey / deriveKey (PBKDF2).
- KVStorageHttpServer: allowOrigins (CORS), including cache-control for SSE preflights.
- Chromium tests (Vitest browser mode + Playwright) against a real cross-origin server:
  conformance for IndexedDB, encrypted, HTTP and composite stores; signed requests and
  SSE; two devices syncing, offline writes surviving a reload. New CI job.
- Composite fixes found by those tests: sync() during a running sync schedules one more
  (changes between the first scan and the stream connecting were missed); a queued write
  the source refuses is dropped, restored and reported (onError) instead of blocking the
  queue forever.
- README with examples, mirrored in test/readme.test.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
This commit is contained in:
Claude
2026-10-23 14:25:22 +00:00
parent 3525dbc622
commit 68d1a1c8e9
21 changed files with 1387 additions and 24 deletions
+32 -1
View File
@@ -41,7 +41,7 @@ describe('KVStorageComposite sync', () => {
network.store.reset()
expect(await db.child('users').get('1')).toEqual({ name: 'Ada' })
expect(await collect(db.child('users').keys())).toEqual(['1', '2'])
expect(network.store.calls).toEqual({})
expect(network.store.calls.readMany).toBeUndefined() // (a follow-up sync started on subscribe may still scan)
})
it('applies changes pushed from other clients to every replica', async () => {
@@ -226,4 +226,35 @@ describe('KVStorageComposite sync', () => {
['c', 3],
])
})
it('sync() during a running sync runs one more, so changes after its scan are picked up', async () => {
let release!: () => void
let reached!: () => void
const gate = new Promise<void>((r) => (release = r))
const paused = new Promise<void>((r) => (reached = r))
const inner = new KVStorageInMemory()
await inner.writeMany([{ key: 'a', value: 1 }])
const source = new KVStorageCounting(inner)
const scan = source.scan.bind(source)
let scans = 0
source.scan = async function* (options) {
const entries = await collect(scan(options)) // a snapshot, like a read-ahead stream
if (options?.values && scans++ === 0) (reached(), await gate)
yield* entries
}
const db = new KVStorageComposite({ source, replicas: [new KVStorageInMemory()] })
composites.push(db)
const first = db.sync()
await paused
await inner.writeMany([{ key: 'b', value: 2 }]) // after the running sync's snapshot
const second = db.sync()
expect(db.sync()).toBe(second) // later callers share the pending one
release()
await first
await second
expect(await collect(db.scan({ values: true }))).toEqual([
['a', 1],
['b', 2],
])
})
})
@@ -0,0 +1,92 @@
import { afterEach, describe, expect, it } from 'vitest'
import { KVStorageComposite } from '../../src/kv-storage/kv-storage-composite.js'
import { KVStorageEncrypted } from '../../src/kv-storage/kv-storage-encrypted.js'
import { KVStorageInMemory } from '../../src/kv-storage/kv-storage-in-memory.js'
import { collect } from '../helpers.js'
import { runKVStorageConformance } from './kv-storage.conformance.js'
import { TestNetwork } from './sync-helpers.js'
const key = await KVStorageEncrypted.generateKey()
runKVStorageConformance('KVStorageEncrypted (in memory)', () => new KVStorageEncrypted(new KVStorageInMemory(), { key }))
let network: TestNetwork | undefined
afterEach(async () => {
await network?.close()
network = undefined
})
describe('KVStorageEncrypted', () => {
it('stores only ciphertext, with readable keys', async () => {
const inner = new KVStorageInMemory()
await new KVStorageEncrypted(inner, { key }).child('users').set('1', { name: 'Ada', n: 1n })
const [[storedKey, stored]] = (await collect(inner.scan({ values: true }))) as [[string, Uint8Array]]
expect(storedKey).toBe('users/1')
expect(stored).toBeInstanceOf(Uint8Array)
expect(new TextDecoder().decode(stored)).not.toContain('Ada')
})
it('uses a fresh IV for every write', async () => {
const inner = new KVStorageInMemory()
const db = new KVStorageEncrypted(inner, { key })
await db.set('a', 'same')
await db.set('b', 'same')
const [a, b] = (await inner.readMany(['a', 'b'])) as Uint8Array[]
expect(a).not.toEqual(b)
})
it('rejects a wrong key, a value moved to another key, tampering and plaintext', async () => {
const inner = new KVStorageInMemory()
await new KVStorageEncrypted(inner, { key }).set('a', 'secret')
const other = new KVStorageEncrypted(inner, { key: await KVStorageEncrypted.generateKey() })
await expect(other.get('a')).rejects.toThrow(/Cannot decrypt/)
const db = new KVStorageEncrypted(inner, { key })
const [stored] = (await inner.readMany(['a'])) as [Uint8Array]
await inner.writeMany([{ key: 'b', value: stored }])
await expect(db.get('b')).rejects.toThrow(/Cannot decrypt/)
const tampered = stored.slice()
tampered[tampered.length - 1]! ^= 1
await inner.writeMany([{ key: 'c', value: tampered }])
await expect(db.get('c')).rejects.toThrow(/Cannot decrypt/)
await inner.writeMany([{ key: 'd', value: 'plain' }])
await expect(db.get('d')).rejects.toThrow(/not encrypted/)
})
it('round-trips keys through export/import, and derives the same key from the same password and salt', async () => {
const inner = new KVStorageInMemory()
await new KVStorageEncrypted(inner, { key }).set('a', 1)
const imported = await KVStorageEncrypted.importKey(await KVStorageEncrypted.exportKey(key))
expect(await new KVStorageEncrypted(inner, { key: imported }).get('a')).toBe(1)
const salt = crypto.getRandomValues(new Uint8Array(16))
await new KVStorageEncrypted(inner, { key: await KVStorageEncrypted.deriveKey('hunter2', salt, 1000) }).set('p', 'x')
expect(await new KVStorageEncrypted(inner, { key: await KVStorageEncrypted.deriveKey('hunter2', salt, 1000) }).get('p')).toBe('x')
await expect(new KVStorageEncrypted(inner, { key: await KVStorageEncrypted.deriveKey('wrong', salt, 1000) }).get('p')).rejects.toThrow(/Cannot decrypt/)
})
it('decrypts pushed changes and passes subscriptions through', async () => {
network = new TestNetwork()
const watcher = new KVStorageEncrypted(network.client(), { key })
await watcher.child('notes').subscribe()
const pushed = watcher.waitFor('change', (e) => e.origin === 'remote')
await new KVStorageEncrypted(network.client(), { key }).child('notes').set('1', { text: 'hi' })
expect((await pushed).changes).toEqual([{ key: 'notes/1', value: { text: 'hi' } }])
})
it('works as a composite source: the server stores ciphertext, replicas hold plaintext', async () => {
network = new TestNetwork()
const replica = new KVStorageInMemory()
const a = new KVStorageComposite({ source: new KVStorageEncrypted(network.client(), { key }), replicas: [replica] })
const b = new KVStorageComposite({ source: new KVStorageEncrypted(network.client(), { key }), replicas: [new KVStorageInMemory()] })
await Promise.all([a.sync(), b.sync()])
const seen = b.waitFor('change', (e) => e.origin === 'remote')
await a.child('notes').set('1', { text: 'hi' })
expect((await seen).changes).toEqual([{ key: 'notes/1', value: { text: 'hi' } }])
expect(await replica.readMany(['notes/1'])).toEqual([{ text: 'hi' }])
expect((await network.store.readMany(['notes/1']))[0]).toBeInstanceOf(Uint8Array)
await Promise.all([a.close(), b.close()])
})
})
+37 -5
View File
@@ -5,7 +5,7 @@ import { KVStorageHttpServer } from '../../src/kv-storage/kv-storage-http-server
import { KVStorageInMemory } from '../../src/kv-storage/kv-storage-in-memory.js'
import { AuthError } from '../../src/utils/errors.js'
import { Ed25519Signer, Ed25519Verifier } from '../../src/utils/request-auth.js'
import { collect } from '../helpers.js'
import { collect, KVStorageCounting } from '../helpers.js'
import { TestNetwork } from './sync-helpers.js'
let network: TestNetwork
@@ -24,6 +24,11 @@ beforeEach(async () => {
})
afterEach(() => network.close())
/** Swaps in a server that rejects every signature (as if the user's access was revoked). */
function refuseEverySignature() {
network.server = new KVStorageHttpServer(network.store, { auth: { verifiers: [{ algorithm: 'ed25519', verify: async () => false }] } })
}
describe('authenticated KVStorageHttpServer', () => {
it('lets a user read, write and list their own keys', async () => {
const mine = network.client(alice).child(alice.publicKey)
@@ -82,13 +87,40 @@ describe('authenticated KVStorageHttpServer', () => {
})
it('a composite undoes a local write the server refuses', async () => {
const replica = new KVStorageInMemory()
const db = new KVStorageComposite({ source: network.client(alice), prefix: alice.publicKey, replicas: [replica] })
await db.sync()
// A source without subscriptions, so no follow-up sync is running when the write is made.
const db = new KVStorageComposite({ source: new KVStorageCounting(network.client(alice)), prefix: alice.publicKey, replicas: [new KVStorageInMemory()] })
await db.child(alice.publicKey).set('x', 'before')
network.server = new KVStorageHttpServer(network.store, { auth: { verifiers: [{ algorithm: 'ed25519', verify: async () => false }] } }) // now rejects every signature
await db.sync()
refuseEverySignature()
await expect(db.child(alice.publicKey).set('x', 'after')).rejects.toMatchObject({ status: 401 })
expect(await db.child(alice.publicKey).get('x')).toBe('before')
await db.close()
})
it('a composite drops a queued write the server refuses, restores the server value, and reports it', async () => {
const errors: Error[] = []
const db = new KVStorageComposite({
source: network.client(alice),
prefix: alice.publicKey,
replicas: [new KVStorageInMemory()],
onError: (e) => errors.push(e),
})
const mine = db.child(alice.publicKey)
await mine.set('x', 'before')
await db.sync()
network.online = false
await mine.set('x', 'queued')
await mine.set('y', 'queued after')
expect(db.queued).toBe(2)
const server = network.server
refuseEverySignature()
network.online = true
await db.sync()
network.server = server
expect(db.queued).toBe(0) // dropped, not retried forever
expect(errors.filter((e) => e.message === 'A queued write was refused by the source and dropped')).toHaveLength(2) // the sync's own scan is reported too
await db.sync()
expect(await mine.getMany(['x', 'y'])).toEqual(['before', undefined])
await db.close()
})
})
+13
View File
@@ -51,4 +51,17 @@ describe('server', () => {
expect((await server.handle(new Request('http://x/nope', { method: 'POST', body: '{}' }))).status).toBe(404)
expect((await server.handle(new Request('http://x/readMany', { method: 'POST', body: '{"keys": 5}' }))).status).toBe(500)
})
it('answers CORS preflights and allows only listed origins', async () => {
const server = new KVStorageHttpServer(new KVStorageInMemory(), { auth: 'none', allowOrigins: ['https://app.example'] })
const preflight = await server.handle(new Request('http://x/write', { method: 'OPTIONS', headers: { origin: 'https://app.example' } }))
expect(preflight.status).toBe(204)
expect(preflight.headers.get('access-control-allow-origin')).toBe('https://app.example')
expect(preflight.headers.get('access-control-allow-headers')).toContain('X-Signature')
expect(preflight.headers.get('access-control-allow-headers')).toContain('cache-control') // sent by SSESession
const allowed = await server.handle(new Request('http://x/readMany', { method: 'POST', body: '{"keys":[]}', headers: { origin: 'https://app.example' } }))
expect(allowed.headers.get('access-control-allow-origin')).toBe('https://app.example')
const other = await server.handle(new Request('http://x/readMany', { method: 'POST', body: '{"keys":[]}', headers: { origin: 'https://evil.example' } }))
expect(other.headers.get('access-control-allow-origin')).toBeNull()
})
})
+6 -1
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { BaseKVStorage, ScanOptions } from '../../src/kv-storage/base-kv-storage.js'
import { InvalidKeyError } from '../../src/utils/errors.js'
import { collect } from '../helpers.js'
@@ -10,6 +10,11 @@ export function runKVStorageConformance(name: string, make: () => BaseKVStorage
beforeEach(async () => {
db = await make()
})
// Stores with open connections (e.g. a subscribed composite) release them; browsers allow
// only 6 HTTP/1.1 connections per host.
afterEach(async () => {
await (db as Partial<{ close(): Promise<void> }>).close?.()
})
const scanKeys = async (options: ScanOptions) => (await collect(db.scan(options))).map(([k]) => k)
describe('primitives', () => {
+5 -1
View File
@@ -7,7 +7,11 @@ export async function serve(handle: (request: Request) => Promise<Response>): Pr
const chunks: Buffer[] = []
for await (const chunk of req) chunks.push(chunk as Buffer)
const response = await handle(
new Request(`http://localhost${req.url}`, { method: req.method, body: req.method === 'POST' ? Buffer.concat(chunks) : undefined }),
new Request(`http://localhost${req.url}`, {
method: req.method,
headers: Object.entries(req.headers).flatMap(([name, value]) => (typeof value === 'string' ? [[name, value] as [string, string]] : [])),
body: req.method === 'POST' ? Buffer.concat(chunks) : undefined,
}),
)
res.writeHead(response.status, Object.fromEntries(response.headers))
if (!response.body) return void res.end()