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

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

Merged opened this

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

Merged

mason/forge:mason/10568-an-app-granted-the-printer-prints-a-pdf → main 5 commits fork 3 threads · 3 unresolved

  • Merged. mason/10568-an-app-granted-the-printer-prints-a-pdf was merged into main as 324c6af.

Review

Sign in to review this change.
18 files changed +710 −14
docs/features/devices.md +11
@@ -8,3 +8,14 @@
88 - Declaring and removing append `device.declared` / `device.removed` events.
99 - Assumptions: IPP 1.1 over plain HTTP(S) to the address given; supply levels are the percent the printer reports. 0.0.0.0 and 169.254.0.0/16 (cloud metadata) are refused outright.
1010 - The terminal has the same screen for site admins (palette: Devices): `d` declares, `x` removes after a confirm, through the same actions and events as the page.
11
12 ## Printing from an app
13
14 - On a device's row ("Allowed apps") the owner grants it to an app by slug, and revokes it again (`device.granted` / `device.revoked` events). No grant, no print. In the terminal's Devices screen, `a` allows the device under the cursor to an app (type its slug) and `r` revokes one; the same actions and events as the page.
15 - An app prints with `POST $FORGE_API_URL/devices/<id>/print`, `Authorization: Bearer $FORGE_TOKEN`: the app process's own token, body the PDF (`content-type: application/pdf`). The forge knows the app from `app_token`, which only the runtime writes when it mints that token; a person's token named like an app is not one. The reply is `{"job":{"id","state","reasons"}}` from the printer's IPP Print-Job answer.
16 - Checked before anything leaves for the LAN: at most 10 MB and 20 pages, `%PDF-` header and `%%EOF` trailer, otherwise 422. Pages are counted from the plain page tree; a PDF whose tree is in a compressed object stream answers 422 `pages_unknown`. Not granted, or not a declared device: 403. Printer silent or refusing: 502.
17 - The route takes a device number only; the job goes to the declared address, so an app reaches no other LAN host through it. Its cell's own egress is unchanged (`src/apps/egress.ts` still opens only granted `host:port`s).
18 - Each print appends `device.printed` (device, app, pages).
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 - A PDF containing an object stream (`/ObjStm`) answers 422 `pages_unknown`: the page tree could hide there behind a plain decoy.
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.
src/actions/devices.ts +25 −1
@@ -7,6 +7,7 @@
77 import type { App } from "../app.ts";
88 import {
99 declareDevice,
10 getDevice,
1011 isFresh,
1112 keepProbe,
1213 listDevices,
@@ -14,11 +15,12 @@ import {
1415 type Device,
1516 type DeviceError,
1617 } from "../core/devices.ts";
18 import { grantDevice, revokeDevice } from "../core/device_grants.ts";
1719 import type { User } from "../core/users.ts";
1820 import { probePrinter, type PrinterReport } from "../devices/ipp.ts";
1921 import { err, ok, type Result } from "../kernel/result.ts";
2022
21 export type DeviceActionError = DeviceError | "forbidden";
23 export type DeviceActionError = DeviceError | "forbidden" | "no_such_app";
2224
2325 export const DEVICE_ERRORS: Record<DeviceActionError, string> = {
2426 invalid_name: "A name is letters, digits, spaces, dots, dashes or underscores, up to 60.",
@@ -27,6 +29,7 @@ export const DEVICE_ERRORS: Record<DeviceActionError, string> = {
2729 public_address: "The address must be a number on your own network (like 192.168.1.161). The forge does not ask public hosts or look up names.",
2830 name_taken: "A device already has that name.",
2931 not_found: "No such device.",
32 no_such_app: "No such app, or nothing to change for it.",
3033 forbidden: "Only a site admin manages devices.",
3134 };
3235
@@ -58,6 +61,27 @@ export function remove(app: App, actor: User | undefined, id: number): Result<tr
5861 return gone;
5962 }
6063
64 /** The owner lets one app print on one device; the app is named by slug. */
65 export function grant(app: App, actor: User | undefined, id: number, slug: string): Result<string, DeviceActionError> {
66 if (!mayManageDevices(actor)) return err("forbidden");
67 const device = Number.isSafeInteger(id) ? getDevice(app.db, id) : undefined;
68 if (!device) return err("not_found");
69 const made = grantDevice(app.db, id, slug, actor.id);
70 if (!made.ok) return err("no_such_app");
71 app.bus.append({ kind: "device.granted", actorId: actor.id, payload: { device: device.name, app: made.value.slug } });
72 return ok(made.value.slug);
73 }
74
75 export function revoke(app: App, actor: User | undefined, id: number, slug: string): Result<string, DeviceActionError> {
76 if (!mayManageDevices(actor)) return err("forbidden");
77 const device = Number.isSafeInteger(id) ? getDevice(app.db, id) : undefined;
78 if (!device) return err("not_found");
79 const gone = revokeDevice(app.db, id, slug);
80 if (!gone.ok) return err("no_such_app");
81 app.bus.append({ kind: "device.revoked", actorId: actor.id, payload: { device: device.name, app: gone.value.slug } });
82 return ok(gone.value.slug);
83 }
84
6185 /** The devices, each stale printer asked at once within the probe's own bound; a fresh one is not asked. */
6286 export async function devicesWithStatus(app: App): Promise<Device[]> {
6387 return Promise.all(
src/apps/token.ts +7 −1
@@ -41,9 +41,15 @@ export function mintAppToken(
4141 env: string,
4242 now: number = Date.now(),
4343 ): AppToken {
44 return createToken(db, ownerId, appTokenName(slug, env), "ro", {
44 const token = createToken(db, ownerId, appTokenName(slug, env), "ro", {
4545 expiresAt: until(now),
4646 });
47 // Which app holds it: what the forge trusts when the process asks for a
48 // device (`core/device_grants.ts`), because only this function writes it.
49 db.query(
50 "INSERT INTO app_token (token_id, app_id) SELECT ?1, id FROM app WHERE slug = ?2",
51 ).run(token.id, slug);
52 return token;
4753 }
4854
4955 export function renewAppToken(
src/contract/openapi/routes/index.ts +1
@@ -255,6 +255,7 @@ export { route as postApiV1ConversationMessages } from "./postApiV1ConversationM
255255 export { route as postApiV1ConversationStop } from "./postApiV1ConversationStop.ts";
256256 export { route as postApiV1ConversationTokens } from "./postApiV1ConversationTokens.ts";
257257 export { route as postApiV1Conversations } from "./postApiV1Conversations.ts";
258 export { route as postApiV1DevicesIdPrint } from "./postApiV1DevicesIdPrint.ts";
258259 export { route as postApiV1Drills } from "./postApiV1Drills.ts";
259260 export { route as postApiV1FeedChips } from "./postApiV1FeedChips.ts";
260261 export { route as postApiV1FeedInteractions } from "./postApiV1FeedInteractions.ts";
src/contract/openapi/routes/postApiV1DevicesIdPrint.ts added +19
@@ -0,0 +1,19 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: "/api/v1/devices/{id}/print",
6 summary: "Print the PDF in the request body on a device the app was granted (the app's own token); answers 200 with the job",
7 auth: true,
8 refusals: {
9 forbidden: "403: the app holds no grant on this device",
10 rate_limited: "429: the app started more than 10 prints in a minute, or has 2 in flight",
11 too_large: "422: the PDF is over the size bound",
12 not_pdf: "422: the body is not a PDF",
13 too_many_pages: "422: the PDF has more pages than the grant allows",
14 pages_unknown: "422: the page count could not be read",
15 printer_unreachable: "502: the printer did not answer",
16 printer_refused: "502: the printer refused the job",
17 unauthorized: "no token",
18 },
19 };
src/core/device_grants.ts added +60
@@ -0,0 +1,60 @@
1 /**
2 * Which apps may use which device. The owner grants one declared device to
3 * one app; an app is known by the credential its process holds (`app_token`),
4 * never by a name or an address it sends. No grant, no print.
5 */
6
7 import type { Database } from "bun:sqlite";
8 import { sha256 } from "../kernel/hash.ts";
9 import { err, ok, type Result } from "../kernel/result.ts";
10
11 export interface AppRef {
12 readonly id: string;
13 readonly slug: string;
14 }
15
16 export type GrantError = "not_found";
17
18 /** The app whose running process holds this token secret, or undefined. */
19 export function appOfToken(db: Database, secret: string): AppRef | undefined {
20 return (
21 db
22 .query<AppRef, [string]>(
23 `SELECT a.id, a.slug FROM token t
24 JOIN app_token x ON x.token_id = t.id
25 JOIN app a ON a.id = x.app_id
26 WHERE t.token_hash = ?1 AND (t.expires_at IS NULL OR t.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))`,
27 )
28 .get(sha256(secret)) ?? undefined
29 );
30 }
31
32 export const isGranted = (db: Database, deviceId: number, appId: string): boolean =>
33 db.query("SELECT 1 FROM device_grant WHERE device_id = ?1 AND app_id = ?2").get(deviceId, appId) !== null;
34
35 export function grantDevice(db: Database, deviceId: number, slug: string, by: string): Result<AppRef, GrantError> {
36 const app = db.query<AppRef, [string]>("SELECT id, slug FROM app WHERE slug = ?1").get(slug);
37 const device = db.query("SELECT 1 FROM device WHERE id = ?1").get(deviceId);
38 if (!app || !device) return err("not_found");
39 db.query("INSERT OR IGNORE INTO device_grant (device_id, app_id, granted_by) VALUES (?1, ?2, ?3)").run(deviceId, app.id, by);
40 return ok(app);
41 }
42
43 export function revokeDevice(db: Database, deviceId: number, slug: string): Result<AppRef, GrantError> {
44 const app = db.query<AppRef, [string]>("SELECT id, slug FROM app WHERE slug = ?1").get(slug);
45 if (!app) return err("not_found");
46 const gone = db.query("DELETE FROM device_grant WHERE device_id = ?1 AND app_id = ?2").run(deviceId, app.id);
47 return gone.changes === 0 ? err("not_found") : ok(app);
48 }
49
50 /** Slugs of the apps granted this device, and every app slug for the picker. */
51 export const grantedSlugs = (db: Database, deviceId: number): string[] =>
52 db
53 .query<{ slug: string }, [number]>(
54 "SELECT a.slug FROM device_grant g JOIN app a ON a.id = g.app_id WHERE g.device_id = ?1 ORDER BY a.slug",
55 )
56 .all(deviceId)
57 .map((r) => r.slug);
58
59 export const allAppSlugs = (db: Database): string[] =>
60 db.query<{ slug: string }, []>("SELECT slug FROM app ORDER BY slug").all().map((r) => r.slug);
src/core/migration-pins/20261110120000-device-grants.pin added +1
@@ -0,0 +1,1 @@
1 dfadb8e0cefb29a13678e3273325a59aa53a5ec2c0ff6aac56e78cb37d4c005a
src/core/migrations/20261110120000-device-grants.ts added +18
@@ -0,0 +1,18 @@
1 // An app granted a device may print on it through the forge (#10568).
2 // `device_grant` is the owner's yes, one row per (device, app); `app_token`
3 // says which app a running process's token belongs to, so the forge knows who
4 // is asking from the credential and not from a name anyone could choose.
5 // Both rows go with the device, the app or the token. Additive.
6 export default `
7 CREATE TABLE device_grant (
8 device_id INTEGER NOT NULL REFERENCES device(id) ON DELETE CASCADE,
9 app_id TEXT NOT NULL REFERENCES app(id) ON DELETE CASCADE,
10 granted_by TEXT REFERENCES user(id) ON DELETE SET NULL,
11 granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
12 PRIMARY KEY (device_id, app_id)
13 );
14 CREATE TABLE app_token (
15 token_id TEXT PRIMARY KEY REFERENCES token(id) ON DELETE CASCADE,
16 app_id TEXT NOT NULL REFERENCES app(id) ON DELETE CASCADE
17 );
18 `;
src/devices/ipp.ts +75 −2
@@ -5,6 +5,7 @@
55 * touches the network, to the one validated URL it is given.
66 */
77
8 const OP_PRINT_JOB = 0x0002; // RFC 8011 §4.2.1
89 const OP_GET_PRINTER_ATTRIBUTES = 0x000b; // RFC 8011 §5.2 operation-id registry
910 const TAG = {
1011 operation: 0x01,
@@ -13,6 +14,8 @@ const TAG = {
1314 boolean: 0x22,
1415 enum: 0x23,
1516 keyword: 0x44,
17 mimeType: 0x49,
18 nameWithoutLanguage: 0x42,
1619 uri: 0x45,
1720 charset: 0x47,
1821 language: 0x48,
@@ -158,6 +161,17 @@ export type ProbeResult =
158161 * the wait bounded.
159162 */
160163 export async function probePrinter(url: string, timeoutMs = 2500): Promise<ProbeResult> {
164 const answer = await exchange(url, encodeGetPrinterAttributes(url), timeoutMs);
165 if (!answer.ok) return answer;
166 return { ok: true, report: reportOf(answer.response.attributes) };
167 }
168
169 type Exchange =
170 | { readonly ok: true; readonly response: IppResponse }
171 | { readonly ok: false; readonly error: "unreachable" | "refused" | "bad_answer" };
172
173 /** One IPP request to `url` and its decoded, successful answer. */
174 async function exchange(url: string, body: Uint8Array, timeoutMs: number): Promise<Exchange> {
161175 const parsed = URL.parse(url);
162176 if (parsed === null) return { ok: false, error: "bad_answer" };
163177 const secure = parsed.protocol === "ipps:" || parsed.protocol === "https:";
@@ -169,7 +183,7 @@ export async function probePrinter(url: string, timeoutMs = 2500): Promise<Probe
169183 const res = await fetch(wire, {
170184 method: "POST",
171185 headers: { "content-type": "application/ipp" },
172 body: encodeGetPrinterAttributes(url),
186 body,
173187 redirect: "error",
174188 signal: AbortSignal.timeout(timeoutMs),
175189 });
@@ -179,12 +193,71 @@ export async function probePrinter(url: string, timeoutMs = 2500): Promise<Probe
179193 const decoded = decodeResponse(bytes);
180194 // 0x0000–0x00ff: successful (RFC 8011 §5.4.1).
181195 if (decoded === undefined || decoded.status > 0xff) return { ok: false, error: "bad_answer" };
182 return { ok: true, report: reportOf(decoded.attributes) };
196 return { ok: true, response: decoded };
183197 } catch {
184198 return { ok: false, error: "unreachable" };
185199 }
186200 }
187201
202 /** The request body for Print-Job: the document follows the attributes. */
203 export function encodePrintJob(printerUri: string, pdf: Uint8Array, jobName: string, requestId = 1): Uint8Array {
204 const head: number[] = [
205 1, 1,
206 OP_PRINT_JOB >> 8, OP_PRINT_JOB & 255,
207 (requestId >>> 24) & 255, (requestId >>> 16) & 255, (requestId >>> 8) & 255, requestId & 255,
208 TAG.operation,
209 ...attr(TAG.charset, "attributes-charset", "utf-8"),
210 ...attr(TAG.language, "attributes-natural-language", "en"),
211 ...attr(TAG.uri, "printer-uri", printerUri),
212 ...attr(TAG.nameWithoutLanguage, "requesting-user-name", "forge"),
213 ...attr(TAG.nameWithoutLanguage, "job-name", jobName),
214 ...attr(TAG.mimeType, "document-format", "application/pdf"),
215 TAG.end,
216 ];
217 const out = new Uint8Array(head.length + pdf.length);
218 out.set(head);
219 out.set(pdf, head.length);
220 return out;
221 }
222
223 export type JobState = "pending" | "held" | "processing" | "stopped" | "canceled" | "aborted" | "completed" | "unknown";
224
225 /** job-state enum (RFC 8011 §5.3.7). */
226 const JOB_STATES: Record<number, JobState> = {
227 3: "pending", 4: "held", 5: "processing", 6: "stopped", 7: "canceled", 8: "aborted", 9: "completed",
228 };
229
230 export interface PrintJob {
231 readonly id: number | null;
232 readonly state: JobState;
233 readonly reasons: readonly string[];
234 }
235
236 export type PrintResult =
237 | { readonly ok: true; readonly job: PrintJob }
238 | { readonly ok: false; readonly error: "unreachable" | "refused" | "bad_answer" };
239
240 /**
241 * Send `pdf` to the printer at `url` (already validated and granted) as an
242 * IPP Print-Job and return the job state the printer reports. A printer that
243 * takes a long document needs longer than a probe does.
244 */
245 export async function printPdf(url: string, pdf: Uint8Array, jobName: string, timeoutMs = 30_000): Promise<PrintResult> {
246 const answer = await exchange(url, encodePrintJob(url, pdf, jobName), timeoutMs);
247 if (!answer.ok) return answer;
248 const a = answer.response.attributes;
249 const id = a.get("job-id")?.[0];
250 const state = a.get("job-state")?.[0];
251 return {
252 ok: true,
253 job: {
254 id: typeof id === "number" ? id : null,
255 state: typeof state === "number" ? (JOB_STATES[state] ?? "unknown") : "unknown",
256 reasons: str(a, "job-state-reasons").filter((r) => r !== "none"),
257 },
258 };
259 }
260
188261 async function readCapped(res: Response): Promise<Uint8Array | undefined> {
189262 const reader = res.body?.getReader();
190263 if (!reader) return undefined;
src/devices/print.ts added +109
@@ -0,0 +1,109 @@
1 /**
2 * An app printing through the forge. The app holds only its own token; the
3 * forge decides: the token names the app, the owner's grant names the device,
4 * and the address is the one declared, never one the request carries. The
5 * document is checked here, before any byte leaves for the LAN.
6 */
7
8 import type { App } from "../app.ts";
9 import { appOfToken, isGranted, type AppRef } from "../core/device_grants.ts";
10 import { getDevice, type Device } from "../core/devices.ts";
11 import { RateLimiter } from "../core/ratelimit.ts";
12 import { err, ok, type Result } from "../kernel/result.ts";
13 import { printPdf, type PrintJob } from "./ipp.ts";
14
15 export const PDF_MAX_BYTES = 10 * 1024 * 1024;
16 export const PDF_MAX_PAGES = 20;
17
18 /** Prints one app may start in a minute, and at once: each holds the forge up to the printer's own bound. */
19 export const PRINTS_PER_MINUTE = 10;
20 export const PRINTS_AT_ONCE = 2;
21
22 export type PrintError = "forbidden" | "rate_limited" | "not_pdf" | "too_large" | "too_many_pages" | "pages_unknown" | "printer_unreachable" | "printer_refused";
23
24 export const PRINT_WORDS: Record<PrintError, string> = {
25 forbidden: "This app is not granted that printer.",
26 rate_limited: "This app is printing too fast; wait a little.",
27 not_pdf: "The body must be a PDF (application/pdf).",
28 too_large: "The PDF is over 10 MB.",
29 too_many_pages: "The PDF is over 20 pages.",
30 pages_unknown: "The page count could not be read; save the PDF without compressed object streams.",
31 printer_unreachable: "The printer did not answer.",
32 printer_refused: "The printer did not accept the job.",
33 };
34
35 const latin = {
36 decode: (b: Uint8Array): string => Buffer.from(b.buffer, b.byteOffset, b.byteLength).toString("latin1"),
37 };
38
outdated

A PDF with a plain fake '/Type /Pages /Count 1' and the real tree in an /ObjStm passes the 20-page cap. Refuse /ObjStm outright or parse the tree.

39 /**
40 * The page count of a PDF read from its plain page tree: the largest `/Count`
41 * of a `/Type /Pages` node, or the number of `/Type /Page` leaves, whichever is
42 * greater. Zero when the file has an object stream (or a type name written
43 * with `#` escapes), since the real tree could sit there while a plain
44 * decoy node says `/Count 1`.
45 */
46 export function pageCount(pdf: Uint8Array): number {
47 const text = latin.decode(pdf);
48 if (/\/ObjStm|\/Type\s*\/\w*#/.test(text)) return 0;
49 const counted = [...text.matchAll(/\/Type\s*\/Page(?![A-Za-z])/g)].length;
50 let declared = 0;
51 for (const m of text.matchAll(/\/Type\s*\/Pages(?![A-Za-z])/g)) {
52 const near = text.slice(Math.max(0, m.index - 300), m.index + 300);
53 for (const c of near.matchAll(/\/Count\s+(\d+)/g)) declared = Math.max(declared, Number(c[1]));
54 }
55 return Math.max(counted, declared);
56 }
57
58 /** The body is a PDF within the limits, or why not. */
59 export function checkPdf(pdf: Uint8Array): Result<number, PrintError> {
60 if (pdf.length > PDF_MAX_BYTES) return err("too_large");
61 const tail = latin.decode(pdf.subarray(Math.max(0, pdf.length - 1024)));
62 if (pdf.length < 8 || latin.decode(pdf.subarray(0, 5)) !== "%PDF-" || !tail.includes("%%EOF")) return err("not_pdf");
63 const pages = pageCount(pdf);
64 if (pages === 0) return err("pages_unknown");
65 if (pages > PDF_MAX_PAGES) return err("too_many_pages");
66 return ok(pages);
67 }
68
69 const perMinute = new RateLimiter({ perMinute: PRINTS_PER_MINUTE, adminPerMinute: PRINTS_PER_MINUTE, enabled: true });
70 const inFlight = new Map<string, number>();
71
72 /** Who is printing and where, decided from the token and the grant alone. */
73 export interface Permit {
74 readonly caller: AppRef;
75 readonly device: Device;
76 }
77
78 /**
79 * The app holding `secret` and the device it is granted, or `forbidden`
80 * (so a caller learns nothing about devices it was not given), or
81 * `rate_limited`. Nothing of the body has been read yet: an unknown or
82 * ungranted caller costs a lookup.
83 */
84 export function permitPrint(app: App, secret: string, deviceId: number): Result<Permit, PrintError> {
85 const caller = appOfToken(app.db, secret);
86 if (!caller || !Number.isSafeInteger(deviceId)) return err("forbidden");
87 const device = getDevice(app.db, deviceId);
88 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 });
91 }
92
93 /** Print `pdf` for a permitted app; the document is checked before any byte leaves for the LAN. */
94 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);
107 }
108 return err(sent.error === "unreachable" ? "printer_unreachable" : "printer_refused");
109 }
src/http/api.ts +2
@@ -340,6 +340,7 @@ import { frontInput, frontJson, setFront } from "../actions/front.ts";
340340 import { COPIES_PER_DAY, makeItYours } from "../actions/make_it_yours.ts";
341341 import { passportRoutes } from "./passport_edge.ts";
342342 import { egressGrantRoutes } from "./api/egress_grants.ts";
343 import { printRoutes } from "./api/print.ts";
343344 import { forgeRoutes } from "./api/forges.ts";
344345 import { forgeViewRoutes } from "./api/forge_view.ts";
345346 import { economicsRoutes } from "./api/economics.ts";
@@ -3763,6 +3764,7 @@ export function apiRoutes(router: Router<Handler>): void {
37633764 iterationRoutes(router);
37643765
37653766 egressGrantRoutes(router);
3767 printRoutes(router);
37663768
37673769 promptRoutes(router);
37683770
src/http/api/print.ts added +39
@@ -0,0 +1,39 @@
1 /**
2 * `POST /api/v1/devices/:id/print` — an app prints a PDF on a device its
3 * owner granted it. The credential is the app's own token; the body is the
4 * PDF. The route takes a device number and nothing that names an address.
5 */
6
7 import { PRINT_WORDS, PDF_MAX_BYTES, permitPrint, printFor, type PrintError } from "../../devices/print.ts";
8 import type { Handler } from "../context.ts";
9 import { readBounded } from "../limits.ts";
10 import { apiError, json, unauthorized } from "../respond.ts";
11 import type { Router } from "../router.ts";
12 import { tokenSecret } from "../token_header.ts";
13
14 const STATUS: Record<PrintError, number> = {
15 forbidden: 403,
16 rate_limited: 429,
17 not_pdf: 422,
18 too_large: 422,
19 too_many_pages: 422,
20 pages_unknown: 422,
21 printer_unreachable: 502,
22 printer_refused: 502,
23 };
24
25 export function printRoutes(router: Router<Handler>): void {
26 router.add("POST", "/api/v1/devices/:id/print", async (ctx) => {
27 const secret = tokenSecret(ctx.request.headers.get("authorization") ?? "");
28 if (secret === null) return unauthorized();
outdated

High: the body is read (up to 10 MB) before the caller is verified; any bearer string reaches this. Verify the token and the grant first, then read. Add a rate limit per app id.

29 const id = /^\d{1,9}$/.test(ctx.params["id"] ?? "") ? Number(ctx.params["id"]) : Number.NaN;
30 // Who and where first; the body (up to 10 MB) is read only for a granted app.
31 const permit = permitPrint(ctx.app, secret, id);
32 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);
35 const done = await printFor(ctx.app, permit.value, pdf);
36 if (!done.ok) return apiError(done.error, STATUS[done.error], PRINT_WORDS[done.error]);
37 return json({ job: { id: done.value.id, state: done.value.state, reasons: done.value.reasons } });
38 });
39 }
src/http/settings_routes.ts +3 −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, devicesPage, deviceRemove } from "../web/settings/devices.ts";
41 import { deviceDeclare, deviceGrant, devicesPage, deviceRemove, deviceRevoke } from "../web/settings/devices.ts";
4242 import { secretDelete, secretSet, secretsPage } from "../web/pages/secrets.ts";
4343 import {
4444 backupsClear,
@@ -182,6 +182,8 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
182182 router.add("GET", "/settings/devices", devicesPage);
183183 router.add("POST", "/settings/devices", deviceDeclare);
184184 router.add("POST", "/settings/devices/delete", deviceRemove);
185 router.add("POST", "/settings/devices/grant", deviceGrant);
186 router.add("POST", "/settings/devices/revoke", deviceRevoke);
185187 router.add("GET", "/settings/cloud", cloudPage);
186188 router.add("POST", "/settings/cloud", cloudAdd);
187189 router.add("POST", "/settings/cloud/verify", cloudVerify);
src/tui/screens/devices.ts +30 −4
@@ -3,11 +3,14 @@
33 * Devices lists, each with its model, state and supplies on one line. `d`
44 * declares one (a name, then its address, in one line); `x` removes the one
55 * under the cursor after a confirm. Both go through `actions/devices.ts`, so
6 * the refusals and the `device.*` events are the page's. Anyone who is not an
6 * the refusals and the `device.*` events are the page's. `a` allows the one
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
79 * admin (or is an agent) is offered no entry and changes nothing.
810 */
911
10 import { DEVICE_ERRORS, declare, devicesWithStatus, mayManageDevices, remove, statusLine } from "../../actions/devices.ts";
12 import { DEVICE_ERRORS, declare, devicesWithStatus, grant, mayManageDevices, remove, revoke, statusLine } from "../../actions/devices.ts";
13 import { allAppSlugs, grantedSlugs } from "../../core/device_grants.ts";
1114 import { listDevices, type Device } from "../../core/devices.ts";
1215 import type { Ctx, Screen } from "../app/app.ts";
1316 import type { PaletteEntry } from "./entries.ts";
@@ -54,6 +57,20 @@ export function devicesScreen(f: Session, ctx: Ctx): Screen {
5457 placeholder: "Hall printer ipp://192.168.1.161:631/ipp/print",
5558 onSubmit: declareOne,
5659 });
60 const change = (d: Device, act: typeof grant, done: string) => (line: string): string | undefined => {
61 const slug = line.trim();
62 const made = act(f.app, f.user, d.id, slug);
63 note = made.ok ? `${made.value} ${done} ${d.name}.` : DEVICE_ERRORS[made.error];
64 if (made.ok) reload();
65 return made.ok ? undefined : note;
66 };
67 const askApp = (d: Device, act: typeof grant, title: string, done: string): Screen =>
68 promptScreen(ctx, screen, {
69 title,
70 label: `App slug for ${d.name}`,
71 placeholder: allAppSlugs(f.db).join(" ") || "no apps yet",
72 onSubmit: change(d, act, done),
73 });
5774 const confirmRemove = (d: Device): Screen =>
5875 confirmScreen(ctx, screen, {
5976 title: "Remove this device",
@@ -67,10 +84,14 @@ export function devicesScreen(f: Session, ctx: Ctx): Screen {
6784 },
6885 });
6986
87 const apps = (d: Device): string => {
88 const slugs = grantedSlugs(f.db, d.id);
89 return slugs.length === 0 ? "" : ` · apps: ${slugs.join(", ")}`;
90 };
7091 const lines = (): ListRow[] =>
7192 rows.map((d) => ({
7293 title: d.name,
73 meta: d.probedAt === null ? d.url : `${statusLine(d.probe)} · ${d.url}`,
94 meta: `${d.probedAt === null ? d.url : `${statusLine(d.probe)} · ${d.url}`}${apps(d)}`,
7495 ...(d.probe?.supplies.length
7596 ? { below: [d.probe.supplies.map((s) => `${s.name} ${s.percent === null ? "?" : `${s.percent}%`}`).join(" · ")] }
7697 : {}),
@@ -78,7 +99,7 @@ export function devicesScreen(f: Session, ctx: Ctx): Screen {
7899
79100 const screen: Screen = {
80101 title: "Devices",
81 keys: [binding(["d"], "declare"), binding(["x"], "remove"), binding(["j", "k"], "move cursor")],
102 keys: [binding(["d"], "declare"), binding(["x"], "remove"), binding(["a"], "allow an app"), binding(["r"], "revoke an app"), binding(["j", "k"], "move cursor")],
82103 render(area, s) {
83104 const c = ctx.theme.c;
84105 const text = note !== "" ? note : mayUse(f) ? `${rows.length} declared.` : DEVICE_ERRORS.forbidden;
@@ -96,6 +117,11 @@ export function devicesScreen(f: Session, ctx: Ctx): Screen {
96117 return true;
97118 }
98119 const d = rows[state.index];
120 if ((e.key === "a" || e.key === "r") && d !== undefined) {
121 note = "";
122 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 return true;
124 }
99125 if (e.key === "x" && d !== undefined) {
100126 note = "";
101127 ctx.push(confirmRemove(d));
src/web/settings/devices.ts +48 −3
@@ -7,7 +7,8 @@
77 */
88
99 import type { Ctx } from "../../http/context.ts";
10 import { DEVICE_ERRORS, declare, devicesWithStatus, remove, statusLine } from "../../actions/devices.ts";
10 import { DEVICE_ERRORS, declare, grant, revoke, devicesWithStatus, remove, statusLine } from "../../actions/devices.ts";
11 import { allAppSlugs, grantedSlugs } from "../../core/device_grants.ts";
1112 import { NAME_MAX, URL_MAX, type Device } from "../../core/devices.ts";
1213 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
1314 import type { PrinterReport } from "../../devices/ipp.ts";
@@ -37,7 +38,33 @@ function supplies(report: PrinterReport): Html | null {
3738 </details>`;
3839 }
3940
40 function card(d: Device): Html {
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 function access(ctx: Ctx, d: Device): Html {
43 const granted = grantedSlugs(ctx.app.db, d.id);
44 const rest = allAppSlugs(ctx.app.db).filter((s) => !granted.includes(s));
45 if (granted.length === 0 && rest.length === 0) return html``;
outdated

DESIGN.md 1, 3 and 7: hand-rolled 'small' div with a repeated muted label and 'none'. A fact is a dl row; each grant is a row with its revoke button. Also no terminal screen for this (both doors).

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>`,
50 trail: html`<form class="row-actions" method="post" action="/settings/devices/revoke">
51 <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}` })}
54 </form>`,
55 })}</li>`,
56 )}</ul>
57 ${when(rest.length > 0, () => html`<form class="edit-row" method="post" action="/settings/devices/grant">
58 <input type="hidden" name="id" value="${String(d.id)}" />
59 <select class="input" name="app" aria-label="App to grant ${d.name}">
60 ${rest.map((slug) => html`<option value="${slug}">${slug}</option>`)}
61 </select>
62 ${btn("Grant", { size: "sm", icon: "plus", label: `Grant an app ${d.name}` })}
63 </form>`)}
64 </dd></div></dl>`;
65 }
66
67 function card(ctx: Ctx, d: Device): Html {
4168 return html`<li>
4269 ${row({
4370 title: html`${d.name} <span class="muted small">printer</span>`,
@@ -48,6 +75,7 @@ function card(d: Device): Html {
4875 </form>`,
4976 })}
5077 ${d.probe ? supplies(d.probe) : null}
78 ${access(ctx, d)}
5179 </li>`;
5280 }
5381
@@ -67,7 +95,7 @@ export async function devicesPage(ctx: Ctx): Promise<Response> {
6795 html`<section class="settings-block">
6896 <p class="muted">Printers on your network, as the forge sees them; only addresses like 192.168.1.161 are accepted.</p>
6997 ${when(devices.length === 0, () => html`<p class="muted">No devices declared.</p>`)}
70 ${when(devices.length > 0, () => html`<ul class="list setting-list">${devices.map(card)}</ul>`)}
98 ${when(devices.length > 0, () => html`<ul class="list setting-list">${devices.map((d) => card(ctx, d))}</ul>`)}
7199 <form class="edit-row mt" method="post" action="/settings/devices">
72100 <input type="hidden" name="kind" value="printer" />
73101 <input class="input" type="text" name="name" placeholder="Name" aria-label="Device name" maxlength="${String(NAME_MAX)}" required />
@@ -113,3 +141,20 @@ export async function deviceRemove(ctx: Ctx): Promise<Response> {
113141 gone.ok ? { message: "Removed." } : { tone: "danger", message: DEVICE_ERRORS[gone.error] },
114142 );
115143 }
144
145 // POST /settings/devices/grant and /settings/devices/revoke
146 async function grantChange(ctx: Ctx, change: typeof grant, done: string): Promise<Response> {
147 const denied = guard(ctx);
148 if (denied) return denied;
149 const form = await readForm(ctx, "admin");
150 if (form instanceof Response) return form;
151 const result = change(ctx.app, ctx.auth.user, Number(field(form, "id")), field(form, "app"));
152 return flashTo(
153 ctx,
154 "/settings/devices",
155 result.ok ? { message: `${result.value} ${done}.` } : { tone: "danger", message: DEVICE_ERRORS[result.error] },
156 );
157 }
158
159 export const deviceGrant = (ctx: Ctx): Promise<Response> => grantChange(ctx, grant, "may print here now");
160 export const deviceRevoke = (ctx: Ctx): Promise<Response> => grantChange(ctx, revoke, "may no longer print here");
test/checks-home-sweep.test.ts +7 −1
@@ -7,7 +7,7 @@
77 * (#7673).
88 */
99
10 import { afterAll, beforeAll, expect, test } from "bun:test";
10 import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test";
1111 import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
1212 import { tmpdir } from "node:os";
1313 import { join } from "node:path";
@@ -43,6 +43,12 @@ beforeAll(async () => {
4343 });
4444 afterAll(() => rmSync(dataDir, { recursive: true, force: true }));
4545
46 // A sweep an earlier test started (the timer starts one) may still be in flight, and sweepSoon answers it
47 // rather than a new one: let it end before each test.
48 beforeEach(async () => {
49 await sweepSoon(app);
50 });
51
4652 const home = (id: string): string => join(dataDir, "checks", `${id}.home`);
4753 const populate = (id: string, readOnly = false): void => {
4854 const deep = join(home(id), ".bun", "install", "cache");
test/print.test.ts added +228
@@ -0,0 +1,228 @@
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";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { mintAppToken, revokeAppToken } from "../src/apps/token.ts";
8 import { grantDevice, isGranted } from "../src/core/device_grants.ts";
9 import { declareDevice } from "../src/core/devices.ts";
10 import { createToken } from "../src/core/tokens.ts";
11 import { getUserByName } from "../src/core/users.ts";
12 import { PDF_MAX_BYTES, PRINTS_PER_MINUTE, checkPdf, pageCount } from "../src/devices/print.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14 import { adminCookie } from "./helpers/admin.ts";
15
16 const enc = new TextEncoder();
17 const attr = (tag: number, name: string, value: string | number) => {
18 const n = enc.encode(name);
19 const v = typeof value === "number" ? [value >>> 24, (value >>> 16) & 255, (value >>> 8) & 255, value & 255] : [...enc.encode(value)];
20 return [tag, n.length >> 8, n.length & 255, ...n, v.length >> 8, v.length & 255, ...v];
21 };
22
23 /** A one-page-per-leaf PDF the checker can read. */
24 const pdf = (pages: number): Uint8Array =>
25 enc.encode(
26 `%PDF-1.4\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Count ${pages} /Kids [] >>\nendobj\n` +
27 Array.from({ length: pages }, (_, i) => `${i + 3} 0 obj\n<< /Type /Page /Parent 2 0 R >>\nendobj\n`).join("") +
28 "trailer\n<< /Root 1 0 R >>\n%%EOF\n",
29 );
30
31 let received: Uint8Array[] = [];
32 const printer = Bun.serve({
33 port: 0,
34 hostname: "127.0.0.1",
35 async fetch(req) {
36 received.push(new Uint8Array(await req.arrayBuffer()));
37 return new Response(
38 Uint8Array.from([
39 1, 1, 0, 0, 0, 0, 0, 1,
40 0x01, ...attr(0x47, "attributes-charset", "utf-8"),
41 0x02, ...attr(0x21, "job-id", 42), ...attr(0x23, "job-state", 9), ...attr(0x44, "job-state-reasons", "job-completed-successfully"),
42 0x03,
43 ]),
44 { headers: { "content-type": "application/ipp" } },
45 );
46 },
47 });
48
49 let dataDir = "";
50 let app: App, servers: Servers, cookie = "";
51 let deviceId = 0, otherId = 0;
52 let granted = "", ungranted = "", plainToken = "";
53 const prior = process.env.FORGE_DEVICES_ALLOW_LOOPBACK;
54
55 function addApp(id: string, slug: string): void {
56 app.db.run("PRAGMA foreign_keys = OFF");
57 app.db.run("INSERT INTO app (id, repo_id, slug, access) VALUES (?1, ?2, ?3, 'private')", [id, `repo-${id}`, slug]);
58 app.db.run("PRAGMA foreign_keys = ON");
59 }
60
61 beforeAll(async () => {
62 process.env.FORGE_DEVICES_ALLOW_LOOPBACK = "1";
63 dataDir = await mkdtemp(join(tmpdir(), "forge-print-"));
64 app = createApp({ dataDir, port: 0 });
65 servers = await serve(app);
66 cookie = await adminCookie(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" });
67 const owner = getUserByName(app.db, "alice")!;
68 addApp("a1", "labels");
69 addApp("a2", "other");
70 const url = `ipp://127.0.0.1:${printer.port}/ipp/print`;
71 const d = declareDevice(app.db, { name: "Hall", kind: "printer", url });
72 const e = declareDevice(app.db, { name: "Attic", kind: "printer", url });
73 if (!d.ok || !e.ok) throw new Error("declare");
74 deviceId = d.value.id;
75 otherId = e.value.id;
76 expect(grantDevice(app.db, deviceId, "labels", owner.id).ok).toBe(true);
77 granted = mintAppToken(app.db, owner.id, "labels", "production").token;
78 ungranted = mintAppToken(app.db, owner.id, "other", "production").token;
79 // A person's own token that merely borrows an app's name is not an app.
80 plainToken = createToken(app.db, owner.id, "app:labels/production", "ro").token;
81 });
82 afterAll(async () => {
83 if (prior === undefined) delete process.env.FORGE_DEVICES_ALLOW_LOOPBACK;
84 else process.env.FORGE_DEVICES_ALLOW_LOOPBACK = prior;
85 printer.stop(true);
86 servers.stop();
87 app.close();
88 await rm(dataDir, { recursive: true, force: true });
89 });
90
91 const print = (path: string, token: string | undefined, body: string | Uint8Array) =>
92 fetch(`${servers.url}${path}`, {
93 method: "POST",
94 headers: { connection: "close", "content-type": "application/pdf", ...(token ? { authorization: `Bearer ${token}` } : {}) },
95 body,
96 });
97
98 test("a granted app prints and gets the job state", async () => {
99 received = [];
100 const res = await print(`/api/v1/devices/${deviceId}/print`, granted, pdf(2));
101 expect(res.status).toBe(200);
102 expect(await res.json()).toEqual({ job: { id: 42, state: "completed", reasons: ["job-completed-successfully"] } });
103 expect(received.length).toBe(1);
104 const sent = received[0]!;
105 expect([...sent.slice(0, 4)]).toEqual([1, 1, 0, 2]); // Print-Job
106 expect(Buffer.from(sent).toString("latin1")).toContain("application/pdf");
107 expect(Buffer.from(sent).toString("latin1")).toContain("%PDF-1.4");
108 });
109
110 test("an app without the grant is 403, and nothing is sent", async () => {
111 received = [];
112 expect((await print(`/api/v1/devices/${deviceId}/print`, ungranted, pdf(1))).status).toBe(403);
113 // Granted one device, not the other.
114 expect((await print(`/api/v1/devices/${otherId}/print`, granted, pdf(1))).status).toBe(403);
115 // A person's token named like an app is not an app.
116 expect((await print(`/api/v1/devices/${deviceId}/print`, plainToken, pdf(1))).status).toBe(403);
117 expect((await print(`/api/v1/devices/${deviceId}/print`, undefined, pdf(1))).status).toBe(401);
118 expect(received.length).toBe(0);
119 });
120
121 test("a stopped app's token no longer prints", async () => {
122 const owner = getUserByName(app.db, "alice")!;
123 const t = mintAppToken(app.db, owner.id, "labels", "preview");
124 expect((await print(`/api/v1/devices/${deviceId}/print`, t.token, pdf(1))).status).toBe(200);
125 revokeAppToken(app.db, t.id);
126 expect((await print(`/api/v1/devices/${deviceId}/print`, t.token, pdf(1))).status).toBe(403);
127 });
128
129 test("a non-PDF, an oversize or a long document is 422 and is not sent", async () => {
130 received = [];
131 const path = `/api/v1/devices/${deviceId}/print`;
132 expect((await print(path, granted, "just text")).status).toBe(422);
133 expect((await print(path, granted, enc.encode("%PDF-1.4 truncated, no end"))).status).toBe(422);
134 expect((await print(path, granted, pdf(21))).status).toBe(422);
135 expect((await print(path, granted, pdf(20))).status).toBe(200);
136 received = [];
137 const big = new Uint8Array(10 * 1024 * 1024 + 1);
138 big.set(enc.encode("%PDF-1.4\n"));
139 const res = await print(path, granted, big);
140 expect(res.status).toBe(422);
141 expect(((await res.json()) as { error: string }).error).toBe("too_large");
142 expect(received.length).toBe(0); // the oversize body closes its connection, so it goes last
143 });
144
145 test("a request naming any other address is refused, each with its own status", async () => {
146 received = [];
147 const lan = encodeURIComponent("ipp://192.168.1.50/ipp/print");
148 // Not a device number, or not a declared device: the same 403 as an ungranted one, whatever the body.
149 expect((await print(`/api/v1/devices/${lan}/print`, granted, pdf(1))).status).toBe(403);
150 expect((await print("/api/v1/devices/192.168.1.50/print", granted, pdf(1))).status).toBe(403);
151 expect((await print("/api/v1/devices/999/print", granted, pdf(1))).status).toBe(403);
152 expect(received.length).toBe(0);
153 // A named address in the query is ignored: the job still goes only to the declared one.
154 expect((await print(`/api/v1/devices/${deviceId}/print?url=${lan}`, granted, pdf(1))).status).toBe(200);
155 expect(received.length).toBe(1);
156 });
157
158 test("an ungranted caller is refused before its body is read", async () => {
159 const big = new Uint8Array(PDF_MAX_BYTES + 1);
160 const res = await print(`/api/v1/devices/${deviceId}/print`, "not-a-token", big);
161 expect(res.status).toBe(403); // not 422 too_large: the size was never looked at
162 });
163
164 test("an app is limited to a few prints a minute", async () => {
165 const owner = getUserByName(app.db, "alice")!;
166 addApp("a3", "chatty");
167 grantDevice(app.db, deviceId, "chatty", owner.id);
168 const t = mintAppToken(app.db, owner.id, "chatty", "production").token;
169 const statuses: number[] = [];
170 for (let i = 0; i < PRINTS_PER_MINUTE + 1; i++) statuses.push((await print(`/api/v1/devices/${deviceId}/print`, t, pdf(1))).status);
171 expect(statuses.slice(0, PRINTS_PER_MINUTE).every((s) => s === 200)).toBe(true);
172 expect(statuses[PRINTS_PER_MINUTE]).toBe(429);
173 // Another app's budget is its own.
174 expect((await print(`/api/v1/devices/${deviceId}/print`, granted, pdf(1))).status).toBe(200);
175 });
176
177 test("prints, grants and revokes append their events", () => {
178 const kinds = app.db.query<{ kind: string; payload: string }, []>("SELECT kind, payload FROM event WHERE kind LIKE 'device.%'").all();
179 const printed = kinds.find((k) => k.kind === "device.printed");
180 expect(printed).toBeDefined();
181 expect(JSON.parse(printed!.payload)).toMatchObject({ device: "Hall", app: "labels", pages: 2 });
182 });
183
184 test("the owner grants and revokes on the device page", async () => {
185 const post = (path: string, body: Record<string, string>) =>
186 fetch(`${servers.url}${path}`, {
187 method: "POST",
188 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url, cookie },
189 body: new URLSearchParams(body),
190 redirect: "manual",
191 });
192 expect((await post("/settings/devices/grant", { id: String(deviceId), app: "other" })).status).toBe(303);
193 expect((await print(`/api/v1/devices/${deviceId}/print`, ungranted, pdf(1))).status).toBe(200);
194 const page = await (await fetch(`${servers.url}/settings/devices`, { headers: { cookie } })).text();
195 expect(page).toContain("Allowed apps");
196 const events = () => app.db.query<{ kind: string }, []>("SELECT kind FROM event").all().map((e) => e.kind);
197 expect(events()).toContain("device.granted");
198 expect((await post("/settings/devices/revoke", { id: String(deviceId), app: "other" })).status).toBe(303);
199 expect(events()).toContain("device.revoked");
200 expect((await print(`/api/v1/devices/${deviceId}/print`, ungranted, pdf(1))).status).toBe(403);
201 });
202
203 test("a non-admin is refused on grant and revoke, and changes nothing", async () => {
204 const { createUser } = await import("../src/core/users.ts");
205 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
206 if (!made.ok) throw new Error(made.error);
207 const { createSession } = await import("../src/core/sessions.ts");
208 const bob = `forge_session=${createSession(app.db, made.value.id).token}`;
209 const res = await fetch(`${servers.url}/settings/devices/grant`, {
210 method: "POST",
211 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url, cookie: bob },
212 body: new URLSearchParams({ id: String(otherId), app: "other" }),
213 redirect: "manual",
214 });
215 expect(res.status).not.toBe(303);
216 expect(isGranted(app.db, otherId, "a2")).toBe(false);
217 });
218
219 test("an object stream cannot hide the page tree behind a plain decoy", () => {
220 const decoy = "%PDF-1.5\n<< /Type /Pages /Count 1 >>\n5 0 obj\n<< /Type /ObjStm /N 1 >>\nstream\n\nendstream\n%%EOF\n";
221 expect(checkPdf(enc.encode(decoy))).toEqual({ ok: false, error: "pages_unknown" });
222 expect(checkPdf(enc.encode(decoy.replace("/ObjStm", "/Obj#53tm")))).toEqual({ ok: false, error: "pages_unknown" });
223 });
224
225 test("the page count is read from the plain page tree", () => {
226 expect(pageCount(pdf(3))).toBe(3);
227 expect(checkPdf(enc.encode("%PDF-1.5\n<< /Type /ObjStm >>\n%%EOF"))).toEqual({ ok: false, error: "pages_unknown" });
228 });
test/tui-devices.test.ts +27 −1
@@ -7,7 +7,8 @@ import { mkdtemp, rm } from "node:fs/promises";
77 import { tmpdir } from "node:os";
88 import { join } from "node:path";
99 import { createApp, type App } from "../src/app.ts";
10 import { listDevices } from "../src/core/devices.ts";
10 import { declareDevice, listDevices } from "../src/core/devices.ts";
11 import { grantedSlugs } from "../src/core/device_grants.ts";
1112 import { createUser, type User } from "../src/core/users.ts";
1213 import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts";
1314 import { devicesScreen } from "../src/tui/screens/devices.ts";
@@ -97,4 +98,29 @@ describe("the devices screen", () => {
9798 expect(listDevices(app.db)).toEqual([]);
9899 expect(kinds.includes("device.removed")).toBe(true);
99100 }));
101
102 test("a allows the device under the cursor to an app and r revokes it, with the page's events", () =>
103 (async () => {
104 const d = declareDevice(app.db, { name: "Den", kind: "printer", url: "ipp://192.168.1.162/ipp/print" });
105 if (!d.ok) throw new Error(d.error);
106 app.db.run("PRAGMA foreign_keys = OFF");
107 app.db.run("INSERT INTO app (id, repo_id, slug, access) VALUES ('t1', 'repo-t1', 'labels', 'private')");
108 app.db.run("PRAGMA foreign_keys = ON");
109 await withScreen(admin, async ({ text, feed }) => {
110 const mine = () => grantedSlugs(app.db, d.value.id);
111 await feed("a");
112 await feed("nope\r");
113 expect(text()).toContain("No such app");
114 expect(mine()).toEqual([]);
115 for (let n = 0; n < 4; n++) await feed("\x7f");
116 await feed("labels\r");
117 expect(mine()).toEqual(["labels"]);
118 expect(kinds.includes("device.granted")).toBe(true);
119 expect(text()).toContain("apps: labels");
120 await feed("r");
121 await feed("labels\r");
122 expect(mine()).toEqual([]);
123 expect(kinds.includes("device.revoked")).toBe(true);
124 });
125 })());
100126 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs