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

Merged opened this

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

Merged

scout/forge:scout/10569-each-app-s-prints-are-capped-per-day-and → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/10569-each-app-s-prints-are-capped-per-day-and was merged into main as 08df049.

Review

Sign in to review this change.
13 files changed +438 −48
docs/features/devices.md +6
@@ -19,3 +19,9 @@
1919 - The app and grant are checked before the body is read, so a caller without a grant costs a lookup, not 10 MB. An app may start 10 prints a minute and have 2 in flight; past that, 429 `rate_limited`.
2020 - A PDF containing an object stream (`/ObjStm`) answers 422 `pages_unknown`: the page tree could hide there behind a plain decoy.
2121 - A token minted before this feature has no `app_token` row, so a running app gets 403 until it restarts and mints a fresh one.
22
23 ## Daily limit and the print log
24
25 - Each grant has a daily cap on jobs, default 10, set by the owner from 0 (no prints) to 1000. Jobs that reached the printer (printed or failed there) count, per UTC day; a refusal never uses up the day. Past the cap the app gets 429 `daily_cap` and can print again after midnight UTC. In-flight jobs count, and a place is reserved before the body is read, so parallel requests cannot slip past the last one.
26 - Every job is a row in `print_job` (app, device, pages, outcome, time). A cap refusal is written once per app, device and day; refusals are pruned to the last 100 per device and counted rows are dropped after 30 days, so a caller cannot grow the log.
27 - The device page shows "n of cap printed today" per app, a "Daily limit" row to set it and a "Recent prints" row (last 10). In the terminal's Devices screen `l` sets the limit (slug, then number) and each row lists the last 5 prints; both doors use `setCap`, admin only, and append `device.cap_set` (device, app, cap).
src/actions/devices.ts +14 −1
@@ -16,11 +16,12 @@ import {
1616 type DeviceError,
1717 } from "../core/devices.ts";
1818 import { grantDevice, revokeDevice } from "../core/device_grants.ts";
19 import { setDailyCap } from "../core/print_log.ts";
1920 import type { User } from "../core/users.ts";
2021 import { probePrinter, type PrinterReport } from "../devices/ipp.ts";
2122 import { err, ok, type Result } from "../kernel/result.ts";
2223
23 export type DeviceActionError = DeviceError | "forbidden" | "no_such_app";
24 export type DeviceActionError = DeviceError | "invalid_cap" | "forbidden" | "no_such_app";
2425
2526 export const DEVICE_ERRORS: Record<DeviceActionError, string> = {
2627 invalid_name: "A name is letters, digits, spaces, dots, dashes or underscores, up to 60.",
@@ -30,6 +31,7 @@ export const DEVICE_ERRORS: Record<DeviceActionError, string> = {
3031 name_taken: "A device already has that name.",
3132 not_found: "No such device.",
3233 no_such_app: "No such app, or nothing to change for it.",
34 invalid_cap: "A daily limit is a whole number from 0 to 1000.",
3335 forbidden: "Only a site admin manages devices.",
3436 };
3537
@@ -82,6 +84,17 @@ export function revoke(app: App, actor: User | undefined, id: number, slug: stri
8284 return ok(gone.value.slug);
8385 }
8486
87 /** The owner sets how many jobs one app may print on one device each day. */
88 export function setCap(app: App, actor: User | undefined, id: number, slug: string, cap: number): Result<string, DeviceActionError> {
89 if (!mayManageDevices(actor)) return err("forbidden");
90 const device = Number.isSafeInteger(id) ? getDevice(app.db, id) : undefined;
91 if (!device) return err("not_found");
92 const set = setDailyCap(app.db, id, slug, cap);
93 if (!set.ok) return err(set.error === "invalid_cap" ? "invalid_cap" : "no_such_app");
94 app.bus.append({ kind: "device.cap_set", actorId: actor.id, payload: { device: device.name, app: slug, cap } });
95 return ok(slug);
96 }
97
8598 /** The devices, each stale printer asked at once within the probe's own bound; a fresh one is not asked. */
8699 export async function devicesWithStatus(app: App): Promise<Device[]> {
87100 return Promise.all(
src/core/device_grants.ts +3
@@ -58,3 +58,6 @@ export const grantedSlugs = (db: Database, deviceId: number): string[] =>
5858
5959 export const allAppSlugs = (db: Database): string[] =>
6060 db.query<{ slug: string }, []>("SELECT slug FROM app ORDER BY slug").all().map((r) => r.slug);
61
62 export const appIdOfSlug = (db: Database, slug: string): string | undefined =>
63 db.query<{ id: string }, [string]>("SELECT id FROM app WHERE slug = ?1").get(slug)?.id;
src/core/migration-pins/20261110130000-print-log.pin added +1
@@ -0,0 +1,1 @@
1 d0153c91314b39279bf44f1a59967d9671c50a54f749646cef0af2dfea688e75
src/core/migrations/20261110130000-print-log.ts added +17
@@ -0,0 +1,17 @@
1 // Each app's prints on a device are capped per day and logged (#10569).
2 // `daily_cap` is the owner's number for one grant (default 10 jobs a day);
3 // `print_job` is one row per job: app, device, pages, outcome and the UTC day,
4 // kept with the device. Additive.
5 export default `
6 ALTER TABLE device_grant ADD COLUMN daily_cap INTEGER NOT NULL DEFAULT 10;
7 CREATE TABLE print_job (
8 id INTEGER PRIMARY KEY AUTOINCREMENT,
9 device_id INTEGER NOT NULL REFERENCES device(id) ON DELETE CASCADE,
10 app_id TEXT NOT NULL REFERENCES app(id) ON DELETE CASCADE,
11 pages INTEGER NOT NULL DEFAULT 0,
12 outcome TEXT NOT NULL,
13 day TEXT NOT NULL,
14 at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
15 );
16 CREATE INDEX print_job_day ON print_job (device_id, app_id, day);
17 `;
src/core/print_log.ts added +124
@@ -0,0 +1,124 @@
1 /**
2 * The print log and the daily cap. One row per job an app starts on a device;
3 * the cap counts the jobs of the UTC day that reached the printer (printed or
4 * failed there), so a refusal never uses up the day. The cap is a number on
5 * the owner's grant, default 10.
6 */
7
8 import type { Database } from "bun:sqlite";
9 import { err, ok, type Result } from "../kernel/result.ts";
10
11 export const DEFAULT_DAILY_CAP = 10;
12 export const CAP_MAX = 1000;
13
14 export type Outcome = "printed" | "failed" | "refused_cap" | "refused_document";
15
16 export interface PrintJobRow {
17 readonly app: string;
18 readonly pages: number;
19 readonly outcome: Outcome;
20 readonly at: string;
21 }
22
23 export const dayOf = (now: Date): string => now.toISOString().slice(0, 10);
24
25 export function dailyCap(db: Database, deviceId: number, appId: string): number {
26 const r = db
27 .query<{ daily_cap: number }, [number, string]>("SELECT daily_cap FROM device_grant WHERE device_id = ?1 AND app_id = ?2")
28 .get(deviceId, appId);
29 return r?.daily_cap ?? DEFAULT_DAILY_CAP;
30 }
31
32 export function printedToday(db: Database, deviceId: number, appId: string, now = new Date()): number {
33 return db
34 .query<{ n: number }, [number, string, string]>(
35 "SELECT COUNT(*) AS n FROM print_job WHERE device_id = ?1 AND app_id = ?2 AND day = ?3 AND outcome IN ('printed','failed')",
36 )
37 .get(deviceId, appId, dayOf(now))!.n;
38 }
39
40 export const overCap = (db: Database, deviceId: number, appId: string, now = new Date()): boolean =>
41 printedToday(db, deviceId, appId, now) >= dailyCap(db, deviceId, appId);
42
43 /** Refusals kept per device; jobs that reached the printer are kept for KEEP_DAYS. */
44 export const KEEP_REFUSALS = 100;
45 export const KEEP_DAYS = 30;
46
47 export const OUTCOME_WORDS: Record<Outcome, string> = {
48 printed: "printed",
49 failed: "the printer failed",
50 refused_cap: "refused: daily limit",
51 refused_document: "refused: not a printable PDF",
52 };
53
54 /**
55 * One row per job. A cap refusal is written once per app, device and day (the
56 * day is already full; more rows say nothing new), and refusals are pruned to
57 * the last KEEP_REFUSALS per device, so a caller cannot grow the table. The
58 * rows the cap counts (printed, failed) are only dropped after KEEP_DAYS.
59 */
60 export function logJob(
61 db: Database,
62 job: { deviceId: number; appId: string; pages: number; outcome: Outcome },
63 now = new Date(),
64 ): void {
65 if (
66 job.outcome === "refused_cap" &&
67 db
68 .query("SELECT 1 FROM print_job WHERE device_id = ?1 AND app_id = ?2 AND day = ?3 AND outcome = 'refused_cap' LIMIT 1")
69 .get(job.deviceId, job.appId, dayOf(now))
70 )
71 return;
72 db.query("INSERT INTO print_job (device_id, app_id, pages, outcome, day, at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)").run(
73 job.deviceId,
74 job.appId,
75 job.pages,
76 job.outcome,
77 dayOf(now),
78 now.toISOString(),
79 );
80 if (job.outcome === "printed" || job.outcome === "failed") {
81 db.query("DELETE FROM print_job WHERE day < ?1").run(dayOf(new Date(now.getTime() - KEEP_DAYS * 86_400_000)));
82 return;
83 }
84 db.query(
85 `DELETE FROM print_job WHERE device_id = ?1 AND outcome LIKE 'refused%'
86 AND id NOT IN (SELECT id FROM print_job WHERE device_id = ?1 AND outcome LIKE 'refused%' ORDER BY id DESC LIMIT ?2)`,
87 ).run(job.deviceId, KEEP_REFUSALS);
88 }
89
90 export interface GrantLimit {
91 readonly slug: string;
92 readonly cap: number;
93 readonly today: number;
94 }
95
96 /** Each app granted the device with its daily cap and what it printed today: one query, the app id never looked up apart. */
97 export function grantLimits(db: Database, deviceId: number, now = new Date()): GrantLimit[] {
98 return db
99 .query<GrantLimit, [number, string]>(
100 `SELECT a.slug AS slug, g.daily_cap AS cap,
101 (SELECT COUNT(*) FROM print_job j WHERE j.device_id = g.device_id AND j.app_id = g.app_id AND j.day = ?2
102 AND j.outcome IN ('printed','failed')) AS today
103 FROM device_grant g JOIN app a ON a.id = g.app_id WHERE g.device_id = ?1 ORDER BY a.slug`,
104 )
105 .all(deviceId, dayOf(now));
106 }
107
108 export function recentJobs(db: Database, deviceId: number, limit = 10): PrintJobRow[] {
109 return db
110 .query<PrintJobRow, [number, number]>(
111 `SELECT a.slug AS app, j.pages, j.outcome, j.at FROM print_job j JOIN app a ON a.id = j.app_id
112 WHERE j.device_id = ?1 ORDER BY j.id DESC LIMIT ?2`,
113 )
114 .all(deviceId, limit);
115 }
116
117 /** Set the cap on one grant; a whole number from 0 (no prints) to CAP_MAX. */
118 export function setDailyCap(db: Database, deviceId: number, slug: string, cap: number): Result<true, "invalid_cap" | "not_found"> {
119 if (!Number.isInteger(cap) || cap < 0 || cap > CAP_MAX) return err("invalid_cap");
120 const r = db
121 .query("UPDATE device_grant SET daily_cap = ?3 WHERE device_id = ?1 AND app_id = (SELECT id FROM app WHERE slug = ?2)")
122 .run(deviceId, slug, cap);
123 return r.changes === 0 ? err("not_found") : ok(true);
124 }
src/devices/print.ts +46 −18
@@ -8,6 +8,7 @@
88 import type { App } from "../app.ts";
99 import { appOfToken, isGranted, type AppRef } from "../core/device_grants.ts";
1010 import { getDevice, type Device } from "../core/devices.ts";
11 import { dailyCap, logJob, printedToday, type Outcome } from "../core/print_log.ts";
1112 import { RateLimiter } from "../core/ratelimit.ts";
1213 import { err, ok, type Result } from "../kernel/result.ts";
1314 import { printPdf, type PrintJob } from "./ipp.ts";
@@ -19,11 +20,12 @@ export const PDF_MAX_PAGES = 20;
1920 export const PRINTS_PER_MINUTE = 10;
2021 export const PRINTS_AT_ONCE = 2;
2122
22 export type PrintError = "forbidden" | "rate_limited" | "not_pdf" | "too_large" | "too_many_pages" | "pages_unknown" | "printer_unreachable" | "printer_refused";
23 export type PrintError = "forbidden" | "rate_limited" | "daily_cap" | "not_pdf" | "too_large" | "too_many_pages" | "pages_unknown" | "printer_unreachable" | "printer_refused";
2324
2425 export const PRINT_WORDS: Record<PrintError, string> = {
2526 forbidden: "This app is not granted that printer.",
2627 rate_limited: "This app is printing too fast; wait a little.",
28 daily_cap: "This app has used its prints for today on this printer; it can print again tomorrow.",
2729 not_pdf: "The body must be a PDF (application/pdf).",
2830 too_large: "The PDF is over 10 MB.",
2931 too_many_pages: "The PDF is over 20 pages.",
@@ -73,37 +75,63 @@ const inFlight = new Map<string, number>();
7375 export interface Permit {
7476 readonly caller: AppRef;
7577 readonly device: Device;
78 /** Set once the reserved place is given back. */
79 released: boolean;
80 }
81
82 /** Give back the place `permitPrint` reserved; safe to call twice. */
83 export function releasePermit(permit: Permit): void {
84 if (permit.released) return;
85 permit.released = true;
86 const left = (inFlight.get(permit.caller.id) ?? 1) - 1;
87 if (left <= 0) inFlight.delete(permit.caller.id);
88 else inFlight.set(permit.caller.id, left);
7689 }
7790
7891 /**
7992 * The app holding `secret` and the device it is granted, or `forbidden`
outdated

Race: in-flight is only counted after the body is read (printFor), so parallel requests all pass here. Reserve the slot in this function.

8093 * (so a caller learns nothing about devices it was not given), or
outdated

Unbounded write per refused request, before the rate limiter: a log-filling DoS. Coalesce/prune.

81 * `rate_limited`. Nothing of the body has been read yet: an unknown or
82 * ungranted caller costs a lookup.
94 * `rate_limited`, or `daily_cap`. Nothing of the body has been read yet: an
95 * unknown or ungranted caller costs a lookup. A permit reserves its place
96 * here, synchronously, so parallel requests are counted against the cap and
97 * the at-once bound before any of them reads its body; whoever holds a permit
98 * must `releasePermit` (printFor does).
8399 */
84100 export function permitPrint(app: App, secret: string, deviceId: number): Result<Permit, PrintError> {
85101 const caller = appOfToken(app.db, secret);
86102 if (!caller || !Number.isSafeInteger(deviceId)) return err("forbidden");
87103 const device = getDevice(app.db, deviceId);
88104 if (!device || device.kind !== "printer" || !isGranted(app.db, device.id, caller.id)) return err("forbidden");
89 if ((inFlight.get(caller.id) ?? 0) >= PRINTS_AT_ONCE || !perMinute.take(caller.id, "ro").allowed) return err("rate_limited");
90 return ok({ caller, device });
105 const held = inFlight.get(caller.id) ?? 0;
106 if (held >= PRINTS_AT_ONCE || !perMinute.take(caller.id, "ro").allowed) return err("rate_limited");
107 // Jobs still in flight count, so parallel requests cannot slip past the last place.
108 if (printedToday(app.db, device.id, caller.id) + held >= dailyCap(app.db, device.id, caller.id)) {
109 logJob(app.db, { deviceId: device.id, appId: caller.id, pages: 0, outcome: "refused_cap" });
110 return err("daily_cap");
111 }
112 inFlight.set(caller.id, held + 1);
113 return ok({ caller, device, released: false });
91114 }
92115
93116 /** Print `pdf` for a permitted app; the document is checked before any byte leaves for the LAN. */
94117 export async function printFor(app: App, permit: Permit, pdf: Uint8Array): Promise<Result<PrintJob, PrintError>> {
95 const { caller, device } = permit;
96 const checked = checkPdf(pdf);
97 if (!checked.ok) return checked;
98 inFlight.set(caller.id, (inFlight.get(caller.id) ?? 0) + 1);
99 const sent = await printPdf(device.url, pdf, `${caller.slug} (forge)`).finally(() => {
100 const left = (inFlight.get(caller.id) ?? 1) - 1;
101 if (left <= 0) inFlight.delete(caller.id);
102 else inFlight.set(caller.id, left);
103 });
104 if (sent.ok) {
105 app.bus.append({ kind: "device.printed", payload: { device: device.name, app: caller.slug, pages: checked.value } });
106 return ok(sent.job);
118 try {
119 const { caller, device } = permit;
120 const checked = checkPdf(pdf);
121 const log = (pages: number, outcome: Outcome): void => logJob(app.db, { deviceId: device.id, appId: caller.id, pages, outcome });
122 if (!checked.ok) {
123 log(0, "refused_document");
124 return checked;
125 }
126 const sent = await printPdf(device.url, pdf, `${caller.slug} (forge)`);
127 log(checked.value, sent.ok ? "printed" : "failed");
128 releasePermit(permit);
129 if (sent.ok) {
130 app.bus.append({ kind: "device.printed", payload: { device: device.name, app: caller.slug, pages: checked.value } });
131 return ok(sent.job);
132 }
133 return err(sent.error === "unreachable" ? "printer_unreachable" : "printer_refused");
134 } finally {
135 releasePermit(permit);
107136 }
108 return err(sent.error === "unreachable" ? "printer_unreachable" : "printer_refused");
109137 }
src/http/api/print.ts +14 −3
@@ -4,7 +4,7 @@
44 * PDF. The route takes a device number and nothing that names an address.
55 */
66
7 import { PRINT_WORDS, PDF_MAX_BYTES, permitPrint, printFor, type PrintError } from "../../devices/print.ts";
7 import { PRINT_WORDS, PDF_MAX_BYTES, permitPrint, printFor, releasePermit, type PrintError } from "../../devices/print.ts";
88 import type { Handler } from "../context.ts";
99 import { readBounded } from "../limits.ts";
1010 import { apiError, json, unauthorized } from "../respond.ts";
@@ -14,6 +14,7 @@ import { tokenSecret } from "../token_header.ts";
1414 const STATUS: Record<PrintError, number> = {
1515 forbidden: 403,
1616 rate_limited: 429,
17 daily_cap: 429,
1718 not_pdf: 422,
1819 too_large: 422,
1920 too_many_pages: 422,
@@ -30,8 +31,18 @@ export function printRoutes(router: Router<Handler>): void {
3031 // Who and where first; the body (up to 10 MB) is read only for a granted app.
3132 const permit = permitPrint(ctx.app, secret, id);
3233 if (!permit.ok) return apiError(permit.error, STATUS[permit.error], PRINT_WORDS[permit.error]);
33 const pdf = await readBounded(ctx.request, PDF_MAX_BYTES);
34 if (pdf === "too_large") return apiError("too_large", 422, PRINT_WORDS.too_large);
34 // The permit holds a place until printFor, or here when the body never gets that far.
35 let pdf: Awaited<ReturnType<typeof readBounded>>;
36 try {
37 pdf = await readBounded(ctx.request, PDF_MAX_BYTES);
38 } catch (e) {
39 releasePermit(permit.value);
40 throw e;
41 }
42 if (pdf === "too_large") {
43 releasePermit(permit.value);
44 return apiError("too_large", 422, PRINT_WORDS.too_large);
45 }
3546 const done = await printFor(ctx.app, permit.value, pdf);
3647 if (!done.ok) return apiError(done.error, STATUS[done.error], PRINT_WORDS[done.error]);
3748 return json({ job: { id: done.value.id, state: done.value.state, reasons: done.value.reasons } });
src/http/settings_routes.ts +2 −1
@@ -38,7 +38,7 @@ import { confirmsList, confirmYes } from "../web/pages/confirms.ts";
3838 import { packsSettingsPage, packsSettingsSave } from "../web/pages/packs-settings.ts";
3939 import { featureAccept, featurePage, featureRun, featureSave, featuresPage, featureSwitch } from "../web/pages/features.ts";
4040 import { passkeyAdd, passkeyDelete, passkeyOptions, passkeysPage } from "../web/pages/passkeys.ts";
41 import { deviceDeclare, deviceGrant, devicesPage, deviceRemove, deviceRevoke } from "../web/settings/devices.ts";
41 import { deviceCap, deviceDeclare, deviceGrant, devicesPage, deviceRemove, deviceRevoke } from "../web/settings/devices.ts";
4242 import { secretDelete, secretSet, secretsPage } from "../web/pages/secrets.ts";
4343 import {
4444 backupsClear,
@@ -184,6 +184,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
184184 router.add("POST", "/settings/devices/delete", deviceRemove);
185185 router.add("POST", "/settings/devices/grant", deviceGrant);
186186 router.add("POST", "/settings/devices/revoke", deviceRevoke);
187 router.add("POST", "/settings/devices/cap", deviceCap);
187188 router.add("GET", "/settings/cloud", cloudPage);
188189 router.add("POST", "/settings/cloud", cloudAdd);
189190 router.add("POST", "/settings/cloud/verify", cloudVerify);
src/tui/screens/devices.ts +36 −10
@@ -5,12 +5,15 @@
55 * under the cursor after a confirm. Both go through `actions/devices.ts`, so
66 * the refusals and the `device.*` events are the page's. `a` allows the one
77 * under the cursor to an app (its slug, in one line) and `r` revokes one
8 * (likewise): who may print there. Anyone who is not an
8 * (likewise): who may print there. `l` sets an app's daily print limit (its
9 * slug and a number, in one line); each row lists the limits and the last
10 * prints, as the page does. Anyone who is not an
911 * admin (or is an agent) is offered no entry and changes nothing.
1012 */
1113
12 import { DEVICE_ERRORS, declare, devicesWithStatus, grant, mayManageDevices, remove, revoke, statusLine } from "../../actions/devices.ts";
13 import { allAppSlugs, grantedSlugs } from "../../core/device_grants.ts";
14 import { DEVICE_ERRORS, declare, devicesWithStatus, grant, mayManageDevices, remove, revoke, setCap, statusLine } from "../../actions/devices.ts";
15 import { allAppSlugs } from "../../core/device_grants.ts";
16 import { CAP_MAX, OUTCOME_WORDS, grantLimits, recentJobs } from "../../core/print_log.ts";
1417 import { listDevices, type Device } from "../../core/devices.ts";
1518 import type { Ctx, Screen } from "../app/app.ts";
1619 import type { PaletteEntry } from "./entries.ts";
@@ -84,22 +87,40 @@ export function devicesScreen(f: Session, ctx: Ctx): Screen {
8487 },
8588 });
8689
87 const apps = (d: Device): string => {
88 const slugs = grantedSlugs(f.db, d.id);
89 return slugs.length === 0 ? "" : ` · apps: ${slugs.join(", ")}`;
90 const setLimit = (d: Device) => (line: string): string | undefined => {
91 const [slug = "", raw = "", extra] = line.trim().split(/\s+/);
92 const made = setCap(f.app, f.user, d.id, slug, /^\d{1,4}$/.test(raw) && extra === undefined ? Number(raw) : Number.NaN);
93 note = made.ok ? `${made.value} may print ${raw} a day on ${d.name}.` : DEVICE_ERRORS[made.error];
94 if (made.ok) reload();
95 return made.ok ? undefined : note;
9096 };
97 const askLimit = (d: Device): Screen =>
98 promptScreen(ctx, screen, {
99 title: "Daily print limit",
100 label: `App slug, then prints a day (0 to ${CAP_MAX}) on ${d.name}`,
101 placeholder: grantLimits(f.db, d.id).map((l) => l.slug).join(" ") || "grant an app first",
102 onSubmit: setLimit(d),
103 });
104 const apps = (d: Device): string => {
105 const limits = grantLimits(f.db, d.id);
106 return limits.length === 0 ? "" : ` · apps: ${limits.map((l) => `${l.slug} ${l.today}/${l.cap} today`).join(", ")}`;
107 };
108 const below = (d: Device): string[] => [
109 ...(d.probe?.supplies.length ? [d.probe.supplies.map((s) => `${s.name} ${s.percent === null ? "?" : `${s.percent}%`}`).join(" · ")] : []),
110 ...recentJobs(f.db, d.id, 5).map(
111 (j) => `${j.at.slice(0, 16).replace("T", " ")} ${j.app} · ${j.pages} ${j.pages === 1 ? "page" : "pages"} · ${OUTCOME_WORDS[j.outcome]}`,
112 ),
113 ];
91114 const lines = (): ListRow[] =>
92115 rows.map((d) => ({
93116 title: d.name,
94117 meta: `${d.probedAt === null ? d.url : `${statusLine(d.probe)} · ${d.url}`}${apps(d)}`,
95 ...(d.probe?.supplies.length
96 ? { below: [d.probe.supplies.map((s) => `${s.name} ${s.percent === null ? "?" : `${s.percent}%`}`).join(" · ")] }
97 : {}),
118 below: below(d),
98119 }));
99120
100121 const screen: Screen = {
101122 title: "Devices",
102 keys: [binding(["d"], "declare"), binding(["x"], "remove"), binding(["a"], "allow an app"), binding(["r"], "revoke an app"), binding(["j", "k"], "move cursor")],
123 keys: [binding(["d"], "declare"), binding(["x"], "remove"), binding(["a"], "allow an app"), binding(["r"], "revoke an app"), binding(["l"], "daily limit"), binding(["j", "k"], "move cursor")],
103124 render(area, s) {
104125 const c = ctx.theme.c;
105126 const text = note !== "" ? note : mayUse(f) ? `${rows.length} declared.` : DEVICE_ERRORS.forbidden;
@@ -122,6 +143,11 @@ export function devicesScreen(f: Session, ctx: Ctx): Screen {
122143 ctx.push(e.key === "a" ? askApp(d, grant, "Let an app print here", "may print on") : askApp(d, revoke, "Stop an app printing here", "may no longer print on"));
123144 return true;
124145 }
146 if (e.key === "l" && d !== undefined) {
147 note = "";
148 ctx.push(askLimit(d));
149 return true;
150 }
125151 if (e.key === "x" && d !== undefined) {
126152 note = "";
127153 ctx.push(confirmRemove(d));
src/web/settings/devices.ts +48 −12
@@ -7,8 +7,9 @@
77 */
88
99 import type { Ctx } from "../../http/context.ts";
10 import { DEVICE_ERRORS, declare, grant, revoke, devicesWithStatus, remove, statusLine } from "../../actions/devices.ts";
11 import { allAppSlugs, grantedSlugs } from "../../core/device_grants.ts";
10 import { DEVICE_ERRORS, declare, setCap, grant, revoke, devicesWithStatus, remove, statusLine } from "../../actions/devices.ts";
11 import { allAppSlugs } from "../../core/device_grants.ts";
12 import { CAP_MAX, OUTCOME_WORDS, grantLimits, recentJobs } from "../../core/print_log.ts";
1213 import { NAME_MAX, URL_MAX, type Device } from "../../core/devices.ts";
1314 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
1415 import type { PrinterReport } from "../../devices/ipp.ts";
@@ -38,19 +39,23 @@ function supplies(report: PrinterReport): Html | null {
3839 </details>`;
3940 }
4041
41 /** Who may print here, as one fact: each granted app is a row with its revoke button, then a picker for one more. */
42 /** Who may print here, as facts: the granted apps (revoke), one picker to grant more, the daily limit, the last prints. */
4243 function access(ctx: Ctx, d: Device): Html {
43 const granted = grantedSlugs(ctx.app.db, d.id);
44 const limits = grantLimits(ctx.app.db, d.id);
45 const granted = limits.map((l) => l.slug);
4446 const rest = allAppSlugs(ctx.app.db).filter((s) => !granted.includes(s));
45 if (granted.length === 0 && rest.length === 0) return html``;
46 return html`<dl class="status-rows"><div class="status-row"><dt>Allowed apps</dt><dd>
47 <ul class="list">${granted.map(
48 (slug) => html`<li>${row({
49 title: html`<span class="mono">${slug}</span>`,
47 const jobs = recentJobs(ctx.app.db, d.id);
48 if (limits.length === 0 && rest.length === 0) return html``;
49 return html`<dl class="status-rows">
50 <div class="status-row"><dt>Allowed apps</dt><dd>
51 <ul class="list">${limits.map(
52 (l) => html`<li>${row({
outdated

Principles 3/6/7: a form and a stat nested in a row's meta, and a second DB lookup of the app id per call; hand-rolled markup. Use a dl row for the limit, and look up the id once.

53 title: html`<span class="mono">${l.slug}</span>`,
54 meta: html`<span class="small muted">${String(l.today)} of ${String(l.cap)} printed today</span>`,
5055 trail: html`<form class="row-actions" method="post" action="/settings/devices/revoke">
5156 <input type="hidden" name="id" value="${String(d.id)}" />
52 <input type="hidden" name="app" value="${slug}" />
53 ${btn("Revoke", { size: "sm", icon: "x", label: `Revoke ${slug} from ${d.name}` })}
57 <input type="hidden" name="app" value="${l.slug}" />
58 ${btn("Revoke", { size: "sm", icon: "x", label: `Revoke ${l.slug} from ${d.name}` })}
5459 </form>`,
5560 })}</li>`,
5661 )}</ul>
@@ -61,7 +66,23 @@ function access(ctx: Ctx, d: Device): Html {
6166 </select>
6267 ${btn("Grant", { size: "sm", icon: "plus", label: `Grant an app ${d.name}` })}
6368 </form>`)}
64 </dd></div></dl>`;
69 </dd></div>
70 ${when(limits.length > 0, () => html`<div class="status-row"><dt>Daily limit</dt><dd>
71 <form class="edit-row" method="post" action="/settings/devices/cap">
72 <input type="hidden" name="id" value="${String(d.id)}" />
73 <select class="input" name="app" aria-label="App to limit on ${d.name}">
74 ${limits.map((l) => html`<option value="${l.slug}">${l.slug} (${String(l.cap)})</option>`)}
75 </select>
76 <input class="input mono" type="number" name="cap" min="0" max="${String(CAP_MAX)}" value="${String(limits[0]!.cap)}" aria-label="Prints a day" />
77 ${btn("Set limit", { size: "sm", label: `Set the daily limit on ${d.name}` })}
78 </form>
79 </dd></div>`)}
80 ${when(jobs.length > 0, () => html`<div class="status-row"><dt>Recent prints</dt><dd>
81 <ul class="list">${jobs.map(
82 (j) => html`<li class="small"><span class="mono">${j.app}</span> · ${String(j.pages)} ${j.pages === 1 ? "page" : "pages"} · ${OUTCOME_WORDS[j.outcome]} · <span class="mono">${j.at.slice(0, 16).replace("T", " ")}</span></li>`,
83 )}</ul>
84 </dd></div>`)}
85 </dl>`;
6586 }
6687
6788 function card(ctx: Ctx, d: Device): Html {
@@ -158,3 +179,18 @@ async function grantChange(ctx: Ctx, change: typeof grant, done: string): Promis
158179
159180 export const deviceGrant = (ctx: Ctx): Promise<Response> => grantChange(ctx, grant, "may print here now");
160181 export const deviceRevoke = (ctx: Ctx): Promise<Response> => grantChange(ctx, revoke, "may no longer print here");
182
183 // POST /settings/devices/cap
184 export async function deviceCap(ctx: Ctx): Promise<Response> {
185 const denied = guard(ctx);
186 if (denied) return denied;
187 const form = await readForm(ctx, "admin");
188 if (form instanceof Response) return form;
189 const raw = field(form, "cap").trim();
190 const result = setCap(ctx.app, ctx.auth.user, Number(field(form, "id")), field(form, "app"), /^\d{1,4}$/.test(raw) ? Number(raw) : Number.NaN);
191 return flashTo(
192 ctx,
193 "/settings/devices",
194 result.ok ? { message: `${result.value} has a new daily limit.` } : { tone: "danger", message: DEVICE_ERRORS[result.error] },
195 );
196 }
test/print.test.ts +115 −2
@@ -1,5 +1,5 @@
11 /** An app granted a printer prints a PDF through the forge, and reaches nothing else. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
2 import { afterAll, beforeAll, expect, setSystemTime, test } from "bun:test";
33 import { mkdtemp, rm } from "node:fs/promises";
44 import { tmpdir } from "node:os";
55 import { join } from "node:path";
@@ -8,6 +8,7 @@ import { mintAppToken, revokeAppToken } from "../src/apps/token.ts";
88 import { grantDevice, isGranted } from "../src/core/device_grants.ts";
99 import { declareDevice } from "../src/core/devices.ts";
1010 import { createToken } from "../src/core/tokens.ts";
11 import { createSession } from "../src/core/sessions.ts";
1112 import { getUserByName } from "../src/core/users.ts";
1213 import { PDF_MAX_BYTES, PRINTS_PER_MINUTE, checkPdf, pageCount } from "../src/devices/print.ts";
1314 import { serve, type Servers } from "../src/http/server.ts";
@@ -202,7 +203,7 @@ test("the owner grants and revokes on the device page", async () => {
202203
203204 test("a non-admin is refused on grant and revoke, and changes nothing", async () => {
204205 const { createUser } = await import("../src/core/users.ts");
205 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "b@forge.local", password: "a-good-passphrase" });
206 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "zed", email: "zed@forge.local", password: "a-good-passphrase" });
206207 if (!made.ok) throw new Error(made.error);
207208 const { createSession } = await import("../src/core/sessions.ts");
208209 const bob = `forge_session=${createSession(app.db, made.value.id).token}`;
@@ -226,3 +227,115 @@ test("the page count is read from the plain page tree", () => {
226227 expect(pageCount(pdf(3))).toBe(3);
227228 expect(checkPdf(enc.encode("%PDF-1.5\n<< /Type /ObjStm >>\n%%EOF"))).toEqual({ ok: false, error: "pages_unknown" });
228229 });
230
231 test("the daily cap refuses past the limit, says why, and resets the next day", async () => {
232 const { dailyCap, printedToday, setDailyCap, DEFAULT_DAILY_CAP } = await import("../src/core/print_log.ts");
233 const owner = getUserByName(app.db, "alice")!;
234 addApp("a4", "capped");
235 grantDevice(app.db, otherId, "capped", owner.id);
236 expect(dailyCap(app.db, otherId, "a4")).toBe(DEFAULT_DAILY_CAP);
237 expect(DEFAULT_DAILY_CAP).toBe(10);
238 expect(setDailyCap(app.db, otherId, "capped", 2).ok).toBe(true);
239 expect(setDailyCap(app.db, otherId, "capped", -1).ok).toBe(false);
240 const t = mintAppToken(app.db, owner.id, "capped", "production").token;
241 const path = `/api/v1/devices/${otherId}/print`;
242 expect((await print(path, t, pdf(1))).status).toBe(200);
243 expect((await print(path, t, pdf(3))).status).toBe(200);
244 const res = await print(path, t, pdf(1));
245 expect(res.status).toBe(429);
246 expect(((await res.json()) as { error: string }).error).toBe("daily_cap");
247 expect(printedToday(app.db, otherId, "a4")).toBe(2);
248 // Tomorrow the day's count starts again, through the print path.
249 setSystemTime(new Date(Date.now() + 86_400_000));
250 try {
251 expect((await print(path, t, pdf(1))).status).toBe(200);
252 } finally {
253 setSystemTime();
254 }
255 // One row per job: two printed, one refused by the cap.
256 const rows = app.db.query<{ pages: number; outcome: string }, []>("SELECT pages, outcome FROM print_job WHERE app_id = 'a4' ORDER BY id").all();
257 expect(rows).toEqual([{ pages: 1, outcome: "printed" }, { pages: 3, outcome: "printed" }, { pages: 0, outcome: "refused_cap" }, { pages: 1, outcome: "printed" }]);
258 });
259
260 test("the owner sets the limit on the device page, which lists the last jobs", async () => {
261 const body = (b: Record<string, string>) => ({
262 method: "POST",
263 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url, cookie },
264 body: new URLSearchParams(b),
265 redirect: "manual" as const,
266 });
267 expect((await fetch(`${servers.url}/settings/devices/cap`, body({ id: String(otherId), app: "capped", cap: "5" }))).status).toBe(303);
268 expect(app.db.query<{ daily_cap: number }, []>("SELECT daily_cap FROM device_grant WHERE app_id = 'a4'").get()!.daily_cap).toBe(5);
269 await fetch(`${servers.url}/settings/devices/cap`, body({ id: String(otherId), app: "capped", cap: "abc" }));
270 expect(app.db.query<{ daily_cap: number }, []>("SELECT daily_cap FROM device_grant WHERE app_id = 'a4'").get()!.daily_cap).toBe(5);
271 const page = await (await fetch(`${servers.url}/settings/devices`, { headers: { cookie } })).text();
272 expect(page).toContain("Recent prints");
273 expect(page).toContain("refused: daily limit");
274 expect(page).toContain("printed today");
275 });
276
277 const countRows = (appId: string, outcome: string): number =>
278 app.db.query<{ n: number }, [string, string]>("SELECT COUNT(*) AS n FROM print_job WHERE app_id = ?1 AND outcome = ?2").get(appId, outcome)!.n;
279
280 test("by default the eleventh job of the day is refused, and hammering does not grow the log", async () => {
281 const owner = getUserByName(app.db, "alice")!;
282 addApp("a5", "dflt");
283 grantDevice(app.db, deviceId, "dflt", owner.id);
284 const t = mintAppToken(app.db, owner.id, "dflt", "production").token;
285 const path = `/api/v1/devices/${deviceId}/print`;
286 // Six jobs are already in the log; the per-minute bound is not what this test is about.
287 const { logJob } = await import("../src/core/print_log.ts");
288 for (let i = 0; i < 6; i++) logJob(app.db, { deviceId, appId: "a5", pages: 1, outcome: "printed" });
289 for (let i = 0; i < 4; i++) expect((await print(path, t, pdf(1))).status).toBe(200);
290 for (let i = 0; i < 3; i++) expect((await print(path, t, pdf(1))).status).toBe(429);
291 expect(countRows("a5", "printed")).toBe(10);
292 expect(countRows("a5", "refused_cap")).toBe(1);
293 });
294
295 test("parallel requests cannot pass the cap together", async () => {
296 const owner = getUserByName(app.db, "alice")!;
297 addApp("a6", "racer");
298 grantDevice(app.db, deviceId, "racer", owner.id);
299 const { setDailyCap } = await import("../src/core/print_log.ts");
300 setDailyCap(app.db, deviceId, "racer", 1);
301 const t = mintAppToken(app.db, owner.id, "racer", "production").token;
302 const path = `/api/v1/devices/${deviceId}/print`;
303 const statuses = (await Promise.all([print(path, t, pdf(1)), print(path, t, pdf(1)), print(path, t, pdf(1))])).map((r) => r.status);
304 expect(statuses.filter((s) => s === 200).length).toBe(1);
305 expect(countRows("a6", "printed")).toBe(1);
306 // The places are given back: once the day turns, it prints again.
307 setSystemTime(new Date(Date.now() + 86_400_000));
308 try {
309 expect((await print(path, t, pdf(1))).status).toBe(200);
310 } finally {
311 setSystemTime();
312 }
313 });
314
315 test("a refused log is pruned to the last hundred", async () => {
316 const { logJob, KEEP_REFUSALS } = await import("../src/core/print_log.ts");
317 for (let i = 0; i < KEEP_REFUSALS + 20; i++) logJob(app.db, { deviceId: otherId, appId: "a2", pages: 0, outcome: "refused_document" });
318 expect(countRows("a2", "refused_document")).toBe(KEEP_REFUSALS);
319 });
320
321 test("only an admin sets the cap, and setting it appends device.cap_set", async () => {
322 const { setCap } = await import("../src/actions/devices.ts");
323 const owner = getUserByName(app.db, "alice")!;
324 const cap = () => app.db.query<{ daily_cap: number }, []>("SELECT daily_cap FROM device_grant WHERE app_id = 'a6'").get()!.daily_cap;
325 const bob = getUserByName(app.db, "zed")!;
326 expect(setCap(app, bob, deviceId, "racer", 7)).toEqual({ ok: false, error: "forbidden" });
327 expect(setCap(app, undefined, deviceId, "racer", 7)).toEqual({ ok: false, error: "forbidden" });
328 const bobCookie = `forge_session=${createSession(app.db, bob.id).token}`;
329 const res = await fetch(`${servers.url}/settings/devices/cap`, {
330 method: "POST",
331 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url, cookie: bobCookie },
332 body: new URLSearchParams({ id: String(deviceId), app: "racer", cap: "7" }),
333 redirect: "manual",
334 });
335 expect(res.status).toBe(404);
336 expect(cap()).toBe(1);
337 const before = app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM event WHERE kind = 'device.cap_set'").get()!.n;
338 expect(setCap(app, owner, deviceId, "racer", 7).ok).toBe(true);
339 expect(cap()).toBe(7);
340 expect(app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM event WHERE kind = 'device.cap_set'").get()!.n).toBe(before + 1);
341 });
test/tui-devices.test.ts +12 −1
@@ -116,7 +116,18 @@ describe("the devices screen", () => {
116116 await feed("labels\r");
117117 expect(mine()).toEqual(["labels"]);
118118 expect(kinds.includes("device.granted")).toBe(true);
119 expect(text()).toContain("apps: labels");
119 expect(text()).toContain("apps: labels 0/10 today");
120 // l sets the daily limit through the page's action and event; a bad number changes nothing.
121 const cap = () => app.db.query<{ daily_cap: number }, []>("SELECT daily_cap FROM device_grant WHERE app_id = 't1'").get()!.daily_cap;
122 await feed("l");
123 await feed("labels many\r");
124 expect(cap()).toBe(10);
125 expect(text()).toContain("A daily limit is a whole number");
126 for (let n = 0; n < "labels many".length; n++) await feed("\x7f");
127 await feed("labels 3\r");
128 expect(cap()).toBe(3);
129 expect(kinds.includes("device.cap_set")).toBe(true);
130 expect(text()).toContain("labels 0/3 today");
120131 await feed("r");
121132 await feed("labels\r");
122133 expect(mine()).toEqual([]);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs