From 6bf4a831ebf91a0fa65a1e817a40d2117cf6f498 Mon Sep 17 00:00:00 2001 From: ylm Date: Wed, 16 Sep 2026 17:23:12 -0400 Subject: [PATCH 1/2] Measure card write size per-file, not per whole request body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A card write POSTs a JSON:API document whose `included[]` inlines every linked card the tab has resident. The realm discards every included member without a `lid` on write (it keeps only the primary card plus any brand-new, unsaved links being created in the same request), then holds each resulting file to the card size limit individually. The client-side size check measured the entire concatenated request body instead, so a new card whose own document was a few KB could fail "Card size exceeds maximum allowed size" purely because of how much of the realm the tab happened to have loaded into `included[]` — a silent, session-dependent failure whose message named the wrong cause. Validate each resource that will actually become a file on its own — the primary card plus only the `lid`-bearing included members — mirroring the realm's own per-file write-size check. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014GsYGsuCqHJz9GAti4jheG --- packages/host/app/services/card-service.ts | 40 ++++++++++++++++++- .../host/tests/integration/store-test.gts | 39 ++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/packages/host/app/services/card-service.ts b/packages/host/app/services/card-service.ts index ce3ea6bc9e2..eefc6e9dacf 100644 --- a/packages/host/app/services/card-service.ts +++ b/packages/host/app/services/card-service.ts @@ -185,7 +185,7 @@ export default class CardService extends Service { typeof requestInit.body === 'string' ? requestInit.body : JSON.stringify(requestInit.body, null, 2); - this.validateSizeLimit(urlString, jsonString, 'card'); + this.validateCardWriteSize(urlString, jsonString); } let response = await this.network.authedFetch(url, requestInit); @@ -429,6 +429,44 @@ export default class CardService extends Service { return response.json(); } + // The 512 KB ceiling is a per-card-*file* limit, but a card write POSTs a + // document whose `included[]` inlines every linked card the tab happens to + // have resident — and the realm discards every included member that has no + // `lid` (it keeps only the primary card plus any brand-new, unsaved links it + // is being asked to create in the same request; see the realm's card POST + // handler). Measuring the concatenated body would therefore fail a tiny card + // because of cards it merely links to, so validate each resource that will + // actually become a file on its own — mirroring the realm's own per-file + // `assertWriteSize` — rather than the whole request body. + private validateCardWriteSize(url: string, body: string) { + let doc: LooseSingleCardDocument | undefined; + try { + doc = JSON.parse(body); + } catch { + // Not a JSON document we can split into resources; fall back to holding + // the whole body to the limit rather than letting an unmeasured write by. + } + if (!doc || typeof doc !== 'object' || !doc.data) { + this.validateSizeLimit(url, body, 'card'); + return; + } + // The primary card is always written; an included member is written only + // when it carries a `lid` (an unsaved link created alongside this card). + let resources = [ + doc.data, + ...(doc.included ?? []).filter( + (resource) => typeof (resource as { lid?: unknown }).lid === 'string', + ), + ]; + for (let resource of resources) { + this.validateSizeLimit( + url, + JSON.stringify({ data: resource }, null, 2), + 'card', + ); + } + } + private validateSizeLimit( url: string, content: string, diff --git a/packages/host/tests/integration/store-test.gts b/packages/host/tests/integration/store-test.gts index 797e8d57f39..cb54cf2d578 100644 --- a/packages/host/tests/integration/store-test.gts +++ b/packages/host/tests/integration/store-test.gts @@ -2201,6 +2201,45 @@ module('Integration | Store', function (hooks) { ); }); + test('a small new card saves even when its resident linked graph would overflow the size limit if inlined', async function (assert) { + // Regression: the client size check used to measure the whole POST body, + // `included[]` and all. A new card that links to already-saved cards the + // tab has loaded serialises those cards into `included` — which the realm + // discards on write — so a tiny card could fail "Card size exceeds maximum" + // purely because of how much of the realm the tab happened to have resident. + let environmentService = getService('environment-service') as any; + let originalMaxSize = environmentService.cardSizeLimitBytes; + try { + // A saved, resident linked card large enough that inlining it into + // `included` would blow the limit, while the new card's own document is + // tiny. Saved under the realm's default ceiling before the client limit + // is lowered; the realm keeps its own (unchanged) ceiling throughout. + let bigFriend = new PersonDef({ name: 'x'.repeat(6000) }); + let savedFriend = await (storeService as any).persistAndUpdate(bigFriend); + assert.true(isCardInstance(savedFriend), 'the large linked card saved'); + + environmentService.cardSizeLimitBytes = 2500; + + let instance = new PersonDef({ name: 'Small' }); + (instance as any).bestFriend = bigFriend; + + let result = await (storeService as any).persistAndUpdate(instance); + assert.true( + isCardInstance(result), + "a new card whose own document is under the limit saves regardless of how large a graph it links to — the check measures what the realm stores, not the tab's loaded `included`", + ); + let cardPath = `${(instance as any).id.substring( + testRealmURL.length, + )}.json`; + assert.ok( + await testRealmAdapter.openFile(cardPath), + 'the realm holds the created card', + ); + } finally { + environmentService.cardSizeLimitBytes = originalMaxSize; + } + }); + test('a save overlapping a create PATCHes instead of issuing a second POST', async function (assert) { // Driven through `persistAndUpdate` rather than `save`, because the // autosave queue awaits the in-flight mutation before it saves at all — From a59c285fff680c913b31cec837e815b40f4f11c6 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Wed, 16 Sep 2026 18:38:06 -0400 Subject: [PATCH 2/2] Pin the size check's co-created half; state the client's stricter edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The regression test pinned only the permissive half of the per-resource filter — nothing failed if lid-bearing side-loads stopped being measured. The counterpart test saves a card alongside an oversized unsaved link and asserts the refusal names the size limit. The filter comment now also states the one edge where the client is deliberately stricter than the realm: a foreign-realm lid side-load is measured here but silently skipped (never written) there — a request defect the realm swallows, not a contract to reproduce. Co-Authored-By: Claude Fable 5 --- packages/host/app/services/card-service.ts | 8 +++++ .../host/tests/integration/store-test.gts | 31 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/packages/host/app/services/card-service.ts b/packages/host/app/services/card-service.ts index eefc6e9dacf..66c69152a7b 100644 --- a/packages/host/app/services/card-service.ts +++ b/packages/host/app/services/card-service.ts @@ -438,6 +438,14 @@ export default class CardService extends Service { // because of cards it merely links to, so validate each resource that will // actually become a file on its own — mirroring the realm's own per-file // `assertWriteSize` — rather than the whole request body. + // + // The client is deliberately the stricter of the two on one edge: the realm + // additionally skips (without error) any resource whose `meta.realmURL` + // names a different realm, so a foreign-realm `lid` side-load is measured + // here but silently dropped there. That shape is a request defect the realm + // currently swallows — a co-create the write will never perform — so + // refusing it client-side over size is acceptable, and matching the skip + // would mean reproducing a silent-drop behavior rather than a contract. private validateCardWriteSize(url: string, body: string) { let doc: LooseSingleCardDocument | undefined; try { diff --git a/packages/host/tests/integration/store-test.gts b/packages/host/tests/integration/store-test.gts index cb54cf2d578..116ffd9a1ef 100644 --- a/packages/host/tests/integration/store-test.gts +++ b/packages/host/tests/integration/store-test.gts @@ -2240,6 +2240,37 @@ module('Integration | Store', function (hooks) { } }); + test('an oversized unsaved link created alongside a card still fails the size check', async function (assert) { + // The counterpart that makes the per-resource filter load-bearing: a + // `lid`-bearing side-load becomes its own file on the realm, so it must + // keep being measured. If the check stopped measuring included members + // entirely, this save would sail through client-side and 413 on the + // realm instead. + let environmentService = getService('environment-service') as any; + let originalMaxSize = environmentService.cardSizeLimitBytes; + try { + environmentService.cardSizeLimitBytes = 2500; + + let bigUnsaved = new PersonDef({ name: 'x'.repeat(6000) }); + let instance = new PersonDef({ name: 'Small' }); + (instance as any).bestFriend = bigUnsaved; + + let result = await (storeService as any).persistAndUpdate(instance); + assert.false( + isCardInstance(result), + 'the save is refused: the unsaved link is co-created as its own file and is over the ceiling', + ); + assert.ok( + String((result as any)?.message).includes( + 'exceeds maximum allowed size', + ), + `the error names the size limit (got: ${(result as any)?.message})`, + ); + } finally { + environmentService.cardSizeLimitBytes = originalMaxSize; + } + }); + test('a save overlapping a create PATCHes instead of issuing a second POST', async function (assert) { // Driven through `persistAndUpdate` rather than `save`, because the // autosave queue awaits the in-flight mutation before it saves at all —