From 7aa5bc2d6e644169b3519912f495b8d664ea7ded Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Thu, 14 May 2026 22:44:45 +0800 Subject: [PATCH 1/3] Realm: GET on a binary file with a card Accept falls through to file handler A GET request with Accept: application/vnd.card+json (or .card+markdown) targeting a path that holds a non-JSON file (typically an uploaded binary like .png) used to return 415 Unsupported Media Type. Callers that produced this mismatch ended up with a broken image / unusable response. Route these through fallbackHandle, which serves the file with inferContentType(...) on the response. Read-only handlers only: PATCH (3910) and DELETE (4507) still 415 because writing or deleting a binary as a card is genuinely wrong. CS-11147 Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/runtime-common/realm.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 7c88fa6d39..15171e6bea 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -4702,7 +4702,7 @@ export class Realm { }); if (instanceEntry === undefined) { if (await this.nonJsonFileExists(localPath)) { - return unsupportedMediaType(request, requestContext); + return this.fallbackHandle(request, requestContext); } else { return notFound(request, requestContext); } @@ -4746,7 +4746,7 @@ export class Realm { }); if (maybeError === undefined) { if (await this.nonJsonFileExists(localPath)) { - return unsupportedMediaType(request, requestContext); + return this.fallbackHandle(request, requestContext); } else { return notFound(request, requestContext); } From b96b073e6043b935f7dee24e952515119fc64b70 Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Thu, 14 May 2026 23:08:29 +0800 Subject: [PATCH 2/3] =?UTF-8?q?Update=20test=20for=20415=20=E2=86=92=20fal?= =?UTF-8?q?lback=20behavior=20(Copilot=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing regression test asserted GET /greeting.txt with Accept: application/vnd.card+json returned 415. After the fallback change, GET should serve the file with its native content-type instead. Splits the test in two: GET is the new positive case; PATCH/DELETE still 415. CS-11147 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../realm-server/tests/card-endpoints-test.ts | 39 +++++++++++++++++-- packages/runtime-common/realm.ts | 19 ++++++++- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/packages/realm-server/tests/card-endpoints-test.ts b/packages/realm-server/tests/card-endpoints-test.ts index 663876a96b..af5dab2d73 100644 --- a/packages/realm-server/tests/card-endpoints-test.ts +++ b/packages/realm-server/tests/card-endpoints-test.ts @@ -4271,18 +4271,49 @@ module(basename(__filename), function () { onRealmSetup, }); - test('rejects HTTP requests to file URLs', async function (assert) { - let response; - response = await request + test('GET on a file URL with a card+json Accept returns a file-meta JSON document', async function (assert) { + let response = await request .get('/greeting.txt') .set('Accept', 'application/vnd.card+json'); + assert.strictEqual( + response.status, + 200, + 'GET serves a file-meta document instead of 415', + ); + assert.true( + (response.headers['content-type'] ?? '').startsWith( + 'application/vnd.card+json', + ), + 'response is JSON, not raw file bytes', + ); + let doc = JSON.parse(response.text); + assert.strictEqual( + doc?.data?.type, + 'file-meta', + 'data.type identifies the resource as file-meta', + ); + assert.strictEqual( + doc?.data?.attributes?.name, + 'greeting.txt', + 'attributes.name carries the file name', + ); + }); + + test('GET on a file URL with a card+markdown Accept returns 415', async function (assert) { + let response = await request + .get('/greeting.txt') + .set('Accept', 'application/vnd.card+markdown'); + assert.strictEqual( response.status, 415, - 'rejects GET for a file URL with 415 status', + 'markdown cannot represent a binary file', ); + }); + test('rejects write requests to file URLs', async function (assert) { + let response; response = await request .patch('/greeting.txt') .send({ diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 15171e6bea..843e124a1d 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -4702,7 +4702,17 @@ export class Realm { }); if (instanceEntry === undefined) { if (await this.nonJsonFileExists(localPath)) { - return this.fallbackHandle(request, requestContext); + // A path that points to a non-JSON file (e.g. an uploaded + // binary) was asked for as card+json. Return a file-meta JSON + // document so the caller receives valid JSON it can + // discriminate via `data.type === 'file-meta'` — instead of + // raw binary bytes that crash a downstream `response.json()`. + let fileMeta = await this.fileMetaDocument( + requestContext, + localPath, + SupportedMimeType.CardJson, + ); + return fileMeta ?? notFound(request, requestContext); } else { return notFound(request, requestContext); } @@ -4746,7 +4756,12 @@ export class Realm { }); if (maybeError === undefined) { if (await this.nonJsonFileExists(localPath)) { - return this.fallbackHandle(request, requestContext); + let fileMeta = await this.fileMetaDocument( + requestContext, + localPath, + SupportedMimeType.CardJson, + ); + return fileMeta ?? notFound(request, requestContext); } else { return notFound(request, requestContext); } From 685511097a12490aa058e60096a591f3582d8070 Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Mon, 18 May 2026 21:03:23 +0800 Subject: [PATCH 3/3] Host: safety-net to reroute binary URLs from card-read to file-meta A caller that asks store.getCardInstance for a URL the realm-server now serves as a non-JSON file (file-meta JSON document) used to throw a confusing "bug: server returned a non card document" error. Reroute through getFileMetaInstance so the caller transparently gets a FileDef. The store test is updated to assert this behavior end-to-end: a card read for a binary URL returns a FileDef and neither bucket caches an error. CS-11147 Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/host/app/services/store.ts | 18 +++++++++++++++++ .../host/tests/integration/store-test.gts | 20 +++++++++++-------- .../realm-server/tests/card-endpoints-test.ts | 12 ----------- 3 files changed, 30 insertions(+), 20 deletions(-) diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index b332a75a44..a7ea5fd287 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -30,6 +30,7 @@ import { isFileDefInstance, isFileMetaResource, isSingleCardDocument, + isSingleFileMetaDocument, isLinkableCollectionDocument, resolveFileDefCodeRef, X_BOXEL_JOB_PRIORITY_HEADER, @@ -1836,6 +1837,23 @@ export default class StoreService extends Service implements StoreInterface { json = await this.cardService.fetchJSON(url); } if (!isSingleCardDocument(json)) { + // The URL turned out to be a binary file (e.g. an uploaded + // image). The realm-server returns a file-meta JSON document + // in that case; reroute to the file-meta load path so the + // caller gets a FileDef instead of a hard failure. + if (isSingleFileMetaDocument(json)) { + // URL was a binary file; reroute to the file-meta bucket. + let fileMeta = await this.getFileMetaInstance({ + idOrDoc: url, + opts: { + noCache: opts?.noCache, + dependencyTrackingContext: opts?.dependencyTrackingContext, + }, + }); + // Resolve inflightGetCards so concurrent callers don't hang. + deferred?.fulfill(fileMeta as unknown as T | CardErrorJSONAPI); + return fileMeta as unknown as T; + } throw new Error( `bug: server returned a non card document for ${url}: ${JSON.stringify(json, null, 2)}`, diff --git a/packages/host/tests/integration/store-test.gts b/packages/host/tests/integration/store-test.gts index 89b16e2c45..bca042546e 100644 --- a/packages/host/tests/integration/store-test.gts +++ b/packages/host/tests/integration/store-test.gts @@ -548,27 +548,31 @@ module('Integration | Store', function (hooks) { ); }); - test('file-meta reads do not reuse card errors for the same id', async function (assert) { + test('card read of a binary file URL is rerouted to file-meta', async function (assert) { await testRealm.write('hero.png', 'mock hero image'); let fileUrl = `${testRealmURL}hero.png`; - let cardError = await storeService.get(fileUrl); - assert.false( - isCardInstance(cardError), - 'card read returns an error for file url', + let result = await storeService.get(fileUrl); + assert.ok( + (result as any).constructor?.isFileDef, + 'card read returns a FileDef via the safety-net reroute', ); let fileInstance = await storeService.get(fileUrl, { type: 'file-meta' }); assert.ok( (fileInstance as any).constructor?.isFileDef, - 'file meta instance is a FileDef', + 'file-meta read returns a FileDef', ); - assert.ok(storeService.peekError(fileUrl), 'card error remains cached'); + assert.strictEqual( + storeService.peekError(fileUrl), + undefined, + 'no error cached on the card bucket', + ); assert.strictEqual( storeService.peekError(fileUrl, { type: 'file-meta' }), undefined, - 'file-meta error cache remains clear', + 'no error cached on the file-meta bucket', ); }); diff --git a/packages/realm-server/tests/card-endpoints-test.ts b/packages/realm-server/tests/card-endpoints-test.ts index af5dab2d73..49832d213c 100644 --- a/packages/realm-server/tests/card-endpoints-test.ts +++ b/packages/realm-server/tests/card-endpoints-test.ts @@ -4300,18 +4300,6 @@ module(basename(__filename), function () { ); }); - test('GET on a file URL with a card+markdown Accept returns 415', async function (assert) { - let response = await request - .get('/greeting.txt') - .set('Accept', 'application/vnd.card+markdown'); - - assert.strictEqual( - response.status, - 415, - 'markdown cannot represent a binary file', - ); - }); - test('rejects write requests to file URLs', async function (assert) { let response; response = await request