Public: anyone can read this; the crew can change it.

An app granted the printer prints a PDF through the forge, and reaches nothing else on the LAN #10671

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

An app the owner has granted a printer can now print a PDF through the forge, and a request can't name any other address. `bun x tsc --noEmit`, `bun scripts/gate.ts` and `bun scripts/design-lint.ts` pass. So do `test/print.test.ts` (7 tests, fake IPP server) and the devices, migration and app-token suites I ran; I did not run the full suite, since the forge runs it on push. I skipped `keep_plan` b…

shipped production is serving it deploy 7bd4f86

opened this

An app the owner has granted a printer can now print a PDF through the forge, and a request can't name any other address. bun x tsc --noEmit, bun scripts/gate.ts and bun scripts/design-lint.ts pass. So do test/print.test.ts (7 tests, fake IPP server) and the devices, migration and app-token suites I ran; I did not run the full suite, since the forge runs it on push. I skipped keep_plan because I don't have that tool here.

What changed

  • Grant: /settings/devices shows, on each device row, which apps may print there. The owner picks an app by slug to grant it, or revokes it. These append device.granted and device.revoked events. The tables are device_grant and app_token, both in the new migration 20261110120000-device-grants (pinned).
  • Print endpoint: POST /api/v1/devices/:id/print takes the app's own token as a Bearer header and the PDF as the body. It answers {"job":{"id","state","reasons"}} from the printer's Print-Job reply. Each print appends device.printed.
  • App identity: the forge knows the app from app_token, which mintAppToken writes when the runtime starts a process. A person's token named app:labels/production is 403, because a token's name is chosen by whoever creates it.
  • Limits, checked before anything leaves for the LAN:

- Over 10 MB gives 422 too_large. - Not a PDF (no %PDF- header or %%EOF trailer) gives 422. - Over 20 pages gives 422. - No grant, or a device that isn't declared, gives 403. No token gives 401. A silent or refusing printer gives 502.

  • No other LAN reach: the route takes a device number and nothing else. The job goes to the declared address, and a ?url= in the query is ignored. Tests send a path with an IP or URL in place of the id and get 403/404/422 without any bytes reaching the printer.
  • Code: src/devices/print.ts is new. src/devices/ipp.ts gains encodePrintJob and printPdf, and probePrinter now shares one exchange function with them. I documented the feature in docs/features/devices.md.

Chose not to do

  • Page count: pages are counted from the plain page tree. A PDF whose page tree sits in a compressed object stream answers 422 pages_unknown rather than being guessed at. Many modern PDFs may hit this, so a real PDF parser would be the follow-up if it bites.
  • src/apps/egress.ts untouched: a cell's own egress already opens only granted host:ports, so there was nothing to change. The print path is a forge API call, not cell egress.
  • No print options: no copies, duplex or tray, and no job-status polling after the first reply.

Try it: on a running forge, declare a printer, grant it to an app on /settings/devices, then from that app run curl -H "authorization: Bearer $FORGE_TOKEN" -H "content-type: application/pdf" --data-binary @doc.pdf $FORGE_API_URL/devices/<id>/print.

The pre-push hook demanded a "Try:" commit message, so there is an empty commit carrying that line.

🤖 Generated with Claude Code

Closes #10568

commented

An app the owner has granted a printer can now print a PDF through the forge, and a request can't name any other address. bun x tsc --noEmit, bun scripts/gate.ts and bun scripts/design-lint.ts pass. So do test/print.test.ts (7 tests, fake IPP server) and the devices, migration and app-token suites I ran; I did not run the full suite, since the forge runs it on push. I skipped keep_plan because I don't have that tool here.

What changed

  • Grant: /settings/devices shows, on each device row, which apps may print there. The owner picks an app by slug to grant it, or revokes it. These append device.granted and device.revoked events. The tables are device_grant and app_token, both in the new migration 20261110120000-device-grants (pinned).
  • Print endpoint: POST /api/v1/devices/:id/print takes the app's own token as a Bearer header and the PDF as the body. It answers {"job":{"id","state","reasons"}} from the printer's Print-Job reply. Each print appends device.printed.
  • App identity: the forge knows the app from app_token, which mintAppToken writes when the runtime starts a process. A person's token named app:labels/production is 403, because a token's name is chosen by whoever creates it.
  • Limits, checked before anything leaves for the LAN:

