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.
scout/forge:scout/10569-each-app-s-prints-are-capped-per-day-and → main 3 commits fork 3 threads · 3 unresolved
13 files
changed +438
−48
docs/features/devices.md +6
| @@ -19,3 +19,9 @@ | ||
| 19 | 19 | - 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`. |
| 20 | 20 | - A PDF containing an object stream (`/ObjStm`) answers 422 `pages_unknown`: the page tree could hide there behind a plain decoy. |
| 21 | 21 | - 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 { | ||
| 16 | 16 | type DeviceError, |
| 17 | 17 | } from "../core/devices.ts"; |
| 18 | 18 | import { grantDevice, revokeDevice } from "../core/device_grants.ts"; |
| 19 | import { setDailyCap } from "../core/print_log.ts"; | |
| 19 | 20 | import type { User } from "../core/users.ts"; |
| 20 | 21 | import { probePrinter, type PrinterReport } from "../devices/ipp.ts"; |
| 21 | 22 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 22 | 23 | |
| 23 | export type DeviceActionError = DeviceError | "forbidden" | "no_such_app"; | |
| 24 | export type DeviceActionError = DeviceError | "invalid_cap" | "forbidden" | "no_such_app"; | |
| 24 | 25 | |
| 25 | 26 | export const DEVICE_ERRORS: Record<DeviceActionError, string> = { |
| 26 | 27 | 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> = { | ||
| 30 | 31 | name_taken: "A device already has that name.", |
| 31 | 32 | not_found: "No such device.", |
| 32 | 33 | 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.", | |
| 33 | 35 | forbidden: "Only a site admin manages devices.", |
| 34 | 36 | }; |
| 35 | 37 | |
| @@ -82,6 +84,17 @@ export function revoke(app: App, actor: User | undefined, id: number, slug: stri | ||
| 82 | 84 | return ok(gone.value.slug); |
| 83 | 85 | } |
| 84 | 86 | |
| 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 | ||
| 85 | 98 | /** The devices, each stale printer asked at once within the probe's own bound; a fresh one is not asked. */ |
| 86 | 99 | export async function devicesWithStatus(app: App): Promise<Device[]> { |
| 87 | 100 | return Promise.all( |
src/core/device_grants.ts +3
| @@ -58,3 +58,6 @@ export const grantedSlugs = (db: Database, deviceId: number): string[] => | ||
| 58 | 58 | |
| 59 | 59 | export const allAppSlugs = (db: Database): string[] => |
| 60 | 60 | 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 @@ | ||
| 8 | 8 | import type { App } from "../app.ts"; |
| 9 | 9 | import { appOfToken, isGranted, type AppRef } from "../core/device_grants.ts"; |
| 10 | 10 | import { getDevice, type Device } from "../core/devices.ts"; |
| 11 | import { dailyCap, logJob, printedToday, type Outcome } from "../core/print_log.ts"; | |
| 11 | 12 | import { RateLimiter } from "../core/ratelimit.ts"; |
| 12 | 13 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 13 | 14 | import { printPdf, type PrintJob } from "./ipp.ts"; |
| @@ -19,11 +20,12 @@ export const PDF_MAX_PAGES = 20; | ||
| 19 | 20 | export const PRINTS_PER_MINUTE = 10; |
| 20 | 21 | export const PRINTS_AT_ONCE = 2; |
| 21 | 22 | |
| 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"; | |
| 23 | 24 | |
| 24 | 25 | export const PRINT_WORDS: Record<PrintError, string> = { |
| 25 | 26 | forbidden: "This app is not granted that printer.", |
| 26 | 27 | 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.", | |
| 27 | 29 | not_pdf: "The body must be a PDF (application/pdf).", |
| 28 | 30 | too_large: "The PDF is over 10 MB.", |
| 29 | 31 | too_many_pages: "The PDF is over 20 pages.", |
| @@ -73,37 +75,63 @@ const inFlight = new Map<string, number>(); | ||
| 73 | 75 | export interface Permit { |
| 74 | 76 | readonly caller: AppRef; |
| 75 | 77 | 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); | |
| 76 | 89 | } |
| 77 | 90 | |
| 78 | 91 | /** |
| 79 | 92 | * The app holding `secret` and the device it is granted, or `forbidden` |
| 80 | 93 | * (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). | |
| 83 | 99 | */ |
| 84 | 100 | export function permitPrint(app: App, secret: string, deviceId: number): Result<Permit, PrintError> { |
| 85 | 101 | const caller = appOfToken(app.db, secret); |
| 86 | 102 | if (!caller || !Number.isSafeInteger(deviceId)) return err("forbidden"); |
| 87 | 103 | const device = getDevice(app.db, deviceId); |
| 88 | 104 | 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 }); | |
| 91 | 114 | } |
| 92 | 115 | |
| 93 | 116 | /** Print `pdf` for a permitted app; the document is checked before any byte leaves for the LAN. */ |
| 94 | 117 | 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); | |
| 107 | 136 | } |
| 108 | return err(sent.error === "unreachable" ? "printer_unreachable" : "printer_refused"); | |
| 109 | 137 | } |
src/http/api/print.ts +14 −3
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | * PDF. The route takes a device number and nothing that names an address. |
| 5 | 5 | */ |
| 6 | 6 | |
| 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"; | |
| 8 | 8 | import type { Handler } from "../context.ts"; |
| 9 | 9 | import { readBounded } from "../limits.ts"; |
| 10 | 10 | import { apiError, json, unauthorized } from "../respond.ts"; |
| @@ -14,6 +14,7 @@ import { tokenSecret } from "../token_header.ts"; | ||
| 14 | 14 | const STATUS: Record<PrintError, number> = { |
| 15 | 15 | forbidden: 403, |
| 16 | 16 | rate_limited: 429, |
| 17 | daily_cap: 429, | |
| 17 | 18 | not_pdf: 422, |
| 18 | 19 | too_large: 422, |
| 19 | 20 | too_many_pages: 422, |
| @@ -30,8 +31,18 @@ export function printRoutes(router: Router<Handler>): void { | ||
| 30 | 31 | // Who and where first; the body (up to 10 MB) is read only for a granted app. |
| 31 | 32 | const permit = permitPrint(ctx.app, secret, id); |
| 32 | 33 | 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 | } | |
| 35 | 46 | const done = await printFor(ctx.app, permit.value, pdf); |
| 36 | 47 | if (!done.ok) return apiError(done.error, STATUS[done.error], PRINT_WORDS[done.error]); |
| 37 | 48 | 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"; | ||
| 38 | 38 | import { packsSettingsPage, packsSettingsSave } from "../web/pages/packs-settings.ts"; |
| 39 | 39 | import { featureAccept, featurePage, featureRun, featureSave, featuresPage, featureSwitch } from "../web/pages/features.ts"; |
| 40 | 40 | 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"; | |
| 42 | 42 | import { secretDelete, secretSet, secretsPage } from "../web/pages/secrets.ts"; |
| 43 | 43 | import { |
| 44 | 44 | backupsClear, |
| @@ -184,6 +184,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 184 | 184 | router.add("POST", "/settings/devices/delete", deviceRemove); |
| 185 | 185 | router.add("POST", "/settings/devices/grant", deviceGrant); |
| 186 | 186 | router.add("POST", "/settings/devices/revoke", deviceRevoke); |
| 187 | router.add("POST", "/settings/devices/cap", deviceCap); | |
| 187 | 188 | router.add("GET", "/settings/cloud", cloudPage); |
| 188 | 189 | router.add("POST", "/settings/cloud", cloudAdd); |
| 189 | 190 | router.add("POST", "/settings/cloud/verify", cloudVerify); |
src/tui/screens/devices.ts +36 −10
| @@ -5,12 +5,15 @@ | ||
| 5 | 5 | * under the cursor after a confirm. Both go through `actions/devices.ts`, so |
| 6 | 6 | * the refusals and the `device.*` events are the page's. `a` allows the one |
| 7 | 7 | * 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 | |
| 9 | 11 | * admin (or is an agent) is offered no entry and changes nothing. |
| 10 | 12 | */ |
| 11 | 13 | |
| 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"; | |
| 14 | 17 | import { listDevices, type Device } from "../../core/devices.ts"; |
| 15 | 18 | import type { Ctx, Screen } from "../app/app.ts"; |
| 16 | 19 | import type { PaletteEntry } from "./entries.ts"; |
| @@ -84,22 +87,40 @@ export function devicesScreen(f: Session, ctx: Ctx): Screen { | ||
| 84 | 87 | }, |
| 85 | 88 | }); |
| 86 | 89 | |
| 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; | |
| 90 | 96 | }; |
| 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 | ]; | |
| 91 | 114 | const lines = (): ListRow[] => |
| 92 | 115 | rows.map((d) => ({ |
| 93 | 116 | title: d.name, |
| 94 | 117 | 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), | |
| 98 | 119 | })); |
| 99 | 120 | |
| 100 | 121 | const screen: Screen = { |
| 101 | 122 | 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")], | |
| 103 | 124 | render(area, s) { |
| 104 | 125 | const c = ctx.theme.c; |
| 105 | 126 | const text = note !== "" ? note : mayUse(f) ? `${rows.length} declared.` : DEVICE_ERRORS.forbidden; |
| @@ -122,6 +143,11 @@ export function devicesScreen(f: Session, ctx: Ctx): Screen { | ||
| 122 | 143 | 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")); |
| 123 | 144 | return true; |
| 124 | 145 | } |
| 146 | if (e.key === "l" && d !== undefined) { | |
| 147 | note = ""; | |
| 148 | ctx.push(askLimit(d)); | |
| 149 | return true; | |
| 150 | } | |
| 125 | 151 | if (e.key === "x" && d !== undefined) { |
| 126 | 152 | note = ""; |
| 127 | 153 | ctx.push(confirmRemove(d)); |
src/web/settings/devices.ts +48 −12
| @@ -7,8 +7,9 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | 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"; | |
| 12 | 13 | import { NAME_MAX, URL_MAX, type Device } from "../../core/devices.ts"; |
| 13 | 14 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 14 | 15 | import type { PrinterReport } from "../../devices/ipp.ts"; |
| @@ -38,19 +39,23 @@ function supplies(report: PrinterReport): Html | null { | ||
| 38 | 39 | </details>`; |
| 39 | 40 | } |
| 40 | 41 | |
| 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. */ | |
| 42 | 43 | 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); | |
| 44 | 46 | 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>`, | |
| 50 | 55 | trail: html`<form class="row-actions" method="post" action="/settings/devices/revoke"> |
| 51 | 56 | <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}` })} | |
| 54 | 59 | </form>`, |
| 55 | 60 | })}</li>`, |
| 56 | 61 | )}</ul> |
| @@ -61,7 +66,23 @@ function access(ctx: Ctx, d: Device): Html { | ||
| 61 | 66 | </select> |
| 62 | 67 | ${btn("Grant", { size: "sm", icon: "plus", label: `Grant an app ${d.name}` })} |
| 63 | 68 | </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>`; | |
| 65 | 86 | } |
| 66 | 87 | |
| 67 | 88 | function card(ctx: Ctx, d: Device): Html { |
| @@ -158,3 +179,18 @@ async function grantChange(ctx: Ctx, change: typeof grant, done: string): Promis | ||
| 158 | 179 | |
| 159 | 180 | export const deviceGrant = (ctx: Ctx): Promise<Response> => grantChange(ctx, grant, "may print here now"); |
| 160 | 181 | 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 @@ | ||
| 1 | 1 | /** 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"; | |
| 3 | 3 | import { mkdtemp, rm } from "node:fs/promises"; |
| 4 | 4 | import { tmpdir } from "node:os"; |
| 5 | 5 | import { join } from "node:path"; |
| @@ -8,6 +8,7 @@ import { mintAppToken, revokeAppToken } from "../src/apps/token.ts"; | ||
| 8 | 8 | import { grantDevice, isGranted } from "../src/core/device_grants.ts"; |
| 9 | 9 | import { declareDevice } from "../src/core/devices.ts"; |
| 10 | 10 | import { createToken } from "../src/core/tokens.ts"; |
| 11 | import { createSession } from "../src/core/sessions.ts"; | |
| 11 | 12 | import { getUserByName } from "../src/core/users.ts"; |
| 12 | 13 | import { PDF_MAX_BYTES, PRINTS_PER_MINUTE, checkPdf, pageCount } from "../src/devices/print.ts"; |
| 13 | 14 | import { serve, type Servers } from "../src/http/server.ts"; |
| @@ -202,7 +203,7 @@ test("the owner grants and revokes on the device page", async () => { | ||
| 202 | 203 | |
| 203 | 204 | test("a non-admin is refused on grant and revoke, and changes nothing", async () => { |
| 204 | 205 | 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" }); | |
| 206 | 207 | if (!made.ok) throw new Error(made.error); |
| 207 | 208 | const { createSession } = await import("../src/core/sessions.ts"); |
| 208 | 209 | 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", () => { | ||
| 226 | 227 | expect(pageCount(pdf(3))).toBe(3); |
| 227 | 228 | expect(checkPdf(enc.encode("%PDF-1.5\n<< /Type /ObjStm >>\n%%EOF"))).toEqual({ ok: false, error: "pages_unknown" }); |
| 228 | 229 | }); |
| 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", () => { | ||
| 116 | 116 | await feed("labels\r"); |
| 117 | 117 | expect(mine()).toEqual(["labels"]); |
| 118 | 118 | 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"); | |
| 120 | 131 | await feed("r"); |
| 121 | 132 | await feed("labels\r"); |
| 122 | 133 | expect(mine()).toEqual([]); |
Race: in-flight is only counted after the body is read (printFor), so parallel requests all pass here. Reserve the slot in this function.