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
+43 -10
View File
@@ -94,26 +94,58 @@ Node API (defined once): `child`, `as`, `get(key?)`, `getMany`, `set(value)` / `
`handle(Request) → Response`): `POST /<primitive>` with Extended JSON bodies; `scan` streams `handle(Request) → Response`): `POST /<primitive>` with Extended JSON bodies; `scan` streams
NDJSON. Unreachable server (fetch failure, 502/503/504) → `StorageUnavailableError`. The server NDJSON. Unreachable server (fetch failure, 502/503/504) → `StorageUnavailableError`. The server
exposes the whole underlying store (primitives use absolute keys). exposes the whole underlying store (primitives use absolute keys).
- `KVStorageComposite`: see 4.1. - `KVStorageComposite`: see 4.2.
- **Change stream (SSE).** `GET /events` on the server streams every change to the wrapped storage - **Change stream (SSE).** `POST /events` with `{ paths }` streams changes to those keys and their
as `event: change` with id `<epoch>-<seq>` (epoch = server instance). The server keeps the last subtrees as `event: change` with id `<epoch>-<seq>` (epoch = server instance). The server keeps the last
`historySize` (10 000) change batches. `historySize` (10 000) change batches.
- A client reconnecting with `Last-Event-ID` gets the batches it missed, or `event: resync` if it - A client reconnecting with `Last-Event-ID` gets the batches it missed, or `event: resync` if it
can't be resumed (another epoch, or too old). can't be resumed (another epoch, or too old).
- Every catch-up ends with `event: ready`. - Every catch-up ends with `event: ready`.
- Keep-alive comments are sent every 15 s. - Keep-alive comments are sent every 15 s.
- `KVStorageHttp.watch()` subscribes (via the vendored `SSESession`, which reconnects with - `KVStorageHttp.subscribe(path = this.key)` / `unsubscribe(path)` manage the subscribed paths.
backoff), emits `change` events with `origin: 'remote'` and `resync`, and resolves once caught up. Each change reconnects the stream (vendored `SSESession`, backoff) with the new list, resuming
from `lastEventId`. The client emits `change` (`origin: 'remote'`) and `resync`, and `subscribe`
resolves once caught up. A 401/403 rejects `subscribe` instead of retrying.
- Limitation: a server only sees writes made through its own storage instance, so several server - Limitation: a server only sees writes made through its own storage instance, so several server
processes over one database need a shared pub/sub (not built). processes over one database need a shared pub/sub (not built).
- **Wrappers** (e.g. a future encryption layer) extend `BaseKVStorage` and delegate the primitives. - **Wrappers** (e.g. a future encryption layer) extend `BaseKVStorage` and delegate the primitives.
Keys must stay plaintext (or be deterministically encrypted) for prefix scans to work; values can be Keys must stay plaintext (or be deterministically encrypted) for prefix scans to work; values can be
encrypted freely. encrypted freely.
### 4.1 `KVStorageComposite({ source, replicas, retry? })` ### 4.1 Authentication (`utils/request-auth.ts`)
Keeps full local copies of a source (e.g. `KVStorageHttp`) so reads are local and the app works The server requires `auth`: either `{ verifiers, maxSkewMs?, now? }`, or `'none'` (open, for local
offline. Replicas are listed fastest first (e.g. `[inMemory, indexedDb]`). The last replica is the development and tests). It can't be left open by omission.
- **Signed requests.** The client's `RequestSigner` signs `${timestamp}:${path}:${body}` (path =
URL pathname, body = the exact request body; same scheme as Harvmaster/sync-server-example).
Headers: `X-Public-Key` (lowercase hex), `X-Timestamp` (ms), `X-Signature` (hex) and
`X-Signature-Algorithm`. Every request is signed, and so is each SSE (re)connect, with a fresh
timestamp.
- **Verification (401).** The server picks the `SignatureVerifier` registered for the algorithm
(unknown → 401) and checks the timestamp is within `maxSkewMs` (default 60 s). It then rejects a
signature already accepted within that window (replay protection), and verifies the signature
over the received path and raw body. The body is parsed only afterwards.
- **Algorithms are pluggable.** Built in: `Ed25519Signer` / `Ed25519Verifier` (WebCrypto, so no
dependencies; Node 22+ and current browsers; keys persist as PKCS #8). Others, e.g. secp256k1 via
libauth, implement `RequestSigner` (`algorithm`, `publicKey`, `sign`) and `SignatureVerifier`
(`algorithm`, `verify`). The server can accept several algorithms at once.
- **Authorization (403): the public key is the user id.** Every key in `readMany`, `writeMany`,
`removeMany` and `writeIfAbsent`, and every subscription path, must be `<publicKey>` or under
`<publicKey>/`. A scan must stay within `[<publicKey>, <publicKey>0)`, i.e. the key and its
subtree (escaping guarantees no other key falls in that range, including lookalikes such as
`<publicKey>x`).
- **Scoped events.** Each subscriber only receives changes under its own (authorized) paths, live
and when resuming. Event ids are a global sequence, so a client can infer the server's overall
write volume, though nothing about the contents.
- Proxies must preserve the URL path, because the signature covers it.
### 4.2 `KVStorageComposite({ source, prefix?, replicas, retry? })`
Keeps local copies of a source (e.g. `KVStorageHttp`) so reads are local and the app works
offline. With `prefix` (e.g. the user's public key) only that key and its subtree are synced, cached
and subscribed to; keys outside it throw `RangeError`. Without it, the whole source is copied, which
is only appropriate when the client may read all of it. Replicas are listed fastest first (e.g. `[inMemory, indexedDb]`). The last replica is the
persistent one: it seeds the others on start and stores the composite's internal keys under `~q/` persistent one: it seeds the others on start and stores the composite's internal keys under `~q/`
(hidden from reads): `~q/meta` (`{ lastEventId }`, present once a sync has completed) and the (hidden from reads): `~q/meta` (`{ lastEventId }`, present once a sync has completed) and the
outbox `~q/o/<seq:12>`. outbox `~q/o/<seq:12>`.
@@ -222,8 +254,9 @@ Node API: `get(key?)`, `getMany`, `set(doc)` / `set(key, doc)`, `insert(doc)`
## 8. Open questions ## 8. Open questions
- Composite: replicate a subtree instead of the whole store; conflict policies beyond last writer - Composite: lazily syncing parts of a large subtree on first read; conflict policies beyond last
wins for offline writes. writer wins for offline writes.
- Authorization beyond "public key = user id" (sharing a subtree with other keys).
- Multi-process servers need a shared change feed (pub/sub) for SSE. - Multi-process servers need a shared change feed (pub/sub) for SSE.
- Hard deletes from immutable event logs (privacy requests): compaction vs crypto-shredding. The - Hard deletes from immutable event logs (privacy requests): compaction vs crypto-shredding. The
+2
View File
@@ -16,6 +16,8 @@ export * from './utils/document-update.js'
export * from './utils/errors.js' export * from './utils/errors.js'
export * from './utils/event-emitter.js' export * from './utils/event-emitter.js'
export * from './utils/extended-json.js' export * from './utils/extended-json.js'
export * from './utils/hex.js'
export * from './utils/request-auth.js'
export * from './utils/json.js' export * from './utils/json.js'
export * from './utils/keys.js' export * from './utils/keys.js'
export * from './utils/schema.js' export * from './utils/schema.js'
+53 -14
View File
@@ -1,13 +1,20 @@
import { StorageUnavailableError } from '../utils/errors.js' import { StorageUnavailableError } from '../utils/errors.js'
import type { OffCallback } from '../utils/event-emitter.js' import type { OffCallback } from '../utils/event-emitter.js'
import { Json } from '../utils/json.js' import { Json } from '../utils/json.js'
import type { AnySpec, Spec } from '../utils/schema.js' import { Keys } from '../utils/keys.js'
import type { AnySpec, ChildOf, KeyOf, Spec } from '../utils/schema.js'
import { ExponentialBackoff } from '../utils/sse-session/exponential-backoff.js' import { ExponentialBackoff } from '../utils/sse-session/exponential-backoff.js'
import { BaseKVStorage, type KVChange, type KVChangeEvent, type ScanOptions } from './base-kv-storage.js' import { BaseKVStorage, type KVChange, type KVChangeEvent, type ScanOptions } from './base-kv-storage.js'
export interface KVStorageCompositeOptions { export interface KVStorageCompositeOptions {
/** The source of truth (e.g. `KVStorageHttp`). */ /** The source of truth (e.g. `KVStorageHttp`). */
source: BaseKVStorage source: BaseKVStorage
/**
* Only this key and its subtree are synced, cached and subscribed to (e.g. the user's public
* key). Keys outside it are rejected. Default `''`: the whole source (only for sources the
* client may read entirely).
*/
prefix?: string
/** /**
* Local full copies, fastest first (e.g. `[inMemory, indexedDb]`). The last one should be * Local full copies, fastest first (e.g. `[inMemory, indexedDb]`). The last one should be
* persistent: it also stores the offline outbox and sync metadata, and seeds the others on start. * persistent: it also stores the offline outbox and sync metadata, and seeds the others on start.
@@ -19,8 +26,8 @@ export interface KVStorageCompositeOptions {
/** Implemented by sources that can push remote changes (e.g. `KVStorageHttp`). */ /** Implemented by sources that can push remote changes (e.g. `KVStorageHttp`). */
interface WatchableSource { interface WatchableSource {
watch(): Promise<void> subscribe(path: string): Promise<void>
unwatch(): Promise<void> unsubscribe(path: string): Promise<void>
lastEventId: string | undefined lastEventId: string | undefined
} }
@@ -38,16 +45,22 @@ const OUTBOX_PREFIX = '~q/o/'
const OUTBOX_END = '~q/o0' const OUTBOX_END = '~q/o0'
const SYNC_BATCH = 500 const SYNC_BATCH = 500
// Types only: children of a KVStorageComposite are KVStorageComposite nodes too (child() creates them with
// Object.create, so they share this class's prototype).
export interface KVStorageComposite<S extends Spec = AnySpec> {
child<K extends KeyOf<S>>(key: K): KVStorageComposite<ChildOf<S, K>>
}
/** /**
* Keeps local replicas of a source in sync, so reads (including `keys()`, `entries()` and document * Keeps local replicas of a source in sync, so reads (including `keys()`, `entries()` and document
* queries) are served locally, and the app keeps working offline. * queries) are served locally, and the app keeps working offline.
* *
* - **Start** (lazily, on first use): the persistent replica seeds the faster ones. If it holds a * - **Start** (lazily, on first use): the persistent replica seeds the faster ones. If it holds a
* previous sync, reads are served from it straight away (possibly stale until caught up). * previous sync, reads are served from it straight away (possibly stale until caught up).
* - **Staying in sync:** remote changes pushed by the source (`watch()`, e.g. SSE) are applied to * - **Staying in sync:** remote changes pushed by the source (`subscribe(prefix)`, e.g. SSE) are applied to
* every replica. On restart the source resumes from the saved `lastEventId`; a full sync (diffing * every replica. On restart the source resumes from the saved `lastEventId`; a full sync (diffing
* the source against the replicas) only runs the first time, or when the source says it can't * the source against the replicas) only runs the first time, or when the source says it can't
* resume (`resync`). Without `watch()`, a full sync runs on start. * resume (`resync`). Without `subscribe()`, a full sync runs on start.
* - **Writes** go to the source, then the replicas. If the source is unreachable * - **Writes** go to the source, then the replicas. If the source is unreachable
* (`StorageUnavailableError`), they are applied locally and queued in a persisted outbox, then * (`StorageUnavailableError`), they are applied locally and queued in a persisted outbox, then
* replayed in order when it's reachable again (last writer wins on conflict). While any write is * replayed in order when it's reachable again (last writer wins on conflict). While any write is
@@ -59,6 +72,8 @@ const SYNC_BATCH = 500
*/ */
export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<S> { export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<S> {
private readonly source: BaseKVStorage private readonly source: BaseKVStorage
/** Synced key range: `[prefix, prefix + '0')` is the prefix's own key and its subtree. */
private readonly scope: { prefix: string; gte?: string; lt?: string }
private readonly replicas: BaseKVStorage[] private readonly replicas: BaseKVStorage[]
/** Persistent replica: seeds the others and holds the outbox and metadata. */ /** Persistent replica: seeds the others and holds the outbox and metadata. */
private readonly persistent: BaseKVStorage private readonly persistent: BaseKVStorage
@@ -102,6 +117,8 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
super() super()
if (options.replicas.length === 0) throw new TypeError('KVStorageComposite needs at least one replica') if (options.replicas.length === 0) throw new TypeError('KVStorageComposite needs at least one replica')
this.source = options.source this.source = options.source
const prefix = options.prefix ?? ''
this.scope = prefix === '' ? { prefix } : { prefix, gte: prefix, lt: prefix + Keys.afterSeparator }
this.replicas = options.replicas this.replicas = options.replicas
this.persistent = options.replicas.at(-1)! this.persistent = options.replicas.at(-1)!
this.backoff = { baseDelay: 1000, maxDelay: 30_000, ...options.retry } this.backoff = { baseDelay: 1000, maxDelay: 30_000, ...options.retry }
@@ -134,21 +151,27 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
this.state.closed = true this.state.closed = true
clearTimeout(this.state.retryTimer) clearTimeout(this.state.retryTimer)
for (const off of this.offs.splice(0)) off() for (const off of this.offs.splice(0)) off()
if (this.watchable) await (this.source as unknown as WatchableSource).unwatch() if (this.watchable) await (this.source as unknown as WatchableSource).unsubscribe(this.scope.prefix)
} }
async readMany(keys: string[]): Promise<unknown[]> { async readMany(keys: string[]): Promise<unknown[]> {
this.checkScope(keys)
await this.start() await this.start()
return (this.state.hasSnapshot ? this.replicas[0]! : this.source).readMany(keys) return (this.state.hasSnapshot ? this.replicas[0]! : this.source).readMany(keys)
} }
async *scan(options: ScanOptions = {}): AsyncGenerator<[string, unknown]> { async *scan(options: ScanOptions = {}): AsyncGenerator<[string, unknown]> {
await this.start() await this.start()
if (!this.state.hasSnapshot) return yield* this.source.scan(options) // Narrow the range to the synced scope (and, against an authenticated server, to what it allows).
const gte = this.scope.gte !== undefined && (options.gte === undefined || options.gte < this.scope.gte) ? this.scope.gte : options.gte
const lt = this.scope.lt !== undefined && (options.lt === undefined || options.lt > this.scope.lt) ? this.scope.lt : options.lt
if (gte !== undefined && lt !== undefined && gte >= lt) return
const scoped = { ...options, gte, lt }
if (!this.state.hasSnapshot) return yield* this.source.scan(scoped)
// Skip internal keys before applying the limit. // Skip internal keys before applying the limit.
let remaining = options.limit ?? Infinity let remaining = options.limit ?? Infinity
if (remaining <= 0) return if (remaining <= 0) return
for await (const entry of this.replicas[0]!.scan({ ...options, limit: undefined })) { for await (const entry of this.replicas[0]!.scan({ ...scoped, limit: undefined })) {
if (entry[0] >= INTERNAL_PREFIX && entry[0] < INTERNAL_END) continue if (entry[0] >= INTERNAL_PREFIX && entry[0] < INTERNAL_END) continue
yield entry yield entry
if (--remaining <= 0) return if (--remaining <= 0) return
@@ -156,16 +179,19 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
} }
protected async applyWrite(entries: [string, unknown][]): Promise<void> { protected async applyWrite(entries: [string, unknown][]): Promise<void> {
this.checkScope(entries.map(([key]) => key))
await this.start() await this.start()
await this.writeThrough({ type: 'write', entries }) await this.writeThrough({ type: 'write', entries })
} }
protected async applyRemove(keys: string[]): Promise<void> { protected async applyRemove(keys: string[]): Promise<void> {
this.checkScope(keys)
await this.start() await this.start()
await this.writeThrough({ type: 'remove', keys }) await this.writeThrough({ type: 'remove', keys })
} }
protected async applyWriteIfAbsent(entries: [string, unknown][]): Promise<boolean[]> { protected async applyWriteIfAbsent(entries: [string, unknown][]): Promise<boolean[]> {
this.checkScope(entries.map(([key]) => key))
await this.start() await this.start()
const written = await this.source.writeIfAbsent(entries) const written = await this.source.writeIfAbsent(entries)
const changes = entries.filter((_, i) => written[i]).map(([key, value]) => ({ key, value })) const changes = entries.filter((_, i) => written[i]).map(([key, value]) => ({ key, value }))
@@ -176,8 +202,17 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
// ------------------------------------------------------------------ start // ------------------------------------------------------------------ start
private inScope(key: string): boolean {
const { prefix } = this.scope
return prefix === '' || key === prefix || key.startsWith(prefix + Keys.separator)
}
private checkScope(keys: string[]): void {
for (const key of keys) if (!this.inScope(key)) throw new RangeError(`"${key}" is outside this composite's prefix "${this.scope.prefix}"`)
}
private get watchable(): boolean { private get watchable(): boolean {
return typeof (this.source as unknown as Partial<WatchableSource>).watch === 'function' return typeof (this.source as unknown as Partial<WatchableSource>).subscribe === 'function'
} }
private async open(): Promise<void> { private async open(): Promise<void> {
@@ -201,8 +236,9 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
const source = this.source as unknown as WatchableSource const source = this.source as unknown as WatchableSource
source.lastEventId = meta?.lastEventId source.lastEventId = meta?.lastEventId
// Without a resume point, catch up on the stream first so no change after the snapshot is missed. // Without a resume point, catch up on the stream first so no change after the snapshot is missed.
const watching = source.watch() const watching = source.subscribe(this.scope.prefix)
if (!meta?.lastEventId) void watching.then(() => this.enqueueApply(() => this.fullSync())) if (!meta?.lastEventId) void watching.then(() => this.enqueueApply(() => this.fullSync())).catch((error) => console.error(error))
else void watching.catch((error) => console.error(error))
} else { } else {
this.enqueueApply(() => this.fullSync()) this.enqueueApply(() => this.fullSync())
} }
@@ -238,7 +274,9 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
} }
private async applyRemote(event: KVChangeEvent): Promise<void> { private async applyRemote(event: KVChangeEvent): Promise<void> {
const candidates = event.changes.filter(({ key }) => !this.consumeEcho(key) && !this.pending.has(key) && !key.startsWith(INTERNAL_PREFIX)) const candidates = event.changes.filter(
({ key }) => this.inScope(key) && !this.consumeEcho(key) && !this.pending.has(key) && !key.startsWith(INTERNAL_PREFIX),
)
// Skip changes the replicas already reflect. // Skip changes the replicas already reflect.
const current = await this.replicas[0]!.readMany(candidates.map(({ key }) => key)) const current = await this.replicas[0]!.readMany(candidates.map(({ key }) => key))
const changes = candidates.filter(({ value }, i) => !Json.equal(value, current[i])) const changes = candidates.filter(({ value }, i) => !Json.equal(value, current[i]))
@@ -276,8 +314,9 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
/** Changes that turn the persistent replica into the source (both scanned in key order). */ /** Changes that turn the persistent replica into the source (both scanned in key order). */
private async *diff(): AsyncGenerator<KVChange> { private async *diff(): AsyncGenerator<KVChange> {
const remote = this.source.scan({ values: true })[Symbol.asyncIterator]() const range = { gte: this.scope.gte, lt: this.scope.lt, values: true }
const local = this.persistent.scan({ values: true })[Symbol.asyncIterator]() const remote = this.source.scan(range)[Symbol.asyncIterator]()
const local = this.persistent.scan(range)[Symbol.asyncIterator]()
const nextLocal = async () => { const nextLocal = async () => {
for (let next = await local.next(); ; next = await local.next()) if (next.done || !next.value[0].startsWith(INTERNAL_PREFIX)) return next for (let next = await local.next(); ; next = await local.next()) if (next.done || !next.value[0].startsWith(INTERNAL_PREFIX)) return next
} }
+108 -33
View File
@@ -1,7 +1,16 @@
import { AuthError } from '../utils/errors.js'
import { ExtendedJson } from '../utils/extended-json.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' import type { BaseKVStorage, KVChange, ScanOptions } from './base-kv-storage.js'
export interface KVStorageHttpServerOptions { 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 `''`. */ /** Path prefix to strip, e.g. `/kv`. Default `''`. */
basePath?: string basePath?: string
/** Recent change batches kept for clients resuming with `Last-Event-ID`. Default 10 000. */ /** Recent change batches kept for clients resuming with `Last-Event-ID`. Default 10 000. */
@@ -15,32 +24,45 @@ interface HistoryEntry {
changes: KVChange[] 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)` * 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). * - `/readMany`, `/writeMany`, `/removeMany`, `/writeIfAbsent`, `/scan` (streams NDJSON).
* - `GET /events`: Server-Sent Events of every change to the storage. Event ids are * - `/events` with `{ paths }`: Server-Sent Events of changes to those keys and their subtrees.
* `<epoch>-<seq>` (epoch = this server instance). A client sending `Last-Event-ID` gets the * Event ids are `<epoch>-<seq>` (epoch = this server instance). A client sending
* changes it missed if they are still in the history; otherwise it gets a `resync` event. * `Last-Event-ID` gets the matching changes it missed if they are still in the history,
* A client without one gets a `ready` event carrying the current id. * 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 * Only changes made through the wrapped storage instance are seen; several server processes over
* one database each see only their own writes. * one database each see only their own writes.
*/ */
export class KVStorageHttpServer { export class KVStorageHttpServer {
private readonly auth: RequestAuth | undefined
private readonly basePath: string private readonly basePath: string
private readonly historySize: number private readonly historySize: number
private readonly keepAliveMs: number private readonly keepAliveMs: number
private readonly epoch = Math.random().toString(36).slice(2, 10) private readonly epoch = Math.random().toString(36).slice(2, 10)
private seq = 0 private seq = 0
private readonly history: HistoryEntry[] = [] private readonly history: HistoryEntry[] = []
private readonly subscribers = new Set<(frame: string) => void>() private readonly subscribers = new Set<Subscriber>()
constructor( constructor(
private readonly storage: BaseKVStorage, 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.basePath = options.basePath ?? ''
this.historySize = options.historySize ?? 10_000 this.historySize = options.historySize ?? 10_000
this.keepAliveMs = options.keepAliveMs ?? 15_000 this.keepAliveMs = options.keepAliveMs ?? 15_000
@@ -49,58 +71,99 @@ export class KVStorageHttpServer {
async handle(request: Request): Promise<Response> { async handle(request: Request): Promise<Response> {
const path = new URL(request.url).pathname const path = new URL(request.url).pathname
if (!path.startsWith(this.basePath)) return KVStorageHttpServer.error(404, 'Not found') if (request.method !== 'POST' || !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')
try { try {
const body = ExtendedJson.parse(await request.text()) as { keys: string[]; entries: [string, unknown][] } & ScanOptions const text = await request.text()
switch (route) { const owner = this.auth ? await this.auth.verify(request.headers, path, text) : undefined
case 'POST /readMany': { 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 values = await this.storage.readMany(body.keys)
const missing = values.flatMap((v, i) => (v === undefined ? [i] : [])) const missing = values.flatMap((v, i) => (v === undefined ? [i] : []))
return KVStorageHttpServer.json({ values: values.map((v) => v ?? null), missing }) 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) await this.storage.writeMany(body.entries)
return new Response(null, { status: 204 }) return new Response(null, { status: 204 })
case 'POST /removeMany': case '/removeMany':
this.authorize(owner, body.keys)
await this.storage.removeMany(body.keys) await this.storage.removeMany(body.keys)
return new Response(null, { status: 204 }) 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)) return KVStorageHttpServer.json(await this.storage.writeIfAbsent(body.entries))
case 'POST /scan': case '/scan':
this.authorizeRange(owner, body)
return this.scan(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: default:
return KVStorageHttpServer.error(404, 'Not found') return KVStorageHttpServer.error(404, 'Not found')
} }
} catch (error) { } catch (error) {
if (error instanceof AuthError) return KVStorageHttpServer.error(error.status, error.message)
return KVStorageHttpServer.error(500, error instanceof Error ? error.message : String(error)) 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 { private publish(changes: KVChange[]): void {
const entry = { seq: ++this.seq, changes } const entry = { seq: ++this.seq, changes }
this.history.push(entry) this.history.push(entry)
if (this.history.length > this.historySize) this.history.splice(0, this.history.length - this.historySize) 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 subscriber of this.subscribers) {
for (const send of this.subscribers) send(frame) 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() const encoder = new TextEncoder()
let send: ((frame: string) => void) | undefined let subscriber: Subscriber | undefined
let keepAlive: ReturnType<typeof setInterval> | undefined let keepAlive: ReturnType<typeof setInterval> | undefined
const stream = new ReadableStream<Uint8Array>({ const stream = new ReadableStream<Uint8Array>({
start: (controller) => { start: (controller) => {
send = (frame) => controller.enqueue(encoder.encode(frame)) subscriber = { paths, send: (frame) => controller.enqueue(encoder.encode(frame)) }
send(this.catchUpFrames(lastEventId)) subscriber.send(this.catchUpFrames(paths, lastEventId))
this.subscribers.add(send) this.subscribers.add(subscriber)
keepAlive = setInterval(() => send!(': keep-alive\n\n'), this.keepAliveMs) keepAlive = setInterval(() => subscriber!.send(': keep-alive\n\n'), this.keepAliveMs)
;(keepAlive as { unref?: () => void }).unref?.() ;(keepAlive as { unref?: () => void }).unref?.()
}, },
cancel: () => { cancel: () => {
if (send) this.subscribers.delete(send) if (subscriber) this.subscribers.delete(subscriber)
clearInterval(keepAlive) 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 * What a (re)connecting client gets before live changes: the matching changes it missed, or
* they can't be replayed; then always `ready` (the client is caught up as of that id). * `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) const ready = this.frame('ready', this.seq, null)
if (lastEventId === null) return ready if (lastEventId === null) return ready
const [epoch, seqText] = lastEventId.split('-') const [epoch, seqText] = lastEventId.split('-')
@@ -119,14 +182,26 @@ export class KVStorageHttpServer {
const oldest = this.history[0]?.seq ?? this.seq + 1 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. // 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 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 (
return missed.join('') + ready 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 { private frame(event: string, seq: number, data: unknown): string {
return `id: ${this.epoch}-${seq}\nevent: ${event}\ndata: ${ExtendedJson.stringify(data)}\n\n` return `id: ${this.epoch}-${seq}\nevent: ${event}\ndata: ${ExtendedJson.stringify(data)}\n\n`
} }
// ------------------------------------------------------------------ scan
private scan(options: ScanOptions): Response { private scan(options: ScanOptions): Response {
const iterator = this.storage.scan(options)[Symbol.asyncIterator]() const iterator = this.storage.scan(options)[Symbol.asyncIterator]()
const encoder = new TextEncoder() const encoder = new TextEncoder()
+128 -43
View File
@@ -1,39 +1,58 @@
import { StorageUnavailableError } from '../utils/errors.js' import { AuthError, StorageUnavailableError } from '../utils/errors.js'
import { ExtendedJson } from '../utils/extended-json.js' import { ExtendedJson } from '../utils/extended-json.js'
import type { AnySpec, Spec } from '../utils/schema.js' import { RequestAuth, type RequestSigner } from '../utils/request-auth.js'
import type { AnySpec, ChildOf, KeyOf, Spec } from '../utils/schema.js'
import { ExponentialBackoff } from '../utils/sse-session/exponential-backoff.js'
import { SSESession } from '../utils/sse-session/sse-session.js' import { SSESession } from '../utils/sse-session/sse-session.js'
import { BaseKVStorage, type KVChange, type ScanOptions } from './base-kv-storage.js' import { BaseKVStorage, type KVChange, type ScanOptions } from './base-kv-storage.js'
export interface KVStorageHttpOptions { export interface KVStorageHttpOptions {
/** Base URL of a `KVStorageHttpServer`, e.g. `https://host/kv`. */ /** Base URL of a `KVStorageHttpServer`, e.g. `https://host/kv`. */
url: string url: string
/** Signs every request (required by servers with authentication). */
signer?: RequestSigner
headers?: Record<string, string> headers?: Record<string, string>
fetch?: (url: string, init: RequestInit) => Promise<Response> fetch?: (url: string, init: RequestInit) => Promise<Response>
/** Called when the change stream fails (it keeps reconnecting). Default: ignore. */ /** Called when the change stream fails (it keeps reconnecting). Default: ignore. */
onWatchError?: (error: Error) => void onSubscriptionError?: (error: Error) => void
}
// Types only: children of a KVStorageHttp are KVStorageHttp nodes too (child() creates them with
// Object.create, so they share this class's prototype).
export interface KVStorageHttp<S extends Spec = AnySpec> {
child<K extends KeyOf<S>>(key: K): KVStorageHttp<ChildOf<S, K>>
} }
/** /**
* KV storage client for a `KVStorageHttpServer`. Same API as any other KV storage. * KV storage client for a `KVStorageHttpServer`. Same API as any other KV storage.
* *
* `watch()` subscribes to the server's change stream (SSE): changes made by anyone are emitted * `subscribe(path)` receives changes under a key (pushed over SSE) as `change` events with
* here as `change` events with `origin: 'remote'`, and `resync` is emitted when the server can't * `origin: 'remote'`; `resync` is emitted when the server can't resume from `lastEventId`. With a
* resume from `lastEventId`. Values travel as Extended JSON (`bigint` and `Uint8Array` survive). * `signer`, every request is signed, and an authenticated server only allows keys under the
* signer's public key. Values travel as Extended JSON (`bigint` and `Uint8Array` survive).
*/ */
export class KVStorageHttp<S extends Spec = AnySpec> extends BaseKVStorage<S> { export class KVStorageHttp<S extends Spec = AnySpec> extends BaseKVStorage<S> {
private readonly url: string private readonly url: string
private readonly signer: RequestSigner | undefined
private readonly headers: Record<string, string> private readonly headers: Record<string, string>
private readonly fetcher: (url: string, init: RequestInit) => Promise<Response> private readonly fetcher: (url: string, init: RequestInit) => Promise<Response>
private readonly onWatchError: (error: Error) => void private readonly onSubscriptionError: (error: Error) => void
/** Mutable state lives here: child nodes share this object (see `child()`). */ /** Mutable state lives here: child nodes share this object (see `child()`). */
private readonly state: { session?: SSESession; lastEventId?: string } = {} private readonly state: {
paths: Set<string>
session?: SSESession
lastEventId?: string
/** Subscription changes are applied one at a time. */
updating: Promise<void>
} = { paths: new Set(), updating: Promise.resolve() }
constructor(options: KVStorageHttpOptions) { constructor(options: KVStorageHttpOptions) {
super() super()
this.url = options.url.replace(/\/$/, '') this.url = options.url.replace(/\/$/, '')
this.signer = options.signer
this.headers = options.headers ?? {} this.headers = options.headers ?? {}
this.fetcher = options.fetch ?? ((url, init) => globalThis.fetch(url, init)) this.fetcher = options.fetch ?? ((url, init) => globalThis.fetch(url, init))
this.onWatchError = options.onWatchError ?? (() => {}) this.onSubscriptionError = options.onSubscriptionError ?? (() => {})
} }
/** Position in the server's change stream; sent as `Last-Event-ID` when (re)connecting. */ /** Position in the server's change stream; sent as `Last-Event-ID` when (re)connecting. */
@@ -45,6 +64,11 @@ export class KVStorageHttp<S extends Spec = AnySpec> extends BaseKVStorage<S> {
this.state.lastEventId = id this.state.lastEventId = id
} }
/** Keys currently subscribed to. */
get subscriptions(): ReadonlySet<string> {
return this.state.paths
}
async readMany(keys: string[]): Promise<unknown[]> { async readMany(keys: string[]): Promise<unknown[]> {
if (keys.length === 0) return [] if (keys.length === 0) return []
const { values, missing } = (await this.post('readMany', { keys })) as { values: unknown[]; missing: number[] } const { values, missing } = (await this.post('readMany', { keys })) as { values: unknown[]; missing: number[] }
@@ -75,39 +99,22 @@ export class KVStorageHttp<S extends Spec = AnySpec> extends BaseKVStorage<S> {
} }
/** /**
* Subscribes to the server's change stream. Resolves once connected and caught up (missed * Receives changes to `path` (default: this node's key) and everything under it. Resolves once
* changes replayed, or `resync` emitted); keeps reconnecting and resuming from `lastEventId` * the stream is connected and caught up (missed changes replayed, or `resync` emitted). While
* until `unwatch()`. While the server is unreachable this stays pending, so don't await it on a * the server is unreachable this stays pending, so don't await it on a path that must work offline.
* path that must work offline.
*/ */
async watch(): Promise<void> { subscribe(path: string = this.key): Promise<void> {
if (this.state.session) return return this.updateSubscriptions(() => this.state.paths.add(path))
const session = new SSESession(`${this.url}/events`, {
fetch: this.fetcher,
headers: this.headers,
persistent: true,
onError: this.onWatchError,
onRequest: async (request) =>
this.lastEventId ? { ...request, headers: { ...(request.headers as Record<string, string>), 'Last-Event-ID': this.lastEventId } } : request,
})
session.on('message', (message) => {
if (message.id) this.lastEventId = message.id
if (message.event === 'change') {
this.emit('change', { changes: ExtendedJson.parse(message.data) as KVChange[], origin: 'remote', id: message.id })
} else if (message.event === 'resync') {
this.emit('resync', { id: message.id })
}
})
this.state.session = session
const caughtUp = session.waitFor('message', (message) => message.event === 'ready')
await session.connect()
await caughtUp
} }
async unwatch(): Promise<void> { /** Stops receiving changes to `path` (default: this node's key). */
const session = this.state.session unsubscribe(path: string = this.key): Promise<void> {
this.state.session = undefined return this.updateSubscriptions(() => this.state.paths.delete(path))
await session?.disconnect() }
/** Drops every subscription and closes the stream. */
close(): Promise<void> {
return this.updateSubscriptions(() => this.state.paths.clear())
} }
protected async applyWrite(entries: [string, unknown][]): Promise<void> { protected async applyWrite(entries: [string, unknown][]): Promise<void> {
@@ -122,24 +129,102 @@ export class KVStorageHttp<S extends Spec = AnySpec> extends BaseKVStorage<S> {
return (await this.post('writeIfAbsent', { entries })) as boolean[] return (await this.post('writeIfAbsent', { entries })) as boolean[]
} }
// ------------------------------------------------------------------ subscriptions
/** Applies a subscription change, then reconnects the stream with the new path list. */
private updateSubscriptions(change: () => unknown): Promise<void> {
const run = async () => {
const before = JSON.stringify([...this.state.paths])
change()
if (JSON.stringify([...this.state.paths]) === before && (this.state.session || this.state.paths.size === 0)) return
await this.state.session?.disconnect()
this.state.session = undefined
if (this.state.paths.size > 0) await this.connect([...this.state.paths])
}
const result = this.state.updating.then(run)
this.state.updating = result.catch(() => {})
return result
}
private async connect(paths: string[]): Promise<void> {
const url = `${this.url}/events`
const body = ExtendedJson.stringify({ paths })
const session = new SSESession(url, {
method: 'POST',
body,
fetch: this.fetcher,
headers: { ...this.headers, 'content-type': 'application/json' },
persistent: true,
onError: this.onSubscriptionError,
retry: KVStorageHttp.retryUnlessDenied(),
// Signed on every (re)connect, so the timestamp is always fresh.
onRequest: async (request) => ({
...request,
headers: {
...(request.headers as Record<string, string>),
...(this.state.lastEventId ? { 'Last-Event-ID': this.state.lastEventId } : {}),
...(await this.authHeaders(url, body)),
},
}),
})
session.on('message', (message) => {
if (message.id) this.state.lastEventId = message.id
if (message.event === 'change') {
this.emit('change', { changes: ExtendedJson.parse(message.data) as KVChange[], origin: 'remote', id: message.id })
} else if (message.event === 'resync') {
this.emit('resync', { id: message.id })
}
})
this.state.session = session
const caughtUp = session.waitFor('message', (message) => message.event === 'ready')
try {
await session.connect()
} catch (error) {
this.state.session = undefined
throw (error as Error).cause instanceof AuthError ? (error as Error).cause : error
}
await caughtUp
}
// ------------------------------------------------------------------ requests
private async post(path: string, body: unknown): Promise<unknown> { private async post(path: string, body: unknown): Promise<unknown> {
const response = await this.request(path, body) const response = await this.request(path, body)
return response.status === 204 ? undefined : ExtendedJson.parse(await response.text()) return response.status === 204 ? undefined : ExtendedJson.parse(await response.text())
} }
private async request(path: string, body: unknown): Promise<Response> { private async request(path: string, payload: unknown): Promise<Response> {
const url = `${this.url}/${path}`
const body = ExtendedJson.stringify(payload)
let response: Response let response: Response
try { try {
response = await this.fetcher(`${this.url}/${path}`, { response = await this.fetcher(url, {
method: 'POST', method: 'POST',
headers: { ...this.headers, 'content-type': 'application/json' }, headers: { ...this.headers, 'content-type': 'application/json', ...(await this.authHeaders(url, body)) },
body: ExtendedJson.stringify(body), body,
}) })
} catch (error) { } catch (error) {
throw new StorageUnavailableError(`Cannot reach ${this.url}`, { cause: error }) throw new StorageUnavailableError(`Cannot reach ${this.url}`, { cause: error })
} }
if ([502, 503, 504].includes(response.status)) throw new StorageUnavailableError(`HTTP ${response.status} from ${this.url}`) if ([502, 503, 504].includes(response.status)) throw new StorageUnavailableError(`HTTP ${response.status} from ${this.url}`)
if (response.status === 401 || response.status === 403) throw new AuthError(response.status, await response.text())
if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`) if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`)
return response return response
} }
/** Retries connecting with backoff, except when the server denies access (401/403): that won't fix itself. */
private static retryUnlessDenied(): { run<T>(fn: () => Promise<T>): Promise<T> } {
const backoff = new ExponentialBackoff({ baseDelay: 1000, maxDelay: 10_000, maxAttempts: 0, growthRate: 1.3, jitter: 0.3 })
return {
run: (fn) =>
backoff.run(fn, (error, context) => {
const status = /^HTTP error: (40[13])/.exec(error.message)?.[1]
if (status) context.abort(new AuthError(Number(status) as 401 | 403, error.message))
}),
}
}
private async authHeaders(url: string, body: string): Promise<Record<string, string>> {
return this.signer ? RequestAuth.headers(this.signer, new URL(url).pathname, body) : {}
}
} }
+11
View File
@@ -25,3 +25,14 @@ export class StorageUnavailableError extends Error {
this.name = 'StorageUnavailableError' this.name = 'StorageUnavailableError'
} }
} }
/** A request was not authenticated (401) or not allowed for this caller (403). */
export class AuthError extends Error {
constructor(
readonly status: 401 | 403,
message: string,
) {
super(message)
this.name = 'AuthError'
}
}
+4 -14
View File
@@ -1,6 +1,8 @@
// Adapted from xo-cash-utils `source/extended-json.ts` (branch installable-sse), with local hex // Adapted from xo-cash-utils `source/extended-json.ts` (branch installable-sse), with local hex
// encoding instead of @bitauth/libauth. // encoding instead of @bitauth/libauth.
import { Hex } from './hex.js'
/** Matches a bigint encoded as `<bigint: 123n>`. */ /** Matches a bigint encoded as `<bigint: 123n>`. */
const BIGINT_PATTERN = /^<bigint: (?<bigint>[+-]?[0-9]+)n>$/u const BIGINT_PATTERN = /^<bigint: (?<bigint>[+-]?[0-9]+)n>$/u
@@ -22,7 +24,7 @@ export class ExtendedJson {
/** `JSON.stringify` replacer. */ /** `JSON.stringify` replacer. */
static replacer(this: void, _key: string, value: unknown): unknown { static replacer(this: void, _key: string, value: unknown): unknown {
if (value instanceof Uint8Array) return `<uint8array: ${ExtendedJson.toHex(value)}>` if (value instanceof Uint8Array) return `<uint8array: ${Hex.encode(value)}>`
if (typeof value === 'bigint') return `<bigint: ${value.toString()}n>` if (typeof value === 'bigint') return `<bigint: ${value.toString()}n>`
return value return value
} }
@@ -33,19 +35,7 @@ export class ExtendedJson {
const bigint = value.match(BIGINT_PATTERN) const bigint = value.match(BIGINT_PATTERN)
if (bigint) return BigInt(bigint.groups!.bigint!) if (bigint) return BigInt(bigint.groups!.bigint!)
const bytes = value.match(UINT8ARRAY_PATTERN) const bytes = value.match(UINT8ARRAY_PATTERN)
if (bytes) return ExtendedJson.fromHex(bytes.groups!.hex!) if (bytes) return Hex.decode(bytes.groups!.hex!)
return value return value
} }
private static toHex(bytes: Uint8Array): string {
let hex = ''
for (const byte of bytes) hex += byte.toString(16).padStart(2, '0')
return hex
}
private static fromHex(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
return bytes
}
} }
+16
View File
@@ -0,0 +1,16 @@
/** Lowercase hex encoding of bytes. */
export class Hex {
static encode(bytes: Uint8Array): string {
let hex = ''
for (const byte of bytes) hex += byte.toString(16).padStart(2, '0')
return hex
}
/** Throws on odd length or non-hex characters. */
static decode(hex: string): Uint8Array {
if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(hex)) throw new TypeError('Invalid hex string')
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
return bytes
}
}
+150
View File
@@ -0,0 +1,150 @@
import { AuthError } from './errors.js'
import { Hex } from './hex.js'
/** Signs requests. Implement this to use another algorithm (e.g. secp256k1 via libauth). */
export interface RequestSigner {
/** Algorithm name sent in `X-Signature-Algorithm`; the server must have a matching verifier. */
readonly algorithm: string
/** Public key as lowercase hex. Also the owner segment of the caller's keys (`/<publicKey>/...`). */
readonly publicKey: string
sign(payload: Uint8Array): Promise<Uint8Array>
}
/** Verifies signatures for one algorithm. */
export interface SignatureVerifier {
readonly algorithm: string
verify(publicKey: string, payload: Uint8Array, signature: Uint8Array): Promise<boolean>
}
export const AUTH_HEADERS = {
publicKey: 'X-Public-Key',
timestamp: 'X-Timestamp',
signature: 'X-Signature',
algorithm: 'X-Signature-Algorithm',
} as const
export interface RequestAuthOptions {
/** Accepted difference between the request timestamp and the server clock. Default 60 000 ms. */
maxSkewMs?: number
now?: () => number
}
/**
* Signed-request authentication. The client signs `${timestamp}:${path}:${body}` (path = URL
* pathname, body = the exact request body) and sends the public key, timestamp, signature (hex)
* and algorithm as headers. The server checks the timestamp is recent, verifies the signature
* with the verifier for that algorithm, and rejects a signature it has already seen within the
* timestamp window (replay protection).
*/
export class RequestAuth {
private readonly verifiers: Map<string, SignatureVerifier>
private readonly maxSkewMs: number
private readonly now: () => number
/** Signatures accepted within the window → when they expire; insertion order ≈ expiry order. */
private readonly seen = new Map<string, number>()
constructor(verifiers: SignatureVerifier[], options: RequestAuthOptions = {}) {
if (verifiers.length === 0) throw new TypeError('RequestAuth needs at least one verifier')
this.verifiers = new Map(verifiers.map((v) => [v.algorithm, v]))
this.maxSkewMs = options.maxSkewMs ?? 60_000
this.now = options.now ?? Date.now
}
static payload(timestamp: string, path: string, body: string): Uint8Array {
return new TextEncoder().encode(`${timestamp}:${path}:${body}`)
}
/** Auth headers for a request to `path` with `body`. */
static async headers(signer: RequestSigner, path: string, body: string, now: number = Date.now()): Promise<Record<string, string>> {
const timestamp = String(now)
const signature = await signer.sign(RequestAuth.payload(timestamp, path, body))
return {
[AUTH_HEADERS.publicKey]: signer.publicKey,
[AUTH_HEADERS.timestamp]: timestamp,
[AUTH_HEADERS.signature]: Hex.encode(signature),
[AUTH_HEADERS.algorithm]: signer.algorithm,
}
}
/** Returns the caller's public key, or throws `AuthError` (401). */
async verify(headers: Headers, path: string, body: string): Promise<string> {
const publicKey = headers.get(AUTH_HEADERS.publicKey)
const timestamp = headers.get(AUTH_HEADERS.timestamp)
const signature = headers.get(AUTH_HEADERS.signature)
const algorithm = headers.get(AUTH_HEADERS.algorithm)
if (!publicKey || !timestamp || !signature || !algorithm) throw new AuthError(401, 'Missing authentication headers')
const verifier = this.verifiers.get(algorithm)
if (!verifier) throw new AuthError(401, `Unsupported signature algorithm "${algorithm}"`)
if (!/^[0-9a-f]+$/.test(publicKey)) throw new AuthError(401, 'Public key must be lowercase hex')
const now = this.now()
const time = Number(timestamp)
if (!Number.isInteger(time) || Math.abs(now - time) > this.maxSkewMs) throw new AuthError(401, 'Timestamp outside the accepted window')
this.forgetExpired(now)
if (this.seen.has(signature)) throw new AuthError(401, 'Replayed request')
let valid = false
try {
valid = await verifier.verify(publicKey, RequestAuth.payload(timestamp, path, body), Hex.decode(signature))
} catch {
valid = false
}
if (!valid) throw new AuthError(401, 'Invalid signature')
this.seen.set(signature, time + this.maxSkewMs)
return publicKey
}
private forgetExpired(now: number): void {
for (const [signature, expires] of this.seen) {
if (expires >= now) break
this.seen.delete(signature)
}
}
}
/** Ed25519 signer using WebCrypto (Node 22+ and current browsers; no dependencies). */
export class Ed25519Signer implements RequestSigner {
readonly algorithm = 'ed25519'
private constructor(
private readonly privateKey: CryptoKey,
readonly publicKey: string,
) {}
/** A new random key pair. */
static async generate(): Promise<Ed25519Signer> {
const pair = (await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as CryptoKeyPair
return new Ed25519Signer(pair.privateKey, Hex.encode(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey))))
}
/** Restores a key exported with `exportPrivateKey()` (PKCS #8). */
static async fromPrivateKey(pkcs8: Uint8Array): Promise<Ed25519Signer> {
const privateKey = await crypto.subtle.importKey('pkcs8', pkcs8 as BufferSource, { name: 'Ed25519' }, true, ['sign'])
// WebCrypto can't derive the public key directly; the JWK export of a private key includes it (`x`).
const jwk = await crypto.subtle.exportKey('jwk', privateKey)
const publicKey = Uint8Array.from(atob(jwk.x!.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0))
return new Ed25519Signer(privateKey, Hex.encode(publicKey))
}
async exportPrivateKey(): Promise<Uint8Array> {
return new Uint8Array(await crypto.subtle.exportKey('pkcs8', this.privateKey))
}
async sign(payload: Uint8Array): Promise<Uint8Array> {
return new Uint8Array(await crypto.subtle.sign({ name: 'Ed25519' }, this.privateKey, payload as BufferSource))
}
}
/** Ed25519 verifier using WebCrypto. */
export class Ed25519Verifier implements SignatureVerifier {
readonly algorithm = 'ed25519'
private readonly keys = new Map<string, Promise<CryptoKey>>()
async verify(publicKey: string, payload: Uint8Array, signature: Uint8Array): Promise<boolean> {
let key = this.keys.get(publicKey)
if (!key) {
key = crypto.subtle.importKey('raw', Hex.decode(publicKey) as BufferSource, { name: 'Ed25519' }, false, ['verify'])
this.keys.set(publicKey, key)
if (this.keys.size > 10_000) this.keys.delete(this.keys.keys().next().value!)
}
return crypto.subtle.verify({ name: 'Ed25519' }, await key, signature as BufferSource, payload as BufferSource)
}
}
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { KVChangeEvent } from '../../src/kv-storage/base-kv-storage.js'
import { KVStorageComposite } from '../../src/kv-storage/kv-storage-composite.js'
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 { TestNetwork } from './sync-helpers.js'
let network: TestNetwork
let alice: Ed25519Signer
let bob: Ed25519Signer
beforeEach(async () => {
network = new TestNetwork({ auth: { verifiers: [new Ed25519Verifier()] } })
alice = await Ed25519Signer.generate()
bob = await Ed25519Signer.generate()
await network.store.writeMany([
[`${alice.publicKey}/profile`, { name: 'Alice' }],
[`${bob.publicKey}/profile`, { name: 'Bob' }],
[`${bob.publicKey}/posts/1`, { title: 'secret' }],
])
})
afterEach(() => network.close())
describe('authenticated KVStorageHttpServer', () => {
it('lets a user read, write and list their own keys', async () => {
const mine = network.client(alice).child(alice.publicKey)
expect(await mine.get('profile')).toEqual({ name: 'Alice' })
await mine.child('posts').set('1', { title: 'hello' })
expect(await collect(mine.keys())).toEqual(['posts', 'profile'])
expect(await collect(mine.child('posts').entries())).toEqual([['1', { title: 'hello' }]])
})
it("forbids reading, writing or scanning another user's keys", async () => {
const client = network.client(alice)
const theirs = client.child(bob.publicKey)
await expect(theirs.get('profile')).rejects.toMatchObject({ status: 403 })
await expect(theirs.set('profile', { name: 'hacked' })).rejects.toBeInstanceOf(AuthError)
await expect(theirs.delete('profile')).rejects.toMatchObject({ status: 403 })
await expect(collect(theirs.keys())).rejects.toMatchObject({ status: 403 })
await expect(collect(client.keys())).rejects.toMatchObject({ status: 403 }) // root listing
await expect(client.readMany([alice.publicKey + 'x/a'])).rejects.toMatchObject({ status: 403 }) // lookalike prefix
expect(await network.store.readMany([`${bob.publicKey}/profile`])).toEqual([{ name: 'Bob' }])
})
it('rejects unsigned requests', async () => {
await expect(network.client().child(alice.publicKey).get('profile')).rejects.toMatchObject({ status: 401 })
})
it('delivers only subscribed, authorized changes', async () => {
const watcher = network.client(alice)
const received: KVChangeEvent[] = []
watcher.on('change', (e) => e.origin === 'remote' && received.push(e))
await watcher.child(alice.publicKey).child('posts').subscribe()
const writer = network.client(alice).child(alice.publicKey)
await network.client(bob).child(bob.publicKey).child('posts').set('2', { title: 'bob only' })
await writer.set('profile', { name: 'Alice 2' }) // not subscribed
const arrived = watcher.waitFor('change', () => true)
await writer.child('posts').set('1', { title: 'mine' })
await arrived
expect(received.flatMap((e) => e.changes)).toEqual([{ key: `${alice.publicKey}/posts/1`, value: { title: 'mine' } }])
})
it("refuses subscriptions to another user's keys without retrying forever", async () => {
await expect(network.client(alice).child(bob.publicKey).subscribe()).rejects.toMatchObject({ status: 403 })
await expect(network.client(alice).subscribe('')).rejects.toMatchObject({ status: 403 })
})
it('replays only matching changes when resuming', async () => {
const watcher = network.client(alice)
const mine = watcher.child(alice.publicKey)
await mine.subscribe()
await mine.unsubscribe()
await network.client(bob).child(bob.publicKey).set('x', 1)
await network.client(alice).child(alice.publicKey).set('y', 2)
const replayed: KVChangeEvent[] = []
watcher.on('change', (e) => replayed.push(e))
await mine.subscribe()
expect(replayed.flatMap((e) => e.changes)).toEqual([{ key: `${alice.publicKey}/y`, value: 2 }])
})
it('a composite scoped to the user syncs only their data', async () => {
const db = new KVStorageComposite({ source: network.client(alice), prefix: alice.publicKey, replicas: [new KVStorageInMemory()] })
await db.synced()
expect(await collect(db.scan({ values: true }))).toEqual([[`${alice.publicKey}/profile`, { name: 'Alice' }]])
expect(await collect(db.keys())).toEqual([alice.publicKey])
await expect(db.child(bob.publicKey).get('profile')).rejects.toBeInstanceOf(RangeError)
const pushed = db.waitFor('change', (e) => e.origin === 'remote')
await network.client(bob).child(bob.publicKey).set('ignored', 1)
await network.client(alice).child(alice.publicKey).set('fromElsewhere', 1)
expect((await pushed).changes).toEqual([{ key: `${alice.publicKey}/fromElsewhere`, value: 1 }])
await db.close()
})
})
@@ -6,11 +6,11 @@ import { TestNetwork } from './sync-helpers.js'
let network: TestNetwork let network: TestNetwork
afterEach(() => network?.close()) afterEach(() => network?.close())
describe('KVStorageHttp.watch (SSE)', () => { describe('KVStorageHttp.subscribe (SSE)', () => {
it('receives changes made by other clients as remote change events', async () => { it('receives changes made by other clients as remote change events', async () => {
network = new TestNetwork() network = new TestNetwork()
const watcher = network.client() const watcher = network.client()
await watcher.watch() await watcher.subscribe('')
const received = watcher.waitFor('change', (e) => e.origin === 'remote') const received = watcher.waitFor('change', (e) => e.origin === 'remote')
await network.client().child('users').set('1', { name: 'Ada', n: 1n }) await network.client().child('users').set('1', { name: 'Ada', n: 1n })
const event = await received const event = await received
@@ -23,16 +23,16 @@ describe('KVStorageHttp.watch (SSE)', () => {
network = new TestNetwork() network = new TestNetwork()
const writer = network.client() const writer = network.client()
const watcher = network.client() const watcher = network.client()
await watcher.watch() await watcher.subscribe('')
const first = watcher.waitFor('change', (e) => e.origin === 'remote') const first = watcher.waitFor('change', (e) => e.origin === 'remote')
await writer.writeMany([['a', 1]]) await writer.writeMany([['a', 1]])
await first await first
await watcher.unwatch() await watcher.unsubscribe('')
await writer.writeMany([['b', 2]]) await writer.writeMany([['b', 2]])
await writer.writeMany([['c', 3]]) await writer.writeMany([['c', 3]])
const replayed: KVChangeEvent[] = [] const replayed: KVChangeEvent[] = []
watcher.on('change', (e) => replayed.push(e)) watcher.on('change', (e) => replayed.push(e))
await watcher.watch() // resolves once caught up, so the missed changes have been emitted await watcher.subscribe('') // resolves once caught up, so the missed changes have been emitted
expect(replayed.map((e) => e.changes)).toEqual([[{ key: 'b', value: 2 }], [{ key: 'c', value: 3 }]]) expect(replayed.map((e) => e.changes)).toEqual([[{ key: 'b', value: 2 }], [{ key: 'c', value: 3 }]])
}) })
@@ -40,15 +40,15 @@ describe('KVStorageHttp.watch (SSE)', () => {
network = new TestNetwork({ historySize: 1 }) network = new TestNetwork({ historySize: 1 })
const writer = network.client() const writer = network.client()
const watcher = network.client() const watcher = network.client()
await watcher.watch() await watcher.subscribe('')
const seen = watcher.waitFor('change', () => true) const seen = watcher.waitFor('change', () => true)
await writer.writeMany([['a', 1]]) await writer.writeMany([['a', 1]])
await seen await seen
await watcher.unwatch() await watcher.unsubscribe('')
await writer.writeMany([['b', 2]]) await writer.writeMany([['b', 2]])
await writer.writeMany([['c', 3]]) await writer.writeMany([['c', 3]])
const resync = watcher.waitFor('resync', () => true) const resync = watcher.waitFor('resync', () => true)
await watcher.watch() await watcher.subscribe('')
expect(await resync).toEqual({ id: expect.stringMatching(/-3$/) }) expect(await resync).toEqual({ id: expect.stringMatching(/-3$/) })
}) })
+3 -3
View File
@@ -9,7 +9,7 @@ import { runKVStorageConformance } from './kv-storage.conformance.js'
import { serve } from './node-server.js' import { serve } from './node-server.js'
runKVStorageConformance('KVStorageHttp (in-process)', () => { runKVStorageConformance('KVStorageHttp (in-process)', () => {
const server = new KVStorageHttpServer(new KVStorageInMemory(), { basePath: '/kv' }) const server = new KVStorageHttpServer(new KVStorageInMemory(), { auth: 'none', basePath: '/kv' })
return new KVStorageHttp({ url: 'http://test/kv', fetch: async (input, init) => server.handle(new Request(input, init)) }) return new KVStorageHttp({ url: 'http://test/kv', fetch: async (input, init) => server.handle(new Request(input, init)) })
}) })
@@ -22,7 +22,7 @@ describe('over a real socket', () => {
}) })
afterAll(() => server.close()) afterAll(() => server.close())
const fresh = () => { const fresh = () => {
stores.push(new KVStorageHttpServer(new KVStorageInMemory(), { basePath: `/${stores.length}` })) stores.push(new KVStorageHttpServer(new KVStorageInMemory(), { auth: 'none', basePath: `/${stores.length}` }))
return new KVStorageHttp({ url: `${server.url}/${stores.length - 1}` }) return new KVStorageHttp({ url: `${server.url}/${stores.length - 1}` })
} }
@@ -47,7 +47,7 @@ describe('over a real socket', () => {
describe('server', () => { describe('server', () => {
it('maps errors to 404 / 500', async () => { it('maps errors to 404 / 500', async () => {
const server = new KVStorageHttpServer(new KVStorageInMemory()) const server = new KVStorageHttpServer(new KVStorageInMemory(), { auth: 'none' })
expect((await server.handle(new Request('http://x/nope', { method: 'POST', body: '{}' }))).status).toBe(404) 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) expect((await server.handle(new Request('http://x/readMany', { method: 'POST', body: '{"keys": 5}' }))).status).toBe(500)
}) })
+6 -4
View File
@@ -1,6 +1,7 @@
import { KVStorageHttp } from '../../src/kv-storage/kv-storage-http.js' import { KVStorageHttp } from '../../src/kv-storage/kv-storage-http.js'
import { KVStorageHttpServer, type KVStorageHttpServerOptions } from '../../src/kv-storage/kv-storage-http-server.js' import { KVStorageHttpServer, type KVStorageHttpServerOptions } from '../../src/kv-storage/kv-storage-http-server.js'
import { KVStorageInMemory } from '../../src/kv-storage/kv-storage-in-memory.js' import { KVStorageInMemory } from '../../src/kv-storage/kv-storage-in-memory.js'
import type { RequestSigner } from '../../src/utils/request-auth.js'
import { KVStorageCounting } from '../helpers.js' import { KVStorageCounting } from '../helpers.js'
/** /**
@@ -13,13 +14,14 @@ export class TestNetwork {
readonly server: KVStorageHttpServer readonly server: KVStorageHttpServer
private readonly clients: KVStorageHttp[] = [] private readonly clients: KVStorageHttp[] = []
constructor(options: KVStorageHttpServerOptions = {}) { constructor(options: Partial<KVStorageHttpServerOptions> = {}) {
this.server = new KVStorageHttpServer(this.store, options) this.server = new KVStorageHttpServer(this.store, { auth: 'none', ...options })
} }
client(): KVStorageHttp { client(signer?: RequestSigner): KVStorageHttp {
const client = new KVStorageHttp({ const client = new KVStorageHttp({
url: 'http://server', url: 'http://server',
signer,
fetch: async (url, init) => { fetch: async (url, init) => {
if (!this.online) throw new TypeError('fetch failed') if (!this.online) throw new TypeError('fetch failed')
return this.server.handle(new Request(url, init)) return this.server.handle(new Request(url, init))
@@ -30,6 +32,6 @@ export class TestNetwork {
} }
async close(): Promise<void> { async close(): Promise<void> {
await Promise.all(this.clients.map((c) => c.unwatch())) await Promise.all(this.clients.map((c) => c.close()))
} }
} }
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import { AUTH_HEADERS, Ed25519Signer, Ed25519Verifier, RequestAuth, type RequestSigner, type SignatureVerifier } from '../../src/utils/request-auth.js'
import { AuthError } from '../../src/utils/errors.js'
const NOW = 1_700_000_000_000
async function signed(signer: RequestSigner, path = '/kv/readMany', body = '{"keys":["a"]}', at = NOW) {
return new Headers(await RequestAuth.headers(signer, path, body, at))
}
describe('RequestAuth', () => {
it('accepts a correctly signed request and returns the public key', async () => {
const signer = await Ed25519Signer.generate()
const auth = new RequestAuth([new Ed25519Verifier()], { now: () => NOW })
const headers = await signed(signer)
expect(headers.get(AUTH_HEADERS.algorithm)).toBe('ed25519')
expect(signer.publicKey).toMatch(/^[0-9a-f]{64}$/)
expect(await auth.verify(headers, '/kv/readMany', '{"keys":["a"]}')).toBe(signer.publicKey)
})
it('rejects tampered bodies, paths and timestamps', async () => {
const signer = await Ed25519Signer.generate()
const auth = new RequestAuth([new Ed25519Verifier()], { now: () => NOW })
await expect(auth.verify(await signed(signer), '/kv/readMany', '{"keys":["b"]}')).rejects.toThrow(/Invalid signature/)
await expect(auth.verify(await signed(signer), '/kv/writeMany', '{"keys":["a"]}')).rejects.toThrow(/Invalid signature/)
const headers = await signed(signer)
headers.set(AUTH_HEADERS.timestamp, String(NOW + 1))
await expect(auth.verify(headers, '/kv/readMany', '{"keys":["a"]}')).rejects.toThrow(/Invalid signature/)
const other = await Ed25519Signer.generate()
const swapped = await signed(signer)
swapped.set(AUTH_HEADERS.publicKey, other.publicKey)
await expect(auth.verify(swapped, '/kv/readMany', '{"keys":["a"]}')).rejects.toThrow(/Invalid signature/)
})
it('rejects old or future timestamps, replays, missing headers and unknown algorithms', async () => {
const signer = await Ed25519Signer.generate()
const auth = new RequestAuth([new Ed25519Verifier()], { now: () => NOW, maxSkewMs: 1000 })
await expect(auth.verify(await signed(signer, '/p', 'b', NOW - 5000), '/p', 'b')).rejects.toThrow(/window/)
await expect(auth.verify(await signed(signer, '/p', 'b', NOW + 5000), '/p', 'b')).rejects.toThrow(/window/)
const once = await signed(signer, '/p', 'b')
await auth.verify(once, '/p', 'b')
await expect(auth.verify(once, '/p', 'b')).rejects.toThrow(/Replayed/)
await expect(auth.verify(new Headers(), '/p', 'b')).rejects.toBeInstanceOf(AuthError)
const unknown = await signed(signer, '/p', 'c')
unknown.set(AUTH_HEADERS.algorithm, 'rot13')
await expect(auth.verify(unknown, '/p', 'c')).rejects.toThrow(/Unsupported signature algorithm "rot13"/)
})
it('restores an Ed25519 key from its exported private key', async () => {
const signer = await Ed25519Signer.generate()
const restored = await Ed25519Signer.fromPrivateKey(await signer.exportPrivateKey())
expect(restored.publicKey).toBe(signer.publicKey)
const auth = new RequestAuth([new Ed25519Verifier()], { now: () => NOW })
expect(await auth.verify(await signed(restored), '/kv/readMany', '{"keys":["a"]}')).toBe(signer.publicKey)
})
it('supports other algorithms through the verifier registry', async () => {
// A toy algorithm (signature = sha256(publicKey + payload)) standing in for e.g. secp256k1 via libauth.
const digest = async (publicKey: string, payload: Uint8Array) =>
new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array([...new TextEncoder().encode(publicKey), ...payload])))
const toySigner: RequestSigner = { algorithm: 'toy', publicKey: 'abcd', sign: (payload) => digest('abcd', payload) }
const toyVerifier: SignatureVerifier = {
algorithm: 'toy',
verify: async (publicKey, payload, signature) => (await digest(publicKey, payload)).every((byte, i) => byte === signature[i]),
}
const auth = new RequestAuth([new Ed25519Verifier(), toyVerifier], { now: () => NOW })
expect(await auth.verify(await signed(toySigner), '/kv/readMany', '{"keys":["a"]}')).toBe('abcd')
})
})