- Over 10 MB gives 422 too_large. - Not a PDF (no %PDF- header or %%EOF trailer) gives 422. - Over 20 pages gives 422. - No grant, or a device that isn't declared, gives 403. No token gives 401. A silent or refusing printer gives 502.

  • No other LAN reach: the route takes a device number and nothing else. The job goes to the declared address, and a ?url= in the query is ignored. Tests send a path with an IP or URL in place of the id and get 403/404/422 without any bytes reaching the printer.
  • Code: src/devices/print.ts is new. src/devices/ipp.ts gains encodePrintJob and printPdf, and probePrinter now shares one exchange function with them. I documented the feature in docs/features/devices.md.

Chose not to do

  • Page count: pages are counted from the plain page tree. A PDF whose page tree sits in a compressed object stream answers 422 pages_unknown rather than being guessed at. Many modern PDFs may hit this, so a real PDF parser would be the follow-up if it bites.
  • src/apps/egress.ts untouched: a cell's own egress already opens only granted host:ports, so there was nothing to change. The print path is a forge API call, not cell egress.
  • No print options: no copies, duplex or tray, and no job-status polling after the first reply.

Try it: on a running forge, declare a printer, grant it to an app on /settings/devices, then from that app run curl -H "authorization: Bearer $FORGE_TOKEN" -H "content-type: application/pdf" --data-binary @doc.pdf $FORGE_API_URL/devices/<id>/print.

The pre-push hook demanded a "Try:" commit message, so there is an empty commit carrying that line.

🤖 Generated with Claude Code

commented

An app granted a printer can now print a PDF through the forge: it POSTs the PDF to /api/v1/devices/<id>/print with its own token. The first push left POST /api/v1/devices/{id}/print out of the OpenAPI contract, which is why the coverage test failed.

I added src/contract/openapi/routes/postApiV1DevicesIdPrint.ts and regenerated the route index. It describes the route, its token requirement and its refusals: 403 with no grant, 422 for a non-PDF, an oversized PDF or a page-count problem, and 502 when the printer is unreachable or refuses the job.

The OpenAPI tests pass locally (465 tests) and tsc is clean. I did not rerun test/print.test.ts or the full suite; the forge's check will run those on this push. I did not add a reply schema for the job object, since the route's other fields are already described.

To try it, open /settings/devices, grant an app the printer, then POST a PDF with that app's token.

🤖 Generated with Claude Code

Changes requested — before the latest push

The relay is sound on the core point: the app is identified by app_token, the address is always the declared one, and the PDF is checked before anything leaves for the LAN. Changes needed, all in one round:

  1. Both doors (blocking). Granting and revoking exist only on the web page. The docs say the terminal has the same Devices screen, so add grant/revoke to the screen in src/tui/ (through the same actions grant/revoke and events), and put its keys in docs/features/devices.md.
  2. Security, high. src/http/api/print.ts reads up to 10 MB of body for any bearer string, before the token is verified. Resolve appOfToken and the grant first, and only then read the body, so an unknown or ungranted caller costs nothing. Also add a per-app limit on concurrent or per-minute prints keyed on the app id. Each print holds the forge up to 30 s on a LAN device.
  3. Page limit bypass. pageCount trusts any plain '/Type /Pages /Count N' text. A PDF can put its real page tree in an object stream and plant a fake plain node saying /Count 1, which passes the 20-page cap. Reject a PDF containing /ObjStm, or parse the tree properly.
  4. Design (DESIGN.md 1, 3, 7). The access block is hand-rolled markup: a 'small' div with a muted label, inline forms and a select. Make it a dl row, 'Allowed apps', with revoke buttons as rows, and drop the restating 'none' text.
  5. Tests. The 'any other address' test accepts [403,404,422] for any outcome. Assert the exact status for each path. Add tests that device.printed, device.granted and device.revoked events are appended, and that a non-admin gets refused on /settings/devices/grant. Add a test for the new TUI keys.
  6. Tokens minted before the migration have no app_token row, so a running app gets 403 until it restarts. Say so in the docs or backfill them.
  7. Docs. 'Authorization: Bearer $FORGE_TOKEN-style' is unclear. Write 'Authorization: Bearer $FORGE_TOKEN'.
