Authenticated, scoped sync: signed requests and per-path subscriptions
- 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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user