From abce643aea0bdef072b87ff75486dcade821e473 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 30 Jul 2026 17:56:26 +0000 Subject: [PATCH] fix(pwa): unbreak sign-out, and stop echoing $PUBLIC_ORIGIN in the CLI hint Two bugs on the dashboard, both fixed by handing appBar/CLI_HINT the request. Sign-out was broken for everyone. csrfGuard rejects any POST whose _csrf does not match the mc_csrf cookie, and /auth/logout is a POST that is not on the exempt list, but the sign-out form carried no hidden field -- every click answered 403 "bad csrf token". appBar now takes the request rather than the user, because it needs the token as well as the identity. The field is written out instead of reusing csrfInput(): html.mjs is the view layer and imports nothing, and pulling in session.mjs would drag the database driver with it. The "Connect the CLI" snippet still printed $PUBLIC_ORIGIN, so users on app.logicsrc.com were told to point LOGICSRC_API at the generated Railway hostname. #105 added requestOrigin() for exactly this and fixed the device-flow URLs; the dashboard hint was missed. It now follows the request too, which is not a hardcode swap -- the same deployment answering on its Railway hostname still self-describes correctly. Co-Authored-By: Claude Opus 5 (1M context) --- apps/pwa/src/lib/html.mjs | 12 +++++++++-- apps/pwa/src/routes/cli.mjs | 4 ++-- apps/pwa/src/routes/pages.mjs | 9 ++++---- apps/pwa/test/appbar.test.mjs | 39 +++++++++++++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 8 deletions(-) create mode 100644 apps/pwa/test/appbar.test.mjs diff --git a/apps/pwa/src/lib/html.mjs b/apps/pwa/src/lib/html.mjs index 85342d8..18508b7 100644 --- a/apps/pwa/src/lib/html.mjs +++ b/apps/pwa/src/lib/html.mjs @@ -101,14 +101,22 @@ ${head} `; } -export function appBar(user) { +// Takes the request, not just the user: signing out is a POST, and csrfGuard +// rejects any POST whose _csrf does not match the cookie. Without the hidden +// field here every "Sign out" click answered "bad csrf token". +// +// The field is written out rather than imported from session.mjs on purpose -- +// this module is the view layer and has no imports, and pulling in session.mjs +// would drag the database driver along with it. +export function appBar(req) { + const user = req?.user; return `
LSLogicSRCcredentials
${user ? `${esc(user.email || user.display_name || "signed in")} Settings -
` +
` : `Sign in`}
`; diff --git a/apps/pwa/src/routes/cli.mjs b/apps/pwa/src/routes/cli.mjs index f96e075..5558b95 100644 --- a/apps/pwa/src/routes/cli.mjs +++ b/apps/pwa/src/routes/cli.mjs @@ -36,7 +36,7 @@ cliRouter.get("/cli/authorize", requireAuth, (req, res) => { return res.status(400).type("html").send(page({ body: `

Bad CLI request

missing/invalid redirect_uri, state, or code_challenge.

` })); } const name = String(req.query.name || "logicsrc cli").slice(0, 40); - const body = `${appBar(req.user)} + const body = `${appBar(req)}
🔑
@@ -142,7 +142,7 @@ cliRouter.post("/cli/device/code", async (req, res) => { }); const devicePage = (req, body) => - page({ title: "LogicSRC â–¸ authorize CLI", body: `${appBar(req.user)}
${body}
${footer}` }); + page({ title: "LogicSRC â–¸ authorize CLI", body: `${appBar(req)}
${body}
${footer}` }); const deviceResult = (req, res, status, heading, detail) => res.status(status).type("html").send(devicePage(req, `
diff --git a/apps/pwa/src/routes/pages.mjs b/apps/pwa/src/routes/pages.mjs index 8677c28..f6fc3fd 100644 --- a/apps/pwa/src/routes/pages.mjs +++ b/apps/pwa/src/routes/pages.mjs @@ -7,6 +7,7 @@ import { id, token, sha256 } from "../lib/crypto.mjs"; import { page, footer, appBar, esc } from "../lib/html.mjs"; import { requireAuth, csrfInput } from "../lib/session.mjs"; import { createApiKey, listApiKeys, revokeApiKey } from "../lib/apikey.mjs"; +import { requestOrigin } from "../lib/origin.mjs"; import { config } from "../config.mjs"; export const pagesRouter = Router(); @@ -60,10 +61,10 @@ export async function dashboardHandler(req, res) { for (const t of teams) cards += await teamCard(t, uid); cards = cards.split(CSRF).join(csrfInput(req)); - const body = `${appBar(req.user)} + const body = `${appBar(req)}

Your teams

${teams.length}
- ${CLI_HINT(config.origin)} + ${CLI_HINT(requestOrigin(req, config.origin))} ${cards || `
You're not on any teams yet. Create one below or accept an invite.
`}
New team
${csrfInput(req)} @@ -109,7 +110,7 @@ pagesRouter.get("/teams/accept", requireAuth, (req, res) => { const tok = String(req.query.token || ""); const shared = req.query.shared; const err = req.query.err; - const body = `${appBar(req.user)} + const body = `${appBar(req)}

Accept team invite

@@ -143,7 +144,7 @@ pagesRouter.get("/settings", requireAuth, async (req, res) => { ${esc(k.name)} ${esc(k.prefix)}… ${csrfInput(req)}
`).join("") : `
no keys yet
`; - const body = `${appBar(req.user)} + const body = `${appBar(req)}

Settings

${newKey ? `
New API key (copy it now — shown once):
${esc(newKey)}
` : ""} diff --git a/apps/pwa/test/appbar.test.mjs b/apps/pwa/test/appbar.test.mjs new file mode 100644 index 0000000..b244b89 --- /dev/null +++ b/apps/pwa/test/appbar.test.mjs @@ -0,0 +1,39 @@ +// Signing out is a POST and csrfGuard rejects any POST whose _csrf does not +// match the mc_csrf cookie. The Sign out form shipped without that field, so +// every click answered "bad csrf token" and nobody could log out. These pin the +// hidden input in place. +import assert from "node:assert/strict"; +import test from "node:test"; + +import { appBar } from "../src/lib/html.mjs"; + +const req = (extra = {}) => ({ csrfToken: "deadbeefdeadbeef", user: { email: "a@example.com" }, ...extra }); + +test("the sign-out form carries the CSRF token", () => { + const html = appBar(req()); + assert.match(html, /action="\/auth\/logout"/); + assert.match(html, //); + // the field has to be inside the form, not merely somewhere on the page + const form = html.slice(html.indexOf('action="/auth/logout"')); + assert.ok( + form.indexOf('name="_csrf"') < form.indexOf(""), + "the _csrf input must be inside the sign-out form", + ); +}); + +test("signed-out visitors get no sign-out form at all", () => { + const html = appBar({ user: null, csrfToken: "x" }); + assert.doesNotMatch(html, /\/auth\/logout/); + assert.match(html, /Sign in/); +}); + +test("survives a request with no CSRF token rather than printing undefined", () => { + const html = appBar(req({ csrfToken: undefined })); + assert.match(html, /name="_csrf" value=""/); +}); + +test("the signed-in identity is escaped", () => { + const html = appBar(req({ user: { email: '' } })); + assert.doesNotMatch(html, /