From d6811b2aad51eff4e8b47565a41c887a9dad440f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 12:45:13 +0000 Subject: [PATCH] fix(rest): emit the projected export header on an empty result set (#3547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /data/:object/export` wrote a zero-byte file whenever the query matched no rows — the header was only ever written alongside the first data chunk. The `getReadableFields` projection derives the readable column set from schema + context, so it is known even with zero rows: "export columns don't depend on row content" is only true if it also holds at zero rows. The header is emitted only when the column set is AUTHORITATIVE — the security service's readable projection, or an explicit `?fields=` request (the caller named the columns, so echoing them discloses nothing new). When the header is schema-derived and the projection was unavailable, the export stays headerless as before: the masked-row fallback has no rows to narrow with, and writing the full schema header would name FLS-hidden columns. `header=false` still suppresses the header in every case. Tests: empty result emits the projected header (csv + xlsx); no projection stays headerless; explicit `?fields=` is echoed; `header=false` wins. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KZ2BGusRo58ZW8FMkDhGTb --- .changeset/export-empty-result-header.md | 19 +++++ packages/rest/src/export-integration.test.ts | 78 ++++++++++++++++++++ packages/rest/src/rest-server.ts | 31 ++++++++ 3 files changed, 128 insertions(+) create mode 100644 .changeset/export-empty-result-header.md diff --git a/.changeset/export-empty-result-header.md b/.changeset/export-empty-result-header.md new file mode 100644 index 0000000000..861f092b3a --- /dev/null +++ b/.changeset/export-empty-result-header.md @@ -0,0 +1,19 @@ +--- +"@objectstack/rest": patch +--- + +fix(rest): export emits the projected header row on an empty result set (#3547) + +`GET /data/:object/export` wrote a zero-byte file whenever the query matched no +rows — the header was only ever written alongside the first data chunk. With the +`getReadableFields` column projection the readable column set is derived from +schema + context, so it is known even when no rows come back: an empty CSV/xlsx +export now carries the exact readable header, which also makes it a usable +import template. + +The header is emitted only when the column set is AUTHORITATIVE — the security +service's readable projection, or an explicit `?fields=` request. When the header +is schema-derived and the projection was unavailable, the export stays headerless +as before: the masked-row fallback has no rows to narrow with, and writing the +full schema header would name FLS-hidden columns. `header=false` still suppresses +the header in every case. diff --git a/packages/rest/src/export-integration.test.ts b/packages/rest/src/export-integration.test.ts index 3b52c0d612..c50eab178e 100644 --- a/packages/rest/src/export-integration.test.ts +++ b/packages/rest/src/export-integration.test.ts @@ -490,4 +490,82 @@ describe('export route — FLS column projection via getReadableFields (#3547)', const header = parseCsv(csv.chunks.join(''))[0]; expect(header).toEqual(['ID', '标题']); // requested columns kept as asked }); + + // ------------------------------------------------------------------------- + // Empty result sets. "Export columns don't depend on row content" is only + // true if it also holds at ZERO rows — the case the masked-row inference + // (#3498) could never serve, because it had no rows to narrow with. + // ------------------------------------------------------------------------- + + it('empty result set: still emits the projected header (an exact, row-independent column set)', async () => { + // No rows at all. The service knows the readable columns from schema + + // context, so the export carries the precise header — and an empty export + // becomes a usable import template instead of a zero-byte file. + const { route } = await bootWithSecurity({ + getReadableFields: () => ['id', 'done', 'priority', 'due', 'owner'], + tasks: [], + }); + const csv = makeRes(); + await route.handler({ params: { object: 'task' }, query: { format: 'csv' } } as any, csv.res); + const rows = parseCsv(csv.chunks.join('')); + expect(rows).toHaveLength(1); // header only, no data rows + expect(rows[0]).toEqual(['ID', '完成', '优先级', '截止', '负责人']); + expect(rows[0]).not.toContain('标题'); // the masked column stays out + }); + + it('empty result set with NO projection available: stays headerless (never names FLS-hidden columns)', async () => { + // getReadableFields → undefined (schema unresolvable, or no security service + // at all) → the route falls back to masked-row inference, which has no rows. + // Writing the full schema header HERE would leak the names of FLS-hidden + // columns — the very leak #3391 closes — so the empty file stays headerless. + const { route } = await bootWithSecurity({ + getReadableFields: () => undefined, + tasks: [], + }); + const csv = makeRes(); + await route.handler({ params: { object: 'task' }, query: { format: 'csv' } } as any, csv.res); + expect(csv.chunks.join('')).toBe(''); + }); + + it('empty result set + explicit ?fields=: the requested header is echoed back', async () => { + // An explicit request is authoritative for the same reason the projection is: + // the caller named the columns, so nothing new is disclosed by echoing them. + const { route } = await bootWithSecurity({ + getReadableFields: () => ['id'], + tasks: [], + }); + const csv = makeRes(); + await route.handler( + { params: { object: 'task' }, query: { format: 'csv', fields: 'id,title' } } as any, + csv.res, + ); + expect(parseCsv(csv.chunks.join(''))).toEqual([['ID', '标题']]); + }); + + it('empty result set + header=false: no header, as asked', async () => { + const { route } = await bootWithSecurity({ + getReadableFields: () => ['id', 'done'], + tasks: [], + }); + const csv = makeRes(); + await route.handler( + { params: { object: 'task' }, query: { format: 'csv', header: 'false' } } as any, + csv.res, + ); + expect(csv.chunks.join('')).toBe(''); + }); + + it('xlsx: an empty result set carries the projected header row', async () => { + const { route } = await bootWithSecurity({ + getReadableFields: () => ['id', 'done'], + tasks: [], + }); + const { res, getBuffer } = makeBinRes(); + await route.handler({ params: { object: 'task' }, query: { format: 'xlsx' } } as any, res); + const wb = new ExcelJS.Workbook(); + await wb.xlsx.load(getBuffer() as any); + const ws = wb.worksheets[0]; + expect((ws.getRow(1).values as any[]).slice(1)).toEqual(['ID', '完成']); + expect(ws.rowCount).toBe(1); // header only + }); }); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 2e9df4245f..4ba30f6068 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -4219,6 +4219,11 @@ export class RestServer { // their option label, booleans to 是/否, dates to YYYY-MM-DD. When the // schema is unavailable the raw stored values stream through unchanged. // + // A zero-row result still emits the header row when the column set is + // authoritative (the security service's readable projection, or an explicit + // `fields=`), so an empty export doubles as an import template. Without a + // projection it stays headerless, so FLS-hidden column names never leak. + // // Streams the response so 50k-row exports do not buffer in memory; the // xlsx path pipes exceljs' streaming writer straight onto the response. // Filename suggests `${objectLabel}-${YYYYMMDD}-${HHMMSS}.${ext}` for @@ -4481,6 +4486,32 @@ export class RestServer { skip += rows.length; if (rows.length < take) break; } + // [#3547] Zero rows: still emit the header when the column set is + // AUTHORITATIVE. "Export columns don't depend on row content" is + // only true if it also holds at zero rows — and the readable + // projection above is derived from schema + context, so an empty + // result has an exact header to write (which also makes an empty + // export a usable import template). An explicit `?fields=` is + // authoritative for the same reason: the caller named the columns. + // + // Deliberately NOT emitted when the header is schema-derived and + // the projection was unavailable (`fieldsFromSchema && + // !readableProjected`): the masked-row fallback has no rows to + // narrow with, so writing the full schema header would name + // FLS-hidden columns — precisely the leak #3391 closes. That path + // keeps today's headerless empty file. + if ( + firstChunk && includeHeader && fields && fields.length > 0 && + (readableProjected || !fieldsFromSchema) + ) { + if (format === 'csv') { + res.write(rowsToCsv(fields, [], true, metaMap)); + } else if (format === 'xlsx') { + xlsx!.ws.addRow(fields.map((f) => headerLabel(f, metaMap))).commit(); + } + // json has no header concept — the empty array is already correct. + } + if (format === 'json') { res.write(']'); res.end();