From b08322092a51a66adc17c04a21541c1297668035 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 02:04:55 +0000 Subject: [PATCH 01/21] feat(service-print): scaffold the print service package (WIP) Claude-Session: https://claude.ai/code/session_01W2XFCTBfK4LabgqovmvSgK Co-authored-by: Claude --- packages/services/service-print/LICENSE | 202 +++++++++++ packages/services/service-print/package.json | 62 ++++ .../service-print/src/chromium-driver.ts | 230 ++++++++++++ packages/services/service-print/src/config.ts | 191 ++++++++++ packages/services/service-print/src/index.ts | 24 ++ .../service-print/src/principal-session.ts | 68 ++++ .../service-print/src/print-service-plugin.ts | 116 ++++++ .../service-print/src/print-service.ts | 341 ++++++++++++++++++ .../services/service-print/src/refusal.ts | 18 + packages/services/service-print/tsconfig.json | 17 + .../services/service-print/vitest.config.ts | 31 ++ pnpm-lock.yaml | 79 +++- 12 files changed, 1378 insertions(+), 1 deletion(-) create mode 100644 packages/services/service-print/LICENSE create mode 100644 packages/services/service-print/package.json create mode 100644 packages/services/service-print/src/chromium-driver.ts create mode 100644 packages/services/service-print/src/config.ts create mode 100644 packages/services/service-print/src/index.ts create mode 100644 packages/services/service-print/src/principal-session.ts create mode 100644 packages/services/service-print/src/print-service-plugin.ts create mode 100644 packages/services/service-print/src/print-service.ts create mode 100644 packages/services/service-print/src/refusal.ts create mode 100644 packages/services/service-print/tsconfig.json create mode 100644 packages/services/service-print/vitest.config.ts diff --git a/packages/services/service-print/LICENSE b/packages/services/service-print/LICENSE new file mode 100644 index 00000000000..16bc23f4043 --- /dev/null +++ b/packages/services/service-print/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute + must include a readable copy of the attribution notices + contained within such NOTICE file, excluding those notices + that do not pertain to any part of the Derivative Works, + in at least one of the following places: within a NOTICE + text file distributed as part of the Derivative Works; within + the Source form or documentation, if provided along with + the Derivative Works; or, within a display generated by the + Derivative Works, if and wherever such third-party notices + normally appear. The contents of the NOTICE file are for + informational purposes only and do not modify the License. + You may add Your own attribution notices within Derivative + Works that You distribute, alongside or as an addendum to + the NOTICE text from the Work, provided that such additional + attribution notices cannot be construed as modifying the + License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 ObjectStack + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/services/service-print/package.json b/packages/services/service-print/package.json new file mode 100644 index 00000000000..6afeb5043bc --- /dev/null +++ b/packages/services/service-print/package.json @@ -0,0 +1,62 @@ +{ + "name": "@objectstack/service-print", + "version": "18.0.0-next.1", + "license": "Apache-2.0", + "description": "Print Service for ObjectStack — renders a record's print page in headless Chromium under the requesting user's own principal and archives the PDF as a file attached to the record.", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "scripts": { + "build": "tsup --config ../../../tsup.config.ts && node ../../../scripts/check-dts-emitted.mjs", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@objectstack/core": "workspace:*", + "@objectstack/spec": "workspace:*", + "playwright-core": "1.63.0" + }, + "devDependencies": { + "@types/node": "^26.6.3", + "typescript": "^6.0.3", + "vitest": "^4.1.11" + }, + "keywords": [ + "objectstack", + "service", + "print", + "pdf", + "chromium" + ], + "author": "ObjectStack", + "repository": { + "type": "git", + "url": "https://github.com/objectstack-ai/objectstack.git", + "directory": "packages/services/service-print" + }, + "homepage": "https://objectstack.ai/docs", + "bugs": "https://github.com/objectstack-ai/objectstack/issues", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist", + "README.md", + "CHANGELOG.md" + ], + "engines": { + "node": ">=22.0.0" + } +} diff --git a/packages/services/service-print/src/chromium-driver.ts b/packages/services/service-print/src/chromium-driver.ts new file mode 100644 index 00000000000..c0bb6696bdd --- /dev/null +++ b/packages/services/service-print/src/chromium-driver.ts @@ -0,0 +1,230 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describeEndpoint, type PrintPlacement, type ResolvedPrintPlacement } from './config.js'; +import { printRefusal } from './refusal.js'; +import type { PrintSessionCookie } from './principal-session.js'; + +/** + * The print service's one engine: headless Chromium, driven through + * `playwright-core` (pinned exact on this package only), in either placement + * {@link ResolvedPrintPlacement} names. + * + * - **local** — `chromium.launch({ executablePath })`, one browser per render, + * closed in a `finally`. + * - **remote** — `chromium.connectOverCDP(endpoint)`, one connection per + * render. Closing a connected browser disconnects from it and leaves the + * remote browser running; the context this render opened is closed first, + * so its cookies leave the remote browser with it. + * + * Both placements run the SAME render code after the browser is open — the + * remote placement is not a second driver, only a second way to reach the one + * engine. + * + * ## What a render waits for + * + * The console renders a record on the page bound to it. A page that declares + * `print` stamps `data-print-page` on its root, and its stylesheet puts only + * that page on paper (every rule sits inside `@media print`, which `page.pdf` + * renders under). The console also mirrors its in-flight request count onto + * `window.__objectui.idle` (ADR-0054 C5). A render prints once BOTH hold, and + * have held for {@link STABLE_POLLS} polls in a row: `idle` is also true before + * the first request leaves, so the marker alone or a single idle reading + * could print a page whose blocks have not loaded yet. Then it waits for the + * document's fonts, so a glyph is never printed from a fallback that a web + * font was about to replace. + * + * The PDF takes its sheet size and margins from the page's own `@page` rule + * (`preferCSSPageSize`): the print declaration is mapped to CSS once, by the + * console, and never a second time here. + */ + +/** What a render needs: the page to load, the session to load it as, and its time budget. */ +export interface ChromiumRenderInput { + /** The console URL of the record's print page. */ + url: string; + /** The requesting user's short-lived session cookie. A secret: never logged. */ + cookie: PrintSessionCookie; + /** The whole render's time budget, in milliseconds. */ + timeoutMs: number; +} + +/** The engine, in one placement. */ +export interface ChromiumDriver { + readonly placement: PrintPlacement; + /** Open the browser, read its version, close it. Throws when the engine cannot be reached. */ + probe(timeoutMs: number): Promise<{ version: string }>; + /** Render one page to PDF bytes. Throws an ADR-0112-shaped refusal on a timeout or an engine failure. */ + renderPdf(input: ChromiumRenderInput): Promise; +} + +/** How many consecutive ready polls a render waits for before it prints. */ +export const STABLE_POLLS = 3; +/** The interval between ready polls, in milliseconds. */ +const POLL_INTERVAL_MS = 100; + +/** The subset of playwright-core's browser this driver uses. */ +interface BrowserHandle { + version(): string; + newContext(): Promise; + close(): Promise; +} +interface ContextHandle { + addCookies(cookies: Array>): Promise; + newPage(): Promise; + close(): Promise; +} +interface PageHandle { + goto(url: string, options: { waitUntil: 'load'; timeout: number }): Promise; + waitForFunction(fn: string, arg: undefined, options: { timeout: number; polling: number }): Promise; + evaluate(fn: string): Promise; + pdf(options: { preferCSSPageSize: boolean; printBackground: boolean }): Promise; +} +interface ChromiumLauncher { + launch(options: { executablePath: string; headless: boolean; timeout: number }): Promise; + connectOverCDP(endpoint: string, options: { timeout: number }): Promise; +} + +/** + * The ready predicate, evaluated in the page. A string, not a function, so the + * page sees exactly this text and nothing from this module's scope. + */ +const PRINT_READY_PREDICATE = `(() => { + const ready = document.querySelector('[data-print-page]') !== null + && window.__objectui !== undefined && window.__objectui.idle === true; + window.__objectstackPrintStable = ready ? (window.__objectstackPrintStable || 0) + 1 : 0; + return window.__objectstackPrintStable >= ${STABLE_POLLS}; +})()`; + +const FONTS_READY = `document.fonts.ready.then(() => true)`; + +/** `SameSite` as playwright spells it. */ +function sameSiteOf(value: PrintSessionCookie['sameSite']): 'Strict' | 'Lax' | 'None' | undefined { + if (value === 'strict') return 'Strict'; + if (value === 'lax') return 'Lax'; + if (value === 'none') return 'None'; + return undefined; +} + +/** The session cookie, scoped to the console's host unless the auth service gave it a domain of its own. */ +function cookieFor(url: string, cookie: PrintSessionCookie): Record { + const sameSite = sameSiteOf(cookie.sameSite); + return { + name: cookie.name, + value: cookie.value, + domain: cookie.domain ?? new URL(url).hostname, + path: cookie.path, + httpOnly: cookie.httpOnly, + secure: cookie.secure, + ...(sameSite ? { sameSite } : {}), + }; +} + +function isTimeout(err: unknown): boolean { + return (err as { name?: unknown } | null)?.name === 'TimeoutError'; +} + +/** A message with every secret this render held replaced, so an engine error can be passed on. */ +function redact(message: string, secrets: readonly string[]): string { + let out = message; + for (const secret of secrets) { + if (secret.length > 0) out = out.split(secret).join('[redacted]'); + } + return out; +} + +async function loadLauncher(): Promise { + const mod = (await import('playwright-core')) as unknown as { chromium: ChromiumLauncher }; + return mod.chromium; +} + +class PlaywrightChromiumDriver implements ChromiumDriver { + readonly placement: PrintPlacement; + + constructor( + private readonly resolved: ResolvedPrintPlacement, + private readonly launcher: () => Promise, + ) { + this.placement = resolved.placement; + } + + /** Where the engine is, as a log line may name it. */ + private where(): string { + return this.placement === 'local' + ? `the local executable ${this.resolved.target}` + : `the remote endpoint ${describeEndpoint(this.resolved.target)}`; + } + + private async open(timeoutMs: number): Promise { + const chromium = await this.launcher(); + return this.placement === 'local' + ? chromium.launch({ executablePath: this.resolved.target, headless: true, timeout: timeoutMs }) + : chromium.connectOverCDP(this.resolved.target, { timeout: timeoutMs }); + } + + async probe(timeoutMs: number): Promise<{ version: string }> { + let browser: BrowserHandle; + try { + browser = await this.open(timeoutMs); + } catch (err) { + throw new Error( + `Chromium could not be reached at ${this.where()}: ${redact(String((err as Error)?.message ?? err), [this.resolved.target])}`, + ); + } + try { + return { version: browser.version() }; + } finally { + await browser.close().catch(() => undefined); + } + } + + async renderPdf(input: ChromiumRenderInput): Promise { + const secrets = [this.resolved.target, input.cookie.value]; + const started = Date.now(); + const remaining = (): number => Math.max(1, input.timeoutMs - (Date.now() - started)); + let browser: BrowserHandle | undefined; + let context: ContextHandle | undefined; + try { + browser = await this.open(remaining()); + context = await browser.newContext(); + await context.addCookies([cookieFor(input.url, input.cookie)]); + const page = await context.newPage(); + await page.goto(input.url, { waitUntil: 'load', timeout: remaining() }); + await page.waitForFunction(PRINT_READY_PREDICATE, undefined, { timeout: remaining(), polling: POLL_INTERVAL_MS }); + await page.evaluate(FONTS_READY); + return await page.pdf({ preferCSSPageSize: true, printBackground: true }); + } catch (err) { + if (isTimeout(err)) { + printRefusal( + 'TIMEOUT', + 504, + `The print page did not finish loading within ${Math.round(input.timeoutMs / 1000)} s at ${input.url}: ` + + 'the console never showed a print page for the record (a root carrying data-print-page, with no request ' + + 'in flight). Check that the console is served at that URL and reachable from the browser.', + ); + } + printRefusal( + 'INTERNAL_ERROR', + 500, + `The print engine failed at ${this.where()}: ${redact(String((err as Error)?.message ?? err), secrets)}`, + ); + } finally { + // The context first: on the remote placement it holds the session cookie + // inside a browser this process does not own. + if (context) await context.close().catch(() => undefined); + if (browser) await browser.close().catch(() => undefined); + } + } +} + +/** + * The driver for a resolved placement. `launcher` exists for tests that + * observe the calls; a deployment never passes it. + */ +export function createChromiumDriver( + resolved: ResolvedPrintPlacement, + launcher: () => Promise = loadLauncher, +): ChromiumDriver { + return new PlaywrightChromiumDriver(resolved, launcher); +} + +export type { ChromiumLauncher, BrowserHandle, ContextHandle, PageHandle }; diff --git a/packages/services/service-print/src/config.ts b/packages/services/service-print/src/config.ts new file mode 100644 index 00000000000..1e5915c451d --- /dev/null +++ b/packages/services/service-print/src/config.ts @@ -0,0 +1,191 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Where the print service's one engine, headless Chromium, runs — and where it + * loads the console from. + * + * ## One engine, two placements + * + * The driver is headless Chromium and nothing else: a print page is a page + * model, and only a browser renders it the way the console prints it. It has + * two placements, and a deployment configures exactly one: + * + * - **local** — a Chromium executable on this host, launched per render + * (`chromiumPath`, or `OS_PRINT_CHROMIUM_PATH`). Self-hosted deployments. + * - **remote** — a browser this deployment reaches over the Chrome DevTools + * Protocol at a WebSocket endpoint (`browserEndpoint`, or + * `OS_PRINT_BROWSER_ENDPOINT`), connected per render. A deployment whose + * hosts cannot carry a browser of their own. + * + * Both need `consoleUrl` (or `OS_PRINT_CONSOLE_URL`): the URL the browser + * loads this deployment's console from, `/_console` included — for the local + * placement usually this host's own address, for the remote one an address + * that browser can reach. A plugin option wins over its environment variable. + * + * Nothing here is guessed. No placement configured is `unconfigured`; both + * placements configured, or a value that will not parse, is `misconfigured` + * and names the value to fix. The plugin says so once at `start()`, and the + * service refuses every render until it is fixed. + * + * ## The remote endpoint may carry a secret + * + * A hosted browser commonly authenticates the WebSocket by a token in the + * endpoint's query string. The endpoint therefore never appears in a log line + * or an error message: {@link describeEndpoint} gives the scheme and host only. + */ + +/** The environment variable naming a local Chromium executable. */ +export const PRINT_CHROMIUM_PATH_ENV = 'OS_PRINT_CHROMIUM_PATH'; +/** The environment variable naming a remote browser's DevTools WebSocket endpoint. */ +export const PRINT_BROWSER_ENDPOINT_ENV = 'OS_PRINT_BROWSER_ENDPOINT'; +/** The environment variable naming the URL the browser loads the console from. */ +export const PRINT_CONSOLE_URL_ENV = 'OS_PRINT_CONSOLE_URL'; + +/** Where the Chromium engine runs. */ +export type PrintPlacement = 'local' | 'remote'; + +/** What a deployment configures, as plugin options. Each wins over its environment variable. */ +export interface PrintPlacementOptions { + /** A local Chromium executable. Environment: `OS_PRINT_CHROMIUM_PATH`. */ + chromiumPath?: string; + /** + * A remote browser's Chrome DevTools Protocol WebSocket endpoint + * (`ws://` or `wss://`). Environment: `OS_PRINT_BROWSER_ENDPOINT`. + */ + browserEndpoint?: string; + /** + * The URL the browser loads this deployment's console from, `/_console` + * included (`http://127.0.0.1:3000/_console`). Environment: + * `OS_PRINT_CONSOLE_URL`. + */ + consoleUrl?: string; +} + +/** A placement that is fully configured. */ +export interface ResolvedPrintPlacement { + placement: PrintPlacement; + /** The executable (local) or the endpoint (remote). The endpoint is a secret: never log it. */ + target: string; + /** The console URL, without a trailing slash. */ + consoleUrl: string; +} + +/** The configuration as read: a placement, or why there is none. */ +export type PrintPlacementResolution = + | { state: 'configured'; resolved: ResolvedPrintPlacement } + | { state: 'unconfigured'; reason: string } + | { state: 'misconfigured'; placement: PrintPlacement | null; reason: string }; + +const UNCONFIGURED_REASON = + 'No print engine is configured, so no record can be printed to a file. Set ' + + `${PRINT_CHROMIUM_PATH_ENV} (a local Chromium executable) or ${PRINT_BROWSER_ENDPOINT_ENV} (a remote ` + + `browser's DevTools WebSocket endpoint), and ${PRINT_CONSOLE_URL_ENV} (the URL the browser loads the console ` + + 'from) — or the matching PrintServicePlugin options.'; + +/** A configured value: a non-empty string after trimming, or nothing. */ +function setting(option: string | undefined, env: string | undefined): string | undefined { + const value = option !== undefined ? option : env; + if (value === undefined) return undefined; + const trimmed = value.trim(); + return trimmed === '' ? undefined : trimmed; +} + +/** + * The scheme and host of an endpoint, for a log line or a message — never its + * path or query, which may carry a token. An endpoint that does not parse is + * described as such, without its text. + */ +export function describeEndpoint(endpoint: string): string { + try { + const url = new URL(endpoint); + return `${url.protocol}//${url.host}`; + } catch { + return '(an endpoint that is not a URL)'; + } +} + +/** + * Read the placement from the plugin options and the environment. Pure: the + * same inputs always give the same answer, so it is pinned per case. + */ +export function resolvePrintPlacement( + options: PrintPlacementOptions, + env: Readonly>, +): PrintPlacementResolution { + const chromiumPath = setting(options.chromiumPath, env[PRINT_CHROMIUM_PATH_ENV]); + const browserEndpoint = setting(options.browserEndpoint, env[PRINT_BROWSER_ENDPOINT_ENV]); + const consoleUrl = setting(options.consoleUrl, env[PRINT_CONSOLE_URL_ENV]); + + if (chromiumPath === undefined && browserEndpoint === undefined) { + return { state: 'unconfigured', reason: UNCONFIGURED_REASON }; + } + if (chromiumPath !== undefined && browserEndpoint !== undefined) { + return { + state: 'misconfigured', + placement: null, + reason: + `Both print placements are configured: a local executable (${PRINT_CHROMIUM_PATH_ENV} or chromiumPath) ` + + `and a remote endpoint (${PRINT_BROWSER_ENDPOINT_ENV} or browserEndpoint). The engine runs in one ` + + 'place — remove one of them.', + }; + } + + const placement: PrintPlacement = chromiumPath !== undefined ? 'local' : 'remote'; + if (placement === 'remote') { + let protocol: string | null = null; + try { + protocol = new URL(browserEndpoint!).protocol; + } catch { + protocol = null; + } + if (protocol !== 'ws:' && protocol !== 'wss:') { + return { + state: 'misconfigured', + placement, + reason: + `The remote print endpoint (${PRINT_BROWSER_ENDPOINT_ENV} or browserEndpoint) must be a ws:// or ` + + `wss:// DevTools WebSocket URL; the configured value is ${describeEndpoint(browserEndpoint!)}.`, + }; + } + } + + if (consoleUrl === undefined) { + return { + state: 'misconfigured', + placement, + reason: + `The ${placement} print engine is configured, but ${PRINT_CONSOLE_URL_ENV} (or consoleUrl) is not: the ` + + 'browser has no URL to load the console from. Set it to the console URL that browser can reach, ' + + '/_console included.', + }; + } + let parsedConsole: URL | null = null; + try { + parsedConsole = new URL(consoleUrl); + } catch { + parsedConsole = null; + } + if (!parsedConsole || (parsedConsole.protocol !== 'http:' && parsedConsole.protocol !== 'https:')) { + return { + state: 'misconfigured', + placement, + reason: `${PRINT_CONSOLE_URL_ENV} (or consoleUrl) must be an http:// or https:// URL; it is '${consoleUrl}'.`, + }; + } + if (parsedConsole.search !== '' || parsedConsole.hash !== '') { + return { + state: 'misconfigured', + placement, + reason: `${PRINT_CONSOLE_URL_ENV} (or consoleUrl) must be the console's base URL, with no query or fragment; it is '${consoleUrl}'.`, + }; + } + + return { + state: 'configured', + resolved: { + placement, + target: placement === 'local' ? chromiumPath! : browserEndpoint!, + consoleUrl: consoleUrl.replace(/\/+$/, ''), + }, + }; +} diff --git a/packages/services/service-print/src/index.ts b/packages/services/service-print/src/index.ts new file mode 100644 index 00000000000..c44f039966b --- /dev/null +++ b/packages/services/service-print/src/index.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * @objectstack/service-print — print a record's print page to a PDF in + * headless Chromium, as the requesting user, and archive it as a file attached + * to the record. + */ + +export { PrintServicePlugin, PRINT_SERVICE, type PrintServicePluginOptions } from './print-service-plugin.js'; +export { + PRINT_RENDER_TIMEOUT_MS, + PRINT_SESSION_TTL_SECONDS, + type IPrintService, + type PrintEngineStatus, + type PrintRecordRequest, + type PrintedRecordFile, +} from './print-service.js'; +export { + PRINT_CHROMIUM_PATH_ENV, + PRINT_BROWSER_ENDPOINT_ENV, + PRINT_CONSOLE_URL_ENV, + type PrintPlacement, + type PrintPlacementOptions, +} from './config.js'; diff --git a/packages/services/service-print/src/principal-session.ts b/packages/services/service-print/src/principal-session.ts new file mode 100644 index 00000000000..78e1305bf33 --- /dev/null +++ b/packages/services/service-print/src/principal-session.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { ExecutionContext } from '@objectstack/spec/kernel'; + +/** + * The ONE place this package reaches the auth service's principal-session + * mint: an optional `IAuthService` member that mints a short-lived session for + * the requesting user's own principal, so the browser loads the console as + * that user and as nobody else. The print service never holds a system + * principal and never forwards a credential it was handed. + * + * ## Binding + * + * The member, its name and its shape belong to the auth contract + * (`IAuthService.mintPrincipalSession`, `@objectstack/spec/contracts`). Until + * that member is on the contract this adapter mirrors its shape locally and + * reads it off the registered `auth` service by name; once it is, the three + * local types below become imports from `@objectstack/spec/contracts` + * (`PrincipalSession`, `PrincipalSessionCookie`) and the read becomes a typed + * call on `IAuthService`. Nothing else in the package changes: every other + * file reaches the mint through {@link principalSessionMinterOf}. + */ + +/** The minted session's cookie, under the auth service's own name and attributes. A secret. */ +export interface PrintSessionCookie { + name: string; + value: string; + path: string; + httpOnly: boolean; + secure: boolean; + sameSite?: 'lax' | 'strict' | 'none'; + domain?: string; +} + +/** A minted session, as this package uses it. */ +export interface PrintSession { + /** The session cookie the browser loads the console with. */ + cookie: PrintSessionCookie; + /** The session as a bearer token. Never used here; held only so it can be redacted. */ + token: string; + /** End the session. Safe to call more than once. */ + revoke(): Promise; +} + +/** What the print service asks of the mint. */ +export interface PrincipalSessionMinter { + /** Mint a session for `context`'s own user that lives at most `ttlSeconds`. */ + mint(context: ExecutionContext, ttlSeconds: number): Promise; +} + +/** The member as the auth contract declares it. */ +type MintMember = ( + context: ExecutionContext, + options?: { ttlSeconds?: number }, +) => Promise; + +/** + * The mint of the registered `auth` service, or `undefined` when that service + * is absent or offers no principal-session mint. Read at call time, never + * cached, so a provider that registers later is seen. + */ +export function principalSessionMinterOf(auth: unknown): PrincipalSessionMinter | undefined { + if (!auth || typeof auth !== 'object') return undefined; + const member = (auth as { mintPrincipalSession?: unknown }).mintPrincipalSession; + if (typeof member !== 'function') return undefined; + const mint = member as MintMember; + return { mint: (context, ttlSeconds) => mint.call(auth, context, { ttlSeconds }) }; +} diff --git a/packages/services/service-print/src/print-service-plugin.ts b/packages/services/service-print/src/print-service-plugin.ts new file mode 100644 index 00000000000..93d1612f272 --- /dev/null +++ b/packages/services/service-print/src/print-service-plugin.ts @@ -0,0 +1,116 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Plugin, PluginContext } from '@objectstack/core'; +import { + FILE_ATTACHMENT_SERVICE, + type IDataEngine, + type IFileAttachmentService, +} from '@objectstack/spec/contracts'; +import { createChromiumDriver } from './chromium-driver.js'; +import { + describeEndpoint, + resolvePrintPlacement, + type PrintPlacementOptions, + type PrintPlacementResolution, +} from './config.js'; +import { principalSessionMinterOf } from './principal-session.js'; +import { PrintService, type PrintMetadataReader } from './print-service.js'; + +/** The service-registry slot the plugin registers {@link PrintService} under. */ +export const PRINT_SERVICE = 'print' as const; + +/** Plugin configuration: where the engine runs and where it loads the console from. */ +export type PrintServicePluginOptions = PrintPlacementOptions; + +/** + * PrintServicePlugin — registers the `print` service. + * + * 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. + * + * Lifecycle: + * - `init`: read the placement (options, then `OS_PRINT_*`), build the + * driver for it, and register the service. + * - `start`: probe the engine — launch the local executable or connect to the + * remote endpoint, read its version, close — and say what it found, once. + * A ready engine logs its placement and Chromium version. An absent, + * misconfigured or unreachable engine logs a `warn` naming the + * consequence and the remedy, so a deployment learns it at boot and never + * at its first print. Printing is a capability, not a store: a missing + * engine makes the deployment visibly smaller and loses nothing, which is + * a `warn`. + * - `kernel:ready`: the services a print needs (the auth service's + * principal-session mint, `file-attachments`, metadata, 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. + */ +export class PrintServicePlugin implements Plugin { + name = 'com.objectstack.service.print'; + providesServices = [PRINT_SERVICE]; + version = '1.0.0'; + type = 'standard' as const; + + private service?: PrintService; + private placement?: PrintPlacementResolution; + + constructor(private readonly options: PrintServicePluginOptions = {}) {} + + async init(ctx: PluginContext): Promise { + const placement = resolvePrintPlacement(this.options, process.env); + this.placement = placement; + const driver = placement.state === 'configured' ? createChromiumDriver(placement.resolved) : null; + const optional = (name: string): T | undefined => { + try { + return ctx.getService(name) ?? undefined; + } catch { + return undefined; + } + }; + this.service = new PrintService({ + placement, + driver, + getMetadata: () => optional('metadata'), + getEngine: () => optional>('objectql'), + getAttachments: () => optional(FILE_ATTACHMENT_SERVICE), + getMinter: () => principalSessionMinterOf(optional('auth')), + }); + ctx.registerService(PRINT_SERVICE, this.service); + } + + async start(ctx: PluginContext): Promise { + const { service, placement } = this; + if (!service || !placement) return; + if (placement.state === 'unconfigured') { + ctx.logger.warn(`PrintServicePlugin: ${placement.reason}`); + return; + } + if (placement.state === 'misconfigured') { + ctx.logger.warn(`PrintServicePlugin: printing is off until this is fixed — ${placement.reason}`); + return; + } + const { resolved } = placement; + const where = + resolved.placement === 'local' + ? `local executable ${resolved.target}` + : `remote endpoint ${describeEndpoint(resolved.target)}`; + await service.probe(); + const probed = service.status(); + if (probed.version === undefined) { + ctx.logger.warn(`PrintServicePlugin: printing is off until this is fixed — ${probed.reason}`); + return; + } + ctx.logger.info(`PrintServicePlugin: Chromium ${probed.version} on the ${where}; console at ${resolved.consoleUrl}`); + ctx.hook('kernel:ready', async () => { + const ready = service.status(); + if (ready.state !== 'ready') { + ctx.logger.warn(`PrintServicePlugin: printing is off until this is fixed — ${ready.reason}`); + } + }); + } + + async destroy(): Promise { + this.service = undefined; + this.placement = undefined; + } +} diff --git a/packages/services/service-print/src/print-service.ts b/packages/services/service-print/src/print-service.ts new file mode 100644 index 00000000000..95307c6172b --- /dev/null +++ b/packages/services/service-print/src/print-service.ts @@ -0,0 +1,341 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { recordNotFoundError } from '@objectstack/core'; +import type { IDataEngine, IFileAttachmentService } from '@objectstack/spec/contracts'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { defaultRecordPageObject } from '@objectstack/spec/ui'; +import type { ChromiumDriver } from './chromium-driver.js'; +import type { PrintPlacement, PrintPlacementResolution } from './config.js'; +import type { PrincipalSessionMinter, PrintSession } from './principal-session.js'; +import { printRefusal } from './refusal.js'; + +/** + * The print service: render a record's print page in headless Chromium, as + * the requesting user, and archive the PDF as a file attached to the record. + * + * ## The order — every refusal before anything is minted or launched + * + * A call is judged in this order, and each step refuses with `code` and + * `status` (ADR-0112) before the next one runs. Nothing is minted, no browser + * is launched or connected and nothing is stored until every step has passed: + * + * 1. **The principal.** A human user acting for themselves: a context with a + * user, not the system, `principalKind` absent or `human`, and no + * authentication-policy hold (`authGate`). Anything else answers + * `PERMISSION_DENIED` / `403`. There is no system principal to fall back + * to: the PDF is what this user may see, rendered as this user. + * 2. **The engine.** {@link PrintService.status} must be `ready`. Otherwise + * `SERVICE_UNAVAILABLE` / `503`, carrying the reason `status()` gives. + * 3. **The page.** The request names the page it expects; it must exist + * (`RESOURCE_NOT_FOUND` / `404`), be bound to the record's object, declare + * `print`, and be the object's default record page — the one page the + * console renders a record on (`VALIDATION_ERROR` / `400` for each). + * 4. **The record, read AS THE CALLER.** A record the caller cannot read and + * a record that does not exist answer the same `RECORD_NOT_FOUND` / `404`, + * so the answer says nothing about a record the caller may not see. A user + * never archives what they could not read. + * 5. **The files opt-in.** The record's object must declare + * `enable: { files: true }`, the opt-in every attachment of it needs + * (`FILES_DISABLED` / `403`), checked here so a refused archive never costs + * a render. + * + * Then the session is minted for the caller's own principal, the page is + * rendered with it, the PDF is archived through the `file-attachments` service, + * and the session is revoked in a `finally` — after a failed render or a + * refused archive as much as after a success. + * + * The archive is the one writer of `sys_file` and `sys_attachment` + * (`IFileAttachmentService.attachFile`, `@objectstack/service-storage`): its + * size limit, its organization wall and every gate on an attachment insert + * judge this file exactly as they judge any other. + */ + +/** A request to print one record. */ +export interface PrintRecordRequest { + /** The app the console opens the record in (its route segment: the package id or the app name). */ + app: string; + /** The record's object. */ + object: string; + /** The record's id. */ + recordId: string; + /** The print page the caller expects the record to print on. */ + page: string; + /** The archived file's name. Defaults to `-.pdf`. */ + filename?: string; +} + +/** What a print stored. */ +export interface PrintedRecordFile { + /** The `sys_file` row's id. */ + fileId: string; + /** The `sys_attachment` row's id. */ + attachmentId: string; + /** The PDF's size in bytes. */ + size: number; + /** The file name it was stored under. */ + filename: string; +} + +/** + * The print engine's state. + * + * - `ready` — a placement is configured, the engine answered its probe, and + * every service a print needs is registered. + * - `unconfigured` — no placement is configured. Printing is off. + * - `misconfigured` — a placement is configured but cannot be used as given. + * - `unavailable` — configured, but the engine did not answer its probe or a + * service a print needs is missing. + */ +export interface PrintEngineStatus { + state: 'ready' | 'unconfigured' | 'misconfigured' | 'unavailable'; + /** Where the engine runs, when one is configured. */ + placement: PrintPlacement | null; + /** The Chromium version the last probe read. */ + version?: string; + /** Why the engine is not `ready`, with the remedy. Absent when `ready`. */ + reason?: string; +} + +/** The public face of the `print` service. */ +export interface IPrintService { + /** The engine's state, read now. */ + status(): PrintEngineStatus; + /** Print one record to a PDF as the caller and attach it to the record. */ + printRecordToFile(request: PrintRecordRequest, context: ExecutionContext): Promise; +} + +/** The metadata reads a print needs. */ +export interface PrintMetadataReader { + get(type: string, name: string): Promise; + getObject(name: string): Promise; +} + +/** What {@link PrintService} is composed over — each service read at call time. */ +export interface PrintServiceDeps { + placement: PrintPlacementResolution; + /** The engine for the configured placement; `null` exactly when none is configured. */ + driver: ChromiumDriver | null; + getMetadata(): PrintMetadataReader | undefined; + getEngine(): Pick | undefined; + getAttachments(): IFileAttachmentService | undefined; + getMinter(): PrincipalSessionMinter | undefined; +} + +/** The whole render's time budget. */ +export const PRINT_RENDER_TIMEOUT_MS = 30_000; +/** + * The minted session's life: twice the render budget, so a render that runs + * its full budget still holds a live session, and no longer. Revoked in a + * `finally` well before it would expire on its own. + */ +export const PRINT_SESSION_TTL_SECONDS = 60; +/** The engine probe's time budget. */ +export const PRINT_PROBE_TIMEOUT_MS = 15_000; + +const PDF_MIME_TYPE = 'application/pdf'; + +function nonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +/** What the engine's last probe answered. */ +type ProbeResult = { ok: true; version: string } | { ok: false; reason: string }; + +export class PrintService implements IPrintService { + private lastProbe: ProbeResult | undefined; + + constructor(private readonly deps: PrintServiceDeps) {} + + /** + * Probe the engine: open the browser, read its version, close it. Records + * the answer for {@link status}, and returns it. + */ + async probe(): Promise { + const { driver } = this.deps; + if (driver) { + try { + const { version } = await driver.probe(PRINT_PROBE_TIMEOUT_MS); + this.lastProbe = { ok: true, version }; + } catch (err) { + this.lastProbe = { ok: false, reason: String((err as Error)?.message ?? err) }; + } + } + return this.status(); + } + + status(): PrintEngineStatus { + const { placement } = this.deps; + if (placement.state === 'unconfigured') { + return { state: 'unconfigured', placement: null, reason: placement.reason }; + } + if (placement.state === 'misconfigured') { + return { state: 'misconfigured', placement: placement.placement, reason: placement.reason }; + } + const where = placement.resolved.placement; + if (!this.lastProbe) { + return { state: 'unavailable', placement: where, reason: 'The print engine has not been probed yet.' }; + } + if (!this.lastProbe.ok) { + return { state: 'unavailable', placement: where, reason: this.lastProbe.reason }; + } + const version = this.lastProbe.version; + const missing = this.missingService(); + if (missing) return { state: 'unavailable', placement: where, version, reason: missing }; + return { state: 'ready', placement: where, version }; + } + + /** The first service a print needs that is not registered, as a reason; `undefined` when all are. */ + private missingService(): string | undefined { + if (!this.deps.getMinter()) { + return ( + 'The registered auth service offers no principal-session mint (IAuthService.mintPrincipalSession), so ' + + "a page cannot be rendered as the requesting user. Compose an auth plugin that provides it." + ); + } + if (!this.deps.getAttachments()) { + return ( + 'No file-attachments service is registered, so a rendered PDF has nowhere to be archived. Compose ' + + "@objectstack/service-storage's StorageServicePlugin." + ); + } + if (!this.deps.getMetadata()) return 'No metadata service is registered, so a print page cannot be read.'; + if (!this.deps.getEngine()) return 'No data engine is registered, so a record cannot be read.'; + return undefined; + } + + async printRecordToFile(request: PrintRecordRequest, context: ExecutionContext): Promise { + // ① The principal. + this.refuseUnlessHumanCaller(context); + + // ② The engine. An engine that failed its probe is probed once more, so an + // endpoint that came up after boot serves without a restart. + let status = this.status(); + if (status.state === 'unavailable' && this.lastProbe && !this.lastProbe.ok) status = await this.probe(); + if (status.state !== 'ready') { + printRefusal('SERVICE_UNAVAILABLE', 503, `Records cannot be printed to a file: ${status.reason}`); + } + const driver = this.deps.driver!; + const metadata = this.deps.getMetadata()!; + const engine = this.deps.getEngine()!; + const attachments = this.deps.getAttachments()!; + const minter = this.deps.getMinter()!; + const placement = this.deps.placement as Extract; + + // ③ The page. + const req = validateRequest(request); + await this.refuseUnlessPrintPage(metadata, req); + + // ④ The record, read as the caller. + await this.refuseUnlessReadable(engine, req, context); + + // ⑤ The files opt-in. + await this.refuseUnlessFilesEnabled(metadata, req.object); + + // Mint, render, archive — and revoke, whatever happened. + const filename = req.filename ?? `${req.page}-${req.recordId}.pdf`; + const url = + `${placement.resolved.consoleUrl}/apps/${encodeURIComponent(req.app)}/` + + `${encodeURIComponent(req.object)}/record/${encodeURIComponent(req.recordId)}`; + const session: PrintSession = await minter.mint(context, PRINT_SESSION_TTL_SECONDS); + try { + const pdf = await driver.renderPdf({ url, cookie: session.cookie, timeoutMs: PRINT_RENDER_TIMEOUT_MS }); + const attached = await attachments.attachFile({ + parentObject: req.object, + parentId: req.recordId, + organizationId: nonEmptyString(context.tenantId) ? context.tenantId : null, + filename, + mimeType: PDF_MIME_TYPE, + data: pdf, + }); + return { fileId: attached.fileId, attachmentId: attached.attachmentId, size: attached.size, filename }; + } finally { + await session.revoke(); + } + } + + private refuseUnlessHumanCaller(context: ExecutionContext): void { + const deny = (why: string): never => + printRefusal('PERMISSION_DENIED', 403, `A record can be printed to a file only by a signed-in user acting for themselves: ${why}.`); + if (!context || typeof context !== 'object') deny('the call carries no execution context'); + if (context.isSystem === true) deny('the system context has no user to render the page as'); + if (!nonEmptyString(context.userId)) deny('the call carries no user'); + if (context.principalKind !== undefined && context.principalKind !== 'human') { + deny(`the caller is a ${context.principalKind} principal, not a user`); + } + if (context.authGate) deny(`the user's sign-in is held by an authentication policy (${context.authGate.code})`); + } + + private async refuseUnlessPrintPage(metadata: PrintMetadataReader, req: ValidRequest): Promise { + const page = (await metadata.get('page', req.page)) as Record | undefined; + if (!page || typeof page !== 'object') { + printRefusal('RESOURCE_NOT_FOUND', 404, `There is no page named '${req.page}'.`); + } + if (page.object !== req.object) { + printRefusal( + 'VALIDATION_ERROR', + 400, + `The page '${req.page}' is not a record page of '${req.object}', so it cannot print a ${req.object} record.`, + ); + } + const print = page.print; + if (!print || typeof print !== 'object' || Array.isArray(print)) { + printRefusal( + 'VALIDATION_ERROR', + 400, + `The page '${req.page}' does not declare 'print', so it is not a print page. Declare print on the page to print records on it.`, + ); + } + if (defaultRecordPageObject(page) !== req.object) { + printRefusal( + 'VALIDATION_ERROR', + 400, + `The page '${req.page}' is not the default record page of '${req.object}' (isDefault: true). The console ` + + 'renders a record on its default record page only, so that is the one page a record can be printed on.', + ); + } + } + + private async refuseUnlessReadable( + engine: Pick, + req: ValidRequest, + context: ExecutionContext, + ): Promise { + let row: Record | null; + try { + row = await engine.findOne(req.object, { where: { id: req.recordId } }, { context }); + } catch (err) { + // A read the caller is refused answers exactly as a record that does not + // exist: never a 403 that tells the two apart. + if ((err as { status?: unknown })?.status === 403) throw recordNotFoundError(req.object, req.recordId); + throw err; + } + if (!row) throw recordNotFoundError(req.object, req.recordId); + } + + private async refuseUnlessFilesEnabled(metadata: PrintMetadataReader, object: string): Promise { + const def = (await metadata.getObject(object)) as { enable?: { files?: unknown } } | undefined; + if (def?.enable?.files !== true) { + printRefusal( + 'FILES_DISABLED', + 403, + `Files are not enabled on '${object}', so a printed PDF cannot be attached to its records. Declare ` + + `enable: { files: true } on the object.`, + ); + } + } +} + +type ValidRequest = Required> & { filename?: string }; + +/** The request, checked field by field: a JavaScript caller can hand anything. */ +function validateRequest(request: PrintRecordRequest): ValidRequest { + const fail = (message: string): never => printRefusal('VALIDATION_ERROR', 400, `printRecordToFile: ${message}`); + if (!request || typeof request !== 'object') fail('the request must be an object'); + for (const field of ['app', 'object', 'recordId', 'page'] as const) { + if (!nonEmptyString(request[field])) fail(`'${field}' must be a non-empty string`); + } + if (request.filename !== undefined && !nonEmptyString(request.filename)) { + fail("'filename' must be a non-empty string when given"); + } + return request as ValidRequest; +} diff --git a/packages/services/service-print/src/refusal.ts b/packages/services/service-print/src/refusal.ts new file mode 100644 index 00000000000..34a618e841d --- /dev/null +++ b/packages/services/service-print/src/refusal.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { RegisteredErrorCode, StandardErrorCode } from '@objectstack/spec/api'; + +/** An ADR-0112-shaped refusal: an `Error` carrying `code` and `status`. */ +export type PrintRefusal = Error & { code: StandardErrorCode | RegisteredErrorCode; status: number }; + +/** + * Throw an ADR-0112-shaped refusal. Every code the print service answers is + * from the standard catalog, plus the registered `FILES_DISABLED`: it adds no + * wire vocabulary of its own. + */ +export function printRefusal(code: StandardErrorCode | RegisteredErrorCode, status: number, message: string): never { + const err = new Error(message) as PrintRefusal; + err.code = code; + err.status = status; + throw err; +} diff --git a/packages/services/service-print/tsconfig.json b/packages/services/service-print/tsconfig.json new file mode 100644 index 00000000000..0b8b99d8820 --- /dev/null +++ b/packages/services/service-print/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": [ + "node" + ] + }, + "include": [ + "src" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/packages/services/service-print/vitest.config.ts b/packages/services/service-print/vitest.config.ts new file mode 100644 index 00000000000..237a8c4ff06 --- /dev/null +++ b/packages/services/service-print/vitest.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from 'vitest/config'; +import path from 'node:path'; + +/** + * Every workspace package these tests import resolves to its SOURCE in this + * checkout, so a verdict here is a verdict about the source, never about a + * stale `dist/` (`pnpm check:test-source-alias`). Anchored, array form: a bare + * string `find` matches by prefix and would swallow the subpaths. + */ + +export default defineConfig({ + test: { + // A late console.* must not redden a green suite (#10374): vitest's worker + // forwards console output over RPC and discards the promise, and a write + // landing after teardown's rpcDone() snapshot is rejected into an unhandled + // error — a fully green run that exits 1. Disarming removes the mechanism. + // Mechanism + measured costs: examples/app-showcase/vitest.config.ts. + // Enforced repo-wide by scripts/check-console-intercept-disarm.mjs. + disableConsoleIntercept: true, + }, + resolve: { + alias: [ + { find: /^@objectstack\/core$/, replacement: path.resolve(__dirname, '../../core/src/index.ts') }, + { find: /^@objectstack\/spec$/, replacement: path.resolve(__dirname, '../../spec/src/index.ts') }, + { find: /^@objectstack\/spec\/api$/, replacement: path.resolve(__dirname, '../../spec/src/api/index.ts') }, + { 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') }, + ], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef069e88bab..8041a2ced38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -402,7 +402,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@4.1.11)(happy-dom@20.10.2)(jsdom@30.1.1(@noble/hashes@2.4.0))(msw@2.14.6(@types/node@26.6.3)(typescript@6.0.3))(vite@8.0.16(@types/node@26.6.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@4.1.11)(happy-dom@20.10.2)(jsdom@30.1.1(@noble/hashes@2.4.0))(msw@2.14.6(@types/node@26.6.3)(typescript@6.0.3))(vite@8.0.16(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1)) packages/apps/setup: dependencies: @@ -2831,6 +2831,28 @@ importers: specifier: ^4.1.11 version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@4.1.11)(happy-dom@20.10.2)(jsdom@30.1.1(@noble/hashes@2.4.0))(msw@2.14.6(@types/node@26.6.3)(typescript@6.0.3))(vite@8.0.16(@types/node@26.6.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1)) + packages/services/service-print: + dependencies: + '@objectstack/core': + specifier: workspace:* + version: link:../../core + '@objectstack/spec': + specifier: workspace:* + version: link:../../spec + playwright-core: + specifier: 1.63.0 + version: 1.63.0 + devDependencies: + '@types/node': + specifier: ^26.6.3 + version: 26.6.3 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.11 + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@4.1.11)(happy-dom@20.10.2)(jsdom@30.1.1(@noble/hashes@2.4.0))(msw@2.14.6(@types/node@26.6.3)(typescript@6.0.3))(vite@8.0.16(@types/node@26.6.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1)) + packages/services/service-queue: dependencies: '@objectstack/core': @@ -11925,6 +11947,15 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 + '@vitest/mocker@4.1.11(msw@2.14.6(@types/node@26.6.3)(typescript@6.0.3))(vite@8.0.16(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.14.6(@types/node@26.6.3)(typescript@6.0.3) + vite: 8.0.16(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1) + '@vitest/mocker@4.1.11(msw@2.14.6(@types/node@26.6.3)(typescript@6.0.3))(vite@8.0.16(@types/node@26.6.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1))': dependencies: '@vitest/spy': 4.1.11 @@ -16034,6 +16065,21 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite@8.0.16(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.6.3 + esbuild: 0.28.1 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6) + yaml: 2.9.1 + vite@8.0.16(@types/node@26.6.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1): dependencies: lightningcss: 1.33.0 @@ -16049,6 +16095,37 @@ snapshots: tsx: 4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6) yaml: 2.9.1 + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@4.1.11)(happy-dom@20.10.2)(jsdom@30.1.1(@noble/hashes@2.4.0))(msw@2.14.6(@types/node@26.6.3)(typescript@6.0.3))(vite@8.0.16(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(msw@2.14.6(@types/node@26.6.3)(typescript@6.0.3))(vite@8.0.16(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.0.16(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 26.6.3 + '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + happy-dom: 20.10.2 + jsdom: 30.1.1(@noble/hashes@2.4.0) + transitivePeerDependencies: + - msw + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@4.1.11)(happy-dom@20.10.2)(jsdom@30.1.1(@noble/hashes@2.4.0))(msw@2.14.6(@types/node@26.6.3)(typescript@6.0.3))(vite@8.0.16(@types/node@26.6.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1)): dependencies: '@vitest/expect': 4.1.11 From 7c7f036c4034c2d7099cd1b07804b8a2b9f298cf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 02:11:44 +0000 Subject: [PATCH 02/21] feat(service-print): the print service, its Chromium driver on two placements, and their pins Claude-Session: https://claude.ai/code/session_01W2XFCTBfK4LabgqovmvSgK Co-authored-by: Claude --- .../service-print/src/chromium-driver.test.ts | 327 +++++++++++++++ .../service-print/src/chromium-driver.ts | 6 +- .../services/service-print/src/config.test.ts | 85 ++++ .../src/print-service-plugin.test.ts | 126 ++++++ .../service-print/src/print-service.test.ts | 372 ++++++++++++++++++ .../services/service-print/src/refusal.ts | 7 +- 6 files changed, 919 insertions(+), 4 deletions(-) create mode 100644 packages/services/service-print/src/chromium-driver.test.ts create mode 100644 packages/services/service-print/src/config.test.ts create mode 100644 packages/services/service-print/src/print-service-plugin.test.ts create mode 100644 packages/services/service-print/src/print-service.test.ts diff --git a/packages/services/service-print/src/chromium-driver.test.ts b/packages/services/service-print/src/chromium-driver.test.ts new file mode 100644 index 00000000000..a812d2717d2 --- /dev/null +++ b/packages/services/service-print/src/chromium-driver.test.ts @@ -0,0 +1,327 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { accessSync, constants, mkdtempSync, rmSync } from 'node:fs'; +import { createServer, type Server } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { inflateSync } from 'node:zlib'; +import type { PluginContext } from '@objectstack/core'; +import { createChromiumDriver, type ChromiumDriver } from './chromium-driver.js'; +import { PRINT_SERVICE, PrintServicePlugin } from './print-service-plugin.js'; +import type { PrintService } from './print-service.js'; +import type { PrintSessionCookie } from './principal-session.js'; + +/** + * The one engine, on BOTH placements, against a real headless Chromium. + * + * - **local** — the driver launches the executable itself. + * - **remote** — the test spawns that same executable with its DevTools + * WebSocket endpoint open, and the driver reaches it ONLY through the remote + * placement's code path (`connectOverCDP`), the path a deployment's remote + * endpoint takes. + * + * The page is a fixture "console" served here: it shows a print page (a root + * carrying `data-print-page`, settling `window.__objectui.idle` after a fake + * request) only to a browser that sends the minted session cookie, which is how + * the real console treats a session it cannot resolve. + * + * ## The CJK pin + * + * Extracting the PDF's text cannot tell rendered glyphs from empty boxes: + * measured, `pdftotext` returns the Chinese text from a PDF printed with no + * CJK font at all. What does tell them apart is the fonts' ToUnicode maps. A + * glyph Chromium actually drew is embedded with a ToUnicode entry naming its + * code point; a character drawn as an empty box is the font's `.notdef`, which + * maps to nothing. So the pin is: every Chinese code point on the page is the + * target of some embedded font's ToUnicode map. Measured both ways — with the + * host's CJK font, every code point mapped; with Chromium's fonts narrowed to a + * Latin-only fontconfig, none did. + * + * ## Which Chromium + * + * `OS_TEST_PRINT_CHROMIUM_PATH`, else Playwright's provisioned browser + * (`$PLAYWRIGHT_BROWSERS_PATH/chromium`), whose provisioning installs a CJK + * font beside it. With neither, the whole file is skipped, and says so: a + * skipped engine test is not a passed one. + */ + +function chromiumUnderTest(): string | undefined { + const candidates = [ + process.env.OS_TEST_PRINT_CHROMIUM_PATH, + process.env.PLAYWRIGHT_BROWSERS_PATH ? join(process.env.PLAYWRIGHT_BROWSERS_PATH, 'chromium') : undefined, + ]; + for (const candidate of candidates) { + if (!candidate) continue; + try { + accessSync(candidate, constants.X_OK); + return candidate; + } catch { + // not this one + } + } + return undefined; +} + +const CHROMIUM = chromiumUnderTest(); +if (!CHROMIUM) { + console.warn( + '[service-print] chromium-driver.test.ts SKIPPED: no Chromium (set OS_TEST_PRINT_CHROMIUM_PATH, or ' + + 'PLAYWRIGHT_BROWSERS_PATH holding a provisioned chromium). The engine was NOT measured on this host.', + ); +} + +const COOKIE: PrintSessionCookie = { + name: 'os.session_token', + value: 'minted-session-cookie-value', + path: '/', + httpOnly: true, + secure: false, + sameSite: 'lax', +}; +const CJK_TEXT = '发票客户名称上海示例有限公司合计金额'; +const RECORD_PATH = '/_console/apps/showcase/invoice/record/inv_1'; + +function printPageHtml(): string { + return ` + +
+`; +} + +const SIGNED_OUT_HTML = `
Sign in
+`; + +interface Fixture { + server: Server; + origin: string; + cookieSeen: string[]; +} + +async function startFixture(): Promise { + const cookieSeen: string[] = []; + const server = createServer((req, res) => { + const cookie = req.headers.cookie ?? ''; + cookieSeen.push(cookie); + res.setHeader('content-type', 'text/html; charset=utf-8'); + const signedIn = cookie.split(/;\s*/).includes(`${COOKIE.name}=${COOKIE.value}`); + res.end(req.url === RECORD_PATH && signedIn ? printPageHtml() : SIGNED_OUT_HTML); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as { port: number }; + return { server, origin: `http://127.0.0.1:${port}`, cookieSeen }; +} + +/** A Chromium the test runs itself, with its DevTools WebSocket endpoint open — the remote placement's far end. */ +async function spawnRemoteBrowser(executable: string): Promise<{ child: ChildProcess; endpoint: string; dir: string }> { + const dir = mkdtempSync(join(tmpdir(), 'os-print-remote-')); + const child = spawn( + executable, + [ + '--headless=new', + '--no-sandbox', + '--no-first-run', + '--remote-debugging-address=127.0.0.1', + '--remote-debugging-port=0', + `--user-data-dir=${dir}`, + 'about:blank', + ], + { stdio: ['ignore', 'ignore', 'pipe'] }, + ); + const endpoint = await new Promise((resolve, reject) => { + let stderr = ''; + const timer = setTimeout(() => reject(new Error(`no DevTools endpoint: ${stderr.slice(0, 500)}`)), 20_000); + child.stderr!.on('data', (chunk) => { + stderr += String(chunk); + const m = stderr.match(/DevTools listening on (ws:\/\/\S+)/); + if (m) { + clearTimeout(timer); + resolve(m[1]!); + } + }); + }); + return { child, endpoint, dir }; +} + +/** Every code point some embedded font's ToUnicode map names. */ +function toUnicodeTargets(pdf: Buffer): Set { + const text = pdf.toString('latin1'); + const targets = new Set(); + const stream = /stream\r?\n/g; + let m: RegExpExecArray | null; + while ((m = stream.exec(text))) { + const start = m.index + m[0].length; + const end = text.indexOf('endstream', start); + if (end < 0) break; + let body: string; + try { + body = inflateSync(pdf.subarray(start, end)).toString('latin1'); + } catch { + continue; + } + if (!body.includes('begincmap')) continue; + for (const block of body.matchAll(/beginbfchar([\s\S]*?)endbfchar/g)) { + for (const pair of block[1]!.matchAll(/<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>/g)) { + targets.add(parseInt(pair[2]!.slice(0, 4), 16)); + } + } + for (const block of body.matchAll(/beginbfrange([\s\S]*?)endbfrange/g)) { + for (const range of block[1]!.matchAll(/<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>/g)) { + const lo = parseInt(range[1]!, 16); + const hi = parseInt(range[2]!, 16); + const dst = parseInt(range[3]!.slice(0, 4), 16); + for (let i = 0; i <= hi - lo; i++) targets.add(dst + i); + } + } + } + return targets; +} + +function mediaBox(pdf: Buffer): [number, number] { + const m = pdf.toString('latin1').match(/\/MediaBox\s*\[\s*0\s+0\s+([\d.]+)\s+([\d.]+)\s*\]/); + if (!m) throw new Error('no MediaBox in the PDF'); + return [Number(m[1]), Number(m[2])]; +} + +function fakeContext(): { ctx: PluginContext; logged: Array<{ level: string; message: string }>; services: Map } { + const logged: Array<{ level: string; message: string }> = []; + const services = new Map(); + const ctx = { + logger: { + info: (message: string) => logged.push({ level: 'info', message }), + warn: (message: string) => logged.push({ level: 'warn', message }), + error: (message: string) => logged.push({ level: 'error', message }), + debug: () => undefined, + }, + 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: () => undefined, + } as unknown as PluginContext; + return { ctx, logged, services }; +} + +async function refusalOf(p: Promise): Promise<{ code: unknown; status: unknown; message: string }> { + try { + await p; + } catch (err) { + const e = err as { code?: unknown; status?: unknown; message: string }; + return { code: e.code, status: e.status, message: e.message }; + } + throw new Error('expected a refusal, the call resolved'); +} + +describe.skipIf(!CHROMIUM)('the Chromium engine on both placements', () => { + let fixture: Fixture; + let remote: { child: ChildProcess; endpoint: string; dir: string }; + + beforeAll(async () => { + fixture = await startFixture(); + remote = await spawnRemoteBrowser(CHROMIUM!); + }, 60_000); + + afterAll(async () => { + fixture?.server.close(); + if (remote) { + const exited = new Promise((resolve) => { + if (remote.child.exitCode !== null) resolve(); + else remote.child.once('exit', () => resolve()); + }); + remote.child.kill(); + await exited; + rmSync(remote.dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + }); + + const placements = (): Array<{ label: 'local' | 'remote'; driver: ChromiumDriver; options: Record }> => [ + { + label: 'local', + driver: createChromiumDriver({ placement: 'local', target: CHROMIUM!, consoleUrl: `${fixture.origin}/_console` }), + options: { chromiumPath: CHROMIUM! }, + }, + { + label: 'remote', + driver: createChromiumDriver({ placement: 'remote', target: remote.endpoint, consoleUrl: `${fixture.origin}/_console` }), + options: { browserEndpoint: remote.endpoint }, + }, + ]; + + for (const label of ['local', 'remote'] as const) { + describe(`${label} placement`, () => { + const pick = () => placements().find((p) => p.label === label)!; + + it('start() probes the engine and logs its placement and Chromium version', async () => { + const { ctx, logged, services } = fakeContext(); + const plugin = new PrintServicePlugin({ ...pick().options, consoleUrl: `${fixture.origin}/_console` }); + await plugin.init(ctx); + await plugin.start(ctx); + const info = logged.filter((l) => l.level === 'info'); + expect(info).toHaveLength(1); + expect(info[0]!.message).toMatch(new RegExp(`Chromium \\d+\\.\\d+.* on the ${label} `)); + expect(logged.filter((l) => l.level === 'warn')).toHaveLength(0); + const status = (services.get(PRINT_SERVICE) as PrintService).status(); + expect(status.placement).toBe(label); + expect(status.version).toMatch(/^\d+\./); + await plugin.destroy(); + }, 60_000); + + it('renders the print page as the session, on A4 from the page’s own @page rule, with its Chinese glyphs drawn', async () => { + const before = fixture.cookieSeen.length; + const pdf = await pick().driver.renderPdf({ + url: `${fixture.origin}${RECORD_PATH}`, + cookie: COOKIE, + timeoutMs: 30_000, + }); + expect(pdf.subarray(0, 5).toString('latin1')).toBe('%PDF-'); + const [w, h] = mediaBox(pdf); + expect(Math.round(w)).toBe(595); + expect(Math.round(h)).toBe(842); + expect(fixture.cookieSeen.slice(before).some((c) => c.includes(`${COOKIE.name}=${COOKIE.value}`))).toBe(true); + + const targets = toUnicodeTargets(pdf); + const missing = [...CJK_TEXT].filter((ch) => !targets.has(ch.codePointAt(0)!)); + expect(missing, 'Chinese characters printed as empty boxes (no embedded glyph maps to them)').toEqual([]); + }, 60_000); + + it('a page that never shows a print root is refused TIMEOUT / 504, not printed', async () => { + const r = await refusalOf( + pick().driver.renderPdf({ + url: `${fixture.origin}${RECORD_PATH}`, + cookie: { ...COOKIE, value: 'a-session-the-console-does-not-know' }, + timeoutMs: 3_000, + }), + ); + expect([r.code, r.status]).toEqual(['TIMEOUT', 504]); + expect(r.message).not.toContain('a-session-the-console-does-not-know'); + }, 60_000); + }); + } + + it('remote: the render leaves no context (and so no session cookie) in a browser it does not own, and leaves it running', async () => { + const driver = placements().find((p) => p.label === 'remote')!.driver; + await driver.renderPdf({ url: `${fixture.origin}${RECORD_PATH}`, cookie: COOKIE, timeoutMs: 30_000 }); + expect(remote.child.exitCode).toBeNull(); + const { chromium } = (await import('playwright-core')) as unknown as { + chromium: { connectOverCDP(e: string): Promise<{ contexts(): Array<{ cookies(): Promise> }>; close(): Promise }> }; + }; + const browser = await chromium.connectOverCDP(remote.endpoint); + try { + for (const context of browser.contexts()) { + const cookies = await context.cookies(); + expect(cookies.map((c) => c.value)).not.toContain(COOKIE.value); + } + } finally { + await browser.close(); + } + }, 60_000); +}); diff --git a/packages/services/service-print/src/chromium-driver.ts b/packages/services/service-print/src/chromium-driver.ts index c0bb6696bdd..2dd8afa15f8 100644 --- a/packages/services/service-print/src/chromium-driver.ts +++ b/packages/services/service-print/src/chromium-driver.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describeEndpoint, type PrintPlacement, type ResolvedPrintPlacement } from './config.js'; -import { printRefusal } from './refusal.js'; +import { refusalError } from './refusal.js'; import type { PrintSessionCookie } from './principal-session.js'; /** @@ -194,7 +194,7 @@ class PlaywrightChromiumDriver implements ChromiumDriver { return await page.pdf({ preferCSSPageSize: true, printBackground: true }); } catch (err) { if (isTimeout(err)) { - printRefusal( + throw refusalError( 'TIMEOUT', 504, `The print page did not finish loading within ${Math.round(input.timeoutMs / 1000)} s at ${input.url}: ` + @@ -202,7 +202,7 @@ class PlaywrightChromiumDriver implements ChromiumDriver { 'in flight). Check that the console is served at that URL and reachable from the browser.', ); } - printRefusal( + throw refusalError( 'INTERNAL_ERROR', 500, `The print engine failed at ${this.where()}: ${redact(String((err as Error)?.message ?? err), secrets)}`, diff --git a/packages/services/service-print/src/config.test.ts b/packages/services/service-print/src/config.test.ts new file mode 100644 index 00000000000..a64223e1f9c --- /dev/null +++ b/packages/services/service-print/src/config.test.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; +import { + describeEndpoint, + PRINT_BROWSER_ENDPOINT_ENV, + PRINT_CHROMIUM_PATH_ENV, + PRINT_CONSOLE_URL_ENV, + resolvePrintPlacement, +} from './config.js'; + +const CONSOLE = 'http://127.0.0.1:3000/_console'; +const SECRET_ENDPOINT = 'wss://browser.example.com/devtools/browser/abc?token=s3cr3t-value'; + +describe('resolvePrintPlacement — one engine, two placements', () => { + it('nothing configured is unconfigured, and the reason names every variable to set', () => { + const r = resolvePrintPlacement({}, {}); + expect(r.state).toBe('unconfigured'); + if (r.state !== 'unconfigured') return; + for (const name of [PRINT_CHROMIUM_PATH_ENV, PRINT_BROWSER_ENDPOINT_ENV, PRINT_CONSOLE_URL_ENV]) { + expect(r.reason).toContain(name); + } + }); + + it('a blank value is not a value', () => { + expect(resolvePrintPlacement({}, { [PRINT_CHROMIUM_PATH_ENV]: ' ' }).state).toBe('unconfigured'); + }); + + it('a local executable from the environment', () => { + const r = resolvePrintPlacement({}, { [PRINT_CHROMIUM_PATH_ENV]: '/usr/bin/chromium', [PRINT_CONSOLE_URL_ENV]: `${CONSOLE}/` }); + expect(r).toEqual({ + state: 'configured', + resolved: { placement: 'local', target: '/usr/bin/chromium', consoleUrl: CONSOLE }, + }); + }); + + it('a remote endpoint from the options, and an option wins over its variable', () => { + const r = resolvePrintPlacement( + { browserEndpoint: SECRET_ENDPOINT, consoleUrl: CONSOLE }, + { [PRINT_CONSOLE_URL_ENV]: 'http://elsewhere/_console' }, + ); + expect(r).toEqual({ + state: 'configured', + resolved: { placement: 'remote', target: SECRET_ENDPOINT, consoleUrl: CONSOLE }, + }); + }); + + it('both placements at once is misconfigured, never a silent pick', () => { + const r = resolvePrintPlacement({ chromiumPath: '/usr/bin/chromium' }, { [PRINT_BROWSER_ENDPOINT_ENV]: SECRET_ENDPOINT, [PRINT_CONSOLE_URL_ENV]: CONSOLE }); + expect(r.state).toBe('misconfigured'); + if (r.state === 'misconfigured') expect(r.placement).toBeNull(); + }); + + it('a remote endpoint that is not ws:// or wss:// is misconfigured, and its text never reaches the reason', () => { + const r = resolvePrintPlacement({ browserEndpoint: 'https://browser.example.com/x?token=s3cr3t-value', consoleUrl: CONSOLE }, {}); + expect(r.state).toBe('misconfigured'); + if (r.state !== 'misconfigured') return; + expect(r.reason).toContain('https://browser.example.com'); + expect(r.reason).not.toContain('s3cr3t-value'); + }); + + it('an engine with no console URL is misconfigured, on either placement', () => { + for (const options of [{ chromiumPath: '/usr/bin/chromium' }, { browserEndpoint: SECRET_ENDPOINT }]) { + const r = resolvePrintPlacement(options, {}); + expect(r.state).toBe('misconfigured'); + if (r.state === 'misconfigured') expect(r.reason).toContain(PRINT_CONSOLE_URL_ENV); + } + }); + + it('a console URL that is not http(s), or carries a query, is misconfigured', () => { + for (const consoleUrl of ['ftp://host/_console', 'not a url', `${CONSOLE}?x=1`]) { + expect(resolvePrintPlacement({ chromiumPath: '/c', consoleUrl }, {}).state).toBe('misconfigured'); + } + }); +}); + +describe('describeEndpoint — the endpoint as a log line may name it', () => { + it('keeps the scheme and host, drops the path and the token', () => { + expect(describeEndpoint(SECRET_ENDPOINT)).toBe('wss://browser.example.com'); + }); + + it('describes an unparseable endpoint without echoing it', () => { + expect(describeEndpoint('token=s3cr3t-value')).not.toContain('s3cr3t'); + }); +}); diff --git a/packages/services/service-print/src/print-service-plugin.test.ts b/packages/services/service-print/src/print-service-plugin.test.ts new file mode 100644 index 00000000000..893303dda05 --- /dev/null +++ b/packages/services/service-print/src/print-service-plugin.test.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { PluginContext } from '@objectstack/core'; +import { PRINT_SERVICE, PrintServicePlugin } from './print-service-plugin.js'; +import type { PrintService } from './print-service.js'; +import { PRINT_BROWSER_ENDPOINT_ENV, PRINT_CHROMIUM_PATH_ENV, PRINT_CONSOLE_URL_ENV } from './config.js'; + +/** + * A deployment learns at `start()` — never at its first print — that its + * engine is absent, misconfigured or unreachable: one `warn` naming the + * remedy. The unreachable cases drive the real `playwright-core` against a path + * and an endpoint that answer nothing, so the reason is the library's own. + * The ready case, which needs a real Chromium, is in `chromium-driver.test.ts`. + */ + +const SECRET = 's3cr3t-token-value'; +const ENV_KEYS = [PRINT_CHROMIUM_PATH_ENV, PRINT_BROWSER_ENDPOINT_ENV, PRINT_CONSOLE_URL_ENV]; + +interface Logged { + level: 'info' | 'warn' | 'error'; + message: string; +} + +function fakeContext(): { ctx: PluginContext; logged: Logged[]; services: Map; ready: () => Promise } { + const logged: Logged[] = []; + const services = new Map(); + const hooks: Array<() => Promise> = []; + const ctx = { + logger: { + info: (message: string) => logged.push({ level: 'info', message }), + warn: (message: string) => logged.push({ level: 'warn', message }), + error: (message: string) => logged.push({ level: 'error', message }), + debug: () => undefined, + }, + 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); + }, + } as unknown as PluginContext; + return { ctx, logged, services, ready: async () => { for (const h of hooks) await h(); } }; +} + +const saved: Record = {}; +beforeEach(() => { + for (const k of ENV_KEYS) { + saved[k] = process.env[k]; + delete process.env[k]; + } +}); +afterEach(() => { + for (const k of ENV_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } +}); + +async function boot(options: ConstructorParameters[0]) { + const f = fakeContext(); + const plugin = new PrintServicePlugin(options); + await plugin.init(f.ctx); + await plugin.start(f.ctx); + return { ...f, service: f.services.get(PRINT_SERVICE) as PrintService }; +} + +describe('PrintServicePlugin.start — absence is loud, once', () => { + it('registers the print service on every path', async () => { + const { service } = await boot({}); + expect(typeof service.printRecordToFile).toBe('function'); + expect(typeof service.status).toBe('function'); + }); + + it('no engine configured: one warn naming the variables; status unconfigured', async () => { + const { logged, service } = await boot({}); + const warns = logged.filter((l) => l.level === 'warn'); + expect(warns).toHaveLength(1); + expect(warns[0]!.message).toContain(PRINT_CHROMIUM_PATH_ENV); + expect(warns[0]!.message).toContain(PRINT_BROWSER_ENDPOINT_ENV); + expect(service.status()).toMatchObject({ state: 'unconfigured', placement: null }); + }); + + it('the environment configures it as the options do', async () => { + process.env[PRINT_CHROMIUM_PATH_ENV] = '/nonexistent/chromium-for-print-test'; + const { service } = await boot({}); + expect(service.status()).toMatchObject({ placement: 'local' }); + }); + + it('both placements: one warn, status misconfigured', async () => { + const { logged, service } = await boot({ + chromiumPath: '/usr/bin/chromium', + browserEndpoint: `ws://127.0.0.1:9/devtools/browser/x?token=${SECRET}`, + consoleUrl: 'http://127.0.0.1:3000/_console', + }); + expect(logged.filter((l) => l.level === 'warn')).toHaveLength(1); + expect(service.status().state).toBe('misconfigured'); + expect(JSON.stringify(logged)).not.toContain(SECRET); + }); + + it('a local executable that does not exist: one warn naming the path; status unavailable', async () => { + const path = '/nonexistent/chromium-for-print-test'; + const { logged, service } = await boot({ chromiumPath: path, consoleUrl: 'http://127.0.0.1:3000/_console' }); + const warns = logged.filter((l) => l.level === 'warn'); + expect(warns).toHaveLength(1); + expect(warns[0]!.message).toContain(path); + expect(service.status()).toMatchObject({ state: 'unavailable', placement: 'local' }); + expect(logged.some((l) => l.level === 'info')).toBe(false); + }, 30_000); + + it('a remote endpoint nobody answers: one warn naming its host, never its token', async () => { + const { logged, service } = await boot({ + browserEndpoint: `ws://127.0.0.1:9/devtools/browser/x?token=${SECRET}`, + consoleUrl: 'http://127.0.0.1:3000/_console', + }); + const warns = logged.filter((l) => l.level === 'warn'); + expect(warns).toHaveLength(1); + expect(warns[0]!.message).toContain('ws://127.0.0.1:9'); + expect(JSON.stringify(logged)).not.toContain(SECRET); + const status = service.status(); + expect(status).toMatchObject({ state: 'unavailable', placement: 'remote' }); + expect(JSON.stringify(status)).not.toContain(SECRET); + }, 30_000); +}); diff --git a/packages/services/service-print/src/print-service.test.ts b/packages/services/service-print/src/print-service.test.ts new file mode 100644 index 00000000000..18e7c0e9832 --- /dev/null +++ b/packages/services/service-print/src/print-service.test.ts @@ -0,0 +1,372 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it, vi } from 'vitest'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import type { ChromiumDriver } from './chromium-driver.js'; +import type { PrintPlacementResolution } from './config.js'; +import type { PrincipalSessionMinter, PrintSession } from './principal-session.js'; +import { principalSessionMinterOf } from './principal-session.js'; +import { + PRINT_RENDER_TIMEOUT_MS, + PRINT_SESSION_TTL_SECONDS, + PrintService, + type PrintRecordRequest, +} from './print-service.js'; +import { refusalError } from './refusal.js'; + +const CONSOLE = 'http://127.0.0.1:3000/_console'; +const COOKIE_VALUE = 'minted-cookie-value.signature'; +const TOKEN = 'minted-bearer-token'; +const PDF = Buffer.from('%PDF-1.4 fake'); + +const USER: ExecutionContext = { userId: 'usr_alice', tenantId: 'org_1', principalKind: 'human' } as ExecutionContext; +const REQUEST: PrintRecordRequest = { app: 'showcase', object: 'invoice', recordId: 'inv_1', page: 'invoice_print' }; + +const PRINT_PAGE = { + name: 'invoice_print', + type: 'record', + object: 'invoice', + isDefault: true, + print: { paperSize: 'A4' }, +}; + +interface Harness { + service: PrintService; + driver: { probe: ReturnType; renderPdf: ReturnType }; + mint: ReturnType; + revoke: ReturnType; + attachFile: ReturnType; + findOne: ReturnType; + metadataGet: ReturnType; +} + +interface Setup { + placement?: PrintPlacementResolution; + page?: unknown; + object?: unknown; + record?: unknown; + readError?: unknown; + probeError?: Error; + renderError?: unknown; + attachError?: unknown; + noMint?: boolean; + noAttachments?: boolean; +} + +const configured: PrintPlacementResolution = { + state: 'configured', + resolved: { placement: 'local', target: '/usr/bin/chromium', consoleUrl: CONSOLE }, +}; + +async function harness(setup: Setup = {}): Promise { + const placement = setup.placement ?? configured; + const revoke = vi.fn(async () => undefined); + const session: PrintSession = { + token: TOKEN, + cookie: { name: 'os.session_token', value: COOKIE_VALUE, path: '/', httpOnly: true, secure: false, sameSite: 'lax' }, + revoke, + }; + const mint = vi.fn(async () => session); + const driver = { + placement: 'local' as const, + probe: vi.fn(async () => { + if (setup.probeError) throw setup.probeError; + return { version: '141.0.7390.37' }; + }), + renderPdf: vi.fn(async () => { + if (setup.renderError) throw setup.renderError; + return PDF; + }), + }; + const attachFile = vi.fn(async (input: { data: Buffer }) => { + if (setup.attachError) throw setup.attachError; + return { fileId: 'file_1', attachmentId: 'att_1', size: input.data.byteLength }; + }); + const findOne = vi.fn(async () => { + if (setup.readError) throw setup.readError; + return 'record' in setup ? setup.record : { id: 'inv_1' }; + }); + const metadataGet = vi.fn(async (_type: string, name: string) => + 'page' in setup ? setup.page : name === PRINT_PAGE.name ? PRINT_PAGE : undefined, + ); + const minter: PrincipalSessionMinter = { mint }; + const service = new PrintService({ + placement, + driver: placement.state === 'configured' ? (driver as ChromiumDriver) : null, + getMetadata: () => ({ + get: metadataGet, + getObject: async () => ('object' in setup ? setup.object : { name: 'invoice', enable: { files: true } }), + }), + getEngine: () => ({ findOne }) as never, + getAttachments: () => (setup.noAttachments ? undefined : ({ attachFile } as never)), + getMinter: () => (setup.noMint ? undefined : minter), + }); + await service.probe(); + return { service, driver, mint, revoke, attachFile, findOne, metadataGet }; +} + +/** The refusal's ADR-0112 envelope: `code` and `status`. */ +async function refusalOf(p: Promise): Promise<{ code: unknown; status: unknown; message: string }> { + try { + await p; + } catch (err) { + const e = err as { code?: unknown; status?: unknown; message: string }; + return { code: e.code, status: e.status, message: e.message }; + } + throw new Error('expected a refusal, the call resolved'); +} + +/** Nothing was minted, rendered or stored. */ +function expectNothingStarted(h: Harness): void { + expect(h.mint).not.toHaveBeenCalled(); + expect(h.driver.renderPdf).not.toHaveBeenCalled(); + expect(h.attachFile).not.toHaveBeenCalled(); +} + +describe('PrintService — the principal (step ①)', () => { + const cases: Array<[string, unknown]> = [ + ['no context', undefined], + ['the system context', { isSystem: true, userId: 'usr_alice' }], + ['a context with no user', { tenantId: 'org_1' }], + ['an agent principal', { userId: 'usr_alice', principalKind: 'agent' }], + ['a service principal', { userId: 'usr_alice', principalKind: 'service' }], + ['a guest principal', { userId: 'usr_alice', principalKind: 'guest' }], + ['a system principal kind', { userId: 'usr_alice', principalKind: 'system' }], + ['a user held by an authentication policy', { userId: 'usr_alice', authGate: { code: 'MFA_REQUIRED', message: 'enroll' } }], + ]; + for (const [label, context] of cases) { + it(`refuses ${label}: PERMISSION_DENIED / 403, before anything is minted or launched`, async () => { + const h = await harness(); + const r = await refusalOf(h.service.printRecordToFile(REQUEST, context as ExecutionContext)); + expect([r.code, r.status]).toEqual(['PERMISSION_DENIED', 403]); + expectNothingStarted(h); + expect(h.findOne).not.toHaveBeenCalled(); + }); + } + + it('a principal refusal wins over an absent engine (the ruled order)', async () => { + const h = await harness({ placement: { state: 'unconfigured', reason: 'none' } }); + const r = await refusalOf(h.service.printRecordToFile(REQUEST, { isSystem: true } as ExecutionContext)); + expect([r.code, r.status]).toEqual(['PERMISSION_DENIED', 403]); + }); +}); + +describe('PrintService — the engine (step ②)', () => { + it('no placement configured: SERVICE_UNAVAILABLE / 503 naming the reason; the page is never read', async () => { + const h = await harness({ placement: { state: 'unconfigured', reason: 'Set OS_PRINT_CHROMIUM_PATH.' } }); + const r = await refusalOf(h.service.printRecordToFile(REQUEST, USER)); + expect([r.code, r.status]).toEqual(['SERVICE_UNAVAILABLE', 503]); + expect(r.message).toContain('OS_PRINT_CHROMIUM_PATH'); + expect(h.metadataGet).not.toHaveBeenCalled(); + expectNothingStarted(h); + }); + + it('a misconfigured placement: SERVICE_UNAVAILABLE / 503', async () => { + const h = await harness({ placement: { state: 'misconfigured', placement: 'remote', reason: 'bad endpoint' } }); + const r = await refusalOf(h.service.printRecordToFile(REQUEST, USER)); + expect([r.code, r.status]).toEqual(['SERVICE_UNAVAILABLE', 503]); + expectNothingStarted(h); + }); + + it('an unreachable engine is probed once more per call, then refused 503', async () => { + const h = await harness({ probeError: new Error('connect ECONNREFUSED') }); + expect(h.service.status()).toMatchObject({ state: 'unavailable', placement: 'local' }); + const r = await refusalOf(h.service.printRecordToFile(REQUEST, USER)); + expect([r.code, r.status]).toEqual(['SERVICE_UNAVAILABLE', 503]); + expect(h.driver.probe).toHaveBeenCalledTimes(2); + expectNothingStarted(h); + }); + + it('no principal-session mint on the auth service: unavailable, 503, and status says why', async () => { + const h = await harness({ noMint: true }); + expect(h.service.status()).toMatchObject({ state: 'unavailable', version: '141.0.7390.37' }); + expect(h.service.status().reason).toContain('mintPrincipalSession'); + const r = await refusalOf(h.service.printRecordToFile(REQUEST, USER)); + expect([r.code, r.status]).toEqual(['SERVICE_UNAVAILABLE', 503]); + expectNothingStarted(h); + }); + + it('no file-attachments service: unavailable, 503', async () => { + const h = await harness({ noAttachments: true }); + expect(h.service.status().reason).toContain('file-attachments'); + const r = await refusalOf(h.service.printRecordToFile(REQUEST, USER)); + expect([r.code, r.status]).toEqual(['SERVICE_UNAVAILABLE', 503]); + expectNothingStarted(h); + }); + + it('a ready engine reports its placement and version', async () => { + const h = await harness(); + expect(h.service.status()).toEqual({ state: 'ready', placement: 'local', version: '141.0.7390.37' }); + }); +}); + +describe('PrintService — the page (step ③)', () => { + it('a malformed request: VALIDATION_ERROR / 400', async () => { + const h = await harness(); + for (const bad of [{ ...REQUEST, app: '' }, { ...REQUEST, recordId: 7 }, { ...REQUEST, filename: '' }]) { + const r = await refusalOf(h.service.printRecordToFile(bad as PrintRecordRequest, USER)); + expect([r.code, r.status]).toEqual(['VALIDATION_ERROR', 400]); + } + expectNothingStarted(h); + }); + + it('an unknown page: RESOURCE_NOT_FOUND / 404, and the record is never read', async () => { + const h = await harness({ page: undefined }); + const r = await refusalOf(h.service.printRecordToFile(REQUEST, USER)); + expect([r.code, r.status]).toEqual(['RESOURCE_NOT_FOUND', 404]); + expect(h.findOne).not.toHaveBeenCalled(); + expectNothingStarted(h); + }); + + const invalid: Array<[string, unknown]> = [ + ['a page of another object', { ...PRINT_PAGE, object: 'account' }], + ['a page that does not declare print', { ...PRINT_PAGE, print: undefined }], + ['a print page that is not the default record page', { ...PRINT_PAGE, isDefault: undefined }], + ['a default page that is not a record page', { ...PRINT_PAGE, type: 'home' }], + ]; + for (const [label, page] of invalid) { + it(`${label}: VALIDATION_ERROR / 400, and the record is never read`, async () => { + const h = await harness({ page }); + const r = await refusalOf(h.service.printRecordToFile(REQUEST, USER)); + expect([r.code, r.status]).toEqual(['VALIDATION_ERROR', 400]); + expect(h.findOne).not.toHaveBeenCalled(); + expectNothingStarted(h); + }); + } +}); + +describe('PrintService — the record, read as the caller (step ④)', () => { + it('reads the record with the caller’s own context, never a system one', async () => { + const h = await harness(); + await h.service.printRecordToFile(REQUEST, USER); + expect(h.findOne).toHaveBeenCalledWith('invoice', { where: { id: 'inv_1' } }, { context: USER }); + }); + + it('a record the caller cannot see: RECORD_NOT_FOUND / 404', async () => { + const h = await harness({ record: null }); + const r = await refusalOf(h.service.printRecordToFile(REQUEST, USER)); + expect([r.code, r.status]).toEqual(['RECORD_NOT_FOUND', 404]); + expectNothingStarted(h); + }); + + it('a read refused 403 answers the same 404, so the answer discloses nothing', async () => { + const h = await harness({ readError: refusalError('PERMISSION_DENIED', 403, 'no read on invoice') }); + const r = await refusalOf(h.service.printRecordToFile(REQUEST, USER)); + expect([r.code, r.status]).toEqual(['RECORD_NOT_FOUND', 404]); + expectNothingStarted(h); + }); + + it('a read that fails for another reason is not disguised', async () => { + const h = await harness({ readError: refusalError('DATABASE_ERROR', 500, 'down') }); + const r = await refusalOf(h.service.printRecordToFile(REQUEST, USER)); + expect([r.code, r.status]).toEqual(['DATABASE_ERROR', 500]); + expectNothingStarted(h); + }); +}); + +describe('PrintService — the files opt-in (step ⑤)', () => { + it('an object without enable.files: FILES_DISABLED / 403, before any render', async () => { + const h = await harness({ object: { name: 'invoice', enable: {} } }); + const r = await refusalOf(h.service.printRecordToFile(REQUEST, USER)); + expect([r.code, r.status]).toEqual(['FILES_DISABLED', 403]); + expectNothingStarted(h); + }); + + it('an unreadable record wins over a files refusal (the ruled order)', async () => { + const h = await harness({ record: null, object: { enable: {} } }); + const r = await refusalOf(h.service.printRecordToFile(REQUEST, USER)); + expect(r.code).toBe('RECORD_NOT_FOUND'); + }); +}); + +describe('PrintService — mint, render, archive, revoke', () => { + it('mints for the caller with the TTL, renders the record URL with the cookie, archives the PDF', async () => { + const h = await harness(); + const out = await h.service.printRecordToFile(REQUEST, USER); + expect(h.mint).toHaveBeenCalledWith(USER, PRINT_SESSION_TTL_SECONDS); + expect(PRINT_SESSION_TTL_SECONDS * 1000).toBeGreaterThan(PRINT_RENDER_TIMEOUT_MS); + expect(PRINT_SESSION_TTL_SECONDS).toBeLessThanOrEqual(120); + expect(h.driver.renderPdf).toHaveBeenCalledWith({ + url: `${CONSOLE}/apps/showcase/invoice/record/inv_1`, + cookie: expect.objectContaining({ value: COOKIE_VALUE }), + timeoutMs: PRINT_RENDER_TIMEOUT_MS, + }); + expect(h.attachFile).toHaveBeenCalledWith({ + parentObject: 'invoice', + parentId: 'inv_1', + organizationId: 'org_1', + filename: 'invoice_print-inv_1.pdf', + mimeType: 'application/pdf', + data: PDF, + }); + expect(h.revoke).toHaveBeenCalledTimes(1); + expect(out).toEqual({ fileId: 'file_1', attachmentId: 'att_1', size: PDF.byteLength, filename: 'invoice_print-inv_1.pdf' }); + }); + + it('the mint happens only after every refusal check, and the revoke after the archive', async () => { + const h = await harness(); + await h.service.printRecordToFile(REQUEST, USER); + const order = (fn: ReturnType): number => fn.mock.invocationCallOrder[0]!; + expect(order(h.findOne)).toBeLessThan(order(h.mint)); + expect(order(h.mint)).toBeLessThan(order(h.driver.renderPdf)); + expect(order(h.driver.renderPdf)).toBeLessThan(order(h.attachFile)); + expect(order(h.attachFile)).toBeLessThan(order(h.revoke)); + }); + + it('a caller with no organization archives in none', async () => { + const h = await harness(); + await h.service.printRecordToFile({ ...REQUEST, filename: 'copy.pdf' }, { userId: 'usr_alice' } as ExecutionContext); + expect(h.attachFile).toHaveBeenCalledWith(expect.objectContaining({ organizationId: null, filename: 'copy.pdf' })); + }); + + it('a failed render still revokes the session, and the failure is passed on unchanged', async () => { + const h = await harness({ renderError: refusalError('TIMEOUT', 504, 'the print page did not load') }); + const r = await refusalOf(h.service.printRecordToFile(REQUEST, USER)); + expect([r.code, r.status]).toEqual(['TIMEOUT', 504]); + expect(h.revoke).toHaveBeenCalledTimes(1); + expect(h.attachFile).not.toHaveBeenCalled(); + }); + + it('a refused archive still revokes the session', async () => { + const h = await harness({ attachError: refusalError('PAYLOAD_TOO_LARGE', 413, 'too large') }); + const r = await refusalOf(h.service.printRecordToFile(REQUEST, USER)); + expect([r.code, r.status]).toEqual(['PAYLOAD_TOO_LARGE', 413]); + expect(h.revoke).toHaveBeenCalledTimes(1); + }); + + it('a mint refusal is passed on, and nothing is rendered', async () => { + const h = await harness(); + h.mint.mockRejectedValueOnce(refusalError('PERMISSION_DENIED', 403, 'banned')); + const r = await refusalOf(h.service.printRecordToFile(REQUEST, USER)); + expect([r.code, r.status]).toEqual(['PERMISSION_DENIED', 403]); + expect(h.driver.renderPdf).not.toHaveBeenCalled(); + expect(h.revoke).not.toHaveBeenCalled(); + }); + + it('the minted credential appears in no refusal message', async () => { + const h = await harness({ renderError: refusalError('INTERNAL_ERROR', 500, 'engine crashed') }); + const r = await refusalOf(h.service.printRecordToFile(REQUEST, USER)); + expect(r.message).not.toContain(COOKIE_VALUE); + expect(r.message).not.toContain(TOKEN); + }); +}); + +describe('principalSessionMinterOf — the one read of the auth service’s mint', () => { + it('absent auth, or an auth service without the member, has no mint', () => { + expect(principalSessionMinterOf(undefined)).toBeUndefined(); + expect(principalSessionMinterOf({ verify: () => undefined })).toBeUndefined(); + }); + + it('calls the member on the auth service with the caller’s context and the TTL', async () => { + const auth = { + seen: [] as unknown[], + async mintPrincipalSession(this: { seen: unknown[] }, ctx: unknown, opts: unknown) { + this.seen.push(ctx, opts); + return { token: 't', cookie: { name: 'n', value: 'v', path: '/', httpOnly: true, secure: true }, revoke: async () => {} }; + }, + }; + const minter = principalSessionMinterOf(auth)!; + await minter.mint(USER, 60); + expect(auth.seen).toEqual([USER, { ttlSeconds: 60 }]); + }); +}); diff --git a/packages/services/service-print/src/refusal.ts b/packages/services/service-print/src/refusal.ts index 34a618e841d..614ef0f29e7 100644 --- a/packages/services/service-print/src/refusal.ts +++ b/packages/services/service-print/src/refusal.ts @@ -11,8 +11,13 @@ export type PrintRefusal = Error & { code: StandardErrorCode | RegisteredErrorCo * wire vocabulary of its own. */ export function printRefusal(code: StandardErrorCode | RegisteredErrorCode, status: number, message: string): never { + throw refusalError(code, status, message); +} + +/** The refusal {@link printRefusal} throws, for a caller that throws it itself. */ +export function refusalError(code: StandardErrorCode | RegisteredErrorCode, status: number, message: string): PrintRefusal { const err = new Error(message) as PrintRefusal; err.code = code; err.status = status; - throw err; + return err; } From 025d3acd2c7c32c9181cde1265a28747fd546a3b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 02:12:50 +0000 Subject: [PATCH 03/21] feat(example-showcase): an invoice print page, and files on invoices for its archived PDF Claude-Session: https://claude.ai/code/session_01W2XFCTBfK4LabgqovmvSgK Co-authored-by: Claude --- examples/app-showcase/objectstack.config.ts | 4 +- .../src/data/objects/invoice.object.ts | 4 ++ examples/app-showcase/src/ui/pages/index.ts | 1 + .../src/ui/pages/invoice-document.page.ts | 63 +++++++++++++++++++ 4 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 examples/app-showcase/src/ui/pages/invoice-document.page.ts diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index c52f2dd027e..68b8c8a5c38 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -24,7 +24,7 @@ import { ChartGalleryDashboard, OpsDashboard, RevenuePulseDashboard } from './sr import { ShowcaseTaskDataset, ShowcaseProjectDataset, ShowcaseInvoiceDataset, ShowcaseAccountDataset } from './src/ui/datasets/index.js'; import { allReports } from './src/ui/reports/index.js'; import { allActions } from './src/ui/actions/index.js'; -import { CapabilityMapPage, StartHerePage, ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, CommandCenterJsxPage, CrmWorkbenchPage, TaskDeskPage, PageVariablesPage, ContactFormPage, RenewalsPipelinePage } from './src/ui/pages/index.js'; +import { CapabilityMapPage, StartHerePage, ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, InvoiceDocumentPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, CommandCenterJsxPage, CrmWorkbenchPage, TaskDeskPage, PageVariablesPage, ContactFormPage, RenewalsPipelinePage } from './src/ui/pages/index.js'; import { allFlows } from './src/automation/flows/index.js'; import { allWebhooks } from './src/automation/webhooks/index.js'; import { allHooks } from './src/data/hooks/index.js'; @@ -202,7 +202,7 @@ export default defineStack({ // UI apps: [ShowcaseApp], views: [TaskViews, ProjectViews, InquiryViews, BusinessUnitViews, ContactViews, FieldZooViews], - pages: [CapabilityMapPage, StartHerePage, ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, CommandCenterJsxPage, CrmWorkbenchPage, TaskDeskPage, PageVariablesPage, ContactFormPage, RenewalsPipelinePage], + pages: [CapabilityMapPage, StartHerePage, ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, InvoiceDocumentPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, CommandCenterJsxPage, CrmWorkbenchPage, TaskDeskPage, PageVariablesPage, ContactFormPage, RenewalsPipelinePage], dashboards: [ChartGalleryDashboard, OpsDashboard, RevenuePulseDashboard], books: allBooks, datasets: [ShowcaseTaskDataset, ShowcaseProjectDataset, ShowcaseInvoiceDataset, ShowcaseAccountDataset], diff --git a/examples/app-showcase/src/data/objects/invoice.object.ts b/examples/app-showcase/src/data/objects/invoice.object.ts index 631070aa116..c89347b7cff 100644 --- a/examples/app-showcase/src/data/objects/invoice.object.ts +++ b/examples/app-showcase/src/data/objects/invoice.object.ts @@ -46,6 +46,10 @@ export const Invoice = ObjectSchema.create({ icon: 'receipt', description: 'A customer invoice entered together with its line items.', + // Files on an invoice: its printed PDF is archived as an attachment of the + // record (`showcase_invoice_document` is its print page). + enable: { files: true }, + // Per-record row-action gating (objectui#2614) — a PAID invoice is frozen: // its fields already lock via `readonlyWhen: record.status == 'paid'` below, // and here the built-in row buttons follow the same truth instead of diff --git a/examples/app-showcase/src/ui/pages/index.ts b/examples/app-showcase/src/ui/pages/index.ts index 31a5b32a2cd..3f297ffc6a8 100644 --- a/examples/app-showcase/src/ui/pages/index.ts +++ b/examples/app-showcase/src/ui/pages/index.ts @@ -10,6 +10,7 @@ export { TaskWorkbenchPage } from './task-workbench.page.js'; export { TaskTriagePage } from './task-triage.page.js'; export { ActiveProjectsPage } from './active-projects.page.js'; export { TaskDetailPage } from './task-detail.page.js'; +export { InvoiceDocumentPage } from './invoice-document.page.js'; export { ReviewQueuePage } from './review-queue.page.js'; export { NewProjectWizardPage } from './new-project-wizard.page.js'; export { MyWorkPage } from './my-work.page.js'; diff --git a/examples/app-showcase/src/ui/pages/invoice-document.page.ts b/examples/app-showcase/src/ui/pages/invoice-document.page.ts new file mode 100644 index 00000000000..133933fb750 --- /dev/null +++ b/examples/app-showcase/src/ui/pages/invoice-document.page.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { definePage } from '@objectstack/spec/ui'; + +/** + * Invoice Document — the invoice's record page, and a PRINT page: it declares + * `print`, so the console's Print action prints it through the browser, and + * `@objectstack/service-print` renders the same page in headless Chromium, as + * 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 + * (`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. + */ +export const InvoiceDocumentPage = definePage({ + name: 'showcase_invoice_document', + label: 'Invoice', + type: 'record', + object: 'showcase_invoice', + kind: 'full', + isDefault: true, + print: { + paperSize: 'A4', + orientation: 'portrait', + margins: { top: 18, bottom: 18, left: 15, right: 15 }, + repeatHeader: true, + repeatFooter: true, + pageNumbers: true, + avoidBreakInside: true, + }, + regions: [ + { + name: 'header', + components: [ + { type: 'element:text', properties: { content: 'ObjectStack Showcase Ltd · 示例有限公司' } }, + { type: 'element:divider' }, + ], + }, + { + name: 'main', + components: [ + { type: 'record:details' }, + { + type: 'record:line_items', + properties: { + childObject: 'showcase_invoice_line', + relationshipField: 'invoice', + columns: [{ name: 'description' }, { name: 'quantity' }, { name: 'unit_price' }, { name: 'amount' }], + readonly: true, + }, + }, + ], + }, + { + name: 'footer', + components: [{ type: 'element:text', properties: { content: 'Thank you for your business · 感谢惠顾' } }], + }, + ], +}); From b600e420ff16f8fa1f82abb2952f0a20fbc5e128 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 02:22:56 +0000 Subject: [PATCH 04/21] feat(service-print): read pages and objects from the engine's schema registry; a print dogfood test on the showcase Claude-Session: https://claude.ai/code/session_01W2XFCTBfK4LabgqovmvSgK Co-authored-by: Claude --- .../print-service-archive.dogfood.test.ts | 217 ++++++++++++++++++ .../service-print/src/print-service-plugin.ts | 13 +- .../service-print/src/print-service.test.ts | 15 +- .../service-print/src/print-service.ts | 36 +-- 4 files changed, 248 insertions(+), 33 deletions(-) create mode 100644 packages/qa/dogfood/test/print-service-archive.dogfood.test.ts diff --git a/packages/qa/dogfood/test/print-service-archive.dogfood.test.ts b/packages/qa/dogfood/test/print-service-archive.dogfood.test.ts new file mode 100644 index 00000000000..14b69888ef1 --- /dev/null +++ b/packages/qa/dogfood/test/print-service-archive.dogfood.test.ts @@ -0,0 +1,217 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The print service on a real showcase boot: `PrintServicePlugin` composed +// explicitly beside the real storage and audit plugins, and its refusal chain +// and archive judged by the REAL services a deployment composes — the +// metadata service's pages and objects, the data engine's row-level security +// read as the caller, and `file-attachments`' one writer of `sys_file` and +// `sys_attachment`. +// +// Two layers, on the same boot: +// +// - **The plugin as composed** (no engine configured here): `status()` says +// so, and a print is refused `503` after the principal check and before any +// page is read. +// - **The service over the real kernel services**, with the two parts that +// need a browser or the auth mint stood in by recording doubles: the page, +// record and files refusals in the ruled order, and a successful archive — +// the PDF lands as one committed `sys_file` and one `sys_attachment` on the +// invoice, in the caller's organization, with the session revoked after it. +// The engine itself is pinned against a real Chromium on both placements in +// `@objectstack/service-print`'s own suite; the console route it loads is +// the console's. +// +// The boot keeps the showcase's own default profile, as `objectstack serve` +// wires it, and the member holds the app's `showcase_contributor`. The showcase's print page is `showcase_invoice_document`, the invoice's +// default record page; `showcase_invoice` declares `enable.files` for it, and +// its select RLS (`owner == current_user.email`) is what hides another user's +// invoice from a contributor. + +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 } from '../../../services/service-print/src/print-service.js'; + +const SYS = { isSystem: true } as const; +const PAGE = 'showcase_invoice_document'; +const PDF = Buffer.from('%PDF-1.4\n% print-service dogfood\n%%EOF\n'); +const PRINT_ENV = ['OS_PRINT_CHROMIUM_PATH', 'OS_PRINT_BROWSER_ENDPOINT', 'OS_PRINT_CONSOLE_URL']; + +interface Refusal { + code: unknown; + status: unknown; +} + +async function refusalOf(p: Promise): Promise { + try { + await p; + } catch (err) { + return { code: (err as Refusal).code, status: (err as Refusal).status }; + } + throw new Error('expected a refusal, the call resolved'); +} + +describe('print service on a real showcase boot', () => { + let stack: VerifyStack; + let rootDir: string; + let ql: any; + let plugin: any; + let service: PrintService; + const saved: Record = {}; + const revoke = vi.fn(async () => undefined); + const mint = vi.fn(async () => ({ + token: 'stand-in-token', + cookie: { name: 'stand-in', value: 'stand-in-cookie', path: '/', httpOnly: true, secure: false }, + revoke, + })); + const renderPdf = vi.fn(async () => PDF); + let memberToken: string; + let memberEmail: string; + let ownInvoice: string; + let othersInvoice: string; + + beforeAll(async () => { + for (const k of PRINT_ENV) { + saved[k] = process.env[k]; + delete process.env[k]; + } + rootDir = mkdtempSync(join(tmpdir(), 'print-dogfood-')); + stack = await bootShowcase({ + extraPlugins: [ + new StorageServicePlugin({ adapter: 'local', local: { rootDir }, bindToSettings: false }), + new AuditPlugin(), + new PrintServicePlugin(), + ], + }); + ql = stack.kernel.getService('objectql'); + plugin = stack.kernel.getService('print'); + + memberEmail = 'print-member@dogfood.test'; + memberToken = await stack.signUp(memberEmail); + // The app's own `showcase_contributor`: invoice read scoped to the + // caller's own rows (`owner == current_user.email`). + const member = await ql.findOne('sys_user', { where: { email: memberEmail } }, { context: SYS }); + await ql.insert('sys_user_permission_set', { user_id: member.id, permission_set: 'showcase_contributor' }, { context: SYS }); + // Two invoices cloned from a seeded one, differing only in who owns them. + const [seeded] = await ql.find('showcase_invoice', { limit: 1 }, { context: SYS }); + const invoice = (name: string, owner: string) => + ql.insert('showcase_invoice', { name, owner, account: seeded.account, status: 'draft' }, { context: SYS }); + ownInvoice = String((await invoice('INV-PRINT-OWN', memberEmail)).id); + othersInvoice = String((await invoice('INV-PRINT-OTHER', 'someone-else@dogfood.test')).id); + + const k = stack.kernel as any; + const optional = (name: string) => { + try { + return k.getService(name); + } catch { + return undefined; + } + }; + service = new PrintService({ + placement: { + state: 'configured', + resolved: { placement: 'local', target: '/stand-in/chromium', consoleUrl: 'http://127.0.0.1:1/_console' }, + }, + driver: { placement: 'local', probe: async () => ({ version: 'stand-in' }), renderPdf }, + getEngine: () => optional('objectql'), + getAttachments: () => optional('file-attachments'), + getMinter: () => ({ mint }), + }); + await service.probe(); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + 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 = (recordId: string, page = PAGE, object = 'showcase_invoice') => ({ + app: 'showcase', + object, + recordId, + page, + }); + + describe('the plugin as composed, with no engine configured', () => { + it('registers the print service and reports it unconfigured', () => { + expect(plugin.status()).toMatchObject({ state: 'unconfigured', placement: null }); + }); + + it('refuses a print 503 for a signed-in user, and 403 for the system context first', async () => { + const ctx = await stack.contextFor(memberToken); + expect(await refusalOf(plugin.printRecordToFile(request(ownInvoice), ctx))).toEqual({ + code: 'SERVICE_UNAVAILABLE', + status: 503, + }); + expect(await refusalOf(plugin.printRecordToFile(request(ownInvoice), SYS))).toEqual({ + code: 'PERMISSION_DENIED', + status: 403, + }); + }); + }); + + describe('the service over the real kernel services', () => { + it('is ready once the archive, metadata and data services are found', () => { + expect(service.status()).toMatchObject({ state: 'ready' }); + }); + + it('an unknown page: RESOURCE_NOT_FOUND / 404', async () => { + const ctx = await stack.contextFor(memberToken); + expect(await refusalOf(service.printRecordToFile(request(ownInvoice, 'showcase_no_such_page'), ctx))).toEqual({ + code: 'RESOURCE_NOT_FOUND', + status: 404, + }); + }); + + it('a record page that does not declare print: VALIDATION_ERROR / 400', async () => { + const ctx = await stack.contextFor(memberToken); + const [task] = await ql.find('showcase_task', { limit: 1 }, { context: SYS }); + expect( + await refusalOf(service.printRecordToFile(request(String(task.id), 'showcase_task_detail', 'showcase_task'), ctx)), + ).toEqual({ code: 'VALIDATION_ERROR', status: 400 }); + }); + + it('another user’s invoice, hidden by RLS from the caller: RECORD_NOT_FOUND / 404', async () => { + const ctx = await stack.contextFor(memberToken); + // Control: the caller-scoped read really does not see it, and the system does. + expect(await ql.findOne('showcase_invoice', { where: { id: othersInvoice } }, { context: ctx })).toBeNull(); + expect(await ql.findOne('showcase_invoice', { where: { id: othersInvoice } }, { context: SYS })).not.toBeNull(); + mint.mockClear(); + renderPdf.mockClear(); + expect(await refusalOf(service.printRecordToFile(request(othersInvoice), ctx))).toEqual({ + code: 'RECORD_NOT_FOUND', + status: 404, + }); + expect(mint).not.toHaveBeenCalled(); + expect(renderPdf).not.toHaveBeenCalled(); + }); + + it('archives the PDF on the caller’s own invoice as one committed sys_file and one sys_attachment', async () => { + const ctx = await stack.contextFor(memberToken); + mint.mockClear(); + revoke.mockClear(); + const out = await service.printRecordToFile(request(ownInvoice), ctx); + expect(mint).toHaveBeenCalledWith(ctx, 60); + expect(renderPdf).toHaveBeenCalledWith( + expect.objectContaining({ url: `http://127.0.0.1:1/_console/apps/showcase/showcase_invoice/record/${ownInvoice}` }), + ); + expect(revoke).toHaveBeenCalledTimes(1); + expect(out).toMatchObject({ size: PDF.byteLength, filename: `${PAGE}-${ownInvoice}.pdf` }); + + const file = await ql.findOne('sys_file', { where: { id: out.fileId } }, { context: SYS }); + expect(file).toMatchObject({ status: 'committed', mime_type: 'application/pdf', size: PDF.byteLength, scope: 'attachments' }); + const attachment = await ql.findOne('sys_attachment', { where: { id: out.attachmentId } }, { context: SYS }); + expect(attachment).toMatchObject({ parent_object: 'showcase_invoice', parent_id: ownInvoice, file_id: out.fileId }); + }); + }); +}); diff --git a/packages/services/service-print/src/print-service-plugin.ts b/packages/services/service-print/src/print-service-plugin.ts index 93d1612f272..363b9e7ff4f 100644 --- a/packages/services/service-print/src/print-service-plugin.ts +++ b/packages/services/service-print/src/print-service-plugin.ts @@ -1,11 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import type { Plugin, PluginContext } from '@objectstack/core'; -import { - FILE_ATTACHMENT_SERVICE, - type IDataEngine, - type IFileAttachmentService, -} from '@objectstack/spec/contracts'; +import { FILE_ATTACHMENT_SERVICE, type IFileAttachmentService } from '@objectstack/spec/contracts'; import { createChromiumDriver } from './chromium-driver.js'; import { describeEndpoint, @@ -14,7 +10,7 @@ import { type PrintPlacementResolution, } from './config.js'; import { principalSessionMinterOf } from './principal-session.js'; -import { PrintService, type PrintMetadataReader } from './print-service.js'; +import { PrintService, type PrintEngine } from './print-service.js'; /** The service-registry slot the plugin registers {@link PrintService} under. */ export const PRINT_SERVICE = 'print' as const; @@ -41,7 +37,7 @@ export type PrintServicePluginOptions = PrintPlacementOptions; * engine makes the deployment visibly smaller and loses nothing, which is * a `warn`. * - `kernel:ready`: the services a print needs (the auth service's - * principal-session mint, `file-attachments`, metadata, the data engine) + * 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. */ @@ -70,8 +66,7 @@ export class PrintServicePlugin implements Plugin { this.service = new PrintService({ placement, driver, - getMetadata: () => optional('metadata'), - getEngine: () => optional>('objectql'), + getEngine: () => optional('objectql'), getAttachments: () => optional(FILE_ATTACHMENT_SERVICE), getMinter: () => principalSessionMinterOf(optional('auth')), }); diff --git a/packages/services/service-print/src/print-service.test.ts b/packages/services/service-print/src/print-service.test.ts index 18e7c0e9832..d0ca131d43f 100644 --- a/packages/services/service-print/src/print-service.test.ts +++ b/packages/services/service-print/src/print-service.test.ts @@ -86,18 +86,21 @@ async function harness(setup: Setup = {}): Promise { if (setup.readError) throw setup.readError; return 'record' in setup ? setup.record : { id: 'inv_1' }; }); - const metadataGet = vi.fn(async (_type: string, name: string) => + const metadataGet = vi.fn((_type: string, name: string) => 'page' in setup ? setup.page : name === PRINT_PAGE.name ? PRINT_PAGE : undefined, ); const minter: PrincipalSessionMinter = { mint }; const service = new PrintService({ placement, driver: placement.state === 'configured' ? (driver as ChromiumDriver) : null, - getMetadata: () => ({ - get: metadataGet, - getObject: async () => ('object' in setup ? setup.object : { name: 'invoice', enable: { files: true } }), - }), - getEngine: () => ({ findOne }) as never, + getEngine: () => + ({ + findOne, + registry: { + getItem: metadataGet, + getObject: () => ('object' in setup ? setup.object : { name: 'invoice', enable: { files: true } }), + }, + }) as never, getAttachments: () => (setup.noAttachments ? undefined : ({ attachFile } as never)), getMinter: () => (setup.noMint ? undefined : minter), }); diff --git a/packages/services/service-print/src/print-service.ts b/packages/services/service-print/src/print-service.ts index 95307c6172b..7cbf9a8b90e 100644 --- a/packages/services/service-print/src/print-service.ts +++ b/packages/services/service-print/src/print-service.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { recordNotFoundError } from '@objectstack/core'; -import type { IDataEngine, IFileAttachmentService } from '@objectstack/spec/contracts'; +import type { EngineSchemaRegistryView, IDataEngine, IFileAttachmentService } from '@objectstack/spec/contracts'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import { defaultRecordPageObject } from '@objectstack/spec/ui'; import type { ChromiumDriver } from './chromium-driver.js'; @@ -104,19 +104,21 @@ export interface IPrintService { printRecordToFile(request: PrintRecordRequest, context: ExecutionContext): Promise; } -/** The metadata reads a print needs. */ -export interface PrintMetadataReader { - get(type: string, name: string): Promise; - getObject(name: string): Promise; -} +/** + * The data engine a print reads through: the record, read as the caller, and + * the engine's schema registry — the pages and objects the console itself is + * served, so the page judged here is the page the browser renders. + */ +export type PrintEngine = Pick & { + readonly registry: Pick; +}; /** What {@link PrintService} is composed over — each service read at call time. */ export interface PrintServiceDeps { placement: PrintPlacementResolution; /** The engine for the configured placement; `null` exactly when none is configured. */ driver: ChromiumDriver | null; - getMetadata(): PrintMetadataReader | undefined; - getEngine(): Pick | undefined; + getEngine(): PrintEngine | undefined; getAttachments(): IFileAttachmentService | undefined; getMinter(): PrincipalSessionMinter | undefined; } @@ -198,8 +200,7 @@ export class PrintService implements IPrintService { "@objectstack/service-storage's StorageServicePlugin." ); } - if (!this.deps.getMetadata()) return 'No metadata service is registered, so a print page cannot be read.'; - if (!this.deps.getEngine()) return 'No data engine is registered, so a record cannot be read.'; + if (!this.deps.getEngine()) return 'No data engine is registered, so a page or a record cannot be read.'; return undefined; } @@ -215,7 +216,6 @@ export class PrintService implements IPrintService { printRefusal('SERVICE_UNAVAILABLE', 503, `Records cannot be printed to a file: ${status.reason}`); } const driver = this.deps.driver!; - const metadata = this.deps.getMetadata()!; const engine = this.deps.getEngine()!; const attachments = this.deps.getAttachments()!; const minter = this.deps.getMinter()!; @@ -223,13 +223,13 @@ export class PrintService implements IPrintService { // ③ The page. const req = validateRequest(request); - await this.refuseUnlessPrintPage(metadata, req); + this.refuseUnlessPrintPage(engine, req); // ④ The record, read as the caller. await this.refuseUnlessReadable(engine, req, context); // ⑤ The files opt-in. - await this.refuseUnlessFilesEnabled(metadata, req.object); + this.refuseUnlessFilesEnabled(engine, req.object); // Mint, render, archive — and revoke, whatever happened. const filename = req.filename ?? `${req.page}-${req.recordId}.pdf`; @@ -265,8 +265,8 @@ export class PrintService implements IPrintService { if (context.authGate) deny(`the user's sign-in is held by an authentication policy (${context.authGate.code})`); } - private async refuseUnlessPrintPage(metadata: PrintMetadataReader, req: ValidRequest): Promise { - const page = (await metadata.get('page', req.page)) as Record | undefined; + private refuseUnlessPrintPage(engine: PrintEngine, req: ValidRequest): void { + const page = engine.registry.getItem>('page', req.page); if (!page || typeof page !== 'object') { printRefusal('RESOURCE_NOT_FOUND', 404, `There is no page named '${req.page}'.`); } @@ -296,7 +296,7 @@ export class PrintService implements IPrintService { } private async refuseUnlessReadable( - engine: Pick, + engine: PrintEngine, req: ValidRequest, context: ExecutionContext, ): Promise { @@ -312,8 +312,8 @@ export class PrintService implements IPrintService { if (!row) throw recordNotFoundError(req.object, req.recordId); } - private async refuseUnlessFilesEnabled(metadata: PrintMetadataReader, object: string): Promise { - const def = (await metadata.getObject(object)) as { enable?: { files?: unknown } } | undefined; + private refuseUnlessFilesEnabled(engine: PrintEngine, object: string): void { + const def = engine.registry.getObject(object) as { enable?: { files?: unknown } } | undefined; if (def?.enable?.files !== true) { printRefusal( 'FILES_DISABLED', From 7e32a3b75716e6e22f462594de9308a663bc1ab0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 02:24:19 +0000 Subject: [PATCH 05/21] docs(service-print): the print pages guide, the package row, the changeset and the lockstep count Claude-Session: https://claude.ai/code/session_01W2XFCTBfK4LabgqovmvSgK Co-authored-by: Claude --- .changeset/22269-print-render-service.md | 20 +++ .changeset/config.json | 1 + content/docs/plugins/packages.mdx | 9 +- .../docs/protocol/backward-compatibility.mdx | 4 +- content/docs/ui/meta.json | 1 + content/docs/ui/print-pages.mdx | 119 ++++++++++++++++++ packages/services/service-print/README.md | 26 ++++ scripts/publish-smoke-pack.mjs | 2 +- 8 files changed, 178 insertions(+), 4 deletions(-) create mode 100644 .changeset/22269-print-render-service.md create mode 100644 content/docs/ui/print-pages.mdx create mode 100644 packages/services/service-print/README.md diff --git a/.changeset/22269-print-render-service.md b/.changeset/22269-print-render-service.md new file mode 100644 index 00000000000..b502a906e00 --- /dev/null +++ b/.changeset/22269-print-render-service.md @@ -0,0 +1,20 @@ +--- +'@objectstack/service-print': minor +--- + +feat(service-print): print a record's print page to a PDF in headless Chromium, as the requesting user, and archive it as a file attached to the record + +Clause-②: yes (widening) + +A new package, `@objectstack/service-print`. `PrintServicePlugin` registers the in-process `print` service (`IPrintService`): `status()` and `printRecordToFile({ app, object, recordId, page, filename? }, context)`. It adds no route, no metadata key and no error code. Compose it explicitly: + +```ts +import { PrintServicePlugin } from '@objectstack/service-print'; +plugins: [new PrintServicePlugin()] +``` + +- **One engine, headless Chromium, in one of two placements.** A local executable (`OS_PRINT_CHROMIUM_PATH`, option `chromiumPath`), or a remote browser's DevTools WebSocket endpoint (`OS_PRINT_BROWSER_ENDPOINT`, option `browserEndpoint`). Either one needs `OS_PRINT_CONSOLE_URL` (option `consoleUrl`): the console's URL as that browser reaches it, `/_console` included. Configuring both placements is refused. `playwright-core` is pinned exact on this package only. +- **Loud at startup.** `start()` probes the engine and logs its placement and Chromium version. An engine that is not configured, is misconfigured or does not answer is reported once, with the setting to fix, and `status()` says why. A remote endpoint is never logged beyond its scheme and host. +- **As the requesting user.** The page is rendered through a short-lived session minted for the caller's own principal by the auth service (`mintPrincipalSession`), and revoked in a `finally`. There is no system principal. +- **Refusals first, each with `code` and `status`, before any session is minted or browser started:** a caller that is not a human user acting for themselves (`PERMISSION_DENIED` / 403); an engine that is not ready (`SERVICE_UNAVAILABLE` / 503); an unknown page (`RESOURCE_NOT_FOUND` / 404); a page that is not the object's default record page or declares no `print` (`VALIDATION_ERROR` / 400); a record the caller cannot read (`RECORD_NOT_FOUND` / 404); an object without `enable: { files: true }` (`FILES_DISABLED` / 403). +- **The archive** goes through `file-attachments` (`IFileAttachmentService.attachFile`): one `sys_file` and one `sys_attachment` on the record, under its size limit and organization wall. diff --git a/.changeset/config.json b/.changeset/config.json index e30aac455f4..bf35a5e71df 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -67,6 +67,7 @@ "@objectstack/service-job", "@objectstack/service-knowledge", "@objectstack/service-mailbox", + "@objectstack/service-print", "@objectstack/service-messaging", "@objectstack/service-package", "@objectstack/service-queue", diff --git a/content/docs/plugins/packages.mdx b/content/docs/plugins/packages.mdx index 98f4eac0e4c..56f21b59c18 100644 --- a/content/docs/plugins/packages.mdx +++ b/content/docs/plugins/packages.mdx @@ -15,7 +15,7 @@ ObjectStack is organized into **72 package manifests** across multiple categorie | **Framework adapters** | 1 | `hono` (other frameworks: build a thin adapter on `HttpDispatcher` — see below) | | **Drivers** | 5 | `driver-memory`, `driver-sql`, `driver-sqlite-wasm`, `driver-mongodb`, `driver-turso` | | **Plugins** | 17 | `plugin-auth`, `plugin-security`, `plugin-audit`, `plugin-approvals`, `plugin-sharing`, `plugin-email`, `plugin-webhooks`, `plugin-hono-server`, `plugin-dev`, `plugin-pinyin-search`, `mcp`, trigger plugins (`trigger-api`, `trigger-record-change`, `trigger-schedule`), and knowledge/embedder plugins (`knowledge-memory`, `knowledge-ragflow`, `embedder-openai`) | -| **Platform services** | 16 | `service-analytics`, `service-automation`, `service-cache`, `service-cluster`, `service-cluster-redis`, `service-datasource`, `service-i18n`, `service-job`, `service-knowledge`, `service-messaging`, `service-package`, `service-queue`, `service-realtime`, `service-settings`, `service-sms`, `service-storage` | +| **Platform services** | 17 | `service-analytics`, `service-automation`, `service-cache`, `service-cluster`, `service-cluster-redis`, `service-datasource`, `service-i18n`, `service-job`, `service-knowledge`, `service-messaging`, `service-package`, `service-print`, `service-queue`, `service-realtime`, `service-settings`, `service-sms`, `service-storage` | ## Core Packages @@ -250,6 +250,13 @@ All services implement contracts from `@objectstack/spec/contracts` and are kern - **Features**: Provider adapters (Aliyun SMS, Twilio), `sms` settings namespace with live rebind, backs phone-number OTP sign-in/reset and the messaging `sms` channel - **When to use**: Phone OTP first-login / self-service reset, SMS notifications +### @objectstack/service-print + +**Print Service** — Prints a record's print page to a PDF in headless Chromium, as the requesting user, and archives it as a file attached to the record (`print` service). + +- **Features**: One engine, headless Chromium, on a local executable (`OS_PRINT_CHROMIUM_PATH`) or a remote DevTools endpoint (`OS_PRINT_BROWSER_ENDPOINT`); probed and reported at startup; a short-lived session for the requesting user, revoked after each print; the PDF archived through `file-attachments` +- **When to use**: Archiving invoices, delivery orders and letters as PDF attachments of their records — see [Print Pages](/docs/ui/print-pages) + ### @objectstack/service-i18n **I18n Service** — Internationalization with file-based locales. diff --git a/content/docs/protocol/backward-compatibility.mdx b/content/docs/protocol/backward-compatibility.mdx index a31efe829e1..a0b900df244 100644 --- a/content/docs/protocol/backward-compatibility.mdx +++ b/content/docs/protocol/backward-compatibility.mdx @@ -114,7 +114,7 @@ Read the **Breaking?** column, not the version number: in the 17.x minors every 1. **RFC (Request for Comments)** — Breaking changes are proposed as GitHub issues with the `protocol:breaking` label. 2. **Deprecation** — The old behavior is deprecated in a MINOR release (see timeline above). 3. **Migration Guide** — A detailed migration guide is published before the removal lands, in the release notes for the version that carries it. -4. **Release** — In the 17.x minors the breaking change shipped in the next **MINOR** version, carrying a changeset entry marked `**BREAKING**`, and `scripts/check-changeset-no-major.mjs` failed a pull request that declared a `major` bump, because under lockstep one `major` would promote all 72 published packages. On the v18 line it ships in the next `18.0.0-next.N`: the guard stands aside in pre mode, so a pull request may declare a major bump. See [how a breaking stage lands on the v18 line](/docs/releases/v18#how-a-breaking-stage-lands-on-this-line). +4. **Release** — In the 17.x minors the breaking change shipped in the next **MINOR** version, carrying a changeset entry marked `**BREAKING**`, and `scripts/check-changeset-no-major.mjs` failed a pull request that declared a `major` bump, because under lockstep one `major` would promote all 73 published packages. On the v18 line it ships in the next `18.0.0-next.N`: the guard stands aside in pre mode, so a pull request may declare a major bump. See [how a breaking stage lands on the v18 line](/docs/releases/v18#how-a-breaking-stage-lands-on-this-line). --- @@ -192,7 +192,7 @@ The `@objectstack/spec` package provides additional stability guarantees: ### Which surfaces this covers -**All of them.** It was not scoped to an experimental corner or a pre-release channel: all **72** packages published from this repository belong to a single Changesets `fixed` group, so they share one version number and one policy. No published surface was exempt. +**All of them.** It was not scoped to an experimental corner or a pre-release channel: all **73** packages published from this repository belong to a single Changesets `fixed` group, so they share one version number and one policy. No published surface was exempt. The convention was enforced rather than informal — `scripts/check-changeset-no-major.mjs` failed a pull request that introduced a `major` bump, because under lockstep a single `major` on one package would promote the entire stack. Changesets is now in pre mode with the tag `next`, and the guard stands aside in pre mode, so a pull request on the v18 line may declare a major bump. diff --git a/content/docs/ui/meta.json b/content/docs/ui/meta.json index 235850bb98b..9bf31a8cd75 100644 --- a/content/docs/ui/meta.json +++ b/content/docs/ui/meta.json @@ -3,6 +3,7 @@ "pages": [ "apps", "pages", + "print-pages", "react-pages", "views", "actions", diff --git a/content/docs/ui/print-pages.mdx b/content/docs/ui/print-pages.mdx new file mode 100644 index 00000000000..c8979cddc85 --- /dev/null +++ b/content/docs/ui/print-pages.mdx @@ -0,0 +1,119 @@ +--- +title: Print pages — printing a record to a PDF file +navTitle: Print Pages +description: Declare print on a record page, then archive a record's PDF as an attachment with the print service, rendered in headless Chromium as the requesting user +--- + +A **print page** is an ordinary page with one more key, `print`. The console +draws it on screen as before and, when a user prints, puts only that page on +paper: the sheet size, margins, page numbers and repeated header and footer come +from the `print` declaration, mapped to print CSS by the console. + +`@objectstack/service-print` prints the same page on the server. It renders the +record's print page in **headless Chromium**, as the requesting user, and +archives the PDF as a file attached to the record — downloadable from the +record's attachments, and usable wherever a file is. + +## Declare the print page + +A print page is a `kind: 'full'` record page whose blocks are all from the +printable subset (`PRINTABLE_PAGE_COMPONENT_TYPES` in `@objectstack/spec/ui`; +`os validate` refuses anything else). The record's object must allow files. + +```ts +import { definePage } from '@objectstack/spec/ui'; + +export const InvoiceDocumentPage = definePage({ + name: 'invoice_document', + label: 'Invoice', + type: 'record', + object: 'invoice', + kind: 'full', + isDefault: true, // the console renders a record on its default record page + print: { paperSize: 'A4', orientation: 'portrait', repeatHeader: true, pageNumbers: true }, + regions: [ + { name: 'header', components: [{ type: 'element:text', properties: { content: 'ACME Ltd' } }] }, + { name: 'main', components: [{ type: 'record:details' }] }, + ], +}); + +// …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. + +## Compose the print service + +The service is composed explicitly; a deployment that does not list it never +loads a browser driver. + +```ts +import { PrintServicePlugin } from '@objectstack/service-print'; + +export default defineStack({ + // … + plugins: [new PrintServicePlugin()], +}); +``` + +The engine is headless Chromium, in one of two placements. Configure exactly +one, plus the URL the browser loads the console from: + +| Setting | Plugin option | What it is | +|:---|:---|:---| +| `OS_PRINT_CHROMIUM_PATH` | `chromiumPath` | **Local placement**: a Chromium executable on this host, launched per print. | +| `OS_PRINT_BROWSER_ENDPOINT` | `browserEndpoint` | **Remote placement**: a browser's DevTools WebSocket endpoint (`ws://` or `wss://`), connected per print. For hosts that cannot carry a browser of their own. | +| `OS_PRINT_CONSOLE_URL` | `consoleUrl` | The console's URL as that browser reaches it, `/_console` included (`http://127.0.0.1:3000/_console`). | + +A plugin option wins over its variable. At startup the plugin probes the engine +— launches the executable or connects to the endpoint — and logs the placement +and Chromium version. An engine that is not configured, is configured twice, or +does not answer is reported once, at startup, with the setting to fix; every +print is then refused until it is. The remote endpoint is never logged beyond +its scheme and host, since it may carry a token. + +Chinese, Japanese and Korean text prints with whatever fonts the browser's host +has: install a CJK font there (for example `fonts-noto-cjk`), or those +characters print as empty boxes. + +## Print a record + +The service is reached in-process, through the `print` service: + +```ts +const print = kernel.getService('print'); + +print.status(); +// { state: 'ready', placement: 'local', version: '141.0.7390.37' } + +const file = await print.printRecordToFile( + { app: 'sales', object: 'invoice', recordId: 'inv_1', page: 'invoice_document' }, + context, // the requesting user's execution context +); +// { fileId, attachmentId, size, filename: 'invoice_document-inv_1.pdf' } +``` + +The page is rendered as the requesting user — never as the system — through a +short-lived session minted for that user and revoked as soon as the print ends, +whether it succeeded or not. A user can never archive a record they could not +read. + +## Refusals + +Each refusal carries `code` and `status`, and every one of them is decided +before a session is minted or a browser is started, in this order: + +| Order | Condition | `code` / `status` | +|:---|:---|:---| +| 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 | +| 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 | + +After those, a print can still fail as the render or the archive does: the page +not loading in time (`TIMEOUT` / 504), or the archive's own refusals, such as a +file over the upload size limit (`PAYLOAD_TOO_LARGE` / 413). diff --git a/packages/services/service-print/README.md b/packages/services/service-print/README.md new file mode 100644 index 00000000000..c5aa7904e5a --- /dev/null +++ b/packages/services/service-print/README.md @@ -0,0 +1,26 @@ +# @objectstack/service-print + +Prints a record's print page to a PDF in headless Chromium, as the requesting +user, and archives it as a file attached to the record. + +```ts +import { PrintServicePlugin } from '@objectstack/service-print'; + +plugins: [new PrintServicePlugin()]; +``` + +| Setting | Option | | +|:---|:---|:---| +| `OS_PRINT_CHROMIUM_PATH` | `chromiumPath` | A local Chromium executable (local placement) | +| `OS_PRINT_BROWSER_ENDPOINT` | `browserEndpoint` | A remote browser's DevTools WebSocket endpoint (remote placement) | +| `OS_PRINT_CONSOLE_URL` | `consoleUrl` | The console's URL as that browser reaches it, `/_console` included | + +Configure one placement. The plugin probes the engine at startup and reports an +absent, misconfigured or unreachable one once, with the setting to fix. + +```ts +const print = kernel.getService('print'); +await print.printRecordToFile({ app, object, recordId, page }, context); +``` + +Documentation: [Print Pages](https://objectstack.ai/docs/ui/print-pages). diff --git a/scripts/publish-smoke-pack.mjs b/scripts/publish-smoke-pack.mjs index bd122c8c0d1..9bfd0fdf072 100644 --- a/scripts/publish-smoke-pack.mjs +++ b/scripts/publish-smoke-pack.mjs @@ -33,7 +33,7 @@ * a future exclusion cannot re-open the hole silently. * * Source of truth: the workspace itself. The Changesets `fixed` group in - * .changeset/config.json enumerates the same 72 names, but it is a DERIVED + * .changeset/config.json enumerates the same 73 names, but it is a DERIVED * declaration validated against the workspace by scripts/check-changeset-fixed.mjs * (which reddens both when a public package is missing from the group and * when a group name no longer exists) — deriving from the group would mean From 68ab49b6d22d9be7c0017f079e206d6bbc06ef89 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 02:47:24 +0000 Subject: [PATCH 06/21] feat(example-showcase): the invoice print page names its detail sections Claude-Session: https://claude.ai/code/session_01W2XFCTBfK4LabgqovmvSgK Co-authored-by: Claude --- .../app-showcase/src/ui/pages/invoice-document.page.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/examples/app-showcase/src/ui/pages/invoice-document.page.ts b/examples/app-showcase/src/ui/pages/invoice-document.page.ts index 133933fb750..becca6efc1e 100644 --- a/examples/app-showcase/src/ui/pages/invoice-document.page.ts +++ b/examples/app-showcase/src/ui/pages/invoice-document.page.ts @@ -43,7 +43,15 @@ export const InvoiceDocumentPage = definePage({ { name: 'main', components: [ - { type: 'record:details' }, + { + type: 'record:details', + properties: { + sections: [ + { name: 'invoice', label: 'Invoice', columns: 2, fields: ['name', 'status', 'account', 'contact', 'issued_on', 'paid_on'] }, + { name: 'amounts', label: 'Amounts', columns: 2, fields: ['tax_rate', 'total'] }, + ], + }, + }, { type: 'record:line_items', properties: { From cff4e6d25183fbf676acaf46d4560a32fe5bab84 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 02:47:59 +0000 Subject: [PATCH 07/21] chore(service-print): version in step with its fixed group Claude-Session: https://claude.ai/code/session_01W2XFCTBfK4LabgqovmvSgK Co-authored-by: Claude --- packages/services/service-print/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/services/service-print/package.json b/packages/services/service-print/package.json index 6afeb5043bc..cc05f3f5194 100644 --- a/packages/services/service-print/package.json +++ b/packages/services/service-print/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-print", - "version": "18.0.0-next.1", + "version": "18.0.0-next.2", "license": "Apache-2.0", "description": "Print Service for ObjectStack — renders a record's print page in headless Chromium under the requesting user's own principal and archives the PDF as a file attached to the record.", "type": "module", From 967431d96a473f478343ddb9cb65e3f551b89bf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 02:56:24 +0000 Subject: [PATCH 08/21] feat(example-showcase): translate the invoice print page's section headings Claude-Session: https://claude.ai/code/session_01W2XFCTBfK4LabgqovmvSgK Co-authored-by: Claude --- examples/app-showcase/src/system/translations/index.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index b4d2ccf091b..59499222a74 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -233,6 +233,12 @@ export const ShowcaseTranslationBundle = { paid_on: { label: 'Paid On' }, total: { label: 'Total' }, }, + // `ui/pages/invoice-document.page.ts` `record:details` sections — the + // invoice print page's headings. + _sections: { + invoice: { label: 'Invoice' }, + amounts: { label: 'Amounts' }, + }, // The object's ONE authored rule message, on the #14253 channel. The // `en` entry is the authored sentence VERBATIM — the bundle WINS over // `rule.message` in every locale, so a drifted entry would turn the @@ -668,6 +674,10 @@ export const ShowcaseTranslationBundle = { paid_on: { label: '付款日期' }, total: { label: '合计' }, }, + _sections: { + invoice: { label: '发票' }, + amounts: { label: '金额' }, + }, // The zh-CN mirror of the `en` `_validations` block above. _validations: { no_invoice_for_churned_account: { From 52dd46e3fe3f104cfbd1145d5a70f7e0d14efb70 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 03:10:14 +0000 Subject: [PATCH 09/21] chore(service-print): the registries and gates a new package and a new docs page owe Claude-Session: https://claude.ai/code/session_01W2XFCTBfK4LabgqovmvSgK Co-authored-by: Claude --- .changeset/22269-print-render-service.md | 3 +++ content/docs/permissions/system-context.mdx | 1 + content/docs/ui/index.mdx | 1 + .../src/system/translations/index.ts | 2 ++ .../service-print/src/chromium-driver.ts | 12 +++++++++- .../service-print/src/print-service.test.ts | 6 ++++- .../service-print/src/print-service.ts | 14 ++++++++++- .../services/service-print/src/refusal.ts | 23 ------------------- packages/spec/llms.txt | 4 ++-- scripts/docs-audit/handwritten-docs.json | 1 + turbo.json | 1 + 11 files changed, 40 insertions(+), 28 deletions(-) delete mode 100644 packages/services/service-print/src/refusal.ts diff --git a/.changeset/22269-print-render-service.md b/.changeset/22269-print-render-service.md index b502a906e00..aba55015cb2 100644 --- a/.changeset/22269-print-render-service.md +++ b/.changeset/22269-print-render-service.md @@ -1,5 +1,6 @@ --- '@objectstack/service-print': minor +'@objectstack/spec': patch --- feat(service-print): print a record's print page to a PDF in headless Chromium, as the requesting user, and archive it as a file attached to the record @@ -18,3 +19,5 @@ plugins: [new PrintServicePlugin()] - **As the requesting user.** The page is rendered through a short-lived session minted for the caller's own principal by the auth service (`mintPrincipalSession`), and revoked in a `finally`. There is no system principal. - **Refusals first, each with `code` and `status`, before any session is minted or browser started:** a caller that is not a human user acting for themselves (`PERMISSION_DENIED` / 403); an engine that is not ready (`SERVICE_UNAVAILABLE` / 503); an unknown page (`RESOURCE_NOT_FOUND` / 404); a page that is not the object's default record page or declares no `print` (`VALIDATION_ERROR` / 400); a record the caller cannot read (`RECORD_NOT_FOUND` / 404); an object without `enable: { files: true }` (`FILES_DISABLED` / 403). - **The archive** goes through `file-attachments` (`IFileAttachmentService.attachFile`): one `sys_file` and one `sys_attachment` on the record, under its size limit and organization wall. + +`@objectstack/spec`'s `llms.txt` counts the new package: the workspace publishes 72 packages. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 3b733ffa6a7..27eb61ded2b 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -171,6 +171,7 @@ One of the largest consumers of the flag; | 47 | The inbound-mail read gates return early (the read AST) | service-mailbox | Get: every `sys_mail_message` and `sys_mail_association` row on `find` / `findOne` — the ingest writer and each gate's own pre-scan, which read the association and mailbox rows a decision needs. Lose: the narrowing of a message to callers who can read a record it is associated with or who own its mailbox (so the unassociated pool is restricted), and of an association to callers who can read the record it names (on `count` and `aggregate` too, so a total agrees with the rows) | `packages/services/service-mailbox/src/mail-read-visibility.ts#gateMiddleware` | | 47b | The inbound-mail ingest seam: `ingest` admits the system context ALONE, and `rematch` admits it without the organization, owner or `manage_mailboxes` check | service-mailbox | Get: a mail source hands a mailbox its messages, and server code re-matches any mailbox. Lose: nothing a user held — every other caller of `ingest` is refused (`PERMISSION_DENIED`), because an ingested message is shown on every record its correspondents' addresses match and a user who could hand the seam a message could write one in a correspondent's name; every other caller of `rematch` must be in the mailbox's organization and own the mailbox or hold `manage_mailboxes` | `packages/services/service-mailbox/src/ingest/mail-ingest-service.ts#ingest`, `#authorizeRematch` | | 47c | The inbound-mail IMAP sync: `syncMailbox` and `syncAll` admit the system context ALONE | service-mailbox | Get: the sync job reads every mailbox row (its organization, address and IMAP cursor), writes the cursor back, and hands each fetched message to `ingest` under the system context. Lose: nothing a user held — every other caller is refused (`PERMISSION_DENIED`), because a sync stores a mailbox's mail and moves its cursor | `packages/services/service-mailbox/src/sync/mailbox-sync-service.ts#syncMailbox`, `#syncAll` | +| 47d | The print service REFUSES the system context: `printRecordToFile` admits a human user acting for themselves alone | service-print | Get: nothing — a system caller is refused (`PERMISSION_DENIED`) before any page is read, session minted or browser started. Lose: nothing a user held — the PDF is rendered as the requesting user and never as the system, so a record is archived only by someone who could read it | `packages/services/service-print/src/print-service.ts#refuseUnlessHumanCaller` | ### 5. Actions, metadata plane, provenance, the organization wall diff --git a/content/docs/ui/index.mdx b/content/docs/ui/index.mdx index fef56d8f159..b9f48556bb7 100644 --- a/content/docs/ui/index.mdx +++ b/content/docs/ui/index.mdx @@ -40,6 +40,7 @@ export const CrmApp = App.create({ + diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index 59499222a74..073f938e486 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -1059,6 +1059,8 @@ export const ShowcaseTranslationBundle = { // The pages' own label/title/subtitle predate the ratchet and stay in the // frozen baseline, same as Revenue Pulse's older widget titles above. pages: { + // `ui/pages/invoice-document.page.ts` — the invoice print page. + showcase_invoice_document: { label: '发票' }, showcase_contact_form: { components: { field_name: { label: '姓名', placeholder: '艾达·洛夫莱斯' }, diff --git a/packages/services/service-print/src/chromium-driver.ts b/packages/services/service-print/src/chromium-driver.ts index 2dd8afa15f8..8ff4de8c440 100644 --- a/packages/services/service-print/src/chromium-driver.ts +++ b/packages/services/service-print/src/chromium-driver.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describeEndpoint, type PrintPlacement, type ResolvedPrintPlacement } from './config.js'; -import { refusalError } from './refusal.js'; +import type { RegisteredErrorCode, StandardErrorCode } from '@objectstack/spec/api'; import type { PrintSessionCookie } from './principal-session.js'; /** @@ -97,6 +97,16 @@ const PRINT_READY_PREDICATE = `(() => { const FONTS_READY = `document.fonts.ready.then(() => true)`; +/** + * An ADR-0112-shaped refusal: an `Error` carrying `code` and `status`. Its codes are from the standard catalog. + */ +function refusalError(code: StandardErrorCode | RegisteredErrorCode, status: number, message: string): Error { + const err = new Error(message) as Error & { code: string; status: number }; + err.code = code; + err.status = status; + return err; +} + /** `SameSite` as playwright spells it. */ function sameSiteOf(value: PrintSessionCookie['sameSite']): 'Strict' | 'Lax' | 'None' | undefined { if (value === 'strict') return 'Strict'; diff --git a/packages/services/service-print/src/print-service.test.ts b/packages/services/service-print/src/print-service.test.ts index d0ca131d43f..5c084bc7cfe 100644 --- a/packages/services/service-print/src/print-service.test.ts +++ b/packages/services/service-print/src/print-service.test.ts @@ -12,7 +12,11 @@ import { PrintService, type PrintRecordRequest, } from './print-service.js'; -import { refusalError } from './refusal.js'; + +/** A refusal as a gate or a collaborator throws it: `code` and `status` on an `Error`. */ +function refusalError(code: string, status: number, message: string): Error { + return Object.assign(new Error(message), { code, status }); +} const CONSOLE = 'http://127.0.0.1:3000/_console'; const COOKIE_VALUE = 'minted-cookie-value.signature'; diff --git a/packages/services/service-print/src/print-service.ts b/packages/services/service-print/src/print-service.ts index 7cbf9a8b90e..4dba3f4a200 100644 --- a/packages/services/service-print/src/print-service.ts +++ b/packages/services/service-print/src/print-service.ts @@ -1,13 +1,13 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { recordNotFoundError } from '@objectstack/core'; +import type { RegisteredErrorCode, StandardErrorCode } from '@objectstack/spec/api'; import type { EngineSchemaRegistryView, IDataEngine, IFileAttachmentService } from '@objectstack/spec/contracts'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import { defaultRecordPageObject } from '@objectstack/spec/ui'; import type { ChromiumDriver } from './chromium-driver.js'; import type { PrintPlacement, PrintPlacementResolution } from './config.js'; import type { PrincipalSessionMinter, PrintSession } from './principal-session.js'; -import { printRefusal } from './refusal.js'; /** * The print service: render a record's print page in headless Chromium, as @@ -136,6 +136,18 @@ export const PRINT_PROBE_TIMEOUT_MS = 15_000; const PDF_MIME_TYPE = 'application/pdf'; +/** + * An ADR-0112-shaped refusal: an `Error` carrying `code` and `status`. Every + * code the print service answers is from the standard catalog, plus the + * registered `FILES_DISABLED`: it adds no wire vocabulary of its own. + */ +function printRefusal(code: StandardErrorCode | RegisteredErrorCode, status: number, message: string): never { + const err = new Error(message) as Error & { code: string; status: number }; + err.code = code; + err.status = status; + throw err; +} + function nonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.trim().length > 0; } diff --git a/packages/services/service-print/src/refusal.ts b/packages/services/service-print/src/refusal.ts deleted file mode 100644 index 614ef0f29e7..00000000000 --- a/packages/services/service-print/src/refusal.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { RegisteredErrorCode, StandardErrorCode } from '@objectstack/spec/api'; - -/** An ADR-0112-shaped refusal: an `Error` carrying `code` and `status`. */ -export type PrintRefusal = Error & { code: StandardErrorCode | RegisteredErrorCode; status: number }; - -/** - * Throw an ADR-0112-shaped refusal. Every code the print service answers is - * from the standard catalog, plus the registered `FILES_DISABLED`: it adds no - * wire vocabulary of its own. - */ -export function printRefusal(code: StandardErrorCode | RegisteredErrorCode, status: number, message: string): never { - throw refusalError(code, status, message); -} - -/** The refusal {@link printRefusal} throws, for a caller that throws it itself. */ -export function refusalError(code: StandardErrorCode | RegisteredErrorCode, status: number, message: string): PrintRefusal { - const err = new Error(message) as PrintRefusal; - err.code = code; - err.status = status; - return err; -} diff --git a/packages/spec/llms.txt b/packages/spec/llms.txt index c40bc6cf1cf..ce80e20d499 100644 --- a/packages/spec/llms.txt +++ b/packages/spec/llms.txt @@ -178,9 +178,9 @@ function registerObject(rawConfig: unknown) { --- -## 7. Package Ecosystem (71 packages) +## 7. Package Ecosystem (72 packages) -The workspace publishes 71 packages under the `@objectstack` scope. The table +The workspace publishes 72 packages under the `@objectstack` scope. The table below is a curated entry-point list, not the full set — drivers, connectors, triggers, plugins and kernel-managed services each form their own family. diff --git a/scripts/docs-audit/handwritten-docs.json b/scripts/docs-audit/handwritten-docs.json index 75ca12d2c67..0d8f414e2cb 100644 --- a/scripts/docs-audit/handwritten-docs.json +++ b/scripts/docs-audit/handwritten-docs.json @@ -216,6 +216,7 @@ "content/docs/ui/forms.mdx", "content/docs/ui/index.mdx", "content/docs/ui/pages.mdx", + "content/docs/ui/print-pages.mdx", "content/docs/ui/public-data-collection.mdx", "content/docs/ui/react-pages.mdx", "content/docs/ui/reports.mdx", diff --git a/turbo.json b/turbo.json index 7c60860de7a..528fabeafa0 100644 --- a/turbo.json +++ b/turbo.json @@ -539,6 +539,7 @@ "$TURBO_ROOT$/packages/plugins/plugin-hono-server/src/**", "$TURBO_ROOT$/packages/rest/src/**", "$TURBO_ROOT$/packages/runtime/src/**", + "$TURBO_ROOT$/packages/services/service-print/src/**", "$TURBO_ROOT$/packages/services/service-realtime/src/**", "$TURBO_ROOT$/packages/services/service-storage/src/storage-route-ledger.ts", "$TURBO_ROOT$/packages/services/service-i18n/src/i18n-route-ledger.ts", From 51858e22b0f4e48192824596ffc6e68cb441ff7d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 03:58:03 +0000 Subject: [PATCH 10/21] test(dogfood): declare the print test's service-print input and type its slot lookups Claude-Session: https://claude.ai/code/session_01W2XFCTBfK4LabgqovmvSgK Co-authored-by: Claude --- .../print-service-archive.dogfood.test.ts | 29 +++++++++---------- scripts/cross-package-test-inputs.mjs | 1 + 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/packages/qa/dogfood/test/print-service-archive.dogfood.test.ts b/packages/qa/dogfood/test/print-service-archive.dogfood.test.ts index 14b69888ef1..0daf2baa7e9 100644 --- a/packages/qa/dogfood/test/print-service-archive.dogfood.test.ts +++ b/packages/qa/dogfood/test/print-service-archive.dogfood.test.ts @@ -36,7 +36,12 @@ 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 } from '../../../services/service-print/src/print-service.js'; +import { + PrintService, + type IPrintService, + type PrintEngine, +} from '../../../services/service-print/src/print-service.js'; +import { FILE_ATTACHMENT_SERVICE, type IDataEngine, type IFileAttachmentService } from '@objectstack/spec/contracts'; const SYS = { isSystem: true } as const; const PAGE = 'showcase_invoice_document'; @@ -60,8 +65,8 @@ async function refusalOf(p: Promise): Promise { describe('print service on a real showcase boot', () => { let stack: VerifyStack; let rootDir: string; - let ql: any; - let plugin: any; + let ql: IDataEngine; + let plugin: IPrintService; let service: PrintService; const saved: Record = {}; const revoke = vi.fn(async () => undefined); @@ -89,15 +94,15 @@ describe('print service on a real showcase boot', () => { new PrintServicePlugin(), ], }); - ql = stack.kernel.getService('objectql'); - plugin = stack.kernel.getService('print'); + ql = stack.kernel.getService('objectql'); + plugin = stack.kernel.getService('print'); memberEmail = 'print-member@dogfood.test'; memberToken = await stack.signUp(memberEmail); // The app's own `showcase_contributor`: invoice read scoped to the // caller's own rows (`owner == current_user.email`). const member = await ql.findOne('sys_user', { where: { email: memberEmail } }, { context: SYS }); - await ql.insert('sys_user_permission_set', { user_id: member.id, permission_set: 'showcase_contributor' }, { context: SYS }); + await ql.insert('sys_user_permission_set', { user_id: member!.id, permission_set: 'showcase_contributor' }, { context: SYS }); // Two invoices cloned from a seeded one, differing only in who owns them. const [seeded] = await ql.find('showcase_invoice', { limit: 1 }, { context: SYS }); const invoice = (name: string, owner: string) => @@ -105,22 +110,14 @@ describe('print service on a real showcase boot', () => { ownInvoice = String((await invoice('INV-PRINT-OWN', memberEmail)).id); othersInvoice = String((await invoice('INV-PRINT-OTHER', 'someone-else@dogfood.test')).id); - const k = stack.kernel as any; - const optional = (name: string) => { - try { - return k.getService(name); - } catch { - return undefined; - } - }; service = new PrintService({ placement: { state: 'configured', resolved: { placement: 'local', target: '/stand-in/chromium', consoleUrl: 'http://127.0.0.1:1/_console' }, }, driver: { placement: 'local', probe: async () => ({ version: 'stand-in' }), renderPdf }, - getEngine: () => optional('objectql'), - getAttachments: () => optional('file-attachments'), + getEngine: () => stack.kernel.getService('objectql'), + getAttachments: () => stack.kernel.getService(FILE_ATTACHMENT_SERVICE), getMinter: () => ({ mint }), }); await service.probe(); diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs index c991ca96325..8f131d6a6c5 100644 --- a/scripts/cross-package-test-inputs.mjs +++ b/scripts/cross-package-test-inputs.mjs @@ -1170,6 +1170,7 @@ export const CROSS_PACKAGE_TEST_INPUTS = { 'packages/plugins/plugin-hono-server/src/**', 'packages/rest/src/**', 'packages/runtime/src/**', + 'packages/services/service-print/src/**', 'packages/services/service-realtime/src/**', // The three ledgers test/route-ledger-live-mount-parity.dogfood.test.ts // IMPORTS, which no read named and nothing declared until #10452 taught From dcaa22d7605846548640d88f235657d6294382a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 04:25:48 +0000 Subject: [PATCH 11/21] feat(service-print): bind the principal-session mint to IAuthService.mintPrincipalSession Claude-Session: https://claude.ai/code/session_01W2XFCTBfK4LabgqovmvSgK Co-authored-by: Claude --- .../print-service-archive.dogfood.test.ts | 41 ++++++++++++- .../service-print/src/principal-session.ts | 60 +++++-------------- .../service-print/src/print-service-plugin.ts | 4 +- .../service-print/src/print-service.test.ts | 11 +++- 4 files changed, 65 insertions(+), 51 deletions(-) diff --git a/packages/qa/dogfood/test/print-service-archive.dogfood.test.ts b/packages/qa/dogfood/test/print-service-archive.dogfood.test.ts index 0daf2baa7e9..64eca010871 100644 --- a/packages/qa/dogfood/test/print-service-archive.dogfood.test.ts +++ b/packages/qa/dogfood/test/print-service-archive.dogfood.test.ts @@ -36,12 +36,18 @@ 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 { principalSessionMinterOf } from '../../../services/service-print/src/principal-session.js'; import { PrintService, type IPrintService, type PrintEngine, } from '../../../services/service-print/src/print-service.js'; -import { FILE_ATTACHMENT_SERVICE, type IDataEngine, type IFileAttachmentService } from '@objectstack/spec/contracts'; +import { + FILE_ATTACHMENT_SERVICE, + type IAuthService, + type IDataEngine, + type IFileAttachmentService, +} from '@objectstack/spec/contracts'; const SYS = { isSystem: true } as const; const PAGE = 'showcase_invoice_document'; @@ -210,5 +216,38 @@ describe('print service on a real showcase boot', () => { const attachment = await ql.findOne('sys_attachment', { where: { id: out.attachmentId } }, { context: SYS }); expect(attachment).toMatchObject({ parent_object: 'showcase_invoice', parent_id: ownInvoice, file_id: out.fileId }); }); + + it('with the real auth mint: the render loads as the caller, and the session is gone once the print returns', async () => { + const ctx = await stack.contextFor(memberToken); + const minter = principalSessionMinterOf(stack.kernel.getService('auth')); + expect(minter, 'the auth service implements IAuthService.mintPrincipalSession').toBeDefined(); + const asSession = (cookie: { name: string; value: string }) => + stack.raw('/api/v1/auth/get-session', { headers: { cookie: `${cookie.name}=${cookie.value}` } }); + let seenCookie: { name: string; value: string } | undefined; + let duringRender: unknown; + const real = new PrintService({ + placement: { + state: 'configured', + resolved: { placement: 'local', target: '/stand-in/chromium', consoleUrl: 'http://127.0.0.1:1/_console' }, + }, + driver: { + placement: 'local', + probe: async () => ({ version: 'stand-in' }), + renderPdf: async ({ cookie }) => { + seenCookie = cookie; + duringRender = await (await asSession(cookie)).json(); + return PDF; + }, + }, + getEngine: () => stack.kernel.getService('objectql'), + getAttachments: () => stack.kernel.getService(FILE_ATTACHMENT_SERVICE), + getMinter: () => minter, + }); + await real.probe(); + await real.printRecordToFile(request(ownInvoice), ctx); + expect((duringRender as { user?: { id?: string } } | null)?.user?.id).toBe(ctx.userId); + const after = await (await asSession(seenCookie!)).json(); + expect(after).toBeNull(); + }); }); }); diff --git a/packages/services/service-print/src/principal-session.ts b/packages/services/service-print/src/principal-session.ts index 78e1305bf33..69671ca8b40 100644 --- a/packages/services/service-print/src/principal-session.ts +++ b/packages/services/service-print/src/principal-session.ts @@ -1,46 +1,25 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +import type { IAuthService, PrincipalSession } from '@objectstack/spec/contracts'; import type { ExecutionContext } from '@objectstack/spec/kernel'; /** * The ONE place this package reaches the auth service's principal-session - * mint: an optional `IAuthService` member that mints a short-lived session for - * the requesting user's own principal, so the browser loads the console as - * that user and as nobody else. The print service never holds a system - * principal and never forwards a credential it was handed. + * mint, `IAuthService.mintPrincipalSession`: a short-lived session for the + * requesting user's own principal, so the browser loads the console as that + * user and as nobody else. The print service never holds a system principal + * and never forwards a credential it was handed. * - * ## Binding - * - * The member, its name and its shape belong to the auth contract - * (`IAuthService.mintPrincipalSession`, `@objectstack/spec/contracts`). Until - * that member is on the contract this adapter mirrors its shape locally and - * reads it off the registered `auth` service by name; once it is, the three - * local types below become imports from `@objectstack/spec/contracts` - * (`PrincipalSession`, `PrincipalSessionCookie`) and the read becomes a typed - * call on `IAuthService`. Nothing else in the package changes: every other - * file reaches the mint through {@link principalSessionMinterOf}. + * The mint mints for whatever user the context names, so it is handed the + * incoming request's `ExecutionContext` UNCHANGED — the object the caller + * passed, never one assembled here (pinned in `print-service.test.ts`). */ /** The minted session's cookie, under the auth service's own name and attributes. A secret. */ -export interface PrintSessionCookie { - name: string; - value: string; - path: string; - httpOnly: boolean; - secure: boolean; - sameSite?: 'lax' | 'strict' | 'none'; - domain?: string; -} +export type PrintSessionCookie = PrincipalSession['cookie']; /** A minted session, as this package uses it. */ -export interface PrintSession { - /** The session cookie the browser loads the console with. */ - cookie: PrintSessionCookie; - /** The session as a bearer token. Never used here; held only so it can be redacted. */ - token: string; - /** End the session. Safe to call more than once. */ - revoke(): Promise; -} +export type PrintSession = Pick; /** What the print service asks of the mint. */ export interface PrincipalSessionMinter { @@ -48,21 +27,12 @@ export interface PrincipalSessionMinter { mint(context: ExecutionContext, ttlSeconds: number): Promise; } -/** The member as the auth contract declares it. */ -type MintMember = ( - context: ExecutionContext, - options?: { ttlSeconds?: number }, -) => Promise; - /** * The mint of the registered `auth` service, or `undefined` when that service - * is absent or offers no principal-session mint. Read at call time, never - * cached, so a provider that registers later is seen. + * is absent or its provider does not implement the optional member. Read at + * call time, never cached, so a provider that registers later is seen. */ -export function principalSessionMinterOf(auth: unknown): PrincipalSessionMinter | undefined { - if (!auth || typeof auth !== 'object') return undefined; - const member = (auth as { mintPrincipalSession?: unknown }).mintPrincipalSession; - if (typeof member !== 'function') return undefined; - const mint = member as MintMember; - return { mint: (context, ttlSeconds) => mint.call(auth, context, { ttlSeconds }) }; +export function principalSessionMinterOf(auth: IAuthService | undefined): PrincipalSessionMinter | undefined { + if (!auth || typeof auth.mintPrincipalSession !== 'function') return undefined; + return { mint: (context, ttlSeconds) => auth.mintPrincipalSession!(context, { ttlSeconds }) }; } diff --git a/packages/services/service-print/src/print-service-plugin.ts b/packages/services/service-print/src/print-service-plugin.ts index 363b9e7ff4f..0a1b6078789 100644 --- a/packages/services/service-print/src/print-service-plugin.ts +++ b/packages/services/service-print/src/print-service-plugin.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import type { Plugin, PluginContext } from '@objectstack/core'; -import { FILE_ATTACHMENT_SERVICE, type IFileAttachmentService } from '@objectstack/spec/contracts'; +import { FILE_ATTACHMENT_SERVICE, type IAuthService, type IFileAttachmentService } from '@objectstack/spec/contracts'; import { createChromiumDriver } from './chromium-driver.js'; import { describeEndpoint, @@ -68,7 +68,7 @@ export class PrintServicePlugin implements Plugin { driver, getEngine: () => optional('objectql'), getAttachments: () => optional(FILE_ATTACHMENT_SERVICE), - getMinter: () => principalSessionMinterOf(optional('auth')), + getMinter: () => principalSessionMinterOf(optional('auth')), }); ctx.registerService(PRINT_SERVICE, this.service); } diff --git a/packages/services/service-print/src/print-service.test.ts b/packages/services/service-print/src/print-service.test.ts index 5c084bc7cfe..59f21a06887 100644 --- a/packages/services/service-print/src/print-service.test.ts +++ b/packages/services/service-print/src/print-service.test.ts @@ -1,6 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, expect, it, vi } from 'vitest'; +import { PRINCIPAL_SESSION_MAX_TTL_SECONDS } from '@objectstack/spec/contracts'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import type { ChromiumDriver } from './chromium-driver.js'; import type { PrintPlacementResolution } from './config.js'; @@ -291,8 +292,11 @@ describe('PrintService — mint, render, archive, revoke', () => { const h = await harness(); const out = await h.service.printRecordToFile(REQUEST, USER); expect(h.mint).toHaveBeenCalledWith(USER, PRINT_SESSION_TTL_SECONDS); + // The request's own context, unchanged — the very object the caller passed, never one assembled. + expect(h.mint.mock.calls[0]![0]).toBe(USER); + expect(h.findOne.mock.calls[0]![2].context).toBe(USER); expect(PRINT_SESSION_TTL_SECONDS * 1000).toBeGreaterThan(PRINT_RENDER_TIMEOUT_MS); - expect(PRINT_SESSION_TTL_SECONDS).toBeLessThanOrEqual(120); + expect(PRINT_SESSION_TTL_SECONDS).toBeLessThanOrEqual(PRINCIPAL_SESSION_MAX_TTL_SECONDS); expect(h.driver.renderPdf).toHaveBeenCalledWith({ url: `${CONSOLE}/apps/showcase/invoice/record/inv_1`, cookie: expect.objectContaining({ value: COOKIE_VALUE }), @@ -361,7 +365,7 @@ describe('PrintService — mint, render, archive, revoke', () => { describe('principalSessionMinterOf — the one read of the auth service’s mint', () => { it('absent auth, or an auth service without the member, has no mint', () => { expect(principalSessionMinterOf(undefined)).toBeUndefined(); - expect(principalSessionMinterOf({ verify: () => undefined })).toBeUndefined(); + expect(principalSessionMinterOf({ verify: () => undefined } as never)).toBeUndefined(); }); it('calls the member on the auth service with the caller’s context and the TTL', async () => { @@ -372,8 +376,9 @@ describe('principalSessionMinterOf — the one read of the auth service’s mint return { token: 't', cookie: { name: 'n', value: 'v', path: '/', httpOnly: true, secure: true }, revoke: async () => {} }; }, }; - const minter = principalSessionMinterOf(auth)!; + const minter = principalSessionMinterOf(auth as never)!; await minter.mint(USER, 60); expect(auth.seen).toEqual([USER, { ttlSeconds: 60 }]); + expect(auth.seen[0]).toBe(USER); }); }); From 4aa215ac5c2094a450fd82a3516949d7fd717bc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 04:33:25 +0000 Subject: [PATCH 12/21] =?UTF-8?q?test(dogfood):=20pin=20the=20real=20mint?= =?UTF-8?q?=20=E2=80=94=20the=20render=20loads=20as=20the=20caller,=20the?= =?UTF-8?q?=20session=20is=20gone=20after=20the=20print?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01W2XFCTBfK4LabgqovmvSgK Co-authored-by: Claude --- .../qa/dogfood/test/print-service-archive.dogfood.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/qa/dogfood/test/print-service-archive.dogfood.test.ts b/packages/qa/dogfood/test/print-service-archive.dogfood.test.ts index 64eca010871..d440b6b5b89 100644 --- a/packages/qa/dogfood/test/print-service-archive.dogfood.test.ts +++ b/packages/qa/dogfood/test/print-service-archive.dogfood.test.ts @@ -246,8 +246,8 @@ describe('print service on a real showcase boot', () => { await real.probe(); await real.printRecordToFile(request(ownInvoice), ctx); expect((duringRender as { user?: { id?: string } } | null)?.user?.id).toBe(ctx.userId); - const after = await (await asSession(seenCookie!)).json(); - expect(after).toBeNull(); + const after = await asSession(seenCookie!); + expect([after.status, ((await after.json()) as { error?: { code?: string } }).error?.code]).toEqual([401, 'UNAUTHENTICATED']); }); }); }); From 4f9a3499c2d053f6067c5aa5d00485f5c38c57a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 05:05:29 +0000 Subject: [PATCH 13/21] =?UTF-8?q?feat(service-storage):=20the=20file-attac?= =?UTF-8?q?hments=20writer=20as=20the=20requesting=20user=20=E2=80=94=20ma?= =?UTF-8?q?yAttach=20and=20attachFileAs=20(WIP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01W2XFCTBfK4LabgqovmvSgK Co-authored-by: Claude --- .../src/attachment-access-hooks.ts | 117 ++++++++++------ .../src/file-attachment-service.ts | 126 ++++++++++++++++-- .../services/service-storage/src/index.ts | 1 + .../src/storage-service-plugin.ts | 16 +++ 4 files changed, 206 insertions(+), 54 deletions(-) diff --git a/packages/services/service-storage/src/attachment-access-hooks.ts b/packages/services/service-storage/src/attachment-access-hooks.ts index 3e8aaf2cdb1..8ddd212e3b5 100644 --- a/packages/services/service-storage/src/attachment-access-hooks.ts +++ b/packages/services/service-storage/src/attachment-access-hooks.ts @@ -312,6 +312,76 @@ function callerContext(ctx: any): ExecutionContext { return { userId: s.userId, tenantId: s.organizationId, positions: s.positions }; } +/** What {@link mayEditAttachmentParent} composes over, each resolved per call. */ +export interface AttachmentParentVerdictDeps { + getSharing: () => AttachmentSharingLike | null | undefined; + getSecurity?: () => AttachmentSecurityLike | null | undefined; + logger: Pick; +} + +/** + * May the caller EDIT the parent record `(object, recordId)`? The one + * question the attach rule, the row rule's parent-editor limb and the + * re-point's attach rule all ask — see "Parent EDIT" in the module header for + * the composition and why each outcome lands where it does. Exported so the + * caller-scoped attach verb (`FileAttachmentService.mayAttach`) asks the SAME + * question before a producer spends work on a file it could not attach. + * + * `recordId` is the value the limb holds (the degraded read probe keeps the + * shape each limb always queried with); the sharing and security services + * are handed its string form, as `canEdit` always was. `readParent` is the + * degraded mode's caller-scoped read of the parent. + */ +export async function mayEditAttachmentParent( + deps: AttachmentParentVerdictDeps, + object: string, + recordId: unknown, + callerCtx: ExecutionContext, + limb: string, + readParent: () => Promise, +): Promise { + const { getSharing, getSecurity, logger } = deps; + const sharing = getSharing(); + if (!sharing || typeof sharing.checkEdit !== 'function') { + // Degraded mode (no sharing service): caller-scoped parent READ + // visibility — still strictly tighter than no gate. + logger.debug?.(`[storage] attachment access: sharing service absent — ${limb} gated on parent read visibility`); + try { + return !!(await readParent()); + } catch { + return false; + } + } + const verdict = await sharing.checkEdit(object, String(recordId), callerCtx); + if (verdict === 'allow') return true; + // `deny`, and anything that is not one of the three verdicts, refuses. + if (verdict !== 'abstain') return false; + + // Record sharing does not enforce on this parent. Whether that is + // permission is the master-detail check's question (ADR-0055): it answers + // `not_applicable` for a parent that derives nothing from a master. + let security: AttachmentSecurityLike | null | undefined; + try { + security = getSecurity?.(); + } catch { + security = undefined; + } + if (!security || typeof security.checkControlledByParentWrite !== 'function') { + // No master-detail check is composed in this kernel, and the parent's own + // update meets none either. ⛔ Not "the master was judged editable" — + // nothing measured it. + logger.debug?.( + `[storage] attachment access: record sharing abstains on ${object} and the security service composes no ` + + `master-detail write check — ${limb} admitted on the abstention, as the parent's own update would be`, + ); + return true; + } + // A rejection (a datasource fault, a context the write path refuses) is a + // refusal of the request, and propagates as it is. + const answer = await security.checkControlledByParentWrite(object, String(recordId), callerCtx); + return answer.outcome === 'allow' || answer.outcome === 'not_applicable'; +} + /** * Install the write-side gates on `sys_attachment` (insert / update / delete). * @@ -352,53 +422,16 @@ export function installAttachmentAccessHooks( * shape each limb always queried with); the sharing and security services * are handed its string form, as `canEdit` always was. */ - const mayEditParent = async ( + const mayEditParent = ( ctx: any, object: string, recordId: unknown, callerCtx: ExecutionContext, limb: string, - ): Promise => { - const sharing = getSharing(); - if (!sharing || typeof sharing.checkEdit !== 'function') { - // Degraded mode (no sharing service): caller-scoped parent READ - // visibility — still strictly tighter than no gate. - logger.debug?.(`[storage] attachment access: sharing service absent — ${limb} gated on parent read visibility`); - try { - return !!(await ctx.api.object(object).findOne({ where: { id: recordId } })); - } catch { - return false; - } - } - const verdict = await sharing.checkEdit(object, String(recordId), callerCtx); - if (verdict === 'allow') return true; - // `deny`, and anything that is not one of the three verdicts, refuses. - if (verdict !== 'abstain') return false; - - // Record sharing does not enforce on this parent. Whether that is - // permission is the master-detail check's question (ADR-0055): it answers - // `not_applicable` for a parent that derives nothing from a master. - let security: AttachmentSecurityLike | null | undefined; - try { - security = getSecurity?.(); - } catch { - security = undefined; - } - if (!security || typeof security.checkControlledByParentWrite !== 'function') { - // No master-detail check is composed in this kernel, and the parent's own - // update meets none either. ⛔ Not "the master was judged editable" — - // nothing measured it. - logger.debug?.( - `[storage] attachment access: record sharing abstains on ${object} and the security service composes no ` + - `master-detail write check — ${limb} admitted on the abstention, as the parent's own update would be`, - ); - return true; - } - // A rejection (a datasource fault, a context the write path refuses) is a - // refusal of the request, and propagates as it is. - const answer = await security.checkControlledByParentWrite(object, String(recordId), callerCtx); - return answer.outcome === 'allow' || answer.outcome === 'not_applicable'; - }; + ): Promise => + mayEditAttachmentParent({ getSharing, getSecurity, logger }, object, recordId, callerCtx, limb, () => + ctx.api.object(object).findOne({ where: { id: recordId } }), + ); /** Resolve every sys_attachment row a write matches, under SYSTEM context * — the caller may legitimately be unable to READ rows they are allowed to diff --git a/packages/services/service-storage/src/file-attachment-service.ts b/packages/services/service-storage/src/file-attachment-service.ts index 2024b60cb81..af344ed6605 100644 --- a/packages/services/service-storage/src/file-attachment-service.ts +++ b/packages/services/service-storage/src/file-attachment-service.ts @@ -10,7 +10,15 @@ import type { IObjectQLEngine, IStorageService, } from '@objectstack/spec/contracts'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; import { StorageMetadataStore, type StorageWriteContext } from './metadata-store.js'; +// The attach rule's one verdict (parent EDIT), asked here before a producer +// spends work on a file it could not attach. +import { + mayEditAttachmentParent, + type AttachmentSecurityLike, + type AttachmentSharingLike, +} from './attachment-access-hooks.js'; // The package's one answer to "which organization is this record walled // into?", read off the registered schema — never a hard-coded column. import { createWallOrganizationResolver } from './backfill-sys-file-organizations.js'; @@ -101,7 +109,41 @@ export interface FileAttachmentServiceDeps { getStorage(): IStorageService | null; /** The `storage` namespace's Limits group as last read — the upload doors' own source. */ getLimitsSnapshot(): StorageLimitsSnapshot | undefined; - logger: { warn(message: string): void }; + logger: { warn(message: string): void; debug?(message: string): void }; + /** The sharing service, for the attach rule's verdict ({@link FileAttachmentService.mayAttach}). */ + getSharing?(): AttachmentSharingLike | null | undefined; + /** The security service, for the attach rule's master-detail check. */ + getSecurity?(): AttachmentSecurityLike | null | undefined; +} + +/** + * The caller-scoped face of the `file-attachments` service: the same one + * writer, run AS THE REQUESTING USER instead of as the system. A package-local + * contract of `@objectstack/service-storage`, reached through the same + * `FILE_ATTACHMENT_SERVICE` slot (the registered service implements both). + * + * For a producer acting for a signed-in user (a record printed to a PDF): the + * parent is read as the caller, the `sys_attachment` insert runs under the + * caller's context — so every gate on it judges the caller (the parent-EDIT + * attach rule, the `enable.files` opt-in) and it is stamped `uploaded_by` the + * caller — and the `sys_file` row is owned by the caller. + */ +export interface ICallerFileAttachmentService { + /** + * Would the attach rule let `context` attach a file to the record? The + * verdict the `sys_attachment` insert gate itself asks (parent EDIT), with + * no write — for a producer to refuse before it spends work on the file. + */ + mayAttach(parentObject: string, parentId: string, context: ExecutionContext): Promise; + /** + * {@link IFileAttachmentService.attachFile}, as `context`'s user. Refuses as + * `attachFile` does, plus: a parent the caller cannot read answers + * `RECORD_NOT_FOUND` / `404`, and the attach rule's refusal + * (`ATTACHMENT_PARENT_ACCESS` / `403`) is the insert gate's own. A context + * without a user, or the system context, is refused `PERMISSION_DENIED` / + * `403`: the system has `attachFile`. + */ + attachFileAs(input: AttachFileInput, context: ExecutionContext): Promise; } /** The upload scope of every file this verb writes: the attachments surface. */ @@ -123,6 +165,19 @@ function nonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.length > 0; } +/** The caller-scoped verbs serve a user; the system has `attachFile`. */ +function requireUserContext(context: ExecutionContext): ExecutionContext { + if (!context || typeof context !== 'object' || context.isSystem === true || !nonEmptyString(context.userId)) { + refuse( + 'PERMISSION_DENIED', + 403, + 'attachFileAs / mayAttach act as a signed-in user: the context carries no user, or is the system context ' + + '(which attaches through attachFile).', + ); + } + return context; +} + /** * The input, checked field by field: a JavaScript caller can hand anything, * and a malformed field is the caller's to fix (`400 VALIDATION_ERROR`) before @@ -145,10 +200,37 @@ function validateAttachFileInput(input: AttachFileInput): AttachFileInput { return input; } -export class FileAttachmentService implements IFileAttachmentService { +export class FileAttachmentService implements IFileAttachmentService, ICallerFileAttachmentService { constructor(private readonly deps: FileAttachmentServiceDeps) {} async attachFile(input: AttachFileInput): Promise { + return this.attach(input, null); + } + + async attachFileAs(input: AttachFileInput, context: ExecutionContext): Promise { + return this.attach(input, requireUserContext(context)); + } + + async mayAttach(parentObject: string, parentId: string, context: ExecutionContext): Promise { + const caller = requireUserContext(context); + const engine = this.deps.getEngine(); + if (!engine) refuse('SERVICE_UNAVAILABLE', 503, NOT_READY_MESSAGE); + return mayEditAttachmentParent( + { + getSharing: () => this.deps.getSharing?.(), + getSecurity: () => this.deps.getSecurity?.(), + logger: this.deps.logger, + }, + parentObject, + parentId, + caller, + 'attach', + () => engine.findOne(parentObject, { where: { id: parentId } }, { context: caller }), + ); + } + + /** The one write path: as the system (`caller` null), or as the caller. */ + private async attach(input: AttachFileInput, caller: ExecutionContext | null): Promise { const request = validateAttachFileInput(input); const engine = this.deps.getEngine(); const storage = this.deps.getStorage(); @@ -164,7 +246,7 @@ export class FileAttachmentService implements IFileAttachmentService { if (tooLarge !== null) refuse(UPLOAD_TOO_LARGE_CODE, UPLOAD_TOO_LARGE_STATUS, tooLarge); // ② The parent, and the organization both rows are written in. - const organizationId = await this.organizationOfParent(engine, request); + const organizationId = await this.organizationOfParent(engine, request, caller); const writeContext: StorageWriteContext = { organizationId }; const store = new StorageMetadataStore(engine); @@ -181,6 +263,7 @@ export class FileAttachmentService implements IFileAttachmentService { scope: ATTACHMENTS_SCOPE, acl: 'private', status: 'pending', + ...(caller ? { owner_id: caller.userId as string } : {}), }, writeContext, ); @@ -197,7 +280,7 @@ export class FileAttachmentService implements IFileAttachmentService { let attachmentId: string | undefined; let ownedUnit = false; try { - await this.inOneUnit(engine, organizationId, async (context, owned) => { + await this.inOneUnit(engine, organizationId, caller, async (context, owned) => { ownedUnit = owned; const row = (await engine.insert( 'sys_attachment', @@ -241,12 +324,24 @@ export class FileAttachmentService implements IFileAttachmentService { * caller's (`null` for none). A parent that does not exist, or that is walled * into another organization than the caller's, is refused with one answer. */ - private async organizationOfParent(engine: FileAttachmentEngine, request: AttachFileInput): Promise { - const parent = await engine.findOne( - request.parentObject, - { where: { id: request.parentId } }, - { context: { isSystem: true } }, - ); + private async organizationOfParent( + engine: FileAttachmentEngine, + request: AttachFileInput, + caller: ExecutionContext | null, + ): Promise { + let parent: Record | null; + try { + parent = await engine.findOne( + request.parentObject, + { where: { id: request.parentId } }, + { context: caller ?? { isSystem: true } }, + ); + } catch (err) { + // A caller refused the read answers exactly as a record that does not + // exist: never a 403 that tells the two apart. + if (caller && (err as { status?: unknown })?.status === 403) parent = null; + else throw err; + } const parentOrganization = parent ? createWallOrganizationResolver(engine).organizationOf(request.parentObject, parent) : null; @@ -273,10 +368,17 @@ export class FileAttachmentService implements IFileAttachmentService { private async inOneUnit( engine: FileAttachmentEngine, organizationId: string | null, + caller: ExecutionContext | null, work: (context: Record, owned: boolean) => Promise, ): Promise { - const base: Record = - organizationId === null ? { isSystem: true } : { isSystem: true, tenantId: organizationId }; + // As the caller: the caller's own context, unchanged, so every gate on the + // insert judges the caller. As the system: the system, in the parent's + // organization. + const base: Record = caller + ? (caller as Record) + : organizationId === null + ? { isSystem: true } + : { isSystem: true, tenantId: organizationId }; if (typeof engine.transaction !== 'function') { await work(base, false); return; diff --git a/packages/services/service-storage/src/index.ts b/packages/services/service-storage/src/index.ts index 17fb305b211..29414ed2f68 100644 --- a/packages/services/service-storage/src/index.ts +++ b/packages/services/service-storage/src/index.ts @@ -110,6 +110,7 @@ export type { SysFilePublicScopeBackfillReport, } from './backfill-sys-file-public-scope.js'; export { installAttachmentAccessHooks, installAttachmentReadVisibility } from './attachment-access-hooks.js'; +export type { ICallerFileAttachmentService } from './file-attachment-service.js'; export type { AttachmentSharingLike, AttachmentSecurityLike } from './attachment-access-hooks.js'; export { runFilesToReferencesMigration } from './files-to-references-migration.js'; export type { diff --git a/packages/services/service-storage/src/storage-service-plugin.ts b/packages/services/service-storage/src/storage-service-plugin.ts index b7520fbaebb..f2d367eb41a 100644 --- a/packages/services/service-storage/src/storage-service-plugin.ts +++ b/packages/services/service-storage/src/storage-service-plugin.ts @@ -363,6 +363,22 @@ export class StorageServicePlugin implements Plugin { getStorage: () => storage, getLimitsSnapshot: () => storage.getLimitsSnapshot(), logger: ctx.logger, + // The attach rule's verdict for the caller-scoped verbs, resolved per + // call, the same two services the `sys_attachment` insert gate asks. + getSharing: () => { + try { + return ctx.getService('sharing'); + } catch { + return null; + } + }, + getSecurity: () => { + try { + return ctx.getService('security'); + } catch { + return null; + } + }, }), ); ctx.logger.info( From 2d05495ece51abe1e0a98bcaee87ca74e913bf1c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 05:17:02 +0000 Subject: [PATCH 14/21] feat(service-print,service-storage): archive the printed PDF as the requesting user The file-attachments writer gains a caller-scoped face (ICallerFileAttachmentService: assertMayAttach, attachFileAs) on the same slot; the print service asks it in the files step, before any mint or launch, and archives through it. Claude-Session: https://claude.ai/code/session_01W2XFCTBfK4LabgqovmvSgK Co-authored-by: Claude --- .../print-service-archive.dogfood.test.ts | 35 +++++++- packages/services/service-print/package.json | 1 + .../service-print/src/print-service.test.ts | 74 ++++++++++++++--- .../service-print/src/print-service.ts | 58 ++++++++----- .../src/file-attachment-service.test.ts | 83 ++++++++++++++++++- .../src/file-attachment-service.ts | 51 ++++++++++-- pnpm-lock.yaml | 3 + 7 files changed, 259 insertions(+), 46 deletions(-) diff --git a/packages/qa/dogfood/test/print-service-archive.dogfood.test.ts b/packages/qa/dogfood/test/print-service-archive.dogfood.test.ts index d440b6b5b89..8dc1209de07 100644 --- a/packages/qa/dogfood/test/print-service-archive.dogfood.test.ts +++ b/packages/qa/dogfood/test/print-service-archive.dogfood.test.ts @@ -83,6 +83,8 @@ describe('print service on a real showcase boot', () => { })); const renderPdf = vi.fn(async () => PDF); let memberToken: string; + let auditorToken: string; + let adminToken: string; let memberEmail: string; let ownInvoice: string; let othersInvoice: string; @@ -109,6 +111,12 @@ describe('print service on a real showcase boot', () => { // caller's own rows (`owner == current_user.email`). const member = await ql.findOne('sys_user', { where: { email: memberEmail } }, { context: SYS }); await ql.insert('sys_user_permission_set', { user_id: member!.id, permission_set: 'showcase_contributor' }, { context: SYS }); + adminToken = await stack.signIn(); + // A reader who may read every invoice and edit none: the app's own `showcase_auditor`. + const auditorEmail = 'print-auditor@dogfood.test'; + auditorToken = await stack.signUp(auditorEmail); + const auditor = await ql.findOne('sys_user', { where: { email: auditorEmail } }, { context: SYS }); + await ql.insert('sys_user_permission_set', { user_id: auditor!.id, permission_set: 'showcase_auditor' }, { context: SYS }); // Two invoices cloned from a seeded one, differing only in who owns them. const [seeded] = await ql.find('showcase_invoice', { limit: 1 }, { context: SYS }); const invoice = (name: string, owner: string) => @@ -199,8 +207,26 @@ describe('print service on a real showcase boot', () => { expect(renderPdf).not.toHaveBeenCalled(); }); - it('archives the PDF on the caller’s own invoice as one committed sys_file and one sys_attachment', async () => { - const ctx = await stack.contextFor(memberToken); + it('a reader who can read the invoice but may not attach to it: refused with the archive’s own answer, before any mint or render', async () => { + const ctx = await stack.contextFor(auditorToken); + // Control: the auditor really reads the invoice, so this is the attach rule and not the read. + expect(await ql.findOne('showcase_invoice', { where: { id: ownInvoice } }, { context: ctx })).not.toBeNull(); + mint.mockClear(); + renderPdf.mockClear(); + // The showcase grants the auditor no attachment create at all: the insert's + // own CRUD answer, asked before the render. (The attach-rule leg, + // ATTACHMENT_PARENT_ACCESS, is pinned against the real insert gate in + // service-storage's file-attachment-service.test.ts.) + expect(await refusalOf(service.printRecordToFile(request(ownInvoice), ctx))).toEqual({ + code: 'PERMISSION_DENIED', + status: 403, + }); + expect(mint).not.toHaveBeenCalled(); + expect(renderPdf).not.toHaveBeenCalled(); + }); + + it('archives the PDF as one committed sys_file and one sys_attachment, both naming the requester', async () => { + const ctx = await stack.contextFor(adminToken); mint.mockClear(); revoke.mockClear(); const out = await service.printRecordToFile(request(ownInvoice), ctx); @@ -215,10 +241,13 @@ describe('print service on a real showcase boot', () => { expect(file).toMatchObject({ status: 'committed', mime_type: 'application/pdf', size: PDF.byteLength, scope: 'attachments' }); const attachment = await ql.findOne('sys_attachment', { where: { id: out.attachmentId } }, { context: SYS }); expect(attachment).toMatchObject({ parent_object: 'showcase_invoice', parent_id: ownInvoice, file_id: out.fileId }); + // Attached AS THE CALLER: both rows name the requester. + expect(file!.owner_id).toBe(ctx.userId); + expect(attachment!.uploaded_by).toBe(ctx.userId); }); it('with the real auth mint: the render loads as the caller, and the session is gone once the print returns', async () => { - const ctx = await stack.contextFor(memberToken); + const ctx = await stack.contextFor(adminToken); const minter = principalSessionMinterOf(stack.kernel.getService('auth')); expect(minter, 'the auth service implements IAuthService.mintPrincipalSession').toBeDefined(); const asSession = (cookie: { name: string; value: string }) => diff --git a/packages/services/service-print/package.json b/packages/services/service-print/package.json index cc05f3f5194..c7ac03373c2 100644 --- a/packages/services/service-print/package.json +++ b/packages/services/service-print/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@objectstack/core": "workspace:*", + "@objectstack/service-storage": "workspace:*", "@objectstack/spec": "workspace:*", "playwright-core": "1.63.0" }, diff --git a/packages/services/service-print/src/print-service.test.ts b/packages/services/service-print/src/print-service.test.ts index 59f21a06887..f164436d900 100644 --- a/packages/services/service-print/src/print-service.test.ts +++ b/packages/services/service-print/src/print-service.test.ts @@ -41,6 +41,8 @@ interface Harness { mint: ReturnType; revoke: ReturnType; attachFile: ReturnType; + systemAttachFile: ReturnType; + assertMayAttach: ReturnType; findOne: ReturnType; metadataGet: ReturnType; } @@ -56,6 +58,8 @@ interface Setup { attachError?: unknown; noMint?: boolean; noAttachments?: boolean; + noCallerAttach?: boolean; + attachRefusal?: Error; } const configured: PrintPlacementResolution = { @@ -83,10 +87,14 @@ async function harness(setup: Setup = {}): Promise { return PDF; }), }; - const attachFile = vi.fn(async (input: { data: Buffer }) => { + const attachFile = vi.fn(async (input: { data: Buffer }, _context?: unknown) => { if (setup.attachError) throw setup.attachError; return { fileId: 'file_1', attachmentId: 'att_1', size: input.data.byteLength }; }); + const systemAttachFile = vi.fn(); + const assertMayAttach = vi.fn(async () => { + if (setup.attachRefusal) throw setup.attachRefusal; + }); const findOne = vi.fn(async () => { if (setup.readError) throw setup.readError; return 'record' in setup ? setup.record : { id: 'inv_1' }; @@ -106,11 +114,16 @@ async function harness(setup: Setup = {}): Promise { getObject: () => ('object' in setup ? setup.object : { name: 'invoice', enable: { files: true } }), }, }) as never, - getAttachments: () => (setup.noAttachments ? undefined : ({ attachFile } as never)), + getAttachments: () => + setup.noAttachments + ? undefined + : ((setup.noCallerAttach + ? { attachFile: systemAttachFile } + : { attachFile: systemAttachFile, attachFileAs: attachFile, assertMayAttach }) as never), getMinter: () => (setup.noMint ? undefined : minter), }); await service.probe(); - return { service, driver, mint, revoke, attachFile, findOne, metadataGet }; + return { service, driver, mint, revoke, attachFile, systemAttachFile, assertMayAttach, findOne, metadataGet }; } /** The refusal's ADR-0112 envelope: `code` and `status`. */ @@ -129,6 +142,7 @@ function expectNothingStarted(h: Harness): void { expect(h.mint).not.toHaveBeenCalled(); expect(h.driver.renderPdf).not.toHaveBeenCalled(); expect(h.attachFile).not.toHaveBeenCalled(); + expect(h.systemAttachFile).not.toHaveBeenCalled(); } describe('PrintService — the principal (step ①)', () => { @@ -280,6 +294,35 @@ describe('PrintService — the files opt-in (step ⑤)', () => { expectNothingStarted(h); }); + for (const [label, refusal] of [ + ['the attach rule refuses (no edit access)', ['ATTACHMENT_PARENT_ACCESS', 403]], + ['the caller may not create attachments at all', ['PERMISSION_DENIED', 403]], + ] as const) { + it(`${label}: the archive's own refusal, ${refusal[0]} / ${refusal[1]}, before any mint or render`, async () => { + const h = await harness({ attachRefusal: refusalError(refusal[0], refusal[1], label) }); + const r = await refusalOf(h.service.printRecordToFile(REQUEST, USER)); + expect([r.code, r.status]).toEqual([...refusal]); + expect(h.assertMayAttach).toHaveBeenCalledWith('invoice', 'inv_1', USER); + expect(h.assertMayAttach.mock.calls[0]![2]).toBe(USER); + expectNothingStarted(h); + }); + } + + it('a files refusal wins over the attach rule (the ruled order)', async () => { + const h = await harness({ object: { enable: {} }, attachRefusal: refusalError('ATTACHMENT_PARENT_ACCESS', 403, 'x') }); + const r = await refusalOf(h.service.printRecordToFile(REQUEST, USER)); + expect(r.code).toBe('FILES_DISABLED'); + expect(h.assertMayAttach).not.toHaveBeenCalled(); + }); + + it('an archive that can only attach as the system: unavailable, 503 — never a system-attributed PDF', async () => { + const h = await harness({ noCallerAttach: true }); + expect(h.service.status().reason).toContain('attachFileAs'); + const r = await refusalOf(h.service.printRecordToFile(REQUEST, USER)); + expect([r.code, r.status]).toEqual(['SERVICE_UNAVAILABLE', 503]); + expectNothingStarted(h); + }); + it('an unreadable record wins over a files refusal (the ruled order)', async () => { const h = await harness({ record: null, object: { enable: {} } }); const r = await refusalOf(h.service.printRecordToFile(REQUEST, USER)); @@ -302,14 +345,21 @@ describe('PrintService — mint, render, archive, revoke', () => { cookie: expect.objectContaining({ value: COOKIE_VALUE }), timeoutMs: PRINT_RENDER_TIMEOUT_MS, }); - expect(h.attachFile).toHaveBeenCalledWith({ - parentObject: 'invoice', - parentId: 'inv_1', - organizationId: 'org_1', - filename: 'invoice_print-inv_1.pdf', - mimeType: 'application/pdf', - data: PDF, - }); + expect(h.attachFile).toHaveBeenCalledWith( + { + parentObject: 'invoice', + parentId: 'inv_1', + organizationId: 'org_1', + filename: 'invoice_print-inv_1.pdf', + mimeType: 'application/pdf', + data: PDF, + }, + USER, + ); + // Archived AS THE CALLER: the caller's own context, and never the system verb. + expect(h.attachFile.mock.calls[0]![1]).toBe(USER); + expect(h.systemAttachFile).not.toHaveBeenCalled(); + expect(h.assertMayAttach).toHaveBeenCalledWith('invoice', 'inv_1', USER); expect(h.revoke).toHaveBeenCalledTimes(1); expect(out).toEqual({ fileId: 'file_1', attachmentId: 'att_1', size: PDF.byteLength, filename: 'invoice_print-inv_1.pdf' }); }); @@ -327,7 +377,7 @@ describe('PrintService — mint, render, archive, revoke', () => { it('a caller with no organization archives in none', async () => { const h = await harness(); await h.service.printRecordToFile({ ...REQUEST, filename: 'copy.pdf' }, { userId: 'usr_alice' } as ExecutionContext); - expect(h.attachFile).toHaveBeenCalledWith(expect.objectContaining({ organizationId: null, filename: 'copy.pdf' })); + expect(h.attachFile).toHaveBeenCalledWith(expect.objectContaining({ organizationId: null, filename: 'copy.pdf' }), expect.anything()); }); it('a failed render still revokes the session, and the failure is passed on unchanged', async () => { diff --git a/packages/services/service-print/src/print-service.ts b/packages/services/service-print/src/print-service.ts index 4dba3f4a200..0250ef4ae71 100644 --- a/packages/services/service-print/src/print-service.ts +++ b/packages/services/service-print/src/print-service.ts @@ -5,6 +5,7 @@ import type { RegisteredErrorCode, StandardErrorCode } from '@objectstack/spec/a import type { EngineSchemaRegistryView, IDataEngine, IFileAttachmentService } from '@objectstack/spec/contracts'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import { defaultRecordPageObject } from '@objectstack/spec/ui'; +import type { ICallerFileAttachmentService } from '@objectstack/service-storage'; import type { ChromiumDriver } from './chromium-driver.js'; import type { PrintPlacement, PrintPlacementResolution } from './config.js'; import type { PrincipalSessionMinter, PrintSession } from './principal-session.js'; @@ -34,20 +35,25 @@ import type { PrincipalSessionMinter, PrintSession } from './principal-session.j * a record that does not exist answer the same `RECORD_NOT_FOUND` / `404`, * so the answer says nothing about a record the caller may not see. A user * never archives what they could not read. - * 5. **The files opt-in.** The record's object must declare - * `enable: { files: true }`, the opt-in every attachment of it needs - * (`FILES_DISABLED` / `403`), checked here so a refused archive never costs - * a render. + * 5. **The files opt-in, and the attach rule.** The record's object must + * declare `enable: { files: true }`, the opt-in every attachment of it + * needs (`FILES_DISABLED` / `403`), and the caller must be allowed to + * attach a file to the record, asked of the archive with the insert's own + * answers (`assertMayAttach`): may they create attachments at all + * (`PERMISSION_DENIED` / `403`), and edit access to the record, the attach + * rule (`ATTACHMENT_PARENT_ACCESS` / `403`). All are checked here so a + * refused archive never costs a render. * * Then the session is minted for the caller's own principal, the page is * rendered with it, the PDF is archived through the `file-attachments` service, * and the session is revoked in a `finally` — after a failed render or a * refused archive as much as after a success. * - * The archive is the one writer of `sys_file` and `sys_attachment` - * (`IFileAttachmentService.attachFile`, `@objectstack/service-storage`): its - * size limit, its organization wall and every gate on an attachment insert - * judge this file exactly as they judge any other. + * The archive is the one writer of `sys_file` and `sys_attachment`, run AS + * THE CALLER (`attachFileAs`, `@objectstack/service-storage`): its size limit, + * its organization wall and every gate on an attachment insert judge this file + * for the requesting user exactly as they judge their own upload, and both + * rows name that user (`sys_file.owner_id`, `sys_attachment.uploaded_by`). */ /** A request to print one record. */ @@ -119,7 +125,7 @@ export interface PrintServiceDeps { /** The engine for the configured placement; `null` exactly when none is configured. */ driver: ChromiumDriver | null; getEngine(): PrintEngine | undefined; - getAttachments(): IFileAttachmentService | undefined; + getAttachments(): (IFileAttachmentService & Partial) | undefined; getMinter(): PrincipalSessionMinter | undefined; } @@ -206,12 +212,20 @@ export class PrintService implements IPrintService { "a page cannot be rendered as the requesting user. Compose an auth plugin that provides it." ); } - if (!this.deps.getAttachments()) { + const attachments = this.deps.getAttachments(); + if (!attachments) { return ( 'No file-attachments service is registered, so a rendered PDF has nowhere to be archived. Compose ' + "@objectstack/service-storage's StorageServicePlugin." ); } + if (typeof attachments.attachFileAs !== 'function' || typeof attachments.assertMayAttach !== 'function') { + return ( + 'The registered file-attachments service cannot attach a file as the requesting user (attachFileAs / ' + + "assertMayAttach, @objectstack/service-storage's ICallerFileAttachmentService), so a PDF could only be archived " + + 'as the system. Compose a StorageServicePlugin that provides them.' + ); + } if (!this.deps.getEngine()) return 'No data engine is registered, so a page or a record cannot be read.'; return undefined; } @@ -229,7 +243,7 @@ export class PrintService implements IPrintService { } const driver = this.deps.driver!; const engine = this.deps.getEngine()!; - const attachments = this.deps.getAttachments()!; + const attachments = this.deps.getAttachments() as IFileAttachmentService & ICallerFileAttachmentService; const minter = this.deps.getMinter()!; const placement = this.deps.placement as Extract; @@ -240,8 +254,9 @@ export class PrintService implements IPrintService { // ④ The record, read as the caller. await this.refuseUnlessReadable(engine, req, context); - // ⑤ The files opt-in. + // ⑤ The files opt-in, and the attach rule for the caller. this.refuseUnlessFilesEnabled(engine, req.object); + await attachments.assertMayAttach(req.object, req.recordId, context); // Mint, render, archive — and revoke, whatever happened. const filename = req.filename ?? `${req.page}-${req.recordId}.pdf`; @@ -251,14 +266,17 @@ export class PrintService implements IPrintService { const session: PrintSession = await minter.mint(context, PRINT_SESSION_TTL_SECONDS); try { const pdf = await driver.renderPdf({ url, cookie: session.cookie, timeoutMs: PRINT_RENDER_TIMEOUT_MS }); - const attached = await attachments.attachFile({ - parentObject: req.object, - parentId: req.recordId, - organizationId: nonEmptyString(context.tenantId) ? context.tenantId : null, - filename, - mimeType: PDF_MIME_TYPE, - data: pdf, - }); + const attached = await attachments.attachFileAs( + { + parentObject: req.object, + parentId: req.recordId, + organizationId: nonEmptyString(context.tenantId) ? context.tenantId : null, + filename, + mimeType: PDF_MIME_TYPE, + data: pdf, + }, + context, + ); return { fileId: attached.fileId, attachmentId: attached.attachmentId, size: attached.size, filename }; } finally { await session.revoke(); diff --git a/packages/services/service-storage/src/file-attachment-service.test.ts b/packages/services/service-storage/src/file-attachment-service.test.ts index ab68704b2c4..4f87ad317ef 100644 --- a/packages/services/service-storage/src/file-attachment-service.test.ts +++ b/packages/services/service-storage/src/file-attachment-service.test.ts @@ -43,7 +43,11 @@ import { SystemFile } from './objects/system-file.object.js'; import { StorageMetadataStoreError } from './metadata-store.js'; import { StorageServicePlugin } from './storage-service-plugin.js'; import type { SwappableStorageService } from './swappable-storage-service.js'; -import { FileAttachmentService, type FileAttachmentEngine } from './file-attachment-service.js'; +import { + FileAttachmentService, + type FileAttachmentEngine, + type ICallerFileAttachmentService, +} from './file-attachment-service.js'; const SYS = { context: { isSystem: true } } as const; /** `max_upload_mb` as saved: 0.001 MB ⇒ floor(0.001 × 1 MiB) = 1048 bytes. */ @@ -132,13 +136,15 @@ async function engineWithParents(): Promise { } /** Boot the plugin over `ql` through its real phases; `bootstrapped: false` stops before the verb is armed. */ -async function boot(opts: { bootstrapped?: boolean; engine?: boolean } = {}) { +async function boot(opts: { bootstrapped?: boolean; engine?: boolean; sharing?: unknown; security?: unknown } = {}) { const ql = await engineWithParents(); const rootDir = await fs.mkdtemp(join(tmpdir(), 'oss-23306-')); const plugin = new StorageServicePlugin({ adapter: 'local', local: { rootDir }, registerRoutes: false }); const { ctx, run } = makeCtx(); if (opts.engine !== false) ctx.registerService('objectql', ql); ctx.registerService('settings', storageSettings()); + if (opts.sharing) ctx.registerService('sharing', opts.sharing); + if (opts.security) ctx.registerService('security', opts.security); await plugin.init(ctx); await plugin.start(ctx); await run('kernel:ready'); @@ -418,3 +424,76 @@ describe('FILE_ATTACHMENT_SERVICE: the server-side attach verb (#23306)', () => }); }); }); + +describe('the file-attachments writer AS THE REQUESTING USER: assertMayAttach and attachFileAs', () => { + const ALICE = { userId: 'usr_alice', tenantId: 'org_a', positions: ['org_member'] }; + const asCaller = (a: IFileAttachmentService) => a as IFileAttachmentService & ICallerFileAttachmentService; + + it('refuses the system context and a context with no user: PERMISSION_DENIED / 403, nothing written', async () => { + const { ql, attachments, storage } = await boot(); + for (const context of [{ isSystem: true }, { tenantId: 'org_a' }]) { + const r = await refusalOf(asCaller(attachments).attachFileAs(input(), context as never)); + expect([r.code, r.status]).toEqual(['PERMISSION_DENIED', 403]); + const m = await refusalOf(asCaller(attachments).assertMayAttach('att_case', 'case_a', context as never)); + expect([m.code, m.status]).toEqual(['PERMISSION_DENIED', 403]); + } + await expectNothingLeft(ql, storage); + }); + + it('attaches as the caller: the sys_file is owned by them and the sys_attachment is stamped uploaded_by them', async () => { + const { ql, attachments } = await boot(); + const out = await asCaller(attachments).attachFileAs(input(), ALICE as never); + const [file] = await rows(ql, 'sys_file'); + expect(file).toMatchObject({ id: out.fileId, status: 'committed', owner_id: 'usr_alice', organization_id: 'org_a' }); + const [link] = await rows(ql, 'sys_attachment'); + expect(link).toMatchObject({ id: out.attachmentId, parent_id: 'case_a', uploaded_by: 'usr_alice' }); + }); + + it('the attach rule refuses the caller (no edit access): ATTACHMENT_PARENT_ACCESS / 403 from the insert gate, nothing left; assertMayAttach answers the same', async () => { + const sharing = { checkEdit: vi.fn(async () => 'deny') }; + const { ql, attachments, storage } = await boot({ sharing }); + const pre = await refusalOf(asCaller(attachments).assertMayAttach('att_case', 'case_a', ALICE as never)); + expect([pre.code, pre.status]).toEqual(['ATTACHMENT_PARENT_ACCESS', 403]); + const r = await refusalOf(asCaller(attachments).attachFileAs(input(), ALICE as never)); + expect([r.code, r.status]).toEqual(['ATTACHMENT_PARENT_ACCESS', 403]); + expect(sharing.checkEdit).toHaveBeenCalledWith('att_case', 'case_a', expect.objectContaining({ userId: 'usr_alice' })); + await expectNothingLeft(ql, storage); + }); + + it('a caller who may not create attachments at all: PERMISSION_DENIED / 403, asked of the security service before the attach rule', async () => { + const sharing = { checkEdit: vi.fn(async () => 'allow') }; + const security = { explain: vi.fn(async () => ({ allowed: false })) }; + const { attachments } = await boot({ sharing, security }); + const pre = await refusalOf(asCaller(attachments).assertMayAttach('att_case', 'case_a', ALICE as never)); + expect([pre.code, pre.status]).toEqual(['PERMISSION_DENIED', 403]); + expect(security.explain).toHaveBeenCalledWith( + { object: 'sys_attachment', operation: 'create' }, + expect.objectContaining({ userId: 'usr_alice' }), + ); + expect(sharing.checkEdit).not.toHaveBeenCalled(); + }); + + it('assertMayAttach admits what the insert gate admits', async () => { + const sharing = { checkEdit: vi.fn(async () => 'allow') }; + const { attachments } = await boot({ sharing }); + await expect(asCaller(attachments).assertMayAttach('att_case', 'case_a', ALICE as never)).resolves.toBeUndefined(); + }); + + it('a parent walled into another organization than the caller’s is refused as not found', async () => { + const { ql, attachments, storage } = await boot(); + const r = await refusalOf( + asCaller(attachments).attachFileAs(input({ parentId: 'case_b', organizationId: 'org_a' }), ALICE as never), + ); + expect([r.code, r.status]).toEqual(['RECORD_NOT_FOUND', 404]); + await expectNothingLeft(ql, storage); + }); + + it('an object without the files opt-in is refused FILES_DISABLED / 403 by the insert gate, as the caller too', async () => { + const { ql, attachments, storage } = await boot(); + const r = await refusalOf( + asCaller(attachments).attachFileAs(input({ parentObject: 'att_memo', parentId: 'memo_a' }), ALICE as never), + ); + expect([r.code, r.status]).toEqual(['FILES_DISABLED', 403]); + await expectNothingLeft(ql, storage); + }); +}); diff --git a/packages/services/service-storage/src/file-attachment-service.ts b/packages/services/service-storage/src/file-attachment-service.ts index af344ed6605..d8afdcbc9a6 100644 --- a/packages/services/service-storage/src/file-attachment-service.ts +++ b/packages/services/service-storage/src/file-attachment-service.ts @@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto'; import type { RegisteredErrorCode, StandardErrorCode } from '@objectstack/spec/api'; import type { + ISecurityService, AttachFileInput, AttachedFile, IDataEngine, @@ -112,8 +113,11 @@ export interface FileAttachmentServiceDeps { logger: { warn(message: string): void; debug?(message: string): void }; /** The sharing service, for the attach rule's verdict ({@link FileAttachmentService.mayAttach}). */ getSharing?(): AttachmentSharingLike | null | undefined; - /** The security service, for the attach rule's master-detail check. */ - getSecurity?(): AttachmentSecurityLike | null | undefined; + /** + * The security service: the attach rule's master-detail check, and `explain` + * for whether the caller may create an attachment at all. + */ + getSecurity?(): (AttachmentSecurityLike & Partial>) | null | undefined; } /** @@ -130,11 +134,15 @@ export interface FileAttachmentServiceDeps { */ export interface ICallerFileAttachmentService { /** - * Would the attach rule let `context` attach a file to the record? The - * verdict the `sys_attachment` insert gate itself asks (parent EDIT), with - * no write — for a producer to refuse before it spends work on the file. + * Refuse, with the answer the `sys_attachment` insert would give, unless + * `context` may attach a file to the record — with no write, for a producer + * to refuse before it spends work on the file. Two questions, each the + * insert's own: may the caller create an attachment at all (the CRUD + * middleware's verdict, asked through `ISecurityService.explain`; + * `PERMISSION_DENIED` / `403`), and does the attach rule admit them (parent + * EDIT, the insert gate's verdict; `ATTACHMENT_PARENT_ACCESS` / `403`). */ - mayAttach(parentObject: string, parentId: string, context: ExecutionContext): Promise; + assertMayAttach(parentObject: string, parentId: string, context: ExecutionContext): Promise; /** * {@link IFileAttachmentService.attachFile}, as `context`'s user. Refuses as * `attachFile` does, plus: a parent the caller cannot read answers @@ -171,7 +179,7 @@ function requireUserContext(context: ExecutionContext): ExecutionContext { refuse( 'PERMISSION_DENIED', 403, - 'attachFileAs / mayAttach act as a signed-in user: the context carries no user, or is the system context ' + + 'attachFileAs / assertMayAttach act as a signed-in user: the context carries no user, or is the system context ' + '(which attaches through attachFile).', ); } @@ -211,11 +219,29 @@ export class FileAttachmentService implements IFileAttachmentService, ICallerFil return this.attach(input, requireUserContext(context)); } - async mayAttach(parentObject: string, parentId: string, context: ExecutionContext): Promise { + async assertMayAttach(parentObject: string, parentId: string, context: ExecutionContext): Promise { const caller = requireUserContext(context); const engine = this.deps.getEngine(); if (!engine) refuse('SERVICE_UNAVAILABLE', 503, NOT_READY_MESSAGE); - return mayEditAttachmentParent( + let security: ReturnType>; + try { + security = this.deps.getSecurity?.(); + } catch { + security = undefined; + } + // A kernel with no security service enforces no object permission on the + // insert either, so there is nothing to ask. + if (security && typeof security.explain === 'function') { + const decision = await security.explain({ object: 'sys_attachment', operation: 'create' }, caller); + if (!decision.allowed) { + refuse( + 'PERMISSION_DENIED', + 403, + 'You cannot attach files: your permissions do not allow creating attachments (sys_attachment).', + ); + } + } + const allowed = await mayEditAttachmentParent( { getSharing: () => this.deps.getSharing?.(), getSecurity: () => this.deps.getSecurity?.(), @@ -227,6 +253,13 @@ export class FileAttachmentService implements IFileAttachmentService, ICallerFil 'attach', () => engine.findOne(parentObject, { where: { id: parentId } }, { context: caller }), ); + if (!allowed) { + refuse( + 'ATTACHMENT_PARENT_ACCESS', + 403, + `Cannot attach to ${parentObject}/${parentId}: the parent record does not exist or you cannot edit it`, + ); + } } /** The one write path: as the system (`caller` null), or as the caller. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8041a2ced38..1b8b7a726e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2836,6 +2836,9 @@ importers: '@objectstack/core': specifier: workspace:* version: link:../../core + '@objectstack/service-storage': + specifier: workspace:* + version: link:../service-storage '@objectstack/spec': specifier: workspace:* version: link:../../spec From ff291771f024af36aabd7022defb65ebd35a8334 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 05:21:00 +0000 Subject: [PATCH 15/21] chore(service-storage): changeset for the caller-scoped file-attachments face Claude-Session: https://claude.ai/code/session_01W2XFCTBfK4LabgqovmvSgK Co-authored-by: Claude --- .changeset/22269-print-render-service.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/22269-print-render-service.md b/.changeset/22269-print-render-service.md index aba55015cb2..040990d50f5 100644 --- a/.changeset/22269-print-render-service.md +++ b/.changeset/22269-print-render-service.md @@ -1,6 +1,7 @@ --- '@objectstack/service-print': minor '@objectstack/spec': patch +'@objectstack/service-storage': minor --- feat(service-print): print a record's print page to a PDF in headless Chromium, as the requesting user, and archive it as a file attached to the record @@ -18,6 +19,6 @@ plugins: [new PrintServicePlugin()] - **Loud at startup.** `start()` probes the engine and logs its placement and Chromium version. An engine that is not configured, is misconfigured or does not answer is reported once, with the setting to fix, and `status()` says why. A remote endpoint is never logged beyond its scheme and host. - **As the requesting user.** The page is rendered through a short-lived session minted for the caller's own principal by the auth service (`mintPrincipalSession`), and revoked in a `finally`. There is no system principal. - **Refusals first, each with `code` and `status`, before any session is minted or browser started:** a caller that is not a human user acting for themselves (`PERMISSION_DENIED` / 403); an engine that is not ready (`SERVICE_UNAVAILABLE` / 503); an unknown page (`RESOURCE_NOT_FOUND` / 404); a page that is not the object's default record page or declares no `print` (`VALIDATION_ERROR` / 400); a record the caller cannot read (`RECORD_NOT_FOUND` / 404); an object without `enable: { files: true }` (`FILES_DISABLED` / 403). -- **The archive** goes through `file-attachments` (`IFileAttachmentService.attachFile`): one `sys_file` and one `sys_attachment` on the record, under its size limit and organization wall. +- **The archive runs as the requesting user.** `@objectstack/service-storage`'s `file-attachments` service gains a caller-scoped face, `ICallerFileAttachmentService` (exported type), on the same slot and the same writer: `assertMayAttach(parentObject, parentId, context)` throws the insert's own refusal without writing (`PERMISSION_DENIED` / 403 when the caller may not create attachments, `ATTACHMENT_PARENT_ACCESS` / 403 when the attach rule refuses), and `attachFileAs(input, context)` writes one `sys_file` owned by the caller and one `sys_attachment` inserted under the caller's context, so every insert gate judges the caller and the row is stamped `uploaded_by` the caller. The print service asks `assertMayAttach` in its files step, before any session is minted or browser started. `attachFile` (the system verb) is unchanged. `@objectstack/spec`'s `llms.txt` counts the new package: the workspace publishes 72 packages. From fb36ebb7047e8928f815511344d996e57c9046e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 05:45:57 +0000 Subject: [PATCH 16/21] chore(service-storage): census row and a typed sharing lookup for the caller-scoped attach Claude-Session: https://claude.ai/code/session_01W2XFCTBfK4LabgqovmvSgK Co-authored-by: Claude --- content/docs/permissions/system-context.mdx | 1 + .../services/service-storage/src/storage-service-plugin.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index d42a1ef8aea..21246ca2c8c 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -172,6 +172,7 @@ One of the largest consumers of the flag; | 47b | The inbound-mail ingest seam: `ingest` admits the system context ALONE, and `rematch` admits it without the organization, owner or `manage_mailboxes` check | service-mailbox | Get: a mail source hands a mailbox its messages, and server code re-matches any mailbox. Lose: nothing a user held — every other caller of `ingest` is refused (`PERMISSION_DENIED`), because an ingested message is shown on every record its correspondents' addresses match and a user who could hand the seam a message could write one in a correspondent's name; every other caller of `rematch` must be in the mailbox's organization and own the mailbox or hold `manage_mailboxes` | `packages/services/service-mailbox/src/ingest/mail-ingest-service.ts#ingest`, `#authorizeRematch` | | 47c | The inbound-mail IMAP sync: `syncMailbox` and `syncAll` admit the system context ALONE | service-mailbox | Get: the sync job reads every mailbox row (its organization, address and IMAP cursor), writes the cursor back, and hands each fetched message to `ingest` under the system context. Lose: nothing a user held — every other caller is refused (`PERMISSION_DENIED`), because a sync stores a mailbox's mail and moves its cursor | `packages/services/service-mailbox/src/sync/mailbox-sync-service.ts#syncMailbox`, `#syncAll` | | 47d | The print service REFUSES the system context: `printRecordToFile` admits a human user acting for themselves alone | service-print | Get: nothing — a system caller is refused (`PERMISSION_DENIED`) before any page is read, session minted or browser started. Lose: nothing a user held — the PDF is rendered as the requesting user and never as the system, so a record is archived only by someone who could read it | `packages/services/service-print/src/print-service.ts#refuseUnlessHumanCaller` | +| 47e | The caller-scoped attach verbs REFUSE the system context: `attachFileAs` and `assertMayAttach` admit a signed-in user alone | service-storage | Get: nothing — a system caller is refused (`PERMISSION_DENIED`); the system attaches through `attachFile`, whose rows name no user. Lose: nothing a user held — the caller-scoped verbs insert the attachment under the caller's context so every insert gate judges that user and both rows name them | `packages/services/service-storage/src/file-attachment-service.ts#requireUserContext` | ### 5. Actions, metadata plane, provenance, the organization wall diff --git a/packages/services/service-storage/src/storage-service-plugin.ts b/packages/services/service-storage/src/storage-service-plugin.ts index f2d367eb41a..0c636120541 100644 --- a/packages/services/service-storage/src/storage-service-plugin.ts +++ b/packages/services/service-storage/src/storage-service-plugin.ts @@ -29,6 +29,7 @@ import type { IDataEngine, II18nService, ISecurityService, + ISharingService, IStorageService, IFileAccessDelegate, ApiKeyLifecyclePolicy, @@ -367,7 +368,7 @@ export class StorageServicePlugin implements Plugin { // call, the same two services the `sys_attachment` insert gate asks. getSharing: () => { try { - return ctx.getService('sharing'); + return ctx.getService('sharing'); } catch { return null; } From 9252cd175efca5e8875ce8956aed7ab7bdcbb4b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 08:05:29 +0000 Subject: [PATCH 17/21] test(dogfood): the print service's real render on both placements, as the requester, with the session gone after Claude-Session: https://claude.ai/code/session_01W2XFCTBfK4LabgqovmvSgK Co-authored-by: Claude --- .../test/print-service-render.dogfood.test.ts | 266 ++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 packages/qa/dogfood/test/print-service-render.dogfood.test.ts diff --git a/packages/qa/dogfood/test/print-service-render.dogfood.test.ts b/packages/qa/dogfood/test/print-service-render.dogfood.test.ts new file mode 100644 index 00000000000..9e52f2c358b --- /dev/null +++ b/packages/qa/dogfood/test/print-service-render.dogfood.test.ts @@ -0,0 +1,266 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The print service end to end, on a real showcase boot: `PrintServicePlugin` +// mints a session for the requesting user (`IAuthService.mintPrincipalSession`), +// headless Chromium loads the REAL console with only that session, prints the +// invoice's print page, and the PDF is archived on the invoice as the requester +// — on BOTH placements: +// +// - **local** — `chromiumPath`, the plugin launches the executable; +// - **remote** — `browserEndpoint`: this test spawns the same executable with +// its DevTools WebSocket endpoint open and hands the plugin that endpoint, +// so the browser is reached only through the remote placement's connect. +// +// Per placement it pins: +// - the console booted under the requester, never the system (every +// `get-session` the browser made answered the requester); +// - a real PDF archived as one `sys_attachment` on the invoice, with +// `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. +// +// Needs two things this suite's CI job does not build: the Console bundle +// (`packages/console/dist`, `pnpm objectui:build`) and a Chromium +// (`OS_TEST_PRINT_CHROMIUM_PATH`, or Playwright's provisioned +// `$PLAYWRIGHT_BROWSERS_PATH/chromium`). Without either the file is skipped and +// says so: a skipped render is not a passed one. + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { accessSync, constants, existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { createServer, type Server } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { VerifyStack } from '@objectstack/verify'; +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 { 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'; + +const SYS = { isSystem: true } as const; +const PAGE = 'showcase_invoice_document'; +const APP = 'com.example.showcase'; +const CONSOLE_DIST = fileURLToPath(new URL('../../../console/dist', import.meta.url)); + +function chromiumUnderTest(): string | undefined { + const candidates = [ + process.env.OS_TEST_PRINT_CHROMIUM_PATH, + process.env.PLAYWRIGHT_BROWSERS_PATH ? join(process.env.PLAYWRIGHT_BROWSERS_PATH, 'chromium') : undefined, + ]; + for (const candidate of candidates) { + if (!candidate) continue; + try { + accessSync(candidate, constants.X_OK); + return candidate; + } catch { + // not this one + } + } + return undefined; +} + +const CHROMIUM = chromiumUnderTest(); +const CONSOLE_BUILT = existsSync(join(CONSOLE_DIST, 'index.html')); +if (!CHROMIUM || !CONSOLE_BUILT) { + console.warn( + `[dogfood] print-service-render.dogfood.test.ts SKIPPED: ${!CONSOLE_BUILT ? 'no built Console (packages/console/dist; pnpm objectui:build)' : ''}` + + `${!CONSOLE_BUILT && !CHROMIUM ? ' and ' : ''}${!CHROMIUM ? 'no Chromium (OS_TEST_PRINT_CHROMIUM_PATH or PLAYWRIGHT_BROWSERS_PATH)' : ''}. ` + + 'The real render was NOT measured on this host.', + ); +} + +async function freePort(): Promise { + const s = createServer(); + await new Promise((r) => s.listen(0, '127.0.0.1', r)); + const { port } = s.address() as { port: number }; + await new Promise((r) => s.close(() => r())); + return port; +} + +async function spawnRemoteBrowser(executable: string): Promise<{ child: ChildProcess; endpoint: string; dir: string }> { + const dir = mkdtempSync(join(tmpdir(), 'os-print-render-remote-')); + const child = spawn( + executable, + ['--headless=new', '--no-sandbox', '--no-first-run', '--remote-debugging-address=127.0.0.1', '--remote-debugging-port=0', `--user-data-dir=${dir}`, 'about:blank'], + { stdio: ['ignore', 'ignore', 'pipe'] }, + ); + const endpoint = await new Promise((resolve, reject) => { + let stderr = ''; + const timer = setTimeout(() => reject(new Error('the remote browser printed no DevTools endpoint')), 20_000); + child.stderr!.on('data', (chunk) => { + stderr += String(chunk); + const m = stderr.match(/DevTools listening on (ws:\/\/\S+)/); + if (m) { + clearTimeout(timer); + resolve(m[1]!); + } + }); + }); + return { child, endpoint, dir }; +} + +interface Refusal { + code?: unknown; + status?: unknown; + message: string; +} + +async function refusalOf(p: Promise): Promise { + try { + await p; + } catch (err) { + const e = err as Refusal; + return { code: e.code, status: e.status, message: e.message }; + } + throw new Error('expected a refusal, the call resolved'); +} + +for (const placement of ['local', 'remote'] as const) { + describe.skipIf(!CHROMIUM || !CONSOLE_BUILT)(`print service real render (${placement} placement)`, () => { + let stack: VerifyStack; + let server: Server; + let remote: { child: ChildProcess; endpoint: string; dir: string } | undefined; + let rootDir: string; + let print: IPrintService; + let ql: IDataEngine; + /** The user id of every `get-session` the browser's requests resolved to. */ + const sessionUsers: Array = []; + /** Each session the plugin minted: its cookie, captured to prove it is gone afterwards. Never printed. */ + const minted: Array = []; + + beforeAll(async () => { + const port = await freePort(); + const engine = + placement === 'local' + ? { chromiumPath: CHROMIUM! } + : { browserEndpoint: (remote = await spawnRemoteBrowser(CHROMIUM!)).endpoint }; + rootDir = mkdtempSync(join(tmpdir(), 'print-render-storage-')); + stack = await bootShowcase({ + extraPlugins: [ + new StorageServicePlugin({ adapter: 'local', local: { rootDir }, bindToSettings: false }), + new AuditPlugin(), + createConsoleStaticPlugin(CONSOLE_DIST), + new PrintServicePlugin({ ...engine, consoleUrl: `http://127.0.0.1:${port}/_console` }), + ], + }); + // The real HTTP door the browser reaches: every request handed to the + // booted stack as it arrives, every answer handed back as it is. + server = createServer(async (req, res) => { + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + const headers: Record = {}; + for (const [k, v] of Object.entries(req.headers)) if (typeof v === 'string') headers[k] = v; + const hasBody = chunks.length > 0 && req.method !== 'GET' && req.method !== 'HEAD'; + const answer = await stack.raw(req.url!, { method: req.method, headers, ...(hasBody ? { body: Buffer.concat(chunks) } : {}) } as RequestInit); + const out: Record = {}; + answer.headers.forEach((v, k) => { + if (k !== 'set-cookie' && k !== 'content-encoding' && k !== 'content-length') out[k] = v; + }); + const setCookie = (answer.headers as unknown as { getSetCookie?: () => string[] }).getSetCookie?.() ?? []; + if (setCookie.length) out['set-cookie'] = setCookie; + const body = Buffer.from(await answer.arrayBuffer()); + if (req.url?.startsWith('/api/v1/auth/get-session')) { + try { + sessionUsers.push((JSON.parse(body.toString('utf8')) as { user?: { id?: string } } | null)?.user?.id ?? null); + } catch { + sessionUsers.push(null); + } + } + res.writeHead(answer.status, out); + res.end(body); + }); + await new Promise((r) => server.listen(port, '127.0.0.1', r)); + + print = stack.kernel.getService('print'); + ql = stack.kernel.getService('objectql'); + // Observe each mint (the plugin reads the auth service per call): keep the + // cookie so the test can ask, afterwards, whether it still opens a session. + const auth = stack.kernel.getService('auth'); + const mint = auth.mintPrincipalSession!.bind(auth); + auth.mintPrincipalSession = async (...args) => { + const session = await mint(...args); + minted.push(session.cookie); + return session; + }; + }, 240_000); + + afterAll(async () => { + server?.close(); + await stack?.stop(); + if (remote) { + const exited = new Promise((r) => (remote!.child.exitCode !== null ? r() : remote!.child.once('exit', () => r()))); + remote.child.kill(); + await exited; + rmSync(remote.dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + if (rootDir) rmSync(rootDir, { recursive: true, force: true }); + }); + + const opensASession = async (cookie: PrincipalSession['cookie']): Promise<[number, string | undefined]> => { + const res = await stack.raw('/api/v1/auth/get-session', { headers: { cookie: `${cookie.name}=${cookie.value}` } }); + const json = (await res.json()) as { user?: { id?: string }; error?: { code?: string } } | null; + return [res.status, json?.user?.id ?? json?.error?.code]; + }; + + const newInvoice = async (name: string, owner: string): Promise => { + const [seeded] = await ql.find('showcase_invoice', { limit: 1 }, { context: SYS }); + return String((await ql.insert('showcase_invoice', { name, owner, account: seeded!.account, status: 'draft' }, { context: SYS })).id); + }; + + it(`the engine is ready on the ${placement} placement`, () => { + expect(print.status()).toMatchObject({ state: 'ready', placement }); + }); + + it('prints the invoice through the real console as the requester, archives it as theirs, and the session is gone', async () => { + const token = await stack.signIn(); + const ctx = await stack.contextFor(token); + const invoice = await newInvoice(`INV-RENDER-${placement}`, 'admin@objectos.ai'); + sessionUsers.length = 0; + minted.length = 0; + + const out = await print.printRecordToFile({ app: APP, object: 'showcase_invoice', recordId: invoice, page: PAGE }, ctx); + + // The console booted under the requester, and only them. + expect(sessionUsers.length).toBeGreaterThan(0); + expect(new Set(sessionUsers)).toEqual(new Set([ctx.userId])); + // A real PDF, archived on the invoice as the requester's. + 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_invoice', parent_id: invoice, file_id: out.fileId, uploaded_by: ctx.userId }); + const bytes = await stack.kernel.getService('storage').download(String(file!.key)); + expect(bytes.subarray(0, 5).toString('latin1')).toBe('%PDF-'); + expect(bytes.byteLength).toBe(out.size); + // The minted session is gone. + 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); + const invoice = await newInvoice(`INV-RENDER-FAIL-${placement}`, 'admin@objectos.ai'); + minted.length = 0; + // Force the failure after the render: the archive's byte store refuses the upload once. + const storage = stack.kernel.getService('storage'); + const upload = storage.upload.bind(storage); + storage.upload = async () => { + storage.upload = upload; + throw new Error('forced byte-store failure'); + }; + + const r = await refusalOf(print.printRecordToFile({ app: APP, object: 'showcase_invoice', recordId: invoice, page: PAGE }, ctx)); + + expect(r.message).toContain('forced byte-store failure'); + expect(minted).toHaveLength(1); + expect(r.message).not.toContain(minted[0]!.value); + expect(await opensASession(minted[0]!)).toEqual([401, 'UNAUTHENTICATED']); + expect(await ql.find('sys_attachment', { where: { parent_id: invoice } }, { context: SYS })).toEqual([]); + }, 120_000); + }); +} From 834de8390107229f153c5500879d4d73563101e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 08:38:52 +0000 Subject: [PATCH 18/21] test(dogfood): declare the print render test's Console-bundle input Claude-Session: https://claude.ai/code/session_01W2XFCTBfK4LabgqovmvSgK Co-authored-by: Claude --- scripts/cross-package-test-inputs.mjs | 2 ++ turbo.json | 1 + 2 files changed, 3 insertions(+) diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs index 8f131d6a6c5..99b4d74228c 100644 --- a/scripts/cross-package-test-inputs.mjs +++ b/scripts/cross-package-test-inputs.mjs @@ -1171,6 +1171,8 @@ export const CROSS_PACKAGE_TEST_INPUTS = { 'packages/rest/src/**', 'packages/runtime/src/**', 'packages/services/service-print/src/**', + // print-service-render.dogfood.test.ts runs only where the Console bundle is built. + 'packages/console/dist/index.html', 'packages/services/service-realtime/src/**', // The three ledgers test/route-ledger-live-mount-parity.dogfood.test.ts // IMPORTS, which no read named and nothing declared until #10452 taught diff --git a/turbo.json b/turbo.json index 528fabeafa0..f3b9409e2bd 100644 --- a/turbo.json +++ b/turbo.json @@ -540,6 +540,7 @@ "$TURBO_ROOT$/packages/rest/src/**", "$TURBO_ROOT$/packages/runtime/src/**", "$TURBO_ROOT$/packages/services/service-print/src/**", + "$TURBO_ROOT$/packages/console/dist/index.html", "$TURBO_ROOT$/packages/services/service-realtime/src/**", "$TURBO_ROOT$/packages/services/service-storage/src/storage-route-ledger.ts", "$TURBO_ROOT$/packages/services/service-i18n/src/i18n-route-ledger.ts", From 19f0174a137cbcf5794cd3d0b7adfee605578a59 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 09:08:58 +0000 Subject: [PATCH 19/21] test(dogfood): name the Console bundle entry the render pin reads, and hold its glob by that test Claude-Session: https://claude.ai/code/session_01W2XFCTBfK4LabgqovmvSgK Co-authored-by: Claude --- .../qa/dogfood/test/print-service-render.dogfood.test.ts | 8 +++++--- scripts/cross-package-test-inputs.mjs | 7 +++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/qa/dogfood/test/print-service-render.dogfood.test.ts b/packages/qa/dogfood/test/print-service-render.dogfood.test.ts index 9e52f2c358b..b7ed6cbc9ac 100644 --- a/packages/qa/dogfood/test/print-service-render.dogfood.test.ts +++ b/packages/qa/dogfood/test/print-service-render.dogfood.test.ts @@ -31,7 +31,7 @@ import { spawn, type ChildProcess } from 'node:child_process'; import { accessSync, constants, existsSync, mkdtempSync, rmSync } from 'node:fs'; import { createServer, type Server } from 'node:http'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { VerifyStack } from '@objectstack/verify'; import { StorageServicePlugin } from '@objectstack/service-storage'; @@ -45,7 +45,9 @@ import type { IPrintService } from '../../../services/service-print/src/print-se const SYS = { isSystem: true } as const; const PAGE = 'showcase_invoice_document'; const APP = 'com.example.showcase'; -const CONSOLE_DIST = fileURLToPath(new URL('../../../console/dist', import.meta.url)); +/** The Console bundle's entry: its presence is what this file runs on. */ +const CONSOLE_INDEX = fileURLToPath(new URL('../../../console/dist/index.html', import.meta.url)); +const CONSOLE_DIST = dirname(CONSOLE_INDEX); function chromiumUnderTest(): string | undefined { const candidates = [ @@ -65,7 +67,7 @@ function chromiumUnderTest(): string | undefined { } const CHROMIUM = chromiumUnderTest(); -const CONSOLE_BUILT = existsSync(join(CONSOLE_DIST, 'index.html')); +const CONSOLE_BUILT = existsSync(CONSOLE_INDEX); if (!CHROMIUM || !CONSOLE_BUILT) { console.warn( `[dogfood] print-service-render.dogfood.test.ts SKIPPED: ${!CONSOLE_BUILT ? 'no built Console (packages/console/dist; pnpm objectui:build)' : ''}` + diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs index 99b4d74228c..a3cfb6764f8 100644 --- a/scripts/cross-package-test-inputs.mjs +++ b/scripts/cross-package-test-inputs.mjs @@ -1209,6 +1209,13 @@ export const CROSS_PACKAGE_TEST_INPUTS = { // change that reopens the #9084 blind spot, so it must re-run this test. 'content/docs/protocol/kernel/realtime-protocol.mdx', ], + heldBy: { + // The render pin runs only where the Console bundle is built, and reads it + // by its entry file. Where no bundle is built (this suite's CI job) the + // path names nothing on disk, so the roster cannot hold the glob; this + // test is what holds it. + 'packages/console/dist/index.html': ['packages/qa/dogfood/test/print-service-render.dogfood.test.ts'], + }, }, '@objectstack/vitest-filter-preflight': { // test/config-wiring-sweep.test.ts is the anti-phantom sweep for the ONE From f653bf5a7bc55acd2380a3619383caeb0edf2dc7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 10:14:45 +0000 Subject: [PATCH 20/21] =?UTF-8?q?fix(service-print,service-storage):=20pat?= =?UTF-8?q?ch=20round=202=20=E2=80=94=20one=20render=20budget,=20a=20quiet?= =?UTF-8?q?=20revoke,=20re-probe=20after=20the=20refusals,=20the=20caller'?= =?UTF-8?q?s=20organization=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01W2XFCTBfK4LabgqovmvSgK Co-authored-by: Claude --- .changeset/22269-print-render-service.md | 4 +- .../docs/deployment/environment-variables.mdx | 12 ++ content/docs/ui/print-pages.mdx | 21 ++- .../service-print/src/chromium-driver.ts | 41 ++++-- .../src/chromium-driver.unit.test.ts | 121 ++++++++++++++++++ .../services/service-print/src/config.test.ts | 9 ++ packages/services/service-print/src/config.ts | 18 +++ .../service-print/src/print-service-plugin.ts | 1 + .../service-print/src/print-service.test.ts | 64 ++++++++- .../service-print/src/print-service.ts | 53 +++++++- .../src/attachment-access-hooks.ts | 2 +- .../src/file-attachment-service.test.ts | 13 ++ .../src/file-attachment-service.ts | 32 ++++- 13 files changed, 356 insertions(+), 35 deletions(-) create mode 100644 packages/services/service-print/src/chromium-driver.unit.test.ts diff --git a/.changeset/22269-print-render-service.md b/.changeset/22269-print-render-service.md index 040990d50f5..cdc78400e31 100644 --- a/.changeset/22269-print-render-service.md +++ b/.changeset/22269-print-render-service.md @@ -18,7 +18,7 @@ plugins: [new PrintServicePlugin()] - **One engine, headless Chromium, in one of two placements.** A local executable (`OS_PRINT_CHROMIUM_PATH`, option `chromiumPath`), or a remote browser's DevTools WebSocket endpoint (`OS_PRINT_BROWSER_ENDPOINT`, option `browserEndpoint`). Either one needs `OS_PRINT_CONSOLE_URL` (option `consoleUrl`): the console's URL as that browser reaches it, `/_console` included. Configuring both placements is refused. `playwright-core` is pinned exact on this package only. - **Loud at startup.** `start()` probes the engine and logs its placement and Chromium version. An engine that is not configured, is misconfigured or does not answer is reported once, with the setting to fix, and `status()` says why. A remote endpoint is never logged beyond its scheme and host. - **As the requesting user.** The page is rendered through a short-lived session minted for the caller's own principal by the auth service (`mintPrincipalSession`), and revoked in a `finally`. There is no system principal. -- **Refusals first, each with `code` and `status`, before any session is minted or browser started:** a caller that is not a human user acting for themselves (`PERMISSION_DENIED` / 403); an engine that is not ready (`SERVICE_UNAVAILABLE` / 503); an unknown page (`RESOURCE_NOT_FOUND` / 404); a page that is not the object's default record page or declares no `print` (`VALIDATION_ERROR` / 400); a record the caller cannot read (`RECORD_NOT_FOUND` / 404); an object without `enable: { files: true }` (`FILES_DISABLED` / 403). -- **The archive runs as the requesting user.** `@objectstack/service-storage`'s `file-attachments` service gains a caller-scoped face, `ICallerFileAttachmentService` (exported type), on the same slot and the same writer: `assertMayAttach(parentObject, parentId, context)` throws the insert's own refusal without writing (`PERMISSION_DENIED` / 403 when the caller may not create attachments, `ATTACHMENT_PARENT_ACCESS` / 403 when the attach rule refuses), and `attachFileAs(input, context)` writes one `sys_file` owned by the caller and one `sys_attachment` inserted under the caller's context, so every insert gate judges the caller and the row is stamped `uploaded_by` the caller. The print service asks `assertMayAttach` in its files step, before any session is minted or browser started. `attachFile` (the system verb) is unchanged. +- **Refusals first, each with `code` and `status`, before any session is minted or browser started** (an engine that failed its last probe is re-probed only after them all): a caller that is not a human user acting for themselves (`PERMISSION_DENIED` / 403); an engine that is not ready (`SERVICE_UNAVAILABLE` / 503); an unknown page (`RESOURCE_NOT_FOUND` / 404); a page that is not the object's default record page or declares no `print` (`VALIDATION_ERROR` / 400); a record the caller cannot read (`RECORD_NOT_FOUND` / 404); an object without `enable: { files: true }` (`FILES_DISABLED` / 403). +- **The archive runs as the requesting user.** `@objectstack/service-storage`'s `file-attachments` service gains a caller-scoped face, `ICallerFileAttachmentService` (exported type), on the same slot and the same writer: `assertMayAttach(parentObject, parentId, context)` throws the insert's own refusal without writing (`PERMISSION_DENIED` / 403 when the caller may not create attachments, `ATTACHMENT_PARENT_ACCESS` / 403 when the attach rule refuses), and `attachFileAs(input, context)` writes one `sys_file` owned by the caller and one `sys_attachment` inserted under the caller's context, so every insert gate judges the caller and the row is stamped `uploaded_by` the caller. The print service asks `assertMayAttach` in its files step, before any session is minted or browser started. `attachFileAs` acts only in the caller's own organization: an `organizationId` other than the context's `tenantId` (or `null` when it names none) is refused `PERMISSION_DENIED` / 403. `attachFile` (the system verb) is unchanged. `@objectstack/spec`'s `llms.txt` counts the new package: the workspace publishes 72 packages. diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index 9b8cc9231a6..02f93925ed5 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -59,6 +59,18 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false --- +## Printing + +Read by `@objectstack/service-print` when `PrintServicePlugin` is composed; see [Print Pages](/docs/ui/print-pages). Configure **one** placement plus the console URL; a plugin option of the same purpose wins over its variable. + +| Variable | Type | Default | Description | +|:---|:---|:---|:---| +| `OS_PRINT_CHROMIUM_PATH` | path | — | **Local placement**: a Chromium executable on this host, launched per print (plugin option `chromiumPath`). | +| `OS_PRINT_BROWSER_ENDPOINT` | url | — | **Remote placement**: a browser's DevTools WebSocket endpoint, connected per print (option `browserEndpoint`). The minted session cookie travels in its frames, so use `wss://` off this host and `ws://` only on loopback. Never logged beyond its scheme and host. Setting it together with `OS_PRINT_CHROMIUM_PATH` is refused. | +| `OS_PRINT_CONSOLE_URL` | url | — | The console URL that browser loads, `/_console` included (option `consoleUrl`). A URL carrying credentials (`user:password@`) is refused: the browser signs in with the requesting user's own session. | + +--- + ## Secrets & Clustering | Variable | Type | Default | Description | diff --git a/content/docs/ui/print-pages.mdx b/content/docs/ui/print-pages.mdx index c8979cddc85..1484c294c94 100644 --- a/content/docs/ui/print-pages.mdx +++ b/content/docs/ui/print-pages.mdx @@ -64,8 +64,8 @@ one, plus the URL the browser loads the console from: | Setting | Plugin option | What it is | |:---|:---|:---| | `OS_PRINT_CHROMIUM_PATH` | `chromiumPath` | **Local placement**: a Chromium executable on this host, launched per print. | -| `OS_PRINT_BROWSER_ENDPOINT` | `browserEndpoint` | **Remote placement**: a browser's DevTools WebSocket endpoint (`ws://` or `wss://`), connected per print. For hosts that cannot carry a browser of their own. | -| `OS_PRINT_CONSOLE_URL` | `consoleUrl` | The console's URL as that browser reaches it, `/_console` included (`http://127.0.0.1:3000/_console`). | +| `OS_PRINT_BROWSER_ENDPOINT` | `browserEndpoint` | **Remote placement**: a browser's DevTools WebSocket endpoint (`ws://` or `wss://`), connected per print. For hosts that cannot carry a browser of their own. The requesting user's session cookie travels in the endpoint's frames: use `wss://` for a browser off this host, and `ws://` only on loopback. | +| `OS_PRINT_CONSOLE_URL` | `consoleUrl` | The console's URL as that browser reaches it, `/_console` included (`http://127.0.0.1:3000/_console`). A URL carrying credentials (`user:password@`) is refused. | A plugin option wins over its variable. At startup the plugin probes the engine — launches the executable or connects to the endpoint — and logs the placement @@ -97,8 +97,10 @@ const file = await print.printRecordToFile( The page is rendered as the requesting user — never as the system — through a short-lived session minted for that user and revoked as soon as the print ends, -whether it succeeded or not. A user can never archive a record they could not -read. +whether it succeeded or not. The PDF is archived as theirs: the attachment is +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. ## Refusals @@ -113,7 +115,14 @@ before a session is minted or a browser is started, in this order: | 3 | The page is not the object's default record page, 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 | +| 5 | The caller may not attach a file to this record (attaching needs edit access to it) | `ATTACHMENT_PARENT_ACCESS` / 403 | + +An engine that did not answer its last probe is probed once more only after +step 5, so no browser is started before every refusal above has had its say. After those, a print can still fail as the render or the archive does: the page -not loading in time (`TIMEOUT` / 504), or the archive's own refusals, such as a -file over the upload size limit (`PAYLOAD_TOO_LARGE` / 413). +not rendering within the 30 s budget (`TIMEOUT` / 504), or the archive's own +refusals, such as a file over the upload size limit (`PAYLOAD_TOO_LARGE` / 413). +A failure to end the session afterwards never replaces the print's own outcome; +it is logged, and the session expires on its own within 60 s. diff --git a/packages/services/service-print/src/chromium-driver.ts b/packages/services/service-print/src/chromium-driver.ts index 8ff4de8c440..58598ba5a5f 100644 --- a/packages/services/service-print/src/chromium-driver.ts +++ b/packages/services/service-print/src/chromium-driver.ts @@ -191,25 +191,41 @@ class PlaywrightChromiumDriver implements ChromiumDriver { const secrets = [this.resolved.target, input.cookie.value]; const started = Date.now(); const remaining = (): number => Math.max(1, input.timeoutMs - (Date.now() - started)); - let browser: BrowserHandle | undefined; - let context: ContextHandle | undefined; - try { - browser = await this.open(remaining()); - context = await browser.newContext(); - await context.addCookies([cookieFor(input.url, input.cookie)]); - const page = await context.newPage(); + const handles: { browser?: BrowserHandle; context?: ContextHandle } = {}; + // Every step of the render — open, the context, the cookie, the page, the + // load, the ready wait, the fonts and the PDF — runs inside ONE deadline: + // a browser that stalls on any of them answers TIMEOUT within the budget. + const render = (async (): Promise => { + handles.browser = await this.open(remaining()); + handles.context = await handles.browser.newContext(); + await handles.context.addCookies([cookieFor(input.url, input.cookie)]); + const page = await handles.context.newPage(); await page.goto(input.url, { waitUntil: 'load', timeout: remaining() }); await page.waitForFunction(PRINT_READY_PREDICATE, undefined, { timeout: remaining(), polling: POLL_INTERVAL_MS }); await page.evaluate(FONTS_READY); - return await page.pdf({ preferCSSPageSize: true, printBackground: true }); + return page.pdf({ preferCSSPageSize: true, printBackground: true }); + })(); + // Once the deadline answers, a late rejection of the abandoned render is + // expected (the cleanup below closes what it was waiting on). + render.catch(() => undefined); + let timer: ReturnType | undefined; + const deadline = new Promise((_, reject) => { + timer = setTimeout( + () => reject(Object.assign(new Error('the render budget ran out'), { name: 'TimeoutError' })), + input.timeoutMs, + ); + }); + try { + return await Promise.race([render, deadline]); } catch (err) { if (isTimeout(err)) { throw refusalError( 'TIMEOUT', 504, - `The print page did not finish loading within ${Math.round(input.timeoutMs / 1000)} s at ${input.url}: ` + + `The print page did not finish rendering within ${Math.round(input.timeoutMs / 1000)} s at ${input.url}: ` + 'the console never showed a print page for the record (a root carrying data-print-page, with no request ' + - 'in flight). Check that the console is served at that URL and reachable from the browser.', + 'in flight), or the browser stalled. Check that the console is served at that URL and reachable from the ' + + 'browser.', ); } throw refusalError( @@ -218,10 +234,11 @@ class PlaywrightChromiumDriver implements ChromiumDriver { `The print engine failed at ${this.where()}: ${redact(String((err as Error)?.message ?? err), secrets)}`, ); } finally { + clearTimeout(timer); // The context first: on the remote placement it holds the session cookie // inside a browser this process does not own. - if (context) await context.close().catch(() => undefined); - if (browser) await browser.close().catch(() => undefined); + if (handles.context) await handles.context.close().catch(() => undefined); + if (handles.browser) await handles.browser.close().catch(() => undefined); } } } diff --git a/packages/services/service-print/src/chromium-driver.unit.test.ts b/packages/services/service-print/src/chromium-driver.unit.test.ts new file mode 100644 index 00000000000..1e0c1602782 --- /dev/null +++ b/packages/services/service-print/src/chromium-driver.unit.test.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it, vi } from 'vitest'; +import { createChromiumDriver, type ChromiumLauncher } from './chromium-driver.js'; +import type { PrintSessionCookie } from './principal-session.js'; + +/** + * The driver with an injected launcher — no browser, so this file runs on every + * host. What a real Chromium cannot be made to do on demand is staged here: an + * engine error that echoes the session cookie, and a browser that stalls. + */ + +const COOKIE: PrintSessionCookie = { + name: 'os.session_token', + value: 'minted-cookie-value-9f2c.signature', + path: '/', + httpOnly: true, + secure: false, + sameSite: 'lax', +}; +const URL_ = 'http://127.0.0.1:3000/_console/apps/showcase/invoice/record/inv_1'; +const ENDPOINT_TOKEN = 'endpoint-token-7d1a'; + +/** A step that never settles: a stalled browser. */ +const never = (): Promise => new Promise(() => undefined); + +interface Stage { + goto?: () => Promise; + newContext?: () => Promise; + pdf?: () => Promise; + connect?: () => Promise; +} + +function stagedLauncher(stage: Stage) { + const closed = { context: 0, browser: 0 }; + const page = { + goto: vi.fn(stage.goto ?? (async () => undefined)), + waitForFunction: vi.fn(async () => undefined), + evaluate: vi.fn(async () => true), + pdf: vi.fn(stage.pdf ?? (async () => Buffer.from('%PDF-1.4'))), + }; + const context = { + addCookies: vi.fn(async () => undefined), + newPage: vi.fn(async () => page), + close: vi.fn(async () => { + closed.context++; + }), + }; + const browser = { + version: () => '141.0.0.0', + newContext: vi.fn(stage.newContext ?? (async () => context)), + close: vi.fn(async () => { + closed.browser++; + }), + }; + const launcher = { + launch: vi.fn(async () => browser), + connectOverCDP: vi.fn(stage.connect ?? (async () => browser)), + } as unknown as ChromiumLauncher; + return { launcher: async () => launcher, closed, page }; +} + +async function refusalOf(p: Promise): Promise<{ code?: unknown; status?: unknown; message: string }> { + try { + await p; + } catch (err) { + const e = err as { code?: unknown; status?: unknown; message: string }; + return { code: e.code, status: e.status, message: e.message }; + } + throw new Error('expected a refusal, the call resolved'); +} + +describe('the driver redacts the session cookie from an engine error', () => { + it('an engine error carrying the cookie value answers INTERNAL_ERROR / 500 with it replaced by [redacted]', async () => { + const { launcher, closed } = stagedLauncher({ + goto: async () => { + throw new Error(`net::ERR_ABORTED loading ${URL_} with cookie ${COOKIE.name}=${COOKIE.value}`); + }, + }); + const driver = createChromiumDriver({ placement: 'local', target: '/usr/bin/chromium', consoleUrl: 'x' }, launcher); + const r = await refusalOf(driver.renderPdf({ url: URL_, cookie: COOKIE, timeoutMs: 5_000 })); + expect([r.code, r.status]).toEqual(['INTERNAL_ERROR', 500]); + expect(r.message).toContain('[redacted]'); + expect(r.message).not.toContain(COOKIE.value); + expect(closed).toEqual({ context: 1, browser: 1 }); + }); + + it('a remote endpoint carrying a token is redacted from an engine error, and never named beyond scheme and host', async () => { + const endpoint = `wss://browser.example.com/devtools?token=${ENDPOINT_TOKEN}`; + const { launcher } = stagedLauncher({ + connect: async () => { + throw new Error(`WebSocket error: ${endpoint} 403 Forbidden`); + }, + }); + const driver = createChromiumDriver({ placement: 'remote', target: endpoint, consoleUrl: 'x' }, launcher); + const r = await refusalOf(driver.renderPdf({ url: URL_, cookie: COOKIE, timeoutMs: 5_000 })); + expect([r.code, r.status]).toEqual(['INTERNAL_ERROR', 500]); + expect(r.message).toContain('wss://browser.example.com'); + expect(r.message).not.toContain(ENDPOINT_TOKEN); + }); +}); + +describe('one budget bounds the whole render', () => { + for (const [step, stage] of [ + ['opening a context', { newContext: () => never() }], + ['printing the PDF', { pdf: () => never() }], + ] as const) { + it(`a browser that stalls ${step} answers TIMEOUT / 504 within the budget, and what was opened is closed`, async () => { + const { launcher, closed } = stagedLauncher(stage); + const driver = createChromiumDriver({ placement: 'remote', target: 'ws://127.0.0.1:9222/x', consoleUrl: 'x' }, launcher); + const started = Date.now(); + const r = await refusalOf(driver.renderPdf({ url: URL_, cookie: COOKIE, timeoutMs: 300 })); + const elapsed = Date.now() - started; + expect([r.code, r.status]).toEqual(['TIMEOUT', 504]); + expect(elapsed).toBeGreaterThanOrEqual(290); + expect(elapsed).toBeLessThan(2_000); + expect(closed.browser).toBe(1); + expect(r.message).not.toContain(COOKIE.value); + }); + } +}); diff --git a/packages/services/service-print/src/config.test.ts b/packages/services/service-print/src/config.test.ts index a64223e1f9c..1f9362703fd 100644 --- a/packages/services/service-print/src/config.test.ts +++ b/packages/services/service-print/src/config.test.ts @@ -67,6 +67,15 @@ describe('resolvePrintPlacement — one engine, two placements', () => { } }); + it('a console URL carrying credentials is misconfigured, and the credentials are not echoed', () => { + const r = resolvePrintPlacement({ chromiumPath: '/c', consoleUrl: 'http://print:hunter2-secret@127.0.0.1:3000/_console' }, {}); + expect(r.state).toBe('misconfigured'); + if (r.state === 'misconfigured') { + expect(r.reason).toContain(PRINT_CONSOLE_URL_ENV); + expect(r.reason).not.toContain('hunter2-secret'); + } + }); + it('a console URL that is not http(s), or carries a query, is misconfigured', () => { for (const consoleUrl of ['ftp://host/_console', 'not a url', `${CONSOLE}?x=1`]) { expect(resolvePrintPlacement({ chromiumPath: '/c', consoleUrl }, {}).state).toBe('misconfigured'); diff --git a/packages/services/service-print/src/config.ts b/packages/services/service-print/src/config.ts index 1e5915c451d..03769f685ae 100644 --- a/packages/services/service-print/src/config.ts +++ b/packages/services/service-print/src/config.ts @@ -27,6 +27,13 @@ * and names the value to fix. The plugin says so once at `start()`, and the * service refuses every render until it is fixed. * + * ## Credentials on the wire + * + * The minted session cookie travels to the browser in the DevTools protocol's + * own frames. On a `ws://` endpoint that is cleartext: use `wss://` for a + * remote browser off this host, and `ws://` only on loopback. The console URL + * carries no credentials of its own — one with `user:password@` is refused. + * * ## The remote endpoint may carry a secret * * A hosted browser commonly authenticates the WebSocket by a token in the @@ -165,6 +172,17 @@ export function resolvePrintPlacement( } catch { parsedConsole = null; } + if (parsedConsole && (parsedConsole.username !== '' || parsedConsole.password !== '')) { + // Credentials in the URL would ride every request the browser makes and + // every line that names the URL; the value is not echoed here either. + return { + state: 'misconfigured', + placement, + reason: + `${PRINT_CONSOLE_URL_ENV} (or consoleUrl) carries credentials (user:password@); the browser signs in with ` + + "the requesting user's own session, so the URL must carry none. Remove them.", + }; + } if (!parsedConsole || (parsedConsole.protocol !== 'http:' && parsedConsole.protocol !== 'https:')) { return { state: 'misconfigured', diff --git a/packages/services/service-print/src/print-service-plugin.ts b/packages/services/service-print/src/print-service-plugin.ts index 0a1b6078789..af82b908bf2 100644 --- a/packages/services/service-print/src/print-service-plugin.ts +++ b/packages/services/service-print/src/print-service-plugin.ts @@ -69,6 +69,7 @@ export class PrintServicePlugin implements Plugin { getEngine: () => optional('objectql'), getAttachments: () => optional(FILE_ATTACHMENT_SERVICE), getMinter: () => principalSessionMinterOf(optional('auth')), + logger: ctx.logger, }); ctx.registerService(PRINT_SERVICE, this.service); } diff --git a/packages/services/service-print/src/print-service.test.ts b/packages/services/service-print/src/print-service.test.ts index f164436d900..e43f2a11a2c 100644 --- a/packages/services/service-print/src/print-service.test.ts +++ b/packages/services/service-print/src/print-service.test.ts @@ -45,6 +45,7 @@ interface Harness { assertMayAttach: ReturnType; findOne: ReturnType; metadataGet: ReturnType; + loggerError: ReturnType; } interface Setup { @@ -60,6 +61,7 @@ interface Setup { noAttachments?: boolean; noCallerAttach?: boolean; attachRefusal?: Error; + revokeError?: Error; } const configured: PrintPlacementResolution = { @@ -69,7 +71,10 @@ const configured: PrintPlacementResolution = { async function harness(setup: Setup = {}): Promise { const placement = setup.placement ?? configured; - const revoke = vi.fn(async () => undefined); + const revoke = vi.fn(async () => { + if (setup.revokeError) throw setup.revokeError; + }); + const loggerError = vi.fn(); const session: PrintSession = { token: TOKEN, cookie: { name: 'os.session_token', value: COOKIE_VALUE, path: '/', httpOnly: true, secure: false, sameSite: 'lax' }, @@ -121,9 +126,10 @@ async function harness(setup: Setup = {}): Promise { ? { attachFile: systemAttachFile } : { attachFile: systemAttachFile, attachFileAs: attachFile, assertMayAttach }) as never), getMinter: () => (setup.noMint ? undefined : minter), + logger: { error: loggerError }, }); await service.probe(); - return { service, driver, mint, revoke, attachFile, systemAttachFile, assertMayAttach, findOne, metadataGet }; + return { service, driver, mint, revoke, attachFile, systemAttachFile, assertMayAttach, findOne, metadataGet, loggerError }; } /** The refusal's ADR-0112 envelope: `code` and `status`. */ @@ -199,6 +205,36 @@ describe('PrintService — the engine (step ②)', () => { expectNothingStarted(h); }); + it('an unreachable engine is re-probed only AFTER step 5: an unknown page is refused 404 with no browser started', async () => { + const h = await harness({ probeError: new Error('connect ECONNREFUSED'), page: undefined }); + const r = await refusalOf(h.service.printRecordToFile(REQUEST, USER)); + expect([r.code, r.status]).toEqual(['RESOURCE_NOT_FOUND', 404]); + expect(h.driver.probe).toHaveBeenCalledTimes(1); // the boot probe alone + expectNothingStarted(h); + }); + + it('an unreachable engine is re-probed only AFTER step 5: an attach refusal wins with no browser started', async () => { + const h = await harness({ + probeError: new Error('connect ECONNREFUSED'), + attachRefusal: refusalError('ATTACHMENT_PARENT_ACCESS', 403, 'no edit'), + }); + const r = await refusalOf(h.service.printRecordToFile(REQUEST, USER)); + expect([r.code, r.status]).toEqual(['ATTACHMENT_PARENT_ACCESS', 403]); + expect(h.driver.probe).toHaveBeenCalledTimes(1); + expectNothingStarted(h); + }); + + it('an engine that answers its re-probe after step 5 serves the print', async () => { + const h = await harness({ probeError: new Error('connect ECONNREFUSED') }); + h.driver.probe.mockResolvedValueOnce({ version: '141.0.7390.37' }); + const out = await h.service.printRecordToFile(REQUEST, USER); + expect(out.fileId).toBe('file_1'); + expect(h.driver.probe).toHaveBeenCalledTimes(2); + const order = (fn: ReturnType): number => fn.mock.invocationCallOrder[fn.mock.invocationCallOrder.length - 1]!; + expect(order(h.assertMayAttach)).toBeLessThan(order(h.driver.probe)); + expect(order(h.driver.probe)).toBeLessThan(order(h.mint)); + }); + it('no principal-session mint on the auth service: unavailable, 503, and status says why', async () => { const h = await harness({ noMint: true }); expect(h.service.status()).toMatchObject({ state: 'unavailable', version: '141.0.7390.37' }); @@ -395,6 +431,30 @@ describe('PrintService — mint, render, archive, revoke', () => { expect(h.revoke).toHaveBeenCalledTimes(1); }); + it('a revoke that fails after a committed archive: the caller still gets the success, and the failure is logged at error by fingerprint', async () => { + const h = await harness({ revokeError: new Error(`session store down while revoking ${TOKEN}`) }); + const out = await h.service.printRecordToFile(REQUEST, USER); + expect(out).toMatchObject({ fileId: 'file_1', attachmentId: 'att_1' }); + expect(h.revoke).toHaveBeenCalledTimes(1); + expect(h.loggerError).toHaveBeenCalledTimes(1); + const line = String(h.loggerError.mock.calls[0]![0]); + expect(line).toMatch(/fingerprint [0-9a-f]{12}/); + expect(line).toContain('usr_alice'); + expect(line).not.toContain(TOKEN); + expect(line).not.toContain(COOKIE_VALUE); + }); + + it('a revoke that fails after a refused print: the original refusal wins, and the failure is logged the same way', async () => { + const h = await harness({ + renderError: refusalError('TIMEOUT', 504, 'the print page did not load'), + revokeError: new Error('session store down'), + }); + const r = await refusalOf(h.service.printRecordToFile(REQUEST, USER)); + expect([r.code, r.status]).toEqual(['TIMEOUT', 504]); + expect(h.loggerError).toHaveBeenCalledTimes(1); + expect(String(h.loggerError.mock.calls[0]![0])).not.toContain(TOKEN); + }); + it('a mint refusal is passed on, and nothing is rendered', async () => { const h = await harness(); h.mint.mockRejectedValueOnce(refusalError('PERMISSION_DENIED', 403, 'banned')); diff --git a/packages/services/service-print/src/print-service.ts b/packages/services/service-print/src/print-service.ts index 0250ef4ae71..aa2bbe53b7e 100644 --- a/packages/services/service-print/src/print-service.ts +++ b/packages/services/service-print/src/print-service.ts @@ -1,5 +1,6 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +import { createHash } from 'node:crypto'; import { recordNotFoundError } from '@objectstack/core'; import type { RegisteredErrorCode, StandardErrorCode } from '@objectstack/spec/api'; import type { EngineSchemaRegistryView, IDataEngine, IFileAttachmentService } from '@objectstack/spec/contracts'; @@ -27,6 +28,10 @@ import type { PrincipalSessionMinter, PrintSession } from './principal-session.j * to: the PDF is what this user may see, rendered as this user. * 2. **The engine.** {@link PrintService.status} must be `ready`. Otherwise * `SERVICE_UNAVAILABLE` / `503`, carrying the reason `status()` gives. + * One exception keeps an engine recoverable without a restart: an engine + * that failed its last probe, with everything else composed, is probed + * once more AFTER step 5 — so even that probe never starts a browser + * before every other refusal has had its say. * 3. **The page.** The request names the page it expects; it must exist * (`RESOURCE_NOT_FOUND` / `404`), be bound to the record's object, declare * `print`, and be the object's default record page — the one page the @@ -47,7 +52,9 @@ import type { PrincipalSessionMinter, PrintSession } from './principal-session.j * Then the session is minted for the caller's own principal, the page is * rendered with it, the PDF is archived through the `file-attachments` service, * and the session is revoked in a `finally` — after a failed render or a - * refused archive as much as after a success. + * refused archive as much as after a success. A revoke that itself fails never + * replaces the print's outcome: it is logged at `error` by a fingerprint of the + * session (never its token), and the session's 60 s TTL ends it. * * The archive is the one writer of `sys_file` and `sys_attachment`, run AS * THE CALLER (`attachFileAs`, `@objectstack/service-storage`): its size limit, @@ -127,6 +134,8 @@ export interface PrintServiceDeps { getEngine(): PrintEngine | undefined; getAttachments(): (IFileAttachmentService & Partial) | undefined; getMinter(): PrincipalSessionMinter | undefined; + /** Where a failed revoke is reported. */ + logger?: { error(message: string): void }; } /** The whole render's time budget. */ @@ -234,11 +243,12 @@ export class PrintService implements IPrintService { // ① The principal. this.refuseUnlessHumanCaller(context); - // ② The engine. An engine that failed its probe is probed once more, so an - // endpoint that came up after boot serves without a restart. - let status = this.status(); - if (status.state === 'unavailable' && this.lastProbe && !this.lastProbe.ok) status = await this.probe(); - if (status.state !== 'ready') { + // ② The engine, as configured and composed. An engine whose last probe + // failed is probed once more — but only after step ⑤, so no browser is + // launched or connected before every other refusal has had its say. + const status = this.status(); + const reprobe = status.state === 'unavailable' && this.lastProbe?.ok === false && !this.missingService(); + if (status.state !== 'ready' && !reprobe) { printRefusal('SERVICE_UNAVAILABLE', 503, `Records cannot be printed to a file: ${status.reason}`); } const driver = this.deps.driver!; @@ -258,6 +268,15 @@ export class PrintService implements IPrintService { this.refuseUnlessFilesEnabled(engine, req.object); await attachments.assertMayAttach(req.object, req.recordId, context); + // ② again, for an engine that failed its last probe: probe it now, so an + // endpoint that came up after boot serves without a restart. + if (reprobe) { + const probed = await this.probe(); + if (probed.state !== 'ready') { + printRefusal('SERVICE_UNAVAILABLE', 503, `Records cannot be printed to a file: ${probed.reason}`); + } + } + // Mint, render, archive — and revoke, whatever happened. const filename = req.filename ?? `${req.page}-${req.recordId}.pdf`; const url = @@ -279,7 +298,29 @@ export class PrintService implements IPrintService { ); return { fileId: attached.fileId, attachmentId: attached.attachmentId, size: attached.size, filename }; } finally { + await this.revokeQuietly(session, context); + } + } + + /** + * End the minted session. A revoke that fails never replaces the print's own + * outcome — the committed archive or the refusal — and is not silent either: + * it is logged at `error`, naming the session by a fingerprint of its token + * (never the token or the cookie), and the session's own TTL ends it. + */ + private async revokeQuietly(session: PrintSession, context: ExecutionContext): Promise { + try { await session.revoke(); + } catch (err) { + const fingerprint = createHash('sha256').update(session.token).digest('hex').slice(0, 12); + const why = [session.token, session.cookie.value].reduce( + (text, secret) => (secret ? text.split(secret).join('[redacted]') : text), + String((err as Error)?.message ?? err), + ); + this.deps.logger?.error( + `[print] the session minted for user ${String(context.userId)} (fingerprint ${fingerprint}) could not be ` + + `revoked: ${why}. It stays valid until it expires, at most ${PRINT_SESSION_TTL_SECONDS} s after the mint.`, + ); } } diff --git a/packages/services/service-storage/src/attachment-access-hooks.ts b/packages/services/service-storage/src/attachment-access-hooks.ts index 8ddd212e3b5..02d8be472c6 100644 --- a/packages/services/service-storage/src/attachment-access-hooks.ts +++ b/packages/services/service-storage/src/attachment-access-hooks.ts @@ -324,7 +324,7 @@ export interface AttachmentParentVerdictDeps { * question the attach rule, the row rule's parent-editor limb and the * re-point's attach rule all ask — see "Parent EDIT" in the module header for * the composition and why each outcome lands where it does. Exported so the - * caller-scoped attach verb (`FileAttachmentService.mayAttach`) asks the SAME + * caller-scoped attach check (`FileAttachmentService.assertMayAttach`) asks the SAME * question before a producer spends work on a file it could not attach. * * `recordId` is the value the limb holds (the degraded read probe keeps the diff --git a/packages/services/service-storage/src/file-attachment-service.test.ts b/packages/services/service-storage/src/file-attachment-service.test.ts index 4f87ad317ef..1e50909b94c 100644 --- a/packages/services/service-storage/src/file-attachment-service.test.ts +++ b/packages/services/service-storage/src/file-attachment-service.test.ts @@ -479,6 +479,19 @@ describe('the file-attachments writer AS THE REQUESTING USER: assertMayAttach an await expect(asCaller(attachments).assertMayAttach('att_case', 'case_a', ALICE as never)).resolves.toBeUndefined(); }); + it('an organizationId other than the caller’s own is refused PERMISSION_DENIED / 403 before anything is written', async () => { + const { ql, attachments, storage } = await boot(); + for (const over of [ + { parentId: 'case_global', organizationId: 'org_b' }, + { parentId: 'case_a', organizationId: 'org_b' }, + { parentId: 'case_global', organizationId: null }, + ]) { + const r = await refusalOf(asCaller(attachments).attachFileAs(input(over), ALICE as never)); + expect([r.code, r.status]).toEqual(['PERMISSION_DENIED', 403]); + } + await expectNothingLeft(ql, storage); + }); + it('a parent walled into another organization than the caller’s is refused as not found', async () => { const { ql, attachments, storage } = await boot(); const r = await refusalOf( diff --git a/packages/services/service-storage/src/file-attachment-service.ts b/packages/services/service-storage/src/file-attachment-service.ts index d8afdcbc9a6..9e935c0f318 100644 --- a/packages/services/service-storage/src/file-attachment-service.ts +++ b/packages/services/service-storage/src/file-attachment-service.ts @@ -111,7 +111,7 @@ export interface FileAttachmentServiceDeps { /** The `storage` namespace's Limits group as last read — the upload doors' own source. */ getLimitsSnapshot(): StorageLimitsSnapshot | undefined; logger: { warn(message: string): void; debug?(message: string): void }; - /** The sharing service, for the attach rule's verdict ({@link FileAttachmentService.mayAttach}). */ + /** The sharing service, for the attach rule's verdict ({@link FileAttachmentService.assertMayAttach}). */ getSharing?(): AttachmentSharingLike | null | undefined; /** * The security service: the attach rule's master-detail check, and `explain` @@ -145,7 +145,9 @@ export interface ICallerFileAttachmentService { assertMayAttach(parentObject: string, parentId: string, context: ExecutionContext): Promise; /** * {@link IFileAttachmentService.attachFile}, as `context`'s user. Refuses as - * `attachFile` does, plus: a parent the caller cannot read answers + * `attachFile` does, plus: an `organizationId` other than the caller's own + * (the context's `tenantId`, or `null` when it names none) answers + * `PERMISSION_DENIED` / `403`; a parent the caller cannot read answers * `RECORD_NOT_FOUND` / `404`, and the attach rule's refusal * (`ATTACHMENT_PARENT_ACCESS` / `403`) is the insert gate's own. A context * without a user, or the system context, is refused `PERMISSION_DENIED` / @@ -265,6 +267,22 @@ export class FileAttachmentService implements IFileAttachmentService, ICallerFil /** The one write path: as the system (`caller` null), or as the caller. */ private async attach(input: AttachFileInput, caller: ExecutionContext | null): Promise { const request = validateAttachFileInput(input); + // As the caller, the call acts in the caller's own organization and no + // other: an input naming another (or none, for a caller who acts in one) + // is refused before anything is read or written. Without this, a global + // parent would take the sys_file into the named organization while the + // caller's insert lands in theirs. + if (caller) { + const callerOrganization = nonEmptyString(caller.tenantId) ? caller.tenantId : null; + if (request.organizationId !== callerOrganization) { + refuse( + 'PERMISSION_DENIED', + 403, + "attachFileAs: 'organizationId' must be the organization the caller acts in (the context's tenantId, or " + + 'null when it names none); a caller cannot attach a file in another organization.', + ); + } + } const engine = this.deps.getEngine(); const storage = this.deps.getStorage(); if (!engine || !storage) refuse('SERVICE_UNAVAILABLE', 503, NOT_READY_MESSAGE); @@ -352,10 +370,12 @@ export class FileAttachmentService implements IFileAttachmentService, ICallerFil } /** - * Read the parent as the system and answer the organization both rows are - * written in: the parent's own when it is walled into one, otherwise the - * caller's (`null` for none). A parent that does not exist, or that is walled - * into another organization than the caller's, is refused with one answer. + * Read the parent — as the system for `attachFile`, as the caller for + * `attachFileAs` — and answer the organization both rows are written in: the + * parent's own when it is walled into one, otherwise the call's (`null` for + * none). A parent that does not exist, that the caller cannot read, or that + * is walled into another organization than the call's, is refused with one + * answer. */ private async organizationOfParent( engine: FileAttachmentEngine, From 78e50355a2c0d3ef89e2d1b6191ac16238c6d96c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 11:54:04 +0000 Subject: [PATCH 21/21] =?UTF-8?q?fix(service-print):=20patch=20round=203?= =?UTF-8?q?=20=E2=80=94=20bounded=20cleanup,=20no=20raw=20console=20URL,?= =?UTF-8?q?=20the=20revoke=20log=20pinned=20through=20the=20plugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01W2XFCTBfK4LabgqovmvSgK Co-authored-by: Claude --- .../service-print/src/chromium-driver.ts | 144 ++++++++++++++---- .../src/chromium-driver.unit.test.ts | 80 +++++++++- .../services/service-print/src/config.test.ts | 16 ++ packages/services/service-print/src/config.ts | 19 ++- .../print-service-plugin.revoke-log.test.ts | 107 +++++++++++++ .../src/print-service-plugin.test.ts | 10 ++ .../service-print/src/print-service-plugin.ts | 3 +- 7 files changed, 341 insertions(+), 38 deletions(-) create mode 100644 packages/services/service-print/src/print-service-plugin.revoke-log.test.ts diff --git a/packages/services/service-print/src/chromium-driver.ts b/packages/services/service-print/src/chromium-driver.ts index 58598ba5a5f..1ee266530c1 100644 --- a/packages/services/service-print/src/chromium-driver.ts +++ b/packages/services/service-print/src/chromium-driver.ts @@ -9,12 +9,28 @@ import type { PrintSessionCookie } from './principal-session.js'; * `playwright-core` (pinned exact on this package only), in either placement * {@link ResolvedPrintPlacement} names. * - * - **local** — `chromium.launch({ executablePath })`, one browser per render, - * closed in a `finally`. + * - **local** — `chromium.launchServer({ executablePath })` and a connection to + * it, one browser process per render. Disposed by closing the connection + * and the server; a process whose close does not settle within + * {@link CLOSE_GRACE_MS} is killed. * - **remote** — `chromium.connectOverCDP(endpoint)`, one connection per - * render. Closing a connected browser disconnects from it and leaves the - * remote browser running; the context this render opened is closed first, - * so its cookies leave the remote browser with it. + * render. The context this render opened is closed first, so its cookies + * leave the remote browser with it; then the connection is closed, which + * leaves the remote browser running. Each close waits at most + * {@link CLOSE_GRACE_MS}: playwright opens a CDP context with + * `disposeOnDetach`, so dropping the connection disposes the context even + * when the browser no longer answers its close. + * + * ## The budget, and what it bounds + * + * Every step of a render — open, the context, the cookie, the page, the load, + * the ready wait, the fonts and the PDF — runs inside ONE deadline (the + * caller's `timeoutMs`): a browser that stalls on any of them answers + * `TIMEOUT` / `504` when the deadline passes. The cleanup that follows is + * bounded too, by {@link CLOSE_GRACE_MS} per close, so the call returns within + * the budget plus the grace periods even when the browser answers nothing + * more. A browser that arrives after the deadline (an open that settled + * late) is disposed by the abandoned render itself, never kept. * * Both placements run the SAME render code after the browser is open — the * remote placement is not a second driver, only a second way to reach the one @@ -79,11 +95,40 @@ interface PageHandle { evaluate(fn: string): Promise; pdf(options: { preferCSSPageSize: boolean; printBackground: boolean }): Promise; } +/** The subset of playwright-core's browser server this driver uses (local placement). */ +interface ServerHandle { + wsEndpoint(): string; + close(): Promise; + kill(): Promise; +} interface ChromiumLauncher { - launch(options: { executablePath: string; headless: boolean; timeout: number }): Promise; + launchServer(options: { executablePath: string; headless: boolean; timeout: number }): Promise; + connect(wsEndpoint: string, options: { timeout: number }): Promise; connectOverCDP(endpoint: string, options: { timeout: number }): Promise; } +/** How long each close in a cleanup may take before the driver stops waiting for it. */ +export const CLOSE_GRACE_MS = 2_000; + +/** Whether `promise` settles within `ms`; it is never awaited past that. */ +async function settlesWithin(promise: Promise, ms: number): Promise { + let timer: ReturnType | undefined; + const late = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), ms); + }); + try { + return await Promise.race([promise.then(() => true, () => true), late]); + } finally { + clearTimeout(timer); + } +} + +/** An open browser, and the bounded way to let go of it. */ +interface OpenBrowser { + browser: BrowserHandle; + dispose(): Promise; +} + /** * The ready predicate, evaluated in the page. A string, not a function, so the * page sees exactly this text and nothing from this module's scope. @@ -153,6 +198,7 @@ class PlaywrightChromiumDriver implements ChromiumDriver { constructor( private readonly resolved: ResolvedPrintPlacement, private readonly launcher: () => Promise, + private readonly closeGraceMs: number, ) { this.placement = resolved.placement; } @@ -164,26 +210,45 @@ class PlaywrightChromiumDriver implements ChromiumDriver { : `the remote endpoint ${describeEndpoint(this.resolved.target)}`; } - private async open(timeoutMs: number): Promise { + private async open(timeoutMs: number): Promise { const chromium = await this.launcher(); - return this.placement === 'local' - ? chromium.launch({ executablePath: this.resolved.target, headless: true, timeout: timeoutMs }) - : chromium.connectOverCDP(this.resolved.target, { timeout: timeoutMs }); + const grace = this.closeGraceMs; + if (this.placement === 'remote') { + const browser = await chromium.connectOverCDP(this.resolved.target, { timeout: timeoutMs }); + return { browser, dispose: async () => void (await settlesWithin(browser.close(), grace)) }; + } + const server = await chromium.launchServer({ executablePath: this.resolved.target, headless: true, timeout: timeoutMs }); + let browser: BrowserHandle; + try { + browser = await chromium.connect(server.wsEndpoint(), { timeout: timeoutMs }); + } catch (err) { + if (!(await settlesWithin(server.close(), grace))) await settlesWithin(server.kill(), grace); + throw err; + } + return { + browser, + dispose: async () => { + await settlesWithin(browser.close(), grace); + // The process this render launched: closed, or killed when the close + // does not settle. + if (!(await settlesWithin(server.close(), grace))) await settlesWithin(server.kill(), grace); + }, + }; } async probe(timeoutMs: number): Promise<{ version: string }> { - let browser: BrowserHandle; + let opened: OpenBrowser; try { - browser = await this.open(timeoutMs); + opened = await this.open(timeoutMs); } catch (err) { throw new Error( `Chromium could not be reached at ${this.where()}: ${redact(String((err as Error)?.message ?? err), [this.resolved.target])}`, ); } try { - return { version: browser.version() }; + return { version: opened.browser.version() }; } finally { - await browser.close().catch(() => undefined); + await opened.dispose(); } } @@ -191,22 +256,40 @@ class PlaywrightChromiumDriver implements ChromiumDriver { const secrets = [this.resolved.target, input.cookie.value]; const started = Date.now(); const remaining = (): number => Math.max(1, input.timeoutMs - (Date.now() - started)); - const handles: { browser?: BrowserHandle; context?: ContextHandle } = {}; - // Every step of the render — open, the context, the cookie, the page, the - // load, the ready wait, the fonts and the PDF — runs inside ONE deadline: - // a browser that stalls on any of them answers TIMEOUT within the budget. + const grace = this.closeGraceMs; + const held: { opened?: OpenBrowser; context?: ContextHandle } = {}; + let abandoned = false; + /** Let go of everything this render holds, each close bounded. */ + const release = async (): Promise => { + const { opened, context } = held; + held.opened = undefined; + held.context = undefined; + // The context first: on the remote placement it holds the session cookie + // inside a browser this process does not own. + if (context) await settlesWithin(context.close(), grace); + if (opened) await opened.dispose(); + }; + /** After a step settles: an abandoned render releases what it just got, and stops. */ + const stillWanted = async (): Promise => { + if (abandoned) { + await release(); + throw Object.assign(new Error('the render was abandoned at its deadline'), { name: 'TimeoutError' }); + } + }; const render = (async (): Promise => { - handles.browser = await this.open(remaining()); - handles.context = await handles.browser.newContext(); - await handles.context.addCookies([cookieFor(input.url, input.cookie)]); - const page = await handles.context.newPage(); + held.opened = await this.open(remaining()); + await stillWanted(); + held.context = await held.opened.browser.newContext(); + await stillWanted(); + await held.context.addCookies([cookieFor(input.url, input.cookie)]); + const page = await held.context.newPage(); await page.goto(input.url, { waitUntil: 'load', timeout: remaining() }); await page.waitForFunction(PRINT_READY_PREDICATE, undefined, { timeout: remaining(), polling: POLL_INTERVAL_MS }); await page.evaluate(FONTS_READY); return page.pdf({ preferCSSPageSize: true, printBackground: true }); })(); // Once the deadline answers, a late rejection of the abandoned render is - // expected (the cleanup below closes what it was waiting on). + // expected: it is the abandoned render releasing what it opened late. render.catch(() => undefined); let timer: ReturnType | undefined; const deadline = new Promise((_, reject) => { @@ -235,23 +318,22 @@ class PlaywrightChromiumDriver implements ChromiumDriver { ); } finally { clearTimeout(timer); - // The context first: on the remote placement it holds the session cookie - // inside a browser this process does not own. - if (handles.context) await handles.context.close().catch(() => undefined); - if (handles.browser) await handles.browser.close().catch(() => undefined); + abandoned = true; + await release(); } } } /** - * The driver for a resolved placement. `launcher` exists for tests that - * observe the calls; a deployment never passes it. + * The driver for a resolved placement. `launcher` and `options` exist for + * tests that stage a browser; a deployment passes neither. */ export function createChromiumDriver( resolved: ResolvedPrintPlacement, launcher: () => Promise = loadLauncher, + options: { closeGraceMs?: number } = {}, ): ChromiumDriver { - return new PlaywrightChromiumDriver(resolved, launcher); + return new PlaywrightChromiumDriver(resolved, launcher, options.closeGraceMs ?? CLOSE_GRACE_MS); } -export type { ChromiumLauncher, BrowserHandle, ContextHandle, PageHandle }; +export type { ChromiumLauncher, BrowserHandle, ContextHandle, PageHandle, ServerHandle }; diff --git a/packages/services/service-print/src/chromium-driver.unit.test.ts b/packages/services/service-print/src/chromium-driver.unit.test.ts index 1e0c1602782..aaf522cfb63 100644 --- a/packages/services/service-print/src/chromium-driver.unit.test.ts +++ b/packages/services/service-print/src/chromium-driver.unit.test.ts @@ -29,10 +29,13 @@ interface Stage { newContext?: () => Promise; pdf?: () => Promise; connect?: () => Promise; + contextClose?: () => Promise; + browserClose?: () => Promise; + serverClose?: () => Promise; } function stagedLauncher(stage: Stage) { - const closed = { context: 0, browser: 0 }; + const closed = { context: 0, browser: 0, server: 0, killed: 0 }; const page = { goto: vi.fn(stage.goto ?? (async () => undefined)), waitForFunction: vi.fn(async () => undefined), @@ -44,6 +47,7 @@ function stagedLauncher(stage: Stage) { newPage: vi.fn(async () => page), close: vi.fn(async () => { closed.context++; + if (stage.contextClose) await stage.contextClose(); }), }; const browser = { @@ -51,13 +55,25 @@ function stagedLauncher(stage: Stage) { newContext: vi.fn(stage.newContext ?? (async () => context)), close: vi.fn(async () => { closed.browser++; + if (stage.browserClose) await stage.browserClose(); + }), + }; + const server = { + wsEndpoint: () => 'ws://127.0.0.1:40001/local-server', + close: vi.fn(async () => { + closed.server++; + if (stage.serverClose) await stage.serverClose(); + }), + kill: vi.fn(async () => { + closed.killed++; }), }; const launcher = { - launch: vi.fn(async () => browser), + launchServer: vi.fn(async () => server), + connect: vi.fn(stage.connect ?? (async () => browser)), connectOverCDP: vi.fn(stage.connect ?? (async () => browser)), } as unknown as ChromiumLauncher; - return { launcher: async () => launcher, closed, page }; + return { launcher: async () => launcher, closed, page, browser }; } async function refusalOf(p: Promise): Promise<{ code?: unknown; status?: unknown; message: string }> { @@ -82,7 +98,7 @@ describe('the driver redacts the session cookie from an engine error', () => { expect([r.code, r.status]).toEqual(['INTERNAL_ERROR', 500]); expect(r.message).toContain('[redacted]'); expect(r.message).not.toContain(COOKIE.value); - expect(closed).toEqual({ context: 1, browser: 1 }); + expect(closed).toMatchObject({ context: 1, browser: 1, server: 1, killed: 0 }); }); it('a remote endpoint carrying a token is redacted from an engine error, and never named beyond scheme and host', async () => { @@ -119,3 +135,59 @@ describe('one budget bounds the whole render', () => { }); } }); + +describe('the cleanup after the deadline is bounded, and nothing opened late is kept', () => { + const GRACE = 100; + + it('remote: a context and a connection whose close never settles — the call still answers TIMEOUT within budget plus grace', async () => { + const { launcher, closed } = stagedLauncher({ + pdf: () => never(), + contextClose: () => never(), + browserClose: () => never(), + }); + const driver = createChromiumDriver({ placement: 'remote', target: 'ws://127.0.0.1:9222/x', consoleUrl: 'x' }, launcher, { + closeGraceMs: GRACE, + }); + const started = Date.now(); + const r = await refusalOf(driver.renderPdf({ url: URL_, cookie: COOKIE, timeoutMs: 300 })); + const elapsed = Date.now() - started; + expect([r.code, r.status]).toEqual(['TIMEOUT', 504]); + expect(elapsed).toBeLessThan(300 + 2 * GRACE + 500); + expect(closed).toMatchObject({ context: 1, browser: 1 }); + }); + + it('local: a browser process whose close never settles is killed, and the call still answers within budget plus grace', async () => { + const { launcher, closed } = stagedLauncher({ + pdf: () => never(), + browserClose: () => never(), + serverClose: () => never(), + }); + const driver = createChromiumDriver({ placement: 'local', target: '/usr/bin/chromium', consoleUrl: 'x' }, launcher, { + closeGraceMs: GRACE, + }); + const started = Date.now(); + const r = await refusalOf(driver.renderPdf({ url: URL_, cookie: COOKIE, timeoutMs: 300 })); + const elapsed = Date.now() - started; + expect([r.code, r.status]).toEqual(['TIMEOUT', 504]); + expect(elapsed).toBeLessThan(300 + 4 * GRACE + 500); + expect(closed).toMatchObject({ server: 1, killed: 1 }); + }); + + it('an open that settles after the deadline is closed by the abandoned render, and never given a context', async () => { + let arrive!: () => void; + const arrived = new Promise((r) => (arrive = r)); + const ref: { browser?: unknown } = {}; + const { launcher, closed, browser } = stagedLauncher({ connect: () => arrived.then(() => ref.browser) }); + ref.browser = browser; + const driver = createChromiumDriver({ placement: 'remote', target: 'ws://127.0.0.1:9222/x', consoleUrl: 'x' }, launcher, { + closeGraceMs: GRACE, + }); + const r = await refusalOf(driver.renderPdf({ url: URL_, cookie: COOKIE, timeoutMs: 200 })); + expect([r.code, r.status]).toEqual(['TIMEOUT', 504]); + expect(closed.browser).toBe(0); + arrive(); + await new Promise((r2) => setTimeout(r2, 50)); + expect(closed.browser).toBe(1); + expect(browser.newContext).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/services/service-print/src/config.test.ts b/packages/services/service-print/src/config.test.ts index 1f9362703fd..8b944a3e837 100644 --- a/packages/services/service-print/src/config.test.ts +++ b/packages/services/service-print/src/config.test.ts @@ -67,6 +67,22 @@ describe('resolvePrintPlacement — one engine, two placements', () => { } }); + it('a console URL is never echoed: credentials with a non-numeric port, and a query, are described by scheme and host or as unparseable', () => { + const unparseable = resolvePrintPlacement({ chromiumPath: '/c', consoleUrl: 'http://print:topsecret@127.0.0.1:abc/_console' }, {}); + expect(unparseable.state).toBe('misconfigured'); + if (unparseable.state === 'misconfigured') { + expect(unparseable.reason).toContain('(a value that is not a URL)'); + expect(unparseable.reason).not.toContain('topsecret'); + expect(unparseable.reason).not.toContain('print:'); + } + const query = resolvePrintPlacement({ chromiumPath: '/c', consoleUrl: 'http://127.0.0.1:3000/_console?token=topsecret' }, {}); + expect(query.state).toBe('misconfigured'); + if (query.state === 'misconfigured') { + expect(query.reason).toContain('http://127.0.0.1:3000'); + expect(query.reason).not.toContain('topsecret'); + } + }); + it('a console URL carrying credentials is misconfigured, and the credentials are not echoed', () => { const r = resolvePrintPlacement({ chromiumPath: '/c', consoleUrl: 'http://print:hunter2-secret@127.0.0.1:3000/_console' }, {}); expect(r.state).toBe('misconfigured'); diff --git a/packages/services/service-print/src/config.ts b/packages/services/service-print/src/config.ts index 03769f685ae..e7f450fad93 100644 --- a/packages/services/service-print/src/config.ts +++ b/packages/services/service-print/src/config.ts @@ -102,6 +102,21 @@ function setting(option: string | undefined, env: string | undefined): string | * path or query, which may carry a token. An endpoint that does not parse is * described as such, without its text. */ +/** + * The console URL as a log line or a status may name it: its scheme and host, + * never its path, credentials or query — or, for a value that does not parse, + * a statement that it does not, never the value. + */ +export function describeConsoleUrl(value: string): string { + try { + const url = new URL(value); + return `${url.protocol}//${url.host}`; + } catch { + return '(a value that is not a URL)'; + } +} + +/** {@link describeConsoleUrl}'s counterpart for the remote endpoint. */ export function describeEndpoint(endpoint: string): string { try { const url = new URL(endpoint); @@ -187,14 +202,14 @@ export function resolvePrintPlacement( return { state: 'misconfigured', placement, - reason: `${PRINT_CONSOLE_URL_ENV} (or consoleUrl) must be an http:// or https:// URL; it is '${consoleUrl}'.`, + reason: `${PRINT_CONSOLE_URL_ENV} (or consoleUrl) must be an http:// or https:// URL; it is ${describeConsoleUrl(consoleUrl)}.`, }; } if (parsedConsole.search !== '' || parsedConsole.hash !== '') { return { state: 'misconfigured', placement, - reason: `${PRINT_CONSOLE_URL_ENV} (or consoleUrl) must be the console's base URL, with no query or fragment; it is '${consoleUrl}'.`, + reason: `${PRINT_CONSOLE_URL_ENV} (or consoleUrl) must be the console's base URL, with no query or fragment; it is ${describeConsoleUrl(consoleUrl)} with a query or fragment.`, }; } diff --git a/packages/services/service-print/src/print-service-plugin.revoke-log.test.ts b/packages/services/service-print/src/print-service-plugin.revoke-log.test.ts new file mode 100644 index 00000000000..c63c0e0705e --- /dev/null +++ b/packages/services/service-print/src/print-service-plugin.revoke-log.test.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it, vi } from 'vitest'; +import type { PluginContext } from '@objectstack/core'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; + +/** + * The plugin as composed wires the service's logger: a revoke that fails after + * a print reaches the PLUGIN's logger at `error`. Pinned through the plugin, not + * the service, because that one line of composition is what makes the failure + * loud. `playwright-core` is replaced by a staged launcher, so this runs on + * every host; the real library is exercised in the plugin's other suites. + */ + +vi.mock('playwright-core', () => { + const page = { + goto: async () => undefined, + waitForFunction: async () => undefined, + evaluate: async () => true, + pdf: async () => Buffer.from('%PDF-1.4 staged'), + }; + const context = { addCookies: async () => undefined, newPage: async () => page, close: async () => undefined }; + const browser = { version: () => '141.0.0.0', newContext: async () => context, close: async () => undefined }; + const server = { wsEndpoint: () => 'ws://127.0.0.1:1/staged', close: async () => undefined, kill: async () => undefined }; + return { + chromium: { + launchServer: async () => server, + connect: async () => browser, + connectOverCDP: async () => browser, + }, + }; +}); + +const { PrintServicePlugin, PRINT_SERVICE } = await import('./print-service-plugin.js'); +const { FILE_ATTACHMENT_SERVICE } = await import('@objectstack/spec/contracts'); + +const TOKEN = 'minted-token-for-the-revoke-log-pin'; +const USER = { userId: 'usr_alice', tenantId: 'org_1', principalKind: 'human' } as ExecutionContext; + +describe('PrintServicePlugin wires its logger into the service', () => { + it('a revoke that fails after a successful print is logged at error on the plugin’s logger, by fingerprint', async () => { + const logged: Array<{ level: string; message: string }> = []; + const services = new Map([ + [ + 'auth', + { + mintPrincipalSession: async () => ({ + token: TOKEN, + cookie: { name: 'os.session_token', value: TOKEN, path: '/', httpOnly: true, secure: false }, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + revoke: async () => { + throw new Error('session store unavailable'); + }, + }), + }, + ], + [ + 'objectql', + { + findOne: async () => ({ id: 'inv_1' }), + registry: { + getItem: () => ({ name: 'invoice_print', type: 'record', object: 'invoice', isDefault: true, print: {} }), + getObject: () => ({ name: 'invoice', enable: { files: true } }), + }, + }, + ], + [ + FILE_ATTACHMENT_SERVICE, + { + attachFile: async () => { + throw new Error('the system verb must not be used'); + }, + assertMayAttach: async () => undefined, + attachFileAs: async (input: { data: Buffer }) => ({ fileId: 'file_1', attachmentId: 'att_1', size: input.data.byteLength }), + }, + ], + ]); + const ctx = { + logger: { + info: (message: string) => logged.push({ level: 'info', message }), + warn: (message: string) => logged.push({ level: 'warn', message }), + error: (message: string) => logged.push({ level: 'error', message }), + debug: () => undefined, + }, + 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: () => undefined, + } as unknown as PluginContext; + + const plugin = new PrintServicePlugin({ chromiumPath: '/staged/chromium', consoleUrl: 'http://127.0.0.1:3000/_console' }); + await plugin.init(ctx); + await plugin.start(ctx); + const print = services.get(PRINT_SERVICE) as { printRecordToFile: Function; status: Function }; + expect(print.status()).toMatchObject({ state: 'ready' }); + + const out = await print.printRecordToFile({ app: 'app', object: 'invoice', recordId: 'inv_1', page: 'invoice_print' }, USER); + + expect(out).toMatchObject({ fileId: 'file_1' }); + const errors = logged.filter((l) => l.level === 'error'); + expect(errors).toHaveLength(1); + expect(errors[0]!.message).toMatch(/fingerprint [0-9a-f]{12}/); + expect(errors[0]!.message).not.toContain(TOKEN); + }); +}); diff --git a/packages/services/service-print/src/print-service-plugin.test.ts b/packages/services/service-print/src/print-service-plugin.test.ts index 893303dda05..f687181b208 100644 --- a/packages/services/service-print/src/print-service-plugin.test.ts +++ b/packages/services/service-print/src/print-service-plugin.test.ts @@ -110,6 +110,16 @@ describe('PrintServicePlugin.start — absence is loud, once', () => { expect(logged.some((l) => l.level === 'info')).toBe(false); }, 30_000); + it('a console URL carrying credentials and a non-numeric port reaches no log line and no status', async () => { + const { logged, service } = await boot({ + chromiumPath: '/nonexistent/chromium-for-print-test', + consoleUrl: 'http://print:topsecret@127.0.0.1:abc/_console', + }); + expect(logged.filter((l) => l.level === 'warn')).toHaveLength(1); + expect(JSON.stringify(logged)).not.toContain('topsecret'); + expect(JSON.stringify(service.status())).not.toContain('topsecret'); + }); + it('a remote endpoint nobody answers: one warn naming its host, never its token', async () => { const { logged, service } = await boot({ browserEndpoint: `ws://127.0.0.1:9/devtools/browser/x?token=${SECRET}`, diff --git a/packages/services/service-print/src/print-service-plugin.ts b/packages/services/service-print/src/print-service-plugin.ts index af82b908bf2..628070c93c9 100644 --- a/packages/services/service-print/src/print-service-plugin.ts +++ b/packages/services/service-print/src/print-service-plugin.ts @@ -4,6 +4,7 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import { FILE_ATTACHMENT_SERVICE, type IAuthService, type IFileAttachmentService } from '@objectstack/spec/contracts'; import { createChromiumDriver } from './chromium-driver.js'; import { + describeConsoleUrl, describeEndpoint, resolvePrintPlacement, type PrintPlacementOptions, @@ -96,7 +97,7 @@ export class PrintServicePlugin implements Plugin { ctx.logger.warn(`PrintServicePlugin: printing is off until this is fixed — ${probed.reason}`); return; } - ctx.logger.info(`PrintServicePlugin: Chromium ${probed.version} on the ${where}; console at ${resolved.consoleUrl}`); + ctx.logger.info(`PrintServicePlugin: Chromium ${probed.version} on the ${where}; console at ${describeConsoleUrl(resolved.consoleUrl)}`); ctx.hook('kernel:ready', async () => { const ready = service.status(); if (ready.state !== 'ready') {