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
15 changes: 15 additions & 0 deletions .changeset/26583-print-named-page.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 11 additions & 6 deletions content/docs/ui/print-pages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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' } }] },
Expand All @@ -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

Expand Down Expand Up @@ -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 |
Expand Down
7 changes: 4 additions & 3 deletions examples/app-showcase/src/ui/pages/invoice-document.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions packages/qa/dogfood/test/print-named-page.fixture.ts
Original file line number Diff line number Diff line change
@@ -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;
161 changes: 161 additions & 0 deletions packages/qa/dogfood/test/print-service-named-page.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>): Promise<Refusal> {
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<string, string | undefined> = {};
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<IDataEngine>('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<string, unknown>): Promise<unknown> }>('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<PrintEngine>('objectql'),
getAttachments: () => stack.kernel.getService<IFileAttachmentService>(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<PrintEngine>('objectql');
expect(engine.registry.getItem<Record<string, unknown>>('page', 'showcase_project_detail')).toMatchObject({
object: 'showcase_project',
isDefault: true,
});
expect(engine.registry.getItem<Record<string, unknown>>('page', 'showcase_project_detail')?.print).toBeUndefined();
const brief = engine.registry.getItem<Record<string, unknown>>('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();
});
});
33 changes: 32 additions & 1 deletion packages/qa/dogfood/test/print-service-render.dogfood.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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';
Expand Down Expand Up @@ -180,6 +185,8 @@ for (const placement of ['local', 'remote'] as const) {

print = stack.kernel.getService<IPrintService>('print');
ql = stack.kernel.getService<IDataEngine>('objectql');
const protocol = await stack.kernel.getServiceAsync<{ saveMetaItem(r: Record<string, unknown>): Promise<unknown> }>('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<IAuthService>('auth');
Expand Down Expand Up @@ -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<IStorageService>('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);
Expand Down
Loading
Loading