From 617255a015c46fd58fd57da755f2f03a2a751c62 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 23:31:53 +0000 Subject: [PATCH 1/4] fix(rest): the import template answers to the import door's gates, not the export's (#20896) Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../rest/src/import-template-route.test.ts | 134 +++++++++++++++--- packages/rest/src/rest-server.ts | 101 +++++++++++-- 2 files changed, 207 insertions(+), 28 deletions(-) diff --git a/packages/rest/src/import-template-route.test.ts b/packages/rest/src/import-template-route.test.ts index 70d6313b305..6bd46f7cd12 100644 --- a/packages/rest/src/import-template-route.test.ts +++ b/packages/rest/src/import-template-route.test.ts @@ -8,7 +8,9 @@ * registry injects onto every object are really there to be excluded. * * Also here: the proof that WITHOUT `?template=true` the export is byte for - * byte what it was before the mode existed — see the last describe block. + * byte what it was before the mode existed — see the last describe block — + * and that the template is judged by the IMPORT door's gates, never the + * export's (#20896 ruling A). */ import { describe, it, expect, afterEach, vi } from 'vitest'; @@ -104,6 +106,12 @@ const DEAL = { /** The seven columns the registry injects onto every object (the card's table). */ const INJECTED = ['organization_id', 'created_at', 'created_by', 'updated_at', 'updated_by', 'owner_id', 'owning_business_unit_id']; +/** + * A security service's `explain` answering "may create" for every object — + * the caller half of the import door's gates on `?template=true` (#20896). + */ +const MAY_CREATE = async (request: { operation: string }) => ({ allowed: request.operation === 'create' }); + interface BootOptions { security?: Record; } @@ -130,7 +138,7 @@ async function boot(opts: BootOptions = {}) { await exportRoute.handler({ params: { object }, query, headers } as any, out.res); return out; }; - return { engine, protocol, findData, get, importRoute }; + return { engine, protocol, findData, get, importRoute, rest }; } const headerRow = async (bytes: Buffer, sheet = 0) => { @@ -221,7 +229,7 @@ describe('?template=true — field-level security: the WRITE projection', () => it('a field the caller may read but not edit is absent, and the response names the write projection', async () => { const { get } = await boot({ - security: { canExport: async () => true, getReadableFields: async () => READABLE, getWritableFields: async () => WRITABLE }, + security: { explain: MAY_CREATE, getReadableFields: async () => READABLE, getWritableFields: async () => WRITABLE }, }); const out = await get({ template: 'true' }); const { wb, header } = await headerRow(out.body()); @@ -233,7 +241,7 @@ describe('?template=true — field-level security: the WRITE projection', () => }); it('a security service without getWritableFields: narrowed by the read projection, and the response says so', async () => { - const { get } = await boot({ security: { canExport: async () => true, getReadableFields: async () => READABLE } }); + const { get } = await boot({ security: { explain: MAY_CREATE, getReadableFields: async () => READABLE } }); const out = await get({ template: 'true' }); const { wb, header } = await headerRow(out.body()); expect(header).toContain('Salary'); @@ -244,7 +252,7 @@ describe('?template=true — field-level security: the WRITE projection', () => it('a security service that answers neither projection fails the request instead of an unnarrowed header', async () => { const { get } = await boot({ - security: { canExport: async () => true, getWritableFields: async () => undefined, getReadableFields: async () => undefined }, + security: { explain: MAY_CREATE, getWritableFields: async () => undefined, getReadableFields: async () => undefined }, }); const out = await get({ template: 'true' }); expect(out.status()).toBe(500); @@ -253,26 +261,85 @@ describe('?template=true — field-level security: the WRITE projection', () => }); }); -describe('?template=true — the two existing gates still decide', () => { - it('a caller without the export permission is refused 403 and gets no workbook', async () => { - const { get } = await boot({ security: { canExport: async () => false, getReadableFields: async () => ['title'] } }); +describe('?template=true — the IMPORT door\'s gates decide, not the export\'s (#20896 ruling A)', () => { + // The template carries no records, so it answers to whoever may import: + // the object's `import` exposure, then the caller's create permission. The + // export gates (`export` exposure, `canExport`) neither admit nor refuse it. + const noCreate = async () => ({ allowed: false }); + + it('create on the object and no allowExport → 200 and the workbook', async () => { + const canExport = vi.fn(async () => false); + const explain = vi.fn(MAY_CREATE); + const { get } = await boot({ security: { canExport, explain, getWritableFields: async () => ['title', 'stage'] } }); + const out = await get({ template: 'true' }); + expect(out.status()).toBe(200); + expect(out.headers['Content-Type']).toBe('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + expect(out.headers['X-Export-Template']).toBe('true'); + expect((await headerRow(out.body())).header).toEqual(['Title *', 'Stage']); + expect(explain).toHaveBeenCalledWith({ object: 'deal', operation: 'create' }, expect.objectContaining({ userId: 'test-user' })); + expect(canExport).not.toHaveBeenCalled(); + }); + + it('no create → 403 PERMISSION_DENIED from the import door, and the template builder is never called', async () => { + const getWritableFields = vi.fn(async () => ['title']); + const { get, rest } = await boot({ security: { canExport: async () => false, explain: noCreate, getWritableFields } }); + const builder = vi.spyOn(rest as any, 'answerImportTemplate'); + const out = await get({ template: 'true' }); + expect(out.status()).toBe(403); + expect(out.json()).toMatchObject({ code: 'PERMISSION_DENIED', object: 'deal' }); + expect(out.body().length).toBe(0); + expect(builder).not.toHaveBeenCalled(); + expect(getWritableFields).not.toHaveBeenCalled(); + }); + + it('allowExport and no create → 403 on template=true — the export permission admits no template', async () => { + const canExport = vi.fn(async () => true); + const getWritableFields = vi.fn(async () => ['title']); + const { get, rest } = await boot({ security: { canExport, explain: noCreate, getWritableFields } }); + const builder = vi.spyOn(rest as any, 'answerImportTemplate'); + const out = await get({ template: 'true' }); + expect(out.status()).toBe(403); + expect(out.json()).toMatchObject({ code: 'PERMISSION_DENIED', object: 'deal' }); + expect(out.body().length).toBe(0); + expect(builder).not.toHaveBeenCalled(); + expect(getWritableFields).not.toHaveBeenCalled(); + expect(canExport).not.toHaveBeenCalled(); + }); + + it('a create verdict that throws is a denial, never a grant', async () => { + const { get } = await boot({ + security: { explain: async () => { throw new Error('permission sets did not resolve'); }, getWritableFields: async () => ['title'] }, + }); const out = await get({ template: 'true' }); expect(out.status()).toBe(403); - expect(out.json()).toMatchObject({ code: 'EXPORT_NOT_PERMITTED' }); + expect(out.json()).toMatchObject({ code: 'PERMISSION_DENIED' }); expect(out.body().length).toBe(0); }); - it('an object that does not expose export is refused before the template', async () => { + it('an object exposing create but not export serves the template — its export exposure no longer stands in front', async () => { const { get, engine } = await boot(); engine.registry.registerObject({ - // `export` is derived from `list`, so only a whitelist without `list` closes it. - name: 'locked', label: 'Locked', enable: { apiMethods: ['get'] }, + // `export` is derived from `list`; `import` from `create ∨ update`. + name: 'intake', label: 'Intake', enable: { apiMethods: ['create'] }, + fields: { title: { name: 'title', type: 'text', label: 'Title' } }, + } as any); + const out = await get({ template: 'true' }, 'intake'); + expect(out.status()).toBe(200); + expect((await headerRow(out.body())).header).toEqual(['Title']); + }); + + it('an object exposing export but neither create nor update is refused 405 before the template', async () => { + const { get, engine, rest } = await boot(); + engine.registry.registerObject({ + name: 'ledger', label: 'Ledger', enable: { apiMethods: ['get', 'list'] }, fields: { title: { name: 'title', type: 'text' } }, } as any); - const out = await get({ template: 'true' }, 'locked'); + const builder = vi.spyOn(rest as any, 'answerImportTemplate'); + const out = await get({ template: 'true' }, 'ledger'); expect(out.status()).toBe(405); expect(out.json()).toMatchObject({ code: 'OBJECT_API_METHOD_NOT_ALLOWED' }); expect(out.body().length).toBe(0); + expect(builder).not.toHaveBeenCalled(); }); }); @@ -556,8 +623,11 @@ const PRE_CHANGE: Record) { const engine = new ObjectQL(); liveEngines.push(engine); engine.registerDriver(makeSqliteDriver(), true); @@ -586,19 +656,22 @@ async function bootExportFixture() { await engine.insert('task', { id: '1', title: '写代码', done: true, priority: 'high', due: '2026-06-30T00:00:00.000Z', owner: 'u1' }); await engine.insert('task', { id: '2', title: '写文档', done: false, priority: 'low', due: '2026-07-01T00:00:00.000Z', owner: 'u2' }); const protocol = new ObjectStackProtocolImplementation(engine as any); + const findData = vi.spyOn(protocol as any, 'findData'); const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); (rest as any).resolveExecCtx = async () => ({ userId: 'test-user', timezone: 'UTC' }); + if (security) (rest as any).resolveSecurityService = async () => security; rest.registerRoutes(); - return rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object/export')!; + const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object/export')!; + return { route, findData }; } -async function exportOnce(query: Record) { +async function exportOnce(query: Record, security?: Record) { vi.useFakeTimers({ now: new Date(FROZEN_NOW), toFake: ['Date'] }); try { - const route: any = await bootExportFixture(); + const { route, findData } = await bootExportFixture(security); const out = makeRes(); await route.handler({ params: { object: 'task' }, query } as any, out.res); - return out; + return { ...out, findData }; } finally { vi.useRealTimers(); } @@ -625,4 +698,27 @@ describe('without ?template=true the export is byte-identical to the pre-change expect(off.body().equals(plain.body())).toBe(true); expect(off.body().toString('utf8')).toBe(PRE_CHANGE.csvDefault.text); }); + + // [#20896] The template's gate swap reaches no export request: the export + // is still judged by `canExport`, and the create verdict is never asked. + it('without allowExport: 403 EXPORT_NOT_PERMITTED before a row is read, whatever the caller may create', async () => { + const explain = vi.fn(MAY_CREATE); + const out = await exportOnce({}, { canExport: async () => false, explain }); + expect(out.status()).toBe(403); + expect(out.json()).toMatchObject({ code: 'EXPORT_NOT_PERMITTED', object: 'task' }); + expect(out.body().length).toBe(0); + expect(out.findData).not.toHaveBeenCalled(); + expect(explain).not.toHaveBeenCalled(); + }); + + it('with allowExport and without create: the pre-change bytes, the create verdict never asked', async () => { + const explain = vi.fn(async () => ({ allowed: false })); + const out = await exportOnce({}, { canExport: async () => true, explain }); + const body = out.body(); + expect(out.status()).toBe(200); + expect(out.headers).toEqual(PRE_CHANGE.csvDefault.headers); + expect(body.toString('utf8')).toBe(PRE_CHANGE.csvDefault.text); + expect(createHash('sha256').update(body).digest('hex')).toBe(PRE_CHANGE.csvDefault.sha256); + expect(explain).not.toHaveBeenCalled(); + }); }); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 963b71e880a..b67bd841ba2 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -644,7 +644,10 @@ export const DATA_RECORD_READ_PARAMS: readonly string[] = ['select', 'expand']; * [#18386] …and `template`, the mode switch: `template=true` answers an xlsx * IMPORT template (`./import-template.ts`) instead of the data. It is read by * `readTemplateMode`, which also refuses the row parameters above on a - * template request, since a template has no rows for them to select. + * template request, since a template has no rows for them to select. It also + * switches the door's gates: a template request is judged by the IMPORT door's + * (the object's import exposure and the caller's create permission), not by + * the export's — a template carries no records to egress ([#20896] ruling A). */ export const DATA_EXPORT_PARAMS: readonly string[] = [ 'format', 'header', @@ -2513,6 +2516,71 @@ export class RestServer { return true; } + /** + * [#20896] The gates `GET …/export?template=true` answers behind: the IMPORT + * door's, never the export's. Returns `true` when a response was sent (the + * caller must return). + * + * A template carries no records — the columns this caller may write, one + * example row of placeholder values and an instructions sheet — so the + * export axis, which segregates a bulk copy of DATA, has nothing to guard + * on it. It belongs to the import it is filled in for: whoever may import + * may download it, and nobody else (ruling A on #20896). + * + * 1. The OBJECT half is the import door's own first gate, the same call + * `POST …/import` makes before it parses a file: + * {@link enforceApiAccess} for `import`, which the spec derives as + * `create ∨ update` (404 when the object is not exposed, 405 when it + * exposes neither). Its second, precise gate is not asked: that one + * needs the write mode a request body names, and a template request + * names none. Nor does a mode shape the template — its columns are + * `templateColumns` over the security service's `getWritableFields`, + * which takes no operation. + * 2. The CALLER half is the create permission — the verdict the engine's + * security middleware reaches on every row the import door writes, and + * answers there as a `PERMISSION_DENIED` row. The import door never + * asks it before a write; this door writes nothing, so it asks the + * security service for that same verdict: `explain` for `create`, the + * contract's own "would the middleware allow this operation?" bottom + * line, computed by the enforcement walk rather than re-derived here + * from permission sets. + * + * Fail stance, as {@link enforceExportPermission}'s: no security service, + * or one without `explain`, → allow (no permission sets exist to deny + * with); `explain` throwing → deny, never read as a grant. One direction is + * stricter than a write: `explain` denies a caller whose permission sets + * resolve EMPTY, where the middleware skips its CRUD gate — reachable only + * on a deployment that configures no baseline set at all, and in the closed + * direction. + */ + private async enforceImportTemplateGates( + req: any, + res: any, + p: RestProtocol, + environmentId: string | undefined, + objectName: string, + context: any, + ): Promise { + if (await this.enforceApiAccess(req, res, p, environmentId, 'import')) return true; + const security = await this.resolveSecurityService(environmentId, req); + if (!security || typeof security.explain !== 'function') return false; + let allowed: boolean; + try { + const decision = await security.explain({ object: objectName, operation: 'create' }, context); + allowed = decision?.allowed === true; + } catch { + allowed = false; // access-narrowing answer → a throw is a denial + } + if (allowed) return false; + res.status(403).json({ + code: 'PERMISSION_DENIED', + error: `Creating records on object '${objectName}' is not permitted for this user, ` + + 'so its import template is not served', + object: objectName, + }); + return true; + } + /** * Load the object metadata items for the current protocol/environment, * coerced to a plain array — `loadObjectItems` in @@ -9630,10 +9698,22 @@ export class RestServer { res.status(400).json({ code: 'INVALID_REQUEST', error: 'object is required' }); return; } - if (await this.enforceApiAccess(req, res, p, environmentId, 'export')) return; - // [#3544] …then the USER-level one. The object may expose - // export while THIS caller's permission sets deny it. - if (await this.enforceExportPermission(req, res, environmentId, objectName, context)) return; + // [#20896] Which door's gates judge the request is decided by + // what the caller ASKED for: `template=true` asks for the + // import template, which the IMPORT door's gates judge (see + // `enforceImportTemplateGates`); everything else is the + // export, judged exactly as before. Only the `template` + // value is read here, so a template request that also names + // a row parameter is still one — and is refused 400 below, + // after these gates, as it was after the export's. + if (readTemplateMode({ template: req.query?.template }).kind === 'template') { + if (await this.enforceImportTemplateGates(req, res, p, environmentId, objectName, context)) return; + } else { + if (await this.enforceApiAccess(req, res, p, environmentId, 'export')) return; + // [#3544] …then the USER-level one. The object may expose + // export while THIS caller's permission sets deny it. + if (await this.enforceExportPermission(req, res, environmentId, objectName, context)) return; + } // [#6877] The worst measured outcome on this surface: // `?limit=1&limit=2` → `Number([...])` is `NaN` → `NaN || 0` // is `0` → `Math.max(1, 0)` is `1`, so the caller downloaded @@ -10042,10 +10122,13 @@ export class RestServer { * write, one example row, dropdowns for the closed value domains, and an * instructions sheet. No data is read. * - * It runs behind the export door's two gates, unchanged — the object's - * `export` exposure ({@link enforceApiAccess}) and the caller's export - * permission ({@link enforceExportPermission}) — and after the query-string - * gates, so it is reached only by a request the export would have served. + * It runs behind the IMPORT door's gates, not the export's + * ({@link enforceImportTemplateGates}, [#20896] ruling A) — the object's + * `import` exposure ({@link enforceApiAccess}) and the caller's create + * permission — because it carries no records, only what an importer needs + * to fill in; a caller may hold one door and not the other, and the export + * permission ({@link enforceExportPermission}) neither admits nor refuses a + * template. It runs after the route's query-string gates as well. * * Columns: an explicit `?fields=` is honoured as asked; otherwise * `templateColumns` over the object as this caller reads it, narrowed by From 3c7d7dd4efaca806f20bead80caf08d54f3e8a57 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 23:45:11 +0000 Subject: [PATCH 2/4] docs(rest): the template's changeset and permission docs name the import door's gates (#20896) Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .changeset/18386-export-import-template.md | 7 +++--- .changeset/20896-template-import-door.md | 23 ++++++++++++++++++++ content/docs/permissions/permission-sets.mdx | 4 +++- 3 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 .changeset/20896-template-import-door.md diff --git a/.changeset/18386-export-import-template.md b/.changeset/18386-export-import-template.md index 542d0899944..c110af4430c 100644 --- a/.changeset/18386-export-import-template.md +++ b/.changeset/18386-export-import-template.md @@ -30,8 +30,9 @@ Without a `template` parameter the export is exactly as before, byte for byte. - **Language.** The sheets are in Chinese for a `zh` request locale (`?locale=` or `Accept-Language`) and in English otherwise. -The same two permission checks as the export apply: an object that does not -expose export answers `405`, and a caller without the export permission answers -`403`. `template` with a value other than `true` or `false`, a `format` other +The import's permission checks apply, not the export's: an object that exposes +neither create nor update answers `405`, and a caller without the create +permission on the object answers `403`. The export permission (`allowExport`) is +not needed. `template` with a value other than `true` or `false`, a `format` other than `xlsx`, or any of `limit`, `page`, `filter`, `search`, `searchFields`, `orderby` or `header` beside `template=true`, answers `400 VALIDATION_ERROR`. diff --git a/.changeset/20896-template-import-door.md b/.changeset/20896-template-import-door.md new file mode 100644 index 00000000000..976726e5066 --- /dev/null +++ b/.changeset/20896-template-import-door.md @@ -0,0 +1,23 @@ +--- +'@objectstack/rest': patch +--- + +fix(rest): the import template (`GET /api/v1/data/:object/export?template=true`) is gated by the import door's permissions, not the export's + +Clause-②: no + +The template carries no records — only the columns the caller may write, one +example row and an instructions sheet — so it answers to whoever may import, and +the export permission (`allowExport`) neither admits nor refuses it: + +- A caller with the create permission on the object gets the template, with or + without `allowExport`. +- A caller without the create permission gets `403 PERMISSION_DENIED`, with or + without `allowExport`. +- An object whose `enable.apiMethods` exposes neither `create` nor `update` + answers `405 OBJECT_API_METHOD_NOT_ALLOWED`, as `POST /api/v1/data/:object/import` + does. An object that exposes `create` without `list` serves the template. +- Without `template=true` the export is unchanged: the same two export checks + and the same bytes. + +To let a role download the template, grant it create on the object. diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index a307c482723..2256db64202 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -95,7 +95,9 @@ opt-in. Grant it through an ordinary position-distributed set instead. Enforcement is server-side and covers both egress doors: - `GET /api/v1/data/:object/export` answers `403 EXPORT_NOT_PERMITTED` before it - reads the first row. + reads the first row. Its import template (`?template=true`) is not a copy of + data — it carries no records — so it is gated by the import instead: the create + permission on the object, never `allowExport`. - A **report** rendered as `csv` or `json` is the same bulk copy and is gated the same way, whether run interactively or delivered by a schedule. `html_table` is a rendered view and stays a read. From 501dca7347ab8234eb36d08c984efb7b8ec67a03 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 00:39:43 +0000 Subject: [PATCH 3/4] fix(rest): the template's create refusal answers in the shared error envelope (#20896) check:route-envelope ratchets the flat sibling-code dialect down and refused the new 403 body; it is now built through sendError from @objectstack/types. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/rest/src/import-template-route.test.ts | 6 +++--- packages/rest/src/rest-server.ts | 14 +++++++++----- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/rest/src/import-template-route.test.ts b/packages/rest/src/import-template-route.test.ts index 6bd46f7cd12..ecc28c30740 100644 --- a/packages/rest/src/import-template-route.test.ts +++ b/packages/rest/src/import-template-route.test.ts @@ -286,7 +286,7 @@ describe('?template=true — the IMPORT door\'s gates decide, not the export\'s const builder = vi.spyOn(rest as any, 'answerImportTemplate'); const out = await get({ template: 'true' }); expect(out.status()).toBe(403); - expect(out.json()).toMatchObject({ code: 'PERMISSION_DENIED', object: 'deal' }); + expect(out.json()).toMatchObject({ success: false, error: { code: 'PERMISSION_DENIED', details: { object: 'deal' } } }); expect(out.body().length).toBe(0); expect(builder).not.toHaveBeenCalled(); expect(getWritableFields).not.toHaveBeenCalled(); @@ -299,7 +299,7 @@ describe('?template=true — the IMPORT door\'s gates decide, not the export\'s const builder = vi.spyOn(rest as any, 'answerImportTemplate'); const out = await get({ template: 'true' }); expect(out.status()).toBe(403); - expect(out.json()).toMatchObject({ code: 'PERMISSION_DENIED', object: 'deal' }); + expect(out.json()).toMatchObject({ success: false, error: { code: 'PERMISSION_DENIED', details: { object: 'deal' } } }); expect(out.body().length).toBe(0); expect(builder).not.toHaveBeenCalled(); expect(getWritableFields).not.toHaveBeenCalled(); @@ -312,7 +312,7 @@ describe('?template=true — the IMPORT door\'s gates decide, not the export\'s }); const out = await get({ template: 'true' }); expect(out.status()).toBe(403); - expect(out.json()).toMatchObject({ code: 'PERMISSION_DENIED' }); + expect(out.json()?.error?.code).toBe('PERMISSION_DENIED'); expect(out.body().length).toBe(0); }); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index b67bd841ba2..8e09988a931 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -2572,12 +2572,16 @@ export class RestServer { allowed = false; // access-narrowing answer → a throw is a denial } if (allowed) return false; - res.status(403).json({ - code: 'PERMISSION_DENIED', - error: `Creating records on object '${objectName}' is not permitted for this user, ` + // Built through the SHARED envelope (`{ success: false, error: { code, + // message, details } }`), not in the flat sibling-`code` dialect the + // export gate above still answers in — `check:route-envelope` ratchets + // that dialect down and refuses a new body in it. + sendEnvelopeError( + res, 403, 'PERMISSION_DENIED', + `Creating records on object '${objectName}' is not permitted for this user, ` + 'so its import template is not served', - object: objectName, - }); + { details: { object: objectName } }, + ); return true; } From 8e2d1fda64a01d9023177e0df3dc00397f848c57 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 01:55:28 +0000 Subject: [PATCH 4/4] revert(changeset): leave the pending 18386 release note as it stands on main (#20896) The maintainer chose B on decision 5922804275: the pending note is not corrected. The template's own patch changeset stays. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .changeset/18386-export-import-template.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.changeset/18386-export-import-template.md b/.changeset/18386-export-import-template.md index c110af4430c..542d0899944 100644 --- a/.changeset/18386-export-import-template.md +++ b/.changeset/18386-export-import-template.md @@ -30,9 +30,8 @@ Without a `template` parameter the export is exactly as before, byte for byte. - **Language.** The sheets are in Chinese for a `zh` request locale (`?locale=` or `Accept-Language`) and in English otherwise. -The import's permission checks apply, not the export's: an object that exposes -neither create nor update answers `405`, and a caller without the create -permission on the object answers `403`. The export permission (`allowExport`) is -not needed. `template` with a value other than `true` or `false`, a `format` other +The same two permission checks as the export apply: an object that does not +expose export answers `405`, and a caller without the export permission answers +`403`. `template` with a value other than `true` or `false`, a `format` other than `xlsx`, or any of `limit`, `page`, `filter`, `search`, `searchFields`, `orderby` or `header` beside `template=true`, answers `400 VALIDATION_ERROR`.