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
14 changes: 14 additions & 0 deletions .changeset/22270-print-record-door.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/service-print': minor
---

Clause-②: no

feat(service-print): print a record from the console — `PrintServicePlugin` mounts `POST /api/v1/print/records`, which prints the record as the signed-in caller and answers with the file it attached

- **What it adds.** One HTTP route on the kernel's HTTP server, mounted at `kernel:ready` whenever the plugin is composed. Its JSON body is the in-process request, `{ app, object, recordId, page, filename? }`. The route resolves the caller with the platform's one authorization resolver, hands that caller's execution context and the request to `IPrintService.printRecordToFile`, and answers `201` with `{ fileId, attachmentId, size, filename }`. The service attaches the PDF once, as the caller; the route never attaches anything itself.
- **How an app uses it.** Declare a `type: 'api'` action whose `target` is `/api/v1/print/records`, with `recordIdParam: 'recordId'` and the rest of the request in `bodyExtra` (`app`, `object`, `page`). The console sends the clicked record's id and those fields, and shows a refusal's message as the action's error.
- **What the route refuses, before the service is asked:** an anonymous request (`UNAUTHENTICATED` / 401); a sign-in held by an authentication policy (`403` with the policy's own code); a request from an impersonation session (`PERMISSION_DENIED` / 403 — a print is archived in the session user's name, so it is never made for them by someone signed in as them); a query parameter, or a body field outside the request (`VALIDATION_ERROR` / 400). A permission store that cannot be read answers its own `SERVICE_UNAVAILABLE` / 503.
- **What it relays.** Every refusal the service declares keeps its `code` and `status` (`SERVICE_UNAVAILABLE` / 503 when no print engine is configured, `VALIDATION_ERROR` / 400 for a page without `print`, `RECORD_NOT_FOUND` / 404 for a record the caller cannot read, the files and attach refusals, `TIMEOUT` / 504, the archive's own). A 4xx keeps the service's message. A 5xx answers with the route's own sentence, and the service's text goes to the server fault log.
- **Stated limits.** The route writes no audit row of its own: a print's session sign-in and sign-out are recorded as any sign-in is, and `sys_file.owner_id` and `sys_attachment.uploaded_by` name the caller. An impersonation session is recognised by the mark the auth service writes on it (`session.impersonatedBy`, stored as `sys_session.impersonated_by`); an auth provider that marks impersonation some other way is not recognised.
- **Unchanged:** the in-process `print` service and its refusals, every export, every plugin option and environment variable, and the error-code vocabulary. A kernel with no HTTP server mounts no route and says so once at `kernel:ready`.
41 changes: 41 additions & 0 deletions content/docs/ui/print-pages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,47 @@ written under their own permissions, and both the file and the attachment name
them. A user can never archive a record they could not read, or attach a file
where they could not attach one by hand.

## Print from the console

`PrintServicePlugin` also mounts one HTTP route on the kernel's HTTP server,
`POST /api/v1/print/records`, so a user can print the record they are looking
at. Its JSON body is the same request, `{ app, object, recordId, page,
filename? }`, and it answers `201` with the stored file,
`{ fileId, attachmentId, size, filename }`. The print runs as the signed-in
caller, exactly as an in-process print with their context does, and the file is
attached once, by the service.

Reach it from the console with an ordinary `type: 'api'` action on the object:

```ts
import { defineAction } from '@objectstack/spec/ui';

export const GenerateInvoicePdf = defineAction({
name: 'generate_invoice_pdf',
label: 'Generate PDF',
objectName: 'invoice',
type: 'api',
target: '/api/v1/print/records',
method: 'POST',
recordIdParam: 'recordId', // the clicked record's id
bodyExtra: { app: 'sales', object: 'invoice', page: 'invoice_document' },
locations: ['list_item'],
refreshAfter: true,
});
```

The route refuses, before it asks the service anything: an anonymous request
(`UNAUTHENTICATED` / 401); a sign-in held by an authentication policy (`403` with
the policy's own code); a request from an **impersonation session** — an
administrator signed in as the user — (`PERMISSION_DENIED` / 403: a print is
archived in the user's name, so it is never made for them by someone else); and
a query parameter or a body field outside the request (`VALIDATION_ERROR` /
400). Every refusal below then reaches the caller with its own `code` and
`status`; a 5xx answers with the route's own sentence, and the service's text is
written to the server log. The route writes no audit row of its own: the print's
session sign-in and sign-out are recorded as any sign-in is, and the file and
the attachment name the caller.

## Refusals

Each refusal carries `code` and `status`, and every one of them is decided
Expand Down
7 changes: 7 additions & 0 deletions examples/app-showcase/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
RuntimeConfigPlugin,
resolveCloudUrl,
} from '@objectstack/cloud-connection';
import { PrintServicePlugin } from '@objectstack/service-print';

import * as objects from './src/data/objects/index.js';
import { ShowcaseExternalDatasource } from './src/system/datasources/showcase-external.datasource.js';
Expand Down Expand Up @@ -161,6 +162,12 @@ export default defineStack({
]
: []),
new RuntimeConfigPlugin({ controlPlaneUrl: '', singleEnvironment: true, installLocal: true }),
// The print service and its door, `POST /api/v1/print/records`, which the
// invoice's Generate PDF action calls (src/ui/actions/). The engine is
// headless Chromium, configured by environment: `OS_PRINT_CHROMIUM_PATH`
// (or `OS_PRINT_BROWSER_ENDPOINT`) and `OS_PRINT_CONSOLE_URL`. With none set
// the plugin says so once at startup, and every print is refused 503.
new PrintServicePlugin(),
],

// Infrastructure
Expand Down
1 change: 1 addition & 0 deletions examples/app-showcase/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"@objectstack/runtime": "workspace:*",
"@objectstack/service-datasource": "workspace:*",
"@objectstack/service-i18n": "workspace:*",
"@objectstack/service-print": "workspace:*",
"@objectstack/spec": "workspace:*"
},
"devDependencies": {
Expand Down
9 changes: 9 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,15 @@ export const ShowcaseTranslationBundle = {
message: '该客户已流失 —— 请先将其恢复为活跃状态,然后再开具发票。',
},
},
// Translated at birth: this example is ratcheted at its untranslated
// count, so a declared action label that skips zh-CN fails
// check-i18n-coverage.
_actions: {
showcase_generate_invoice_pdf: {
label: '生成 PDF',
successMessage: 'PDF 已附加到发票。',
},
},
},
// See the `en` side for why this entry translates exactly ONE field and
// no more (check-i18n-coverage is a two-sided ratchet).
Expand Down
45 changes: 45 additions & 0 deletions examples/app-showcase/src/ui/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,50 @@ export const SubmitForSignoffAction = defineAction({
refreshAfter: true,
});

/**
* api — print the invoice to a PDF and attach it to the invoice, as the user
* who clicks it.
*
* The target is `@objectstack/service-print`'s door, `POST
* /api/v1/print/records`, which `PrintServicePlugin` mounts (composed in
* `objectstack.config.ts`). The door prints the record on the invoice's
* default record page, `showcase_invoice_document`, the page that declares
* `print`, through the in-process print service, which attaches the PDF to the
* invoice once, as the caller. The body is the door's whole request: the
* clicked record's id (`recordIdParam`) and the three constant fields
* (`bodyExtra`); `app` is the console route segment the record opens in.
*
* Its availability is the user's access to the record: a row or a record the
* user cannot read is not shown to them, and a page is never gated apart from
* the data it shows. The door re-judges every print anyway, and each of its
* refusals reaches the user as the action's error: no print engine configured
* (the showcase composes none by default: set `OS_PRINT_CHROMIUM_PATH` or
* `OS_PRINT_BROWSER_ENDPOINT`, and `OS_PRINT_CONSOLE_URL`), a record they may
* not edit (attaching needs it), an impersonation session.
*
* `list_item` puts it in the invoice list's row menu, where the console draws
* it. `record_header` is its record-page placement, beside Submit for Sign-off.
* ⚠️ The console draws record-header actions in a page's `page:header`, and a
* print page may not hold one, so on the invoice's record page (a print page)
* neither action is drawn until the console gives a print page a header host.
*/
export const GenerateInvoicePdfAction = defineAction({
name: 'showcase_generate_invoice_pdf',
label: 'Generate PDF',
icon: 'file-text',
objectName: invoice,
type: 'api',
target: '/api/v1/print/records',
method: 'POST',
// One print per record: the door takes one record id.
execution: 'perRecord',
recordIdParam: 'recordId',
bodyExtra: { app: 'com.example.showcase', object: invoice, page: 'showcase_invoice_document' },
successMessage: 'The PDF is attached to the invoice.',
locations: ['list_item', 'record_header'],
refreshAfter: true,
});

/**
* script — the **action-param widget gallery** (ADR-0059). One inline param of
* every non-trivial widget type, so the `ActionParamDialog` renders each real
Expand Down Expand Up @@ -509,6 +553,7 @@ export const allActions = [
LogTimeAction,
NewTaskAction,
SubmitForSignoffAction,
GenerateInvoicePdfAction,
ActionParamGalleryAction,
ArchiveTaskAction,
PortfolioSnapshotAction,
Expand Down
137 changes: 137 additions & 0 deletions examples/app-showcase/test/invoice-generate-pdf-action.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { PrintServicePlugin } from '@objectstack/service-print';

import stack from '../objectstack.config.js';
import { GenerateInvoicePdfAction } from '../src/ui/actions/index.js';
import { Invoice } from '../src/data/objects/invoice.object.js';

/**
* The invoice's Generate PDF action reaches a door the showcase composes.
*
* The action is a `type: 'api'` action: the console POSTs its body to its
* `target`. That only works if something mounts the target, and the showcase
* mounts it by composing `PrintServicePlugin`. These pins hold the three
* together: the composition, the path the plugin mounts, and the body the
* console builds from the declaration (the clicked record's id under
* `recordIdParam`, then `bodyExtra`).
*
* The last pin sends that body to the mounted door, signed in, on a
* composition with no print engine configured (the showcase's default): the
* print is refused `SERVICE_UNAVAILABLE` / `503`, which the console shows as
* the action's error. The action reports the missing engine; it does not
* fail silently, and the body is accepted as the door's request (a field
* outside its set would be a `400`).
*/

const PRINT_ENV = ['OS_PRINT_CHROMIUM_PATH', 'OS_PRINT_BROWSER_ENDPOINT', 'OS_PRINT_CONSOLE_URL'];
const PRINT_PLUGIN = 'com.objectstack.service.print';

type Handler = (req: unknown, res: unknown) => Promise<void>;

const composed = ((stack.plugins ?? []) as Array<{ name?: unknown }>).filter((p) => p?.name === PRINT_PLUGIN);
const pages = (stack.pages ?? []) as Array<Record<string, unknown>>;

describe('showcase — the invoice Generate PDF action and the print door', () => {
const saved: Record<string, string | undefined> = {};
const mounted = new Map<string, Handler>();

beforeAll(async () => {
for (const k of PRINT_ENV) {
saved[k] = process.env[k];
delete process.env[k];
}
const services = new Map<string, unknown>();
const hooks: Array<() => Promise<void>> = [];
const http = {
get: () => undefined,
put: () => undefined,
patch: () => undefined,
delete: () => undefined,
use: () => undefined,
post: (path: string, handler: Handler) => mounted.set(`POST ${path}`, handler),
};
services.set('http-server', http);
// A signed-in user: the auth service's session read, by bearer.
services.set('auth', {
api: {
getSession: async ({ headers }: { headers: Headers }) =>
headers.get('authorization') === 'Bearer member'
? { user: { id: 'usr_member', email: 'member@example.test' }, session: { id: 'ses_1', userId: 'usr_member' } }
: null,
},
});
services.set('objectql', { find: async () => [] });
const silent = () => undefined;
const ctx = {
logger: { info: silent, warn: silent, error: silent, debug: silent },
registerService: (name: string, service: unknown) => services.set(name, service),
getService: (name: string) => {
if (!services.has(name)) throw new Error(`service ${name} not found`);
return services.get(name);
},
hook: (event: string, fn: () => Promise<void>) => {
if (event === 'kernel:ready') hooks.push(fn);
},
};
const plugin = composed[0] as PrintServicePlugin;
await plugin.init(ctx as never);
await plugin.start(ctx as never);
for (const hook of hooks) await hook();
});

afterAll(async () => {
await (composed[0] as PrintServicePlugin | undefined)?.destroy();
for (const k of PRINT_ENV) {
if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[k];
}
});

it('the showcase composes the print service plugin, once', () => {
expect(composed).toHaveLength(1);
expect(composed[0]).toBeInstanceOf(PrintServicePlugin);
});

it('the action is an api action on the invoice whose target is the route the plugin mounts', () => {
expect(GenerateInvoicePdfAction).toMatchObject({ type: 'api', objectName: Invoice.name, method: 'POST' });
expect([...mounted.keys()]).toEqual([`POST ${GenerateInvoicePdfAction.target}`]);
const declared = ((stack.actions ?? []) as Array<{ name?: unknown }>).filter((a) => a.name === GenerateInvoicePdfAction.name);
expect(declared).toHaveLength(1);
});

it('it prints the invoice on its default record page, which declares print, in this app', () => {
const extra = GenerateInvoicePdfAction.bodyExtra as Record<string, unknown>;
expect(extra.object).toBe(Invoice.name);
expect(extra.app).toBe(stack.manifest?.id);
const defaults = pages.filter((p) => p.type === 'record' && p.object === Invoice.name && p.isDefault === true);
expect(defaults.map((p) => p.name)).toEqual([extra.page]);
expect(typeof defaults[0]?.print).toBe('object');
expect(Invoice.enable?.files).toBe(true);
});

it('the body the console builds is the door\'s request; with no engine configured the print is refused 503', async () => {
const handler = mounted.get(`POST ${GenerateInvoicePdfAction.target}`);
expect(handler).toBeDefined();
const body = { [GenerateInvoicePdfAction.recordIdParam as string]: 'inv_1', ...(GenerateInvoicePdfAction.bodyExtra ?? {}) };
const answer: { status?: number; body?: any } = {};
const res = {
status: (code: number) => {
answer.status = code;
return res;
},
json: (b: unknown) => {
answer.body = b;
},
header: () => res,
send: () => undefined,
};
await handler!(
{ params: {}, query: {}, body, headers: { authorization: 'Bearer member' }, method: 'POST', path: GenerateInvoicePdfAction.target },
res,
);
expect([answer.body?.error?.code, answer.status]).toEqual(['SERVICE_UNAVAILABLE', 503]);
expect(answer.body?.success).toBe(false);
});
});
1 change: 1 addition & 0 deletions examples/app-showcase/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export default defineConfig({
{ find: /^@objectstack\/formula$/, replacement: path.resolve(__dirname, '../../packages/formula/src/index.ts') },
{ find: /^@objectstack\/plugin-email$/, replacement: path.resolve(__dirname, '../../packages/plugins/plugin-email/src/index.ts') },
{ find: /^@objectstack\/lint$/, replacement: path.resolve(__dirname, '../../packages/lint/src/index.ts') },
{ find: /^@objectstack\/service-print$/, replacement: path.resolve(__dirname, '../../packages/services/service-print/src/index.ts') },
],
},
test: {
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/security/authz-store-unavailable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,9 @@ const TRANSPORT_LEDGER: Record<string, 'guarded' | 'propagates'> = {
'packages/plugins/plugin-sharing/src/sharing-plugin.ts': 'guarded',
'packages/cloud-connection/src/marketplace-install-local-plugin.ts': 'guarded',
'packages/cloud-connection/src/cloud-connection-plugin.ts': 'guarded',
// The print door: its caller-resolution `catch` relays the outage with its
// own status and code before any other arm can answer it.
'packages/services/service-print/src/print-routes.ts': 'guarded',
};

/** Test scaffolding is not a transport — it drives the resolver, it does not authorize for anyone. */
Expand Down
Loading
Loading