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
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:
@@ -0,0 +1,33 @@
|
||||
import type { TestProject } from 'vitest/node'
|
||||
import { KVStorageHttpServer } from '../../src/kv-storage/kv-storage-http-server.js'
|
||||
import { KVStorageInMemory } from '../../src/kv-storage/kv-storage-in-memory.js'
|
||||
import { Ed25519Verifier } from '../../src/utils/request-auth.js'
|
||||
import { serve } from '../kv-storage/node-server.js'
|
||||
|
||||
declare module 'vitest' {
|
||||
interface ProvidedContext {
|
||||
/** Base URL of the test server. `<url>/open/<space>` and `<url>/signed/<space>` are fresh, isolated stores. */
|
||||
serverUrl: string
|
||||
}
|
||||
}
|
||||
|
||||
/** A real HTTP server on another origin than the test page, with one store per `/<auth>/<space>`. */
|
||||
export default async function setup(project: TestProject): Promise<() => Promise<void>> {
|
||||
const spaces = new Map<string, KVStorageHttpServer>()
|
||||
const server = await serve(async (request) => {
|
||||
const [, auth, space] = new URL(request.url).pathname.split('/')
|
||||
const basePath = `/${auth}/${space}`
|
||||
let handler = spaces.get(basePath)
|
||||
if (!handler) {
|
||||
handler = new KVStorageHttpServer(new KVStorageInMemory(), {
|
||||
auth: auth === 'signed' ? { verifiers: [new Ed25519Verifier()] } : 'none',
|
||||
basePath,
|
||||
allowOrigins: '*',
|
||||
})
|
||||
spaces.set(basePath, handler)
|
||||
}
|
||||
return handler.handle(request)
|
||||
})
|
||||
project.provide('serverUrl', server.url)
|
||||
return server.close
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, inject, it, vi } from 'vitest'
|
||||
import { DocumentStorageKV } from '../../src/document-storage/document-storage-kv.js'
|
||||
import type { BaseKVStorage } from '../../src/kv-storage/base-kv-storage.js'
|
||||
import { KVStorageComposite } from '../../src/kv-storage/kv-storage-composite.js'
|
||||
import { KVStorageEncrypted } from '../../src/kv-storage/kv-storage-encrypted.js'
|
||||
import { KVStorageHttp } from '../../src/kv-storage/kv-storage-http.js'
|
||||
import { KVStorageInMemory } from '../../src/kv-storage/kv-storage-in-memory.js'
|
||||
import { KVStorageIndexedDB } from '../../src/kv-storage/kv-storage-indexeddb.js'
|
||||
import { Ed25519Signer, type RequestSigner } from '../../src/utils/request-auth.js'
|
||||
import { collect } from '../helpers.js'
|
||||
import { runKVStorageConformance } from '../kv-storage/kv-storage.conformance.js'
|
||||
|
||||
const serverUrl = inject('serverUrl')
|
||||
const key = await KVStorageEncrypted.generateKey()
|
||||
let n = 0
|
||||
const unique = (name: string) => `${name}-${Date.now()}-${n++}`
|
||||
|
||||
/** A fresh store on the test server: `open/<id>` (no auth) or `signed/<id>` (Ed25519). */
|
||||
const space = (auth: 'open' | 'signed') => `${auth}/${unique('space')}`
|
||||
|
||||
/** A client for a store on the test server (another origin, so CORS applies). */
|
||||
function http(path = space('open'), options: { signer?: RequestSigner; network?: { online: boolean } } = {}) {
|
||||
return new KVStorageHttp({
|
||||
url: `${serverUrl}/${path}`,
|
||||
signer: options.signer,
|
||||
fetch: (url, init) => (options.network?.online === false ? Promise.reject(new TypeError('Failed to fetch')) : fetch(url, init)),
|
||||
})
|
||||
}
|
||||
|
||||
runKVStorageConformance('KVStorageIndexedDB (Chromium)', () => new KVStorageIndexedDB({ name: unique('idb') }))
|
||||
runKVStorageConformance('KVStorageEncrypted over IndexedDB (Chromium)', () => new KVStorageEncrypted(new KVStorageIndexedDB({ name: unique('idb') }), { key }))
|
||||
runKVStorageConformance('KVStorageHttp (Chromium, cross-origin)', () => http())
|
||||
runKVStorageConformance(
|
||||
'KVStorageComposite: encrypted HTTP + memory + IndexedDB (Chromium)',
|
||||
() =>
|
||||
new KVStorageComposite({
|
||||
source: new KVStorageEncrypted(http(), { key }),
|
||||
replicas: [new KVStorageInMemory(), new KVStorageIndexedDB({ name: unique('idb') })],
|
||||
}),
|
||||
)
|
||||
|
||||
describe('signed requests (Chromium)', () => {
|
||||
it('signs with Ed25519 and only allows the signer’s own keys', async () => {
|
||||
const path = space('signed')
|
||||
const alice = await Ed25519Signer.generate()
|
||||
const bob = await Ed25519Signer.generate()
|
||||
const client = http(path, { signer: alice })
|
||||
await client.child(alice.publicKey).set('profile', { name: 'Alice' })
|
||||
expect(await client.child(alice.publicKey).get('profile')).toEqual({ name: 'Alice' })
|
||||
await expect(client.child(bob.publicKey).set('profile', {})).rejects.toMatchObject({ status: 403 })
|
||||
await expect(http(path).child(alice.publicKey).get('profile')).rejects.toMatchObject({ status: 401 })
|
||||
})
|
||||
|
||||
it('streams changes over SSE across origins', async () => {
|
||||
const path = space('signed')
|
||||
const alice = await Ed25519Signer.generate()
|
||||
const watcher = http(path, { signer: alice })
|
||||
await watcher.child(alice.publicKey).subscribe()
|
||||
const pushed = watcher.waitFor('change', (e) => e.origin === 'remote')
|
||||
await http(path, { signer: alice }).child(alice.publicKey).set('x', 1n)
|
||||
expect((await pushed).changes).toEqual([{ key: `${alice.publicKey}/x`, value: 1n }])
|
||||
await watcher.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('two devices syncing through the server (Chromium)', () => {
|
||||
/** One device: an encrypted, signed source and a persistent IndexedDB replica and queue. */
|
||||
function device(path: string, signer: Ed25519Signer, dbName: string, network = { online: true }) {
|
||||
const indexedDb = new KVStorageIndexedDB({ name: dbName })
|
||||
const db = new KVStorageComposite({
|
||||
source: new KVStorageEncrypted(http(path, { signer, network }), { key }),
|
||||
replicas: [new KVStorageInMemory(), indexedDb],
|
||||
queue: indexedDb.child('~sync'),
|
||||
prefix: signer.publicKey,
|
||||
retryMs: 50,
|
||||
})
|
||||
return { db, user: db.child(signer.publicKey), network }
|
||||
}
|
||||
|
||||
it('pushes writes from one device to the other', async () => {
|
||||
const path = space('signed')
|
||||
const signer = await Ed25519Signer.generate()
|
||||
const a = device(path, signer, unique('a'))
|
||||
const b = device(path, signer, unique('b'))
|
||||
await Promise.all([a.db.sync(), b.db.sync()])
|
||||
const seen = b.db.waitFor('change', (e) => e.origin === 'remote')
|
||||
await a.user.child('notes').set('1', { text: 'from A' })
|
||||
expect((await seen).changes).toEqual([{ key: `${signer.publicKey}/notes/1`, value: { text: 'from A' } }])
|
||||
expect(await b.user.child('notes').get('1')).toEqual({ text: 'from A' })
|
||||
await Promise.all([a.db.close(), b.db.close()])
|
||||
})
|
||||
|
||||
it('works offline, survives a reload with queued writes, and syncs when back online', async () => {
|
||||
const path = space('signed')
|
||||
const signer = await Ed25519Signer.generate()
|
||||
const dbName = unique('a')
|
||||
const first = device(path, signer, dbName)
|
||||
const docs = new DocumentStorageKV(first.user).child('todos')
|
||||
await docs.set('1', { title: 'Synced', done: false })
|
||||
await first.db.sync()
|
||||
|
||||
first.network.online = false
|
||||
await docs.update('1', { $set: { done: true } })
|
||||
await docs.set('2', { title: 'Written offline', done: false })
|
||||
expect(first.db.queued).toBe(2)
|
||||
await first.db.close()
|
||||
|
||||
// "Reload": new instances over the same IndexedDB database, still offline.
|
||||
const network = { online: false }
|
||||
const reloaded = device(path, signer, dbName, network)
|
||||
const reloadedDocs = new DocumentStorageKV(reloaded.user).child('todos')
|
||||
expect(await collect(reloadedDocs.find({ done: false }))).toEqual([{ key: '2', data: { title: 'Written offline', done: false } }])
|
||||
|
||||
network.online = true
|
||||
await reloaded.db.sync()
|
||||
expect(reloaded.db.queued).toBe(0)
|
||||
|
||||
const other = device(path, signer, unique('b'))
|
||||
await other.db.sync()
|
||||
expect(await collect(new DocumentStorageKV(other.user).child('todos').find())).toEqual([
|
||||
{ key: '1', data: { title: 'Synced', done: true } },
|
||||
{ key: '2', data: { title: 'Written offline', done: false } },
|
||||
])
|
||||
await Promise.all([reloaded.db.close(), other.db.close()])
|
||||
})
|
||||
|
||||
it('stores only ciphertext on the server', async () => {
|
||||
const path = space('signed')
|
||||
const signer = await Ed25519Signer.generate()
|
||||
const a = device(path, signer, unique('a'))
|
||||
await a.user.set('secret', 'the plans')
|
||||
const raw: BaseKVStorage = http(path, { signer })
|
||||
const [stored] = await raw.readMany([`${signer.publicKey}/secret`])
|
||||
expect(stored).toBeInstanceOf(Uint8Array)
|
||||
await vi.waitFor(async () => expect(await a.user.get('secret')).toBe('the plans'))
|
||||
await a.db.close()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user