Skip to content

Commit 3926932

Browse files
committed
fix(rest): the record page passes its context to the API gate and speaks QueryAST
After the main merge the API-exposure gate takes the request's context (#27692), so the record page route now hands it the context it already holds. The related list read now builds a canonical QueryAST (where, limit, orderBy, and count as the find request declares it) with no cast, instead of the wire aliases behind `as any`, which the canonical-query census in the rest repo suite refuses; the census gets the new module's floor of one slot (sum 4 to 5). The route test narrows the related answer union where it reads one arm. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzhCjfxaYJQyy6tFjVr
1 parent 8ad4407 commit 3926932

3 files changed

Lines changed: 29 additions & 11 deletions

File tree

‎packages/rest/src/record-page-routes.test.ts‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ function boot(opts: BootOpts = {}) {
115115
(rest as any).resolveExecCtx = async () => ('context' in opts ? opts.context : CTX);
116116
(rest as any).resolveSecurityService = async () => opts.security;
117117
(rest as any).approvalsServiceProvider = opts.approvals === undefined ? undefined : async () => opts.approvals;
118+
const apiGate = vi.spyOn(rest as any, 'enforceApiAccess');
118119
rest.registerRoutes();
119120
const route = (rest as any).getRoutes().find(
120121
(r: any) => r.method === 'POST' && r.path === '/api/v1/data/:object/:id/page',
@@ -125,7 +126,7 @@ function boot(opts: BootOpts = {}) {
125126
await route.handler({ method: 'POST', params, query, headers: {}, body } as any, res);
126127
return { status: res.statusCode, body: res.json.mock.calls.at(-1)?.[0] };
127128
};
128-
return { getData, findData, call };
129+
return { getData, findData, call, apiGate };
129130
}
130131

131132
const recordNotFound = () => Object.assign(new Error('Record a1 not found in account'), {
@@ -136,6 +137,14 @@ const permissionDenied = () => Object.assign(new Error('[Security] Access denied
136137
});
137138

138139
describe('[#27627] POST /data/:object/:id/page', () => {
140+
it('asks the object-exposure gate as GET /data/:object/:id does, with the caller\'s context (#27692)', async () => {
141+
const { call, apiGate } = boot();
142+
expect((await call({})).status).toBe(200);
143+
expect(apiGate).toHaveBeenCalledTimes(1);
144+
const [, , , environmentId, operation, context] = apiGate.mock.calls[0];
145+
expect([environmentId, operation, context]).toEqual([undefined, 'get', CTX]);
146+
});
147+
139148
it("answers every part with the call the read it replaces makes, as the caller, in the schema's shape", async () => {
140149
const getRecordFieldAccess = vi.fn().mockResolvedValue(ACCESS);
141150
const explain = vi.fn(async (req: any) => DECISION(req.operation));
@@ -168,11 +177,11 @@ describe('[#27627] POST /data/:object/:id/page', () => {
168177
// Each related list: the parent condition (to the field's arity), AND
169178
// the declared scope, with `count` — as `GET /data/note` reads it.
170179
expect(findData.mock.calls.map(([req]) => req)).toEqual([
171-
{ object: 'note', query: { filter: { account: 'a1' }, top: 3, count: true }, context: CTX },
172-
{ object: 'note', query: { filter: { $and: [{ account: 'a1' }, { title: 'x' }] }, top: 0, count: true }, context: CTX },
173-
{ object: 'note', query: { filter: { watchers: { $contains: 'a1' } }, top: 1, count: true, orderBy: [{ field: 'title', order: 'desc' }] }, context: CTX },
180+
{ object: 'note', query: { object: 'note', where: { account: 'a1' }, limit: 3, count: true }, context: CTX },
181+
{ object: 'note', query: { object: 'note', where: { $and: [{ account: 'a1' }, { title: 'x' }] }, limit: 0, count: true }, context: CTX },
182+
{ object: 'note', query: { object: 'note', where: { watchers: { $contains: 'a1' } }, limit: 1, count: true, orderBy: [{ field: 'title', order: 'desc' }] }, context: CTX },
174183
]);
175-
expect(data.related.map((r) => [r.field, r.total, r.records])).toEqual([
184+
expect(data.related.map((r) => ('total' in r ? [r.field, r.total, r.records] : [r.field, r.error]))).toEqual([
176185
['account', 7, [{ id: 'note-1' }]],
177186
['account', 7, [{ id: 'note-1' }]],
178187
['watchers', 7, [{ id: 'note-1' }]],
@@ -241,7 +250,7 @@ describe('[#27627] POST /data/:object/:id/page', () => {
241250
const [list] = RecordPageResponseSchema.parse(answer.body.data).related;
242251
expect(list).not.toHaveProperty('total');
243252
expect(list).not.toHaveProperty('records');
244-
expect(list.error).toMatchObject({ code: 'OBJECT_API_DISABLED', status: 404 });
253+
expect('error' in list ? list.error : undefined).toMatchObject({ code: 'OBJECT_API_DISABLED', status: 404 });
245254
expect(findData).not.toHaveBeenCalled();
246255
});
247256

‎packages/rest/src/record-page-routes.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ export function registerRecordPageEndpoints(this: RecordPageRouteHost, basePath:
116116
const p = await this.resolveProtocol(environmentId, req);
117117
const context = await this.resolveExecCtx(environmentId, req);
118118
if (this.enforceAuth(req, res, context)) return;
119-
if (await this.enforceApiAccess(req, res, p, environmentId, 'get')) return;
119+
if (await this.enforceApiAccess(req, res, p, environmentId, 'get', context)) return;
120120
if (refuseUnknownQueryParams(req, res, RECORD_PAGE_PARAMS)) return;
121121

122122
const rawBody = req.body ?? {};
@@ -199,12 +199,15 @@ export function registerRecordPageEndpoints(this: RecordPageRouteHost, basePath:
199199
const where = declared === undefined ? parentScope : { $and: [parentScope, declared] };
200200
const found: any = await p.findData({
201201
object: entry.object,
202+
// Canonical QueryAST (#16337), with the list's
203+
// `$count` as the request declares it.
202204
query: {
203-
filter: where,
204-
top: entry.top,
205+
object: entry.object,
206+
where,
207+
limit: entry.top,
205208
count: true,
206209
...(entry.sort && entry.sort.length > 0 ? { orderBy: entry.sort } : {}),
207-
} as any,
210+
},
208211
...scope,
209212
} satisfies ServerScopedDataRequest<FindDataRequest>);
210213
const records = Array.isArray(found?.records) ? found.records : [];

‎packages/rest/src/rest-server-canonical-query-ast.test.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,10 @@ interface CensusEntry {
198198
* unchanged: neither the object state-machine read (`meta-object-state-routes.ts`)
199199
* nor the published read (now in `meta-item-history-routes.ts`) builds a `query:`
200200
* slot, so the new module's floor is 0 and the history module's stays 0.
201+
*
202+
* [#27627] 5 = 0 + 3 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 1 + 0 + 0 + 0 + 0 + 0 + 1:
203+
* the record page (`record-page-routes.ts`) builds one `query:` slot, the
204+
* related list read, so the new module's floor is 1.
201205
*/
202206
const REGISTRAR_QUERY_SLOT_FLOORS: Readonly<Record<RestServerRegistrarFile, number>> = {
203207
'rest-server.ts': 0,
@@ -218,6 +222,7 @@ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly<Record<RestServerRegistrarFile, numb
218222
'meta-item-write-routes.ts': 0,
219223
'meta-item-history-routes.ts': 0,
220224
'meta-object-state-routes.ts': 0,
225+
'record-page-routes.ts': 1,
221226
};
222227

223228
const CENSUS: CensusEntry[] = [
@@ -426,10 +431,11 @@ describe('[#16638] §1 CONTROLS on the census instrument itself', () => {
426431
// [#23050 slice 13e] Length 17 → 18, the sum unchanged (neither the object
427432
// state-machine read nor the published read carries a slot).
428433
// [#26433] The list's length is no longer pinned: the list is the authority.
434+
// [#27627] The sum 4 → 5: the record page's related list read.
429435
expect(Object.keys(REGISTRAR_QUERY_SLOT_FLOORS).sort()).toEqual([...REST_SERVER_REGISTRAR_FILES].sort());
430436
expect(CENSUS.filter(({ file }) => REGISTRAR_SOURCES.has(file)).map(({ file }) => file))
431437
.toEqual([...REST_SERVER_REGISTRAR_FILES]);
432-
expect([...REGISTRAR_SOURCES.values()].reduce((n, source) => n + querySlots(source).length, 0)).toBe(4);
438+
expect([...REGISTRAR_SOURCES.values()].reduce((n, source) => n + querySlots(source).length, 0)).toBe(5);
433439
});
434440

435441
it('dropping comment lines leaves the code that is being censused', () => {

0 commit comments

Comments
 (0)