diff --git a/.changeset/client-explain-recordids-batch-spelling.md b/.changeset/client-explain-recordids-batch-spelling.md new file mode 100644 index 0000000000..5cbed93bf1 --- /dev/null +++ b/.changeset/client-explain-recordids-batch-spelling.md @@ -0,0 +1,13 @@ +--- +"@objectstack/client": minor +--- + +feat(client): `security.explain()` accepts the `recordIds` batch spelling (#8480) + +The typed `security.explain()` request now declares the optional +`recordIds?: string[]` field alongside the existing `recordId?: string`, so a +typed-client consumer can reach the batch record-grained explain form added +server-side by #8326 without a cast. Type-level and TSDoc only — the method +still forwards the request body verbatim over POST; the 200-id cap and the +`recordId`/`recordIds` mutual exclusion are validated server-side by +`ExplainRequestSchema` (`@objectstack/spec`), unchanged. diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 298a2a9ad6..0018212176 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -440,6 +440,21 @@ describe('Security explain & global search (#3587 gap closure)', () => { expect(JSON.parse(init.body)).toEqual({ object: 'lead', operation: 'update', userId: 'u1', recordId: 'r1' }); }); + it('security.explain accepts the recordIds batch spelling and forwards it verbatim (#8480)', async () => { + // [#8480] Typed-client completion of #8326's batch spelling. The + // client does NOT validate the cap or the recordId/recordIds + // mutual exclusion — that stays the server's job + // (`ExplainRequestSchema`); this pins that the body goes over the + // wire exactly as given, unmodified, whether or not it would pass + // server-side validation. + const { client, fetchMock } = createMockClient({ allowed: true }); + await client.security.explain({ object: 'lead', operation: 'read', recordIds: ['r1', 'r2'] }); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toBe('http://localhost:3000/api/v1/security/explain'); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body)).toEqual({ object: 'lead', operation: 'read', recordIds: ['r1', 'r2'] }); + }); + it('search pins GET /search with q/objects/limit/perObject', async () => { const { client, fetchMock } = createMockClient({ results: [] }); await client.search('acme', { objects: ['lead', 'account'], limit: 20, perObject: 5 }); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 7ea8a55c9d..c54b0aaa46 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -3435,14 +3435,22 @@ export class ObjectStackClient { * perform `operation` on `object` — the same code paths enforcement * runs, so the report is explained by construction. Explaining ANOTHER * user requires `manage_users` (403 otherwise); `recordId` narrows to - * one concrete row (ADR-0095). Sent via the POST transport; the GET - * query form is the same contract. (#3587 gap closure) + * one concrete row (ADR-0095). `recordIds` is the batch form of the + * same record-grained question (ADR-0095 / #8326): 1–200 ids answered + * in one round trip, `decision.records[i]` answering `recordIds[i]`; + * mutually exclusive with `recordId` — the server refuses a request + * carrying both, or an empty/over-200 array, with a 400 + * (`ExplainRequestSchema` in `@objectstack/spec` is the authority; + * this method forwards the body verbatim and does not itself + * validate it). Sent via the POST transport; the GET query form is + * the same contract. (#3587 gap closure) */ explain: async (request: { object: string; operation?: 'read' | 'create' | 'update' | 'delete' | 'transfer' | 'restore' | 'purge'; userId?: string; recordId?: string; + recordIds?: string[]; }): Promise => { const res = await this.fetch(`${this.baseUrl}/api/v1/security/explain`, { method: 'POST',