From 789ccf382eee52177c1f098e0d7be061e96a9b15 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 06:38:24 +0000 Subject: [PATCH 1/3] feat(plugin-approvals): the request page's Details tab ends with the raw request snapshot as a record:json panel gated on studio.access The Details tab of the served sys_approval_request_detail page now holds record:details, then a record:json block on `payload` (the parsed snapshot the request read serves), gated on requiredPermissions ['studio.access']. The page pin holds the tab's children, and a new pin reads one request through ApprovalService.getRequest (the read the console's request page loads its record through) and asserts the key the panel names is served and the stored column payload_json is not. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP --- .changeset/23746-approval-request-raw-json.md | 14 ++++ .../src/sys-approval-request-page.test.ts | 80 ++++++++++++++++++- .../src/sys-approval-request.page.ts | 17 +++- 3 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 .changeset/23746-approval-request-raw-json.md diff --git a/.changeset/23746-approval-request-raw-json.md b/.changeset/23746-approval-request-raw-json.md new file mode 100644 index 00000000000..503771cd525 --- /dev/null +++ b/.changeset/23746-approval-request-raw-json.md @@ -0,0 +1,14 @@ +--- +'@objectstack/plugin-approvals': patch +--- + +feat(plugin-approvals): the approval request page's Details tab ends with the request snapshot's raw JSON, gated on `studio.access` + +Clause-②: no + +- **One block on the served `sys_approval_request_detail` page.** The Details tab now holds `record:details`, then a `record:json` block on `payload`, labelled "Raw data (JSON)" in the four shipped locales and gated on `requiredPermissions: ['studio.access']`. It is a platform operator's debug view, never the approver's read path: a viewer without `studio.access` draws nothing there. +- **`payload` is the key the request read serves.** `GET /api/v1/approvals/requests/:id`, the read the console's request page loads its record through, serves the parsed snapshot as `payload`, already narrowed to the fields the reader may see on the subject object. The stored column `payload_json` is never served under its own name, and the object definition is unchanged. +- **The block is drawn by the console's `record:json` renderer.** A console with no renderer registered for `record:json` draws its unknown-component panel in that place. +- **Nothing else moves.** No export, type, route, stored value or accepted input changes. + +A patch: one block on one platform page's served definition. diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request-page.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-request-page.test.ts index 04afdb51cac..178173f3061 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-request-page.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-request-page.test.ts @@ -20,7 +20,9 @@ * component-type vocabulary, the props rows and the build rules — with a * control for each half that can go red; * 4. its label is translated in every shipped locale, `en` byte-equal to - * the page literal, and served as the translation (not the fallback). + * the page literal, and served as the translation (not the fallback); + * 5. the Details tab's `record:json` panel names a key the request read + * really serves — no publish check judges that `field`, so this does. */ import { describe, it, expect } from 'vitest'; @@ -35,6 +37,7 @@ import { validateComponentTypes, } from '@objectstack/lint'; import { ApprovalsServicePlugin } from './approvals-plugin.js'; +import { ApprovalService } from './approval-service.js'; import { SysApprovalRequestDetailPage } from './sys-approval-request.page.js'; import { SysApprovalRequest } from './sys-approval-request.object.js'; import { SysApprovalAction } from './sys-approval-action.object.js'; @@ -105,6 +108,52 @@ function pageBuildFindings(page: AnyRec) { /** The page with its decision node replaced. */ const withDecisionNode = (node: AnyRec): AnyRec => ({ ...PAGE, slots: { ...SLOTS, actions: [node] } }); +/** The subject-record snapshot a stored request row holds in `payload_json`. */ +const SNAPSHOT = { id: 'exp_1', title: 'Q1 travel', amount: 1200 }; +const SUBMITTER = 'employee_7'; + +/** + * One request, read the way the request page loads its record. The console + * routes that `findOne` to `GET /approvals/requests/:id` (objectui's + * `routeApprovalRecordReads`), whose handler answers `ApprovalService.getRequest`'s + * row as it stands; this is that call, as the request's submitter, over a + * find-only engine double holding the stored row. + */ +async function servedRequestRow(): Promise { + const tables: Record = { + sys_approval_request: [{ + id: 'req_1', + organization_id: 't1', + process_name: 'flow:expense_review', + object_name: 'expense_report', + record_id: 'exp_1', + submitter_id: SUBMITTER, + status: 'pending', + current_step: 'finance', + pending_approvers: 'finance_user', + payload_json: JSON.stringify(SNAPSHOT), + created_at: '2026-08-01T00:00:00Z', + }], + expense_report: [{ ...SNAPSHOT, organization_id: 't1' }], + }; + const matches = (row: AnyRec, where: AnyRec | undefined): boolean => + Object.entries(where ?? {}).every(([k, v]) => { + if (k === '$or') return (v as AnyRec[]).some((w) => matches(row, w)); + if (k === '$and') return (v as AnyRec[]).every((w) => matches(row, w)); + if (v != null && typeof v === 'object' && '$in' in v) return (v.$in as unknown[]).includes(row[k]); + return row[k] === v; + }); + const engine = { + async find(object: string, options?: AnyRec) { + return (tables[object] ?? []).filter((r) => matches(r, options?.where ?? options?.filter)).map((r) => ({ ...r })); + }, + }; + const row = await new ApprovalService({ engine: engine as any }) + .getRequest('req_1', { userId: SUBMITTER, tenantId: 't1', positions: [], permissions: [] } as any); + expect(row, 'the submitter reads their own request').not.toBeNull(); + return row as AnyRec; +} + describe('sys_approval_request detail page — served through the manifest', () => { it('the plugin manifest carries the page in `pages`', async () => { const manifest = await registeredManifest(); @@ -150,9 +199,17 @@ describe('sys_approval_request detail page — the slot map', () => { expect((SysApprovalRequest.fields as AnyRec).record_id?.referenceVia).toBe('object_name'); }); - it('tabs: Details is record:details, Timeline is sys_approval_action as a related list on request_id', () => { + it('tabs: Details is record:details then the raw snapshot panel, Timeline is sys_approval_action as a related list on request_id', () => { expect(TAB_ITEMS.map((t) => t.label?.en)).toEqual(['Details', 'Timeline']); - expect(TAB_ITEMS[0].children).toEqual([{ type: 'record:details' }]); + const details = TAB_ITEMS[0].children as AnyRec[]; + expect(details.map((n) => n.type)).toEqual(['record:details', 'record:json']); + expect(details[0]).toEqual({ type: 'record:details' }); + // The panel: the parsed request snapshot, gated on the platform-scope + // capability the inbox side sheet's raw panel is gated on. Its label is + // copy, so only its locales are held here. + const { label, ...panel } = details[1].properties as AnyRec; + expect(panel).toEqual({ field: 'payload', requiredPermissions: ['studio.access'] }); + expect(Object.keys(label).sort()).toEqual(Object.keys(ApprovalsTranslations).sort()); const [timeline] = TAB_ITEMS[1].children as AnyRec[]; expect(timeline.type).toBe('record:related_list'); expect(timeline.properties).toMatchObject({ objectName: 'sys_approval_action', relationshipField: 'request_id' }); @@ -180,6 +237,23 @@ describe('sys_approval_request detail page — the slot map', () => { }); }); +describe('sys_approval_request detail page — the raw snapshot panel draws a key the request read serves', () => { + // `record:json`'s `field` is not judged against the object's fields at + // publish (the key it exists for is served beside them), so a key the + // request read does not serve draws nothing, and nothing says so. + it('the request read serves the parsed snapshot under the panel\'s `field`, and never the stored column', async () => { + const panels = nodesOf(SLOTS).filter((n) => n.type === 'record:json'); + expect(panels).toHaveLength(1); + const { field } = panels[0].properties as AnyRec; + const row = await servedRequestRow(); + expect({ field, served: row[field] }).toEqual({ field, served: SNAPSHOT }); + // `payload_json` is the declared column the stored row holds; the read + // serves its parse as `payload`, never the column under its own name. + expect(Object.keys(SysApprovalRequest.fields)).toContain('payload_json'); + expect(row).not.toHaveProperty('payload_json'); + }); +}); + describe('sys_approval_request detail page — what `os validate` judges a page by', () => { it('the component-type vocabulary knows every node, record:approval_decision included', () => { expect(validateComponentTypes({ pages: [PAGE] })).toEqual([]); diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request.page.ts b/packages/plugins/plugin-approvals/src/sys-approval-request.page.ts index e3b2343e065..42f3675ea82 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-request.page.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-request.page.ts @@ -38,6 +38,11 @@ import type { Page } from '@objectstack/spec/ui'; * authored rather than `details` because the console builds the tab strip * from `tabs` when both are present; the details body therefore lives in * the first tab here, and the page authors no `details` slot. + * - The Details tab ends with the request snapshot's raw JSON: a + * `record:json` block on `payload`, the parsed snapshot the request read + * (`GET /approvals/requests/:id`) serves; the stored column `payload_json` + * is never served under its own name. It is a platform operator's debug + * view, gated on `studio.access`, and never the approver's read path. * - `discussion: []` removes the generic discussion thread. A request's * thread is its timeline, and its reply is the declared `approval_comment` * action, which the service stores as a `sys_approval_action` row the @@ -82,7 +87,17 @@ export const SysApprovalRequestDetailPage: Page = { { label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' }, icon: 'file-text', - children: [{ type: 'record:details' }], + children: [ + { type: 'record:details' }, + { + type: 'record:json', + properties: { + field: 'payload', + label: { en: 'Raw data (JSON)', 'zh-CN': '原始数据(JSON)', 'ja-JP': '生データ(JSON)', 'es-ES': 'Datos sin procesar (JSON)' }, + requiredPermissions: ['studio.access'], + }, + }, + ], }, { label: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' }, From 51d33dfe5777746270750b58cb510b212a62b6fb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 06:46:21 +0000 Subject: [PATCH 2/3] test(plugin-approvals): control that the raw snapshot panel's gate is judged by the props call The page schema's parse does not judge a node's props bag, so the pin that the panel's requiredPermissions holds is the props call's refusal of a panel without it. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP --- .../src/sys-approval-request-page.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request-page.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-request-page.test.ts index 178173f3061..cfa344b125d 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-request-page.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-request-page.test.ts @@ -29,6 +29,7 @@ import { describe, it, expect } from 'vitest'; import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec'; import { PageSchema } from '@objectstack/spec/ui'; import { + COMPONENT_PROPS_INVALID, COMPONENT_PROPS_UNKNOWN_KEY, COMPONENT_TYPE_UNKNOWN, runAuthoringRules, @@ -108,6 +109,14 @@ function pageBuildFindings(page: AnyRec) { /** The page with its decision node replaced. */ const withDecisionNode = (node: AnyRec): AnyRec => ({ ...PAGE, slots: { ...SLOTS, actions: [node] } }); +/** The page with the Details tab's raw snapshot panel given other properties. */ +const withRawPanelProps = (properties: AnyRec): AnyRec => { + const [details, ...rest] = TAB_ITEMS; + const children = [(details.children as AnyRec[])[0], { type: 'record:json', properties }]; + const items = [{ ...details, children }, ...rest]; + return { ...PAGE, slots: { ...SLOTS, tabs: { ...SLOTS.tabs, properties: { ...SLOTS.tabs.properties, items } } } }; +}; + /** The subject-record snapshot a stored request row holds in `payload_json`. */ const SNAPSHOT = { id: 'exp_1', title: 'Q1 travel', amount: 1200 }; const SUBMITTER = 'employee_7'; @@ -273,6 +282,16 @@ describe('sys_approval_request detail page — what `os validate` judges a page expect(findings.map((f) => f.rule)).toEqual([COMPONENT_PROPS_UNKNOWN_KEY]); }); + it('control: the raw snapshot panel without its required gate is refused by the same call', () => { + // The page schema's parse does not judge a node's props bag; the props + // row does, so this call is what holds the panel's gate in place. + const { requiredPermissions: _gate, ...ungated } = (TAB_ITEMS[0].children as AnyRec[])[1].properties as AnyRec; + const findings = validateComponentProps({ pages: [withRawPanelProps(ungated)] }); + expect(findings.map((f) => [f.rule, f.path])).toEqual([ + [COMPONENT_PROPS_INVALID, 'pages[0].slots.tabs.properties.items[0].children[1].properties.requiredPermissions'], + ]); + }); + it('the build rules raise no error on the page, in a stack beside the two objects it binds', () => { const { errors } = splitBySeverity(pageBuildFindings(PAGE)); expect(errors, JSON.stringify(errors, null, 2)).toEqual([]); From e46780683bc471311bcf5e91efd3714dd46fcb3d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 07:17:13 +0000 Subject: [PATCH 3/3] test(plugin-approvals): the request-read engine double holds the caller's bound and refuses operators it does not implement check:objectql-double-limit and check:where-matcher judge every new find double. The double now applies `limit` after the filter, by presence, and copies only the bounded rows. Its matcher lives at module scope, where the matcher gate can lift it, and throws on a combinator or operator outside equality, `$in`, `$or` and `$and`. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP --- .../src/sys-approval-request-page.test.ts | 49 +++++++++++++------ 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request-page.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-request-page.test.ts index cfa344b125d..0393d9f86d7 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-request-page.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-request-page.test.ts @@ -122,13 +122,26 @@ const SNAPSHOT = { id: 'exp_1', title: 'Q1 travel', amount: 1200 }; const SUBMITTER = 'employee_7'; /** - * One request, read the way the request page loads its record. The console - * routes that `findOne` to `GET /approvals/requests/:id` (objectui's - * `routeApprovalRecordReads`), whose handler answers `ApprovalService.getRequest`'s - * row as it stands; this is that call, as the request's submitter, over a - * find-only engine double holding the stored row. + * The `where` shapes the request read sends: equality, `$in`, `$or`, `$and`. + * Any other operator or combinator is REFUSED, so a read this double cannot + * answer fails the test instead of matching nothing. */ -async function servedRequestRow(): Promise { +function matchesWhere(row: AnyRec, where: AnyRec | undefined): boolean { + return Object.entries(where ?? {}).every(([k, v]) => { + if (k === '$or') return (v as AnyRec[]).some((w) => matchesWhere(row, w)); + if (k === '$and') return (v as AnyRec[]).every((w) => matchesWhere(row, w)); + if (k.startsWith('$')) throw new Error(`engine double: unsupported combinator ${k}`); + if (v != null && typeof v === 'object') { + const ops = Object.keys(v); + if (ops.length === 1 && ops[0] === '$in') return (v.$in as unknown[]).includes(row[k]); + throw new Error(`engine double: unsupported operator ${ops.join(', ')} on ${k}`); + } + return row[k] === v; + }); +} + +/** A find-only engine double over one stored request and its subject record. */ +function storedRequestEngine() { const tables: Record = { sys_approval_request: [{ id: 'req_1', @@ -145,19 +158,23 @@ async function servedRequestRow(): Promise { }], expense_report: [{ ...SNAPSHOT, organization_id: 't1' }], }; - const matches = (row: AnyRec, where: AnyRec | undefined): boolean => - Object.entries(where ?? {}).every(([k, v]) => { - if (k === '$or') return (v as AnyRec[]).some((w) => matches(row, w)); - if (k === '$and') return (v as AnyRec[]).every((w) => matches(row, w)); - if (v != null && typeof v === 'object' && '$in' in v) return (v.$in as unknown[]).includes(row[k]); - return row[k] === v; - }); - const engine = { + return { async find(object: string, options?: AnyRec) { - return (tables[object] ?? []).filter((r) => matches(r, options?.where ?? options?.filter)).map((r) => ({ ...r })); + const rows = (tables[object] ?? []).filter((r) => matchesWhere(r, options?.where ?? options?.filter)); + const page = typeof options?.limit === 'number' ? rows.slice(0, options.limit) : rows; + return page.map((r) => ({ ...r })); }, }; - const row = await new ApprovalService({ engine: engine as any }) +} + +/** + * One request, read the way the request page loads its record. The console + * routes that `findOne` to `GET /approvals/requests/:id` (objectui's + * `routeApprovalRecordReads`), whose handler answers `ApprovalService.getRequest`'s + * row as it stands; this is that call, as the request's submitter. + */ +async function servedRequestRow(): Promise { + const row = await new ApprovalService({ engine: storedRequestEngine() as any }) .getRequest('req_1', { userId: SUBMITTER, tenantId: 't1', positions: [], permissions: [] } as any); expect(row, 'the submitter reads their own request').not.toBeNull(); return row as AnyRec;