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
+1
View File
@@ -4,6 +4,7 @@ export * from './kv-storage/kv-storage-http.js'
export * from './kv-storage/kv-storage-http-server.js'
export * from './kv-storage/kv-storage-indexeddb.js'
export * from './kv-storage/kv-storage-composite.js'
export * from './kv-storage/kv-storage-encrypted.js'
export * from './event-storage/base-event-storage.js'
export * from './event-storage/event-storage-kv.js'
export * from './document-storage/base-document-storage.js'
+32 -7
View File
@@ -22,6 +22,11 @@ export interface KVStorageCompositeOptions {
prefix?: string
/** Delay before retrying when the source is unreachable. Default 5 000 ms. */
retryMs?: number
/**
* Called with errors that have no caller to reject: a queued write the source refused (it is
* dropped, and the source's values restored locally), or a failed sync. Default: `console.error`.
*/
onError?: (error: Error) => void
}
/** Sources that push remote changes (e.g. `KVStorageHttp`). */
@@ -58,11 +63,14 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
private readonly writes: BaseKVStorage
private readonly range: KeyRange
private readonly retryMs: number
private readonly onError: (error: Error) => void
private readonly ids = new Ulid()
/** Mutable state lives here: child nodes share this object (see `child()`). */
private readonly state: {
started?: Promise<void>
syncing?: Promise<void>
/** A sync requested while one was running. */
again?: Promise<void>
/** A sync has completed at some point, so the replicas hold a full copy. */
synced: boolean
/** Writes waiting in the queue. */
@@ -82,6 +90,7 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
this.writes = this.queue.child('writes')
this.range = new KeyRange(options.prefix ?? '')
this.retryMs = options.retryMs ?? 5000
this.onError = options.onError ?? ((error) => console.error(error))
// A full sync makes the replicas match the source within the range, which would delete queued
// writes stored inside that range.
const queueRoot = KVStorageComposite.rootOf(this.queue)
@@ -95,9 +104,15 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
return (this.state.started ??= this.open())
}
/** Replays queued writes, then brings the replicas up to date with the source (starts first if needed). */
/**
* Replays queued writes, then brings the replicas up to date with the source (starts first if
* needed). Called while a sync is running, it resolves after one more sync that starts after it.
*/
sync(): Promise<void> {
return (this.state.syncing ??= this.runSync().finally(() => (this.state.syncing = undefined)))
// During a sync, schedule one more after it (shared by every caller meanwhile): what changed
// since the running one scanned that part of the source must be picked up too.
if (this.state.syncing) return (this.state.again ??= this.state.syncing.then(() => ((this.state.again = undefined), this.sync())))
return (this.state.syncing = this.runSync().finally(() => (this.state.syncing = undefined)))
}
/** Number of writes waiting for the source. */
@@ -158,7 +173,7 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
this.source.on('change', (event) => {
if (event.origin !== 'remote') return
const changes = event.changes.filter(({ key }) => this.range.contains(key))
this.state.applying = this.state.applying.then(() => this.applyRemote(changes)).catch((error) => console.error(error))
this.state.applying = this.state.applying.then(() => this.applyRemote(changes)).catch((error: Error) => this.onError(error))
}),
this.source.on('resync', () => void this.sync()),
)
@@ -166,7 +181,7 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
void this.source
.subscribe(this.range.prefix)
.then(() => this.sync())
.catch((error) => console.error(error))
.catch((error: Error) => this.onError(error))
} else {
void this.sync()
}
@@ -183,7 +198,7 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
await this.queue.set('synced', true)
this.state.synced = true
} catch (error) {
if (!(error instanceof StorageUnavailableError)) console.error(error)
if (!(error instanceof StorageUnavailableError)) this.onError(error as Error)
this.retryLater()
}
}
@@ -195,8 +210,18 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
for await (const entry of this.writes.entries({ limit: 1 })) next = entry
if (!next) return
const changes = next[1] as KVChange[]
await this.source.writeMany(changes)
await this.apply(changes)
try {
await this.source.writeMany(changes)
await this.apply(changes)
} catch (error) {
if (error instanceof StorageUnavailableError) throw error
// Refused (e.g. 403): retrying won't help, and would block every later write. Drop it and
// restore what the source holds.
this.onError(new Error('A queued write was refused by the source and dropped', { cause: error }))
const keys = changes.map(({ key }) => key)
const current = await this.source.readMany(keys).catch(() => undefined)
if (current) await this.applyRemote(keys.map((key, i) => ({ key, value: current[i] })))
}
await this.writes.delete(next[0])
this.state.queued--
}
+149
View File
@@ -0,0 +1,149 @@
import { ExtendedJson } from '../utils/extended-json.js'
import type { AnySpec, ChildOf, KeyOf, Spec } from '../utils/schema.js'
import { BaseKVStorage, type KVChange, type ScanOptions } from './base-kv-storage.js'
export interface KVStorageEncryptedOptions {
/** An AES-GCM key, e.g. from `KVStorageEncrypted.generateKey()` or `deriveKey(password, salt)`. */
key: CryptoKey
/** Called when a pushed change can't be decrypted (it is dropped). Default: `console.error`. */
onError?: (error: Error) => void
}
/** Sources that push remote changes (e.g. `KVStorageHttp`). */
interface Subscribable {
subscribe(path: string): Promise<void>
unsubscribe(path: string): Promise<void>
}
// Types only: children of a KVStorageEncrypted are KVStorageEncrypted nodes too (child() creates
// them with Object.create, so they share this class's prototype).
export interface KVStorageEncrypted<S extends Spec = AnySpec> {
child<K extends KeyOf<S>>(key: K): KVStorageEncrypted<ChildOf<S, K>>
}
/**
* Encrypts values (not keys) before they reach another KV storage, e.g. `KVStorageHttp`, so the
* server only ever stores ciphertext.
*
* - Each value is AES-GCM encrypted with a fresh random IV and stored as a `Uint8Array`:
* `[1 (format), iv (12 bytes), ciphertext]`. The key it is stored under is authenticated too, so
* a value moved to another key fails to decrypt.
* - Keys stay readable: the server needs them for authorization, ordering and prefix scans. Don't
* put secrets in keys. Value sizes and write times are visible as well.
* - Changes the inner storage pushes (`origin: 'remote'`) are decrypted and re-emitted, and
* `subscribe` / `unsubscribe` are passed through, so it can be a `KVStorageComposite` source.
*/
export class KVStorageEncrypted<S extends Spec = AnySpec> extends BaseKVStorage<S> {
private readonly cryptoKey: CryptoKey
private readonly onError: (error: Error) => void
/** Mutable state lives here: child nodes share this object (see `child()`). */
private readonly state = { relaying: Promise.resolve() }
constructor(
private readonly inner: BaseKVStorage,
options: KVStorageEncryptedOptions,
) {
super()
this.cryptoKey = options.key
this.onError = options.onError ?? ((error) => console.error(error))
inner.on('change', ({ changes, origin }) => {
if (origin !== 'remote') return
// Decrypted one batch at a time, so changes are re-emitted in the order they arrived.
this.state.relaying = this.state.relaying
.then(async () => {
this.emit('change', { changes: await Promise.all(changes.map(({ key, value }) => this.decryptChange(key, value))), origin: 'remote' })
})
.catch((error: Error) => this.onError(error))
})
inner.on('resync', () => this.emit('resync', undefined))
}
async readMany(keys: string[]): Promise<unknown[]> {
const values = await this.inner.readMany(keys)
return Promise.all(values.map((value, i) => (value === undefined ? undefined : this.decrypt(keys[i]!, value))))
}
async *scan(options: ScanOptions = {}): AsyncGenerator<[string, unknown]> {
for await (const [key, value] of this.inner.scan(options)) yield [key, options.values ? await this.decrypt(key, value) : undefined]
}
/** Passes through to the inner storage, when it supports subscriptions. */
async subscribe(path: string = this.key): Promise<void> {
await (this.inner as Partial<Subscribable>).subscribe?.(path)
}
async unsubscribe(path: string = this.key): Promise<void> {
await (this.inner as Partial<Subscribable>).unsubscribe?.(path)
}
protected async write(changes: KVChange[]): Promise<void> {
await this.inner.writeMany(
await Promise.all(changes.map(async ({ key, value }) => (value === undefined ? { key } : { key, value: await this.encrypt(key, value) }))),
)
}
private async encrypt(key: string, value: unknown): Promise<Uint8Array> {
const iv = crypto.getRandomValues(new Uint8Array(12))
const plaintext = new TextEncoder().encode(ExtendedJson.stringify(value))
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: KVStorageEncrypted.encodeKey(key) }, this.cryptoKey, plaintext),
)
const out = new Uint8Array(1 + iv.length + ciphertext.length)
out[0] = 1
out.set(iv, 1)
out.set(ciphertext, 1 + iv.length)
return out
}
private async decrypt(key: string, value: unknown): Promise<unknown> {
if (!(value instanceof Uint8Array) || value[0] !== 1 || value.length < 13) throw new TypeError(`The value at "${key}" is not encrypted`)
const iv = value.subarray(1, 13)
try {
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: iv as BufferSource, additionalData: KVStorageEncrypted.encodeKey(key) },
this.cryptoKey,
value.subarray(13) as BufferSource,
)
return ExtendedJson.parse(new TextDecoder().decode(plaintext))
} catch (error) {
throw new Error(`Cannot decrypt the value at "${key}" (wrong key, or the data was changed)`, { cause: error })
}
}
private async decryptChange(key: string, value: unknown): Promise<KVChange> {
return value === undefined ? { key } : { key, value: await this.decrypt(key, value) }
}
/** A new random AES-GCM 256 key (extractable, so it can be saved with `exportKey`). */
static generateKey(): Promise<CryptoKey> {
return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'])
}
/** The raw bytes of a key, to store it (e.g. in a password manager or the OS keychain). */
static async exportKey(key: CryptoKey): Promise<Uint8Array> {
return new Uint8Array(await crypto.subtle.exportKey('raw', key))
}
static importKey(raw: Uint8Array): Promise<CryptoKey> {
return crypto.subtle.importKey('raw', raw as BufferSource, 'AES-GCM', true, ['encrypt', 'decrypt'])
}
/**
* A key derived from a password with PBKDF2-SHA-256. Use the same `salt` (random, stored with the
* data, not secret) on every device. Not extractable.
*/
static async deriveKey(password: string, salt: Uint8Array, iterations = 600_000): Promise<CryptoKey> {
const material = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey'])
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', hash: 'SHA-256', salt: salt as BufferSource, iterations },
material,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
)
}
private static encodeKey(key: string): Uint8Array<ArrayBuffer> {
return new TextEncoder().encode(key)
}
}
+25 -1
View File
@@ -1,7 +1,7 @@
import { AuthError } from '../utils/errors.js'
import { ExtendedJson } from '../utils/extended-json.js'
import { KeyRange } from '../utils/key-range.js'
import { RequestAuth, type RequestAuthOptions, type SignatureVerifier } from '../utils/request-auth.js'
import { AUTH_HEADERS, RequestAuth, type RequestAuthOptions, type SignatureVerifier } from '../utils/request-auth.js'
import type { BaseKVStorage, KVChange, ScanOptions } from './base-kv-storage.js'
export interface KVStorageHttpServerOptions {
@@ -15,6 +15,11 @@ export interface KVStorageHttpServerOptions {
basePath?: string
/** Interval of SSE keep-alive comments. Default 15 000 ms. */
keepAliveMs?: number
/**
* Origins allowed to call the server from a browser on another origin (CORS), e.g.
* `['https://app.example.com']`, or `'*'` for any. Default: none (same origin only).
*/
allowOrigins?: string[] | '*'
}
type RequestBody = { keys: string[]; changes: KVChange[]; paths: string[] } & ScanOptions
@@ -39,6 +44,7 @@ export class KVStorageHttpServer {
private readonly auth: RequestAuth | undefined
private readonly basePath: string
private readonly keepAliveMs: number
private readonly allowOrigins: string[] | '*'
private readonly subscribers = new Set<{ ranges: KeyRange[]; send: (frame: string) => void }>()
constructor(
@@ -48,6 +54,7 @@ export class KVStorageHttpServer {
this.auth = options.auth === 'none' ? undefined : new RequestAuth(options.auth.verifiers, options.auth)
this.basePath = options.basePath ?? ''
this.keepAliveMs = options.keepAliveMs ?? 15_000
this.allowOrigins = options.allowOrigins ?? []
storage.on('change', ({ changes }) => {
for (const subscriber of this.subscribers) {
const matching = changes.filter(({ key }) => subscriber.ranges.some((range) => range.contains(key)))
@@ -57,7 +64,24 @@ export class KVStorageHttpServer {
}
async handle(request: Request): Promise<Response> {
const response = await this.route(request)
const origin = request.headers.get('origin')
if (!origin || !(this.allowOrigins === '*' || this.allowOrigins.includes(origin))) return response
response.headers.set('access-control-allow-origin', origin)
response.headers.append('vary', 'origin')
return response
}
private async route(request: Request): Promise<Response> {
const path = new URL(request.url).pathname
if (request.method === 'OPTIONS') {
// CORS preflight (signed requests carry custom headers). `handle` adds the allowed origin.
const headers = ['content-type', 'cache-control', 'last-event-id', ...Object.values(AUTH_HEADERS)].join(', ')
return new Response(null, {
status: 204,
headers: { 'access-control-allow-methods': 'POST', 'access-control-allow-headers': headers, 'access-control-max-age': '600' },
})
}
if (request.method !== 'POST' || !path.startsWith(this.basePath)) return KVStorageHttpServer.error(404, 'Not found')
try {
const text = await request.text()