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()
|
||||
})
|
||||
})
|
||||
@@ -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()])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DocumentStorageKV,
|
||||
Ed25519Signer,
|
||||
Ed25519Verifier,
|
||||
EventStorageKV,
|
||||
KVStorageComposite,
|
||||
KVStorageEncrypted,
|
||||
KVStorageHttp,
|
||||
KVStorageHttpServer,
|
||||
KVStorageInMemory,
|
||||
type Fields,
|
||||
type Many,
|
||||
} from '../src/index.js'
|
||||
import { collect } from './helpers.js'
|
||||
|
||||
// The README's examples, run as written (imports from the package entry point), so they stay correct.
|
||||
|
||||
const closers: (() => Promise<void>)[] = []
|
||||
afterEach(async () => {
|
||||
for (const close of closers.splice(0)) await close()
|
||||
})
|
||||
|
||||
describe('README', () => {
|
||||
it('KV storage', async () => {
|
||||
const db = new KVStorageInMemory()
|
||||
const users = db.child('users')
|
||||
await users.set('1', { name: 'Ada' })
|
||||
expect(await users.child('1').get()).toEqual({ name: 'Ada' })
|
||||
expect(await users.get('1')).toEqual({ name: 'Ada' })
|
||||
await users.setMany([
|
||||
['2', { name: 'Bob' }],
|
||||
['3', { name: 'Cy' }],
|
||||
])
|
||||
expect(await users.getMany(['1', 'missing'])).toEqual([{ name: 'Ada' }, undefined])
|
||||
expect(await collect(users.keys({ limit: 10 }))).toEqual(['1', '2', '3'])
|
||||
expect((await collect(users.entries({ reverse: true }))).map(([key]) => key)).toEqual(['3', '2', '1'])
|
||||
await users.delete('3')
|
||||
await users.deleteTree()
|
||||
expect(await collect(db.scan())).toEqual([])
|
||||
|
||||
const settings = db.child('settings')
|
||||
await settings.setFields({ theme: 'dark', fontSize: 14 })
|
||||
expect(await settings.get('theme')).toBe('dark')
|
||||
expect(await settings.getFields()).toEqual({ fontSize: 14, theme: 'dark' })
|
||||
|
||||
const changes: unknown[] = []
|
||||
db.on('change', ({ changes: c, origin }) => changes.push([origin, c]))
|
||||
await users.set('1', { name: 'Ada' })
|
||||
expect(changes).toEqual([['local', [{ key: 'users/1', value: { name: 'Ada' } }]]])
|
||||
})
|
||||
|
||||
it('types', async () => {
|
||||
type User = { name: string }
|
||||
type Schema = { children: { users: Many<User>; settings: { children: Fields<{ theme: string; fontSize: number }> } } }
|
||||
const typed = new KVStorageInMemory<Schema>()
|
||||
await typed.child('users').set('1', { name: 'Ada' })
|
||||
// @ts-expect-error not a User
|
||||
await typed.child('users').set('2', { nom: 'x' })
|
||||
await typed.child('settings').set('theme', 'dark')
|
||||
const theme: string | undefined = await typed.child('settings').get('theme')
|
||||
expect(theme).toBe('dark')
|
||||
})
|
||||
|
||||
it('event storage', async () => {
|
||||
const events = new EventStorageKV(new KVStorageInMemory())
|
||||
const orders = events.child('orders')
|
||||
const [id] = await orders.append('o1', [{ type: 'created', total: 30 }])
|
||||
await orders.child('o1').append([{ type: 'paid' }])
|
||||
expect((await collect(orders.read('o1'))).map((e) => e.data)).toEqual([{ type: 'created', total: 30 }, { type: 'paid' }])
|
||||
expect((await collect(orders.read('o1', { after: id }))).map((e) => e.data)).toEqual([{ type: 'paid' }])
|
||||
expect(await collect(orders.keys())).toEqual(['o1'])
|
||||
})
|
||||
|
||||
it('document storage', async () => {
|
||||
const docs = new DocumentStorageKV(new KVStorageInMemory())
|
||||
const people = docs.child('people')
|
||||
const id = await people.insert({ name: 'Ada', age: 36, tags: ['math'] })
|
||||
await people.set('grace', { name: 'Grace', age: 45, tags: ['navy', 'cobol'] })
|
||||
await people.update(id, { $inc: { age: 1 }, $push: { tags: 'engines' } })
|
||||
expect((await collect(people.find({ age: { $gte: 37 } }, { sort: { age: -1 }, limit: 10 }))).map((r) => r.key)).toEqual(['grace', id])
|
||||
expect((await people.findOne({ tags: 'cobol' }))?.key).toBe('grace')
|
||||
expect(await people.get(id)).toEqual({ name: 'Ada', age: 37, tags: ['math', 'engines'] })
|
||||
expect(await people.delete('grace')).toBe(true)
|
||||
})
|
||||
|
||||
it('sync, offline and encryption (server and client)', async () => {
|
||||
const server = new KVStorageHttpServer(new KVStorageInMemory(), {
|
||||
auth: { verifiers: [new Ed25519Verifier()] },
|
||||
basePath: '/kv',
|
||||
allowOrigins: ['https://app.example.com'],
|
||||
})
|
||||
const fetch = (url: string, init: RequestInit) => server.handle(new Request(url, init))
|
||||
|
||||
const signer = await Ed25519Signer.generate()
|
||||
const saved = await signer.exportPrivateKey()
|
||||
expect((await Ed25519Signer.fromPrivateKey(saved)).publicKey).toBe(signer.publicKey)
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16))
|
||||
const key = await KVStorageEncrypted.deriveKey('correct horse battery staple', salt, 1000)
|
||||
|
||||
const local = new KVStorageInMemory() // stands in for KVStorageIndexedDB
|
||||
const db = new KVStorageComposite({
|
||||
source: new KVStorageEncrypted(new KVStorageHttp({ url: 'https://api.example.com/kv', signer, fetch }), { key }),
|
||||
replicas: [new KVStorageInMemory(), local],
|
||||
queue: local.child('~sync'),
|
||||
prefix: signer.publicKey,
|
||||
})
|
||||
closers.push(() => db.close())
|
||||
|
||||
const me = db.child(signer.publicKey)
|
||||
await me.child('notes').set('1', { text: 'Works offline' })
|
||||
expect(await me.child('notes').get('1')).toEqual({ text: 'Works offline' })
|
||||
|
||||
type Todo = { title: string; done: boolean }
|
||||
const todos = new DocumentStorageKV<{ children: { todos: Many<Todo> } }>(me).child('todos')
|
||||
await todos.insert({ title: 'Ship it', done: false })
|
||||
expect((await collect(todos.find({ done: false }))).map((r) => r.data.title)).toEqual(['Ship it'])
|
||||
|
||||
// Another device with the same keys sees the data; the server only has ciphertext.
|
||||
const other = new KVStorageComposite({
|
||||
source: new KVStorageEncrypted(new KVStorageHttp({ url: 'https://api.example.com/kv', signer, fetch }), { key }),
|
||||
replicas: [new KVStorageInMemory()],
|
||||
prefix: signer.publicKey,
|
||||
})
|
||||
closers.push(() => other.close())
|
||||
expect(await other.child(signer.publicKey).child('notes').get('1')).toEqual({ text: 'Works offline' })
|
||||
|
||||
const http = new KVStorageHttp({ url: 'https://api.example.com/kv', signer, fetch })
|
||||
closers.push(() => http.close())
|
||||
const [stored] = await http.readMany([`${signer.publicKey}/notes/1`])
|
||||
expect(stored).toBeInstanceOf(Uint8Array)
|
||||
const pushed = http.waitFor('change', () => true)
|
||||
await http.child(signer.publicKey).child('notes').subscribe()
|
||||
await me.child('notes').set('2', { text: 'live' })
|
||||
expect((await pushed).changes[0]!.key).toBe(`${signer.publicKey}/notes/2`)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user