From 8a5edb594e40b1e51cac93d377a06444932db6f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 11:08:40 +0000 Subject: [PATCH 1/3] feat(service-print): print a record on any print page of its object, on the console's named record-page route A record prints on any record page of its object that declares `print`. The default record page keeps the console's record route; any other print page renders on `.../record/ID/page/PAGE` (RECORD_NAMED_PAGE_ROUTE). The page check reads a record page the way the console's `isRecordPageOf` does (type 'record' and that object), so the server prints exactly the pages the named route draws. Docs and a changeset follow. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5kGuMFreHR7gCGkpxL --- .changeset/26583-print-named-page.md | 15 ++++ content/docs/ui/print-pages.mdx | 17 +++-- .../service-print/src/print-service.test.ts | 29 +++++++- .../service-print/src/print-service.ts | 70 ++++++++++++++----- 4 files changed, 106 insertions(+), 25 deletions(-) create mode 100644 .changeset/26583-print-named-page.md diff --git a/.changeset/26583-print-named-page.md b/.changeset/26583-print-named-page.md new file mode 100644 index 00000000000..17ae015e0e6 --- /dev/null +++ b/.changeset/26583-print-named-page.md @@ -0,0 +1,15 @@ +--- +'@objectstack/service-print': patch +--- + +feat(service-print): a record prints on any print page of its object, not only on the default record page + +Clause-②: yes (widening) +Metadata-protocol: none + +`printRecordToFile` (and `POST /api/v1/print/records`) used to refuse every page that was not the object's default record page (`VALIDATION_ERROR` / 400), so an app that printed an object had to turn that object's everyday record page into the printed document. A record now prints on any record page of its object that declares `print`: + +- **The default record page** (`isDefault: true`) renders on the console's record route, `CONSOLE/apps/APP/OBJECT/record/ID`, as before. +- **Any other print page** renders on the console's named record-page route, `CONSOLE/apps/APP/OBJECT/record/ID/page/PAGE`, which draws that page and never falls back to the default one. An object keeps its working record page and adds its documents (an invoice, a quote, a delivery note) beside it. + +Two refusals remain, both `VALIDATION_ERROR` / 400 before anything is minted or rendered: the page is not a record page of the object, and the page declares no `print`. "A record page of the object" is the console's own reading (`type: 'record'` and that `object`), so the server prints exactly the pages the console's named route draws. An unknown page name is still `RESOURCE_NOT_FOUND` / 404. No metadata key is added or changed: the request already names its page. diff --git a/content/docs/ui/print-pages.mdx b/content/docs/ui/print-pages.mdx index b64dd512c9a..0f2fca47e9c 100644 --- a/content/docs/ui/print-pages.mdx +++ b/content/docs/ui/print-pages.mdx @@ -28,8 +28,7 @@ export const InvoiceDocumentPage = definePage({ label: 'Invoice', type: 'record', object: 'invoice', - kind: 'full', - isDefault: true, // the console renders a record on its default record page + kind: 'full', // not isDefault: the invoice keeps its own working record page print: { paperSize: 'A4', orientation: 'portrait', repeatHeader: true, pageNumbers: true }, regions: [ { name: 'header', components: [{ type: 'element:text', properties: { content: 'ACME Ltd' } }] }, @@ -40,9 +39,15 @@ export const InvoiceDocumentPage = definePage({ // …and on the object: enable: { files: true } ``` -The server prints a record on the page the console renders it on, which is the -object's **default record page** (`isDefault: true`). A print page that is not -the default is refused rather than printed as some other page. +A record prints on **any record page of its object that declares `print`**, so +an object keeps its everyday record page and adds one or more documents beside +it (an invoice, a delivery note, a quote). The server renders the page the +request names on the console's own routes: the object's default record page +(`isDefault: true`) on the record route, `…/OBJECT/record/ID`, and any other +print page on the named record-page route, `…/OBJECT/record/ID/page/PAGE`. A +page that is not a record page of the object (`type: 'record'` with that +`object`), or that declares no `print`, is refused rather than printed as some +other page. ## Compose the print service @@ -153,7 +158,7 @@ before a session is minted or a browser is started, in this order: | 1 | The caller is not a signed-in user acting for themselves (system, agent, service or guest principal; a sign-in held by an authentication policy) | `PERMISSION_DENIED` / 403 | | 2 | The engine is not ready (`status()` says why) | `SERVICE_UNAVAILABLE` / 503 | | 3 | No such page | `RESOURCE_NOT_FOUND` / 404 | -| 3 | The page is not the object's default record page, or declares no `print` | `VALIDATION_ERROR` / 400 | +| 3 | The page is not a record page of the object (`type: 'record'`, that `object`), or declares no `print` | `VALIDATION_ERROR` / 400 | | 4 | The record does not exist, or the caller cannot read it | `RECORD_NOT_FOUND` / 404 | | 5 | The object does not declare `enable: { files: true }` | `FILES_DISABLED` / 403 | | 5 | The caller may not create attachments at all | `PERMISSION_DENIED` / 403 | diff --git a/packages/services/service-print/src/print-service.test.ts b/packages/services/service-print/src/print-service.test.ts index e43f2a11a2c..00bbb509f89 100644 --- a/packages/services/service-print/src/print-service.test.ts +++ b/packages/services/service-print/src/print-service.test.ts @@ -279,8 +279,13 @@ describe('PrintService — the page (step ③)', () => { const invalid: Array<[string, unknown]> = [ ['a page of another object', { ...PRINT_PAGE, object: 'account' }], ['a page that does not declare print', { ...PRINT_PAGE, print: undefined }], - ['a print page that is not the default record page', { ...PRINT_PAGE, isDefault: undefined }], + ['a non-default page that does not declare print', { ...PRINT_PAGE, isDefault: undefined, print: undefined }], ['a default page that is not a record page', { ...PRINT_PAGE, type: 'home' }], + ['a non-default print page that is not a record page', { ...PRINT_PAGE, type: 'app', isDefault: undefined }], + // The console's named record-page route reads a record page by its `type` + // exactly (`isRecordPageOf`), so a body that carries none is not one there. + ['a page that carries no type', { ...PRINT_PAGE, type: undefined, isDefault: undefined }], + ['a default page that carries no type', { ...PRINT_PAGE, type: undefined }], ]; for (const [label, page] of invalid) { it(`${label}: VALIDATION_ERROR / 400, and the record is never read`, async () => { @@ -400,6 +405,28 @@ describe('PrintService — mint, render, archive, revoke', () => { expect(out).toEqual({ fileId: 'file_1', attachmentId: 'att_1', size: PDF.byteLength, filename: 'invoice_print-inv_1.pdf' }); }); + it('a print page that is not the default record page prints on the console’s named record-page route', async () => { + const named = { ...PRINT_PAGE, name: 'invoice_document', isDefault: undefined }; + const h = await harness({ page: named }); + const out = await h.service.printRecordToFile({ ...REQUEST, page: 'invoice_document' }, USER); + expect(h.driver.renderPdf).toHaveBeenCalledWith({ + url: `${CONSOLE}/apps/showcase/invoice/record/inv_1/page/invoice_document`, + cookie: expect.objectContaining({ value: COOKIE_VALUE }), + timeoutMs: PRINT_RENDER_TIMEOUT_MS, + }); + expect(out.filename).toBe('invoice_document-inv_1.pdf'); + expect(h.revoke).toHaveBeenCalledTimes(1); + }); + + it('the default record page keeps the plain record route; every segment of the named route is encoded', async () => { + const h = await harness({ page: { ...PRINT_PAGE, name: 'doc/1', isDefault: false } }); + await h.service.printRecordToFile({ ...REQUEST, recordId: 'inv 1', page: 'doc/1' }, USER); + expect(h.driver.renderPdf.mock.calls[0]![0].url).toBe(`${CONSOLE}/apps/showcase/invoice/record/inv%201/page/doc%2F1`); + const d = await harness(); + await d.service.printRecordToFile(REQUEST, USER); + expect(d.driver.renderPdf.mock.calls[0]![0].url).toBe(`${CONSOLE}/apps/showcase/invoice/record/inv_1`); + }); + it('the mint happens only after every refusal check, and the revoke after the archive', async () => { const h = await harness(); await h.service.printRecordToFile(REQUEST, USER); diff --git a/packages/services/service-print/src/print-service.ts b/packages/services/service-print/src/print-service.ts index aa2bbe53b7e..61966c7d5b3 100644 --- a/packages/services/service-print/src/print-service.ts +++ b/packages/services/service-print/src/print-service.ts @@ -33,9 +33,11 @@ import type { PrincipalSessionMinter, PrintSession } from './principal-session.j * once more AFTER step 5 — so even that probe never starts a browser * before every other refusal has had its say. * 3. **The page.** The request names the page it expects; it must exist - * (`RESOURCE_NOT_FOUND` / `404`), be bound to the record's object, declare - * `print`, and be the object's default record page — the one page the - * console renders a record on (`VALIDATION_ERROR` / `400` for each). + * (`RESOURCE_NOT_FOUND` / `404`), be a record page of the record's object, + * and declare `print` (`VALIDATION_ERROR` / `400` for each). Any print page + * of the object prints, the default record page or another one: the + * default renders on the console's record route, any other page on its + * named record-page route ({@link printPageUrl}). * 4. **The record, read AS THE CALLER.** A record the caller cannot read and * a record that does not exist answer the same `RECORD_NOT_FOUND` / `404`, * so the answer says nothing about a record the caller may not see. A user @@ -167,6 +169,46 @@ function nonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.trim().length > 0; } +/** + * True when `page` is a record page of `object`: its `type` is `'record'` and + * its `object` names that object. + * + * The same reading as the console's `isRecordPageOf` (`@object-ui/react`, + * `ui/packages/react/src/hooks/usePageAssignment.ts`), which is what the + * console's named record-page route asks before it draws the named page and + * what its record route filters the object's pages with. The console refuses + * every other name with a not-found state, so a page this function accepts is + * one the console draws, and a page it refuses is one a print could only put a + * not-found state (or no page at all) on paper for. No wider and no narrower: + * `type` is read exactly, as the console reads it, so a body that carries no + * `type` is not a record page here either (every door that parses or serves a + * page fills `PageSchema`'s declared default in). + */ +function isRecordPageOf(page: Record, object: string): boolean { + return page.type === 'record' && page.object === object; +} + +/** + * The console URL a record prints from on `page`, a record page of its object + * that declares `print` (checked before this is asked). + * + * - The object's default record page renders on the console's record route, + * `CONSOLE/apps/APP/OBJECT/record/ID`, which draws a record on that page. + * - Any other print page renders on the console's named record-page route, + * `CONSOLE/apps/APP/OBJECT/record/ID/page/PAGE` (`RECORD_NAMED_PAGE_ROUTE`, + * `ui/packages/app-shell/src/console/AppContent.tsx`), which draws the named + * page and never falls back to the default one. + * + * Every segment is encoded on its own, so a name carrying `/` or `?` stays the + * one segment it is. + */ +function printPageUrl(consoleUrl: string, req: ValidRequest, page: Record): string { + const record = + `${consoleUrl}/apps/${encodeURIComponent(req.app)}/` + + `${encodeURIComponent(req.object)}/record/${encodeURIComponent(req.recordId)}`; + return defaultRecordPageObject(page) === req.object ? record : `${record}/page/${encodeURIComponent(req.page)}`; +} + /** What the engine's last probe answered. */ type ProbeResult = { ok: true; version: string } | { ok: false; reason: string }; @@ -259,7 +301,7 @@ export class PrintService implements IPrintService { // ③ The page. const req = validateRequest(request); - this.refuseUnlessPrintPage(engine, req); + const page = this.refuseUnlessPrintPage(engine, req); // ④ The record, read as the caller. await this.refuseUnlessReadable(engine, req, context); @@ -279,9 +321,7 @@ export class PrintService implements IPrintService { // Mint, render, archive — and revoke, whatever happened. const filename = req.filename ?? `${req.page}-${req.recordId}.pdf`; - const url = - `${placement.resolved.consoleUrl}/apps/${encodeURIComponent(req.app)}/` + - `${encodeURIComponent(req.object)}/record/${encodeURIComponent(req.recordId)}`; + const url = printPageUrl(placement.resolved.consoleUrl, req, page); const session: PrintSession = await minter.mint(context, PRINT_SESSION_TTL_SECONDS); try { const pdf = await driver.renderPdf({ url, cookie: session.cookie, timeoutMs: PRINT_RENDER_TIMEOUT_MS }); @@ -336,16 +376,17 @@ export class PrintService implements IPrintService { if (context.authGate) deny(`the user's sign-in is held by an authentication policy (${context.authGate.code})`); } - private refuseUnlessPrintPage(engine: PrintEngine, req: ValidRequest): void { + private refuseUnlessPrintPage(engine: PrintEngine, req: ValidRequest): Record { const page = engine.registry.getItem>('page', req.page); if (!page || typeof page !== 'object') { printRefusal('RESOURCE_NOT_FOUND', 404, `There is no page named '${req.page}'.`); } - if (page.object !== req.object) { + if (!isRecordPageOf(page, req.object)) { printRefusal( 'VALIDATION_ERROR', 400, - `The page '${req.page}' is not a record page of '${req.object}', so it cannot print a ${req.object} record.`, + `The page '${req.page}' is not a record page of '${req.object}' (type: 'record', object: '${req.object}'), ` + + `so it cannot print a ${req.object} record.`, ); } const print = page.print; @@ -356,14 +397,7 @@ export class PrintService implements IPrintService { `The page '${req.page}' does not declare 'print', so it is not a print page. Declare print on the page to print records on it.`, ); } - if (defaultRecordPageObject(page) !== req.object) { - printRefusal( - 'VALIDATION_ERROR', - 400, - `The page '${req.page}' is not the default record page of '${req.object}' (isDefault: true). The console ` + - 'renders a record on its default record page only, so that is the one page a record can be printed on.', - ); - } + return page; } private async refuseUnlessReadable( From 25f758a5e43c80bb0ba4e0b7754f23cc6745f5cc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 11:31:33 +0000 Subject: [PATCH 2/3] test(dogfood): print a project on a print page that is not its default record page Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5kGuMFreHR7gCGkpxL --- .../dogfood/test/print-named-page.fixture.ts | 30 ++++ .../print-service-named-page.dogfood.test.ts | 161 ++++++++++++++++++ .../test/print-service-render.dogfood.test.ts | 33 +++- 3 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 packages/qa/dogfood/test/print-named-page.fixture.ts create mode 100644 packages/qa/dogfood/test/print-service-named-page.dogfood.test.ts diff --git a/packages/qa/dogfood/test/print-named-page.fixture.ts b/packages/qa/dogfood/test/print-named-page.fixture.ts new file mode 100644 index 00000000000..f8548d17bf8 --- /dev/null +++ b/packages/qa/dogfood/test/print-named-page.fixture.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The print page the named-page print dogfood files save (#26583): a project +// brief for `showcase_project`, beside the project's own default record page +// (`showcase_project_detail`, a working page with no `print`). Every block is +// from the printable subset, so the metadata save door accepts it. + +/** The project's print page: not its default record page, which stays the working page. */ +export const PROJECT_BRIEF_PAGE = { + name: 'showcase_project_brief', + label: 'Project brief', + type: 'record', + object: 'showcase_project', + kind: 'full', + print: { paperSize: 'A4', orientation: 'portrait', pageNumbers: true }, + regions: [ + { name: 'header', components: [{ type: 'element:text', properties: { content: 'Project brief' } }] }, + { + name: 'main', + components: [ + { + type: 'record:details', + properties: { + sections: [{ name: 'brief', label: 'Brief', columns: 2, fields: ['name', 'status', 'start_date', 'end_date'] }], + }, + }, + ], + }, + ], +} as const; diff --git a/packages/qa/dogfood/test/print-service-named-page.dogfood.test.ts b/packages/qa/dogfood/test/print-service-named-page.dogfood.test.ts new file mode 100644 index 00000000000..5dbbd126fd8 --- /dev/null +++ b/packages/qa/dogfood/test/print-service-named-page.dogfood.test.ts @@ -0,0 +1,161 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// A record printed on a print page that is NOT its object's default record +// page (#26583, ruling A), on a real showcase boot. +// +// `showcase_project` keeps its everyday record page, `showcase_project_detail` +// (the default, a slotted working page with no `print`). A second page, a +// project brief that declares `print`, is saved through the metadata save door +// the way an author adds one, beside it. The print service, over the real +// kernel services, then: +// +// - prints a project on the brief, rendering the console's named record-page +// route (`…/record/ID/page/PAGE`, `RECORD_NAMED_PAGE_ROUTE`), and archives +// the PDF on the project as the requester; +// - still refuses the working page (it declares no `print`) and a print page +// of another object, `VALIDATION_ERROR` / 400, before anything is minted. +// +// The browser and the auth mint are stood in by recording doubles, as in +// `print-service-archive.dogfood.test.ts`; the console route itself is drawn +// against a real Chromium in `print-service-render.dogfood.test.ts`. + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { VerifyStack } from '@objectstack/verify'; +import { StorageServicePlugin } from '@objectstack/service-storage'; +import { AuditPlugin } from '@objectstack/plugin-audit'; +import { bootShowcase } from './showcase-boot.js'; +import { PrintServicePlugin } from '../../../services/service-print/src/print-service-plugin.js'; +import { PrintService, type PrintEngine } from '../../../services/service-print/src/print-service.js'; +import { FILE_ATTACHMENT_SERVICE, type IDataEngine, type IFileAttachmentService } from '@objectstack/spec/contracts'; +import { PROJECT_BRIEF_PAGE } from './print-named-page.fixture.js'; + +const SYS = { isSystem: true } as const; +const CONSOLE = 'http://127.0.0.1:1/_console'; +const PDF = Buffer.from('%PDF-1.4\n% named print page dogfood\n%%EOF\n'); +const PRINT_ENV = ['OS_PRINT_CHROMIUM_PATH', 'OS_PRINT_BROWSER_ENDPOINT', 'OS_PRINT_CONSOLE_URL']; + +interface Refusal { + code: unknown; + status: unknown; +} + +async function refusalOf(p: Promise): Promise { + try { + await p; + } catch (err) { + return { code: (err as Refusal).code, status: (err as Refusal).status }; + } + throw new Error('expected a refusal, the call resolved'); +} + +describe('a record printed on a print page that is not its object’s default record page', () => { + let stack: VerifyStack; + let rootDir: string; + let ql: IDataEngine; + let service: PrintService; + let project: string; + const saved: Record = {}; + const revoke = vi.fn(async () => undefined); + const mint = vi.fn(async () => ({ + token: 'stand-in-token', + cookie: { name: 'stand-in', value: 'stand-in-cookie', path: '/', httpOnly: true, secure: false }, + revoke, + })); + const renderPdf = vi.fn(async () => PDF); + + beforeAll(async () => { + for (const k of PRINT_ENV) { + saved[k] = process.env[k]; + delete process.env[k]; + } + rootDir = mkdtempSync(join(tmpdir(), 'print-named-page-')); + stack = await bootShowcase({ + extraPlugins: [ + new StorageServicePlugin({ adapter: 'local', local: { rootDir }, bindToSettings: false }), + new AuditPlugin(), + new PrintServicePlugin(), + ], + }); + ql = stack.kernel.getService('objectql'); + // The brief is added the way an author adds a page: through the metadata save door. + const protocol = await stack.kernel.getServiceAsync<{ saveMetaItem(r: Record): Promise }>('protocol'); + await protocol.saveMetaItem({ type: 'page', name: PROJECT_BRIEF_PAGE.name, item: PROJECT_BRIEF_PAGE }); + const [seeded] = await ql.find('showcase_project', { limit: 1 }, { context: SYS }); + project = String(seeded!.id); + + service = new PrintService({ + placement: { state: 'configured', resolved: { placement: 'local', target: '/stand-in/chromium', consoleUrl: CONSOLE } }, + driver: { placement: 'local', probe: async () => ({ version: 'stand-in' }), renderPdf }, + getEngine: () => stack.kernel.getService('objectql'), + getAttachments: () => stack.kernel.getService(FILE_ATTACHMENT_SERVICE), + getMinter: () => ({ mint }), + }); + await service.probe(); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + if (rootDir) rmSync(rootDir, { recursive: true, force: true }); + for (const k of PRINT_ENV) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + }); + + const request = (page: string, object = 'showcase_project', recordId = project) => ({ app: 'showcase', object, recordId, page }); + + it('precondition: the project keeps its working default record page, and the brief is a second page beside it', () => { + const engine = stack.kernel.getService('objectql'); + expect(engine.registry.getItem>('page', 'showcase_project_detail')).toMatchObject({ + object: 'showcase_project', + isDefault: true, + }); + expect(engine.registry.getItem>('page', 'showcase_project_detail')?.print).toBeUndefined(); + const brief = engine.registry.getItem>('page', PROJECT_BRIEF_PAGE.name); + expect(brief).toMatchObject({ type: 'record', object: 'showcase_project', print: { paperSize: 'A4' } }); + expect(brief?.isDefault).toBeUndefined(); + }); + + it('prints the project on the brief through the console’s named record-page route, archived as the requester', async () => { + const ctx = await stack.contextFor(await stack.signIn()); + mint.mockClear(); + revoke.mockClear(); + renderPdf.mockClear(); + const out = await service.printRecordToFile(request(PROJECT_BRIEF_PAGE.name), ctx); + expect(renderPdf).toHaveBeenCalledTimes(1); + expect(renderPdf).toHaveBeenCalledWith( + expect.objectContaining({ url: `${CONSOLE}/apps/showcase/showcase_project/record/${project}/page/${PROJECT_BRIEF_PAGE.name}` }), + ); + expect(revoke).toHaveBeenCalledTimes(1); + expect(out).toMatchObject({ size: PDF.byteLength, filename: `${PROJECT_BRIEF_PAGE.name}-${project}.pdf` }); + const attachment = await ql.findOne('sys_attachment', { where: { id: out.attachmentId } }, { context: SYS }); + expect(attachment).toMatchObject({ parent_object: 'showcase_project', parent_id: project, file_id: out.fileId, uploaded_by: ctx.userId }); + }); + + it('the working default page, which declares no print, is still refused: VALIDATION_ERROR / 400, nothing minted', async () => { + const ctx = await stack.contextFor(await stack.signIn()); + mint.mockClear(); + renderPdf.mockClear(); + expect(await refusalOf(service.printRecordToFile(request('showcase_project_detail'), ctx))).toEqual({ + code: 'VALIDATION_ERROR', + status: 400, + }); + expect(mint).not.toHaveBeenCalled(); + expect(renderPdf).not.toHaveBeenCalled(); + }); + + it('the brief cannot print a record of another object: VALIDATION_ERROR / 400, nothing minted', async () => { + const ctx = await stack.contextFor(await stack.signIn()); + const [invoice] = await ql.find('showcase_invoice', { limit: 1 }, { context: SYS }); + mint.mockClear(); + renderPdf.mockClear(); + expect( + await refusalOf(service.printRecordToFile(request(PROJECT_BRIEF_PAGE.name, 'showcase_invoice', String(invoice!.id)), ctx)), + ).toEqual({ code: 'VALIDATION_ERROR', status: 400 }); + expect(mint).not.toHaveBeenCalled(); + expect(renderPdf).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/qa/dogfood/test/print-service-render.dogfood.test.ts b/packages/qa/dogfood/test/print-service-render.dogfood.test.ts index b7ed6cbc9ac..15deea8177f 100644 --- a/packages/qa/dogfood/test/print-service-render.dogfood.test.ts +++ b/packages/qa/dogfood/test/print-service-render.dogfood.test.ts @@ -18,7 +18,11 @@ // `sys_file.owner_id` and `sys_attachment.uploaded_by` the requester; // - the minted session gone after the print — after a success and after a // forced failure (the archive's byte store refusing after the render); -// - the minted cookie in no refusal message. +// - the minted cookie in no refusal message; +// - a project printed on a print page that is NOT its object's default record +// page (the brief of `print-named-page.fixture.ts`, saved through the +// metadata save door), through the console's named record-page route +// (#26583). // // Needs two things this suite's CI job does not build: the Console bundle // (`packages/console/dist`, `pnpm objectui:build`) and a Chromium @@ -38,6 +42,7 @@ import { StorageServicePlugin } from '@objectstack/service-storage'; import { AuditPlugin } from '@objectstack/plugin-audit'; import type { IAuthService, IDataEngine, IStorageService, PrincipalSession } from '@objectstack/spec/contracts'; import { bootShowcase } from './showcase-boot.js'; +import { PROJECT_BRIEF_PAGE } from './print-named-page.fixture.js'; import { createConsoleStaticPlugin } from '../../../cli/src/utils/console.js'; import { PrintServicePlugin } from '../../../services/service-print/src/print-service-plugin.js'; import type { IPrintService } from '../../../services/service-print/src/print-service.js'; @@ -180,6 +185,8 @@ for (const placement of ['local', 'remote'] as const) { print = stack.kernel.getService('print'); ql = stack.kernel.getService('objectql'); + const protocol = await stack.kernel.getServiceAsync<{ saveMetaItem(r: Record): Promise }>('protocol'); + await protocol.saveMetaItem({ type: 'page', name: PROJECT_BRIEF_PAGE.name, item: PROJECT_BRIEF_PAGE }); // Observe each mint (the plugin reads the auth service per call): keep the // cookie so the test can ask, afterwards, whether it still opens a session. const auth = stack.kernel.getService('auth'); @@ -243,6 +250,30 @@ for (const placement of ['local', 'remote'] as const) { expect(await opensASession(minted[0]!)).toEqual([401, 'UNAUTHENTICATED']); }, 120_000); + it('prints a project on a print page that is not its default record page, through the named record-page route', async () => { + const token = await stack.signIn(); + const ctx = await stack.contextFor(token); + const [project] = await ql.find('showcase_project', { limit: 1 }, { context: SYS }); + const projectId = String(project!.id); + sessionUsers.length = 0; + minted.length = 0; + + const out = await print.printRecordToFile( + { app: APP, object: 'showcase_project', recordId: projectId, page: PROJECT_BRIEF_PAGE.name }, + ctx, + ); + + expect(new Set(sessionUsers)).toEqual(new Set([ctx.userId])); + const file = await ql.findOne('sys_file', { where: { id: out.fileId } }, { context: SYS }); + const attachment = await ql.findOne('sys_attachment', { where: { id: out.attachmentId } }, { context: SYS }); + expect(file).toMatchObject({ status: 'committed', mime_type: 'application/pdf', owner_id: ctx.userId }); + expect(attachment).toMatchObject({ parent_object: 'showcase_project', parent_id: projectId, uploaded_by: ctx.userId }); + const bytes = await stack.kernel.getService('storage').download(String(file!.key)); + expect(bytes.subarray(0, 5).toString('latin1')).toBe('%PDF-'); + expect(minted).toHaveLength(1); + expect(await opensASession(minted[0]!)).toEqual([401, 'UNAUTHENTICATED']); + }, 120_000); + it('a print that fails after the render still revokes the session, stores nothing, and names no credential', async () => { const token = await stack.signIn(); const ctx = await stack.contextFor(token); From 7c5ee8b24eb602bec66f40b3c0113734c95b7cba Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 12:03:12 +0000 Subject: [PATCH 3/3] docs(app-showcase): the invoice print page is the default by choice, not because only the default prints Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5kGuMFreHR7gCGkpxL --- .../app-showcase/src/ui/pages/invoice-document.page.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/examples/app-showcase/src/ui/pages/invoice-document.page.ts b/examples/app-showcase/src/ui/pages/invoice-document.page.ts index 1e5578e84b5..0ac9a54c609 100644 --- a/examples/app-showcase/src/ui/pages/invoice-document.page.ts +++ b/examples/app-showcase/src/ui/pages/invoice-document.page.ts @@ -9,9 +9,10 @@ import { definePage } from '@objectstack/spec/ui'; * the requesting user, and archives the PDF as a file attached to the invoice * (`showcase_invoice` declares `enable: { files: true }` for that). * - * It is the invoice's DEFAULT record page (`isDefault: true`) because the - * console renders a record on its default record page, so that is the page a - * record prints on. Every block is from the printable subset + * It is the invoice's DEFAULT record page (`isDefault: true`), so the invoice + * opens on its document. That is this app's choice, not a rule: a record prints + * on any print page of its object, and one that is not the default renders on + * the console's named record-page route. Every block is from the printable subset * (`PRINTABLE_PAGE_COMPONENT_TYPES`): the header and footer regions are the * letterhead and the footer, repeated on every sheet, and `record:line_items` * prints every line up to its cap.