From 2a91cd59d1d68c13d25f2c328ebd6a1d1bf9cdd1 Mon Sep 17 00:00:00 2001
From: Willem
Date: Tue, 6 Oct 2026 18:02:10 +0200
Subject: [PATCH 1/5] feat(web): per-device Solenta Web tokens stored hashed
(#1512 I2)
Named device tokens in web-access.json (0600), shown once, sha256 on
disk, constant-time authorize over every row. The single web-token is
adopted as Legacy device. Adds qrcode-generator for the pairing QR.
Co-Authored-By: Claude Opus 5.5
---
electron/test/webDevices.test.js | 88 +++++++++++++
electron/webDevices.js | 206 +++++++++++++++++++++++++++++++
package-lock.json | 7 ++
package.json | 1 +
4 files changed, 302 insertions(+)
create mode 100644 electron/test/webDevices.test.js
create mode 100644 electron/webDevices.js
diff --git a/electron/test/webDevices.test.js b/electron/test/webDevices.test.js
new file mode 100644
index 000000000..b0df23130
--- /dev/null
+++ b/electron/test/webDevices.test.js
@@ -0,0 +1,88 @@
+"use strict";
+
+/** Solenta Web per-device tokens (#1512 I2): hashed store, legacy adoption. */
+
+const { describe, it, beforeEach, afterEach } = require("node:test");
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+const { createWebDevices, hashToken, FILE_NAME, LEGACY_NAME } = require("../webDevices.js");
+
+describe("webDevices", () => {
+ let tmp;
+ beforeEach(() => {
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "solenta-web-devices-"));
+ });
+ afterEach(() => fs.rmSync(tmp, { recursive: true, force: true }));
+
+ it("add shows the token once and stores only its sha256 at 0600", () => {
+ const devices = createWebDevices(tmp);
+ const { device, token } = devices.add({ name: "Phone" });
+ assert.equal(device.name, "Phone");
+ assert.ok(token.length >= 40);
+ const raw = fs.readFileSync(path.join(tmp, FILE_NAME), "utf8");
+ assert.ok(!raw.includes(token), "raw token must never reach disk");
+ assert.ok(raw.includes(hashToken(token)));
+ if (process.platform !== "win32") {
+ assert.equal(fs.statSync(path.join(tmp, FILE_NAME)).mode & 0o777, 0o600);
+ }
+ assert.ok(!("token" in devices.list()[0]) && !("tokenHash" in devices.list()[0]));
+ });
+
+ it("authorize matches the right device and stamps lastSeenAt", () => {
+ let t = 1000;
+ const devices = createWebDevices(tmp, { now: () => t });
+ const a = devices.add({ name: "A" });
+ const b = devices.add({ name: "B" });
+ t = 5000;
+ assert.deepEqual(devices.authorize(b.token), { id: b.device.id, name: "B" });
+ assert.equal(devices.authorize("wrong"), null);
+ assert.equal(devices.authorize(""), null);
+ assert.equal(devices.authorize(undefined), null);
+ const seen = Object.fromEntries(devices.list().map((d) => [d.name, d.lastSeenAt]));
+ assert.deepEqual(seen, { A: null, B: 5000 });
+ assert.ok(devices.authorize(a.token));
+ });
+
+ it("revoked tokens stop authorizing and the change survives a reload", () => {
+ const devices = createWebDevices(tmp);
+ const { device, token } = devices.add({ name: "Tablet" });
+ devices.revoke(device.id);
+ assert.equal(devices.authorize(token), null);
+ assert.equal(createWebDevices(tmp).authorize(token), null);
+ assert.throws(() => devices.revoke(device.id), /Unknown device/);
+ });
+
+ it("validates names", () => {
+ const devices = createWebDevices(tmp);
+ assert.throws(() => devices.add({ name: " " }), /Name the device/);
+ assert.throws(() => devices.add({ name: "x".repeat(61) }), /at most 60/);
+ });
+
+ it("adopts the single web-token as Legacy device, once", () => {
+ fs.writeFileSync(path.join(tmp, "web-token"), "old-token\n");
+ const devices = createWebDevices(tmp);
+ assert.deepEqual(
+ devices.list().map((d) => [d.name, d.legacy]),
+ [[LEGACY_NAME, true]],
+ );
+ assert.ok(devices.authorize("old-token"));
+ assert.equal(createWebDevices(tmp).list().length, 1, "no duplicate on reload");
+ });
+
+ it("revoking Legacy device deletes the web-token file", () => {
+ fs.writeFileSync(path.join(tmp, "web-token"), "old-token");
+ const devices = createWebDevices(tmp);
+ devices.revoke(devices.list()[0].id);
+ assert.ok(!fs.existsSync(path.join(tmp, "web-token")));
+ assert.equal(createWebDevices(tmp).list().length, 0);
+ });
+
+ it("persists the server toggle, off and loopback-only by default", () => {
+ const devices = createWebDevices(tmp);
+ assert.deepEqual(devices.server(), { enabled: false, lan: false });
+ devices.setServer({ enabled: true });
+ assert.deepEqual(createWebDevices(tmp).server(), { enabled: true, lan: false });
+ });
+});
diff --git a/electron/webDevices.js b/electron/webDevices.js
new file mode 100644
index 000000000..28f7e388f
--- /dev/null
+++ b/electron/webDevices.js
@@ -0,0 +1,206 @@
+"use strict";
+
+/**
+ * Solenta Web device tokens (#1512 I2, #245).
+ *
+ * One named token per device. The raw token is shown once at mint; disk
+ * stores only the sha256, in /web-access.json at mode 0600 (same
+ * treatment as pairings.json). The same file remembers whether the user
+ * turned the web server on, and whether it may listen beyond loopback.
+ *
+ * The pre-device single token (/web-token) is adopted as
+ * "Legacy device". That file stays while the row is active: --serve-web
+ * prints it and Remote Connections reads it over SSH. Revoking the legacy
+ * row deletes the file.
+ */
+
+const crypto = require("node:crypto");
+const fs = require("node:fs");
+const path = require("node:path");
+
+const FILE_NAME = "web-access.json";
+const LEGACY_TOKEN_FILE = "web-token";
+const LEGACY_NAME = "Legacy device";
+const NAME_MAX = 60;
+const MAX_DEVICES = 30;
+// lastSeenAt only needs minute precision; skip the disk write otherwise.
+const SEEN_WRITE_MS = 60 * 1000;
+
+function hashToken(token) {
+ return crypto.createHash("sha256").update(String(token), "utf8").digest("hex");
+}
+
+function emptyState() {
+ return { server: { enabled: false, lan: false }, devices: [] };
+}
+
+function load(file) {
+ let parsed;
+ try {
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
+ } catch (err) {
+ if (err && err.code === "ENOENT") return emptyState();
+ throw new Error(`web access file at ${file} is not valid JSON`);
+ }
+ const server = parsed && typeof parsed.server === "object" && parsed.server ? parsed.server : {};
+ const rows = Array.isArray(parsed && parsed.devices) ? parsed.devices : [];
+ return {
+ server: { enabled: server.enabled === true, lan: server.lan === true },
+ devices: rows.filter(
+ (d) => d && typeof d.id === "string" && typeof d.tokenHash === "string",
+ ),
+ };
+}
+
+function toPublic(row) {
+ return {
+ id: row.id,
+ name: String(row.name || "Device"),
+ createdAt: Number.isFinite(row.createdAt) ? row.createdAt : 0,
+ lastSeenAt: Number.isFinite(row.lastSeenAt) ? row.lastSeenAt : null,
+ legacy: row.legacy === true,
+ };
+}
+
+/**
+ * @param {string} userDataPath
+ * @param {{ now?: () => number }} [opts]
+ */
+function createWebDevices(userDataPath, opts = {}) {
+ if (!userDataPath) throw new Error("userDataPath is required for web devices");
+ const now = opts.now || Date.now;
+ const file = path.join(userDataPath, FILE_NAME);
+ const legacyFile = path.join(userDataPath, LEGACY_TOKEN_FILE);
+ const state = load(file);
+
+ function save() {
+ fs.mkdirSync(userDataPath, { recursive: true });
+ fs.writeFileSync(file, JSON.stringify(state, null, 2), { mode: 0o600 });
+ try {
+ fs.chmodSync(file, 0o600);
+ } catch {
+ // mode is best-effort on some filesystems
+ }
+ }
+
+ /** Adopt a single plaintext token as the Legacy device row. Idempotent. */
+ function adoptLegacy(token) {
+ const raw = String(token || "").trim();
+ if (!raw) return null;
+ const hash = hashToken(raw);
+ const existing = state.devices.find((d) => d.tokenHash === hash);
+ if (existing) return toPublic(existing);
+ const row = {
+ id: crypto.randomUUID(),
+ name: LEGACY_NAME,
+ tokenHash: hash,
+ createdAt: now(),
+ lastSeenAt: null,
+ legacy: true,
+ };
+ state.devices.push(row);
+ save();
+ return toPublic(row);
+ }
+
+ try {
+ adoptLegacy(fs.readFileSync(legacyFile, "utf8"));
+ } catch (err) {
+ if (!err || err.code !== "ENOENT") throw err;
+ }
+
+ return {
+ list() {
+ return state.devices.map(toPublic);
+ },
+
+ server() {
+ return { ...state.server };
+ },
+
+ /** @param {{ enabled?: boolean, lan?: boolean }} patch */
+ setServer(patch) {
+ if (typeof patch.enabled === "boolean") state.server.enabled = patch.enabled;
+ if (typeof patch.lan === "boolean") state.server.lan = patch.lan;
+ save();
+ return { ...state.server };
+ },
+
+ adoptLegacy,
+
+ /** @returns {{ device: ReturnType, token: string }} */
+ add(input) {
+ const name = String((input && input.name) || "").trim();
+ if (!name) throw new Error("Name the device first.");
+ if (name.length > NAME_MAX) {
+ throw new Error(`Device names are at most ${NAME_MAX} characters.`);
+ }
+ if (state.devices.length >= MAX_DEVICES) {
+ throw new Error(`At most ${MAX_DEVICES} devices. Revoke one first.`);
+ }
+ const token = crypto.randomBytes(32).toString("base64url");
+ const row = {
+ id: crypto.randomUUID(),
+ name,
+ tokenHash: hashToken(token),
+ createdAt: now(),
+ lastSeenAt: null,
+ };
+ state.devices.push(row);
+ save();
+ return { device: toPublic(row), token };
+ },
+
+ /** Delete the row. Callers must also drop that device's live sockets. */
+ revoke(id) {
+ const i = state.devices.findIndex((d) => d.id === id);
+ if (i < 0) throw new Error(`Unknown device: ${id}`);
+ const [row] = state.devices.splice(i, 1);
+ save();
+ if (row.legacy) {
+ try {
+ fs.unlinkSync(legacyFile);
+ } catch {
+ // already gone
+ }
+ }
+ return toPublic(row);
+ },
+
+ /**
+ * Constant-time match of a presented token against every stored hash.
+ * Compares fixed-length sha256 digests and never exits early, so the
+ * timing says nothing about which row (if any) matched.
+ *
+ * @returns {{ id: string, name: string } | null}
+ */
+ authorize(token) {
+ if (typeof token !== "string" || !token) return null;
+ const presented = Buffer.from(hashToken(token), "hex");
+ let found = null;
+ for (const row of state.devices) {
+ const stored = Buffer.from(row.tokenHash, "hex");
+ if (stored.length !== presented.length) continue;
+ if (crypto.timingSafeEqual(stored, presented) && !found) found = row;
+ }
+ if (!found) return null;
+ const t = now();
+ if (!found.lastSeenAt || t - found.lastSeenAt >= SEEN_WRITE_MS) {
+ found.lastSeenAt = t;
+ try {
+ save();
+ } catch {
+ // a persist miss must not reject a valid device
+ }
+ }
+ return { id: found.id, name: found.name };
+ },
+ };
+}
+
+module.exports = {
+ FILE_NAME,
+ LEGACY_NAME,
+ createWebDevices,
+ hashToken,
+};
diff --git a/package-lock.json b/package-lock.json
index 69eab7821..140dbed68 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
"dompurify": "^3.4.16",
"highlight.js": "^11.12.0",
"mermaid": "^12.1.0",
+ "qrcode-generator": "2.0.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
@@ -5131,6 +5132,12 @@
"node": ">=6"
}
},
+ "node_modules/qrcode-generator": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz",
+ "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==",
+ "license": "MIT"
+ },
"node_modules/quick-lru": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
diff --git a/package.json b/package.json
index 873f504a0..91d2c65db 100644
--- a/package.json
+++ b/package.json
@@ -33,6 +33,7 @@
"dompurify": "^3.4.16",
"highlight.js": "^11.12.0",
"mermaid": "^12.1.0",
+ "qrcode-generator": "2.0.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
From 1c50496c65c8a52118d9e92e1072f47e1a432e0c Mon Sep 17 00:00:00 2001
From: Willem
Date: Tue, 6 Oct 2026 18:06:53 +0200
Subject: [PATCH 2/5] feat(web): runtime web access with device auth, revoke
and rate limit (#1512 I2)
webBridge/webServer take an authorize(token) function; each authed socket
remembers its device so revoke terminates it at once. Failed auth is
rate-limited per address (10/min) across the socket and artifact routes.
webAccess.js owns start/stop (loopback unless network access is on),
listen URLs, and a detect-only Tailscale Serve helper.
Co-Authored-By: Claude Opus 5.5
---
electron/test/webAccess.test.js | 305 ++++++++++++++++++++++++++++++++
electron/webAccess.js | 293 ++++++++++++++++++++++++++++++
electron/webBridge.js | 100 +++++++++--
electron/webServer.js | 60 ++++---
4 files changed, 723 insertions(+), 35 deletions(-)
create mode 100644 electron/test/webAccess.test.js
create mode 100644 electron/webAccess.js
diff --git a/electron/test/webAccess.test.js b/electron/test/webAccess.test.js
new file mode 100644
index 000000000..31a45b26c
--- /dev/null
+++ b/electron/test/webAccess.test.js
@@ -0,0 +1,305 @@
+"use strict";
+
+/**
+ * Solenta Web access (#1512 I2) security properties, against a real server:
+ * loopback unless asked, revoke mid-session, failed-auth rate limit,
+ * constant-time token match, and the Tailscale Serve helper (fake CLI).
+ */
+
+const { describe, it, beforeEach, afterEach } = require("node:test");
+const assert = require("node:assert/strict");
+const crypto = require("node:crypto");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+const http = require("node:http");
+const Module = require("node:module");
+const { WebSocket } = require("ws");
+
+// webBridge → ipc.js requires("electron") at load.
+{
+ const origLoad = Module._load;
+ Module._load = function (request) {
+ if (request === "electron") {
+ return {
+ ipcMain: { handle() {} },
+ BrowserWindow: { getAllWindows: () => [] },
+ dialog: {},
+ shell: {},
+ app: { getPath: () => os.tmpdir() },
+ };
+ }
+ return origLoad.apply(this, arguments);
+ };
+}
+
+const { startWebServer } = require("../webServer.js");
+const { WS_PATH, CLOSE_RATE_LIMITED, createAuthLimiter } = require("../webBridge.js");
+const { createWebDevices } = require("../webDevices.js");
+const { createWebAccess, listenUrls } = require("../webAccess.js");
+
+const IFACES = {
+ lo0: [{ address: "127.0.0.1", family: "IPv4", internal: true }],
+ en0: [
+ { address: "192.168.1.20", family: "IPv4", internal: false },
+ { address: "fe80::1", family: "IPv6", internal: false },
+ ],
+};
+
+function socket(port) {
+ return new Promise((resolve, reject) => {
+ const ws = new WebSocket(`ws://127.0.0.1:${port}${WS_PATH}`);
+ ws.once("open", () => resolve(ws));
+ ws.once("error", reject);
+ });
+}
+
+/** Auth and resolve with "ok" or the close code. */
+function auth(port, token) {
+ return socket(port).then(
+ (ws) =>
+ new Promise((resolve) => {
+ ws.once("message", (data) => {
+ if (JSON.parse(String(data)).kind === "auth-ok") resolve({ ws, result: "ok" });
+ });
+ ws.once("close", (code) => resolve({ ws, result: code }));
+ ws.send(JSON.stringify({ kind: "auth", token }));
+ }),
+ );
+}
+
+function closed(ws) {
+ return new Promise((resolve) => {
+ if (ws.readyState === WebSocket.CLOSED) resolve();
+ else ws.once("close", () => resolve());
+ });
+}
+
+function get(url) {
+ return new Promise((resolve, reject) => {
+ http.get(url, (res) => {
+ res.resume();
+ res.on("end", () => resolve(res.statusCode));
+ }).on("error", reject);
+ });
+}
+
+describe("webAccess", () => {
+ let tmp;
+ let devices;
+ let access;
+ let calls;
+ let tsState;
+
+ beforeEach(() => {
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "solenta-web-access-"));
+ const artifact = path.join(tmp, "a.txt");
+ fs.writeFileSync(artifact, "hi");
+ devices = createWebDevices(tmp);
+ calls = [];
+ tsState = { installed: true, backend: "Running", serve: {} };
+ access = createWebAccess({
+ devices,
+ defaultPort: 0,
+ networkInterfaces: () => IFACES,
+ startServer: (opts) =>
+ startWebServer({
+ ...opts,
+ ctx: {},
+ handlers: { "echo:ping": async () => "pong" },
+ artifactStore: {
+ open: async () => ({ info: { mimeType: "text/plain" }, size: 2, path: artifact }),
+ },
+ }),
+ exec: async (bin, args) => {
+ calls.push([bin, ...args]);
+ if (!tsState.installed) throw Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" });
+ if (args[0] === "version") return { stdout: "1.0", stderr: "" };
+ if (args[0] === "status") {
+ return {
+ stdout: JSON.stringify({
+ BackendState: tsState.backend,
+ Self: { DNSName: "mac.tail1.ts.net." },
+ }),
+ stderr: "",
+ };
+ }
+ if (args[0] === "serve" && args[1] === "status") {
+ return { stdout: JSON.stringify(tsState.serve), stderr: "" };
+ }
+ if (args[0] === "serve" && args.includes("off")) {
+ tsState.serve = {};
+ return { stdout: "", stderr: "" };
+ }
+ if (args[0] === "serve") {
+ tsState.serve = {
+ Web: { "mac.tail1.ts.net:443": { Handlers: { "/": { Proxy: args.at(-1) } } } },
+ };
+ return { stdout: "", stderr: "" };
+ }
+ throw new Error(`unexpected ${args.join(" ")}`);
+ },
+ });
+ });
+
+ afterEach(async () => {
+ await access.stop();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ });
+
+ it("binds loopback unless the user turns on network access", async () => {
+ let st = await access.setEnabled({ enabled: true });
+ assert.equal(st.running, true);
+ assert.equal(st.lan, false);
+ assert.deepEqual(st.urls.map((u) => u.kind), ["local"]);
+ assert.match(st.urls[0].url, /^http:\/\/127\.0\.0\.1:\d+$/);
+
+ st = await access.setEnabled({ enabled: true, lan: true });
+ assert.equal(st.lan, true);
+ assert.deepEqual(
+ st.urls.map((u) => u.url.replace(/:\d+$/, "")),
+ ["http://127.0.0.1", "http://192.168.1.20"],
+ );
+ assert.deepEqual(devices.server(), { enabled: true, lan: true });
+
+ st = await access.setEnabled({ enabled: false });
+ assert.equal(st.running, false);
+ assert.deepEqual(st.urls, []);
+ assert.deepEqual(devices.server(), { enabled: false, lan: true });
+ });
+
+ it("restore() starts on loopback only when the switch was left on", async () => {
+ await access.restore();
+ assert.equal(access.isRunning(), false);
+ devices.setServer({ enabled: true });
+ await access.restore();
+ const st = await access.status();
+ assert.deepEqual(st.urls.map((u) => u.kind), ["local"]);
+ });
+
+ it("listenUrls never offers a LAN address for a loopback bind", () => {
+ assert.deepEqual(listenUrls("127.0.0.1", 1, IFACES), [
+ { kind: "local", url: "http://127.0.0.1:1" },
+ ]);
+ assert.deepEqual(listenUrls("10.0.0.4", 1, IFACES), [
+ { kind: "lan", url: "http://10.0.0.4:1" },
+ ]);
+ });
+
+ it("revoke disconnects that device's live sockets, and only those", async () => {
+ const st = await access.setEnabled({ enabled: true });
+ const port = Number(st.urls[0].url.split(":").at(-1));
+ const phone = await access.addDevice({ name: "Phone" });
+ const laptop = await access.addDevice({ name: "Laptop" });
+ const a = await auth(port, phone.token);
+ const b = await auth(port, laptop.token);
+ assert.equal(a.result, "ok");
+ assert.equal(b.result, "ok");
+ const artifactUrl = (token) =>
+ `http://127.0.0.1:${port}/api/run-artifacts/t1/a1?token=${encodeURIComponent(token)}`;
+ assert.equal(await get(artifactUrl(phone.token)), 200);
+
+ await access.revokeDevice({ id: phone.device.id });
+ await closed(a.ws);
+ assert.equal(b.ws.readyState, WebSocket.OPEN, "other devices stay connected");
+
+ const again = await auth(port, phone.token);
+ assert.notEqual(again.result, "ok", "revoked token cannot re-auth");
+ assert.equal(await get(artifactUrl(phone.token)), 401, "revoked token loses artifact links");
+ assert.equal(await get(artifactUrl(laptop.token)), 200);
+ b.ws.close();
+ });
+
+ it("rate-limits failed auth per address, even a later correct token", async () => {
+ const st = await access.setEnabled({ enabled: true });
+ const port = Number(st.urls[0].url.split(":").at(-1));
+ const { token } = await access.addDevice({ name: "Phone" });
+ for (let i = 0; i < 10; i++) {
+ const r = await auth(port, `wrong-${i}`);
+ assert.notEqual(r.result, "ok");
+ assert.notEqual(r.result, CLOSE_RATE_LIMITED);
+ }
+ const blocked = await auth(port, token);
+ assert.equal(blocked.result, CLOSE_RATE_LIMITED);
+ assert.equal(
+ await get(`http://127.0.0.1:${port}/api/run-artifacts/t/a?token=${token}`),
+ 429,
+ "the artifact route shares the limiter",
+ );
+ });
+
+ it("the limiter forgets failures after its window", () => {
+ let t = 0;
+ const limiter = createAuthLimiter({ maxFailures: 2, windowMs: 1000, now: () => t });
+ limiter.fail("ip");
+ limiter.fail("ip");
+ assert.equal(limiter.blocked("ip"), true);
+ assert.equal(limiter.blocked("other"), false);
+ t = 1001;
+ assert.equal(limiter.blocked("ip"), false);
+ });
+
+ it("token match is constant-time: one timingSafeEqual per device, hit or miss", () => {
+ const tokens = [1, 2, 3].map((i) => devices.add({ name: `D${i}` }).token);
+ const orig = crypto.timingSafeEqual;
+ let n = 0;
+ crypto.timingSafeEqual = (a, b) => {
+ n++;
+ assert.equal(a.length, 32, "compares fixed-length digests, never raw tokens");
+ return orig(a, b);
+ };
+ try {
+ for (const presented of [tokens[0], tokens[2], "miss", "x".repeat(5000)]) {
+ n = 0;
+ devices.authorize(presented);
+ assert.equal(n, 3, `scan does not stop early for ${presented.slice(0, 6)}`);
+ }
+ } finally {
+ crypto.timingSafeEqual = orig;
+ }
+ });
+
+ it("tailscale: reports not installed without trying to install", async () => {
+ tsState.installed = false;
+ const st = await access.status();
+ assert.equal(st.tailscale.installed, false);
+ assert.ok(calls.every((c) => c[1] === "version"), "only probes, never installs");
+ });
+
+ it("tailscale: needs a logged-in daemon and a running server", async () => {
+ tsState.backend = "NeedsLogin";
+ assert.equal((await access.status()).tailscale.loggedIn, false);
+ await assert.rejects(access.setTailscale({ on: true }), /not running or not logged in/);
+ tsState.backend = "Running";
+ await assert.rejects(access.setTailscale({ on: true }), /Turn on Solenta Web first/);
+ });
+
+ it("tailscale: serves loopback over HTTPS, reports the URL, and stops", async () => {
+ const st = await access.setEnabled({ enabled: true });
+ const port = st.port;
+ const on = await access.setTailscale({ on: true });
+ assert.deepEqual(
+ calls.find((c) => c.includes("--bg")).slice(1),
+ ["serve", "--bg", "--yes", "--https=443", `http://127.0.0.1:${port}`],
+ );
+ assert.equal(on.serving, true);
+ assert.equal(on.url, "https://mac.tail1.ts.net");
+ assert.equal((await access.status()).lan, false, "Serve never needs the LAN bind");
+
+ const off = await access.setTailscale({ on: false });
+ assert.equal(off.serving, false);
+ });
+
+ it("tailscale: refuses to replace another HTTPS root, and turning Web off stops Serve", async () => {
+ await access.setEnabled({ enabled: true });
+ tsState.serve = {
+ Web: { "mac.tail1.ts.net:443": { Handlers: { "/": { Proxy: "http://127.0.0.1:3000" } } } },
+ };
+ await assert.rejects(access.setTailscale({ on: true }), /already sends HTTPS to http:\/\/127\.0\.0\.1:3000/);
+
+ tsState.serve = {};
+ await access.setTailscale({ on: true });
+ await access.setEnabled({ enabled: false });
+ assert.deepEqual(tsState.serve, {}, "no tailnet URL left pointing at a closed port");
+ });
+});
diff --git a/electron/webAccess.js b/electron/webAccess.js
new file mode 100644
index 000000000..b3158ef00
--- /dev/null
+++ b/electron/webAccess.js
@@ -0,0 +1,293 @@
+"use strict";
+
+/**
+ * Solenta Web at runtime (#1512 I2): the Settings switch, device tokens,
+ * and the optional Tailscale Serve helper. main.js owns one instance; the
+ * web:* IPC handlers (desktop only) call into it.
+ *
+ * Binding: loopback unless the user turns on "Allow devices on my network"
+ * (or passes --serve-host). Tailscale Serve proxies to loopback, so it never
+ * needs the LAN bind.
+ */
+
+const os = require("node:os");
+const { execFile } = require("node:child_process");
+
+const LOOPBACK = "127.0.0.1";
+const ANY = "0.0.0.0";
+const TAILSCALE_BINS = ["tailscale", "/Applications/Tailscale.app/Contents/MacOS/Tailscale"];
+const TAILSCALE_TIMEOUT_MS = 20_000;
+
+function defaultExec(file, args, opts) {
+ return new Promise((resolve, reject) => {
+ execFile(file, args, { timeout: TAILSCALE_TIMEOUT_MS, ...opts }, (err, stdout, stderr) => {
+ if (err) {
+ err.stdout = stdout;
+ err.stderr = stderr;
+ reject(err);
+ } else {
+ resolve({ stdout: String(stdout), stderr: String(stderr) });
+ }
+ });
+ });
+}
+
+function isLoopback(host) {
+ return host === LOOPBACK || host === "localhost" || host === "::1";
+}
+
+function isWildcard(host) {
+ return host === ANY || host === "::" || host === "";
+}
+
+/**
+ * @param {string} host bind address
+ * @param {number} port
+ * @param {ReturnType} ifaces
+ * @returns {{ kind: "local" | "lan", url: string }[]}
+ */
+function listenUrls(host, port, ifaces) {
+ if (isLoopback(host)) return [{ kind: "local", url: `http://${LOOPBACK}:${port}` }];
+ if (!isWildcard(host)) return [{ kind: "lan", url: `http://${host}:${port}` }];
+ const urls = [{ kind: "local", url: `http://${LOOPBACK}:${port}` }];
+ for (const list of Object.values(ifaces || {})) {
+ for (const a of list || []) {
+ if (a.internal || (a.family !== "IPv4" && a.family !== 4)) continue;
+ urls.push({ kind: "lan", url: `http://${a.address}:${port}` });
+ }
+ }
+ return urls;
+}
+
+/** Proxy targets in `tailscale serve status --json`, keyed by "host:port". */
+function serveProxies(json) {
+ const out = [];
+ for (const [hostPort, web] of Object.entries((json && json.Web) || {})) {
+ for (const [mount, h] of Object.entries((web && web.Handlers) || {})) {
+ if (h && typeof h.Proxy === "string") out.push({ hostPort, mount, proxy: h.Proxy });
+ }
+ }
+ return out;
+}
+
+function pointsAtPort(proxy, port) {
+ return new RegExp(`^(https?://)?(127\\.0\\.0\\.1|localhost):${port}/?$`).test(proxy);
+}
+
+function errorText(err) {
+ const msg = [err && err.stderr, err && err.stdout, err && err.message]
+ .map((s) => String(s || "").trim())
+ .find(Boolean);
+ return msg || "tailscale failed";
+}
+
+/**
+ * @param {object} deps
+ * @param {ReturnType} deps.devices
+ * @param {(opts: { host: string, port: number, authorize: Function }) => Promise} deps.startServer
+ * @param {number} deps.defaultPort
+ * @param {typeof defaultExec} [deps.exec]
+ * @param {() => ReturnType} [deps.networkInterfaces]
+ * @param {(msg: string) => void} [deps.log]
+ */
+function createWebAccess(deps) {
+ const devices = deps.devices;
+ const exec = deps.exec || defaultExec;
+ const networkInterfaces = deps.networkInterfaces || os.networkInterfaces;
+ const log = deps.log || (() => {});
+ /** @type {any} */
+ let server = null;
+ let host = LOOPBACK;
+ /** @type {string | null | undefined} undefined = not probed yet */
+ let tailscaleBin;
+
+ async function start(opts) {
+ await stop();
+ host = opts.host || LOOPBACK;
+ server = await deps.startServer({
+ host,
+ port: opts.port ?? deps.defaultPort,
+ authorize: (token) => devices.authorize(token),
+ });
+ return server;
+ }
+
+ async function stop() {
+ const s = server;
+ server = null;
+ if (s) await s.close();
+ }
+
+ async function tailscale(cmdArgs) {
+ if (tailscaleBin === undefined) {
+ tailscaleBin = null;
+ for (const bin of TAILSCALE_BINS) {
+ try {
+ await exec(bin, ["version"]);
+ tailscaleBin = bin;
+ break;
+ } catch {
+ // not this one
+ }
+ }
+ }
+ if (!tailscaleBin) return null;
+ return exec(tailscaleBin, cmdArgs);
+ }
+
+ /** Detect only. Never installs or logs in. */
+ async function tailscaleStatus() {
+ const port = server ? server.port : null;
+ let st;
+ try {
+ st = await tailscale(["status", "--json"]);
+ } catch (err) {
+ // `status` exits non-zero when stopped or logged out.
+ return { installed: true, loggedIn: false, host: null, serving: false, url: null, error: errorText(err) };
+ }
+ if (!st) return { installed: false, loggedIn: false, host: null, serving: false, url: null };
+ let json = {};
+ try {
+ json = JSON.parse(st.stdout);
+ } catch {
+ // treat as logged out
+ }
+ const loggedIn = json.BackendState === "Running";
+ const dns = String((json.Self && json.Self.DNSName) || "").replace(/\.$/, "");
+ const base = { installed: true, loggedIn, host: dns || null, serving: false, url: null };
+ if (!loggedIn || port == null) return base;
+ try {
+ const sv = await tailscale(["serve", "status", "--json"]);
+ const ours = serveProxies(JSON.parse(sv.stdout || "{}")).find(
+ (p) => p.mount === "/" && pointsAtPort(p.proxy, port),
+ );
+ if (ours) {
+ const [h, p] = ours.hostPort.split(":");
+ return { ...base, serving: true, url: p === "443" ? `https://${h}` : `https://${ours.hostPort}` };
+ }
+ } catch (err) {
+ log(`solenta-web: tailscale serve status failed: ${errorText(err)}`);
+ }
+ return base;
+ }
+
+ async function setTailscale(on) {
+ const current = await tailscaleStatus();
+ if (!current.installed) throw new Error("Tailscale is not installed.");
+ if (!current.loggedIn) throw new Error("Tailscale is not running or not logged in.");
+ if (on) {
+ if (!server) throw new Error("Turn on Solenta Web first.");
+ if (current.serving) return current;
+ // Refuse to replace someone else's HTTPS root on this machine.
+ const sv = await tailscale(["serve", "status", "--json"]);
+ const taken = serveProxies(JSON.parse(sv.stdout || "{}")).find(
+ (p) => p.mount === "/" && p.hostPort.endsWith(":443"),
+ );
+ if (taken) {
+ throw new Error(`Tailscale Serve already sends HTTPS to ${taken.proxy}. Remove that first.`);
+ }
+ try {
+ await tailscale(["serve", "--bg", "--yes", "--https=443", `http://${LOOPBACK}:${server.port}`]);
+ } catch (err) {
+ throw new Error(errorText(err));
+ }
+ } else if (current.serving) {
+ try {
+ await tailscale(["serve", "--yes", "--https=443", "off"]);
+ } catch (err) {
+ throw new Error(errorText(err));
+ }
+ }
+ return tailscaleStatus();
+ }
+
+ async function status() {
+ const saved = devices.server();
+ return {
+ running: Boolean(server),
+ lan: server ? !isLoopback(host) : saved.lan,
+ port: server ? server.port : deps.defaultPort,
+ urls: server ? listenUrls(host, server.port, networkInterfaces()) : [],
+ devices: devices.list(),
+ tailscale: await tailscaleStatus(),
+ };
+ }
+
+ return {
+ start,
+ stop,
+ status,
+
+ /** Boot: start if the user left the switch on. Never throws. */
+ async restore() {
+ const saved = devices.server();
+ if (!saved.enabled) return;
+ try {
+ await start({ host: saved.lan ? ANY : LOOPBACK });
+ } catch (err) {
+ log(`solenta-web: cannot start (${err && err.message ? err.message : err})`);
+ }
+ },
+
+ /** @param {{ enabled: boolean, lan?: boolean }} input */
+ async setEnabled(input) {
+ const enabled = Boolean(input && input.enabled);
+ const lan = input && typeof input.lan === "boolean" ? input.lan : devices.server().lan;
+ if (!enabled) {
+ // Do not leave a tailnet URL proxying to a closed port.
+ try {
+ if (server && (await tailscaleStatus()).serving) await setTailscale(false);
+ } catch (err) {
+ log(`solenta-web: tailscale serve off failed: ${errorText(err)}`);
+ }
+ await stop();
+ } else if (!server || host !== (lan ? ANY : LOOPBACK)) {
+ try {
+ await start({ host: lan ? ANY : LOOPBACK, port: server ? server.port : undefined });
+ } catch (err) {
+ const code = err && err.code;
+ throw new Error(
+ code === "EADDRINUSE"
+ ? `Port ${deps.defaultPort} is already in use. Quit the other Solenta first.`
+ : String((err && err.message) || err),
+ );
+ }
+ }
+ devices.setServer({ enabled, lan });
+ return status();
+ },
+
+ /** @param {{ name: string }} input */
+ async addDevice(input) {
+ return devices.add(input);
+ },
+
+ /** Revoke, then drop that device's open sockets immediately. */
+ async revokeDevice(input) {
+ const device = devices.revoke(String((input && input.id) || ""));
+ if (server && typeof server.disconnect === "function") server.disconnect(device.id);
+ return device;
+ },
+
+ /** @param {{ on: boolean }} input */
+ async setTailscale(input) {
+ return setTailscale(Boolean(input && input.on));
+ },
+
+ broadcast(channel, payload) {
+ if (server) server.broadcast(channel, payload);
+ },
+
+ isRunning() {
+ return Boolean(server);
+ },
+ };
+}
+
+module.exports = {
+ createWebAccess,
+ listenUrls,
+ serveProxies,
+ LOOPBACK,
+ ANY,
+};
diff --git a/electron/webBridge.js b/electron/webBridge.js
index bd6394421..2ac0b3aac 100644
--- a/electron/webBridge.js
+++ b/electron/webBridge.js
@@ -8,6 +8,10 @@
* An invoke before auth-ok gets an error reply, then the socket is dropped.
* After auth-ok, invoke is dispatched through IPC_HANDLERS (the same object
* ipcMain registration iterates).
+ *
+ * Failed auth is rate-limited per remote address: past the limit the socket
+ * is closed before the token is even compared. Each authed socket remembers
+ * its device, so revoking a device drops its live sockets at once.
*/
const crypto = require("node:crypto");
@@ -27,6 +31,56 @@ function tokensEqual(a, b) {
return crypto.timingSafeEqual(ba, bb);
}
+const AUTH_MAX_FAILURES = 10;
+const AUTH_WINDOW_MS = 60 * 1000;
+// Close code for "too many failed attempts" (4000-4999 is app-defined).
+const CLOSE_RATE_LIMITED = 4429;
+
+/**
+ * Failed-auth counter per key (remote address), sliding window.
+ *
+ * @param {{ maxFailures?: number, windowMs?: number, now?: () => number }} [opts]
+ */
+function createAuthLimiter(opts = {}) {
+ const maxFailures = opts.maxFailures ?? AUTH_MAX_FAILURES;
+ const windowMs = opts.windowMs ?? AUTH_WINDOW_MS;
+ const now = opts.now || Date.now;
+ /** @type {Map} */
+ const fails = new Map();
+ function recent(key) {
+ const t = now();
+ const list = (fails.get(key) || []).filter((x) => t - x < windowMs);
+ if (list.length) fails.set(key, list);
+ else fails.delete(key);
+ return list;
+ }
+ return {
+ blocked(key) {
+ return recent(String(key)).length >= maxFailures;
+ },
+ fail(key) {
+ const k = String(key);
+ // ponytail: crude cap so a spray of addresses cannot grow the map forever
+ if (!fails.has(k) && fails.size >= 1000) fails.delete(fails.keys().next().value);
+ const list = recent(k);
+ list.push(now());
+ fails.set(k, list);
+ },
+ };
+}
+
+/**
+ * Accept either an authorize(token) → {id}|null function or one fixed token.
+ *
+ * @param {{ authorize?: (token: string) => { id: string } | null, token?: string }} opts
+ */
+function resolveAuthorize(opts) {
+ if (opts && typeof opts.authorize === "function") return opts.authorize;
+ const token = opts && opts.token;
+ if (!token) throw new Error("web auth requires a token or an authorize function");
+ return (presented) => (tokensEqual(presented, token) ? { id: "token" } : null);
+}
+
/**
* @param {import("ws").WebSocket} ws
* @param {object} obj
@@ -42,7 +96,9 @@ function sendJson(ws, obj) {
*
* @param {import("node:http").Server} httpServer
* @param {object} opts
- * @param {string} opts.token
+ * @param {string} [opts.token] one fixed token (tests, legacy callers)
+ * @param {(token: string) => ({ id: string } | null)} [opts.authorize]
+ * @param {ReturnType} [opts.limiter]
* @param {object} opts.ctx ctx passed as first arg to IPC_HANDLERS[channel]
* @param {typeof IPC_HANDLERS} [opts.handlers] defaults to the exported map
* @param {string} [opts.path]
@@ -51,20 +107,21 @@ function sendJson(ws, obj) {
* @returns {{
* wss: import("ws").WebSocketServer,
* broadcast: (channel: string, payload: unknown) => void,
+ * disconnect: (deviceId: string) => number,
* close: () => Promise,
* }}
*/
function attachWebBridge(httpServer, opts) {
- const token = opts && opts.token;
- if (!token) throw new Error("attachWebBridge requires a token");
+ const authorize = resolveAuthorize(opts);
+ const limiter = opts.limiter || createAuthLimiter();
const ctx = opts.ctx;
const handlers = opts.handlers || IPC_HANDLERS;
const path = opts.path || WS_PATH;
const pingIntervalMs = opts.pingIntervalMs ?? 30000;
const maxBufferedBytes = opts.maxBufferedBytes ?? 4 * 1024 * 1024;
- /** @type {Set} */
- const authed = new Set();
+ /** Authed socket → device id. @type {Map} */
+ const authed = new Map();
const wss = new WebSocketServer({ server: httpServer, path });
@@ -85,7 +142,8 @@ function attachWebBridge(httpServer, opts) {
clearInterval(interval);
});
- wss.on("connection", (ws) => {
+ wss.on("connection", (ws, req) => {
+ const remote = (req && req.socket && req.socket.remoteAddress) || "unknown";
let sawFirst = false;
let isAuthed = false;
ws.isAlive = true;
@@ -115,12 +173,18 @@ function attachWebBridge(httpServer, opts) {
ws.close();
return;
}
- if (!tokensEqual(msg.token, token)) {
+ if (limiter.blocked(remote)) {
+ ws.close(CLOSE_RATE_LIMITED, "Too many failed attempts");
+ return;
+ }
+ const device = authorize(msg.token);
+ if (!device) {
+ limiter.fail(remote);
ws.close();
return;
}
isAuthed = true;
- authed.add(ws);
+ authed.set(ws, device.id);
sendJson(ws, { kind: "auth-ok" });
return;
}
@@ -177,7 +241,7 @@ function attachWebBridge(httpServer, opts) {
return;
}
const frame = JSON.stringify({ kind: "push", channel, payload });
- for (const client of authed) {
+ for (const client of authed.keys()) {
if (client.readyState !== WebSocket.OPEN) continue;
if (client.bufferedAmount > maxBufferedBytes) {
authed.delete(client);
@@ -188,6 +252,18 @@ function attachWebBridge(httpServer, opts) {
}
}
+ /** Terminate every socket a device authed with. Returns how many. */
+ function disconnect(deviceId) {
+ let n = 0;
+ for (const [client, id] of authed) {
+ if (id !== deviceId) continue;
+ authed.delete(client);
+ client.terminate();
+ n++;
+ }
+ return n;
+ }
+
async function close() {
clearInterval(interval);
for (const client of wss.clients) {
@@ -202,10 +278,14 @@ function attachWebBridge(httpServer, opts) {
});
}
- return { wss, broadcast, close };
+ return { wss, broadcast, disconnect, close };
}
module.exports = {
attachWebBridge,
+ createAuthLimiter,
+ resolveAuthorize,
+ tokensEqual,
+ CLOSE_RATE_LIMITED,
WS_PATH,
};
diff --git a/electron/webServer.js b/electron/webServer.js
index 9809d80a0..53d1c231d 100644
--- a/electron/webServer.js
+++ b/electron/webServer.js
@@ -3,9 +3,11 @@
/**
* HTTP static server for Solenta Web, plus the --serve-web flag parser.
*
- * Binding: 127.0.0.1 by default. --serve-host widens it.
- * There is no TLS in v1. Opening the bind beyond loopback puts the
+ * Binding: 127.0.0.1 by default. --serve-host, or the Settings "Allow
+ * devices on my network" switch, widens it; nothing else does.
+ * There is no TLS here. Opening the bind beyond loopback puts the
* token-gated API on the LAN; that is the operator's informed choice.
+ * Tailscale Serve (webAccess.js) is the TLS path.
*
* Requiring this module creates ZERO listeners. Listeners exist only
* after startWebServer() is called (main.js does that solely when
@@ -17,7 +19,13 @@ const fs = require("node:fs");
const path = require("node:path");
const crypto = require("node:crypto");
const { pipeline } = require("node:stream/promises");
-const { attachWebBridge, WS_PATH } = require("./webBridge.js");
+const {
+ attachWebBridge,
+ createAuthLimiter,
+ resolveAuthorize,
+ tokensEqual,
+ WS_PATH,
+} = require("./webBridge.js");
const { resolveByteRange } = require("./artifact-range.js");
const TOKEN_FILENAME = "web-token";
@@ -200,17 +208,6 @@ function serveStatic(req, res, staticDir) {
fs.createReadStream(file).pipe(res);
}
-/**
- * @param {string} a
- * @param {string} b
- */
-function tokensEqual(a, b) {
- const ba = Buffer.from(String(a));
- const bb = Buffer.from(String(b));
- if (ba.length !== bb.length) return false;
- return crypto.timingSafeEqual(ba, bb);
-}
-
/**
* @param {string} filePath
* @param {{ start: number, end: number, length: number }} range
@@ -288,13 +285,14 @@ async function pipeArtifactStream(req, res, stream) {
* @param {import("node:http").IncomingMessage} req
* @param {import("node:http").ServerResponse} res
* @param {object} opts
- * @param {string} opts.token
+ * @param {string} [opts.token]
+ * @param {(token: string) => ({ id: string } | null)} [opts.authorize]
+ * @param {ReturnType} [opts.limiter]
* @param {{ open: Function } | null | undefined} opts.artifactStore
* @param {URL} opts.url
* @param {typeof fs.createReadStream} [opts.createReadStream]
*/
async function serveRunArtifact(req, res, opts) {
- const token = opts.token;
const artifactStore = opts.artifactStore;
const url = opts.url;
const createReadStream = opts.createReadStream || fs.createReadStream;
@@ -315,8 +313,16 @@ async function serveRunArtifact(req, res, opts) {
return true;
}
- const queryToken = url.searchParams.get("token");
- if (!tokensEqual(queryToken, token)) {
+ // Checked on every request, so a revoked device's links die at once.
+ const remote = (req.socket && req.socket.remoteAddress) || "unknown";
+ if (opts.limiter && opts.limiter.blocked(remote)) {
+ res.writeHead(429, artifactSecurityHeaders());
+ res.end();
+ return true;
+ }
+ const authorize = resolveAuthorize(opts);
+ if (!authorize(url.searchParams.get("token") || "")) {
+ if (opts.limiter) opts.limiter.fail(remote);
res.writeHead(401, artifactSecurityHeaders());
res.end();
return true;
@@ -401,7 +407,8 @@ async function serveRunArtifact(req, res, opts) {
* @param {object} opts
* @param {string} [opts.host]
* @param {number} [opts.port]
- * @param {string} opts.token
+ * @param {string} [opts.token] one fixed token; or pass authorize
+ * @param {(token: string) => ({ id: string } | null)} [opts.authorize]
* @param {object} opts.ctx
* @param {object} [opts.handlers]
* @param {string | null} [opts.staticDir]
@@ -412,16 +419,16 @@ async function serveRunArtifact(req, res, opts) {
function startWebServer(opts) {
const host = (opts && opts.host) || DEFAULT_HOST;
const port = opts && opts.port != null ? opts.port : DEFAULT_PORT;
- const token = opts && opts.token;
+ const authorize = resolveAuthorize(opts);
+ // One limiter for both doors, so the artifact route is no side channel.
+ const limiter = createAuthLimiter();
const ctx = opts && opts.ctx;
const staticDir = (opts && opts.staticDir) || null;
const artifactStore = (opts && opts.artifactStore) || null;
const createReadStream = (opts && opts.createReadStream) || fs.createReadStream;
const log = (opts && opts.log) || (() => {});
- if (!token) throw new Error("startWebServer requires a token");
-
- // No TLS. Default bind is loopback. --serve-host is an informed LAN choice.
+ // No TLS. Default bind is loopback. LAN is an informed, explicit choice.
const server = http.createServer(async (req, res) => {
let url;
try {
@@ -432,7 +439,8 @@ function startWebServer(opts) {
return;
}
const handled = await serveRunArtifact(req, res, {
- token,
+ authorize,
+ limiter,
artifactStore,
url,
createReadStream,
@@ -442,7 +450,8 @@ function startWebServer(opts) {
});
const bridge = attachWebBridge(server, {
- token,
+ authorize,
+ limiter,
ctx,
handlers: opts.handlers,
path: WS_PATH,
@@ -476,6 +485,7 @@ function startWebServer(opts) {
server,
bridge,
broadcast: bridge.broadcast,
+ disconnect: bridge.disconnect,
close,
};
});
From dea99320062da8a5cf30d2adc0130a724cd4385b Mon Sep 17 00:00:00 2001
From: Willem
Date: Tue, 6 Oct 2026 18:15:03 +0200
Subject: [PATCH 3/5] feat(web): web:* IPC and boot wiring for the Solenta Web
switch (#1512 I2)
main.js owns one webAccess: --serve-web still prints its token (now the
Legacy device row); otherwise the saved Settings switch restores the
server on loopback, or 0.0.0.0 only if network access was turned on.
web:* handlers are desktop only. Declared in ipcChannels, CoderApi,
preload, devCoder and fakeCoder.
Co-Authored-By: Claude Opus 5.5
---
electron/ipc-web.js | 23 ++++++++++++
electron/ipc.js | 3 ++
electron/main.js | 59 ++++++++++++++++++-----------
electron/preload.js | 5 +++
electron/test/web.test.js | 8 ++--
electron/test/webAccess.test.js | 13 +++++++
electron/webAccess.js | 5 +++
src/dev/settings.ts | 66 +++++++++++++++++++++++++++++++++
src/devCoder.ts | 3 +-
src/shared/ipc.ts | 50 +++++++++++++++++++++++++
src/shared/ipcChannels.ts | 5 +++
test/support/fakeCoder.ts | 52 ++++++++++++++++++++++++++
12 files changed, 266 insertions(+), 26 deletions(-)
create mode 100644 electron/ipc-web.js
diff --git a/electron/ipc-web.js b/electron/ipc-web.js
new file mode 100644
index 000000000..389f5a7ba
--- /dev/null
+++ b/electron/ipc-web.js
@@ -0,0 +1,23 @@
+"use strict";
+
+/**
+ * IPC_HANDLERS rows for web:* (#1512 I2): the Solenta Web switch, device
+ * tokens and Tailscale Serve. Desktop only: a paired browser must not be
+ * able to mint more tokens, revoke others, or turn the server off under
+ * itself.
+ */
+
+function requireWebAccess(ctx) {
+ if (ctx.transport !== "desktop" || !ctx.webAccess) {
+ throw new Error("Solenta Web settings require the desktop app.");
+ }
+ return ctx.webAccess;
+}
+
+module.exports = {
+ "web:status": async (ctx) => requireWebAccess(ctx).status(),
+ "web:setEnabled": async (ctx, input) => requireWebAccess(ctx).setEnabled(input || {}),
+ "web:addDevice": async (ctx, input) => requireWebAccess(ctx).addDevice(input || {}),
+ "web:revokeDevice": async (ctx, input) => requireWebAccess(ctx).revokeDevice(input || {}),
+ "web:setTailscale": async (ctx, input) => requireWebAccess(ctx).setTailscale(input || {}),
+};
diff --git a/electron/ipc.js b/electron/ipc.js
index 1dd1cc09f..145bb251d 100644
--- a/electron/ipc.js
+++ b/electron/ipc.js
@@ -23,6 +23,7 @@ const gitHandlers = require("./ipc-git.js");
const filesHandlers = require("./ipc-files.js");
const devserverHandlers = require("./ipc-devserver.js");
const simulatorHandlers = require("./ipc-simulator.js");
+const webHandlers = require("./ipc-web.js");
/**
* Default window fan-out (desktop transport). main.js replaces this with a
@@ -74,6 +75,7 @@ function makeCtx(deps) {
: () => ({ running: false, port: null }),
openRemoteConnection: deps.openRemoteConnection,
forgetRemoteConnection: deps.forgetRemoteConnection,
+ webAccess: deps.webAccess || null,
transport: "desktop",
};
}
@@ -107,6 +109,7 @@ const IPC_HANDLERS = {
...filesHandlers,
...devserverHandlers,
...simulatorHandlers,
+ ...webHandlers,
};
/**
diff --git a/electron/main.js b/electron/main.js
index e650f17b3..a55a013c1 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -60,7 +60,10 @@ const {
startWebServer,
loadOrCreateToken,
HOST_FLAG_HELP,
+ DEFAULT_PORT: WEB_DEFAULT_PORT,
} = require("./webServer.js");
+const { createWebDevices } = require("./webDevices.js");
+const { createWebAccess } = require("./webAccess.js");
const { migrateLegacyUserData } = require("./legacy-migration.js");
const { configureDefaultSecrets } = require("./secrets.js");
const { installCrashGuard } = require("./crash-guard.js");
@@ -161,8 +164,9 @@ let postMergeScheduler = null;
/** @type {ReturnType | null} */
let memoryConsolidateScheduler = null;
-/** @type {Awaited> | null} */
-let webServer = null;
+/** Solenta Web: Settings switch, devices, Tailscale (#1512 I2). */
+/** @type {ReturnType | null} */
+let webAccess = null;
/** @type {InstanceType | null} */
let store = null;
@@ -354,8 +358,8 @@ function broadcast(channel, payload) {
win.webContents.send(channel, payload);
}
}
- if (webServer) {
- webServer.broadcast(channel, payload);
+ if (webAccess) {
+ webAccess.broadcast(channel, payload);
}
}
@@ -747,7 +751,25 @@ app.whenReady().then(async () => {
syncAttentionBadge(store);
setInterval(() => syncAttentionBadge(store), 2000);
- const registered = registerIpc({
+ // ctx is read when the server starts, after registerIpc below returns.
+ let registered = null;
+ const webStaticDir = path.join(__dirname, "../dist");
+ webAccess = createWebAccess({
+ devices: createWebDevices(userData),
+ defaultPort: WEB_DEFAULT_PORT,
+ startServer: (opts) =>
+ startWebServer({
+ ...opts,
+ staticDir: fs.existsSync(webStaticDir) ? webStaticDir : null,
+ ctx: registered.ctx,
+ artifactStore,
+ log: (msg) => console.warn(msg),
+ }),
+ log: (msg) => console.warn(msg),
+ });
+
+ registered = registerIpc({
+ webAccess,
ipcMain,
dialog,
store,
@@ -786,26 +808,19 @@ app.whenReady().then(async () => {
if (serveOpts.enabled) {
const token = loadOrCreateToken(userData);
+ // --serve-web keeps printing one token; it is the Legacy device row.
+ webAccess.adoptLegacy(token);
// Contract: print the token to stdout when serve mode starts.
process.stdout.write(`solenta-web: token ${token}\n`);
if (serveOpts.host !== "127.0.0.1") {
process.stdout.write(`solenta-web: ${HOST_FLAG_HELP}\n`);
}
- const staticDir = path.join(__dirname, "../dist");
// The port is fixed, so EADDRINUSE is routine (a previous instance still
// holds it). Never let that reject out of whenReady: the rest of boot
// (IPC ready push, schedulers) would be skipped even though the window
// is already up.
try {
- webServer = await startWebServer({
- host: serveOpts.host,
- port: serveOpts.port,
- staticDir: fs.existsSync(staticDir) ? staticDir : null,
- token,
- ctx: registered.ctx,
- artifactStore,
- log: (msg) => console.warn(msg),
- });
+ await webAccess.start({ host: serveOpts.host, port: serveOpts.port });
} catch (err) {
// No listener means nothing to keep the process alive headless either.
serveOpts.enabled = false;
@@ -813,6 +828,10 @@ app.whenReady().then(async () => {
`solenta-web: cannot listen on ${serveOpts.host}:${serveOpts.port} (${err && err.message ? err.message : err}); continuing without web serve\n`,
);
}
+ } else {
+ // The Settings switch, if the user left it on. Loopback unless they
+ // also allowed network access. Never throws.
+ await webAccess.restore();
}
// Round 47: lazy PR-state freshness. Async/serialized/latched so a slow gh
@@ -982,13 +1001,9 @@ shutdown = installShutdown({
/** Servers, schedulers, and child processes: last, after runs and the device. */
function teardownServices() {
closeRemoteConnections();
- if (webServer) {
- try {
- void webServer.close();
- } catch {
- // ignore
- }
- webServer = null;
+ if (webAccess) {
+ void webAccess.stop().catch(() => {});
+ webAccess = null;
}
if (prStateRefresher) {
try {
diff --git a/electron/preload.js b/electron/preload.js
index 914745d5f..013fc3029 100644
--- a/electron/preload.js
+++ b/electron/preload.js
@@ -99,6 +99,11 @@ const IPC_CHANNELS = Object.freeze([
{ ns: "pairing", method: "revoke" },
{ ns: "pairing", method: "approve" },
{ ns: "pairing", method: "reject" },
+ { ns: "web", method: "status" },
+ { ns: "web", method: "setEnabled" },
+ { ns: "web", method: "addDevice" },
+ { ns: "web", method: "revokeDevice" },
+ { ns: "web", method: "setTailscale" },
{ ns: "skills", method: "list" },
{ ns: "skills", method: "add" },
{ ns: "skills", method: "remove" },
diff --git a/electron/test/web.test.js b/electron/test/web.test.js
index de60d1bdf..04d83f4f0 100644
--- a/electron/test/web.test.js
+++ b/electron/test/web.test.js
@@ -494,7 +494,7 @@ describe("one map, two transports + packaging", () => {
assert.match(verify, /webBridge\.js/);
});
- it("main.js starts only on --serve-web via shared ctx", () => {
+ it("main.js starts on --serve-web or the saved switch, via shared ctx", () => {
const main = fs.readFileSync(path.join(__dirname, "../main.js"), "utf8");
assert.match(main, /parseServeWebArgs/);
assert.match(main, /startWebServer/);
@@ -504,6 +504,8 @@ describe("one map, two transports + packaging", () => {
assert.match(main, /HOST_FLAG_HELP/);
assert.doesNotMatch(main, /parseServeArgs\(/);
assert.match(main, /process\.stdout\.write\(`solenta-web: token/);
+ assert.match(main, /webAccess\.adoptLegacy\(token\)/);
+ assert.match(main, /\} else \{[^}]*await webAccess\.restore\(\);/);
});
it("main.js survives a web-server bind failure and still opens a window", async () => {
@@ -511,8 +513,8 @@ describe("one map, two transports + packaging", () => {
// #618: createWindow is first-paint (via bootFirstPaint), so a bind
// failure can no longer skip the window. The try/catch still matters:
// without it whenReady rejects and IPC-ready / schedulers never run.
- assert.match(main, /try \{\s*webServer = await startWebServer\(/);
- const start = main.indexOf("webServer = await startWebServer(");
+ assert.match(main, /try \{\s*await webAccess\.start\(/);
+ const start = main.indexOf("await webAccess.start(");
const guard = main.slice(start, start + 900);
assert.match(guard, /\} catch \(err\) \{/);
assert.match(guard, /serveOpts\.enabled = false/);
diff --git a/electron/test/webAccess.test.js b/electron/test/webAccess.test.js
index 31a45b26c..64a46c3cd 100644
--- a/electron/test/webAccess.test.js
+++ b/electron/test/webAccess.test.js
@@ -302,4 +302,17 @@ describe("webAccess", () => {
await access.setEnabled({ enabled: false });
assert.deepEqual(tsState.serve, {}, "no tailnet URL left pointing at a closed port");
});
+
+ it("web:* handlers are desktop only, so a paired browser cannot mint or revoke", async () => {
+ const handlers = require("../ipc-web.js");
+ for (const channel of Object.keys(handlers)) {
+ await assert.rejects(
+ handlers[channel]({ transport: "web", webAccess: access }, {}),
+ /require the desktop app/,
+ channel,
+ );
+ }
+ const st = await handlers["web:status"]({ transport: "desktop", webAccess: access });
+ assert.equal(st.running, false);
+ });
});
diff --git a/electron/webAccess.js b/electron/webAccess.js
index b3158ef00..7aaf5169a 100644
--- a/electron/webAccess.js
+++ b/electron/webAccess.js
@@ -278,6 +278,11 @@ function createWebAccess(deps) {
if (server) server.broadcast(channel, payload);
},
+ /** --serve-web's printed token is the Legacy device row. */
+ adoptLegacy(token) {
+ return devices.adoptLegacy(token);
+ },
+
isRunning() {
return Boolean(server);
},
diff --git a/src/dev/settings.ts b/src/dev/settings.ts
index d16dc80e5..09d1ca0ce 100644
--- a/src/dev/settings.ts
+++ b/src/dev/settings.ts
@@ -12,6 +12,8 @@ import type {
OtelSettings,
WebhookSettings,
ThreadInfo,
+ WebAccessStatus,
+ WebDeviceInfo,
} from "../shared/ipc";
import {
mergeMcpSettingsPatch,
@@ -538,3 +540,67 @@ export function createPairing(ctx: DevCtx): Pick {
},
};
}
+
+/** Solenta Web switch + devices for browser dev and screenshots (#1512 I2). */
+export function createWeb(): Pick {
+ const now = Date.now();
+ let running = false;
+ let lan = false;
+ let serving = false;
+ let devices: WebDeviceInfo[] = [
+ { id: "dev-legacy", name: "Legacy device", createdAt: now - 40 * 864e5, lastSeenAt: now - 3 * 864e5, legacy: true },
+ { id: "dev-ipad", name: "iPad", createdAt: now - 9 * 864e5, lastSeenAt: now - 12 * 60e3, legacy: false },
+ ];
+ const status = async (): Promise => ({
+ running,
+ lan,
+ port: 4620,
+ urls: running
+ ? [
+ { kind: "local", url: "http://127.0.0.1:4620" },
+ ...(lan ? [{ kind: "lan" as const, url: "http://192.168.1.20:4620" }] : []),
+ ]
+ : [],
+ devices,
+ tailscale: {
+ installed: true,
+ loggedIn: true,
+ host: "studio.tail1234.ts.net",
+ serving: running && serving,
+ url: running && serving ? "https://studio.tail1234.ts.net" : null,
+ },
+ });
+ return {
+ web: {
+ status,
+ async setEnabled(input) {
+ running = input.enabled;
+ if (typeof input.lan === "boolean") lan = input.lan;
+ if (!running) serving = false;
+ return status();
+ },
+ async addDevice(input) {
+ const device: WebDeviceInfo = {
+ id: `dev-${devices.length + 1}`,
+ name: input.name.trim(),
+ createdAt: Date.now(),
+ lastSeenAt: null,
+ legacy: false,
+ };
+ devices = devices.concat(device);
+ return { device, token: "dEvT0kEn-scan-me-from-the-settings-pane-0123456789" };
+ },
+ async revokeDevice(input) {
+ const hit = devices.find((d) => d.id === input.id);
+ if (!hit) throw new Error(`Unknown device: ${input.id}`);
+ devices = devices.filter((d) => d.id !== input.id);
+ return hit;
+ },
+ async setTailscale(input) {
+ if (input.on && !running) throw new Error("Turn on Solenta Web first.");
+ serving = input.on;
+ return (await status()).tailscale;
+ },
+ },
+ };
+}
diff --git a/src/devCoder.ts b/src/devCoder.ts
index 9adc99635..a1dbb747e 100644
--- a/src/devCoder.ts
+++ b/src/devCoder.ts
@@ -42,7 +42,7 @@ import {
seedThreads,
seedDetail,
} from "./dev/seed.ts";
-import { createSettings, createPairing } from "./dev/settings.ts";
+import { createSettings, createPairing, createWeb } from "./dev/settings.ts";
import { createSkills } from "./dev/skills.ts";
import { createThreads } from "./dev/threads.ts";
import { createUsage } from "./dev/usage.ts";
@@ -473,6 +473,7 @@ function buildDevCoder(): CoderApi {
},
},
...createPairing(ctx),
+ ...createWeb(),
...createIssues(ctx),
...createWorkspace(),
on(channel, cb) {
diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts
index ae099cf29..d5211df9a 100644
--- a/src/shared/ipc.ts
+++ b/src/shared/ipc.ts
@@ -3162,6 +3162,44 @@ export interface PairingCreated {
pairingPrompt: string | null;
}
+/** A browser or phone allowed into Solenta Web (#1512 I2). Token never listed. */
+export interface WebDeviceInfo {
+ id: string;
+ name: string;
+ createdAt: number;
+ /** Last successful sign-in, minute precision. */
+ lastSeenAt: number | null;
+ /** The single pre-device token (userData/web-token). */
+ legacy: boolean;
+}
+
+/** Detected, never installed. `url` is set while Serve points at Solenta Web. */
+export interface WebTailscaleStatus {
+ installed: boolean;
+ loggedIn: boolean;
+ /** MagicDNS name, without the trailing dot. */
+ host: string | null;
+ serving: boolean;
+ url: string | null;
+ error?: string;
+}
+
+export interface WebAccessStatus {
+ running: boolean;
+ /** Listening beyond loopback (0.0.0.0). Off unless the user turns it on. */
+ lan: boolean;
+ port: number;
+ urls: { kind: "local" | "lan"; url: string }[];
+ devices: WebDeviceInfo[];
+ tailscale: WebTailscaleStatus;
+}
+
+/** Returned once from addDevice. The token is stored only as a hash. */
+export interface WebDeviceCreated {
+ device: WebDeviceInfo;
+ token: string;
+}
+
/** Whole-definition upsert input. Omitted secrets preserve existing values. */
export type McpServerSaveInput =
| {
@@ -3931,6 +3969,18 @@ export interface CoderApi {
approve(input: { threadId: string }): Promise;
reject(input: { threadId: string }): Promise;
};
+ /**
+ * Solenta Web (#1512 I2): the in-app server switch, per-device tokens and
+ * the optional Tailscale Serve helper. Desktop only; a web client gets an
+ * error. Revoking a device drops its open sockets at once.
+ */
+ web: {
+ status(): Promise;
+ setEnabled(input: { enabled: boolean; lan?: boolean }): Promise;
+ addDevice(input: { name: string }): Promise;
+ revokeDevice(input: { id: string }): Promise;
+ setTailscale(input: { on: boolean }): Promise;
+ };
/**
* Agent skills on disk (SKILL.md files). A skill is installed once and
* mirrored into every active provider skills dir; list merges those into
diff --git a/src/shared/ipcChannels.ts b/src/shared/ipcChannels.ts
index 80f1a6d8c..d5a471cce 100644
--- a/src/shared/ipcChannels.ts
+++ b/src/shared/ipcChannels.ts
@@ -67,6 +67,11 @@ export const IPC_CHANNELS = [
{ ns: "pairing", method: "revoke" },
{ ns: "pairing", method: "approve" },
{ ns: "pairing", method: "reject" },
+ { ns: "web", method: "status" },
+ { ns: "web", method: "setEnabled" },
+ { ns: "web", method: "addDevice" },
+ { ns: "web", method: "revokeDevice" },
+ { ns: "web", method: "setTailscale" },
{ ns: "skills", method: "list" },
{ ns: "skills", method: "add" },
{ ns: "skills", method: "remove" },
diff --git a/test/support/fakeCoder.ts b/test/support/fakeCoder.ts
index b0d158124..e9c018419 100644
--- a/test/support/fakeCoder.ts
+++ b/test/support/fakeCoder.ts
@@ -55,6 +55,8 @@ import type {
PairingCreated,
PairingInfo,
PairingList,
+ WebAccessStatus,
+ WebDeviceInfo,
MemoryEntryInfo,
MemoryMaintenanceReport,
PrChecksResult,
@@ -291,6 +293,8 @@ export interface FakeOptions {
insights?: FailureMode[];
/** Override usage.byDay (default: empty ledger). */
usage?: UsageReport;
+ /** Solenta Web (#1512 I2). Default: off, no devices, no Tailscale. */
+ webAccess?: WebAccessStatus;
}
/** Idle terminal session for the harness; no shell exists under jsdom. */
@@ -674,6 +678,15 @@ export function createFakeCoder(opts: FakeOptions = {}): FakeCoder {
code: "unsupported_platform",
});
+ let web: WebAccessStatus = opts.webAccess ?? {
+ running: false,
+ lan: false,
+ port: 4620,
+ urls: [],
+ devices: [],
+ tailscale: { installed: false, loggedIn: false, host: null, serving: false, url: null },
+ };
+
/** Record the call, then either reject (if configured) or resolve. */
function rec(channel: string, args: unknown[], value: T): Promise {
calls.push({ channel, args });
@@ -3475,6 +3488,45 @@ export function createFakeCoder(opts: FakeOptions = {}): FakeCoder {
reject: (input: unknown) =>
rec("pairing.reject", [input], threads[0] ?? ({} as ThreadInfo)),
},
+ web: {
+ status: () => rec("web.status", [], { ...web }),
+ setEnabled: (input: { enabled: boolean; lan?: boolean }) => {
+ web = {
+ ...web,
+ running: input.enabled,
+ lan: input.lan ?? web.lan,
+ urls: input.enabled ? [{ kind: "local", url: `http://127.0.0.1:${web.port}` }] : [],
+ };
+ return rec("web.setEnabled", [input], { ...web });
+ },
+ addDevice: (input: { name: string }) => {
+ const device: WebDeviceInfo = {
+ id: `web-dev-${web.devices.length + 1}`,
+ name: input.name,
+ createdAt: 0,
+ lastSeenAt: null,
+ legacy: false,
+ };
+ web = { ...web, devices: web.devices.concat(device) };
+ return rec("web.addDevice", [input], { device, token: "t".repeat(43) });
+ },
+ revokeDevice: (input: { id: string }) => {
+ const hit = web.devices.find((d) => d.id === input.id);
+ web = { ...web, devices: web.devices.filter((d) => d.id !== input.id) };
+ return rec("web.revokeDevice", [input], hit as WebDeviceInfo);
+ },
+ setTailscale: (input: { on: boolean }) => {
+ web = {
+ ...web,
+ tailscale: {
+ ...web.tailscale,
+ serving: input.on,
+ url: input.on && web.tailscale.host ? `https://${web.tailscale.host}` : null,
+ },
+ };
+ return rec("web.setTailscale", [input], web.tailscale);
+ },
+ },
vibeKanban: {
preview: (input?: unknown) =>
rec("vibeKanban.preview", [input], {
From e96f44c0c1edc6ee692055effe29732f5ce3228f Mon Sep 17 00:00:00 2001
From: Willem
Date: Tue, 6 Oct 2026 18:15:03 +0200
Subject: [PATCH 4/5] feat(settings): Solenta Web section with devices, QR
pairing and Tailscale (#1512 I2)
Connections pane: serve switch, network-access switch with plain-HTTP
warning, Add device with a one-time link and a locally rendered QR,
device list with last seen and Revoke, and Expose over Tailscale (HTTPS)
with a tailnet exposure note.
Co-Authored-By: Claude Opus 5.5
---
src/components/SettingsModal.module.css | 11 +
src/components/SettingsModal.tsx | 9 +-
src/components/WebAccessSection.tsx | 354 ++++++++++++++++++++++++
test/webAccessSection.test.tsx | 128 +++++++++
4 files changed, 500 insertions(+), 2 deletions(-)
create mode 100644 src/components/WebAccessSection.tsx
create mode 100644 test/webAccessSection.test.tsx
diff --git a/src/components/SettingsModal.module.css b/src/components/SettingsModal.module.css
index 687a60aac..8e5897dd0 100644
--- a/src/components/SettingsModal.module.css
+++ b/src/components/SettingsModal.module.css
@@ -410,6 +410,17 @@
overflow: auto;
}
+.webQr {
+ width: 176px;
+ height: 176px;
+ align-self: flex-start;
+ border-radius: 8px;
+ border: 1px solid var(--border);
+ /* QR needs a light quiet zone in either theme to scan. */
+ background: #fff;
+ image-rendering: pixelated;
+}
+
.pairingProjectList {
display: flex;
flex-direction: column;
diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx
index fbd216d91..74d8c0199 100644
--- a/src/components/SettingsModal.tsx
+++ b/src/components/SettingsModal.tsx
@@ -23,6 +23,7 @@ import { SkillsManager, type SkillsManagerProps } from "./SkillsTab";
import styles from "./SettingsModal.module.css";
import { IntegrationsSection } from "./IntegrationsSection";
import { ConnectionsSection } from "./ConnectionsSection";
+import { WebAccessSection } from "./WebAccessSection";
import { isWebMode } from "../shared/wire";
import type { CoderApi } from "../shared/ipc";
import { PaneIcon } from "./settings/PaneIcon";
@@ -108,8 +109,8 @@ const PANE_META: Record<
},
connections: {
label: "Connections",
- hint: "Run Solenta on another machine through SSH.",
- keywords: "remote ssh host server tunnel workstation web token",
+ hint: "Open Solenta from your phone or a browser, or run it on another machine through SSH.",
+ keywords: "remote ssh host server tunnel workstation web token phone device qr pair tailscale https lan network browser",
},
integrations: {
label: "Integrations",
@@ -751,7 +752,11 @@ export function SettingsModal({
)}
{pane === "connections" && !isWebMode() && (
+ <>
+
+ Remote hosts
+ >
)}
{pane === "memory" && (
diff --git a/src/components/WebAccessSection.tsx b/src/components/WebAccessSection.tsx
new file mode 100644
index 000000000..d8cffdb56
--- /dev/null
+++ b/src/components/WebAccessSection.tsx
@@ -0,0 +1,354 @@
+import { useCallback, useEffect, useState } from "react";
+import { resolveCoderApi } from "../coderApi";
+import { formatRelativeAge } from "../format";
+import type {
+ CoderApi,
+ WebAccessStatus,
+ WebDeviceCreated,
+ WebDeviceInfo,
+} from "../shared/ipc";
+import styles from "./SettingsModal.module.css";
+
+/**
+ * Solenta Web in Settings (#1512 I2): the server switch, per-device tokens
+ * with a pairing QR, and the optional Tailscale Serve helper. Desktop only;
+ * the Connections pane is hidden in web mode.
+ */
+
+function resolveWeb(): CoderApi["web"] | null {
+ try {
+ const existing = (window as unknown as { coder?: CoderApi }).coder;
+ const api = existing ?? resolveCoderApi();
+ return typeof api.web?.status === "function" ? api.web : null;
+ } catch {
+ return null;
+ }
+}
+
+function message(err: unknown): string {
+ return err instanceof Error && err.message ? err.message : String(err);
+}
+
+function seenLabel(d: WebDeviceInfo): string {
+ if (d.lastSeenAt == null) return "Not used yet";
+ const r = formatRelativeAge(d.lastSeenAt);
+ return r === "now" ? "Seen just now" : `Seen ${r} ago`;
+}
+
+/** The address a new device should open: tailnet HTTPS, then LAN, then local. */
+export function pairingBases(status: WebAccessStatus): string[] {
+ const out: string[] = [];
+ if (status.tailscale.serving && status.tailscale.url) out.push(status.tailscale.url);
+ for (const u of status.urls) if (u.kind === "lan") out.push(u.url);
+ for (const u of status.urls) if (u.kind === "local") out.push(u.url);
+ return out;
+}
+
+export function pairingLink(base: string, token: string): string {
+ return `${base.replace(/\/+$/, "")}/?token=${encodeURIComponent(token)}`;
+}
+
+/** Rendered locally from the link; nothing leaves the machine. */
+function useQrDataUrl(text: string | null): string | null {
+ const [url, setUrl] = useState(null);
+ useEffect(() => {
+ if (!text) {
+ setUrl(null);
+ return;
+ }
+ let live = true;
+ void import("qrcode-generator").then((mod) => {
+ if (!live) return;
+ const qr = mod.default(0, "M");
+ qr.addData(text);
+ qr.make();
+ const svg = qr.createSvgTag({ cellSize: 4, margin: 4, scalable: true });
+ setUrl(`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`);
+ });
+ return () => {
+ live = false;
+ };
+ }, [text]);
+ return url;
+}
+
+export function WebAccessSection({ active }: { active: boolean }) {
+ const [status, setStatus] = useState(null);
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const [name, setName] = useState("");
+ const [created, setCreated] = useState(null);
+ const [base, setBase] = useState(null);
+ const [copied, setCopied] = useState(false);
+
+ const load = useCallback(async () => {
+ const web = resolveWeb();
+ if (!web) return;
+ try {
+ setStatus(await web.status());
+ } catch (err) {
+ setError(message(err));
+ }
+ }, []);
+
+ useEffect(() => {
+ if (active) void load();
+ }, [active, load]);
+
+ const run = async (fn: (web: CoderApi["web"]) => Promise) => {
+ const web = resolveWeb();
+ if (!web || busy) return;
+ setBusy(true);
+ setError(null);
+ try {
+ await fn(web);
+ } catch (err) {
+ setError(message(err));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const bases = status ? pairingBases(status) : [];
+ const chosen = base && bases.includes(base) ? base : (bases[0] ?? null);
+ const link = created && chosen ? pairingLink(chosen, created.token) : null;
+ const qr = useQrDataUrl(link);
+
+ if (!status) {
+ return error ? (
+
+ {error}
+
+ ) : null;
+ }
+
+ const ts = status.tailscale;
+
+ return (
+
+ Solenta Web
+
+
+ {status.running ? (
+
+ Listening on{" "}
+ {status.urls.map((u, i) => (
+
+ {i > 0 ? ", " : ""}
+ {u.url}
+
+ ))}
+
+ ) : (
+ Off. Turn it on to use Solenta from a browser or your phone.
+ )}
+
+
+ {
+ const enabled = e.target.checked;
+ void run(async (web) => {
+ setStatus(await web.setEnabled({ enabled, lan: status.lan }));
+ });
+ }}
+ />
+ Serve Solenta Web
+
+
+ {
+ const lan = e.target.checked;
+ void run(async (web) => {
+ setStatus(await web.setEnabled({ enabled: status.running, lan }));
+ });
+ }}
+ />
+ Allow devices on my network
+
+
+ Off, only this computer can connect. On, anyone on your network can
+ reach the sign-in page over plain HTTP, and each device still needs
+ its own token. Use Tailscale below for HTTPS.
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ Devices
+ {created ? (
+
+
Pair {created.device.name}
+
+ Scan with the device's camera, or open the link on it. This token
+ is shown once.
+
+ {bases.length > 1 ? (
+
setBase(e.target.value)}
+ >
+ {bases.map((b) => (
+
+ {b}
+
+ ))}
+
+ ) : null}
+ {!status.running ? (
+
Turn on Solenta Web to get a link and QR code.
+ ) : null}
+ {qr ? (
+
+ ) : null}
+
+ {link ?? created.token}
+
+
+ {
+ void navigator.clipboard.writeText(link ?? created.token).then(
+ () => {
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 1200);
+ },
+ () => {},
+ );
+ }}
+ >
+ {copied ? "Copied" : link ? "Copy link" : "Copy token"}
+
+ setCreated(null)}>
+ Done
+
+
+
+ ) : null}
+
+ setName(e.target.value)}
+ />
+
+ void run(async (web) => {
+ const result = await web.addDevice({ name: name.trim() });
+ setCreated(result);
+ setName("");
+ setStatus(await web.status());
+ })
+ }
+ >
+ Add device
+
+
+ {status.devices.length > 0 ? (
+
+ {status.devices.map((d) => (
+
+
+
{d.name}
+
+ {seenLabel(d)}
+ {d.legacy ? " · the token from before devices" : ""}
+
+
+
+ void run(async (web) => {
+ await web.revokeDevice({ id: d.id });
+ if (created?.device.id === d.id) setCreated(null);
+ setStatus(await web.status());
+ })
+ }
+ >
+ Revoke
+
+
+ ))}
+
+ ) : (
+ No devices yet. Revoking one signs it out at once.
+ )}
+
+ {ts.installed ? (
+
+
Tailscale
+ {!ts.loggedIn ? (
+
Tailscale is installed but not running or not logged in.
+ ) : ts.serving && ts.url ? (
+
+ {ts.url}
+
+ void run(async (web) => {
+ await web.setTailscale({ on: false });
+ setStatus(await web.status());
+ })
+ }
+ >
+ Stop
+
+
+ ) : (
+
+
+ void run(async (web) => {
+ await web.setTailscale({ on: true });
+ setStatus(await web.status());
+ })
+ }
+ >
+ Expose over Tailscale (HTTPS)
+
+
+ )}
+
+ This runs Tailscale Serve, which makes Solenta Web reachable over
+ HTTPS from every device on your tailnet{ts.host ? ` at ${ts.host}` : ""},
+ including anyone you share this machine with. Each device still
+ needs its own token.
+
+
+ ) : null}
+
+ );
+}
diff --git a/test/webAccessSection.test.tsx b/test/webAccessSection.test.tsx
new file mode 100644
index 000000000..91a47246d
--- /dev/null
+++ b/test/webAccessSection.test.tsx
@@ -0,0 +1,128 @@
+/**
+ * Settings > Connections > Solenta Web (#1512 I2): switch, devices with a
+ * locally rendered pairing QR, revoke, and the Tailscale Serve helper.
+ */
+import assert from "node:assert/strict";
+import { describe, it, afterEach } from "node:test";
+import { mount, unmountAll } from "./support/dom.ts";
+import { createFakeCoder } from "./support/fakeCoder.ts";
+import {
+ WebAccessSection,
+ pairingBases,
+ pairingLink,
+} from "../src/components/WebAccessSection";
+import type { WebAccessStatus } from "../src/shared/ipc";
+
+/** The jsdom window exists only after the first mount. */
+async function install(webAccess?: WebAccessStatus) {
+ (await mount(
)).unmount();
+ const fake = createFakeCoder({ webAccess });
+ (window as unknown as { coder: unknown }).coder = fake.api;
+ return fake;
+}
+
+const TS_READY = { installed: true, loggedIn: true, host: "mac.tail1.ts.net", serving: false, url: null };
+
+describe("WebAccessSection", () => {
+ afterEach(() => {
+ unmountAll();
+ if (typeof window !== "undefined") delete (window as unknown as { coder?: unknown }).coder;
+ });
+
+ it("starts off and loopback-only; the switch turns it on and shows the URL", async () => {
+ const fake = await install();
+ const m = await mount( );
+ await m.flush();
+ assert.equal(m.query("[data-web-state]")?.getAttribute("data-web-state"), "off");
+ assert.equal((m.query("[data-web-lan]") as HTMLInputElement).checked, false);
+
+ await m.click(m.query("[data-web-enabled]"));
+ await m.flush();
+ assert.deepEqual(
+ fake.calls.find((c) => c.channel === "web.setEnabled")?.args,
+ [{ enabled: true, lan: false }],
+ );
+ assert.equal(m.query("[data-web-url=local]")?.textContent, "http://127.0.0.1:4620");
+ });
+
+ it("adds a device, shows its link and a local QR once, and revokes it", async () => {
+ const fake = await install();
+ const m = await mount( );
+ await m.flush();
+ await m.click(m.query("[data-web-enabled]"));
+ await m.flush();
+
+ await m.type(m.query("[data-web-device-name]"), "Phone");
+ await m.click(m.query("[data-web-add-device]"));
+ for (let i = 0; i < 5 && !m.query("[data-web-qr]"); i++) await m.flush();
+
+ const link = m.query("[data-web-pair-link]")?.textContent ?? "";
+ assert.equal(link, `http://127.0.0.1:4620/?token=${"t".repeat(43)}`);
+ const qr = m.query("[data-web-qr]") as HTMLImageElement | null;
+ assert.ok(qr, "QR rendered");
+ assert.match(qr.getAttribute("src") ?? "", /^data:image\/svg\+xml/, "no network image");
+
+ const row = m.query("[data-web-devices] [data-web-revoke]") as HTMLElement;
+ assert.ok(m.text().includes("Not used yet"));
+ await m.click(row);
+ await m.flush();
+ assert.ok(fake.calls.some((c) => c.channel === "web.revokeDevice"));
+ assert.equal(m.query("[data-web-reveal]"), null, "revoking hides its one-time token");
+ assert.equal(m.query("[data-web-devices]"), null);
+ });
+
+ it("Tailscale: offered only once Web is on, explains the tailnet exposure, and stops", async () => {
+ const fake = await install({
+ running: false,
+ lan: false,
+ port: 4620,
+ urls: [],
+ devices: [],
+ tailscale: TS_READY,
+ });
+ const m = await mount( );
+ await m.flush();
+ const start = () => m.query("[data-web-tailscale-start]") as HTMLButtonElement;
+ assert.equal(start().disabled, true);
+ assert.ok(m.text().includes("every device on your tailnet at mac.tail1.ts.net"));
+
+ await m.click(m.query("[data-web-enabled]"));
+ await m.flush();
+ await m.click(start());
+ await m.flush();
+ assert.deepEqual(fake.calls.find((c) => c.channel === "web.setTailscale")?.args, [{ on: true }]);
+ assert.equal(m.query("[data-web-tailscale-url]")?.textContent, "https://mac.tail1.ts.net");
+
+ await m.click(m.query("[data-web-tailscale-stop]"));
+ await m.flush();
+ assert.ok(start(), "back to the start action");
+ });
+
+ it("hides Tailscale when it is not installed, and uses no em-dashes", async () => {
+ await install();
+ const m = await mount( );
+ await m.flush();
+ assert.equal(m.query("[data-web-tailscale]"), null);
+ assert.ok(!m.text().includes("—"));
+ });
+
+ it("pairs over tailnet HTTPS first, then LAN, then this computer", () => {
+ const st: WebAccessStatus = {
+ running: true,
+ lan: true,
+ port: 4620,
+ urls: [
+ { kind: "local", url: "http://127.0.0.1:4620" },
+ { kind: "lan", url: "http://192.168.1.20:4620" },
+ ],
+ devices: [],
+ tailscale: { ...TS_READY, serving: true, url: "https://mac.tail1.ts.net" },
+ };
+ assert.deepEqual(pairingBases(st), [
+ "https://mac.tail1.ts.net",
+ "http://192.168.1.20:4620",
+ "http://127.0.0.1:4620",
+ ]);
+ assert.equal(pairingLink("https://x/", "a+b"), "https://x/?token=a%2Bb");
+ });
+});
From 91a65d4f8912ad20b9720a047d1be8d8cfa1b5c6 Mon Sep 17 00:00:00 2001
From: Willem
Date: Tue, 6 Oct 2026 18:20:36 +0200
Subject: [PATCH 5/5] fix(settings): mono URLs and tighter labels in the
Solenta Web section
Co-Authored-By: Claude Opus 5.5
---
src/components/SettingsModal.module.css | 5 +++++
src/components/WebAccessSection.tsx | 8 ++++----
2 files changed, 9 insertions(+), 4 deletions(-)
diff --git a/src/components/SettingsModal.module.css b/src/components/SettingsModal.module.css
index 8e5897dd0..a1fbea9e6 100644
--- a/src/components/SettingsModal.module.css
+++ b/src/components/SettingsModal.module.css
@@ -410,6 +410,11 @@
overflow: auto;
}
+.webUrl {
+ font-family: var(--mono);
+ font-size: 11.5px;
+}
+
.webQr {
width: 176px;
height: 176px;
diff --git a/src/components/WebAccessSection.tsx b/src/components/WebAccessSection.tsx
index d8cffdb56..0b7047fcc 100644
--- a/src/components/WebAccessSection.tsx
+++ b/src/components/WebAccessSection.tsx
@@ -135,7 +135,7 @@ export function WebAccessSection({ active }: { active: boolean }) {
{status.urls.map((u, i) => (
{i > 0 ? ", " : ""}
- {u.url}
+ {u.url}
))}
@@ -185,7 +185,7 @@ export function WebAccessSection({ active }: { active: boolean }) {
) : null}
- Devices
+ Devices
{created ? (
Pair {created.device.name}
@@ -302,12 +302,12 @@ export function WebAccessSection({ active }: { active: boolean }) {
{ts.installed ? (
-
Tailscale
+
Tailscale
{!ts.loggedIn ? (
Tailscale is installed but not running or not logged in.
) : ts.serving && ts.url ? (
- {ts.url}
+ {ts.url}