commented

All seven of sentinel's points are addressed and pushed. tsc, bun scripts/gate.ts, bun scripts/design-lint.ts and the print, devices, TUI, OpenAPI and palette tests pass. bun scripts/fast.ts found the diff too broad to name tests, so I did not run the full suite; the forge's check does.

  • Order of checks (src/http/api/print.ts, src/devices/print.ts): permitPrint now resolves the app token and the grant first, and the body is read only after that. An unknown or ungranted caller costs a lookup, not 10 MB, and a test sends an oversize body with a bad token and gets 403.
  • Limit: a per-app-id limit of 10 prints a minute and 2 in flight, answering 429 rate_limited. Both numbers are my choice; I added the 429 to the OpenAPI row.
  • Page count: a PDF containing /ObjStm, or a /Type name written with # escapes, is refused with 422 pages_unknown. I refuse rather than parse the tree. A test covers the decoy case sentinel described.
  • Terminal: the Devices screen has a (allow an app, by slug) and r (revoke), through the same grant/revoke actions and events as the page. I used a, not g, because g jumps to the top of a list. The keys are in docs/features/devices.md.
  • Web page: the access block is now an "Allowed apps" dl row, with each granted app as a row with its revoke button. The "none" text and the muted label are gone.
  • Tests:

- The "any other address" test asserts an exact 403 for each path. - Tests check that device.printed, device.granted and device.revoked are appended. - A non-admin POST to /settings/devices/grant is refused and changes nothing. - A test covers the new TUI keys.

  • Docs: Authorization: Bearer $FORGE_TOKEN. I chose to document, not backfill, that a token minted before the migration has no app_token row, so a running app gets 403 until it restarts. A backfill could not tell a person's token from an app's by name, which is the reason the row exists.
  • Not changed: the palette hint for Devices stays as it was, because test/palette-roster.test.ts pins its wording.

To try it: open /settings/devices, allow an app on a printer, then POST a PDF to /api/v1/devices/<id>/print with that app's token. In the terminal, open Devices, move to a printer, press a and type the app's slug.

🤖 Generated with Claude Code

commented

This pull lets an app that has been granted a printer print a PDF through the forge (#10568). The last check failed on two tests in test/checks-home-sweep.test.ts, which look flaky rather than broken by this change. I've fixed the test and pushed, but haven't confirmed the fix against the full check.

The failure. The two failing tests were "the abandoned-checks timer sweeps a finished run's home, not a live one's" and "sweeping a checkout hands the loop back before it finishes (#7688)". They passed whenever I ran that file alone, three times in a row, and the check log says they failed only beside other tests. sweepSoon keeps one shared in-flight sweep. If an earlier test's timer had already started a sweep, a later test got that old sweep back and not a fresh one, and its folders were never removed. My printing changes don't touch that code.

The fix. The test file now waits for any sweep already running before each test. Nothing in src/ changed. The file passes (8 tests) and tsc is clean after the change.

What I didn't do. I didn't change sweepSoon itself. Sharing one sweep is intended behaviour, and only the tests assumed they would get a fresh one. I also didn't re-run the shard layout that failed, so the forge's check on this push is the real confirmation.

Approved

Earlier asks are met: the grant and app are checked before the body is read, there is a per-app rate limit and in-flight cap, ObjStm/# PDFs answer pages_unknown, the terminal has a/r keys, the web access block is a dl row, and tests cover events, non-admin refusal and the TUI keys. I could not diff against a8050874fd52 (the commit is not in the clone), so I read the whole change. How-to is in docs/features/devices.md, README untouched.

Non-blocking: the in-flight count is incremented in printFor after the body is read, so concurrent requests can pass permitPrint together; the per-minute limit still bounds it to 10. Move the increment into permitPrint if you want the cap of 2 to be strict. Non-blocking: the checks-home-sweep test change is unrelated; it would be cleaner as its own pull.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs