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

Merged opened this in answer to

Check · Review · Land done · Live — Landed; nothing deploys from this branch, so this is the end of its path.

Merged

quill/arm:quill/635-p0-sign-in-works-while-the-workshop-is-c → main 2 commits fork

  • Merged. quill/635-p0-sign-in-works-while-the-workshop-is-c was merged into main as 888bdc7.

Review

Sign in to review this change.
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
3434
3535 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.
3636
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).
3838
3939 The switch also works outside the app:
4040
Software/so101/src/lobby.ts +2 −2
@@ -259,8 +259,8 @@ export class Lobby {
259259 }
260260
261261 /** 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 };
264264 if (this.local) return null;
265265 if (!who) return { status: 401, error: "join to take control" };
266266 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", () => {
159159 expect(lobby.turns.driver).toBeNull();
160160 expect(lobby.takeControl(ann)).toBe("closed");
161161 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 });
163163 expect(lobby.refusal(owner)?.status).toBe(503);
164164 expect(lobby.admin.log.at(-1)?.what).toBe('closed the arm: "Lights off in the shop"');
165165 lobby.setOpen(owner, { open: true });
Software/so101/src/relay.ts +30 −8
@@ -62,9 +62,11 @@
6262 * Resting (the owner closed it, or a real arm unheard for two minutes; src/opening.ts): / is the resting card
6363 * (src/resting-page.ts), /status answers {"open": false, "reason", "note", "reopenAt", "lastOpenAt"} without asking the
6464 * 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.
6870 *
6971 * GET|PUT|DELETE /leader/calibration the signed-in person's own leader calibration (src/leader-map.js), any time,
7072 * 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"])
257259 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"]);
258260 /** The driver's, and any admin's at any time. */
259261 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 }
262273 /** What still goes to the arm server while the link is reconnecting: stopping it. */
263274 const STOPS = new Set(["POST /stop", "POST /estop"]);
264275 const RECONNECTING = "reconnecting to the arm";
@@ -1185,6 +1196,17 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
11851196 res.setHeader("allow", "POST");
11861197 return json(res, 405, { error: "POST to sign out" });
11871198 }
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" });
11881210 if (url.pathname === "/join") {
11891211 if (req.method === "POST") return await join(req, res);
11901212 res.setHeader("allow", "POST");
@@ -1217,7 +1239,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
12171239 : ADMIN_TOO.has(route) && who?.owner
12181240 ? null
12191241 : 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 } : {}) });
12211243 by = who!.name;
12221244 }
12231245 // Public on purpose, like /link: timing aggregates only (README, "Metrics").
@@ -1261,8 +1283,8 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
12611283 if (req.method === "GET" && PAGE.test(url.pathname)) return await page(req, res, await ask(simulation + path.replace(/^\/arm(?=$|\?)/, "/"), req, undefined, null, false));
12621284 if (req.method === "GET" && STATIC.test(url.pathname) && acceptsGzip(req.headers)) return await packed(res, await ask(simulation + path, req, undefined, null, false), path);
12631285 // 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 });
12661288 if (route === "POST /stop" || route === "POST /estop") facts?.event("STOP", lobby ? lobby.who(req)?.id : null);
12671289 if (route === "POST /stop") {
12681290 // 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";
99 import { sign } from "./identity.ts";
1010 import { CameraHub } from "./cameras.ts";
1111 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";
1313 import { OWNER_SIGNIN, REPO_URL, RESTING_CSP, RESTING_STYLE, STORY_PATH, renderResting } from "./resting-page.ts";
1414 import { startServer, type RunningServer } from "./server.ts";
1515
@@ -161,6 +161,49 @@ describe("closed, the owner is never locked out (#578)", () => {
161161 expect((await fetch(`${base}/admin`)).status).toBe(404); // anonymous: the page is still nobody's business
162162 });
163163
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
164207 test("a signed-in owner reaches /admin, and the card shows Open now, which opens it and goes back to the arm", async () => {
165208 const base = await boot();
166209 await fetch(`${base}/admin`, form({ action: "close", note: "Lights off" }));

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs