Authenticated, scoped sync: signed requests and per-path subscriptions
CI / Node 24 (pull_request) Successful in 12s
CI / Node 22 (pull_request) Successful in 13s
CI / Node 26 (pull_request) Successful in 13s

- RequestAuth: clients sign `${timestamp}:${path}:${body}` (as in
  sync-server-example); headers carry public key, timestamp, signature and
  algorithm. Pluggable signers/verifiers; Ed25519 via WebCrypto built in.
  Timestamp window and replay protection.
- KVStorageHttpServer requires auth ('none' must be explicit). With auth, every
  key, scan range and subscription path must be under the caller's public key.
- POST /events with { paths }: subscribers only receive (and on resume only
  replay) changes under their authorized paths.
- KVStorageHttp: signer option; subscribe(path)/unsubscribe(path)/close()
  replace watch(); 401/403 reject instead of retrying.
- KVStorageComposite: prefix option scopes sync, cache and subscription.

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 10:01:06 +00:00
parent df7de27e20
commit a8c9539ad9
14 changed files with 696 additions and 129 deletions
+108 -33
View File
@@ -1,7 +1,16 @@
import { AuthError } from '../utils/errors.js'
import { ExtendedJson } from '../utils/extended-json.js'
import { Keys } from '../utils/keys.js'
import { RequestAuth, type RequestAuthOptions, type SignatureVerifier } from '../utils/request-auth.js'
import type { BaseKVStorage, KVChange, ScanOptions } from './base-kv-storage.js'
export interface KVStorageHttpServerOptions {
/**
* `{ verifiers }`: requests must be signed (see `RequestAuth`), and a caller may only touch keys
* under its own public key (`<publicKey>` and `<publicKey>/...`).
* `'none'`: no authentication, everything is open; for local development and tests only.
*/
auth: 'none' | ({ verifiers: SignatureVerifier[] } & RequestAuthOptions)
/** Path prefix to strip, e.g. `/kv`. Default `''`. */
basePath?: string
/** Recent change batches kept for clients resuming with `Last-Event-ID`. Default 10 000. */
@@ -15,32 +24,45 @@ interface HistoryEntry {
changes: KVChange[]
}
interface Subscriber {
/** Keys whose own value and subtree this subscriber receives (`''` = everything). */
paths: string[]
send: (frame: string) => void
}
type RequestBody = { keys: string[]; entries: [string, unknown][]; paths: string[] } & ScanOptions
/**
* Serves any KV storage over HTTP for `KVStorageHttp`. Framework-agnostic: `handle(Request)`
* returns a `Response`.
* returns a `Response`. Every route is `POST` with an Extended JSON body:
*
* - `POST /<primitive>` with an Extended JSON body (`scan` streams NDJSON).
* - `GET /events`: Server-Sent Events of every change to the storage. Event ids are
* `<epoch>-<seq>` (epoch = this server instance). A client sending `Last-Event-ID` gets the
* changes it missed if they are still in the history; otherwise it gets a `resync` event.
* A client without one gets a `ready` event carrying the current id.
* - `/readMany`, `/writeMany`, `/removeMany`, `/writeIfAbsent`, `/scan` (streams NDJSON).
* - `/events` with `{ paths }`: Server-Sent Events of changes to those keys and their subtrees.
* Event ids are `<epoch>-<seq>` (epoch = this server instance). A client sending
* `Last-Event-ID` gets the matching changes it missed if they are still in the history,
* otherwise a `resync` event; either way a `ready` event ends the catch-up.
*
* With authentication, each request is checked first (401), then every key, scan range and
* subscription path must be the caller's public key or under it (403).
*
* Only changes made through the wrapped storage instance are seen; several server processes over
* one database each see only their own writes.
*/
export class KVStorageHttpServer {
private readonly auth: RequestAuth | undefined
private readonly basePath: string
private readonly historySize: number
private readonly keepAliveMs: number
private readonly epoch = Math.random().toString(36).slice(2, 10)
private seq = 0
private readonly history: HistoryEntry[] = []
private readonly subscribers = new Set<(frame: string) => void>()
private readonly subscribers = new Set<Subscriber>()
constructor(
private readonly storage: BaseKVStorage,
options: KVStorageHttpServerOptions = {},
options: KVStorageHttpServerOptions,
) {
this.auth = options.auth === 'none' ? undefined : new RequestAuth(options.auth.verifiers, options.auth)
this.basePath = options.basePath ?? ''
this.historySize = options.historySize ?? 10_000
this.keepAliveMs = options.keepAliveMs ?? 15_000
@@ -49,58 +71,99 @@ export class KVStorageHttpServer {
async handle(request: Request): Promise<Response> {
const path = new URL(request.url).pathname
if (!path.startsWith(this.basePath)) return KVStorageHttpServer.error(404, 'Not found')
const route = `${request.method} ${path.slice(this.basePath.length)}`
if (route === 'GET /events') return this.events(request.headers.get('last-event-id'))
if (request.method !== 'POST') return KVStorageHttpServer.error(404, 'Not found')
if (request.method !== 'POST' || !path.startsWith(this.basePath)) return KVStorageHttpServer.error(404, 'Not found')
try {
const body = ExtendedJson.parse(await request.text()) as { keys: string[]; entries: [string, unknown][] } & ScanOptions
switch (route) {
case 'POST /readMany': {
const text = await request.text()
const owner = this.auth ? await this.auth.verify(request.headers, path, text) : undefined
const body = ExtendedJson.parse(text) as RequestBody
switch (path.slice(this.basePath.length)) {
case '/readMany': {
this.authorize(owner, body.keys)
const values = await this.storage.readMany(body.keys)
const missing = values.flatMap((v, i) => (v === undefined ? [i] : []))
return KVStorageHttpServer.json({ values: values.map((v) => v ?? null), missing })
}
case 'POST /writeMany':
case '/writeMany':
this.authorize(
owner,
body.entries.map(([key]) => key),
)
await this.storage.writeMany(body.entries)
return new Response(null, { status: 204 })
case 'POST /removeMany':
case '/removeMany':
this.authorize(owner, body.keys)
await this.storage.removeMany(body.keys)
return new Response(null, { status: 204 })
case 'POST /writeIfAbsent':
case '/writeIfAbsent':
this.authorize(
owner,
body.entries.map(([key]) => key),
)
return KVStorageHttpServer.json(await this.storage.writeIfAbsent(body.entries))
case 'POST /scan':
case '/scan':
this.authorizeRange(owner, body)
return this.scan(body)
case '/events':
if (!Array.isArray(body.paths) || body.paths.length === 0) return KVStorageHttpServer.error(400, 'Subscribe to at least one path')
this.authorize(owner, body.paths)
return this.events(body.paths, request.headers.get('last-event-id'))
default:
return KVStorageHttpServer.error(404, 'Not found')
}
} catch (error) {
if (error instanceof AuthError) return KVStorageHttpServer.error(error.status, error.message)
return KVStorageHttpServer.error(500, error instanceof Error ? error.message : String(error))
}
}
// ------------------------------------------------------------------ authorization
/** Every key must be the owner's key or under it (no-op without authentication). */
private authorize(owner: string | undefined, keys: string[]): void {
if (owner === undefined) return
for (const key of keys) {
if (typeof key !== 'string' || !KVStorageHttpServer.within(owner, key)) throw new AuthError(403, `No access to "${key}"`)
}
}
/** A scan must stay inside `[owner, owner + '0')`: the owner's key and its subtree. */
private authorizeRange(owner: string | undefined, options: ScanOptions): void {
if (owner === undefined) return
const { gte, lt } = options
if (gte === undefined || lt === undefined || gte < owner || lt > owner + Keys.afterSeparator) throw new AuthError(403, 'Scan outside your keys')
}
/** Whether `key` is `path` or below it (`''` covers everything). */
private static within(path: string, key: string): boolean {
return path === '' || key === path || key.startsWith(path + Keys.separator)
}
// ------------------------------------------------------------------ change stream
private publish(changes: KVChange[]): void {
const entry = { seq: ++this.seq, changes }
this.history.push(entry)
if (this.history.length > this.historySize) this.history.splice(0, this.history.length - this.historySize)
const frame = this.frame('change', entry.seq, changes)
for (const send of this.subscribers) send(frame)
for (const subscriber of this.subscribers) {
const frame = this.changeFrame(entry, subscriber.paths)
if (frame) subscriber.send(frame)
}
}
private events(lastEventId: string | null): Response {
private events(paths: string[], lastEventId: string | null): Response {
const encoder = new TextEncoder()
let send: ((frame: string) => void) | undefined
let subscriber: Subscriber | undefined
let keepAlive: ReturnType<typeof setInterval> | undefined
const stream = new ReadableStream<Uint8Array>({
start: (controller) => {
send = (frame) => controller.enqueue(encoder.encode(frame))
send(this.catchUpFrames(lastEventId))
this.subscribers.add(send)
keepAlive = setInterval(() => send!(': keep-alive\n\n'), this.keepAliveMs)
subscriber = { paths, send: (frame) => controller.enqueue(encoder.encode(frame)) }
subscriber.send(this.catchUpFrames(paths, lastEventId))
this.subscribers.add(subscriber)
keepAlive = setInterval(() => subscriber!.send(': keep-alive\n\n'), this.keepAliveMs)
;(keepAlive as { unref?: () => void }).unref?.()
},
cancel: () => {
if (send) this.subscribers.delete(send)
if (subscriber) this.subscribers.delete(subscriber)
clearInterval(keepAlive)
},
})
@@ -108,10 +171,10 @@ export class KVStorageHttpServer {
}
/**
* What a (re)connecting client gets before live changes: the changes it missed, or `resync` when
* they can't be replayed; then always `ready` (the client is caught up as of that id).
* What a (re)connecting client gets before live changes: the matching changes it missed, or
* `resync` when they can't be replayed; then always `ready` (caught up as of that id).
*/
private catchUpFrames(lastEventId: string | null): string {
private catchUpFrames(paths: string[], lastEventId: string | null): string {
const ready = this.frame('ready', this.seq, null)
if (lastEventId === null) return ready
const [epoch, seqText] = lastEventId.split('-')
@@ -119,14 +182,26 @@ export class KVStorageHttpServer {
const oldest = this.history[0]?.seq ?? this.seq + 1
// Resumable only if it's our epoch and nothing after `seq` has been dropped from the history.
if (epoch !== this.epoch || !Number.isInteger(seq) || seq > this.seq || seq < oldest - 1) return this.frame('resync', this.seq, null) + ready
const missed = this.history.filter((entry) => entry.seq > seq).map((entry) => this.frame('change', entry.seq, entry.changes))
return missed.join('') + ready
return (
this.history
.filter((entry) => entry.seq > seq)
.map((entry) => this.changeFrame(entry, paths))
.join('') + ready
)
}
/** A `change` frame with only the changes under `paths`, or `''` if none match. */
private changeFrame(entry: HistoryEntry, paths: string[]): string {
const changes = entry.changes.filter(({ key }) => paths.some((path) => KVStorageHttpServer.within(path, key)))
return changes.length ? this.frame('change', entry.seq, changes) : ''
}
private frame(event: string, seq: number, data: unknown): string {
return `id: ${this.epoch}-${seq}\nevent: ${event}\ndata: ${ExtendedJson.stringify(data)}\n\n`
}
// ------------------------------------------------------------------ scan
private scan(options: ScanOptions): Response {
const iterator = this.storage.scan(options)[Symbol.asyncIterator]()
const encoder = new TextEncoder()