Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/client-explain-recordids-batch-spelling.md
Original file line numberDiff line numberDiff line change
@@ -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.
15 changes: 15 additions & 0 deletions packages/client/src/client.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 });
Expand Down
12 changes: 10 additions & 2 deletions packages/client/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<any> => {
const res = await this.fetch(`${this.baseUrl}/api/v1/security/explain`, {
method: 'POST',
Expand Down
Loading