Public: anyone can read this; the crew can change it.
Migrated from github.com/Trevato/SO-ARM100
workbenchthis machine
P0: sign-in works while the workshop is closed: allow /_plat/* (redirect to /__forge/signin) and the owner paths through the resting guard #636
Check · Review · Land done · Live — Landed; nothing deploys from this branch, so this is the end of its path.
quill/arm:quill/635-p0-sign-in-works-while-the-workshop-is-c → main 2 commits fork
5 files
changed +78
−13
Software/so101/README.md +1 −1
| @@ -34,7 +34,7 @@ The owner can close the whole app when the shop is dark: on `/admin`, **Open / C | ||
| 34 | 34 | |
| 35 | 35 | Closed means: every turn ends (the owner's too) and the line is emptied, nobody can join, every open `/follow` stream is closed, and nothing reaches the arm but a STOP (anything else is 503). `/` is then a calm card, **The arm is resting**, with the note, when it should be back and when it was last open, a "Get notified when it's back" button (the browser's own notification, while that tab stays open), and links to the story (`/story`) and the code (src/resting-page.ts). Resting stops driving, never the cameras: each streams (`/camera/<id>/snapshot`, `stream`, `ws`) whenever its own source answers, and the home still shows the arm's camera on its resting card. A page that was open when it closed reloads into the card, and the card reloads into the arm when it opens. The reopen time is only shown: the arm never reopens by itself. |
| 36 | 36 | |
| 37 | Closing never locks the owner out. Sign-in (the forge's `/__forge/signin`), `/admin` and `/story` keep working while closed; only turns, cameras, `/follow` and commands are refused. The card has a quiet **Owner? Sign in to open** link (sign-in, then `/admin`), and a signed-in owner sees **Open now** right on the card, which opens it and goes back to the arm (a same-origin form post to `/admin`; anyone else gets no button and is refused). | |
| 37 | Closing never locks the owner out. Signing in and out keep working while closed, whoever drew the link: the forge's `/__forge/signin` and `/__forge/signout`, this app's `/signin`, `POST /signout` and `POST /join`, and the forge's signed-out chrome, whose `/_plat/login?rd=…` is a 303 to `/__forge/signin?to=…` (a path on this site only) and `/_plat/logout` a 303 to `/__forge/signout`. So do `/admin`, `/story`, `/status`, `/link` and `/deploy-ok` (one list, `RESTING_ALLOWED` in src/relay.ts); `/metrics`, `/version` and every command stay 503 `{"open": false}`; only turns, cameras, `/follow` and commands are refused. The card has a quiet **Owner? Sign in to open** link (sign-in, then `/admin`), and a signed-in owner sees **Open now** right on the card, which opens it and goes back to the arm (a same-origin form post to `/admin`; anyone else gets no button and is refused). | |
| 38 | 38 | |
| 39 | 39 | The switch also works outside the app: |
| 40 | 40 |
Software/so101/src/lobby.ts +2 −2
| @@ -259,8 +259,8 @@ export class Lobby { | ||
| 259 | 259 | } |
| 260 | 260 | |
| 261 | 261 | /** Why this person may not command the arm right now, or null when they may (and then it counts as activity). */ |
| 262 | refusal(who: Who, req?: IncomingMessage): { status: number; error: string } | null { | |
| 263 | if (this.rest) return { status: 503, error: restingWords(this.rest) }; | |
| 262 | refusal(who: Who, req?: IncomingMessage): { status: number; error: string; open?: false } | null { | |
| 263 | if (this.rest) return { status: 503, error: restingWords(this.rest), open: false }; | |
| 264 | 264 | if (this.local) return null; |
| 265 | 265 | if (!who) return { status: 401, error: "join to take control" }; |
| 266 | 266 | if (this.barred(who, req ? visitor(req) : null)) return { status: 403, error: "you are banned: you can still watch" }; |
Software/so101/src/opening.test.ts +1 −1
| @@ -159,7 +159,7 @@ describe("the lobby's switch", () => { | ||
| 159 | 159 | expect(lobby.turns.driver).toBeNull(); |
| 160 | 160 | expect(lobby.takeControl(ann)).toBe("closed"); |
| 161 | 161 | expect(lobby.takeControl(owner)).toBe("closed"); |
| 162 | expect(lobby.refusal(ann)).toEqual({ status: 503, error: "The arm is resting" }); | |
| 162 | expect(lobby.refusal(ann)).toEqual({ status: 503, error: "The arm is resting", open: false }); | |
| 163 | 163 | expect(lobby.refusal(owner)?.status).toBe(503); |
| 164 | 164 | expect(lobby.admin.log.at(-1)?.what).toBe('closed the arm: "Lights off in the shop"'); |
| 165 | 165 | lobby.setOpen(owner, { open: true }); |
Software/so101/src/relay.ts +30 −8
| @@ -62,9 +62,11 @@ | ||
| 62 | 62 | * Resting (the owner closed it, or a real arm unheard for two minutes; src/opening.ts): / is the resting card |
| 63 | 63 | * (src/resting-page.ts), /status answers {"open": false, "reason", "note", "reopenAt", "lastOpenAt"} without asking the |
| 64 | 64 | * arm, every turn has ended, /follow is refused (and those open are closed), and nothing goes to the arm but a STOP: |
| 65 | * anything else is 503. The cameras never rest: each streams whenever its own source answers (#623). Sign-in (/__forge/…), /admin and /story keep working, so the owner can always open | |
| 66 | * it again (#578): the card links an anonymous visitor to sign-in and shows the owner an Open now button. Open, /status | |
| 67 | * carries "open": true. | |
| 65 | * anything else is 503 {"open": false}. The cameras never rest: each streams whenever its own source answers (#623). | |
| 66 | * Signing in and out keep working (/__forge/…, /signin, /signout, /join, and the forge's signed-out /_plat/login?rd= and | |
| 67 | * /_plat/logout, which 303 to /__forge/signin?to= and /__forge/signout), as do /admin, /story, /status, /link and | |
| 68 | * /deploy-ok (RESTING_ALLOWED), so the owner can always open it again (#578, #635): the card links an anonymous visitor | |
| 69 | * to sign-in and shows the owner an Open now button. Open, /status carries "open": true. | |
| 68 | 70 | * |
| 69 | 71 | * GET|PUT|DELETE /leader/calibration the signed-in person's own leader calibration (src/leader-map.js), any time, |
| 70 | 72 | * turn or no turn: GET answers it or null, PUT {"joints": {...}} stores it |
| @@ -257,8 +259,17 @@ const GROW_KEYED = new Set(["GET /grow", "POST /grow/light", "POST /grow/pump"]) | ||
| 257 | 259 | const OWNER_READS = new Set(["GET /servo-params", "PUT /servo-params", "POST /servo-params/factory", "GET /tuning", "POST /tuning/p", "GET /grow", "POST /grow/light", "POST /grow/pump", "GET /probes", "POST /probes/calibrate"]); |
| 258 | 260 | /** The driver's, and any admin's at any time. */ |
| 259 | 261 | const ADMIN_TOO = new Set(["POST /stop", "POST /resume", "POST /estop"]); |
| 260 | /** The forge's own paths (/__forge/signin and the like): the forge answers them in front of the app. */ | |
| 261 | const FORGE_PATH = /^\/__forge(?:\/|$)/; | |
| 262 | /** | |
| 263 | * What resting never refuses, so a person can always sign in and the owner can always get back to /admin to open it: | |
| 264 | * the forge's paths (the forge answers /__forge/ in front of the app), the forge's signed-out chrome (/_plat/login and | |
| 265 | * /_plat/logout, redirected to the forge's own), the session routes, the owner's pages, and the public reads that describe | |
| 266 | * the app rather than drive the arm. Everything else (/metrics, /version, every command) is the arm's, and resting says so. | |
| 267 | */ | |
| 268 | export const RESTING_ALLOWED = /^\/(?:__forge|_plat)(?:\/|$)|^\/(?:signin|signout|join|admin|story|status|link|deploy-ok)$/; | |
| 269 | /** A sign-in link's return path, kept only when it stays on this site: a path, never `//host` or `/\\host`. */ | |
| 270 | export function returnPath(raw: string | null): string { | |
| 271 | return raw && raw.length <= 2048 && /^\/(?![\/\\])/.test(raw) && !/[\x00-\x1f]/.test(raw) ? raw : "/"; | |
| 272 | } | |
| 262 | 273 | /** What still goes to the arm server while the link is reconnecting: stopping it. */ |
| 263 | 274 | const STOPS = new Set(["POST /stop", "POST /estop"]); |
| 264 | 275 | const RECONNECTING = "reconnecting to the arm"; |
| @@ -1185,6 +1196,17 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1185 | 1196 | res.setHeader("allow", "POST"); |
| 1186 | 1197 | return json(res, 405, { error: "POST to sign out" }); |
| 1187 | 1198 | } |
| 1199 | // Whoever rendered the sign-in link, it ends at the forge's own: the forge's signed-out chrome links /_plat/login?rd=. | |
| 1200 | if (url.pathname === "/_plat/login" || url.pathname === "/signin") { | |
| 1201 | const to = returnPath(url.searchParams.get("rd") ?? url.searchParams.get("to")); | |
| 1202 | res.writeHead(303, { location: `/__forge/signin?to=${encodeURIComponent(to)}`, "cache-control": "no-store" }); | |
| 1203 | return res.end(); | |
| 1204 | } | |
| 1205 | if (url.pathname === "/_plat/logout") { | |
| 1206 | res.writeHead(303, { location: "/__forge/signout", "cache-control": "no-store" }); | |
| 1207 | return res.end(); | |
| 1208 | } | |
| 1209 | if (/^\/_plat(?:\/|$)/.test(url.pathname)) return json(res, 404, { error: "no such route" }); | |
| 1188 | 1210 | if (url.pathname === "/join") { |
| 1189 | 1211 | if (req.method === "POST") return await join(req, res); |
| 1190 | 1212 | res.setHeader("allow", "POST"); |
| @@ -1217,7 +1239,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1217 | 1239 | : ADMIN_TOO.has(route) && who?.owner |
| 1218 | 1240 | ? null |
| 1219 | 1241 | : lobby.refusal(who, req); |
| 1220 | if (refused) return json(res, refused.status, { error: refused.error }); | |
| 1242 | if (refused) return json(res, refused.status, { error: refused.error, ...(refused.open === false ? { open: false } : {}) }); | |
| 1221 | 1243 | by = who!.name; |
| 1222 | 1244 | } |
| 1223 | 1245 | // Public on purpose, like /link: timing aggregates only (README, "Metrics"). |
| @@ -1261,8 +1283,8 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1261 | 1283 | if (req.method === "GET" && PAGE.test(url.pathname)) return await page(req, res, await ask(simulation + path.replace(/^\/arm(?=$|\?)/, "/"), req, undefined, null, false)); |
| 1262 | 1284 | if (req.method === "GET" && STATIC.test(url.pathname) && acceptsGzip(req.headers)) return await packed(res, await ask(simulation + path, req, undefined, null, false), path); |
| 1263 | 1285 | // Resting, nothing reaches an arm but a STOP: not a command, not a read. |
| 1264 | // The forge's own paths (sign-in among them) are never the arm's: resting never refuses them, so the owner can get back in. | |
| 1265 | if (rest && !LOCAL.test(url.pathname) && !FORGE_PATH.test(url.pathname) && !STOPS.has(route)) return json(res, 503, { error: `${restingWords(rest)}: nothing goes to it now`, open: false }); | |
| 1286 | // Signing in, signing out and the owner's pages are never the arm's: resting never refuses them, so the owner can get back in. | |
| 1287 | if (rest && !LOCAL.test(url.pathname) && !RESTING_ALLOWED.test(url.pathname) && !STOPS.has(route)) return json(res, 503, { error: `${restingWords(rest)}: nothing goes to it now`, open: false }); | |
| 1266 | 1288 | if (route === "POST /stop" || route === "POST /estop") facts?.event("STOP", lobby ? lobby.who(req)?.id : null); |
| 1267 | 1289 | if (route === "POST /stop") { |
| 1268 | 1290 | // Shut before anything is asked of an arm: a frame already on its way finds the gate closed. |
Software/so101/src/resting.test.ts +44 −1
| @@ -9,7 +9,7 @@ import { FakeBus } from "./fake-bus.ts"; | ||
| 9 | 9 | import { sign } from "./identity.ts"; |
| 10 | 10 | import { CameraHub } from "./cameras.ts"; |
| 11 | 11 | import { Lobby } from "./lobby.ts"; |
| 12 | import { parseRemote, startRelay, type RunningRelay } from "./relay.ts"; | |
| 12 | import { RESTING_ALLOWED, parseRemote, startRelay, type RunningRelay } from "./relay.ts"; | |
| 13 | 13 | import { OWNER_SIGNIN, REPO_URL, RESTING_CSP, RESTING_STYLE, STORY_PATH, renderResting } from "./resting-page.ts"; |
| 14 | 14 | import { startServer, type RunningServer } from "./server.ts"; |
| 15 | 15 | |
| @@ -161,6 +161,49 @@ describe("closed, the owner is never locked out (#578)", () => { | ||
| 161 | 161 | expect((await fetch(`${base}/admin`)).status).toBe(404); // anonymous: the page is still nobody's business |
| 162 | 162 | }); |
| 163 | 163 | |
| 164 | test("every sign-in link works while closed, whoever rendered it; the arm's own routes stay refused (#635)", async () => { | |
| 165 | const base = await boot(); | |
| 166 | await fetch(`${base}/admin`, form({ action: "close", note: "Lights off" })); | |
| 167 | const json = { "content-type": "application/json" }; | |
| 168 | const allowed: Array<[string, RequestInit, number, string | null]> = [ | |
| 169 | ["/_plat/login?rd=%2F", {}, 303, "/__forge/signin?to=%2F"], | |
| 170 | ["/_plat/login?rd=%2Fadmin", {}, 303, "/__forge/signin?to=%2Fadmin"], | |
| 171 | ["/_plat/login?rd=%2F%2Felsewhere.example", {}, 303, "/__forge/signin?to=%2F"], // never off the site | |
| 172 | ["/_plat/login?rd=https%3A%2F%2Felsewhere.example", {}, 303, "/__forge/signin?to=%2F"], | |
| 173 | ["/_plat/login", {}, 303, "/__forge/signin?to=%2F"], | |
| 174 | ["/_plat/logout", {}, 303, "/__forge/signout"], | |
| 175 | ["/signin?to=%2Fadmin", {}, 303, "/__forge/signin?to=%2Fadmin"], | |
| 176 | ["/signout", { method: "POST" }, 303, "/"], | |
| 177 | ["/admin", form({ action: "close", note: "Still closed" }), 303, "/admin?done=closed"], | |
| 178 | ["/admin", { headers: owner }, 200, null], | |
| 179 | ["/story", {}, 200, null], | |
| 180 | ["/status", {}, 200, null], | |
| 181 | ["/link", {}, 200, null], | |
| 182 | ["/deploy-ok", {}, 200, null], | |
| 183 | ]; | |
| 184 | for (const [path, init, status, location] of allowed) { | |
| 185 | const answer = await fetch(`${base}${path}`, { redirect: "manual", ...init }); | |
| 186 | expect([path, answer.status]).toEqual([path, status]); | |
| 187 | if (location) expect([path, answer.headers.get("location")]).toEqual([path, location]); | |
| 188 | await answer.arrayBuffer(); | |
| 189 | } | |
| 190 | expect(RESTING_ALLOWED.test("/_plat/login")).toBe(true); | |
| 191 | expect((await fetch(`${base}/_plat/elsewhere`)).status).toBe(404); // the forge's chrome only: nothing else under it reaches the arm | |
| 192 | ||
| 193 | const refused: Array<[string, RequestInit]> = [ | |
| 194 | ["/metrics", {}], | |
| 195 | ["/version", {}], | |
| 196 | ["/turn", { method: "POST", headers: { ...owner, ...json }, body: "{}" }], | |
| 197 | ["/move", { method: "POST", headers: { ...owner, ...json }, body: "{}" }], | |
| 198 | ["/admin/elsewhere", {}], | |
| 199 | ]; | |
| 200 | for (const [path, init] of refused) { | |
| 201 | const answer = await fetch(`${base}${path}`, init); | |
| 202 | expect([path, answer.status]).toEqual([path, 503]); | |
| 203 | expect(await answer.json()).toMatchObject({ open: false }); | |
| 204 | } | |
| 205 | }); | |
| 206 | ||
| 164 | 207 | test("a signed-in owner reaches /admin, and the card shows Open now, which opens it and goes back to the arm", async () => { |
| 165 | 208 | const base = await boot(); |
| 166 | 209 | await fetch(`${base}/admin`, form({ action: "close", note: "Lights off" })); |