Public: anyone can read this; the crew can change it.
The owner declares the LAN printer and sees its model, state and supplies #10636
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/10567-the-owner-declares-the-lan-printer-and-s → main 5 commits fork 3 threads · 3 unresolved
17 files
changed +1011
−2
docs/design/attention-budget.json +2 −2
| @@ -109,12 +109,12 @@ | ||
| 109 | 109 | }, |
| 110 | 110 | "settings": { |
| 111 | 111 | "390": { |
| 112 | "words": 139, | |
| 112 | "words": 140, | |
| 113 | 113 | "blocks": 32, |
| 114 | 114 | "colours": 1 |
| 115 | 115 | }, |
| 116 | 116 | "1280": { |
| 117 | "words": 139, | |
| 117 | "words": 140, | |
| 118 | 118 | "blocks": 32, |
| 119 | 119 | "colours": 1 |
| 120 | 120 | } |
docs/design/audit.md +1
| @@ -145,6 +145,7 @@ Machine routes are not pages and have no entry: `/audit.csv`, | ||
| 145 | 145 | - `/settings/people` — `pages/people.ts` · people and their roles · `/settings/accounts`, `/settings/crew` · none seen. |
| 146 | 146 | - `/settings/crew` — `pages/crew.ts` · the named agents and what each may do · `/settings/model`, `/settings/telemetry` · none seen. |
| 147 | 147 | - `/settings/teams` — `pages/teams.ts` · the named crew teams, each with a charter, a dial and members · `/settings/crew` · none seen. |
| 148 | - `/settings/devices` — `settings/devices.ts` · LAN printers the owner declares, with model, state and supplies · `/settings/origins` (the same account sections) · none seen. | |
| 148 | 149 | - `/settings/origins` — `pages/origins.ts` (1,497 lines) · other forges this one reads and mirrors from · `/migrate` · none seen. |
| 149 | 150 | - `/settings/security` — `pages/security.ts` · the Shield and the evidence an admin needs to act with no AI · `/settings/traffic`, `/settings/backups` · none seen. |
| 150 | 151 | - `/settings/security/controls` — `pages/security_controls.ts` · one line per RED-TEAM control and when it was last checked · `/settings/security` · none seen. |
docs/features/devices.md added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | # Devices | |
| 2 | ||
| 3 | `/settings/devices` (forge owner only) declares a LAN printer: a name and an IPP URL such as `ipp://192.168.1.161:631/ipp/print`. | |
| 4 | ||
| 5 | - The address must be an IP literal on a private network (RFC 1918, fc00::/7, fe80::/10), scheme `ipp`, `ipps`, `http` or `https`, no credentials, query or fragment. A public address or a name is refused; names are never resolved, so a checked address cannot change underneath. | |
| 6 | - The page asks each printer with IPP Get-Printer-Attributes (`src/devices/ipp.ts`, no dependency) and keeps the answer 30 seconds. Model, state and low supplies are one line; supplies open on demand. A printer that does not answer shows "Not reachable right now." | |
| 7 | - `FORGE_DEVICES_ALLOW_LOOPBACK=1` admits 127.0.0.1 for a fake printer in tests; never set it in production. | |
| 8 | - Declaring and removing append `device.declared` / `device.removed` events. | |
| 9 | - 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. | |
| 10 | - 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. |
src/actions/devices.ts added +84
| @@ -0,0 +1,84 @@ | ||
| 1 | /** | |
| 2 | * Declaring, removing and reading devices, for the page and the terminal | |
| 3 | * alike: one gate (a forge admin), one set of refusals in words, and the same | |
| 4 | * `device.*` events, so a write from either door reaches viewers of the other. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { App } from "../app.ts"; | |
| 8 | import { | |
| 9 | declareDevice, | |
| 10 | isFresh, | |
| 11 | keepProbe, | |
| 12 | listDevices, | |
| 13 | removeDevice, | |
| 14 | type Device, | |
| 15 | type DeviceError, | |
| 16 | } from "../core/devices.ts"; | |
| 17 | import type { User } from "../core/users.ts"; | |
| 18 | import { probePrinter, type PrinterReport } from "../devices/ipp.ts"; | |
| 19 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 20 | ||
| 21 | export type DeviceActionError = DeviceError | "forbidden"; | |
| 22 | ||
| 23 | export const DEVICE_ERRORS: Record<DeviceActionError, string> = { | |
| 24 | invalid_name: "A name is letters, digits, spaces, dots, dashes or underscores, up to 60.", | |
| 25 | invalid_kind: "Only printers can be declared for now.", | |
| 26 | invalid_url: "That is not an IPP address. Try ipp://192.168.1.161:631/ipp/print.", | |
| 27 | 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.", | |
| 28 | name_taken: "A device already has that name.", | |
| 29 | not_found: "No such device.", | |
| 30 | forbidden: "Only a site admin manages devices.", | |
| 31 | }; | |
| 32 | ||
| 33 | export const mayManageDevices = (actor: User | undefined): actor is User => | |
| 34 | actor !== undefined && actor.isAdmin && actor.kind === "user"; | |
| 35 | ||
| 36 | export function declare( | |
| 37 | app: App, | |
| 38 | actor: User | undefined, | |
| 39 | input: { name: string; kind: string; url: string }, | |
| 40 | ): Result<Device, DeviceActionError> { | |
| 41 | if (!mayManageDevices(actor)) return err("forbidden"); | |
| 42 | const made = declareDevice(app.db, input); | |
| 43 | if (!made.ok) return made; | |
| 44 | app.bus.append({ | |
| 45 | kind: "device.declared", | |
| 46 | actorId: actor.id, | |
| 47 | payload: { name: made.value.name, kind: made.value.kind }, | |
| 48 | }); | |
| 49 | return ok(made.value); | |
| 50 | } | |
| 51 | ||
| 52 | export function remove(app: App, actor: User | undefined, id: number): Result<true, DeviceActionError> { | |
| 53 | if (!mayManageDevices(actor)) return err("forbidden"); | |
| 54 | if (!Number.isSafeInteger(id)) return err("not_found"); | |
| 55 | const gone = removeDevice(app.db, id); | |
| 56 | if (!gone.ok) return gone; | |
| 57 | app.bus.append({ kind: "device.removed", actorId: actor.id, payload: { id } }); | |
| 58 | return gone; | |
| 59 | } | |
| 60 | ||
| 61 | /** The devices, each stale printer asked at once within the probe's own bound; a fresh one is not asked. */ | |
| 62 | export async function devicesWithStatus(app: App): Promise<Device[]> { | |
| 63 | return Promise.all( | |
| 64 | listDevices(app.db).map(async (d) => { | |
| 65 | if (isFresh(d)) return d; | |
| 66 | const result = await probePrinter(d.url); | |
| 67 | const report = result.ok ? result.report : null; | |
| 68 | keepProbe(app.db, d.id, report); | |
| 69 | return { ...d, probe: report, probedAt: new Date().toISOString() }; | |
| 70 | }), | |
| 71 | ); | |
| 72 | } | |
| 73 | ||
| 74 | const STATE_WORDS = { idle: "Ready", printing: "Printing", stopped: "Stopped", unknown: "State unknown" } as const; | |
| 75 | ||
| 76 | /** What the printer said, as the one line a person needs. */ | |
| 77 | export function statusLine(report: PrinterReport | null): string { | |
| 78 | if (report === null) return "Not reachable right now."; | |
| 79 | const low = report.supplies.filter((s) => s.percent !== null && s.percent <= 10); | |
| 80 | const bits = [report.model, STATE_WORDS[report.state]]; | |
| 81 | if (low.length > 0) bits.push(`low: ${low.map((s) => s.name).join(", ")}`); | |
| 82 | else if (report.supplies.length > 0) bits.push("supplies fine"); | |
| 83 | return bits.filter((b) => b !== "").join(" · "); | |
| 84 | } |
src/core/devices.ts added +130
| @@ -0,0 +1,130 @@ | ||
| 1 | /** | |
| 2 | * Devices on the owner's network: the rows, and the one check that decides | |
| 3 | * whether a declared address may be asked at all. A device is a name, a kind | |
| 4 | * (only `printer` for now) and an IPP URL. The URL must name a private-network | |
| 5 | * IP *literal* (RFC 1918, fc00::/7, fe80::/10): a name would be resolved at | |
| 6 | * probe time, and what it resolves to could change after it was checked. So | |
| 7 | * the forge never reaches a public host, and never one it has to look up. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { Database } from "bun:sqlite"; | |
| 11 | import { isPrivateHost } from "../actions/migrate.ts"; | |
| 12 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 13 | import type { PrinterReport } from "../devices/ipp.ts"; | |
| 14 | ||
| 15 | export const DEVICE_KINDS = ["printer"] as const; | |
| 16 | export type DeviceKind = (typeof DEVICE_KINDS)[number]; | |
| 17 | ||
| 18 | export const NAME_MAX = 60; | |
| 19 | export const URL_MAX = 200; | |
| 20 | const NAME = /^[\p{L}\p{N}][\p{L}\p{N} ._-]*$/u; | |
| 21 | const PATH = /^\/[A-Za-z0-9._~\-/%]*$/; | |
| 22 | const SCHEMES: Record<string, string> = { "ipp:": "631", "ipps:": "631", "http:": "80", "https:": "443" }; | |
| 23 | ||
| 24 | export type DeviceError = | |
| 25 | | "invalid_name" | |
| 26 | | "invalid_kind" | |
| 27 | | "invalid_url" | |
| 28 | | "public_address" | |
| 29 | | "name_taken" | |
| 30 | | "not_found"; | |
| 31 | ||
| 32 | /** | |
| 33 | * Loopback is only for a fake printer in a test or a bench, switched on by | |
| 34 | * `FORGE_DEVICES_ALLOW_LOOPBACK=1`; production never sets it. | |
| 35 | */ | |
| 36 | const allowLoopback = (): boolean => process.env.FORGE_DEVICES_ALLOW_LOOPBACK === "1"; | |
| 37 | ||
| 38 | const isLoopbackLiteral = (h: string): boolean => h.startsWith("127.") || h === "[::1]"; | |
| 39 | ||
| 40 | /** Never a printer: the unspecified address, and IPv4 link-local 169.254.0.0/16 (cloud metadata lives there). */ | |
| 41 | const isNeverLiteral = (h: string): boolean => h === "0.0.0.0" || h === "[::]" || h.startsWith("169.254."); | |
| 42 | ||
| 43 | /** The URL as it will be stored, or why it is refused. */ | |
| 44 | export function validateDeviceUrl(raw: string): Result<string, DeviceError> { | |
| 45 | if (raw.length === 0 || raw.length > URL_MAX || /[\u0000-\u0020\u007f]/.test(raw)) return err("invalid_url"); | |
| 46 | const u = URL.parse(raw); | |
| 47 | if (u === null || !(u.protocol in SCHEMES)) return err("invalid_url"); | |
| 48 | if (u.username !== "" || u.password !== "" || u.search !== "" || u.hash !== "") return err("invalid_url"); | |
| 49 | if (u.port !== "" && !(Number(u.port) >= 1 && Number(u.port) <= 65535)) return err("invalid_url"); | |
| 50 | if (!PATH.test(u.pathname) || u.pathname.includes("..")) return err("invalid_url"); | |
| 51 | const host = u.hostname; // IPv6 keeps its brackets | |
| 52 | const literal = /^\d{1,3}(\.\d{1,3}){3}$/.test(host) || (host.startsWith("[") && host.endsWith("]")); | |
| 53 | if (!literal) return err("public_address"); // a name is never looked up | |
| 54 | if (isNeverLiteral(host)) return err("public_address"); | |
| 55 | if (isLoopbackLiteral(host)) return allowLoopback() ? ok(u.toString()) : err("public_address"); | |
| 56 | if (!isPrivateHost(host)) return err("public_address"); | |
| 57 | return ok(u.toString()); | |
| 58 | } | |
| 59 | ||
| 60 | export interface Device { | |
| 61 | readonly id: number; | |
| 62 | readonly name: string; | |
| 63 | readonly kind: DeviceKind; | |
| 64 | readonly url: string; | |
| 65 | readonly createdAt: string; | |
| 66 | readonly probe: PrinterReport | null; | |
| 67 | readonly probedAt: string | null; | |
| 68 | } | |
| 69 | ||
| 70 | interface Row { | |
| 71 | id: number; | |
| 72 | name: string; | |
| 73 | kind: string; | |
| 74 | url: string; | |
| 75 | created_at: string; | |
| 76 | probe_json: string | null; | |
| 77 | probed_at: string | null; | |
| 78 | } | |
| 79 | ||
| 80 | function fromRow(r: Row): Device { | |
| 81 | let probe: PrinterReport | null = null; | |
| 82 | try { | |
| 83 | probe = r.probe_json === null ? null : (JSON.parse(r.probe_json) as PrinterReport); | |
| 84 | } catch { | |
| 85 | probe = null; | |
| 86 | } | |
| 87 | return { id: r.id, name: r.name, kind: r.kind as DeviceKind, url: r.url, createdAt: r.created_at, probe, probedAt: r.probed_at }; | |
| 88 | } | |
| 89 | ||
| 90 | export function listDevices(db: Database): Device[] { | |
| 91 | return db.query<Row, []>("SELECT * FROM device ORDER BY name").all().map(fromRow); | |
| 92 | } | |
| 93 | ||
| 94 | export function getDevice(db: Database, id: number): Device | undefined { | |
| 95 | const r = db.query<Row, [number]>("SELECT * FROM device WHERE id = ?1").get(id); | |
| 96 | return r ? fromRow(r) : undefined; | |
| 97 | } | |
| 98 | ||
| 99 | export function declareDevice( | |
| 100 | db: Database, | |
| 101 | input: { name: string; kind: string; url: string }, | |
| 102 | ): Result<Device, DeviceError> { | |
| 103 | const name = input.name.trim().replace(/\s+/g, " "); | |
| 104 | if (name.length === 0 || name.length > NAME_MAX || !NAME.test(name)) return err("invalid_name"); | |
| 105 | if (!(DEVICE_KINDS as readonly string[]).includes(input.kind)) return err("invalid_kind"); | |
| 106 | const url = validateDeviceUrl(input.url.trim()); | |
| 107 | if (!url.ok) return url; | |
| 108 | const taken = db.query("SELECT 1 FROM device WHERE name = ?1").get(name); | |
| 109 | if (taken) return err("name_taken"); | |
| 110 | const id = Number(db.query("INSERT INTO device (name, kind, url) VALUES (?1, ?2, ?3)").run(name, input.kind, url.value).lastInsertRowid); | |
| 111 | return ok(getDevice(db, id)!); | |
| 112 | } | |
| 113 | ||
| 114 | export function removeDevice(db: Database, id: number): Result<true, DeviceError> { | |
| 115 | return db.query("DELETE FROM device WHERE id = ?1").run(id).changes === 0 ? err("not_found") : ok(true); | |
| 116 | } | |
| 117 | ||
| 118 | export function keepProbe(db: Database, id: number, report: PrinterReport | null, now = new Date()): void { | |
| 119 | db.query("UPDATE device SET probe_json = ?2, probed_at = ?3 WHERE id = ?1").run( | |
| 120 | id, | |
| 121 | report === null ? null : JSON.stringify(report), | |
| 122 | now.toISOString(), | |
| 123 | ); | |
| 124 | } | |
| 125 | ||
| 126 | /** How long an answer (or a silence) is trusted before the printer is asked again. */ | |
| 127 | export const PROBE_FRESH_MS = 30_000; | |
| 128 | ||
| 129 | export const isFresh = (d: Device, now = Date.now()): boolean => | |
| 130 | d.probedAt !== null && now - Date.parse(d.probedAt) < PROBE_FRESH_MS; |
src/core/event_audience.ts +2
| @@ -228,6 +228,8 @@ export function classifiedAudience(kind: string): EventAudience | undefined { | ||
| 228 | 228 | kind.startsWith("pinned.") || |
| 229 | 229 | // Which of a person's connected accounts a mirror syncs through is too. |
| 230 | 230 | kind.startsWith("origin.") || |
| 231 | // A declared LAN device names the owner's own network (#10567). | |
| 232 | kind.startsWith("device.") || | |
| 231 | 233 | kind === "repo.settings_updated" || |
| 232 | 234 | // Where a repository follows is its admins' to read, off or on: the |
| 233 | 235 | // address is often a private repository's. |
src/core/migration-pins/20261110110000-devices.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 5a984a28cdfb0792321cf7cc53874cc0269ae13afa563d1585f3b5f76dd5d267 |
src/core/migrations/20261110110000-devices.ts added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | // A device on the owner's network (a LAN printer), declared by a forge admin. | |
| 2 | // `probe_json`/`probed_at` keep the last answer so a page view inside the | |
| 3 | // freshness window asks the printer nothing. Additive. | |
| 4 | export default ` | |
| 5 | CREATE TABLE device ( | |
| 6 | id INTEGER PRIMARY KEY, | |
| 7 | name TEXT NOT NULL UNIQUE, | |
| 8 | kind TEXT NOT NULL, | |
| 9 | url TEXT NOT NULL, | |
| 10 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 11 | probe_json TEXT, | |
| 12 | probed_at TEXT | |
| 13 | ); | |
| 14 | `; |
src/devices/ipp.ts added +210
| @@ -0,0 +1,210 @@ | ||
| 1 | /** | |
| 2 | * IPP Get-Printer-Attributes: a small encoder and decoder, no dependency. | |
| 3 | * Wire format: RFC 8010 (encoding), operation 0x000B from RFC 8011 §4.2.5. | |
| 4 | * Plain bytes in, plain data out; `probePrinter` is the one function that | |
| 5 | * touches the network, to the one validated URL it is given. | |
| 6 | */ | |
| 7 | ||
| 8 | const OP_GET_PRINTER_ATTRIBUTES = 0x000b; // RFC 8011 §5.2 operation-id registry | |
| 9 | const TAG = { | |
| 10 | operation: 0x01, | |
| 11 | end: 0x03, | |
| 12 | integer: 0x21, | |
| 13 | boolean: 0x22, | |
| 14 | enum: 0x23, | |
| 15 | keyword: 0x44, | |
| 16 | uri: 0x45, | |
| 17 | charset: 0x47, | |
| 18 | language: 0x48, | |
| 19 | } as const; | |
| 20 | ||
| 21 | const WANTED = [ | |
| 22 | "printer-make-and-model", | |
| 23 | "printer-state", | |
| 24 | "printer-state-reasons", | |
| 25 | "printer-state-message", | |
| 26 | "marker-names", | |
| 27 | "marker-levels", | |
| 28 | "marker-types", | |
| 29 | "marker-colors", | |
| 30 | ]; | |
| 31 | ||
| 32 | const RESPONSE_MAX = 256 * 1024; | |
| 33 | ||
| 34 | export type IppValue = string | number | boolean | Uint8Array; | |
| 35 | export type IppAttributes = Map<string, IppValue[]>; | |
| 36 | ||
| 37 | const enc = new TextEncoder(); | |
| 38 | ||
| 39 | function attr(tag: number, name: string, value: string): number[] { | |
| 40 | const n = enc.encode(name); | |
| 41 | const v = enc.encode(value); | |
| 42 | return [tag, n.length >> 8, n.length & 255, ...n, v.length >> 8, v.length & 255, ...v]; | |
| 43 | } | |
| 44 | ||
| 45 | /** The request body for Get-Printer-Attributes on `printerUri`. */ | |
| 46 | export function encodeGetPrinterAttributes(printerUri: string, requestId = 1): Uint8Array { | |
| 47 | const out: number[] = [ | |
| 48 | 1, 1, // version 1.1 | |
| 49 | OP_GET_PRINTER_ATTRIBUTES >> 8, OP_GET_PRINTER_ATTRIBUTES & 255, | |
| 50 | (requestId >>> 24) & 255, (requestId >>> 16) & 255, (requestId >>> 8) & 255, requestId & 255, | |
| 51 | TAG.operation, | |
| 52 | ...attr(TAG.charset, "attributes-charset", "utf-8"), | |
| 53 | ...attr(TAG.language, "attributes-natural-language", "en"), | |
| 54 | ...attr(TAG.uri, "printer-uri", printerUri), | |
| 55 | ]; | |
| 56 | WANTED.forEach((k, i) => out.push(...attr(TAG.keyword, i === 0 ? "requested-attributes" : "", k))); | |
| 57 | out.push(TAG.end); | |
| 58 | return Uint8Array.from(out); | |
| 59 | } | |
| 60 | ||
| 61 | export interface IppResponse { | |
| 62 | readonly status: number; | |
| 63 | readonly attributes: IppAttributes; | |
| 64 | } | |
| 65 | ||
| 66 | /** Decode a response; `undefined` when the bytes are not well-formed IPP. */ | |
| 67 | export function decodeResponse(bytes: Uint8Array): IppResponse | undefined { | |
| 68 | if (bytes.length < 9) return undefined; | |
| 69 | const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); | |
| 70 | const status = view.getUint16(2); | |
| 71 | const attributes: IppAttributes = new Map(); | |
| 72 | const text = new TextDecoder("utf-8", { fatal: false }); | |
| 73 | let at = 8; | |
| 74 | let last = ""; | |
| 75 | while (at < bytes.length) { | |
| 76 | const tag = bytes[at++]!; | |
| 77 | if (tag === TAG.end) return { status, attributes }; | |
| 78 | if (tag <= 0x05) continue; // a group delimiter | |
| 79 | if (at + 2 > bytes.length) return undefined; | |
| 80 | const nameLength = view.getUint16(at); | |
| 81 | at += 2; | |
| 82 | if (at + nameLength + 2 > bytes.length) return undefined; | |
| 83 | const name = nameLength === 0 ? last : text.decode(bytes.subarray(at, at + nameLength)); | |
| 84 | at += nameLength; | |
| 85 | const length = view.getUint16(at); | |
| 86 | at += 2; | |
| 87 | if (at + length > bytes.length) return undefined; | |
| 88 | const raw = bytes.subarray(at, at + length); | |
| 89 | at += length; | |
| 90 | last = name; | |
| 91 | let value: IppValue; | |
| 92 | if ((tag === TAG.integer || tag === TAG.enum) && length === 4) value = view.getInt32(at - 4); | |
| 93 | else if (tag === TAG.boolean && length === 1) value = raw[0] !== 0; | |
| 94 | else if (tag >= 0x40 && tag <= 0x4f) value = text.decode(raw); | |
| 95 | else if (tag === 0x34 || tag === 0x37 || tag === 0x4a) continue; // collections: not read | |
| 96 | else value = Uint8Array.from(raw); | |
| 97 | const list = attributes.get(name); | |
| 98 | if (list) list.push(value); | |
| 99 | else attributes.set(name, [value]); | |
| 100 | } | |
| 101 | return undefined; // no end-of-attributes tag | |
| 102 | } | |
| 103 | ||
| 104 | export type PrinterState = "idle" | "printing" | "stopped" | "unknown"; | |
| 105 | ||
| 106 | export interface Supply { | |
| 107 | readonly name: string; | |
| 108 | /** Percent 0–100, or null when the printer does not know (IPP reports −1 to −3). */ | |
| 109 | readonly percent: number | null; | |
| 110 | readonly type: string; | |
| 111 | } | |
| 112 | ||
| 113 | export interface PrinterReport { | |
| 114 | readonly model: string; | |
| 115 | readonly state: PrinterState; | |
| 116 | readonly reasons: readonly string[]; | |
| 117 | readonly message: string; | |
| 118 | readonly supplies: readonly Supply[]; | |
| 119 | } | |
| 120 | ||
| 121 | const str = (a: IppAttributes, k: string): string[] => | |
| 122 | (a.get(k) ?? []).filter((v): v is string => typeof v === "string"); | |
| 123 | ||
| 124 | /** printer-state enum: 3 idle, 4 processing, 5 stopped (RFC 8011 §5.4.11). */ | |
| 125 | function stateOf(code: unknown): PrinterState { | |
| 126 | return code === 3 ? "idle" : code === 4 ? "printing" : code === 5 ? "stopped" : "unknown"; | |
| 127 | } | |
| 128 | ||
| 129 | /** Plain data from the decoded attributes. */ | |
| 130 | export function reportOf(attributes: IppAttributes): PrinterReport { | |
| 131 | const names = str(attributes, "marker-names"); | |
| 132 | const types = str(attributes, "marker-types"); | |
| 133 | const levels = (attributes.get("marker-levels") ?? []).filter((v): v is number => typeof v === "number"); | |
| 134 | return { | |
| 135 | model: str(attributes, "printer-make-and-model")[0] ?? "", | |
| 136 | state: stateOf(attributes.get("printer-state")?.[0]), | |
| 137 | reasons: str(attributes, "printer-state-reasons").filter((r) => r !== "none"), | |
| 138 | message: str(attributes, "printer-state-message")[0] ?? "", | |
| 139 | supplies: names.map((name, i) => { | |
| 140 | const level = levels[i]; | |
| 141 | return { | |
| 142 | name, | |
| 143 | type: types[i] ?? "", | |
| 144 | percent: level !== undefined && level >= 0 && level <= 100 ? level : null, | |
| 145 | }; | |
| 146 | }), | |
| 147 | }; | |
| 148 | } | |
| 149 | ||
| 150 | export type ProbeResult = | |
| 151 | | { readonly ok: true; readonly report: PrinterReport } | |
| 152 | | { readonly ok: false; readonly error: "unreachable" | "refused" | "bad_answer" }; | |
| 153 | ||
| 154 | /** | |
| 155 | * Ask the printer at `url` (already validated: `core/devices.ts`). The | |
| 156 | * transport is HTTP(S) to the same host, port and path; IPP's `ipp:` scheme | |
| 157 | * is `http:` on the wire. Redirects are errors, the answer is size-capped and | |
| 158 | * the wait bounded. | |
| 159 | */ | |
| 160 | export async function probePrinter(url: string, timeoutMs = 2500): Promise<ProbeResult> { | |
| 161 | const parsed = URL.parse(url); | |
| 162 | if (parsed === null) return { ok: false, error: "bad_answer" }; | |
| 163 | const secure = parsed.protocol === "ipps:" || parsed.protocol === "https:"; | |
| 164 | const ipp = parsed.protocol === "ipp:" || parsed.protocol === "ipps:"; | |
| 165 | // A non-special scheme (`ipp:`) cannot be switched in place: rebuild the string. | |
| 166 | const port = parsed.port === "" && ipp ? "631" : parsed.port; | |
| 167 | const wire = `${secure ? "https" : "http"}://${parsed.hostname}${port === "" ? "" : `:${port}`}${parsed.pathname}`; | |
| 168 | try { | |
| 169 | const res = await fetch(wire, { | |
| 170 | method: "POST", | |
| 171 | headers: { "content-type": "application/ipp" }, | |
| 172 | body: encodeGetPrinterAttributes(url), | |
| 173 | redirect: "error", | |
| 174 | signal: AbortSignal.timeout(timeoutMs), | |
| 175 | }); | |
| 176 | if (!res.ok) return { ok: false, error: "refused" }; | |
| 177 | const bytes = await readCapped(res); | |
| 178 | if (bytes === undefined) return { ok: false, error: "bad_answer" }; | |
| 179 | const decoded = decodeResponse(bytes); | |
| 180 | // 0x0000–0x00ff: successful (RFC 8011 §5.4.1). | |
| 181 | if (decoded === undefined || decoded.status > 0xff) return { ok: false, error: "bad_answer" }; | |
| 182 | return { ok: true, report: reportOf(decoded.attributes) }; | |
| 183 | } catch { | |
| 184 | return { ok: false, error: "unreachable" }; | |
| 185 | } | |
| 186 | } | |
| 187 | ||
| 188 | async function readCapped(res: Response): Promise<Uint8Array | undefined> { | |
| 189 | const reader = res.body?.getReader(); | |
| 190 | if (!reader) return undefined; | |
| 191 | const chunks: Uint8Array[] = []; | |
| 192 | let total = 0; | |
| 193 | for (;;) { | |
| 194 | const { done, value } = await reader.read(); | |
| 195 | if (done) break; | |
| 196 | total += value.length; | |
| 197 | if (total > RESPONSE_MAX) { | |
| 198 | await reader.cancel(); | |
| 199 | return undefined; | |
| 200 | } | |
| 201 | chunks.push(value); | |
| 202 | } | |
| 203 | const out = new Uint8Array(total); | |
| 204 | let at = 0; | |
| 205 | for (const c of chunks) { | |
| 206 | out.set(c, at); | |
| 207 | at += c.length; | |
| 208 | } | |
| 209 | return out; | |
| 210 | } |
src/http/settings_routes.ts +4
| @@ -38,6 +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, devicesPage, deviceRemove } from "../web/settings/devices.ts"; | |
| 41 | 42 | import { secretDelete, secretSet, secretsPage } from "../web/pages/secrets.ts"; |
| 42 | 43 | import { |
| 43 | 44 | backupsClear, |
| @@ -178,6 +179,9 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 178 | 179 | router.add("GET", "/settings/secrets", secretsPage); |
| 179 | 180 | router.add("POST", "/settings/secrets", secretSet); |
| 180 | 181 | router.add("POST", "/settings/secrets/delete", secretDelete); |
| 182 | router.add("GET", "/settings/devices", devicesPage); | |
| 183 | router.add("POST", "/settings/devices", deviceDeclare); | |
| 184 | router.add("POST", "/settings/devices/delete", deviceRemove); | |
| 181 | 185 | router.add("GET", "/settings/cloud", cloudPage); |
| 182 | 186 | router.add("POST", "/settings/cloud", cloudAdd); |
| 183 | 187 | router.add("POST", "/settings/cloud/verify", cloudVerify); |
src/tui/screens/devices.ts added +118
| @@ -0,0 +1,118 @@ | ||
| 1 | /** | |
| 2 | * Devices in the terminal, a forge admin's: the LAN printers Settings → | |
| 3 | * Devices lists, each with its model, state and supplies on one line. `d` | |
| 4 | * declares one (a name, then its address, in one line); `x` removes the one | |
| 5 | * 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 | |
| 7 | * admin (or is an agent) is offered no entry and changes nothing. | |
| 8 | */ | |
| 9 | ||
| 10 | import { DEVICE_ERRORS, declare, devicesWithStatus, mayManageDevices, remove, statusLine } from "../../actions/devices.ts"; | |
| 11 | import { listDevices, type Device } from "../../core/devices.ts"; | |
| 12 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 13 | import type { PaletteEntry } from "./entries.ts"; | |
| 14 | import { binding } from "../app/keys.ts"; | |
| 15 | import { style } from "../term/style.ts"; | |
| 16 | import { wrap } from "../term/width.ts"; | |
| 17 | import { fill, len, split } from "../ui/layout.ts"; | |
| 18 | import type { Session } from "../session.ts"; | |
| 19 | import { ListState, confirmScreen, drawList, promptScreen, type ListRow } from "./common.ts"; | |
| 20 | ||
| 21 | const mayUse = (f: Session): boolean => mayManageDevices(f.user); | |
| 22 | ||
| 23 | export function devicesScreen(f: Session, ctx: Ctx): Screen { | |
| 24 | const state = new ListState(); | |
| 25 | let rows: Device[] = []; | |
| 26 | let note = ""; | |
| 27 | const show = (devices: Device[]): void => { | |
| 28 | rows = mayUse(f) ? devices : []; | |
| 29 | state.clamp(rows.length); | |
| 30 | ctx.invalidate(); | |
| 31 | }; | |
| 32 | // Rows first from what is kept, then the printers are asked (a fresh answer is not asked again). | |
| 33 | const reload = (): void => { | |
| 34 | show(listDevices(f.db)); | |
| 35 | if (mayUse(f)) void devicesWithStatus(f.app).then(show, () => {}); | |
| 36 | }; | |
| 37 | reload(); | |
| 38 | ||
| 39 | const declareOne = (line: string): string | undefined => { | |
| 40 | const at = line.trim().search(/\s+\S+$/); | |
| 41 | const trimmed = line.trim(); | |
| 42 | const made = | |
| 43 | at < 0 | |
| 44 | ? ({ ok: false, error: "invalid_url" } as const) | |
| 45 | : declare(f.app, f.user, { name: trimmed.slice(0, at), kind: "printer", url: trimmed.slice(at).trim() }); | |
| 46 | note = made.ok ? `${made.value.name} is declared.` : DEVICE_ERRORS[made.error]; | |
| 47 | if (made.ok) reload(); | |
| 48 | return made.ok ? undefined : note; | |
| 49 | }; | |
| 50 | const add = (): Screen => | |
| 51 | promptScreen(ctx, screen, { | |
| 52 | title: "Declare a printer", | |
| 53 | label: "Name, then address", | |
| 54 | placeholder: "Hall printer ipp://192.168.1.161:631/ipp/print", | |
| 55 | onSubmit: declareOne, | |
| 56 | }); | |
| 57 | const confirmRemove = (d: Device): Screen => | |
| 58 | confirmScreen(ctx, screen, { | |
| 59 | title: "Remove this device", | |
| 60 | body: [d.name, "The forge stops asking it."], | |
| 61 | actions: [{ label: "Keep", variant: "ghost" }, { label: "Remove", variant: "danger" }], | |
| 62 | onChoose: (i) => { | |
| 63 | if (i !== 1) return; | |
| 64 | const gone = remove(f.app, f.user, d.id); | |
| 65 | note = gone.ok ? `${d.name} is removed.` : DEVICE_ERRORS[gone.error]; | |
| 66 | reload(); | |
| 67 | }, | |
| 68 | }); | |
| 69 | ||
| 70 | const lines = (): ListRow[] => | |
| 71 | rows.map((d) => ({ | |
| 72 | title: d.name, | |
| 73 | meta: d.probedAt === null ? d.url : `${statusLine(d.probe)} · ${d.url}`, | |
| 74 | ...(d.probe?.supplies.length | |
| 75 | ? { below: [d.probe.supplies.map((s) => `${s.name} ${s.percent === null ? "?" : `${s.percent}%`}`).join(" · ")] } | |
| 76 | : {}), | |
| 77 | })); | |
| 78 | ||
| 79 | const screen: Screen = { | |
| 80 | title: "Devices", | |
| 81 | keys: [binding(["d"], "declare"), binding(["x"], "remove"), binding(["j", "k"], "move cursor")], | |
| 82 | render(area, s) { | |
| 83 | const c = ctx.theme.c; | |
| 84 | const text = note !== "" ? note : mayUse(f) ? `${rows.length} declared.` : DEVICE_ERRORS.forbidden; | |
| 85 | const said = wrap(text, Math.max(1, area.w)).slice(0, 3); | |
| 86 | const [head, list] = split(area, "v", [len(said.length), fill(1)]); | |
| 87 | said.forEach((line, i) => s.text(head!.x, head!.y + i, line, style(c.mutedFg, c.bg), head!.x + head!.w)); | |
| 88 | drawList(s, list!, c, state, lines()); | |
| 89 | }, | |
| 90 | key(e) { | |
| 91 | if (state.key(e, rows.length)) return true; | |
| 92 | if (e.ctrl || e.alt || !mayUse(f)) return false; | |
| 93 | if (e.key === "d") { | |
| 94 | note = ""; | |
| 95 | ctx.push(add()); | |
| 96 | return true; | |
| 97 | } | |
| 98 | const d = rows[state.index]; | |
| 99 | if (e.key === "x" && d !== undefined) { | |
| 100 | note = ""; | |
| 101 | ctx.push(confirmRemove(d)); | |
| 102 | return true; | |
| 103 | } | |
| 104 | return false; | |
| 105 | }, | |
| 106 | }; | |
| 107 | return screen; | |
| 108 | } | |
| 109 | ||
| 110 | /** Offered to a forge admin who is not an agent. */ | |
| 111 | export const entry: PaletteEntry = { | |
| 112 | id: "devices", | |
| 113 | title: "Devices", | |
| 114 | hint: "LAN printers: model, state, supplies; declare or remove one", | |
| 115 | after: ["app-templates"], | |
| 116 | offered: (session) => mayUse(session), | |
| 117 | run: (session, ctx) => ctx.push(devicesScreen(session, ctx)), | |
| 118 | }; |
src/tui/screens/entries.ts +2
| @@ -14,6 +14,7 @@ import { entry as askData } from "./ask_data.ts"; | ||
| 14 | 14 | import { entry as archived } from "./archived.ts"; |
| 15 | 15 | import { entry as audit } from "./audit.ts"; |
| 16 | 16 | import { entry as appTemplates } from "./app_templates.ts"; |
| 17 | import { entry as devices } from "./devices.ts"; | |
| 17 | 18 | import { entry as bets } from "./bets.ts"; |
| 18 | 19 | import { entry as boards } from "./boards.ts"; |
| 19 | 20 | import { entry as bugs } from "./bugs.ts"; |
| @@ -117,6 +118,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 117 | 118 | limits, |
| 118 | 119 | economics, |
| 119 | 120 | appTemplates, |
| 121 | devices, | |
| 120 | 122 | flowsAll, |
| 121 | 123 | scrapes, |
| 122 | 124 | trafficScreenEntry, |
src/web/pages/origins.ts +2
| @@ -108,6 +108,7 @@ export type AccountSection = | ||
| 108 | 108 | | "backups" |
| 109 | 109 | | "traffic" |
| 110 | 110 | | "secrets" |
| 111 | | "devices" | |
| 111 | 112 | | "capacity" |
| 112 | 113 | | "telemetry" |
| 113 | 114 | | "access" |
| @@ -153,6 +154,7 @@ const SECTION_ROWS: readonly (readonly [ | ||
| 153 | 154 | ["Connections", "Origins", "/settings/origins", "globe", (c) => c === "origins", false], |
| 154 | 155 | ["Connections", "Cloud accounts", "/settings/cloud", "key", (c) => c === "cloud", true], |
| 155 | 156 | ["Connections", "Secrets", "/settings/secrets", "lock", (c) => c === "secrets", true], |
| 157 | ["Connections", "Devices", "/settings/devices", "globe", (c) => c === "devices", true], | |
| 156 | 158 | ["Security", "Security", "/settings/security", "lock", (c) => c === "security", true], |
| 157 | 159 | ["Security", "Traffic", "/settings/traffic", "users", (c) => c === "traffic", true], |
| 158 | 160 | ["Security", "Trusted sources", "/settings/trusted", "lock", (c) => c === "trusted", true], |
src/web/settings/devices.ts added +115
| @@ -0,0 +1,115 @@ | ||
| 1 | /** | |
| 2 | * Devices, for the forge's owner: declare a LAN printer by name and IPP URL, | |
| 3 | * and see its model, state and supplies. A fine printer is one line (P9 | |
| 4 | * Calm); a printer that cannot be reached is one plain line, never an error | |
| 5 | * page. The address was validated when it was declared (`core/devices.ts`); | |
| 6 | * the probe (`devices/ipp.ts`) is bounded and its answer is kept 30 s. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Ctx } from "../../http/context.ts"; | |
| 10 | import { DEVICE_ERRORS, declare, devicesWithStatus, remove, statusLine } from "../../actions/devices.ts"; | |
| 11 | import { NAME_MAX, URL_MAX, type Device } from "../../core/devices.ts"; | |
| 12 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | |
| 13 | import type { PrinterReport } from "../../devices/ipp.ts"; | |
| 14 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; | |
| 15 | import { field, readForm } from "../form.ts"; | |
| 16 | import { html, when, type Html } from "../html.ts"; | |
| 17 | import { flashTo, page } from "../layout.ts"; | |
| 18 | import { btn, row } from "../ui.ts"; | |
| 19 | import { accountSections } from "../pages/origins.ts"; | |
| 20 | ||
| 21 | function guard(ctx: Ctx): Response | undefined { | |
| 22 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 23 | if (!ctx.auth.user.isAdmin) return notFoundPage(ctx); | |
| 24 | return undefined; | |
| 25 | } | |
| 26 | ||
| 27 | function supplies(report: PrinterReport): Html | null { | |
| 28 | if (report.supplies.length === 0) return null; | |
| 29 | // A fine printer stays one line; the detail opens on demand. | |
| 30 | return html`<details class="small"> | |
| 31 | <summary>Supplies</summary> | |
| 32 | <ul class="list"> | |
| 33 | ${report.supplies.map( | |
| 34 | (s) => html`<li>${s.name}: ${s.percent === null ? "level unknown" : html`<span class="mono">${String(s.percent)}%</span>`}</li>`, | |
| 35 | )} | |
| 36 | </ul> | |
| 37 | </details>`; | |
| 38 | } | |
| 39 | ||
| 40 | function card(d: Device): Html { | |
| 41 | return html`<li> | |
| 42 | ${row({ | |
| 43 | title: html`${d.name} <span class="muted small">printer</span>`, | |
| 44 | meta: html`${statusLine(d.probe)} · <span class="mono">${d.url}</span>`, | |
| 45 | trail: html`<form class="row-actions" method="post" action="/settings/devices/delete"> | |
| 46 | <input type="hidden" name="id" value="${String(d.id)}" /> | |
| 47 | ${btn("Remove", { size: "sm", icon: "x", label: `Remove ${d.name}` })} | |
| 48 | </form>`, | |
| 49 | })} | |
| 50 | ${d.probe ? supplies(d.probe) : null} | |
| 51 | </li>`; | |
| 52 | } | |
| 53 | ||
| 54 | // GET /settings/devices | |
| 55 | export async function devicesPage(ctx: Ctx): Promise<Response> { | |
| 56 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 57 | const denied = guard(ctx); | |
| 58 | if (denied) return denied; | |
| 59 | const devices = await devicesWithStatus(ctx.app); | |
| 60 | const rendering = performance.now(); | |
| 61 | const response = page({ | |
| 62 | ctx, | |
| 63 | title: "Devices", | |
| 64 | body: html`<div class="page-head"><h1>Devices</h1></div> | |
| 65 | ${settingsLayout( | |
| 66 | accountSections("devices", { admin: true }), | |
| 67 | html`<section class="settings-block"> | |
| 68 | <p class="muted">Printers on your network, as the forge sees them; only addresses like 192.168.1.161 are accepted.</p> | |
| 69 | ${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>`)} | |
| 71 | <form class="edit-row mt" method="post" action="/settings/devices"> | |
| 72 | <input type="hidden" name="kind" value="printer" /> | |
| 73 | <input class="input" type="text" name="name" placeholder="Name" aria-label="Device name" maxlength="${String(NAME_MAX)}" required /> | |
| 74 | <input class="input mono" type="text" name="url" placeholder="ipp://192.168.1.161:631/ipp/print" aria-label="IPP address" maxlength="${String(URL_MAX)}" autocomplete="off" spellcheck="false" required /> | |
| 75 | ${btn("Declare", { variant: "primary", size: "sm", icon: "plus" })} | |
| 76 | </form> | |
| 77 | </section>`, | |
| 78 | "Account settings", | |
| 79 | )}`, | |
| 80 | }); | |
| 81 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 82 | return response; | |
| 83 | } | |
| 84 | ||
| 85 | // POST /settings/devices | |
| 86 | export async function deviceDeclare(ctx: Ctx): Promise<Response> { | |
| 87 | const denied = guard(ctx); | |
| 88 | if (denied) return denied; | |
| 89 | const form = await readForm(ctx, "admin"); | |
| 90 | if (form instanceof Response) return form; | |
| 91 | const made = declare(ctx.app, ctx.auth.user, { | |
| 92 | name: field(form, "name"), | |
| 93 | kind: field(form, "kind"), | |
| 94 | url: field(form, "url"), | |
| 95 | }); | |
| 96 | return flashTo( | |
| 97 | ctx, | |
| 98 | "/settings/devices", | |
| 99 | made.ok ? { message: `${made.value.name} is declared.` } : { tone: "danger", message: DEVICE_ERRORS[made.error] }, | |
| 100 | ); | |
| 101 | } | |
| 102 | ||
| 103 | // POST /settings/devices/delete | |
| 104 | export async function deviceRemove(ctx: Ctx): Promise<Response> { | |
| 105 | const denied = guard(ctx); | |
outdated
Principle 1/8: this paragraph and the 'Assumptions' paragraph below explain the page twice; the h2 repeats the nav label. One sentence, plain words (docs/WRITING.md). |
||
| 106 | if (denied) return denied; | |
| 107 | const form = await readForm(ctx, "admin"); | |
| 108 | if (form instanceof Response) return form; | |
| 109 | const gone = remove(ctx.app, ctx.auth.user, Number(field(form, "id"))); | |
| 110 | return flashTo( | |
| 111 | ctx, | |
| 112 | "/settings/devices", | |
| 113 | gone.ok ? { message: "Removed." } : { tone: "danger", message: DEVICE_ERRORS[gone.error] }, | |
| 114 | ); | |
| 115 | } | |
outdated
Principle 1: 'Assumptions: IPP 1.1 over plain HTTP(S)...' is implementation detail that belongs in docs/features/devices.md, not on the page. |
test/devices.test.ts added +215
| @@ -0,0 +1,215 @@ | ||
| 1 | /** A declared LAN printer: validated at the boundary, probed over IPP, shown in one line. */ | |
| 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 { declareDevice, listDevices, validateDeviceUrl } from "../src/core/devices.ts"; | |
| 8 | import { decodeResponse, encodeGetPrinterAttributes, probePrinter } from "../src/devices/ipp.ts"; | |
| 9 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 10 | import { createSession } from "../src/core/sessions.ts"; | |
| 11 | import { createUser } from "../src/core/users.ts"; | |
| 12 | import { adminCookie } from "./helpers/admin.ts"; | |
| 13 | ||
| 14 | const enc = new TextEncoder(); | |
| 15 | const attr = (tag: number, name: string, value: string | number) => { | |
| 16 | const n = enc.encode(name); | |
| 17 | const v = typeof value === "number" ? [value >>> 24, (value >>> 16) & 255, (value >>> 8) & 255, value & 255] : [...enc.encode(value)]; | |
| 18 | return [tag, n.length >> 8, n.length & 255, ...n, v.length >> 8, v.length & 255, ...v]; | |
| 19 | }; | |
| 20 | ||
| 21 | /** A response as a Brother would send it: model, idle (3), two toners. */ | |
| 22 | function answer(): Uint8Array { | |
| 23 | return Uint8Array.from([ | |
| 24 | 1, 1, 0, 0, 0, 0, 0, 1, | |
| 25 | 0x01, | |
| 26 | ...attr(0x47, "attributes-charset", "utf-8"), | |
| 27 | 0x04, | |
| 28 | ...attr(0x41, "printer-make-and-model", "Brother HL-L2350DW series"), | |
| 29 | ...attr(0x23, "printer-state", 3), | |
| 30 | ...attr(0x44, "printer-state-reasons", "none"), | |
| 31 | ...attr(0x42, "marker-names", "Black Toner"), | |
| 32 | ...attr(0x42, "", "Drum"), | |
| 33 | ...attr(0x21, "marker-levels", 80), | |
| 34 | ...attr(0x21, "", 5), | |
| 35 | 0x03, | |
| 36 | ]); | |
| 37 | } | |
| 38 | ||
| 39 | let seen: Uint8Array | undefined; | |
| 40 | let asked = 0; | |
| 41 | const printer = Bun.serve({ | |
| 42 | port: 0, | |
| 43 | hostname: "127.0.0.1", | |
| 44 | async fetch(req) { | |
| 45 | asked++; | |
| 46 | seen = new Uint8Array(await req.arrayBuffer()); | |
| 47 | expect(req.headers.get("content-type")).toBe("application/ipp"); | |
| 48 | return new Response(answer(), { headers: { "content-type": "application/ipp" } }); | |
| 49 | }, | |
| 50 | }); | |
| 51 | const printerUrl = `ipp://127.0.0.1:${printer.port}/ipp/print`; | |
| 52 | ||
| 53 | let dataDir = ""; | |
| 54 | let app: App, servers: Servers, cookie = "", plain = ""; | |
| 55 | const priorLoopback = process.env.FORGE_DEVICES_ALLOW_LOOPBACK; | |
| 56 | beforeAll(async () => { | |
| 57 | process.env.FORGE_DEVICES_ALLOW_LOOPBACK = "1"; | |
| 58 | dataDir = await mkdtemp(join(tmpdir(), "forge-devices-")); | |
| 59 | app = createApp({ dataDir, port: 0 }); | |
| 60 | servers = await serve(app); | |
| 61 | cookie = await adminCookie(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" }); | |
| 62 | const bob = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "correct-horse-battery" }); | |
| 63 | if (!bob.ok) throw new Error(bob.error); | |
| 64 | plain = `forge_session=${createSession(app.db, bob.value.id).token}`; | |
| 65 | }); | |
| 66 | afterAll(async () => { | |
| 67 | if (priorLoopback === undefined) delete process.env.FORGE_DEVICES_ALLOW_LOOPBACK; | |
| 68 | else process.env.FORGE_DEVICES_ALLOW_LOOPBACK = priorLoopback; | |
| 69 | printer.stop(true); | |
| 70 | servers.stop(); | |
| 71 | app.close(); | |
| 72 | await rm(dataDir, { recursive: true, force: true }); | |
| 73 | }); | |
| 74 | ||
| 75 | test("the request is a well-formed Get-Printer-Attributes", () => { | |
| 76 | const b = encodeGetPrinterAttributes("ipp://192.168.1.161:631/ipp/print"); | |
| 77 | expect([...b.slice(0, 4)]).toEqual([1, 1, 0, 0x0b]); | |
| 78 | expect(b.at(-1)).toBe(0x03); | |
| 79 | expect(new TextDecoder().decode(b)).toContain("printer-make-and-model"); | |
| 80 | }); | |
| 81 | ||
| 82 | test("a probe reads model, state and supplies", async () => { | |
| 83 | const r = await probePrinter(printerUrl); | |
| 84 | expect(r.ok).toBe(true); | |
| 85 | if (!r.ok) return; | |
| 86 | expect(r.report.model).toBe("Brother HL-L2350DW series"); | |
| 87 | expect(r.report.state).toBe("idle"); | |
| 88 | expect(r.report.reasons).toEqual([]); | |
| 89 | expect(r.report.supplies.map((s) => [s.name, s.percent])).toEqual([["Black Toner", 80], ["Drum", 5]]); | |
| 90 | expect(new TextDecoder().decode(seen)).toContain("printer-uri"); | |
| 91 | }); | |
| 92 | ||
| 93 | test("garbage and silence are plain failures, not throws", async () => { | |
| 94 | expect(decodeResponse(Uint8Array.from([1, 1, 0, 0, 0, 0, 0, 1, 0x04, 0x41, 0xff]))).toBeUndefined(); | |
| 95 | const dead = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("nope") }); | |
| 96 | const url = `ipp://127.0.0.1:${dead.port}/ipp/print`; | |
| 97 | const bad = await probePrinter(url); | |
| 98 | dead.stop(true); | |
| 99 | expect(bad).toEqual({ ok: false, error: "bad_answer" }); | |
| 100 | expect(await probePrinter(url, 500)).toEqual({ ok: false, error: "unreachable" }); | |
| 101 | }); | |
| 102 | ||
| 103 | test("only private-network IP literals are accepted", () => { | |
| 104 | for (const good of ["ipp://192.168.1.161:631/ipp/print", "ipp://10.0.0.5/ipp/print", "http://172.16.3.4:631/ipp/print", "ipp://[fd00::5]/ipp/print"]) | |
| 105 | expect(validateDeviceUrl(good).ok).toBe(true); | |
| 106 | for (const bad of [ | |
| 107 | "ipp://8.8.8.8:631/ipp/print", | |
| 108 | "ipp://printer.example.com/ipp/print", | |
| 109 | "ipp://printer.local/ipp/print", | |
| 110 | "ipp://[email protected]/ipp/print", | |
| 111 | "ipp://[::ffff:8.8.8.8]/ipp/print", | |
| 112 | "ftp://192.168.1.161/x", | |
| 113 | "ipp://192.168.1.161:99999/ipp/print", | |
| 114 | "ipp://192.168.1.161/ipp/print?x=1", | |
| 115 | "ipp://user:[email protected]/ipp/print", | |
| 116 | "", | |
| 117 | ]) | |
| 118 | expect(validateDeviceUrl(bad).ok).toBe(false); | |
| 119 | expect(validateDeviceUrl("ipp://8.8.8.8/ipp/print")).toEqual({ ok: false, error: "public_address" }); | |
| 120 | const taken = declareDevice(app.db, { name: "Dup", kind: "printer", url: printerUrl }); | |
| 121 | expect(taken.ok).toBe(true); | |
| 122 | expect(declareDevice(app.db, { name: "Dup", kind: "printer", url: printerUrl })).toEqual({ ok: false, error: "name_taken" }); | |
| 123 | expect(declareDevice(app.db, { name: "X", kind: "toaster", url: printerUrl })).toEqual({ ok: false, error: "invalid_kind" }); | |
| 124 | }); | |
| 125 | ||
| 126 | function post(path: string, body: Record<string, string>, who: string | undefined, origin = servers.url) { | |
| 127 | return fetch(`${servers.url}${path}`, { | |
| 128 | method: "POST", | |
| 129 | headers: { "content-type": "application/x-www-form-urlencoded", origin, ...(who ? { cookie: who } : {}) }, | |
| 130 | body: new URLSearchParams(body), | |
| 131 | redirect: "manual", | |
| 132 | }); | |
| 133 | } | |
| 134 | ||
| 135 | test("the owner declares a printer and the page shows it in one line", async () => { | |
| 136 | const made = await post("/settings/devices", { name: "Hall printer", kind: "printer", url: printerUrl }, cookie); | |
| 137 | expect(made.status).toBe(303); | |
| 138 | const res = await fetch(`${servers.url}/settings/devices`, { headers: { cookie } }); | |
| 139 | const text = await res.text(); | |
| 140 | expect(res.status).toBe(200); | |
| 141 | expect(text).toContain("Hall printer"); | |
| 142 | expect(text).toContain("Brother HL-L2350DW series · Ready · low: Drum"); | |
| 143 | expect(text).toContain("Black Toner"); | |
| 144 | }); | |
| 145 | ||
| 146 | test("an unreachable printer is one plain line", async () => { | |
| 147 | const dead = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("x") }); | |
| 148 | const url = `ipp://127.0.0.1:${dead.port}/ipp/print`; | |
| 149 | dead.stop(true); | |
| 150 | declareDevice(app.db, { name: "Attic", kind: "printer", url }); | |
| 151 | const text = await (await fetch(`${servers.url}/settings/devices`, { headers: { cookie } })).text(); | |
| 152 | expect(text).toContain("Attic"); | |
| 153 | expect(text).toContain("Not reachable right now."); | |
| 154 | }); | |
| 155 | ||
| 156 | test("a public address is refused at the form, and nothing is stored", async () => { | |
| 157 | const before = listDevices(app.db).length; | |
| 158 | const res = await post("/settings/devices", { name: "Cloud", kind: "printer", url: "ipp://8.8.8.8/ipp/print" }, cookie); | |
| 159 | expect(res.status).toBe(303); | |
| 160 | expect(listDevices(app.db).length).toBe(before); | |
| 161 | }); | |
| 162 | ||
| 163 | test("only the owner, and only from this origin", async () => { | |
| 164 | const before = listDevices(app.db).length; | |
| 165 | const anon = await post("/settings/devices", { name: "Z", kind: "printer", url: printerUrl }, undefined); | |
| 166 | expect([401, 403, 404, 303]).toContain(anon.status); | |
| 167 | expect(anon.headers.get("location") ?? "").not.toBe("/settings/devices"); | |
| 168 | const cross = await post("/settings/devices", { name: "Z", kind: "printer", url: printerUrl }, cookie, "https://evil.example"); | |
| 169 | expect(cross.status).toBe(403); | |
| 170 | expect(listDevices(app.db).length).toBe(before); | |
| 171 | expect(await (await fetch(`${servers.url}/settings/devices`)).text()).not.toContain("Printers on your network"); | |
| 172 | }); | |
| 173 | ||
| 174 | test("removing a device removes it", async () => { | |
| 175 | const d = listDevices(app.db).find((x) => x.name === "Attic")!; | |
| 176 | await post("/settings/devices/delete", { id: String(d.id) }, cookie); | |
| 177 | expect(listDevices(app.db).some((x) => x.name === "Attic")).toBe(false); | |
| 178 | }); | |
| 179 | ||
| 180 | test("a signed-in non-admin sees nothing and can neither declare nor remove", async () => { | |
| 181 | const keep = declareDevice(app.db, { name: "Keep", kind: "printer", url: printerUrl }); | |
| 182 | if (!keep.ok) throw new Error(keep.error); | |
| 183 | const before = listDevices(app.db).length; | |
| 184 | const page = await fetch(`${servers.url}/settings/devices`, { headers: { cookie: plain } }); | |
| 185 | expect(page.status).toBe(404); | |
| 186 | expect(await page.text()).not.toContain("Keep"); | |
| 187 | await post("/settings/devices", { name: "Mine", kind: "printer", url: printerUrl }, plain); | |
| 188 | await post("/settings/devices/delete", { id: String(keep.value.id) }, plain); | |
| 189 | expect(listDevices(app.db).length).toBe(before); | |
| 190 | expect(listDevices(app.db).some((x) => x.name === "Mine")).toBe(false); | |
| 191 | }); | |
| 192 | ||
| 193 | test("removing refuses another origin", async () => { | |
| 194 | const d = listDevices(app.db).find((x) => x.name === "Keep")!; | |
| 195 | const res = await post("/settings/devices/delete", { id: String(d.id) }, cookie, "https://evil.example"); | |
| 196 | expect(res.status).toBe(403); | |
| 197 | expect(listDevices(app.db).some((x) => x.name === "Keep")).toBe(true); | |
| 198 | }); | |
| 199 | ||
| 200 | test("a second view inside 30 seconds asks the printer nothing", async () => { | |
| 201 | const fresh = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => (asked++, new Response(answer(), { headers: { "content-type": "application/ipp" } })) }); | |
| 202 | declareDevice(app.db, { name: "Fresh", kind: "printer", url: `ipp://127.0.0.1:${fresh.port}/ipp/print` }); | |
| 203 | const view = () => fetch(`${servers.url}/settings/devices`, { headers: { cookie } }); | |
| 204 | await view(); // asks every stale printer once | |
| 205 | const afterFirst = asked; | |
| 206 | const text = await (await view()).text(); | |
| 207 | fresh.stop(true); | |
| 208 | expect(asked).toBe(afterFirst); | |
| 209 | expect(text).toContain("Fresh"); | |
| 210 | }); | |
| 211 | ||
| 212 | test("link-local (cloud metadata) and the unspecified address are never accepted", () => { | |
| 213 | for (const bad of ["ipp://169.254.169.254/ipp/print", "http://169.254.1.1/x", "ipp://0.0.0.0/ipp/print"]) | |
| 214 | expect(validateDeviceUrl(bad)).toEqual({ ok: false, error: "public_address" }); | |
| 215 | }); |
test/palette-roster.test.ts +1
| @@ -143,6 +143,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 143 | 143 | ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"], |
| 144 | 144 | ["subscriptions", "Crew subscriptions", "each account's budget and rest; wake one, set its budget, add or remove one"], |
| 145 | 145 | ["app-templates", "App templates", "the repositories New app offers; mark or unmark one"], |
| 146 | ["devices", "Devices", "LAN printers: model, state, supplies; declare or remove one"], | |
| 146 | 147 | ["edge-only-accounts", "Edge-only accounts", "who would be locked out if the platform edge sign-in went off"], |
| 147 | 148 | ["traffic-sources", "Traffic sources", "where people come from and where they go, for the forge"], |
| 148 | 149 | ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"], |
test/tui-devices.test.ts added +100
| @@ -0,0 +1,100 @@ | ||
| 1 | /** | |
| 2 | * Devices in the terminal: only an admin is offered the screen; declaring and | |
| 3 | * removing there go through the page's actions and append the same events. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { listDevices } from "../src/core/devices.ts"; | |
| 11 | import { createUser, type User } from "../src/core/users.ts"; | |
| 12 | import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts"; | |
| 13 | import { devicesScreen } from "../src/tui/screens/devices.ts"; | |
| 14 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | import { settleApp } from "./helpers/tui.ts"; | |
| 17 | ||
| 18 | quietLogs(); | |
| 19 | setDefaultTimeout(30_000); | |
| 20 | ||
| 21 | let dir: string; | |
| 22 | let app: App; | |
| 23 | let admin: User; | |
| 24 | let taker: User; | |
| 25 | const kinds: string[] = []; | |
| 26 | const identity = (user?: User) => ({ sshUsername: user?.username ?? "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user }); | |
| 27 | ||
| 28 | beforeAll(async () => { | |
| 29 | dir = await mkdtemp(join(tmpdir(), "forge-tui-devices-")); | |
| 30 | app = createApp({ dataDir: dir, url: "https://forge.test", port: 0 }); | |
| 31 | const mk = async (username: string, adminIfFirst = false) => { | |
| 32 | const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@x.test`, password: "a-good-passphrase", adminIfFirst }); | |
| 33 | if (!made.ok) throw new Error(made.error); | |
| 34 | return made.value; | |
| 35 | }; | |
| 36 | admin = await mk("giver", true); | |
| 37 | taker = await mk("taker"); | |
| 38 | app.bus.subscribe((e) => void kinds.push(e.kind)); | |
| 39 | }); | |
| 40 | afterAll(async () => { | |
| 41 | app.db.close(); | |
| 42 | await rm(dir, { recursive: true, force: true }); | |
| 43 | }); | |
| 44 | ||
| 45 | async function withScreen(user: User, body: (h: { text: () => string; feed: (s: string) => Promise<void> }) => Promise<void>) { | |
| 46 | const terminal = headlessTerminal(120, 30); | |
| 47 | const { app: tui, session } = createForgeApp(app, identity(user), { terminal, exitHooks: false }); | |
| 48 | tui.start(); | |
| 49 | tui.ctx.push(devicesScreen(session, tui.ctx)); | |
| 50 | try { | |
| 51 | await settleApp(tui); | |
| 52 | await body({ | |
| 53 | text: () => tui.screen.toText(), | |
| 54 | feed: async (s) => { | |
| 55 | terminal.feed(s); | |
| 56 | await settleApp(tui); | |
| 57 | }, | |
| 58 | }); | |
| 59 | } finally { | |
| 60 | tui.stop(); | |
| 61 | } | |
| 62 | } | |
| 63 | ||
| 64 | describe("the devices screen", () => { | |
| 65 | test("only an admin is offered it", () => { | |
| 66 | const ids = (u?: User) => forgeCommands(new Session(app, identity(u)))({} as never).map((c) => c.id); | |
| 67 | expect(ids(admin)).toContain("devices"); | |
| 68 | expect(ids(taker)).not.toContain("devices"); | |
| 69 | expect(ids({ ...admin, kind: "agent" })).not.toContain("devices"); | |
| 70 | expect(ids()).not.toContain("devices"); | |
| 71 | }); | |
| 72 | ||
| 73 | test("a non-admin who reaches it sees the refusal and declares nothing", () => | |
| 74 | withScreen(taker, async ({ text, feed }) => { | |
| 75 | expect(text()).toContain("Only a site admin manages devices."); | |
| 76 | await feed("d"); | |
| 77 | await feed("Hall ipp://192.168.1.161/ipp/print\r"); | |
| 78 | expect(listDevices(app.db)).toEqual([]); | |
| 79 | })); | |
| 80 | ||
| 81 | test("d declares with the page's refusals and events; x asks first then removes", () => | |
| 82 | withScreen(admin, async ({ text, feed }) => { | |
| 83 | await feed("d"); | |
| 84 | await feed("Cloud ipp://8.8.8.8/ipp/print\r"); | |
| 85 | expect(text()).toContain("The address must be a number on your own network"); | |
| 86 | expect(listDevices(app.db)).toEqual([]); | |
| 87 | for (let i = 0; i < "Cloud ipp://8.8.8.8/ipp/print".length; i++) await feed("\x7f"); | |
| 88 | await feed("Hall printer ipp://192.168.1.161/ipp/print\r"); | |
| 89 | expect(listDevices(app.db).map((d) => d.name)).toEqual(["Hall printer"]); | |
| 90 | expect(kinds.includes("device.declared")).toBe(true); | |
| 91 | await feed("x"); | |
| 92 | await feed("\x1b"); // esc keeps it | |
| 93 | await Bun.sleep(100); | |
| 94 | expect(listDevices(app.db).length).toBe(1); | |
| 95 | await feed("x"); | |
| 96 | await feed("\x1b[C\r"); | |
| 97 | expect(listDevices(app.db)).toEqual([]); | |
| 98 | expect(kinds.includes("device.removed")).toBe(true); | |
| 99 | })); | |
| 100 | }); |
Both doors: add a src/tui screen for devices (admin-gated, same core functions, same events) and its entry per AGENTS.md.