Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
b083220
feat(service-print): scaffold the print service package (WIP)
claude Oct 16, 2026
7c7f036
feat(service-print): the print service, its Chromium driver on two pl…
claude Oct 16, 2026
025d3ac
feat(example-showcase): an invoice print page, and files on invoices …
claude Oct 16, 2026
b600e42
feat(service-print): read pages and objects from the engine's schema …
claude Oct 16, 2026
7e32a3b
docs(service-print): the print pages guide, the package row, the chan…
claude Oct 16, 2026
68ab49b
feat(example-showcase): the invoice print page names its detail sections
claude Oct 16, 2026
f48896f
Merge remote-tracking branch 'origin/main' into claude/issue-22269-pr…
claude Oct 16, 2026
cff4e6d
chore(service-print): version in step with its fixed group
claude Oct 16, 2026
967431d
feat(example-showcase): translate the invoice print page's section he…
claude Oct 16, 2026
52dd46e
chore(service-print): the registries and gates a new package and a ne…
claude Oct 16, 2026
51858e2
test(dogfood): declare the print test's service-print input and type …
claude Oct 16, 2026
41b8919
Merge remote-tracking branch 'origin/main' into claude/issue-22269-pr…
claude Oct 16, 2026
dcaa22d
feat(service-print): bind the principal-session mint to IAuthService.…
claude Oct 16, 2026
4aa215a
test(dogfood): pin the real mint — the render loads as the caller, th…
claude Oct 16, 2026
5cc84cc
Merge remote-tracking branch 'origin/main' into claude/issue-22269-pr…
claude Oct 16, 2026
4f9a349
feat(service-storage): the file-attachments writer as the requesting …
claude Oct 16, 2026
2d05495
feat(service-print,service-storage): archive the printed PDF as the r…
claude Oct 16, 2026
ff29177
chore(service-storage): changeset for the caller-scoped file-attachme…
claude Oct 16, 2026
fb36ebb
chore(service-storage): census row and a typed sharing lookup for the…
claude Oct 16, 2026
fff7ed7
Merge remote-tracking branch 'origin/main' into claude/issue-22269-pr…
claude Oct 16, 2026
9252cd1
test(dogfood): the print service's real render on both placements, as…
claude Oct 16, 2026
834de83
test(dogfood): declare the print render test's Console-bundle input
claude Oct 16, 2026
19f0174
test(dogfood): name the Console bundle entry the render pin reads, an…
claude Oct 16, 2026
f653bf5
fix(service-print,service-storage): patch round 2 — one render budget…
claude Oct 16, 2026
78e5035
fix(service-print): patch round 3 — bounded cleanup, no raw console U…
claude Oct 16, 2026
5a16f29
Merge remote-tracking branch 'origin/main' into claude/issue-22269-pr…
claude Oct 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/22269-print-render-service.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@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

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** (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.
1 change: 1 addition & 0 deletions .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions content/docs/deployment/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ 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` |
| 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

Expand Down
9 changes: 8 additions & 1 deletion content/docs/plugins/packages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions content/docs/protocol/backward-compatibility.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions content/docs/ui/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const CrmApp = App.create({
<Cards>
<Card href="/docs/ui/apps" title="Apps" description="App metadata: navigation, branding, entry points" />
<Card href="/docs/ui/pages" title="Pages" description="Page metadata and layout composition" />
<Card href="/docs/ui/print-pages" title="Print Pages" description="Print a record to a PDF and archive it as an attachment" />
<Card href="/docs/ui/react-pages" title="React Pages" description="Author a page body as real React, or as JSX that is parsed and never executed" />
<Card href="/docs/ui/views" title="Views" description="Grid, kanban, calendar, gantt, map, and more" />
<Card href="/docs/ui/actions" title="Actions" description="Declarative buttons with server-side behavior, bound to lists and records" />
Expand Down
1 change: 1 addition & 0 deletions content/docs/ui/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"pages": [
"apps",
"pages",
"print-pages",
"react-pages",
"views",
"actions",
Expand Down
Loading
Loading