From 6d21b62e84cd7d31cc25570023368dddd3ed278e Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 20 Oct 2026 13:10:22 -0700 Subject: [PATCH] fix(environment): refuse writes the state could not hold or reload Served writes now refuse, before changing the state: - a document write with members or JSON types its plan did not accept (the same body check entity writes use), or at a path other than the one the state holds the document at; - a body or resulting value data/add would refuse (a number inexact as data/get writes it, a __proto__ member, nesting past the limit), so data/get output always loads back, increments included; - a create, update or document write that would take the state past the 32 MiB data/add intake limit (resource_constraint), with the state's serialized size kept incrementally rather than reserialized. Co-Authored-By: Claude Opus 5.5 --- .../environment/environmentStateDocument.ts | 23 +- .../environment/environmentStateStore.ts | 182 +++++++++++---- .../environmentStateWriteAnswer.test.ts | 48 +--- .../environmentStateWriteAnswer.ts | 97 ++++++-- .../environmentStateWriteRefusals.test.ts | 209 ++++++++++++++++++ src/server/environment/stateIntake.ts | 4 +- tests/fixtures/environmentStateServing.ts | 46 ++++ 7 files changed, 500 insertions(+), 109 deletions(-) create mode 100644 src/application/environment/environmentStateWriteRefusals.test.ts create mode 100644 tests/fixtures/environmentStateServing.ts diff --git a/src/application/environment/environmentStateDocument.ts b/src/application/environment/environmentStateDocument.ts index c3250ccbed..7d2f32d963 100644 --- a/src/application/environment/environmentStateDocument.ts +++ b/src/application/environment/environmentStateDocument.ts @@ -6,6 +6,7 @@ import { same, scalarText, segmentsOf, + type JsonObject, type StateDocument, type StateEntity, type StateType, @@ -15,14 +16,22 @@ import { type AnalysisInputIssue, } from "../../domain/analysisErrorCore.js"; import { canonicalJson } from "../../domain/comparisonSemantics.js"; +import { parseExactJson } from "../../domain/exactJsonNumber.js"; import { projectInputIssues } from "../../domain/inputIssueProjection.js"; -import { jsonValueSchema } from "../../domain/jsonValue.js"; +import { + jsonValueSchema, + jsonValueValidationIssue, + type JsonValue, +} from "../../domain/jsonValue.js"; import { err, ok, type Result } from "../../domain/result.js"; import { compositeKey } from "../../domain/unicodeCodePointOrder.js"; import type { EnvironmentState } from "./environmentState.js"; import { ENVIRONMENT_SERVE_OPERATION } from "./environmentSource.js"; import { REA_STATE_FORMAT } from "./environmentTools.js"; +/** A loaded state document is at most this large, whether it arrives as bytes, a file or a download. */ +export const STATE_INTAKE_LIMIT_BYTES = 32 * 1024 * 1024; + /** A document as the state holds it; only a value equal to the Evidence's cites the record it came from. */ export type HeldDocument = Omit & { readonly record_ordinal?: number; @@ -131,6 +140,18 @@ const prototypeMemberPath = (root: unknown): Path | undefined => { return undefined; }; +/** Whether `data/add` accepts a value as a `fields` member or a document's `value`: bounded nesting, no `__proto__` member, and numbers exact as `data/get` writes them. */ +export const isLoadableValue = (value: JsonValue): boolean => + jsonValueValidationIssue(value) === undefined && + prototypeMemberPath(value) === undefined && + parseExactJson(JSON.stringify(value)) !== undefined; + +/** The first member of an object `data/add` would refuse as `fields`, if any: a `__proto__` member, or one whose value does not load. */ +export const unloadableMember = (object: JsonObject): string | undefined => + Object.keys(object).find( + (member) => member === "__proto__" || !isLoadableValue(object[member]!), + ); + /** The first schema problem, located without naming the caller's data. */ const schemaProblem = ( issues: readonly z.core.$ZodIssue[], diff --git a/src/application/environment/environmentStateStore.ts b/src/application/environment/environmentStateStore.ts index 34f040a954..70938c7e3e 100644 --- a/src/application/environment/environmentStateStore.ts +++ b/src/application/environment/environmentStateStore.ts @@ -2,13 +2,18 @@ import type { StateEntity, StateType, } from "../../domain/analysisView/environmentStateRules.js"; -import type { AnalysisInputError } from "../../domain/analysisErrorCore.js"; +import { + AnalysisResourceConstraintError, + type AnalysisInputError, +} from "../../domain/analysisErrorCore.js"; import type { JsonValue } from "../../domain/jsonValue.js"; -import { ok, type Result } from "../../domain/result.js"; +import { err, ok, type Result } from "../../domain/result.js"; import { compositeKey } from "../../domain/unicodeCodePointOrder.js"; +import { ENVIRONMENT_SERVE_OPERATION } from "./environmentSource.js"; import type { EnvironmentState } from "./environmentState.js"; import { parseStateDocument, + STATE_INTAKE_LIMIT_BYTES, type HeldDocument, type StateContent, } from "./environmentStateDocument.js"; @@ -24,10 +29,20 @@ export interface StateSnapshot { }; } +const jsonBytes = (value: unknown): number => + Buffer.byteLength(JSON.stringify(value), "utf8"); + +/** The bytes JSON writes for these array items and the commas between them. */ +const itemsBytes = (items: readonly unknown[]): number => + items.reduce((sum, item) => sum + jsonBytes(item), 0) + + Math.max(0, items.length - 1); + /** * The live state one served environment answers from, shared by every MCP * session and the data plane. Mutations are synchronous and swap the whole - * state, so every answer reads one consistent state. + * state, so every answer reads one consistent state. A mutation that would take + * what `data/get` reports past the size `data/add` loads is refused, so that + * report always loads back. */ export class EnvironmentStateStore { #entities: ReadonlyMap = new Map(); @@ -37,6 +52,11 @@ export class EnvironmentStateStore { #content: StateContent = { entities: [], documents: [] }; readonly #types: ReadonlyMap; readonly #clockStart: number; + /** What `data/get` writes for the current state, in UTF-8 bytes; kept by each mutation rather than reserialized. */ + #bytes = 0; + /** The bytes of `data/get` with no entities or documents. */ + readonly #frameBytes: number; + readonly #initialBytes: number; /** Served writes since the state was last reset or loaded: the clock's seconds and the generators' counter. */ #writes = 0; /** The largest value each sequence has generated since the last reset or load, so a deleted entity's value is not reused. */ @@ -48,12 +68,14 @@ export class EnvironmentStateStore { ); this.#clockStart = state.writeClock === null ? 0 : Date.parse(state.writeClock); + this.#frameBytes = jsonBytes(this.#snapshotOf([], [])); + this.#initialBytes = this.#contentBytes(state); this.reset(); } /** Restore the initial state the Evidence holds; the clock and counter start again. */ reset(): void { - this.#install(this.state); + this.#install(this.state, this.#initialBytes); this.#restart(); } @@ -61,7 +83,7 @@ export class EnvironmentStateStore { replace(document: unknown): Result { const content = parseStateDocument(this.state, document); if (!content.ok) return content; - this.#install(content.value); + this.#install(content.value, this.#contentBytes(content.value)); this.#restart(); return ok(undefined); } @@ -94,9 +116,16 @@ export class EnvironmentStateStore { return this.#content.entities.filter((entity) => entity.type === type); } - /** Add an entity at `index` of its collection's order. */ - insert(entity: StateEntity, index: number): void { + /** Add an entity at `index` of its collection's order, unless the state would outgrow what `data/add` loads. */ + insert( + entity: StateEntity, + index: number, + ): Result { const entities = this.#content.entities; + const bytes = this.#grown( + jsonBytes(entity) + (entities.length > 0 ? 1 : 0), + ); + if (!bytes.ok) return bytes; const members = this.inCollection(entity.type, entity.collection); const anchor = index < members.length @@ -104,49 +133,92 @@ export class EnvironmentStateStore { : members.length > 0 ? entities.indexOf(members.at(-1)!) + 1 : entities.length; - this.#install({ - ...this.#content, - entities: [ - ...entities.slice(0, anchor), - entity, - ...entities.slice(anchor), - ], - }); + this.#install( + { + ...this.#content, + entities: [ + ...entities.slice(0, anchor), + entity, + ...entities.slice(anchor), + ], + }, + bytes.value, + ); + return ok(undefined); } - /** Replace a held entity's values in place. */ - update(entity: StateEntity): void { + /** Replace a held entity's values in place, unless the state would outgrow what `data/add` loads. */ + update(entity: StateEntity): Result { const key = compositeKey([entity.type, entity.collection, entity.id]); - this.#install({ - ...this.#content, - entities: this.#content.entities.map((held) => - compositeKey([held.type, held.collection, held.id]) === key - ? entity - : held, - ), - }); + const held = this.#entities.get(key); + if (held === undefined) return ok(undefined); + const bytes = this.#grown(jsonBytes(entity) - jsonBytes(held)); + if (!bytes.ok) return bytes; + this.#install( + { + ...this.#content, + entities: this.#content.entities.map((candidate) => + candidate === held ? entity : candidate, + ), + }, + bytes.value, + ); + return ok(undefined); } remove(entity: StateEntity): void { - const key = compositeKey([entity.type, entity.collection, entity.id]); - this.#install({ - ...this.#content, - entities: this.#content.entities.filter( - (held) => compositeKey([held.type, held.collection, held.id]) !== key, - ), - }); + const held = this.#entities.get( + compositeKey([entity.type, entity.collection, entity.id]), + ); + if (held === undefined) return; + const entities = this.#content.entities.filter( + (candidate) => candidate !== held, + ); + this.#install( + { ...this.#content, entities }, + this.#bytes - jsonBytes(held) - (entities.length > 0 ? 1 : 0), + ); } - /** Set a held document's value; a value that is not the Evidence's cites no record. */ - setDocument(tool: string, value: JsonValue): void { - this.#install({ - ...this.#content, - documents: this.#content.documents.map((document) => - document.tool === tool - ? { tool: document.tool, path: document.path, value } - : document, + /** Set a held document's value, unless the state would outgrow what `data/add` loads; a value that is not the Evidence's cites no record. */ + setDocument( + tool: string, + value: JsonValue, + ): Result { + const held = this.#documents.get(tool); + if (held === undefined) return ok(undefined); + const written = { tool: held.tool, path: held.path, value }; + const bytes = this.#grown(jsonBytes(written) - jsonBytes(held)); + if (!bytes.ok) return bytes; + this.#install( + { + ...this.#content, + documents: this.#content.documents.map((document) => + document === held ? written : document, + ), + }, + bytes.value, + ); + return ok(undefined); + } + + /** The state's bytes after a change of `delta`, or a refusal when a change that grows it would pass the intake limit. */ + #grown(delta: number): Result { + const bytes = this.#bytes + delta; + if (delta <= 0 || bytes <= STATE_INTAKE_LIMIT_BYTES) return ok(bytes); + return err( + new AnalysisResourceConstraintError( + ENVIRONMENT_SERVE_OPERATION, + "capacity", + "The write would take the served state past the size data/add loads", + { max_state_bytes: STATE_INTAKE_LIMIT_BYTES, state_bytes: bytes }, + { + retryable: false, + remediationAction: + "Delete entities, or restore a smaller state with data/reset or data/add.", + }, ), - }); + ); } type(collectionTemplate: string): StateType | undefined { @@ -176,19 +248,40 @@ export class EnvironmentStateStore { } snapshot(): StateSnapshot { + return this.#snapshotOf(this.#content.entities, this.#content.documents); + } + + /** What `data/get` writes for the current state, in UTF-8 bytes. */ + byteLength(): number { + return this.#bytes; + } + + #snapshotOf( + entities: readonly StateEntity[], + documents: readonly HeldDocument[], + ): StateSnapshot { return { rea_state: { version: 1, state_evidence_id: this.state.evidenceId, types: this.state.types, - entities: this.#content.entities, - documents: this.#content.documents, + entities, + documents, }, }; } + /** What `data/get` writes for some content, summed item by item so the whole state is never one string. */ + #contentBytes(content: StateContent): number { + return ( + this.#frameBytes + + itemsBytes(content.entities) + + itemsBytes(content.documents) + ); + } + /** Build every index before assigning any, so a reader never sees half a state. */ - #install(content: StateContent): void { + #install(content: StateContent, bytes: number): void { const entities = new Map(); const collections = new Map(); const typeCollections = new Map(); @@ -216,5 +309,6 @@ export class EnvironmentStateStore { content.documents.map((document) => [document.tool, document]), ); this.#content = content; + this.#bytes = bytes; } } diff --git a/src/application/environment/environmentStateWriteAnswer.test.ts b/src/application/environment/environmentStateWriteAnswer.test.ts index aaee819cfb..0483bf6bd8 100644 --- a/src/application/environment/environmentStateWriteAnswer.test.ts +++ b/src/application/environment/environmentStateWriteAnswer.test.ts @@ -1,13 +1,9 @@ import { describe, expect, it } from "vitest"; import { - timedCaptureEvidence, - type TimedCaptureRow, -} from "../../../tests/fixtures/analysisView.js"; -import { - environmentStateServeFixture, - environmentStateServeInputs, -} from "../../../tests/fixtures/environmentServe.js"; + servedFrom, + type Served, +} from "../../../tests/fixtures/environmentStateServing.js"; import { BOOK_INIT_ROWS, BOOK_NOT_FOUND, @@ -21,52 +17,14 @@ import { renamedBook, type Book, } from "../../../tests/fixtures/environmentStateWrites.js"; -import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import type { ReplayArguments } from "./environmentReplay.js"; import { servedCatalog } from "./environmentState.js"; -import { EnvironmentStateStore } from "./environmentStateStore.js"; -import { environmentToolAnswer } from "./environmentToolAnswer.js"; import { environmentToolDefinitions } from "./environmentTools.js"; -/** A served environment over the state derived from a capture of `rows`, answering tool calls as the server does. */ -const servedFrom = (rows: readonly TimedCaptureRow[] = BOOK_ROWS) => { - const { source, state } = environmentStateServeInputs( - environmentStateServeFixture(timedCaptureEvidence(rows)), - ); - const store = new EnvironmentStateStore(state); - const respond = environmentToolAnswer(source, store); - const call = (name: string, args: ReplayArguments) => { - const tool = source.manifest.tools.find( - (candidate) => candidate.name === name, - ); - if (tool === undefined) throw new Error(`The manifest lacks ${name}`); - return respond(tool, args); - }; - return { - source, - state, - store, - answer: (name: string, args: ReplayArguments = {}) => { - const result = call(name, args); - if (!result.ok) throw result.error; - return result.value; - }, - refusal: (name: string, args: ReplayArguments = {}) => { - const result = call(name, args); - if (result.ok) throw new Error(`Expected ${name} to be refused`); - return projectAnalysisError(result.error); - }, - /** What data/get reports, as text, so whole states compare byte for byte. */ - snapshot: () => JSON.stringify(store.snapshot()), - }; -}; - /** The book the first served create of "New Book" makes: the next id, one second past the served clock's start. */ const NEW_BOOK = book(3, "New Book", bookClock(13)); const CREATE_NEW_BOOK = { body: { name: "New Book" } }; -type Served = ReturnType; - const entityOf = (books: Served, id: string) => books.store.snapshot().rea_state.entities.find((entity) => entity.id === id); diff --git a/src/application/environment/environmentStateWriteAnswer.ts b/src/application/environment/environmentStateWriteAnswer.ts index 4a7bc9d917..2d8a4a6439 100644 --- a/src/application/environment/environmentStateWriteAnswer.ts +++ b/src/application/environment/environmentStateWriteAnswer.ts @@ -1,4 +1,5 @@ import { + bodyProblem, createdFields, updatedEntity, updatedFields, @@ -22,6 +23,7 @@ import { spelledLike, } from "../../domain/analysisView/environmentStateWriteValues.js"; import { + at, isObject, scalarText, type JsonObject, @@ -49,6 +51,11 @@ import { type StateProvenance, type StateServing, } from "./environmentStateAnswer.js"; +import { + isLoadableValue, + unloadableMember, + type HeldDocument, +} from "./environmentStateDocument.js"; import { environmentToolArguments } from "./environmentTools.js"; /** Attempts at a generated UUID or token before a collision with held values is refused. */ @@ -79,7 +86,17 @@ const pathValues = ( return values; }; -/** The call's body: an object, nothing, or a refusal for any other value. */ +/** A refusal for a value `data/add` would refuse, so `data/get` always reports a state that loads back. */ +const unloadable = ( + tool: EnvironmentManifestTool, + holder: string, +): AnalysisError => + refused( + tool, + `${holder} would hold a value that is not exactly representable JSON, which data/add could not load back.`, + ); + +/** The call's body: an object, nothing, or a refusal for any other value or one holding a value the state cannot hold. */ const callBody = ({ tool, args, @@ -87,9 +104,12 @@ const callBody = ({ const name = environmentToolArguments(tool).body; const value = name === undefined ? undefined : args[name]; if (value === undefined) return ok(undefined); - return isObject(value as JsonValue) + if (!isObject(value as JsonValue)) + return err(refused(tool, `Tool ${tool.name} takes a JSON object body.`)); + const member = unloadableMember(value as JsonObject); + return member === undefined ? ok(value as JsonObject) - : err(refused(tool, `Tool ${tool.name} takes a JSON object body.`)); + : err(unloadable(tool, `Member ${member}`)); }; /** The entity a recorded observation shows: the values a create keeps and an update's constants. */ @@ -257,6 +277,8 @@ const answerCreate = ( }); const fields = createdFields(plan, observed, call, values.generate); if (!fields.ok) return err(refused(tool, fields.error)); + const field = unloadableMember(fields.value); + if (field !== undefined) return err(unloadable(tool, `Field ${field}`)); const id = scalarText(fields.value[idField]); if ( id === undefined || @@ -282,7 +304,7 @@ const answerCreate = ( state, ); if (!answer.ok) return answer; - serving.store.insert( + const inserted = serving.store.insert( { type, collection, @@ -297,6 +319,7 @@ const answerCreate = ( fields.value, ), ); + if (!inserted.ok) return inserted; serving.store.commitWrite(values.taken); return answer; }; @@ -346,6 +369,9 @@ const answerUpdate = ( values.generate, ); if (!written.ok) return err(refused(tool, written.error)); + // An increment can pass the exact integers. + const field = unloadableMember(written.value); + if (field !== undefined) return err(unloadable(tool, `Field ${field}`)); const answer = writeAnswer( serving, request, @@ -358,10 +384,10 @@ const answerUpdate = ( id: entity.id, }, ); - if (answer.ok) { - serving.store.update(updatedEntity(entity, written.value)); - serving.store.commitWrite(values.taken); - } + if (!answer.ok) return answer; + const updated = serving.store.update(updatedEntity(entity, written.value)); + if (!updated.ok) return updated; + serving.store.commitWrite(values.taken); return answer; }; @@ -391,19 +417,56 @@ const answerDelete = ( return answer; }; +/** The held document a call changes: only at the path the state holds it, with members the capture sent as the JSON types the document holds. */ +const documentTarget = ( + serving: StateServing, + { tool, plan, args }: WriteRequest, + call: WriteCall, +): Result => { + const held = serving.store.document(plan.target ?? ""); + const segments = callSegments(tool, args); + if (held === undefined || plan.body === null) + return err( + refused(tool, `The state holds no document for ${plan.target ?? ""}.`), + ); + if (segments === undefined) + return err(refused(tool, `Tool ${tool.name} needs every path argument.`)); + // Document reads answer only on their recorded line, so a write elsewhere has no document to change. + if (collectionMatch(held.path, segments) === undefined) + return err( + refused( + tool, + `The state holds this document only at ${held.path}; the environment models writes to it only there.`, + ), + ); + const pointer = plan.observation?.pointer ?? ""; + const members = (value: JsonValue | undefined): JsonObject => { + const object = value === undefined ? undefined : at(value, pointer); + return isObject(object) ? object : {}; + }; + const initial = serving.store.state.documents.find( + (document) => document.tool === held.tool, + ); + // A member a replacing write dropped keeps the type the capture showed. + const problem = bodyProblem(plan.body, call, { + entity: { ...members(initial?.value), ...members(held.value) }, + }); + return problem === undefined ? ok(held) : err(refused(tool, problem)); +}; + const answerDocument = ( serving: StateServing, request: WriteRequest, call: WriteCall, ): Result => { const { tool, plan } = request; - const held = serving.store.document(plan.target ?? ""); - if (held === undefined) - return err( - refused(tool, `The state holds no document for ${plan.target ?? ""}.`), - ); + const target = documentTarget(serving, request, call); + if (!target.ok) return target; + const held = target.value; const written = writtenDocument(plan, held.value, call); if (!written.ok) return err(refused(tool, written.error)); + if (!isLoadableValue(written.value)) + return err(unloadable(tool, "The document")); const state = { source: "state", view: "document-write", @@ -423,10 +486,10 @@ const answerDocument = ( ); })() : writeAnswer(serving, request, { fields: undefined, id: "" }, state); - if (answer.ok) { - serving.store.setDocument(plan.target ?? "", written.value); - serving.store.commitWrite(new Map()); - } + if (!answer.ok) return answer; + const set = serving.store.setDocument(held.tool, written.value); + if (!set.ok) return set; + serving.store.commitWrite(new Map()); return answer; }; diff --git a/src/application/environment/environmentStateWriteRefusals.test.ts b/src/application/environment/environmentStateWriteRefusals.test.ts new file mode 100644 index 0000000000..2cda9a29f4 --- /dev/null +++ b/src/application/environment/environmentStateWriteRefusals.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from "vitest"; + +import type { TimedCaptureRow } from "../../../tests/fixtures/analysisView.js"; +import { + servedFrom, + type Served, +} from "../../../tests/fixtures/environmentStateServing.js"; +import { + FIRST_BOOK, + SECOND_BOOK, + renamedBook, + type Book, +} from "../../../tests/fixtures/environmentStateWrites.js"; +import { MAX_JSON_DEPTH, type JsonValue } from "../../domain/jsonValue.js"; +import type { ReplayArguments } from "./environmentReplay.js"; +import { STATE_INTAKE_LIMIT_BYTES } from "./environmentStateDocument.js"; + +const DARK = { theme: "dark", meta: { n: 1 } }; +const LIGHT = { theme: "light", meta: { n: 2 } }; + +/** One organisation's settings document, read, patched with both members and read again. */ +const SETTINGS_ROWS: readonly TimedCaptureRow[] = [ + ["GET", "/api/orgs/1/settings", DARK], + ["PATCH", "/api/orgs/1/settings", LIGHT, 200, LIGHT], + ["GET", "/api/orgs/1/settings", LIGHT], +]; + +const READ_SETTINGS = "get_orgs_settings_by_id"; +const PATCH_SETTINGS = "patch_orgs_settings_by_id"; + +/** Every refused call is a tool error naming its problem, and leaves the state as it was. */ +const expectRefusedUnchanged = ( + served: Served, + refused: readonly (readonly [string, ReplayArguments, string])[], +): void => { + const before = served.snapshot(); + for (const [name, args, message] of refused) { + expect(served.refusal(name, args)).toMatchObject({ + code: "invalid_request", + message: expect.stringContaining(message), + }); + expect(served.snapshot()).toBe(before); + } +}; + +/** What data/get reports loads back through data/add byte for byte. */ +const expectReloadsUnchanged = (served: Served): void => { + const before = served.snapshot(); + expect(served.store.replace(JSON.parse(before)).ok).toBe(true); + expect(served.snapshot()).toBe(before); +}; + +const nested = (depth: number): JsonValue => + Array.from({ length: depth }).reduce((inner) => [inner], 1); + +describe("served document writes", () => { + it("accepts only the members the capture sent, as the JSON types the document holds", () => { + const settings = servedFrom(SETTINGS_ROWS); + expect(settings.state.writes.get(PATCH_SETTINGS)).toMatchObject({ + served: true, + effect: "document", + }); + expectRefusedUnchanged(settings, [ + [ + PATCH_SETTINGS, + { id: "1", body: { theme: 5, extra: true } }, + "Member theme must be a JSON string", + ], + [ + PATCH_SETTINGS, + { id: "1", body: { extra: true } }, + "Member extra is not one the capture showed stored", + ], + ]); + const blue = { theme: "blue", meta: DARK.meta }; + expect( + settings.answer(PATCH_SETTINGS, { id: "1", body: { theme: "blue" } }), + ).toMatchObject({ + body: blue, + state: { view: "document-write", path: "/api/orgs/1/settings" }, + }); + expect(settings.answer(READ_SETTINGS, { id: "1" }).body).toEqual(blue); + }); + + it("changes the document only at the path the state holds it", () => { + const settings = servedFrom(SETTINGS_ROWS); + expectRefusedUnchanged(settings, [ + [ + PATCH_SETTINGS, + { id: "2", body: { theme: "blue" } }, + "holds this document only at /api/orgs/1/settings", + ], + ]); + expect(settings.answer(READ_SETTINGS, { id: "1" }).body).toEqual(DARK); + }); +}); + +describe("values the state can load back", () => { + it("refuses a body value data/add would refuse, whatever member carries it", () => { + const settings = servedFrom(SETTINGS_ROWS); + const unexact = + "Member meta would hold a value that is not exactly representable JSON"; + expectRefusedUnchanged(settings, [ + [PATCH_SETTINGS, { id: "1", body: { meta: { n: 1e20 } } }, unexact], + [ + PATCH_SETTINGS, + { + id: "1", + body: JSON.parse('{"meta":{"__proto__":{"n":1}}}') as JsonValue, + }, + unexact, + ], + [ + PATCH_SETTINGS, + { id: "1", body: { meta: { n: nested(MAX_JSON_DEPTH) } } }, + unexact, + ], + ]); + settings.answer(PATCH_SETTINGS, { id: "1", body: { meta: { n: 3 } } }); + expectReloadsUnchanged(settings); + }); + + it("refuses an increment past the exact integers, so data/get still loads back", () => { + const versioned = (entry: Book, version: number): Book => ({ + ...entry, + version, + }); + const last = Number.MAX_SAFE_INTEGER; + const edition = versioned(renamedBook(SECOND_BOOK, "Second Edition", 3), 2); + const books = servedFrom([ + [ + "GET", + "/api/books", + [versioned(FIRST_BOOK, last - 1), versioned(SECOND_BOOK, 1)], + ], + ["GET", "/api/books/1", versioned(FIRST_BOOK, last - 1)], + ["GET", "/api/books/2", versioned(SECOND_BOOK, 1)], + ["PATCH", "/api/books/2", edition, 200, { name: "Second Edition" }], + ["GET", "/api/books/2", edition], + ]); + const rename = (name: string) => ({ id: "1", body: { name } }); + expect( + books.answer("patch_books_by_id", rename("Renamed")).body, + ).toMatchObject({ version: last }); + expectRefusedUnchanged(books, [ + [ + "patch_books_by_id", + rename("Renamed Again"), + "Field version would hold a value that is not exactly representable JSON", + ], + ]); + expectReloadsUnchanged(books); + }); +}); + +describe("the state size limit", () => { + it("keeps the size data/get writes as each write changes the state", () => { + const books = servedFrom(); + const settings = servedFrom(SETTINGS_ROWS); + const tracked = (served: Served) => + expect(served.store.byteLength()).toBe( + Buffer.byteLength(served.snapshot(), "utf8"), + ); + tracked(books); + books.answer("post_books", { body: { name: "Ünïcode Bøok" } }); + tracked(books); + books.answer("patch_books_by_id", { id: "3", body: { name: "Short" } }); + tracked(books); + books.answer("delete_books_by_id", { id: "1" }); + books.answer("delete_books_by_id", { id: "2" }); + books.answer("delete_books_by_id", { id: "3" }); + tracked(books); + books.answer("post_books", { body: { name: "Only Book" } }); + tracked(books); + settings.answer(PATCH_SETTINGS, { id: "1", body: { theme: "dark blue" } }); + tracked(settings); + expect(settings.store.replace(JSON.parse(settings.snapshot())).ok).toBe( + true, + ); + tracked(settings); + }); + + it("refuses a create that would take data/get past what data/add loads", () => { + const books = servedFrom(); + // Each answer renders the book, so each stays under the reply body limit. + const create = { body: { name: "a".repeat(100_000) } }; + let refused: ReturnType | undefined; + for (let made = 0; made < 200 && refused === undefined; made += 1) { + const result = books.call("post_books", create); + if (!result.ok) refused = result; + } + const before = books.snapshot(); + expect(Buffer.byteLength(before, "utf8")).toBeLessThanOrEqual( + STATE_INTAKE_LIMIT_BYTES, + ); + expect(refused?.ok === false && refused.error).toMatchObject({ + resource: "capacity", + reportedLimits: { max_state_bytes: STATE_INTAKE_LIMIT_BYTES }, + }); + expect(books.refusal("post_books", create)).toMatchObject({ + code: "resource_constraint", + }); + expect(books.snapshot()).toBe(before); + expectReloadsUnchanged(books); + // A smaller state has room again. + books.answer("delete_books_by_id", { id: "3" }); + expect(books.answer("post_books", create).status).toBe(201); + }); +}); diff --git a/src/server/environment/stateIntake.ts b/src/server/environment/stateIntake.ts index b8e05cad75..2e7ab8c559 100644 --- a/src/server/environment/stateIntake.ts +++ b/src/server/environment/stateIntake.ts @@ -1,6 +1,7 @@ import type { FileHandle } from "node:fs/promises"; import { fileURLToPath } from "node:url"; +import { STATE_INTAKE_LIMIT_BYTES } from "../../application/environment/environmentStateDocument.js"; import { mediaTypeEssence } from "../../domain/analysisView/webNetworkRouteTemplates.js"; import { isExactJsonNumber } from "../../domain/exactJsonNumber.js"; import { err, ok, type Result } from "../../domain/result.js"; @@ -44,8 +45,7 @@ export const CANCELLED: RpcError = { data: { reason: "cancelled" }, }; -/** A loaded state document is at most this large, whether it arrives as bytes, a file or a download. */ -export const STATE_INTAKE_LIMIT_BYTES = 32 * 1024 * 1024; +export { STATE_INTAKE_LIMIT_BYTES }; /** An https state download must finish within this time. */ export const STATE_FETCH_TIMEOUT_MS = 30_000; diff --git a/tests/fixtures/environmentStateServing.ts b/tests/fixtures/environmentStateServing.ts new file mode 100644 index 0000000000..9b8e687a19 --- /dev/null +++ b/tests/fixtures/environmentStateServing.ts @@ -0,0 +1,46 @@ +import type { ReplayArguments } from "../../src/application/environment/environmentReplay.js"; +import { EnvironmentStateStore } from "../../src/application/environment/environmentStateStore.js"; +import { environmentToolAnswer } from "../../src/application/environment/environmentToolAnswer.js"; +import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js"; +import { timedCaptureEvidence, type TimedCaptureRow } from "./analysisView.js"; +import { + environmentStateServeFixture, + environmentStateServeInputs, +} from "./environmentServe.js"; +import { BOOK_ROWS } from "./environmentStateWrites.js"; + +/** A served environment over the state derived from a capture of `rows`, answering tool calls as the server does. */ +export const servedFrom = (rows: readonly TimedCaptureRow[] = BOOK_ROWS) => { + const { source, state } = environmentStateServeInputs( + environmentStateServeFixture(timedCaptureEvidence(rows)), + ); + const store = new EnvironmentStateStore(state); + const respond = environmentToolAnswer(source, store); + const call = (name: string, args: ReplayArguments) => { + const tool = source.manifest.tools.find( + (candidate) => candidate.name === name, + ); + if (tool === undefined) throw new Error(`The manifest lacks ${name}`); + return respond(tool, args); + }; + return { + source, + state, + store, + call, + answer: (name: string, args: ReplayArguments = {}) => { + const result = call(name, args); + if (!result.ok) throw result.error; + return result.value; + }, + refusal: (name: string, args: ReplayArguments = {}) => { + const result = call(name, args); + if (result.ok) throw new Error(`Expected ${name} to be refused`); + return projectAnalysisError(result.error); + }, + /** What data/get reports, as text, so whole states compare byte for byte. */ + snapshot: () => JSON.stringify(store.snapshot()), + }; +}; + +export type Served = ReturnType;