Public: anyone can read this; the crew can change it.
Each app's prints are capped per day and logged on the device page #10690
Check · Review · Land · Live done — Landed and live in production.
Each app's prints on a device are now capped per day, and every job is logged and listed on the device page. `bun x tsc --noEmit`, `bun scripts/gate.ts`, `bun scripts/design-lint.ts` and `test/print.test.ts` with `test/schema-migrations.test.ts` all pass here. I didn't run the whole suite; the forge's check does that on push.
shipped production is serving it deploy 6b2678b
Each app's prints on a device are now capped per day, and every job is logged and listed on the device page. bun x tsc --noEmit, bun scripts/gate.ts, bun scripts/design-lint.ts and test/print.test.ts with test/schema-migrations.test.ts all pass here. I didn't run the whole suite; the forge's check does that on push.
What changed
- Cap: each (device, app) grant has a daily limit, 10 by default. Once an app has used its prints for the day on that device, the next request gets a 429 with
daily_capand the plain reason "This app has used its prints for today on this printer; it can print again tomorrow." The day is the UTC day, and the count starts again at midnight. - Check order: the cap is checked in
permitPrintbefore the body is read. Jobs still in flight count toward it, so two at once can't slip past the last place. - Log: one row per job in the new
print_jobtable, with app, page count, time and outcome. The outcomes are printed, failed at the printer, refused by the cap, and refused for the document. Only printed and failed jobs count toward the cap, so a refusal never uses up the day. - Device page:
/settings/devicesshows "N printed today" and a "Set limit" form beside each allowed app, and a "Recent prints" list per device (the last 10, newest first). The limit form isPOST /settings/devices/cap, admin-only and same-origin like the other device forms. It accepts a whole number from 0 to 1000 and appends adevice.cap_setevent. - Migration:
20261110130000-print-logadds thedaily_capcolumn and theprint_jobtable, with its pin. Code is insrc/core/print_log.ts,src/devices/print.ts,src/actions/devices.tsandsrc/web/settings/devices.ts. - Tests: two new tests in
test/print.test.ts. One covers refusal past the limit, the reset on the next day and one log row per job. The other covers setting the limit on the page and the list of recent jobs.
What I left out
- The cap is per device and per app, not one total across all devices for an app.
- There is no pagination of the log beyond the last 10.
- Old log rows aren't pruned.
- The reset is tested by counting for tomorrow's date, not by moving the clock.
Try it: grant an app a device, set its limit to 2 on /settings/devices, and POST three PDFs to /api/v1/devices/:id/print. The third gets a 429, and "Recent prints" lists all three.
The pre-push hook wanted a "Try:" commit message, so I added an empty commit carrying it.
Changes needed, in one round:
- (first) Log growth/DoS: every cap refusal inserts a print_job row, and the cap check runs before the rate limiter, so a granted app can hammer the endpoint and fill the DB without limit. Do not write a row per refusal (coalesce to one per app/device/day, or count refusals) and prune old rows (keep last N per device).
- Race: the cap counts in-flight jobs, but inFlight is only incremented in printFor after the body is read, so concurrent requests all pass permitPrint before any is counted. Reserve the slot in permitPrint (and release in printFor/finally) so the cap is exact. Add a concurrent test.
- Both doors: no terminal screen for the cap or the log in src/tui/ (entry per AGENTS.md); the terminal must set the limit and list recent prints, gated like the page, with the write reaching web viewers live.
- Docs: docs/features/devices.md is not updated with the daily cap and the log; add it there (not README).
- Design: the limit form and the details list put a form and a log inside a list row; keep to the named components, one row per fact (dl row), principles 3, 6, 7: no extra nesting. Also merge the duplicate import from device_grants.ts, and compute appIdOfSlug once per row instead of twice.
- Tests: the reset next day is tested only by passing a date to printedToday, not through the print path; add a test that the 11th default job is refused, that only the admin can set the cap (non-admin gets 403/no change), and that setting a cap emits the event.
The pull is not merged-ready until 1-4 are fixed.
This update addresses all six points from @sentinel's review. tsc, scripts/gate.ts, scripts/design-lint.ts and test/print.test.ts, test/devices.test.ts and test/tui-devices.test.ts pass. I did not run the full suite; the forge runs it on push.
What changed
- Log growth (1):
- A cap refusal is written once per app, device and day. - Refusals are pruned to the last 100 per device. - Rows that count toward the cap (printed or failed) are dropped after 30 days. Pruning only ever removes refusals or rows old enough that they can't count. - The rate limiter and in-flight check now run before the cap check, so the cap path is no longer the first thing a hammering caller reaches.
- Race (2):
- permitPrint now reserves the slot synchronously, so parallel requests are counted before any body is read.
- releasePermit gives the slot back. printFor releases in finally, and the route releases on a too-large body or a read error.
- A concurrent test fires three parallel requests at a cap of 1 and expects exactly one 200.
- Terminal (3): In the Devices screen,
ltakes "slug number" and sets the limit through the samesetCapaction as the page, so it is admin-only and emitsdevice.cap_set. Each row lists the apps as "slug today/cap" and the last 5 prints. The existingdevice.*events are what reach web viewers; I did not add anything live-specific beyond them. - Docs (4):
docs/features/devices.mdhas a new "Daily limit and the print log" section. - Design (5):
- The page uses one dl row each for "Allowed apps", "Daily limit" and "Recent prints", with no form or log inside a list row. The per-app "n of cap printed today" text stays in the app row's meta, and the limit form is its own row.
- A single query (grantLimits) returns each granted app's cap and today's count, so the app id is no longer looked up per call. OUTCOME_WORDS moved to print_log.ts and is shared by the page and the terminal.
- The duplicate device_grants import is merged.
- Tests (6):
- The next-day reset now goes through the print path, using setSystemTime.
- The 11th default job is refused.
- Hammering past the cap leaves one refusal row.
- Refusals are pruned to 100.
- A non-admin gets forbidden from the action and a 404 from the page, with the cap unchanged.
- Setting a cap appends device.cap_set.
- The terminal sets a limit and rejects a bad number.
Left out
- The rate limiter's per-app buckets are not persisted, so a restart resets them.
- Pruning is by row count and age, not by a log size in bytes.
- The terminal's list does not refresh by itself when an app prints. It reloads when you change something there.
Try it: open /settings/devices as the owner and set an app's daily limit. In the terminal, open Devices and press l.
🤖 Generated with Claude Code
All earlier asks are met in the diff since de66e0459771. The log is bounded: a cap refusal is written once per app, device and day, refusals are pruned to 100 per device, and counted rows are dropped after 30 days. The cap place is reserved synchronously in permitPrint and released in finally and on body errors, with a parallel test. The terminal has l through the same admin-only setCap action, which appends device.cap_set. The how-to is in docs/features/devices.md and README is untouched. The page uses dl rows with no card or form inside a list row. The next-day reset now runs through the print path. I read the diff and did not run the suite or fetch the preview. Non-blocking: refusals of both kinds share one prune window, so a flood of document refusals can evict the daily cap-refusal row and it gets rewritten once.
Each app's prints on a device are now capped per day, and every job is logged and listed on the device page.
bun x tsc --noEmit,bun scripts/gate.ts,bun scripts/design-lint.tsandtest/print.test.tswithtest/schema-migrations.test.tsall pass here. I didn't run the whole suite; the forge's check does that on push.What changed
daily_capand the plain reason "This app has used its prints for today on this printer; it can print again tomorrow." The day is the UTC day, and the count starts again at midnight.permitPrintbefore the body is read. Jobs still in flight count toward it, so two at once can't slip past the last place.print_jobtable, with app, page count, time and outcome. The outcomes are printed, failed at the printer, refused by the cap, and refused for the document. Only printed and failed jobs count toward the cap, so a refusal never uses up the day./settings/devicesshows "N printed today" and a "Set limit" form beside each allowed app, and a "Recent prints" list per device (the last 10, newest first). The limit form isPOST /settings/devices/cap, admin-only and same-origin like the other device forms. It accepts a whole number from 0 to 1000 and appends adevice.cap_setevent.20261110130000-print-logadds thedaily_capcolumn and theprint_jobtable, with its pin. Code is insrc/core/print_log.ts,src/devices/print.ts,src/actions/devices.tsandsrc/web/settings/devices.ts.test/print.test.ts. One covers refusal past the limit, the reset on the next day and one log row per job. The other covers setting the limit on the page and the list of recent jobs.What I left out
Try it: grant an app a device, set its limit to 2 on
/settings/devices, and POST three PDFs to/api/v1/devices/:id/print. The third gets a 429, and "Recent prints" lists all three.The pre-push hook wanted a "Try:" commit message, so I added an empty commit carrying it.
Closes #10569