diff --git a/.changeset/22270-print-record-door.md b/.changeset/22270-print-record-door.md new file mode 100644 index 00000000000..c87942e46fc --- /dev/null +++ b/.changeset/22270-print-record-door.md @@ -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`. diff --git a/content/docs/ui/print-pages.mdx b/content/docs/ui/print-pages.mdx index 1484c294c94..b64dd512c9a 100644 --- a/content/docs/ui/print-pages.mdx +++ b/content/docs/ui/print-pages.mdx @@ -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 diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index 68b8c8a5c38..a6590768063 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -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'; @@ -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 diff --git a/examples/app-showcase/package.json b/examples/app-showcase/package.json index e68be4c46f1..e18da2d81a2 100644 --- a/examples/app-showcase/package.json +++ b/examples/app-showcase/package.json @@ -33,6 +33,7 @@ "@objectstack/runtime": "workspace:*", "@objectstack/service-datasource": "workspace:*", "@objectstack/service-i18n": "workspace:*", + "@objectstack/service-print": "workspace:*", "@objectstack/spec": "workspace:*" }, "devDependencies": { diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index 073f938e486..f285c33d01a 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -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). diff --git a/examples/app-showcase/src/ui/actions/index.ts b/examples/app-showcase/src/ui/actions/index.ts index 3097b102c39..2615c8b3c4b 100644 --- a/examples/app-showcase/src/ui/actions/index.ts +++ b/examples/app-showcase/src/ui/actions/index.ts @@ -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 @@ -509,6 +553,7 @@ export const allActions = [ LogTimeAction, NewTaskAction, SubmitForSignoffAction, + GenerateInvoicePdfAction, ActionParamGalleryAction, ArchiveTaskAction, PortfolioSnapshotAction, diff --git a/examples/app-showcase/test/invoice-generate-pdf-action.test.ts b/examples/app-showcase/test/invoice-generate-pdf-action.test.ts new file mode 100644 index 00000000000..bcfdf1a1a40 --- /dev/null +++ b/examples/app-showcase/test/invoice-generate-pdf-action.test.ts @@ -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; + +const composed = ((stack.plugins ?? []) as Array<{ name?: unknown }>).filter((p) => p?.name === PRINT_PLUGIN); +const pages = (stack.pages ?? []) as Array>; + +describe('showcase — the invoice Generate PDF action and the print door', () => { + const saved: Record = {}; + const mounted = new Map(); + + beforeAll(async () => { + for (const k of PRINT_ENV) { + saved[k] = process.env[k]; + delete process.env[k]; + } + const services = new Map(); + const hooks: Array<() => Promise> = []; + 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) => { + 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; + 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); + }); +}); diff --git a/examples/app-showcase/vitest.config.ts b/examples/app-showcase/vitest.config.ts index a5b82a9761d..9762855094a 100644 --- a/examples/app-showcase/vitest.config.ts +++ b/examples/app-showcase/vitest.config.ts @@ -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: { diff --git a/packages/core/src/security/authz-store-unavailable.test.ts b/packages/core/src/security/authz-store-unavailable.test.ts index 53a9cf5651f..419ec8c881e 100644 --- a/packages/core/src/security/authz-store-unavailable.test.ts +++ b/packages/core/src/security/authz-store-unavailable.test.ts @@ -188,6 +188,9 @@ const TRANSPORT_LEDGER: Record = { '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. */ diff --git a/packages/qa/dogfood/test/print-door-impersonation.dogfood.test.ts b/packages/qa/dogfood/test/print-door-impersonation.dogfood.test.ts new file mode 100644 index 00000000000..e32c7c48a31 --- /dev/null +++ b/packages/qa/dogfood/test/print-door-impersonation.dogfood.test.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The print door's impersonation refusal, judged against the REAL auth +// service on a real showcase boot. +// +// `POST /api/v1/print/records` (`@objectstack/service-print`'s door, which the +// showcase composes) refuses a print asked from an impersonation session: +// a printed PDF is rendered and archived as the session's user, and in an +// impersonation session that user is not the one asking. The door recognises +// such a session by the mark the auth service writes on it — better-auth's +// admin plugin sets `impersonatedBy` on the session it mints, which +// `@objectstack/plugin-auth` stores as `sys_session.impersonated_by` — read +// off the very session read that admitted the caller. +// +// The door's unit suite (`print-routes.test.ts`) hands the door a session that +// carries the mark, so it cannot notice the mark going away. This file is the +// supplier side: the session comes from the auth service as composed, so a +// better-auth or plugin-auth change that drops or renames the field turns the +// first pin red here instead of turning the refusal off in silence. +// +// The two pins, on the same admin: +// +// - the admin's own session is NOT refused for impersonation: the print +// reaches the service, which refuses it `SERVICE_UNAVAILABLE` / `503` +// because this boot configures no print engine; +// - the impersonation session the admin then starts is refused at the door: +// `PERMISSION_DENIED` / `403`, saying the session is an impersonation. +// +// `/admin/` routes answer only with better-auth's admin plugin on, and +// `bootStack` exposes no auth-plugin override: `OS_SCIM_ENABLED` is the env +// knob that reaches it (SCIM forces the admin plugin on, ADR-0134), read when +// the auth manager is constructed, so it is set before the boot — the same +// switch `admin-platform-admin-standing.dogfood.test.ts` uses. +// +// Not eligible for the shared showcase project: it toggles `process.env`. + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; + +const SYS = { isSystem: true } as const; +const PRINT_ENV = ['OS_PRINT_CHROMIUM_PATH', 'OS_PRINT_BROWSER_ENDPOINT', 'OS_PRINT_CONSOLE_URL']; +const TARGET_EMAIL = 'print-impersonation.target@dogfood.test'; + +const rowsOf = (r: unknown): Array> => + (Array.isArray(r) ? r : ((r as { value?: unknown })?.value ?? [])) as Array>; + +interface DoorAnswer { + status: number; + code: unknown; + message: unknown; +} + +describe('the print door refuses an impersonation session, on the real auth service', () => { + let stack: VerifyStack; + let body: Record; + let targetUserId: string; + const saved: Record = {}; + + async function print(token: string): Promise { + const res = await stack.apiAs(token, 'POST', '/print/records', body); + const json = (await res.json().catch(() => ({}))) as { error?: { code?: unknown; message?: unknown } }; + return { status: res.status, code: json.error?.code, message: json.error?.message }; + } + + beforeAll(async () => { + saved.OS_SCIM_ENABLED = process.env.OS_SCIM_ENABLED; + process.env.OS_SCIM_ENABLED = 'true'; + for (const k of PRINT_ENV) { + saved[k] = process.env[k]; + delete process.env[k]; + } + stack = await bootShowcase(); + const ql = await (stack.kernel as unknown as { + getServiceAsync(n: string): Promise; + }).getServiceAsync<{ find(o: string, q: unknown): Promise }>('objectql'); + await stack.signUp(TARGET_EMAIL, 'Target-Pass-123'); + const [target] = rowsOf(await ql.find('sys_user', { where: { email: TARGET_EMAIL }, limit: 1, context: SYS })); + targetUserId = String(target?.id); + const [invoice] = rowsOf(await ql.find('showcase_invoice', { limit: 1, context: SYS })); + body = { + app: 'com.example.showcase', + object: 'showcase_invoice', + recordId: String(invoice?.id), + page: 'showcase_invoice_document', + }; + }, 300_000); + + afterAll(async () => { + await stack?.stop?.(); + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }); + + it('the admin\'s own session reaches the service, and an impersonation session the admin starts is refused at the door', async () => { + expect(targetUserId).not.toBe('undefined'); + const adminToken = await stack.signIn(); + + // The admin, as themselves: not an impersonation, so the door hands the + // print to the service, which has no engine on this boot. + const own = await print(adminToken); + expect([own.code, own.status], JSON.stringify(own)).toEqual(['SERVICE_UNAVAILABLE', 503]); + + const started = await stack.apiAs(adminToken, 'POST', '/auth/admin/impersonate-user', { userId: targetUserId }); + expect(started.status, 'the admin plugin did not start an impersonation — no reading below means anything').toBe(200); + const session = ((await started.json()) as { session?: { token?: unknown } }).session; + expect(typeof session?.token, 'the impersonation answer carried no session token').toBe('string'); + + // The impersonation session: refused at the door, and the refusal says why, + // so a 403 from anywhere else cannot stand in for this one. + const impersonated = await print(String(session!.token)); + expect([impersonated.code, impersonated.status], JSON.stringify(impersonated)).toEqual(['PERMISSION_DENIED', 403]); + expect(String(impersonated.message)).toMatch(/impersonation/); + }, 120_000); +}); diff --git a/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts b/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts index 9c08997031d..3c9489083dc 100644 --- a/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts +++ b/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts @@ -71,6 +71,7 @@ import { REST_ROUTE_LEDGER } from '../../../rest/src/rest-route-ledger.js'; import { STORAGE_ROUTE_LEDGER } from '../../../services/service-storage/src/storage-route-ledger.js'; import { I18N_ROUTE_LEDGER } from '../../../services/service-i18n/src/i18n-route-ledger.js'; import { SETTINGS_ROUTE_LEDGER } from '../../../services/service-settings/src/settings-route-ledger.js'; +import { PRINT_ROUTE_LEDGER } from '../../../services/service-print/src/print-route-ledger.js'; /** One ledger row, normalized to a wire pattern this gate can probe. */ interface LedgerRow { @@ -106,6 +107,7 @@ function collectLedgerRows(): LedgerRow[] { for (const r of STORAGE_ROUTE_LEDGER) add('service-storage/storage-route-ledger.ts', r.route, ''); for (const r of I18N_ROUTE_LEDGER) add('service-i18n/i18n-route-ledger.ts', r.route, ''); for (const r of SETTINGS_ROUTE_LEDGER) add('service-settings/settings-route-ledger.ts', r.route, ''); + for (const r of PRINT_ROUTE_LEDGER) add('service-print/print-route-ledger.ts', r.route, ''); return rows; } diff --git a/packages/services/service-print/README.md b/packages/services/service-print/README.md index c5aa7904e5a..8c391daa399 100644 --- a/packages/services/service-print/README.md +++ b/packages/services/service-print/README.md @@ -23,4 +23,8 @@ const print = kernel.getService('print'); await print.printRecordToFile({ app, object, recordId, page }, context); ``` +The plugin also mounts `POST /api/v1/print/records` on the kernel's HTTP server: +the same request as a JSON body, printed as the signed-in caller, answering +`201` with the stored file. A console `type: 'api'` action targets it. + Documentation: [Print Pages](https://objectstack.ai/docs/ui/print-pages). diff --git a/packages/services/service-print/package.json b/packages/services/service-print/package.json index 317e5b6116d..8a6ec8f1a2e 100644 --- a/packages/services/service-print/package.json +++ b/packages/services/service-print/package.json @@ -27,6 +27,7 @@ "@objectstack/core": "workspace:*", "@objectstack/service-storage": "workspace:*", "@objectstack/spec": "workspace:*", + "@objectstack/types": "workspace:*", "playwright-core": "1.63.0" }, "devDependencies": { diff --git a/packages/services/service-print/src/print-route-ledger.conformance.test.ts b/packages/services/service-print/src/print-route-ledger.conformance.test.ts new file mode 100644 index 00000000000..5977f361f02 --- /dev/null +++ b/packages/services/service-print/src/print-route-ledger.conformance.test.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Print route-ledger conformance — the package-local guard every + * `*-route-ledger.ts` pairs with. `registerPrintRoutes` is a standalone, + * synchronous function that calls `http.post` and touches neither argument + * before a request arrives, so a capturing mock `IHttpServer` enumerates its + * real surface (the seam the storage and settings ledgers use), and the two + * directions fail here by name on every `pnpm test` of this package. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { registerPrintRoutes } from './print-routes.js'; +import { PRINT_ROUTE_LEDGER } from './print-route-ledger.js'; + +function createMockServer() { + return { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + patch: vi.fn(), + use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +function enumeratePrintRoutes(): Set { + const server = createMockServer(); + registerPrintRoutes(server as any, {} as any); + const keys = new Set(); + for (const verb of ['get', 'post', 'put', 'patch', 'delete'] as const) { + for (const call of server[verb].mock.calls) keys.add(`${verb.toUpperCase()} ${call[0]}`); + } + return keys; +} + +const ledgerKeys = (): Set => new Set(PRINT_ROUTE_LEDGER.map((e) => e.route)); + +describe('print route ledger ↔ registerPrintRoutes enumeration', () => { + it('every mounted print route has a ledger entry', () => { + const ledger = ledgerKeys(); + const missing = [...enumeratePrintRoutes()].filter((k) => !ledger.has(k)); + expect(missing, `print routes with no print-route-ledger entry: ${missing.join(', ')}`).toEqual([]); + }); + + it('every ledger entry is really mounted by the registrar', () => { + const live = enumeratePrintRoutes(); + const stale = [...ledgerKeys()].filter((k) => !live.has(k)); + expect(stale, `print-route-ledger entries the registrar no longer mounts: ${stale.join(', ')}`).toEqual([]); + }); + + it('the ledger is compared against a real enumeration, not an empty one', () => { + const live = enumeratePrintRoutes(); + expect(live.size).toBeGreaterThan(0); + expect(live.size).toBe(ledgerKeys().size); + expect(PRINT_ROUTE_LEDGER.every((e) => e.disposition === 'sdk' || (e.note ?? '').length > 0)).toBe(true); + }); +}); diff --git a/packages/services/service-print/src/print-route-ledger.ts b/packages/services/service-print/src/print-route-ledger.ts new file mode 100644 index 00000000000..ab6af38abc9 --- /dev/null +++ b/packages/services/service-print/src/print-route-ledger.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Print route ledger — the audited disposition of every HTTP route + * `registerPrintRoutes` (`print-routes.ts`) mounts. + * + * WHY THIS EXISTS. `PrintServicePlugin` reaches for the `http-server` service + * and registers straight on `IHttpServer`, so neither the dispatcher ledger nor + * the REST ledger sees its route, the same shape as the storage and settings + * families. `print-route-ledger.conformance.test.ts` fails when the registrar + * mounts a route with no row here, and when a row names a route it no longer + * mounts; the dogfood live-mount parity gate reads this ledger beside the + * others on a real boot. + * + * NOT SDK SURFACE. `@objectstack/client` expresses no print method: the + * consumer is the console, through an app's declared `type: 'api'` action + * whose `target` is the route. That is a `server-only` disposition. + * + * This module is package-internal (not exported from the index): it is the + * guard's data, not public API. It stays import-free, so a guard in another + * package can read it as a relative SOURCE file. + */ + +/** Disposition of a single print route. Same vocabulary as the REST ledger. */ +export type PrintRouteDisposition = + /** Expressed by the SDK — `client` names the method (dotted path). */ + | 'sdk' + /** Should be in the SDK and is not — an open, acknowledged gap. */ + | 'gap' + /** Deliberately not SDK surface. */ + | 'server-only' + /** Public, unauthenticated browser-facing route. */ + | 'public' + /** Server and client disagree on the shape — needs reconciliation. */ + | 'mismatch'; + +export interface PrintRouteLedgerEntry { + /** `VERB /api/v1/print/...` — the full wire path. */ + route: string; + /** Registrar family, for grouping and diff messages. */ + family: string; + disposition: PrintRouteDisposition; + /** Dotted method path on `ObjectStackClient` — required when disposition is `sdk`. */ + client?: string; + /** One-line rationale. Required for every non-`sdk` disposition. */ + note?: string; +} + +export const PRINT_ROUTE_LEDGER: readonly PrintRouteLedgerEntry[] = [ + { route: 'POST /api/v1/print/records', family: 'print', disposition: 'server-only', + note: "print a record's print page to a PDF as the caller and archive it on the record; called by a declared type:'api' console action" }, +]; diff --git a/packages/services/service-print/src/print-routes.test.ts b/packages/services/service-print/src/print-routes.test.ts new file mode 100644 index 00000000000..7422e6b80ff --- /dev/null +++ b/packages/services/service-print/src/print-routes.test.ts @@ -0,0 +1,408 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it, vi } from 'vitest'; +import { ApiErrorSchema, envelopeViolations } from '@objectstack/spec/api'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import type { ChromiumDriver } from './chromium-driver.js'; +import type { PrintPlacementResolution } from './config.js'; +import { PRINT_RECORDS_ROUTE, registerPrintRoutes } from './print-routes.js'; +import { PrintService, type IPrintService } from './print-service.js'; + +/** + * The print door, `POST /api/v1/print/records`, driven through the handler it + * mounts: the real platform resolver turns the request's bearer into an + * execution context, and the real `PrintService` judges the print over + * recording doubles of the data engine, the archive, the mint and the browser. + * + * Each refusal is asserted by its envelope (`code` and `status`, ADR-0112), + * and each one by what it did NOT start: no session minted, no page read, no + * file stored. + */ + +const PDF = Buffer.from('%PDF-1.4 door'); +const PAGE = 'invoice_document'; +const INVOICE = { app: 'sales', object: 'invoice', page: PAGE } as const; +const CONFIGURED: PrintPlacementResolution = { + state: 'configured', + resolved: { placement: 'local', target: '/staged/chromium', consoleUrl: 'http://127.0.0.1:3000/_console' }, +}; + +/** The sessions the fake auth service knows, by bearer token. */ +interface FakeSession { + userId: string; + organizationId?: string; + impersonatedBy?: string | null; + authGate?: { code: string; message: string }; +} + +const SESSIONS: Record = { + alice: { userId: 'usr_alice', organizationId: 'org_1' }, + impersonated: { userId: 'usr_alice', organizationId: 'org_1', impersonatedBy: 'usr_admin' }, + notImpersonated: { userId: 'usr_alice', organizationId: 'org_1', impersonatedBy: null }, + held: { userId: 'usr_alice', authGate: { code: 'PASSWORD_EXPIRED', message: 'Your password has expired.' } }, +}; + +/** Invoices and their owners: a caller reads only their own (row-level security, as the caller). */ +const RECORDS: Record = { + inv_alice: { id: 'inv_alice', owner: 'usr_alice' }, + inv_bob: { id: 'inv_bob', owner: 'usr_bob' }, +}; + +interface DoorSetup { + placement?: PrintPlacementResolution; + page?: Record; + /** Hand the door this service instead of a real `PrintService`. */ + service?: IPrintService; +} + +function door(setup: DoorSetup = {}) { + const handlers = new Map Promise>(); + const http = { + get: vi.fn(), + post: vi.fn((path: string, handler: (req: unknown, res: unknown) => Promise) => handlers.set(`POST ${path}`, handler)), + put: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + use: vi.fn(), + }; + + const sessionReads: unknown[] = []; + const auth = { + api: { + getSession: vi.fn(async (input: { headers: Headers }) => { + sessionReads.push(input); + const bearer = input.headers.get('authorization')?.replace(/^Bearer /, ''); + const s = bearer ? SESSIONS[bearer] : undefined; + if (!s) return null; + return { + user: { id: s.userId, email: `${s.userId}@example.test`, ...(s.authGate ? { authGate: s.authGate } : {}) }, + session: { + id: `ses_${bearer}`, + userId: s.userId, + activeOrganizationId: s.organizationId, + ...(s.impersonatedBy !== undefined ? { impersonatedBy: s.impersonatedBy } : {}), + }, + }; + }), + }, + }; + + // The archive: an in-memory `sys_file` / `sys_attachment` store, written as the caller. + const rows = { sys_file: [] as Record[], sys_attachment: [] as Record[] }; + const attachFileAs = vi.fn( + async (input: { parentObject: string; parentId: string; filename: string; data: Buffer }, context: ExecutionContext) => { + const fileId = `file_${rows.sys_file.length + 1}`; + const attachmentId = `att_${rows.sys_attachment.length + 1}`; + rows.sys_file.push({ id: fileId, owner_id: context.userId, name: input.filename, size: input.data.byteLength }); + rows.sys_attachment.push({ + id: attachmentId, + file_id: fileId, + parent_object: input.parentObject, + parent_id: input.parentId, + uploaded_by: context.userId, + }); + return { fileId, attachmentId, size: input.data.byteLength }; + }, + ); + const systemAttachFile = vi.fn(); + const findOne = vi.fn(async (_object: string, query: { where: { id: string } }, opts: { context: ExecutionContext }) => { + const row = RECORDS[query.where.id]; + return row && row.owner === opts.context.userId ? row : null; + }); + const page = setup.page ?? { name: PAGE, type: 'record', object: 'invoice', isDefault: true, print: { paperSize: 'A4' } }; + const revoke = vi.fn(async () => undefined); + const mint = vi.fn(async (_context: ExecutionContext) => ({ + token: 'minted-token', + cookie: { name: 'os.session_token', value: 'minted-cookie', path: '/', httpOnly: true, secure: false }, + revoke, + })); + const renderPdf = vi.fn(async () => PDF); + const real = new PrintService({ + placement: setup.placement ?? CONFIGURED, + driver: + (setup.placement ?? CONFIGURED).state === 'configured' + ? ({ placement: 'local', probe: async () => ({ version: 'stand-in' }), renderPdf } as unknown as ChromiumDriver) + : null, + getEngine: () => + ({ + findOne, + registry: { + getItem: (_type: string, name: string) => (name === PAGE ? page : undefined), + getObject: () => ({ name: 'invoice', enable: { files: true } }), + }, + }) as never, + getAttachments: () => ({ attachFile: systemAttachFile, attachFileAs, assertMayAttach: async () => undefined }) as never, + getMinter: () => ({ mint }), + }); + const service = setup.service ?? real; + const printRecordToFile = vi.spyOn(service, 'printRecordToFile'); + + registerPrintRoutes(http as never, { + print: service, + // The resolver's grant reads: this caller holds no position or permission set. + getEngine: () => ({ find: async () => [] }), + getAuth: () => auth as never, + resolveTenancyPosture: async () => undefined, + }); + + async function call(opts: { bearer?: string; body?: unknown; query?: Record } = {}) { + const handler = handlers.get(`POST ${PRINT_RECORDS_ROUTE}`); + if (!handler) throw new Error('the door mounted no POST handler at its route'); + const answer: { status: number; body: any } = { status: 0, body: undefined }; + const res = { + status: (code: number) => { + answer.status = code; + return res; + }, + json: (body: unknown) => { + answer.body = body; + }, + header: () => res, + send: () => undefined, + }; + await handler( + { + params: {}, + query: opts.query ?? {}, + body: opts.body, + headers: opts.bearer ? { authorization: `Bearer ${opts.bearer}` } : {}, + method: 'POST', + path: PRINT_RECORDS_ROUTE, + }, + res, + ); + expect(envelopeViolations(answer.body), JSON.stringify(answer.body)).toEqual([]); + return answer; + } + + return { call, real, ready: () => real.probe(), rows, mint, revoke, renderPdf, findOne, attachFileAs, systemAttachFile, printRecordToFile, auth, sessionReads }; +} + +type Door = ReturnType; + +/** The refusal envelope's `code` and `status`, after checking the error parses as the declared `ApiError`. */ +function envelope(answer: { status: number; body: any }): [unknown, number] { + expect(answer.body?.success).toBe(false); + expect(ApiErrorSchema.safeParse(answer.body.error).success, JSON.stringify(answer.body.error)).toBe(true); + return [answer.body.error.code, answer.status]; +} + +/** Capture the server fault log (`console.error` / `console.warn`) for one call, and read what it was told. */ +function quietFaultLog() { + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + // Read before the restore: restoring a spy clears what it recorded. + let errors = ''; + return { + errors: () => errors, + restore: () => { + errors = JSON.stringify(error.mock.calls); + error.mockRestore(); + warn.mockRestore(); + }, + }; +} + +/** Nothing minted, rendered or stored, and the record never read. */ +function expectNothingStarted(d: Door): void { + expect(d.mint).not.toHaveBeenCalled(); + expect(d.renderPdf).not.toHaveBeenCalled(); + expect(d.findOne).not.toHaveBeenCalled(); + expect(d.rows.sys_attachment).toHaveLength(0); + expect(d.systemAttachFile).not.toHaveBeenCalled(); +} + +describe('the print door — mounted', () => { + it('registers exactly one route: POST /api/v1/print/records', () => { + const http = { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn(), use: vi.fn() }; + registerPrintRoutes(http as never, {} as never); + expect(http.post.mock.calls.map((c) => c[0])).toEqual(['/api/v1/print/records']); + for (const verb of ['get', 'put', 'patch', 'delete', 'use'] as const) expect(http[verb]).not.toHaveBeenCalled(); + }); +}); + +describe('the print door — a print', () => { + it('prints as the caller and answers 201 with the file the service stored: exactly one attachment, never a second', async () => { + const d = door(); + await d.ready(); + const answer = await d.call({ bearer: 'alice', body: { ...INVOICE, recordId: 'inv_alice' } }); + expect(answer.status).toBe(201); + expect(answer.body).toEqual({ + success: true, + data: { fileId: 'file_1', attachmentId: 'att_1', size: PDF.byteLength, filename: `${PAGE}-inv_alice.pdf` }, + }); + const forTheInvoice = d.rows.sys_attachment.filter((r) => r.parent_object === 'invoice' && r.parent_id === 'inv_alice'); + expect(forTheInvoice).toHaveLength(1); + expect(d.rows.sys_attachment).toHaveLength(1); + expect(forTheInvoice[0]).toMatchObject({ id: 'att_1', file_id: 'file_1', uploaded_by: 'usr_alice' }); + expect(d.rows.sys_file).toEqual([expect.objectContaining({ id: 'file_1', owner_id: 'usr_alice' })]); + expect(d.attachFileAs).toHaveBeenCalledTimes(1); + expect(d.systemAttachFile).not.toHaveBeenCalled(); + expect(d.revoke).toHaveBeenCalledTimes(1); + }); + + it('hands the service the request and the caller\'s own context, which reaches the mint unchanged', async () => { + const d = door(); + await d.ready(); + await d.call({ bearer: 'alice', body: { ...INVOICE, recordId: 'inv_alice', filename: 'INV-1.pdf' } }); + expect(d.printRecordToFile).toHaveBeenCalledTimes(1); + const [request, context] = d.printRecordToFile.mock.calls[0]!; + expect(request).toEqual({ ...INVOICE, recordId: 'inv_alice', filename: 'INV-1.pdf' }); + expect(context).toMatchObject({ userId: 'usr_alice', tenantId: 'org_1', principalKind: 'human', isSystem: false }); + expect(d.mint.mock.calls[0]![0]).toBe(context); + }); + + it('reads the session once, through the resolver, and a session with no impersonation mark prints', async () => { + const d = door(); + await d.ready(); + const answer = await d.call({ bearer: 'notImpersonated', body: { ...INVOICE, recordId: 'inv_alice' } }); + expect(answer.status).toBe(201); + expect(d.auth.api.getSession).toHaveBeenCalledTimes(1); + }); +}); + +describe('the print door — the refusals card ③ pins, relayed through the door', () => { + it('no render driver configured: SERVICE_UNAVAILABLE / 503, reported, before anything is minted or read', async () => { + const d = door({ placement: { state: 'unconfigured', reason: 'No print engine is configured. Set OS_PRINT_CHROMIUM_PATH.' } }); + const logged = quietFaultLog(); + const answer = await d.call({ bearer: 'alice', body: { ...INVOICE, recordId: 'inv_alice' } }); + logged.restore(); + expect(envelope(answer)).toEqual(['SERVICE_UNAVAILABLE', 503]); + expect(answer.body.error.message.length).toBeGreaterThan(0); + expectNothingStarted(d); + }); + + it('a page that declares no `print`: VALIDATION_ERROR / 400', async () => { + const d = door({ page: { name: PAGE, type: 'record', object: 'invoice', isDefault: true } }); + await d.ready(); + const answer = await d.call({ bearer: 'alice', body: { ...INVOICE, recordId: 'inv_alice' } }); + expect(envelope(answer)).toEqual(['VALIDATION_ERROR', 400]); + expectNothingStarted(d); + }); + + it('a caller who cannot read the record: RECORD_NOT_FOUND / 404, judged as the caller', async () => { + const d = door(); + await d.ready(); + const answer = await d.call({ bearer: 'alice', body: { ...INVOICE, recordId: 'inv_bob' } }); + expect(envelope(answer)).toEqual(['RECORD_NOT_FOUND', 404]); + expect(d.findOne).toHaveBeenCalledTimes(1); + expect(d.mint).not.toHaveBeenCalled(); + expect(d.rows.sys_attachment).toHaveLength(0); + }); + + const declared: Array<[string, number]> = [ + ['PERMISSION_DENIED', 403], + ['SERVICE_UNAVAILABLE', 503], + ['RESOURCE_NOT_FOUND', 404], + ['VALIDATION_ERROR', 400], + ['RECORD_NOT_FOUND', 404], + ['FILES_DISABLED', 403], + ['ATTACHMENT_PARENT_ACCESS', 403], + ['TIMEOUT', 504], + ['INTERNAL_ERROR', 500], + ['PAYLOAD_TOO_LARGE', 413], + ]; + for (const [code, status] of declared) { + it(`relays ${code} / ${status} with its code and status unchanged`, async () => { + const text = `the service's own text for ${code}, naming /srv/secret/host-path`; + const stub: IPrintService = { + status: () => ({ state: 'ready', placement: 'local', version: 'stand-in' }), + printRecordToFile: async () => { + throw Object.assign(new Error(text), { code, status }); + }, + }; + const d = door({ service: stub }); + const logged = quietFaultLog(); + const answer = await d.call({ bearer: 'alice', body: { ...INVOICE, recordId: 'inv_alice' } }); + logged.restore(); + expect(envelope(answer)).toEqual([code, status]); + if (status < 500) { + expect(answer.body.error.message).toBe(text); + } else { + expect(answer.body.error.message).not.toContain('/srv/secret'); + // The operator keeps the text: it is in the server fault log. A 503 is a + // declared capability absence, reported once per route per process, so + // only the other 5xx are asserted here. + if (status !== 503) expect(logged.errors()).toContain('/srv/secret'); + } + }); + } + + it('an undeclared throw is a fault: INTERNAL_ERROR / 500, its text withheld', async () => { + const stub: IPrintService = { + status: () => ({ state: 'ready', placement: 'local', version: 'stand-in' }), + printRecordToFile: async () => { + throw new Error('ECONNRESET at /srv/secret/host-path'); + }, + }; + const d = door({ service: stub }); + const logged = quietFaultLog(); + const answer = await d.call({ bearer: 'alice', body: { ...INVOICE, recordId: 'inv_alice' } }); + logged.restore(); + expect(envelope(answer)).toEqual(['INTERNAL_ERROR', 500]); + expect(answer.body.error.message).not.toContain('/srv/secret'); + expect(logged.errors()).toContain('/srv/secret'); + }); +}); + +describe('the print door — its own refusals, before the service is asked', () => { + it('an impersonation session: PERMISSION_DENIED / 403, saying so, before anything is minted or read', async () => { + const d = door(); + await d.ready(); + const answer = await d.call({ bearer: 'impersonated', body: { ...INVOICE, recordId: 'inv_alice' } }); + expect(envelope(answer)).toEqual(['PERMISSION_DENIED', 403]); + expect(answer.body.error.message).toMatch(/impersonation/); + expect(d.printRecordToFile).not.toHaveBeenCalled(); + expectNothingStarted(d); + }); + + it('an anonymous request: UNAUTHENTICATED / 401, and the service is never asked', async () => { + const d = door(); + await d.ready(); + const answer = await d.call({ body: { ...INVOICE, recordId: 'inv_alice' } }); + expect(envelope(answer)).toEqual(['UNAUTHENTICATED', 401]); + expect(d.printRecordToFile).not.toHaveBeenCalled(); + expectNothingStarted(d); + }); + + it('a sign-in held by an authentication policy: 403 with the policy\'s own code', async () => { + const d = door(); + await d.ready(); + const answer = await d.call({ bearer: 'held', body: { ...INVOICE, recordId: 'inv_alice' } }); + expect(envelope(answer)).toEqual(['PASSWORD_EXPIRED', 403]); + expect(d.printRecordToFile).not.toHaveBeenCalled(); + }); + + it('a body field outside the request\'s closed set: VALIDATION_ERROR / 400, never ignored', async () => { + const d = door(); + await d.ready(); + const answer = await d.call({ bearer: 'alice', body: { ...INVOICE, recordId: 'inv_alice', fileName: 'INV-1.pdf' } }); + expect(envelope(answer)).toEqual(['VALIDATION_ERROR', 400]); + expect(answer.body.error.message).toContain('fileName'); + expect(d.printRecordToFile).not.toHaveBeenCalled(); + }); + + it('a body that is not an object: VALIDATION_ERROR / 400', async () => { + const d = door(); + await d.ready(); + const answer = await d.call({ bearer: 'alice', body: ['inv_alice'] }); + expect(envelope(answer)).toEqual(['VALIDATION_ERROR', 400]); + expect(d.printRecordToFile).not.toHaveBeenCalled(); + }); + + it('a query parameter: VALIDATION_ERROR / 400 — the door reads none', async () => { + const d = door(); + await d.ready(); + const answer = await d.call({ bearer: 'alice', query: { recordId: 'inv_alice' }, body: { ...INVOICE, recordId: 'inv_alice' } }); + expect(envelope(answer)).toEqual(['VALIDATION_ERROR', 400]); + expect(d.printRecordToFile).not.toHaveBeenCalled(); + }); + + it('a missing field reaches the service, which refuses it: VALIDATION_ERROR / 400', async () => { + const d = door(); + await d.ready(); + const answer = await d.call({ bearer: 'alice', body: { app: 'sales', object: 'invoice', page: PAGE } }); + expect(envelope(answer)).toEqual(['VALIDATION_ERROR', 400]); + expect(d.mint).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/services/service-print/src/print-routes.ts b/packages/services/service-print/src/print-routes.ts new file mode 100644 index 00000000000..74c8a200132 --- /dev/null +++ b/packages/services/service-print/src/print-routes.ts @@ -0,0 +1,327 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { + ANONYMOUS_DENY_CODE, + ANONYMOUS_DENY_MESSAGE, + ANONYMOUS_DENY_STATUS, + apiKeyLifecyclePolicySourceOf, + assembleExecutionContext, + evaluateAuthGate, + isApiKeyLifecycleRefusalError, + isAuthzStoreUnavailableError, + resolveAuthzContext, + shouldDenyAnonymous, + userAuthGateSourceOf, +} from '@objectstack/core'; +import type { ErrorCode } from '@objectstack/spec/api'; +import type { IAuthService, IHttpRequest, IHttpResponse, IHttpServer } from '@objectstack/spec/contracts'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import type { TenancyPosture } from '@objectstack/spec/security'; +import { + declaredRefusalMessage, + demotedDeclaredCode, + inProcessSessionReadInput, + resolveThrownHttpError, + sendError, + sendOk, + sendServerFault, +} from '@objectstack/types'; +import type { IPrintService, PrintedRecordFile, PrintRecordRequest } from './print-service.js'; + +/** + * The print door: `POST /api/v1/print/records`, the one HTTP route of + * `@objectstack/service-print`. A user prints a record from the console (a + * declared `type: 'api'` action whose `target` is this path) and the record's + * print page is rendered and archived on the record, as that user, by the + * in-process `print` service (`IPrintService.printRecordToFile`). + * + * The door adds no print rule of its own beyond who may ask. It resolves the + * caller, refuses the callers it must, and hands the service the request and + * the caller's execution context. The service decides everything about the + * page, the record and the file, and it is the one writer of the attachment: + * the door never attaches anything, so a print stores exactly one file. + * + * ## The request + * + * A JSON body with exactly the fields of {@link PrintRecordRequest}: `app`, + * `object`, `recordId`, `page`, and an optional `filename`. A body field + * outside that set, or any query parameter, is refused (`VALIDATION_ERROR` / + * `400`) and never ignored: a misspelled `fileName` would otherwise print under + * the default name and answer success. The fields' values are judged by the + * service, which refuses a malformed one the same way. + * + * ## The order — every door refusal before the service is asked + * + * 1. **The caller**, resolved by the platform's one authorization resolver + * (`resolveAuthzContext`) from the request's session, bearer or API key, + * and assembled into an `ExecutionContext` the way the REST face assembles + * one (`assembleExecutionContext`). A permission store that cannot be read + * answers its own `SERVICE_UNAVAILABLE` / `503`, and an API key its + * lifecycle refuses answers its own `403` and code: both are relayed as + * they are declared, never folded into a denial. + * 2. **Anonymous** — the platform's one anonymous floor (ADR-0056 D2): + * `UNAUTHENTICATED` / `401`. + * 3. **A held sign-in** (ADR-0069: an expired password, enforced MFA not yet + * enrolled): `403` with the authentication policy's own code, the answer + * every other door gives it. + * 4. **An impersonation session** — an administrator signed in as the user + * through the auth service's impersonation: `PERMISSION_DENIED` / `403`, + * saying so. A print is rendered and archived as the session's user, so + * printing under an impersonation would put a file on the record in that + * user's name for something they never asked for. The session the request + * resolved through is the one read, and the mark is the one the auth + * service writes on it (`session.impersonatedBy`, stored as + * `sys_session.impersonated_by`). Only a session credential can carry it: + * a request admitted by an API key is judged by the key alone. + * 5. **The request**: no query parameter, and a body with the closed field + * set above. + * + * Nothing is minted and no browser is started before all five have passed, and + * the service then applies its own refusals, in its own order, before it mints + * anything either (`print-service.ts`). + * + * ## What the door answers + * + * - **Success:** `201` with `{ fileId, attachmentId, size, filename }`, the + * file the service stored. The attachment is the service's, written as the + * caller, so `sys_file.owner_id` and `sys_attachment.uploaded_by` name them. + * - **A refusal the service declares** (`code` and `status` on the thrown + * error, ADR-0112) is relayed with that `code` and `status` unchanged. A + * 4xx carries the service's own message. A 5xx carries this door's own + * sentence for its status instead, unless the producer declared the text + * for its caller (`refusal: true`, `declaredRefusalMessage`), and the + * service's text goes to the server fault log with the error: the same rule + * every door applies to a declared 5xx, since a 5xx text can carry what + * the engine said about the host. + * - **Anything else** is a fault: `500` / `INTERNAL_ERROR`, the door's generic + * sentence, and the error in the fault log. + * + * ## What is not recorded + * + * The door writes no audit row of its own. A print signs a session in and out + * for the caller, which the auth service records as it records any sign-in, + * and the archive names the caller on both rows it writes. That is the whole + * attribution a print gets. + */ + +/** The route the door is mounted at. Not configurable: the console's declared actions name it. */ +export const PRINT_RECORDS_ROUTE = '/api/v1/print/records'; + +/** The body fields the door reads: exactly {@link PrintRecordRequest}'s. */ +const REQUEST_FIELDS = ['app', 'object', 'recordId', 'page', 'filename'] as const satisfies readonly (keyof PrintRecordRequest)[]; + +/** What the door is composed over — every service read when a request arrives. */ +export interface PrintRoutesDeps { + /** The print service the door relays to. */ + print: IPrintService; + /** The data engine the caller's grants are read through; `undefined` when none is registered. */ + getEngine(): unknown; + /** The auth service the caller's session is read through; `undefined` when none is registered. */ + getAuth(): IAuthService | undefined; + /** + * The tenancy posture in force, for the resolver's posture-conditional API-key + * refusals; `undefined` on a kernel that enforces no organization wall. A + * posture that cannot be read rejects, and the door answers that outage. + */ + resolveTenancyPosture(): Promise; +} + +/** Mount the print door on `http`. Nothing on `deps` is called until a request arrives. */ +export function registerPrintRoutes(http: IHttpServer, deps: PrintRoutesDeps): void { + http.post(PRINT_RECORDS_ROUTE, (req, res) => handlePrintRecord(req, res, deps)); +} + +/** The caller, as the door resolved it: the context, and the session read that admitted it. */ +interface ResolvedCaller { + context: ExecutionContext | undefined; + /** The session payload the auth service returned, when a session (not an API key) admitted the caller. */ + session: unknown; + authGate: ReturnType; +} + +async function handlePrintRecord(req: IHttpRequest, res: IHttpResponse, deps: PrintRoutesDeps): Promise { + // ① The caller. + let caller: ResolvedCaller; + try { + caller = await resolveCaller(req, deps); + } catch (err) { + if (isAuthzStoreUnavailableError(err) || isApiKeyLifecycleRefusalError(err)) { + sendError(res, err.status, err.code, err.message); + return; + } + sendServerFault(res, 500, 'INTERNAL_ERROR', 'The caller of this print could not be resolved.', err, { + method: 'POST', + path: PRINT_RECORDS_ROUTE, + }); + return; + } + const { context } = caller; + + // ② Anonymous. + if (!context || shouldDenyAnonymous({ userId: context.userId, method: req.method })) { + sendError(res, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE); + return; + } + + // ③ A held sign-in. + if (caller.authGate) { + sendError(res, 403, caller.authGate.code as ErrorCode, caller.authGate.message); + return; + } + + // ④ An impersonation session. + if (impersonatorOf(caller.session) !== undefined) { + sendError( + res, + 403, + 'PERMISSION_DENIED', + 'This print was refused because the session is an impersonation: a printed PDF is rendered and archived as ' + + 'the signed-in user, and in an impersonation session that user is not the one asking. Stop impersonating, ' + + 'or ask the user to print the record themselves.', + ); + return; + } + + // ⑤ The request. The body is read here, never guarded: a body the transport + // cannot parse is refused by the transport itself, and the door must not + // carry on after it. + const query = Object.keys(req.query ?? {}); + if (query.length > 0) { + sendError( + res, + 400, + 'VALIDATION_ERROR', + `The print door takes no query parameter, and this request carries ${query.map((q) => `'${q}'`).join(', ')}. ` + + `Send the print request as the JSON body: ${REQUEST_FIELDS.join(', ')}.`, + ); + return; + } + const request = printRequestOf(req.body); + if (typeof request === 'string') { + sendError(res, 400, 'VALIDATION_ERROR', request); + return; + } + + let printed: PrintedRecordFile; + try { + printed = await deps.print.printRecordToFile(request, context); + } catch (err) { + relayPrintFailure(res, err); + return; + } + sendOk(res, printed, 201); +} + +/** + * Resolve the caller through the platform's one resolver, keeping the session + * read that admitted them. The read is the auth service's own in-process + * session read (`inProcessSessionReadInput`, so a cookie-carrying request is + * read without renewing the session behind a cookie nobody re-issues), made + * once, by the resolver. + */ +async function resolveCaller(req: IHttpRequest, deps: PrintRoutesDeps): Promise { + const headers = new Headers(); + for (const [name, value] of Object.entries(req.headers ?? {})) { + if (value == null) continue; + headers.set(name, Array.isArray(value) ? value.join(',') : String(value)); + } + const auth = deps.getAuth(); + let sessionRead: unknown; + const getSession = auth + ? async (h: Headers): Promise => { + const api = auth.api ?? (await auth.getApi?.()); + sessionRead = await api?.getSession?.(inProcessSessionReadInput(h)); + return sessionRead; + } + : undefined; + const authz = await resolveAuthzContext({ + ql: deps.getEngine(), + headers, + getSession, + tenancyPosture: await deps.resolveTenancyPosture(), + userAuthGates: userAuthGateSourceOf(auth), + apiKeyLifecycle: apiKeyLifecyclePolicySourceOf(auth), + }); + const context = assembleExecutionContext({ + authz, + // No OAuth access token is accepted here: those are honoured at the MCP door alone. + oauth: undefined, + // The print renders in the console under the user's own minted session, + // which resolves the user's language there; the door's context carries none. + localization: undefined, + requestLocale: undefined, + // The REST face's choice: the session bearer is not carried onto the envelope. + accessToken: undefined, + authGate: authz.authGate, + }); + return { + context, + session: authz.credential === 'session' ? sessionRead : undefined, + authGate: evaluateAuthGate(context, ''), + }; +} + +/** + * The administrator an impersonation session was started by, or `undefined` + * for any other session. The mark is the auth service's: better-auth's admin + * plugin writes `impersonatedBy` on the session it mints for an impersonation, + * and `@objectstack/plugin-auth` stores it as `sys_session.impersonated_by`. + */ +function impersonatorOf(sessionRead: unknown): string | undefined { + const session = (sessionRead as { session?: { impersonatedBy?: unknown } } | null | undefined)?.session; + const by = session?.impersonatedBy; + return typeof by === 'string' && by.trim() !== '' ? by : undefined; +} + +/** The body as a print request, or the reason it is not one. */ +function printRequestOf(body: unknown): PrintRecordRequest | string { + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return `A print request is a JSON object with the fields ${REQUEST_FIELDS.join(', ')}.`; + } + const known = new Set(REQUEST_FIELDS); + const unknown = Object.keys(body).filter((key) => !known.has(key)); + if (unknown.length > 0) { + return ( + `${unknown.map((k) => `'${k}'`).join(', ')} ${unknown.length === 1 ? 'is not a field' : 'are not fields'} of a ` + + `print request. Its fields are ${REQUEST_FIELDS.join(', ')}.` + ); + } + const fields = body as Record; + const request: Record = {}; + for (const key of REQUEST_FIELDS) { + if (key in fields) request[key] = fields[key]; + } + // The values are judged by the service (`VALIDATION_ERROR` / `400` for a + // missing or malformed one), never coerced here. + return request as unknown as PrintRecordRequest; +} + +/** This door's own sentence for a 5xx whose producer declared no text for its caller. */ +function serverAnswerFor(status: number): string { + if (status === 503) return 'Records cannot be printed to a file right now: the print engine is not ready. The server log records why.'; + if (status === 504) return 'The record could not be printed to a file in the time a print is allowed.'; + return 'The record could not be printed to a file because of an error on the server.'; +} + +/** Relay a failed print: a declared refusal with its own `code` and `status`, anything else as a fault. */ +function relayPrintFailure(res: IHttpResponse, err: unknown): void { + const thrown = resolveThrownHttpError(err, 500); + if (thrown.status < 500) { + const declaredCode = demotedDeclaredCode(thrown); + sendError(res, thrown.status, thrown.code, thrown.message, { + ...(thrown.details ? { details: thrown.details } : {}), + ...(declaredCode !== undefined ? { declaredCode } : {}), + ...(thrown.userMessage !== undefined ? { userMessage: thrown.userMessage } : {}), + }); + return; + } + const refusal = declaredRefusalMessage(err); + if (refusal !== undefined) { + sendError(res, thrown.status, thrown.code, refusal); + return; + } + sendServerFault(res, thrown.status, thrown.code, serverAnswerFor(thrown.status), err, { + method: 'POST', + path: PRINT_RECORDS_ROUTE, + }); +} diff --git a/packages/services/service-print/src/print-service-plugin.ts b/packages/services/service-print/src/print-service-plugin.ts index 628070c93c9..127bc172db9 100644 --- a/packages/services/service-print/src/print-service-plugin.ts +++ b/packages/services/service-print/src/print-service-plugin.ts @@ -1,7 +1,14 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -import type { Plugin, PluginContext } from '@objectstack/core'; -import { FILE_ATTACHMENT_SERVICE, type IAuthService, type IFileAttachmentService } from '@objectstack/spec/contracts'; +import type { Plugin, PluginContext, TenancyPostureSource } from '@objectstack/core'; +import { classifyAdmissionTenancyPosture } from '@objectstack/core'; +import { + FILE_ATTACHMENT_SERVICE, + type IAuthService, + type IFileAttachmentService, + type IHttpServer, +} from '@objectstack/spec/contracts'; +import type { TenancyPosture } from '@objectstack/spec/security'; import { createChromiumDriver } from './chromium-driver.js'; import { describeConsoleUrl, @@ -11,6 +18,7 @@ import { type PrintPlacementResolution, } from './config.js'; import { principalSessionMinterOf } from './principal-session.js'; +import { PRINT_RECORDS_ROUTE, registerPrintRoutes } from './print-routes.js'; import { PrintService, type PrintEngine } from './print-service.js'; /** The service-registry slot the plugin registers {@link PrintService} under. */ @@ -20,11 +28,14 @@ export const PRINT_SERVICE = 'print' as const; export type PrintServicePluginOptions = PrintPlacementOptions; /** - * PrintServicePlugin — registers the `print` service. + * PrintServicePlugin — registers the `print` service and its one HTTP door. * * Composed explicitly: a deployment that prints lists this plugin, and one that - * does not never loads a browser driver. It registers no route and no action; - * the in-process service is its only door. + * does not never loads a browser driver. Two doors reach the service: the + * in-process `print` service, and `POST /api/v1/print/records` on the + * kernel's HTTP server (`print-routes.ts`), which a console action calls to + * print the record it is on. The plugin registers no action: an app declares + * one (a `type: 'api'` action whose `target` is that path). * * Lifecycle: * - `init`: read the placement (options, then `OS_PRINT_*`), build the @@ -41,6 +52,10 @@ export type PrintServicePluginOptions = PrintPlacementOptions; * principal-session mint, `file-attachments`, the data engine) * are registered by other plugins, so their absence is reported only once * every plugin has started. `status()` reads them at call time either way. + * The door is mounted then too, on every path — an engine that is not + * configured is answered by the door as the service refuses it + * (`SERVICE_UNAVAILABLE` / `503`), never by an unmounted route. A kernel + * with no HTTP server mounts no door, and says so once. */ export class PrintServicePlugin implements Plugin { name = 'com.objectstack.service.print'; @@ -78,6 +93,7 @@ export class PrintServicePlugin implements Plugin { async start(ctx: PluginContext): Promise { const { service, placement } = this; if (!service || !placement) return; + ctx.hook('kernel:ready', async () => this.mountDoor(ctx, service)); if (placement.state === 'unconfigured') { ctx.logger.warn(`PrintServicePlugin: ${placement.reason}`); return; @@ -106,8 +122,62 @@ export class PrintServicePlugin implements Plugin { }); } + /** + * Mount the print door on the kernel's HTTP server. Every service the door + * reads is read per request, so a provider that registers after this hook is + * still seen. + */ + private mountDoor(ctx: PluginContext, service: PrintService): void { + let http: IHttpServer | undefined; + try { + http = ctx.getService('http-server') ?? undefined; + } catch { + http = undefined; + } + if (!http) { + ctx.logger.warn( + `PrintServicePlugin: no HTTP server is registered, so the print door (POST ${PRINT_RECORDS_ROUTE}) is not ` + + 'mounted and a console action cannot print a record. The print service is still reachable in-process ' + + '(kernel.getService("print")).', + ); + return; + } + const optional = (name: string): T | undefined => { + try { + return ctx.getService(name) ?? undefined; + } catch { + return undefined; + } + }; + registerPrintRoutes(http, { + print: service, + getEngine: () => optional('objectql'), + getAuth: () => optional('auth'), + resolveTenancyPosture: () => resolveAdmissionTenancyPosture(ctx), + }); + ctx.logger.info(`PrintServicePlugin: print door mounted at POST ${PRINT_RECORDS_ROUTE}`); + } + async destroy(): Promise { this.service = undefined; this.placement = undefined; } } + +/** + * The tenancy posture in force, for the door's admission (the resolver's two + * posture-conditional API-key refusals). Read through the kernel's async + * accessor, per request, and classified by the platform's one rule + * (`classifyAdmissionTenancyPosture`): a `tenancy` service never registered is + * a kernel with no organization wall, answered `undefined`; one that is + * registered and cannot answer rejects, and the door answers that outage. A + * host with no async accessor (a `LiteKernel`) registers no `tenancy` service + * either, and is answered `undefined`. + */ +async function resolveAdmissionTenancyPosture(ctx: PluginContext): Promise { + const kernel = ctx.getKernel?.() as + | { getServiceAsync?: (name: string, scopeId?: string) => Promise } + | undefined; + if (!kernel || typeof kernel.getServiceAsync !== 'function') return undefined; + return classifyAdmissionTenancyPosture(() => kernel.getServiceAsync!('tenancy')); +} diff --git a/packages/services/service-print/vitest.config.ts b/packages/services/service-print/vitest.config.ts index 237a8c4ff06..308a69e6793 100644 --- a/packages/services/service-print/vitest.config.ts +++ b/packages/services/service-print/vitest.config.ts @@ -26,6 +26,7 @@ export default defineConfig({ { find: /^@objectstack\/spec\/contracts$/, replacement: path.resolve(__dirname, '../../spec/src/contracts/index.ts') }, { find: /^@objectstack\/spec\/kernel$/, replacement: path.resolve(__dirname, '../../spec/src/kernel/index.ts') }, { find: /^@objectstack\/spec\/ui$/, replacement: path.resolve(__dirname, '../../spec/src/ui/index.ts') }, + { find: /^@objectstack\/types$/, replacement: path.resolve(__dirname, '../../types/src/index.ts') }, ], }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc4da5ecf1d..401e5cea126 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -242,6 +242,9 @@ importers: '@objectstack/service-i18n': specifier: workspace:* version: link:../../packages/services/service-i18n + '@objectstack/service-print': + specifier: workspace:* + version: link:../../packages/services/service-print '@objectstack/spec': specifier: workspace:* version: link:../../packages/spec @@ -2854,6 +2857,9 @@ importers: '@objectstack/spec': specifier: workspace:* version: link:../../spec + '@objectstack/types': + specifier: workspace:* + version: link:../../types playwright-core: specifier: 1.63.0 version: 1.63.0 diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index 47685a11e7f..6e1bd975cbc 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -268,6 +268,12 @@ const MODULES = { // created the module so the gap never exists. 'packages/services/service-storage/src/mount-storage-routes.ts': { responses: 0, ok: 0, err: 0 }, + // The print door (`POST /api/v1/print/records`), mounted by `PrintServicePlugin` + // straight on `IHttpServer`. Every body it answers is written by the shared pair + // (`sendOk` / `sendError` / `sendServerFault`), so it builds none itself. + // Declared in the same PR that created the module. + 'packages/services/service-print/src/print-routes.ts': { responses: 0, ok: 0, err: 0 }, + // ── Exempt ────────────────────────────────────────────────────────────── // A dev-only SSE endpoint (`GET|POST /api/v1/dev/metadata-events`) that closes