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

The owner declares the LAN printer and sees its model, state and supplies #10636

Merged opened this

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

Merged

torque/forge:torque/10567-the-owner-declares-the-lan-printer-and-s → main 5 commits fork 3 threads · 3 unresolved

  • Merged. torque/10567-the-owner-declares-the-lan-printer-and-s was merged into main as dd6ab4d.

Review

Sign in to review this change.
17 files changed +1011 −2
docs/design/attention-budget.json +2 −2
@@ -109,12 +109,12 @@
109109 },
110110 "settings": {
111111 "390": {
112 "words": 139,
112 "words": 140,
113113 "blocks": 32,
114114 "colours": 1
115115 },
116116 "1280": {
117 "words": 139,
117 "words": 140,
118118 "blocks": 32,
119119 "colours": 1
120120 }
docs/design/audit.md +1
@@ -145,6 +145,7 @@ Machine routes are not pages and have no entry: `/audit.csv`,
145145 - `/settings/people` — `pages/people.ts` · people and their roles · `/settings/accounts`, `/settings/crew` · none seen.
146146 - `/settings/crew` — `pages/crew.ts` · the named agents and what each may do · `/settings/model`, `/settings/telemetry` · none seen.
147147 - `/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.
148149 - `/settings/origins` — `pages/origins.ts` (1,497 lines) · other forges this one reads and mirrors from · `/migrate` · none seen.
149150 - `/settings/security` — `pages/security.ts` · the Shield and the evidence an admin needs to act with no AI · `/settings/traffic`, `/settings/backups` · none seen.
150151 - `/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 {
228228 kind.startsWith("pinned.") ||
229229 // Which of a person's connected accounts a mirror syncs through is too.
230230 kind.startsWith("origin.") ||
231 // A declared LAN device names the owner's own network (#10567).
232 kind.startsWith("device.") ||
231233 kind === "repo.settings_updated" ||
232234 // Where a repository follows is its admins' to read, off or on: the
233235 // 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";
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";
4142 import { secretDelete, secretSet, secretsPage } from "../web/pages/secrets.ts";
4243 import {
4344 backupsClear,
@@ -178,6 +179,9 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
178179 router.add("GET", "/settings/secrets", secretsPage);
179180 router.add("POST", "/settings/secrets", secretSet);
180181 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);
181185 router.add("GET", "/settings/cloud", cloudPage);
182186 router.add("POST", "/settings/cloud", cloudAdd);
183187 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";
1414 import { entry as archived } from "./archived.ts";
1515 import { entry as audit } from "./audit.ts";
1616 import { entry as appTemplates } from "./app_templates.ts";
17 import { entry as devices } from "./devices.ts";
1718 import { entry as bets } from "./bets.ts";
1819 import { entry as boards } from "./boards.ts";
1920 import { entry as bugs } from "./bugs.ts";
@@ -117,6 +118,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
117118 limits,
118119 economics,
119120 appTemplates,
121 devices,
120122 flowsAll,
121123 scrapes,
122124 trafficScreenEntry,
src/web/pages/origins.ts +2
@@ -108,6 +108,7 @@ export type AccountSection =
108108 | "backups"
109109 | "traffic"
110110 | "secrets"
111 | "devices"
111112 | "capacity"
112113 | "telemetry"
113114 | "access"
@@ -153,6 +154,7 @@ const SECTION_ROWS: readonly (readonly [
153154 ["Connections", "Origins", "/settings/origins", "globe", (c) => c === "origins", false],
154155 ["Connections", "Cloud accounts", "/settings/cloud", "key", (c) => c === "cloud", true],
155156 ["Connections", "Secrets", "/settings/secrets", "lock", (c) => c === "secrets", true],
157 ["Connections", "Devices", "/settings/devices", "globe", (c) => c === "devices", true],
156158 ["Security", "Security", "/settings/security", "lock", (c) => c === "security", true],
157159 ["Security", "Traffic", "/settings/traffic", "users", (c) => c === "traffic", true],
158160 ["Security", "Trusted sources", "/settings/trusted", "lock", (c) => c === "trusted", true],
src/web/settings/devices.ts added +115
@@ -0,0 +1,115 @@
1 /**

Both doors: add a src/tui screen for devices (admin-gated, same core functions, same events) and its entry per AGENTS.md.

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", () => {
143143 ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"],
144144 ["subscriptions", "Crew subscriptions", "each account's budget and rest; wake one, set its budget, add or remove one"],
145145 ["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"],
146147 ["edge-only-accounts", "Edge-only accounts", "who would be locked out if the platform edge sign-in went off"],
147148 ["traffic-sources", "Traffic sources", "where people come from and where they go, for the forge"],
148149 ["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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs