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
35 changes: 35 additions & 0 deletions .changeset/cross-object-batch-authz-hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
'@objectstack/rest': patch
'@objectstack/spec': minor
---

fix(rest): gate the cross-object transactional batch by the same per-object API rules as single-record writes (#1604)

The `POST {basePath}/batch` route (issue #1604 / ADR-0034) wraps N cross-object
create/update/delete ops in one engine transaction, but it skipped the
per-object API-exposure gate every single-record route applies — an
authenticated caller could write to an `apiEnabled: false` object, or run an
operation outside an object's `apiMethods` whitelist, straight through the batch
surface (ADR-0049 / #1889 — the same "declared ≠ enforced" hole closed for the
generic write path in #3220 / #3213).

The route now:

- validates the body against a new `CrossObjectBatchRequestSchema`
(`@objectstack/spec/api`, Zod-First) — a malformed op, an unknown action, or a
missing `object` is a `400` instead of a `500`;
- enforces `enable.apiEnabled` / `enable.apiMethods` for **every** op (metadata
fetched once, each distinct `(object, action)` checked) BEFORE opening the
transaction — `404 OBJECT_API_DISABLED` / `405 OBJECT_API_METHOD_NOT_ALLOWED`;
- requires an `id` for `update` / `delete` (`400`);
- rejects an unresolvable `{ $ref }` with `400 BATCH_UNRESOLVED_REF` instead of
silently writing a `null` FK;
- rejects an explicit `atomic: false` (`400 BATCH_NOT_ATOMIC`) rather than
silently applying atomically — non-atomic per-object batches stay on
`POST /data/:object/batch`.

`enforceApiAccess` is refactored to share the pure `apiAccessDenialFromEnable`
check + a `loadObjectItems` helper with the batch route (single-record behavior
unchanged). Adds `rest-batch-endpoint.test.ts` — the REST-boundary coverage
ADR-0034 flagged as missing (commit, `$ref`, rollback surfacing, API-access
denial, request validation).
13 changes: 12 additions & 1 deletion content/docs/protocol/kernel/http-protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -655,7 +655,18 @@ Content-Type: application/json
**Behavior:**
- Maximum batch size: 200 operations by default (configurable via `maxBatchSize`)
- The entire batch runs inside **one engine transaction** — if any operation
fails, all are rolled back (commit-all-or-nothing)
fails, all are rolled back (commit-all-or-nothing). The batch is always
atomic; an explicit `"atomic": false` is rejected with `400 BATCH_NOT_ATOMIC`
(use `POST /data/{object}/batch` for a non-atomic per-object batch)
- Every operation is subject to the **same per-object API-exposure gate** as the
single-record routes, enforced before the transaction opens: an object with
`enable.apiEnabled: false` returns `404 OBJECT_API_DISABLED`, and an action
outside an object's `enable.apiMethods` whitelist returns
`405 OBJECT_API_METHOD_NOT_ALLOWED`
- The request shape is validated: a malformed operation, an unknown action, or a
missing `object` returns `400`; `update` / `delete` require an `id`
- A `{ "$ref": <index> }` that does not resolve to an earlier create's id
returns `400 BATCH_UNRESOLVED_REF` (never a silently-written null value)
- Returns HTTP 501 if the underlying runtime does not support transactions

## Metadata API
Expand Down
41 changes: 39 additions & 2 deletions content/docs/references/api/batch.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ Industry alignment: Salesforce Bulk API, Microsoft Dynamics Bulk Operations
## TypeScript Usage

```typescript
import { BatchConfig, BatchOperationResult, BatchOperationType, BatchOptions, BatchRecord, BatchUpdateRequest, BatchUpdateResponse, DeleteManyRequest, UpdateManyRequest } from '@objectstack/spec/api';
import type { BatchConfig, BatchOperationResult, BatchOperationType, BatchOptions, BatchRecord, BatchUpdateRequest, BatchUpdateResponse, DeleteManyRequest, UpdateManyRequest } from '@objectstack/spec/api';
import { BatchConfig, BatchOperationResult, BatchOperationType, BatchOptions, BatchRecord, BatchUpdateRequest, BatchUpdateResponse, CrossObjectBatchOperation, CrossObjectBatchRequest, CrossObjectBatchResponse, DeleteManyRequest, UpdateManyRequest } from '@objectstack/spec/api';
import type { BatchConfig, BatchOperationResult, BatchOperationType, BatchOptions, BatchRecord, BatchUpdateRequest, BatchUpdateResponse, CrossObjectBatchOperation, CrossObjectBatchRequest, CrossObjectBatchResponse, DeleteManyRequest, UpdateManyRequest } from '@objectstack/spec/api';

// Validate data
const result = BatchConfig.parse(data);
Expand Down Expand Up @@ -135,6 +135,43 @@ const result = BatchConfig.parse(data);
| **results** | `{ id?: string; success: boolean; errors?: { code: string; message: string; category?: string; details?: any; … }[]; data?: Record<string, any>; … }[]` | ✅ | Detailed results for each record |


---

## CrossObjectBatchOperation

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **object** | `string` | ✅ | Target object (table) name |
| **action** | `Enum<'create' \| 'update' \| 'delete'>` | ✅ | Operation to perform (default: create) |
| **id** | `string` | optional | Target record id — required for update and delete |
| **data** | `Record<string, any>` | optional | Record payload for create/update; a value may be `{ $ref: <opIndex> }` to reference an earlier op's created id |


---

## CrossObjectBatchRequest

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **operations** | `{ object: string; action: Enum<'create' \| 'update' \| 'delete'>; id?: string; data?: Record<string, any> }[]` | ✅ | Ordered operations executed in one transaction |
| **atomic** | `boolean` | ✅ | Always true — the cross-object batch is all-or-nothing |


---

## CrossObjectBatchResponse

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **results** | `any[]` | ✅ | Per-operation result, index-aligned with the request operations |


---

## DeleteManyRequest
Expand Down
1 change: 1 addition & 0 deletions content/docs/releases/implementation-status.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ The data (`/data`), metadata (`/meta`), and batch endpoints are implemented in `
| `/data/{object}/deleteMany` | POST | Batch delete | ✅ |
| `/data/{object}/{id}/clone` | POST | Clone a record (gated by `enable.clone`) | ✅ |
| `/data/{object}/batch` | POST | Atomic batch operations | ✅ |
| `/batch` | POST | Cross-object atomic batch (parent + children in one transaction, `$ref`) | ✅ |

### Advanced Protocols

Expand Down
232 changes: 232 additions & 0 deletions packages/rest/src/rest-batch-endpoint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// Cross-object transactional batch route (POST {basePath}/batch, issue #1604 /
// ADR-0034). Engine-level atomicity is covered in
// objectql/src/engine-ambient-transaction.test.ts; this suite is the
// REST-boundary contract ADR-0034 flagged as missing: request validation,
// per-op API-exposure enforcement, $ref resolution, and error surfacing.

import { describe, it, expect, vi } from 'vitest';
import { RestServer } from './rest-server';

// ── helpers ──────────────────────────────────────────────────────────────────

function mockServer() {
return {
get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(),
use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
};
}

function mockRes() {
const res: any = { statusCode: 200, body: undefined };
res.status = vi.fn((c: number) => { res.statusCode = c; return res; });
res.json = vi.fn((b: any) => { res.body = b; return res; });
res.end = vi.fn(() => res);
return res;
}

/**
* A fake ObjectQL whose `transaction(cb)` runs the callback and rethrows on
* failure (mirroring commit-on-success / rollback-on-throw). `insert` returns a
* freshly-minted id so `$ref` resolution and index-aligned results are testable.
*/
function makeQl(overrides: Partial<Record<'insert' | 'update' | 'delete', any>> = {}) {
let seq = 0;
const ql: any = {
transaction: vi.fn(async (cb: any, ctx: any) => cb({ __trx: true, ctx })),
insert: overrides.insert ?? vi.fn(async (_object: string, data: any) => ({ id: `id_${++seq}`, ...data })),
update: overrides.update ?? vi.fn(async (_object: string, data: any) => ({ ...data })),
delete: overrides.delete ?? vi.fn(async (_object: string, arg: any) => ({ success: true, id: arg?.where?.id })),
};
return ql;
}

/** Build a RestServer with an optional ObjectQL provider and object metadata. */
function buildServer(opts: { ql?: any; objects?: any[] } = {}) {
const server = mockServer();
const protocol: any = {
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', endpoints: {} }),
getMetaTypes: vi.fn().mockResolvedValue([]),
getMetaItems: vi.fn().mockResolvedValue(opts.objects ?? []),
};
const objectQLProvider = opts.ql ? async () => opts.ql : undefined;
const rest = new RestServer(
server as any, protocol as any, { api: { requireAuth: false } } as any,
undefined, undefined, undefined, undefined, // kernelManager, envRegistry, defaultEnvIdProvider, authServiceProvider
objectQLProvider, // objectQLProvider (positional arg #8)
);
rest.registerRoutes();
const route = rest.getRoutes().find(
(r) => r.method === 'POST' && (r.metadata?.summary ?? '').startsWith('Cross-object'),
);
return { route, protocol };
}

/** POST a body at the cross-object batch route and return the mock response. */
async function post(route: any, body: any) {
const res = mockRes();
await route!.handler({ method: 'POST', params: {}, headers: {}, body } as any, res);
return res;
}

// ── registration ─────────────────────────────────────────────────────────────

describe('POST {basePath}/batch — cross-object transactional batch', () => {
it('registers the route under the data/batch tags', () => {
const { route } = buildServer({ ql: makeQl() });
expect(route).toBeTruthy();
expect(route!.metadata?.tags).toEqual(expect.arrayContaining(['data', 'batch']));
});

it('returns 501 when the runtime has no transactional ObjectQL', async () => {
const { route } = buildServer({}); // no ql provider
const res = await post(route, { operations: [{ object: 'account', data: {} }] });
expect(res.statusCode).toBe(501);
});

// ── happy path ───────────────────────────────────────────────────────────

it('commits multiple ops in one transaction and returns index-aligned results', async () => {
const ql = makeQl();
const { route } = buildServer({ ql });
const res = await post(route, {
operations: [
{ object: 'project', action: 'create', data: { name: 'Apollo' } },
{ object: 'task', action: 'create', data: { title: 'Kickoff' } },
],
});
expect(res.statusCode).toBe(200);
expect(ql.transaction).toHaveBeenCalledTimes(1);
expect(ql.insert).toHaveBeenCalledTimes(2);
expect(res.body.results).toHaveLength(2);
expect(res.body.results[0]).toMatchObject({ id: 'id_1', name: 'Apollo' });
expect(res.body.results[1]).toMatchObject({ id: 'id_2', title: 'Kickoff' });
});

it('resolves { $ref: <opIndex> } to an earlier create\'s id (master-detail)', async () => {
const ql = makeQl();
const { route } = buildServer({ ql });
const res = await post(route, {
operations: [
{ object: 'project', action: 'create', data: { name: 'Apollo' } },
{ object: 'task', action: 'create', data: { title: 'Kickoff', project: { $ref: 0 } } },
],
});
expect(res.statusCode).toBe(200);
// The child's FK was rewritten from { $ref: 0 } to the parent's generated id.
const childData = ql.insert.mock.calls[1][1];
expect(childData.project).toBe('id_1');
});

it('defaults a missing action to create', async () => {
const ql = makeQl();
const { route } = buildServer({ ql });
const res = await post(route, { operations: [{ object: 'account', data: { name: 'X' } }] });
expect(res.statusCode).toBe(200);
expect(ql.insert).toHaveBeenCalledTimes(1);
});

it('returns an empty result set for zero operations without opening a transaction', async () => {
const ql = makeQl();
const { route } = buildServer({ ql });
const res = await post(route, { operations: [] });
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ results: [] });
expect(ql.transaction).not.toHaveBeenCalled();
});

// ── atomicity / rollback surfacing ────────────────────────────────────────

it('surfaces a failing op with its mapped status (atomic rollback, not partial success)', async () => {
const insert = vi.fn()
.mockResolvedValueOnce({ id: 'id_1', name: 'Apollo' })
.mockRejectedValueOnce(Object.assign(new Error('bad'), { code: 'VALIDATION_FAILED', name: 'ValidationError' }));
const ql = makeQl({ insert });
const { route } = buildServer({ ql });
const res = await post(route, {
operations: [
{ object: 'project', action: 'create', data: { name: 'Apollo' } },
{ object: 'task', action: 'create', data: {} },
],
});
expect(res.statusCode).toBe(400);
expect(res.body.code).toBe('VALIDATION_FAILED');
// No success envelope is returned when the transaction threw.
expect(res.body.results).toBeUndefined();
});

it('rejects an unresolvable $ref with 400 BATCH_UNRESOLVED_REF', async () => {
const ql = makeQl();
const { route } = buildServer({ ql });
const res = await post(route, {
operations: [{ object: 'task', action: 'create', data: { project: { $ref: 5 } } }],
});
expect(res.statusCode).toBe(400);
expect(res.body.code).toBe('BATCH_UNRESOLVED_REF');
});

// ── per-object API-exposure enforcement (ADR-0049) ────────────────────────

it('blocks a write to an API-disabled object with 404 before opening a transaction', async () => {
const ql = makeQl();
const { route } = buildServer({ ql, objects: [{ name: 'secret', enable: { apiEnabled: false } }] });
const res = await post(route, { operations: [{ object: 'secret', action: 'create', data: {} }] });
expect(res.statusCode).toBe(404);
expect(res.body.code).toBe('OBJECT_API_DISABLED');
expect(ql.transaction).not.toHaveBeenCalled();
});

it('blocks an operation absent from the apiMethods whitelist with 405', async () => {
const ql = makeQl();
const { route } = buildServer({ ql, objects: [{ name: 'ledger', enable: { apiMethods: ['read'] } }] });
const res = await post(route, { operations: [{ object: 'ledger', action: 'create', data: {} }] });
expect(res.statusCode).toBe(405);
expect(res.body.code).toBe('OBJECT_API_METHOD_NOT_ALLOWED');
expect(ql.transaction).not.toHaveBeenCalled();
});

it('allows an operation that IS in the apiMethods whitelist', async () => {
const ql = makeQl();
const { route } = buildServer({ ql, objects: [{ name: 'ledger', enable: { apiMethods: ['create', 'read'] } }] });
const res = await post(route, { operations: [{ object: 'ledger', action: 'create', data: { amount: 1 } }] });
expect(res.statusCode).toBe(200);
expect(ql.transaction).toHaveBeenCalledTimes(1);
});

// ── request validation ────────────────────────────────────────────────────

it('rejects a non-atomic request (this endpoint is always atomic)', async () => {
const ql = makeQl();
const { route } = buildServer({ ql });
const res = await post(route, { operations: [{ object: 'account', data: {} }], atomic: false });
expect(res.statusCode).toBe(400);
expect(res.body.code).toBe('BATCH_NOT_ATOMIC');
expect(ql.transaction).not.toHaveBeenCalled();
});

it('rejects an update op with no id', async () => {
const ql = makeQl();
const { route } = buildServer({ ql });
const res = await post(route, { operations: [{ object: 'account', action: 'update', data: { name: 'X' } }] });
expect(res.statusCode).toBe(400);
expect(res.body.code).toBe('VALIDATION_FAILED');
expect(ql.transaction).not.toHaveBeenCalled();
});

it('rejects a malformed operation (missing object) with 400', async () => {
const ql = makeQl();
const { route } = buildServer({ ql });
const res = await post(route, { operations: [{ action: 'create', data: {} }] });
expect(res.statusCode).toBe(400);
expect(res.body.code).toBe('VALIDATION_FAILED');
});

it('rejects an unknown action with 400', async () => {
const ql = makeQl();
const { route } = buildServer({ ql });
const res = await post(route, { operations: [{ object: 'account', action: 'frobnicate', data: {} }] });
expect(res.statusCode).toBe(400);
expect(res.body.code).toBe('VALIDATION_FAILED');
});
});
Loading