import { request as httpRequest } from 'node:http'; import { afterAll, beforeAll, describe, expect, test } from 'vitest'; import { BrowserKVStore } from '../src/kv/browser.js'; import type { DeviceSafeKVStore, KVStore, LocalKVStore } from '../src/kv/types.js'; import { KVScopeViolationError } from '../src/kv/types.js'; import { kvPersistStorage } from '../src/zustand/persist.js'; import { HttpAccountKV, HttpKVStore, HttpKVStoreError } from '../src/kv/http.js'; import { runKVStoreContract } from './kv-contract.js'; import { MemoryStorage } from './setup.js'; import { startKvConformanceServer, type KvConformanceServer } from './kv-conformance-server.js'; let server: KvConformanceServer; let namespace = 0; /** * Send a raw HTTP request, so a body can ride on a bodyless method — something * `fetch` refuses to construct but a real HTTP client (or an attacker) can put * on the wire, and exactly the shape the server must defend against. */ function rawRequest( baseUrl: string, options: { method: string; path: string; headers: Record; body?: string }, ): Promise<{ status: number; json: unknown }> { const url = new URL(baseUrl); // An explicit Content-Length (not chunked framing) so the server flushes its // full response body back on a bodyless-method rejection. const headers = options.body === undefined ? options.headers : { ...options.headers, 'content-length': String(Buffer.byteLength(options.body)) }; return new Promise((resolve, reject) => { const req = httpRequest( { hostname: url.hostname, port: url.port, method: options.method, path: options.path, headers, }, (res) => { const chunks: Buffer[] = []; res.on('data', (chunk: Buffer) => chunks.push(chunk)); res.on('end', () => { const text = Buffer.concat(chunks).toString('utf8'); resolve({ status: res.statusCode ?? 0, json: text === '' ? undefined : (JSON.parse(text) as unknown), }); }); }, ); req.on('error', reject); if (options.body !== undefined) req.write(options.body); req.end(); }); } /** * Bind a real loopback conformance server, run `fn` against it, and always close * it — skipping the test where the sandbox forbids binding a listener. Dedups the * bind / EPERM-skip / close lifecycle the network-backed tests all repeat. */ async function withNetworkServer( skip: (reason: string) => void, fn: (server: KvConformanceServer) => Promise, ): Promise { let server: KvConformanceServer; try { server = await startKvConformanceServer(); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'EPERM') { skip('sandbox does not permit binding a 127.0.0.1 listener'); return; } throw error; } try { await fn(server); } finally { await server.close(); } } function makeStore(storageNamespace = `kv-${namespace++}`): HttpKVStore { return new HttpKVStore({ baseUrl: server.baseUrl, fetch: server.fetch, headers: () => ({ 'x-storage-namespace': storageNamespace }), deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }), }); } beforeAll(async () => { server = await startKvConformanceServer({ listen: false }); }); afterAll(async () => { await server.close(); }); runKVStoreContract('HTTP (account) + local (device)', () => makeStore()); describe('HttpKVStore device-scope invariant', () => { test('a device scope is a type error at a direct call on the transport', () => { const account = new HttpAccountKV({ baseUrl: 'https://kv.invalid', fetch: async () => { throw new Error('fetch must not be called'); }, }); // Never invoked — these exist to be type-checked. The scope parameter is // declared as `'account'` precisely so a literal `'device'` cannot be // written here; if that narrowing is ever widened the directives go unused // and `tsc` fails the build, so the guard cannot rot silently. It only // covers *direct* calls, which is why the runtime refusal below exists too. const deviceCalls = (): unknown[] => [ // @ts-expect-error this transport serves the account scope only. account.set('theme', 'dark', 'device'), // @ts-expect-error this transport serves the account scope only. account.get('theme', 'device'), // @ts-expect-error this transport serves the account scope only. account.remove('theme', 'device'), // @ts-expect-error this transport serves the account scope only. account.keys('ui:', 'device'), // An options-shaped scope is no more expressible than a positional one: // the parameter is `'account'`, not an object with a scope in it. // @ts-expect-error there is no options form that carries a scope. account.set('theme', 'dark', { scope: 'device' }), ]; expect(typeof deviceCalls).toBe('function'); }); test('an HttpKVStore cannot be built without somewhere local to keep device values', () => { const withoutDeviceStore = (): HttpKVStore => // @ts-expect-error `deviceStore` is required: there is no default, and no // configuration in which device values fall through to the server. new HttpKVStore({ baseUrl: 'https://kv.invalid' }); expect(typeof withoutDeviceStore).toBe('function'); }); // The transport's missing scope parameter only protects the *positional* // call. What actually keeps device values off the wire is that a networked // store cannot be the deviceStore at all — and that has to be checked, because // `HttpAccountKV` satisfies `KVStore` structurally (its methods are the same // methods minus an optional parameter), so before the `LocalKVStore` brand it // could be injected as the device backend with zero type errors. test('a networked store cannot be injected as the device backend', () => { const remote = new HttpAccountKV({ baseUrl: 'https://kv.invalid', fetch: async () => new Response(null, { status: 204 }), }); const injectTransport = (): HttpKVStore => new HttpKVStore({ baseUrl: 'https://kv.invalid', // @ts-expect-error a remote transport is not a LocalKVStore. deviceStore: remote, }); // Assignability to the *bare* `KVStore` is what made this reachable, so the // probe goes through a `KVStore` annotation as well. const asPlainKvStore: KVStore = remote; const injectViaKVStore = (): HttpKVStore => new HttpKVStore({ baseUrl: 'https://kv.invalid', // @ts-expect-error `KVStore` is not `LocalKVStore` either. deviceStore: asPlainKvStore, }); // And the cast that would erase both type errors is refused at runtime. expect(injectTransport).toThrow(/must be a local KVStore/); expect(injectViaKVStore).toThrow(/must be a local KVStore/); expect( () => new HttpKVStore({ baseUrl: 'https://kv.invalid', deviceStore: remote as unknown as LocalKVStore, }), ).toThrow(/must be a local KVStore/); }); test('an HttpKVStore cannot be nested as its own device backend', () => { const outer = makeStore(); expect( () => new HttpKVStore({ baseUrl: 'https://kv.invalid', fetch: async () => new Response(null, { status: 204 }), deviceStore: outer as unknown as LocalKVStore, }), ).toThrow(/must be a local KVStore/); }); // The transport is publicly exported and structurally a `KVStore`, so its // missing scope parameter was never "inexpressible" — the extra argument was // accepted by the language and dropped on the floor. Both sequences below // compiled without a cast and put a device value on the wire. test('the transport refuses a device scope handed to it through a KVStore reference', async () => { const sent: string[] = []; const account = new HttpAccountKV({ baseUrl: 'https://kv.invalid', fetch: async (input, init) => { sent.push(`${String(input)} ${String(init?.body ?? '')}`); return new Response(null, { status: 204 }); }, }); // Assignable, because TypeScript compares method parameters bivariantly. const asKvStore: KVStore = account; await expect(asKvStore.set('theme', 'DEVICE-ONLY', 'device')).rejects.toBeInstanceOf( KVScopeViolationError, ); await expect(asKvStore.get('theme', 'device')).rejects.toThrow(/account scope only/); await expect(asKvStore.remove('theme', 'device')).rejects.toThrow(/account scope only/); await expect(asKvStore.keys('ui:', 'device')).rejects.toThrow(/account scope only/); expect(sent).toEqual([]); }); test('the transport still serves an explicitly account-scoped call', async () => { const paths: string[] = []; const account = new HttpAccountKV({ baseUrl: 'https://kv.invalid', fetch: async (input) => { // The raw request string the client built — not `new URL(...).pathname`, // which normalizes the `..` away and hides what the client emitted. paths.push(String(input).replace('https://kv.invalid', '')); return new Response(null, { status: 204 }); }, }); await account.set('k', 'v', 'account'); await account.remove('k', 'account'); expect(paths).toEqual(['/kv/entries/k', '/kv/entries/k']); }); test('the persist adapter refuses to pair a device scope with a pure account transport', () => { const sent: string[] = []; const account = new HttpAccountKV({ baseUrl: 'https://kv.invalid', fetch: async (input, init) => { sent.push(`${String(input)} ${String(init?.body ?? '')}`); return new Response(null, { status: 204 }); }, }); // The documented persist wiring, and the shortest path to a device value on // the wire: the adapter takes the store and the scope as separate arguments, // so nothing else was checking that they belong together. HttpAccountKV has // no local device backend, so a device value handed to it goes to the server // — it must stay rejected even though the device-safe composite is now allowed. const pairing = (): unknown => // @ts-expect-error the `device` overload requires a DeviceSafeKVStore. kvPersistStorage(account, 'device'); expect(pairing).toThrow(/servesDeviceScopeLocally/); // And the cast that erases the type error is refused at runtime. expect(() => kvPersistStorage(account as unknown as DeviceSafeKVStore, 'device')).toThrow( KVScopeViolationError, ); expect(sent).toEqual([]); }); // cosarah's finding: a full HttpKVStore routes `device` to its required local // backend and never puts it on the wire, so it is safe to persist device-scoped // state through — the earlier guard wrongly conflated "fully local" with "safe // for device" and rejected this legitimate composite case. test('the persist adapter accepts a device-routing composite for the device scope', async () => { const deviceBacking = new BrowserKVStore({ storage: new MemoryStorage() }); let fetchCalls = 0; const composite = new HttpKVStore({ baseUrl: 'https://kv.invalid', fetch: async () => { fetchCalls += 1; return new Response(null, { status: 204 }); }, deviceStore: deviceBacking, }); const deviceStorage = kvPersistStorage<{ theme: string }>(composite, 'device'); await deviceStorage.setItem('settings-storage', { state: { theme: 'dark' } }); expect(await deviceStorage.getItem('settings-storage')).toEqual({ state: { theme: 'dark' } }); await deviceStorage.removeItem('settings-storage'); expect(await deviceStorage.getItem('settings-storage')).toBeNull(); // The value reached the injected device backend directly... await deviceStorage.setItem('settings-storage', { state: { theme: 'light' } }); expect(await deviceBacking.get('settings-storage', 'device')).toEqual({ state: { theme: 'light' }, }); // ...and it is device-scoped there, not account-scoped. expect(await deviceBacking.get('settings-storage', 'account')).toBeNull(); // ...and no HTTP request was ever made. expect(fetchCalls).toBe(0); }); test('the persist adapter still accepts the legitimate pairings', async () => { const local = new BrowserKVStore({ storage: new MemoryStorage() }); const deviceStorage = kvPersistStorage<{ theme: string }>(local, 'device'); await deviceStorage.setItem('settings-storage', { state: { theme: 'dark' } }); expect(await deviceStorage.getItem('settings-storage')).toEqual({ state: { theme: 'dark' } }); // A remote store with the account scope is exactly what the adapter is for. expect(() => kvPersistStorage(makeStore(), 'account')).not.toThrow(); expect(() => kvPersistStorage(makeStore())).not.toThrow(); }); test('a hand-rolled store lying about being local is the only way through', async () => { // Documented, not endorsed: the brand stops accidents and casts, not an // author who writes the lie out. The point of the assertion is that the lie // has to be *authored* — nothing in the package hands it to you. const sent: string[] = []; const liar = { isLocalKVStore: true as const, servesDeviceScopeLocally: true as const, get: async () => null, set: async (key: string, value: unknown) => { sent.push(`${key}=${JSON.stringify(value)}`); }, remove: async () => undefined, keys: async () => [], } satisfies LocalKVStore; const store = new HttpKVStore({ baseUrl: 'https://kv.invalid', fetch: async () => { throw new Error('fetch must not be called'); }, deviceStore: liar, }); await store.set('theme', 'dark', 'device'); expect(sent).toEqual(['theme="dark"']); }); test('device reads and writes issue no request at all', async () => { const requests: string[] = []; const store = new HttpKVStore({ baseUrl: 'https://kv.invalid', fetch: async (input, init) => { requests.push(`${init?.method ?? 'GET'} ${String(input)} ${String(init?.body ?? '')}`); return new Response(null, { status: 204 }); }, deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }), }); await store.set('theme', { mode: 'dark' }, 'device'); await store.set('layout', 'wide', 'device'); expect(await store.get('theme', 'device')).toEqual({ mode: 'dark' }); expect(await store.keys('', 'device')).toEqual(['theme', 'layout']); await store.remove('theme', 'device'); expect(await store.get('theme', 'device')).toBeNull(); expect(requests).toEqual([]); }); test('a device value never appears in traffic, even alongside account traffic', async () => { const traffic: string[] = []; const store = new HttpKVStore({ baseUrl: 'https://kv.invalid', fetch: async (input, init) => { traffic.push( [ init?.method ?? 'GET', String(input), JSON.stringify(init?.headers ?? {}), String(init?.body ?? ''), ].join(' '), ); const body = String(input).includes('/kv/keys') ? [] : { value: null }; return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' }, }); }, deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }), }); const secret = 'device-only-secret-value'; await store.set('shared-key', secret, 'device'); await store.set('shared-key', 'account-value', 'account'); await store.get('shared-key'); await store.keys(); await store.remove('shared-key'); expect(traffic.length).toBeGreaterThan(0); for (const line of traffic) { expect(line).not.toContain(secret); expect(line).not.toContain('device'); } }); test('the account contract exposes no scope on the wire', async () => { const paths: string[] = []; const account = new HttpAccountKV({ baseUrl: 'https://kv.invalid', fetch: async (input) => { paths.push(new URL(String(input)).pathname + new URL(String(input)).search); return new Response(null, { status: 204 }); }, }); await account.set('k', 'v'); await account.remove('k'); expect(paths).toEqual(['/kv/entries/k', '/kv/entries/k']); }); }); // Coverage matrix — every operation × every channel a scope could hide in must // be closed. A gap here is not academic: the reason this matrix exists is that // the bodyless GET routes once ignored their body, so `GET /kv/keys` with a // `{"scope":"device"}` body returned 200 [] — one uncovered cell shipping the // exact silent-discard the contract forbids. const SCOPE_OPERATIONS = [ { name: 'get', method: 'GET', entryPath: true, writesValue: false }, { name: 'set', method: 'PUT', entryPath: true, writesValue: true }, { name: 'remove', method: 'DELETE', entryPath: true, writesValue: false }, { name: 'keys', method: 'GET', entryPath: false, writesValue: false }, ] as const; // Every header spelling that would carry a scope. Enumerated in the test so a // gap in the server's set (which once had only `x-scope`) shows up as a failing // cell rather than a silently-accepted scope. const SCOPE_HEADER_SPELLINGS = ['scope', 'x-scope', 'kv-scope', 'x-kv-scope'] as const; describe('scope-rejection matrix: {get,set,remove,keys} × {path,query,header spellings}', () => { // Every non-body channel a scope could hide in, run in-process against the // injected server. The body channel needs a client that can put a body on a // bodyless method, which `fetch` refuses to construct — that cell is covered by // the raw-request matrix below. const channels = ['path', 'query', ...SCOPE_HEADER_SPELLINGS] as const; const cells = SCOPE_OPERATIONS.flatMap((op) => channels.map((channel) => [`${op.name} via ${channel}`, op, channel] as const), ); test.each(cells)('rejects a device scope on %s', async (_title, op, channel) => { const ns = `scope-matrix-${namespace++}`; const entryTail = op.entryPath ? '/entries/mk' : '/keys'; // The scope-path-segment cell injects a scope as the segment after `kv` // (`/kv/device/…`); the others keep the contract path and hide the scope in // the query or a header. let path = channel === 'path' ? `${server.baseUrl}/kv/device${entryTail}` : `${server.baseUrl}/kv${entryTail}`; const headers: Record = { 'x-storage-namespace': ns }; if (channel === 'query') path += '?scope=device'; if ((SCOPE_HEADER_SPELLINGS as readonly string[]).includes(channel)) headers[channel] = 'device'; const init: RequestInit = { method: op.method, headers }; if (op.writesValue) { headers['content-type'] = 'application/json'; init.body = JSON.stringify({ value: 1 }); } const response = await server.fetch(path, init); expect(response.status, `${op.name} via ${channel}`).toBe(400); await expect(response.json()).resolves.toMatchObject({ error: { code: 'VALIDATION_FAILED', message: expect.stringContaining('scope') }, }); }); }); describe('scope-rejection matrix: {get,set,remove,keys} × body', () => { // The body channel, exercised by a raw client — precisely the non-conforming // client the finding described, since `fetch` cannot put a body on a GET. For // the bodyless methods the whole body is refused (that is where a scope would // hide); for `set`, which legitimately carries a body, a `scope` field in it // is refused. One test per operation (not `test.each`) so each can consult the // per-test `skip` for sandboxes that cannot bind a loopback listener. for (const op of SCOPE_OPERATIONS) { test(`rejects a scope in the body of ${op.name}`, async ({ skip }) => withNetworkServer(skip, async (networkServer) => { const body = op.writesValue ? JSON.stringify({ value: 1, scope: 'device' }) : JSON.stringify({ scope: 'device' }); const { status, json } = await rawRequest(networkServer.baseUrl, { method: op.method, path: op.entryPath ? '/kv/entries/mk' : '/kv/keys', headers: { 'content-type': 'application/json', 'x-storage-namespace': 'scope-raw' }, body, }); expect(status, op.name).toBe(400); expect(json).toMatchObject({ error: { code: 'VALIDATION_FAILED' } }); })); } }); describe('HttpKVStore opaque keys and transport limits', () => { test('round-trips an empty key through the real server', async () => { // The key domain is opaque, so an empty key is legitimate. It rides as a // trailing-slash path segment and the server keeps it as the empty map key. const remote = makeStore(`kv-empty-${namespace++}`); await remote.set('', { ok: 1 }); expect(await remote.get('')).toEqual({ ok: 1 }); await remote.remove(''); expect(await remote.get('')).toBeNull(); }); test('round-trips a NUL-containing key through the real server', async () => { // NUL is a legitimate opaque key: it percent-encodes to `%00`, which the URL // parser preserves and the server decodes back. No key-domain rejection. const remote = makeStore(`kv-nul-${namespace++}`); const key = 'ns:before\u0000after'; await remote.set(key, { ok: 1 }); expect(await remote.get(key)).toEqual({ ok: 1 }); expect(await remote.keys('ns:')).toContain(key); }); test('round-trips an arbitrarily long key — there is no length ceiling', async () => { // No DoS ceiling in the key domain: a long id is just as valid. (A deployment // may still cap the request-target at its own transport layer; that is a // deployment concern, documented, not a key-domain rule.) const remote = makeStore(`kv-long-${namespace++}`); const key = `editor-current-scene:${'s'.repeat(5000)}`; await remote.set(key, { sceneId: null }); expect(await remote.get(key)).toEqual({ sceneId: null }); expect(await remote.keys('editor-current-scene:')).toContain(key); }); test('round-trips a key containing separators through the real server', async () => { // A stageId of `stage/one` is a legitimate id, so the composed key must // survive the wire. The client percent-encodes it into one path segment and // the server stores the decoded key as an opaque value. const storageNamespace = `kv-opaque-${namespace++}`; const remote = new HttpKVStore({ baseUrl: server.baseUrl, fetch: server.fetch, headers: () => ({ 'x-storage-namespace': storageNamespace }), deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }), }); const key = 'editor-current-scene:stage/one'; await remote.set(key, { sceneId: null }); expect(await remote.get(key)).toEqual({ sceneId: null }); expect(await remote.keys('editor-current-scene:')).toContain(key); await remote.remove(key); expect(await remote.get(key)).toBeNull(); }); test('a lone-surrogate key is an HTTP transport limit, not a key-domain rejection', async () => { // An unpaired UTF-16 surrogate has no percent-encoding, so the URL-path HTTP // transport structurally cannot carry it — the client surfaces a clear // transport error (not a key-domain "invalid key"). The browser primitive, // which uses no URL, stores the very same key. const http = new HttpKVStore({ baseUrl: 'https://kv.invalid', fetch: async () => { throw new Error('fetch must not be called'); }, deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }), }); const failure = http.set('\uD800', 'v'); await expect(failure).rejects.toMatchObject({ code: 'KEY_NOT_ENCODABLE' }); await expect(failure).rejects.toThrow(/cannot be carried over the HTTP URL transport/); // Opaque on the browser primitive — stored and read back. const browser = new BrowserKVStore({ storage: new MemoryStorage() }); await browser.set('\uD800', 'v', 'device'); expect(await browser.get('\uD800', 'device')).toBe('v'); }); test('the server accepts an opaque prefix containing a separator', async () => { // A separator in a prefix is data, not structure: the client encodes it and // the server matches it literally. It must not be rejected. const storageNamespace = `kv-prefix-${namespace++}`; await server.fetch(`${server.baseUrl}/kv/entries/${encodeURIComponent('a/one')}`, { method: 'PUT', headers: { 'content-type': 'application/json', 'x-storage-namespace': storageNamespace }, body: JSON.stringify({ value: 1 }), }); const response = await server.fetch( `${server.baseUrl}/kv/keys?prefix=${encodeURIComponent('a/')}`, { headers: { 'x-storage-namespace': storageNamespace } }, ); expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual(['a/one']); }); test('a percent-encoded traversal is stored as an opaque key, not a path', async () => { // Neutralized by encoding, not by rejection: the client single-segments it, // and the server keeps it as a plain map key that traverses nothing. const storageNamespace = `kv-traversal-${namespace++}`; const remote = new HttpKVStore({ baseUrl: server.baseUrl, fetch: server.fetch, headers: () => ({ 'x-storage-namespace': storageNamespace }), deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }), }); const key = '../../etc/passwd'; await remote.set(key, 'harmless'); expect(await remote.get(key)).toBe('harmless'); // It is one opaque key, not a walk up the tree — a sibling with the same // trailing segment is a different key. expect(await remote.get('passwd')).toBeNull(); }); test('a raw (unencoded) traversal path does not reach the entries route', async () => { // Belt to the encoding's braces: a malicious client that puts literal // separators on the wire produces extra path segments, which match no route. const response = await server.fetch(`${server.baseUrl}/kv/entries/../../etc/passwd`, { method: 'DELETE', headers: { 'x-storage-namespace': `kv-raw-${namespace++}` }, }); expect(response.status).toBe(404); await expect(response.json()).resolves.toMatchObject({ error: { code: 'ROUTE_NOT_FOUND' }, }); }); }); describe('HttpKVStore error-table coverage', () => { function respondWith(status: number, code: string): HttpKVStore { return new HttpKVStore({ baseUrl: 'https://kv.invalid', fetch: async () => new Response(JSON.stringify({ error: { code, message: `${code} from server` } }), { status, headers: { 'content-type': 'application/json' }, }), deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }), }); } test('a 404 ROUTE_NOT_FOUND throws instead of masquerading as a missing key', async () => { // The MUST a status-only client would break: both rows are 404, and only // KEY_NOT_FOUND means "no such entry". const failure = respondWith(404, 'ROUTE_NOT_FOUND').get('k'); await expect(failure).rejects.toBeInstanceOf(HttpKVStoreError); await expect(failure).rejects.toMatchObject({ status: 404, code: 'ROUTE_NOT_FOUND' }); }); test.each([ [401, 'UNAUTHENTICATED'], [403, 'FORBIDDEN_KV'], [413, 'PAYLOAD_TOO_LARGE'], [500, 'INTERNAL_ERROR'], ])('maps %i %s to a typed error', async (status, code) => { await expect(respondWith(status, code).get('k')).rejects.toMatchObject({ status, code }); }); test('an unauthenticated deployment answers 401 on the contract routes', async () => { const guarded = await startKvConformanceServer({ listen: false, authenticate: (req) => req.headers.authorization !== undefined, }); try { const store = new HttpKVStore({ baseUrl: guarded.baseUrl, fetch: guarded.fetch, deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }), }); // Not null: an authentication failure must never look like a missing key. await expect(store.get('k')).rejects.toMatchObject({ status: 401, code: 'UNAUTHENTICATED', }); } finally { await guarded.close(); } }); test('a denied principal receives 403 rather than a missing key', async () => { const guarded = await startKvConformanceServer({ listen: false, authorize: (_req, area) => area !== 'kv', }); try { const store = new HttpKVStore({ baseUrl: guarded.baseUrl, fetch: guarded.fetch, deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }), }); await expect(store.get('k')).rejects.toMatchObject({ status: 403, code: 'FORBIDDEN_KV' }); } finally { await guarded.close(); } }); test('a write past the body ceiling is rejected with 413', async () => { const bounded = await startKvConformanceServer({ listen: false, maxBodyBytes: 8 }); try { const store = new HttpKVStore({ baseUrl: bounded.baseUrl, fetch: bounded.fetch, deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }), }); await expect(store.set('k', 'a value well past eight bytes')).rejects.toMatchObject({ status: 413, code: 'PAYLOAD_TOO_LARGE', }); } finally { await bounded.close(); } }); test('a 2xx with an unparseable body becomes a typed malformed response', async () => { const store = new HttpKVStore({ baseUrl: 'https://kv.invalid', fetch: async () => new Response('a proxy error page', { status: 200, headers: { 'content-type': 'text/html' }, }), deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }), }); const failure = store.get('k'); await expect(failure).rejects.not.toBeInstanceOf(SyntaxError); await expect(failure).rejects.toMatchObject({ code: 'MALFORMED_RESPONSE' }); }); }); describe('HttpKVStore transport semantics', () => { test('maps a missing key to null rather than an error', async () => { const store = makeStore(); await expect(store.get('absent')).resolves.toBeNull(); }); test('rejects values JSON cannot carry faithfully', async () => { const store = new HttpKVStore({ baseUrl: 'https://kv.invalid', fetch: async () => { throw new Error('fetch must not be called'); }, deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }), }); // Narrower than BrowserKVStore on purpose: structured values that survive // `localStorage` round-trips only because `JSON.stringify` silently drops or // rewrites them must fail loud before they are sent. const circular: Record = {}; circular.self = circular; const rejected: [string, unknown][] = [ ['Map', new Map([['k', 'v']])], ['Set', new Set(['v'])], ['Date', new Date('2026-01-01T00:00:00.000Z')], ['NaN', Number.NaN], ['negative zero', -0], ['nested undefined', { nested: undefined }], ['NUL string', 'before\u0000after'], ['unpaired surrogate', '\uD800'], // The three a `JSON.stringify` pre-flight would mishandle: two throw a // native TypeError before the gate can produce its message, and the third // stringifies to `undefined` and would be reclassified as a delete. ['bigint', BigInt(1)], ['circular reference', circular], ['toJSON returning undefined', { toJSON: () => undefined }], ]; for (const [name, value] of rejected) { const failure = store.set('k', value); await expect(failure, name).rejects.toThrow(/not a plain JSON value/); await expect(failure, name).rejects.not.toBeInstanceOf(TypeError); } }); test('the JSON gate runs before anything reads the value', async () => { const store = new HttpKVStore({ baseUrl: 'https://kv.invalid', fetch: async () => { throw new Error('fetch must not be called'); }, deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }), }); // A pre-flight `JSON.stringify` would invoke this before the gate looked, // letting caller code run — and change its mind — between the check and the // serialization the check was supposed to cover. let reads = 0; const spy = { get value() { reads += 1; return reads; }, }; await expect(store.set('k', spy)).rejects.toThrow(/not a plain JSON value/); expect(reads).toBe(0); }); test.each([ ['undefined', undefined], ['a function', () => 'x'], ['a symbol', Symbol('s')], ])('treats %s as a delete, matching the browser backend', async (_name, value) => { const store = makeStore(); await store.set('k', 'present'); await store.set('k', value); expect(await store.get('k')).toBeNull(); expect(await store.keys()).not.toContain('k'); }); test('does not reject opaque keys — it encodes and sends them', async () => { // The client applies no key-domain rules: a NUL and a separator are both // encoded and sent (proven by the raw request target the client builds). const targets: string[] = []; const store = new HttpKVStore({ baseUrl: 'https://kv.invalid', fetch: async (input) => { // The raw string the client passed — not `new URL(...).pathname`, which // would normalize a dot segment and hide what was actually emitted. targets.push(String(input).replace('https://kv.invalid', '')); return new Response(null, { status: 204 }); }, deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }), }); await store.set('a\u0000b', 'v'); await store.set('a/b', 'v'); // A key that merely *contains* a dot is an ordinary segment — no special case. await store.set('a.b', 'v'); expect(targets).toEqual(['/kv/entries/a%00b', '/kv/entries/a%2Fb', '/kv/entries/a.b']); }); test('a whole-key "." or ".." is an HTTP transport limit, not a key-domain rejection', async () => { // The data-correctness case: `encodeURIComponent('.')` is `.`, so a standard // `fetch` would normalize `/kv/entries/.` to `/kv/entries/` (the empty key) // and `/kv/entries/..` up a level — silently reading, overwriting, or // deleting a *different* entry. The client refuses these before the request // is built, exactly as it refuses an unencodable surrogate. The browser // primitive stores them opaquely, unchanged. const fetchCalls: string[] = []; const http = new HttpKVStore({ baseUrl: 'https://kv.invalid', fetch: async (input) => { fetchCalls.push(String(input)); return new Response(null, { status: 204 }); }, deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }), }); for (const key of ['.', '..']) { await expect(http.get(key), key).rejects.toMatchObject({ code: 'KEY_NOT_ENCODABLE' }); await expect(http.set(key, 'v'), key).rejects.toMatchObject({ code: 'KEY_NOT_ENCODABLE' }); await expect(http.remove(key), key).rejects.toMatchObject({ code: 'KEY_NOT_ENCODABLE' }); } // No request was ever built — nothing aliased to another entry. expect(fetchCalls).toEqual([]); // Opaque on the browser primitive: stored and read back, distinct from the // empty key. const browser = new BrowserKVStore({ storage: new MemoryStorage() }); await browser.set('.', 'dot', 'device'); await browser.set('..', 'dotdot', 'device'); await browser.set('', 'empty', 'device'); expect(await browser.get('.', 'device')).toBe('dot'); expect(await browser.get('..', 'device')).toBe('dotdot'); expect(await browser.get('', 'device')).toBe('empty'); }); test('reports a malformed get response instead of inventing a value', async () => { const store = new HttpKVStore({ baseUrl: 'https://kv.invalid', fetch: async () => new Response(JSON.stringify({ notValue: 1 }), { status: 200, headers: { 'content-type': 'application/json' }, }), deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }), }); await expect(store.get('k')).rejects.toMatchObject({ name: 'HttpKVStoreError', code: 'MALFORMED_RESPONSE', }); }); test.each([ ['a non-array body', { keys: [] }], ['a non-string member', ['ok', 7]], ])('reports %s from keys() as a malformed response', async (_name, body) => { const store = new HttpKVStore({ baseUrl: 'https://kv.invalid', fetch: async () => new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' }, }), deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }), }); await expect(store.keys()).rejects.toMatchObject({ code: 'MALFORMED_RESPONSE' }); }); test('surfaces a server failure as a typed error', async () => { const store = new HttpKVStore({ baseUrl: 'https://kv.invalid', fetch: async () => new Response(JSON.stringify({ error: { code: 'INTERNAL_ERROR', message: 'boom' } }), { status: 500, headers: { 'content-type': 'application/json' }, }), deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }), }); const failure = store.get('k'); await expect(failure).rejects.toBeInstanceOf(HttpKVStoreError); await expect(failure).rejects.toMatchObject({ status: 500, code: 'INTERNAL_ERROR' }); }); test('sends a prefix query only when a prefix is given', async () => { const paths: string[] = []; const account = new HttpAccountKV({ baseUrl: 'https://kv.invalid', fetch: async (input) => { const url = new URL(String(input)); paths.push(url.pathname + url.search); return new Response(JSON.stringify([]), { status: 200, headers: { 'content-type': 'application/json' }, }); }, }); await account.keys(); await account.keys('ui:'); expect(paths).toEqual(['/kv/keys', '/kv/keys?prefix=ui%3A']); }); test.each([ ['file:', 'file:///etc'], ['ftp:', 'ftp://host/x'], ])('refuses a %s base url at construction', (_name, baseUrl) => { const deviceStore = new BrowserKVStore({ storage: new MemoryStorage() }); expect(() => new HttpKVStore({ baseUrl, deviceStore })).toThrow(/is not http\(s\)/); }); test('refuses a scheme-without-authority base url', () => { const deviceStore = new BrowserKVStore({ storage: new MemoryStorage() }); expect( () => new HttpKVStore({ baseUrl: 'https:api.example/persistence', deviceStore }), ).toThrow(/scheme:\/\/host/); }); test.each([ ['a query', 'https://kv.invalid/api?v=1'], ['a fragment', 'https://kv.invalid/api#frag'], ])('refuses a base url carrying %s', (_name, baseUrl) => { const deviceStore = new BrowserKVStore({ storage: new MemoryStorage() }); expect(() => new HttpKVStore({ baseUrl, deviceStore })).toThrow(/query or fragment/); }); test('omits credentials entirely when the deployment does not ask for them', async () => { let hadKey = true; const store = new HttpKVStore({ baseUrl: 'https://kv.invalid', fetch: async (_input, init) => { hadKey = init !== undefined && 'credentials' in init; return new Response(null, { status: 204 }); }, deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }), }); await store.remove('k'); expect(hadKey).toBe(false); }); test.each([ ['protocol-relative', '//evil.example'], ['backslash authority', '/\\evil.example'], ])('refuses a %s base url posing as a path', (_name, baseUrl) => { const deviceStore = new BrowserKVStore({ storage: new MemoryStorage() }); expect(() => new HttpKVStore({ baseUrl, deviceStore })).toThrow( /not a reference to another origin/, ); }); test.each([ ['a tab', '/api\u0009/persistence'], ['a NUL', '/api\u0000/persistence'], ])('refuses a base url containing %s', (_name, baseUrl) => { const deviceStore = new BrowserKVStore({ storage: new MemoryStorage() }); expect(() => new HttpKVStore({ baseUrl, deviceStore })).toThrow(/control characters/); }); test.each([ ['a bare query separator', 'https://kv.invalid/api?'], ['a bare fragment separator', '/api/persistence#'], ])('refuses a base url ending in %s', (_name, baseUrl) => { const deviceStore = new BrowserKVStore({ storage: new MemoryStorage() }); expect(() => new HttpKVStore({ baseUrl, deviceStore })).toThrow(/query or fragment/); }); test.each([ ['userinfo', 'https://user:pass@kv.invalid'], ['a trailing space', 'https://kv.invalid/api '], ])('refuses a base url carrying %s', (_name, baseUrl) => { const deviceStore = new BrowserKVStore({ storage: new MemoryStorage() }); expect(() => new HttpKVStore({ baseUrl, deviceStore })).toThrow( /must not carry userinfo|whitespace/, ); }); test('passes credentials through to fetch for a cross-origin cookie deployment', async () => { let seen: RequestCredentials | undefined; const store = new HttpKVStore({ baseUrl: 'https://kv.invalid', credentials: 'include', fetch: async (_input, init) => { seen = init?.credentials; return new Response(null, { status: 204 }); }, deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }), }); await store.remove('k'); expect(seen).toBe('include'); }); test('a headers hook cannot describe the request body', async () => { // The hook attaches authentication headers; the body is always JSON, so a // hook that sets Content-Type is misconfigured and fails loud. let called = false; const store = new HttpKVStore({ baseUrl: 'https://kv.invalid', fetch: async () => { called = true; return new Response(null, { status: 204 }); }, headers: () => ({ 'Content-Type': 'text/plain' }), deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }), }); await expect(store.set('k', 'v')).rejects.toThrow(/must not set Content-Type/); expect(called).toBe(false); // Reads carry no body, so there is nothing for a hook to misdescribe. await expect(store.remove('k')).resolves.toBeUndefined(); }); test('requires a non-empty base url and a usable fetch implementation', () => { const deviceStore = new BrowserKVStore({ storage: new MemoryStorage() }); expect(() => new HttpKVStore({ baseUrl: '', deviceStore })).toThrow( /baseUrl must be non-empty/, ); expect( () => new HttpKVStore({ baseUrl: 'https://kv.invalid', fetch: {} as unknown as typeof globalThis.fetch, deviceStore, }), ).toThrow(/requires a fetch implementation/); }); }); describe('HttpKVStore cache and not-found identity', () => { test.each([ ['a read of one entry', (kv: HttpKVStore) => kv.get('k')], ['a key listing', (kv: HttpKVStore) => kv.keys()], ])('%s is never served from a cache', async (_name, operation) => { // `account` values are precisely the ones another device may have changed a // moment ago, and this client cannot invalidate a cache entry when it does. let seen: RequestCache | undefined; const store = new HttpKVStore({ baseUrl: 'https://kv.invalid', fetch: async (input, init) => { seen = init?.cache; const body = String(input).includes('/kv/keys') ? [] : { value: 'v' }; return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' }, }); }, deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }), }); await operation(store); expect(seen).toBe('no-store'); }); test('a write is not marked no-store, having nothing to read from a cache', async () => { let seen: RequestCache | undefined = 'no-store'; const store = new HttpKVStore({ baseUrl: 'https://kv.invalid', fetch: async (_input, init) => { seen = init?.cache; return new Response(null, { status: 204 }); }, deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }), }); await store.set('k', 'v'); expect(seen).toBeUndefined(); }); test.each([ [401, 'the credential expired'], [403, 'a gateway denied the request'], [500, 'the origin fell over'], ])('throws when a %i answer carries the not-found code', async (status, why) => { // `null` here is indistinguishable from a legitimate miss, so reading an // outage as one would present it to the caller as data loss. const store = new HttpKVStore({ baseUrl: 'https://kv.invalid', fetch: async () => new Response(JSON.stringify({ error: { code: 'KEY_NOT_FOUND', message: why } }), { status, headers: { 'content-type': 'application/json' }, }), deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }), }); const failure = store.get('k'); await expect(failure).rejects.toBeInstanceOf(HttpKVStoreError); await expect(failure).rejects.toMatchObject({ status, code: 'KEY_NOT_FOUND' }); }); test('still maps a genuine 404 KEY_NOT_FOUND to null', async () => { const store = new HttpKVStore({ baseUrl: 'https://kv.invalid', fetch: async () => new Response(JSON.stringify({ error: { code: 'KEY_NOT_FOUND', message: 'absent' } }), { status: 404, headers: { 'content-type': 'application/json' }, }), deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }), }); await expect(store.get('k')).resolves.toBeNull(); }); }); describe('conformance server read-cache and delete hygiene', () => { test.each([ ['a kv entry read', (base: string) => `${base}/kv/entries/cached`], ['a kv key listing', (base: string) => `${base}/kv/keys`], ])('%s is served no-store', async (_name, url) => { // The contract requires it, so the harness has to send it — otherwise the // suite passes against exactly the implementation the requirement exists to // catch. const storageNamespace = `no-store-${namespace++}`; const store = makeStore(storageNamespace); await store.set('cached', 'v'); const response = await server.fetch(url(server.baseUrl), { headers: { 'x-storage-namespace': storageNamespace }, }); expect(response.status).toBe(200); expect(response.headers.get('cache-control')).toBe('no-store'); }); test('a delete carrying a body is refused', async () => { // The body was the one channel left through which a delete could still // describe a scope; query and header were already covered. const response = await server.fetch(`${server.baseUrl}/kv/entries/k`, { method: 'DELETE', headers: { 'content-type': 'application/json', 'x-storage-namespace': `delete-body-${namespace++}`, }, body: JSON.stringify({ scope: 'device' }), }); expect(response.status).toBe(400); await expect(response.json()).resolves.toMatchObject({ error: { code: 'VALIDATION_FAILED', message: expect.stringContaining('body') }, }); }); test('a bodyless delete still succeeds', async () => { const storageNamespace = `delete-clean-${namespace++}`; const store = makeStore(storageNamespace); await store.set('k', 'v'); await expect(store.remove('k')).resolves.toBeUndefined(); expect(await store.get('k')).toBeNull(); }); }); test('real fetch reaches the listening conformance server over loopback', async ({ skip }) => withNetworkServer(skip, async (networkServer) => { const store = new HttpKVStore({ baseUrl: networkServer.baseUrl, headers: () => ({ 'x-storage-namespace': 'real-network' }), deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }), }); await store.set('provider', { name: 'demo', models: ['a', 'b'] }); await expect(store.get('provider')).resolves.toEqual({ name: 'demo', models: ['a', 'b'] }); await expect(store.keys()).resolves.toEqual(['provider']); await store.remove('provider'); await expect(store.get('provider')).resolves.toBeNull(); })); // The key domain is opaque and unbounded, and that holds over a *real* HTTP // connection, not just the in-process harness: an arbitrarily long non-ASCII key // and a NUL-containing key both round-trip, with no ceiling and no NUL rejection. // The browser primitive accepts the same keys — parity on validity. A key the // URL transport genuinely cannot carry (an unpaired surrogate) is a transport // limit surfaced by the HTTP client, tested in the key-constraints suite. test('arbitrary opaque keys round-trip over a real HTTP connection', async ({ skip }) => withNetworkServer(skip, async (networkServer) => { const http = new HttpKVStore({ baseUrl: networkServer.baseUrl, headers: () => ({ 'x-storage-namespace': 'opaque-real' }), deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }), }); const browser = new BrowserKVStore({ storage: new MemoryStorage() }); const keys = [ // Long and non-ASCII: 5000 × U+20AC (encoded ~45 KB would exceed a URL, so // keep it long but within a real request-target — the point is "no domain // ceiling", and a real transport still has its own separate limit). `settings:${'€'.repeat(300)}`, // NUL, which percent-encodes to %00 and is preserved end to end. 'ns:before\u0000after', // A separator, the original regression. 'editor-current-scene:stage/one', ]; for (const [index, key] of keys.entries()) { // The value is plain (the key may contain NUL; a *value* may not, per the // JSON/jsonb value gate — a separate axis from the opaque key domain). const value = { stored: index }; await http.set(key, value); expect(await http.get(key), key).toEqual(value); // The browser primitive stores the identical key — validity parity. await browser.set(key, value, 'device'); expect(await browser.get(key, 'device'), key).toEqual(value); } })); test("a whole-key '.'/'..' never pollutes the empty-key entry over a real server", async ({ skip, }) => withNetworkServer(skip, async (networkServer) => { const http = new HttpKVStore({ baseUrl: networkServer.baseUrl, headers: () => ({ 'x-storage-namespace': 'dot-alias' }), deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }), }); // The empty key holds a distinct value; `.`/`..` would alias to it (or to a // bad route) if the client sent them, corrupting or deleting it. await http.set('', { entry: 'empty' }); for (const key of ['.', '..']) { await expect(http.get(key), key).rejects.toMatchObject({ code: 'KEY_NOT_ENCODABLE' }); await expect(http.set(key, { entry: key }), key).rejects.toMatchObject({ code: 'KEY_NOT_ENCODABLE', }); await expect(http.remove(key), key).rejects.toMatchObject({ code: 'KEY_NOT_ENCODABLE' }); } // The empty-key entry is exactly as it was — never read, overwritten, or deleted. expect(await http.get('')).toEqual({ entry: 'empty' }); expect(await http.keys()).toEqual(['']); }));