Skip to content

Commit bd159e6

Browse files
ralyodioclaude
andcommitted
feat(apps/pwa): CLI-login endpoints + dashboard at root (/)
- /cli/authorize (login-gated approve page), /cli/token (PKCE → API key), /api/me (bearer → whoami); migration 003 cli_auth_codes. Loopback-only redirect_uris; single-use codes; /cli/token exempt from CSRF. - post-login `next` cookie so the CLI authorize flow resumes after any auth method (email/passkey/coinpay). - dashboard now lives at the ROOT (/ = dashboard when signed in, sign-in when not); /dashboard alias; legacy /app → 301 /. Updated manifest start_url, appBar, sw fallbacks; sw cache v2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 41c685a commit bd159e6

12 files changed

Lines changed: 144 additions & 23 deletions

File tree

apps/pwa/public/manifest.webmanifest

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "moshcode",
33
"short_name": "moshcode",
44
"description": "Human-in-the-loop approvals for your moshscript loops.",
5-
"start_url": "/app",
5+
"start_url": "/",
66
"scope": "/",
77
"display": "standalone",
88
"background_color": "#070806",

apps/pwa/public/passkey.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
var att = await SimpleWebAuthnBrowser.startRegistration({ optionsJSON: opts });
2525
var r = await post("/auth/passkey/register/verify", att);
2626
var out = await r.json();
27-
if (out.ok) location.href = out.redirect || "/app";
27+
if (out.ok) location.href = out.redirect || "/";
2828
else say(out.error || "couldn't create passkey");
2929
}
3030

@@ -33,7 +33,7 @@
3333
var asr = await SimpleWebAuthnBrowser.startAuthentication({ optionsJSON: opts });
3434
var r = await post("/auth/passkey/login/verify", asr);
3535
var out = await r.json();
36-
if (out.ok) { location.href = out.redirect || "/app"; return true; }
36+
if (out.ok) { location.href = out.redirect || "/"; return true; }
3737
throw new Error(out.error || "sign-in failed");
3838
}
3939

apps/pwa/public/sw.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* moshcode PWA service worker — offline app shell (network-first for docs). */
2-
const CACHE = "moshcode-v1";
2+
const CACHE = "moshcode-v2";
33
const SHELL = ["/", "/icon.svg", "/manifest.webmanifest", "/passkey.js"];
44

55
self.addEventListener("install", (e) => {
@@ -20,14 +20,14 @@ self.addEventListener("push", (e) => {
2020
body: d.body || "You have an approval waiting.",
2121
icon: "/icon.svg",
2222
badge: "/icon.svg",
23-
data: { url: d.url || "/app" },
23+
data: { url: d.url || "/" },
2424
tag: "moshcode-approval",
2525
}));
2626
});
2727

2828
self.addEventListener("notificationclick", (e) => {
2929
e.notification.close();
30-
const url = (e.notification.data && e.notification.data.url) || "/app";
30+
const url = (e.notification.data && e.notification.data.url) || "/";
3131
e.waitUntil(clients.matchAll({ type: "window" }).then((cs) => {
3232
for (const c of cs) if ("focus" in c) { c.navigate(url); return c.focus(); }
3333
return clients.openWindow(url);

apps/pwa/src/lib/html.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ ${head}
102102

103103
export function appBar(user, balance) {
104104
return `<header class="bar"><div class="wrap bar-inner">
105-
<a class="brand" href="/app"><span class="mark">M</span>MOSHCODE<span class="app">app</span></a>
105+
<a class="brand" href="/"><span class="mark">M</span>MOSHCODE<span class="app">app</span></a>
106106
<div class="bar-right">
107107
${user ? `<span class="bal-chip">◆ <b>${balance.toLocaleString()}</b> cr</span>
108108
<a class="btn" href="/settings">Settings</a>
@@ -114,6 +114,6 @@ export function appBar(user, balance) {
114114

115115
export const footer = `<footer><div class="wrap foot">
116116
<div class="brand" style="font-size:.9rem"><span class="mark" style="width:18px;height:18px;font-size:.75rem">M</span>MOSHCODE</div>
117-
<div style="display:flex;gap:20px;flex-wrap:wrap"><a href="https://moshcode.sh">moshcode.sh</a><a href="/app">Approvals</a><a href="/settings">Settings</a></div>
117+
<div style="display:flex;gap:20px;flex-wrap:wrap"><a href="https://moshcode.sh">moshcode.sh</a><a href="/">Approvals</a><a href="/settings">Settings</a></div>
118118
<div class="metal">no bugs, only <b>features</b>. 🤘</div>
119119
</div></footer>`;

apps/pwa/src/lib/session.mjs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,27 @@ export async function sessionMiddleware(req, res, next) {
4545
}
4646

4747
export function requireAuth(req, res, next) {
48-
if (!req.user) return res.redirect("/?next=" + encodeURIComponent(req.originalUrl));
48+
if (!req.user) { setNext(res, req.originalUrl); return res.redirect("/"); }
4949
next();
5050
}
5151

52+
// Remember where to go after login (safe local paths only), across any auth method.
53+
export function setNext(res, pathname) {
54+
if (typeof pathname === "string" && pathname.startsWith("/") && !pathname.startsWith("//")) {
55+
res.cookie("mc_next", sign(pathname), cookieOpts({ maxAge: 1000 * 60 * 10 }));
56+
}
57+
}
58+
export function takeNext(req, res) {
59+
const p = unsign(req.cookies?.mc_next);
60+
if (req.cookies?.mc_next) res.clearCookie("mc_next", cookieOpts());
61+
return typeof p === "string" && p.startsWith("/") && !p.startsWith("//") ? p : null;
62+
}
63+
5264
// CSRF guard for unsafe methods on browser (form) routes. API/webhooks are Bearer/HMAC.
5365
export function csrfGuard(req, res, next) {
5466
if (["GET", "HEAD", "OPTIONS"].includes(req.method)) return next();
55-
if (req.path.startsWith("/api/") || req.path.startsWith("/webhooks/")) return next();
67+
// machine endpoints are Bearer/HMAC/PKCE-authenticated, not cookie sessions
68+
if (req.path.startsWith("/api/") || req.path.startsWith("/webhooks/") || req.path === "/cli/token") return next();
5669
const sent = req.body?._csrf || req.get("x-csrf-token");
5770
if (!sent || sent !== req.cookies?.[CSRF]) return res.status(403).send("bad csrf token");
5871
next();
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
-- Short-lived authorization codes for the `moshcode login` CLI flow (PKCE).
2+
CREATE TABLE IF NOT EXISTS cli_auth_codes (
3+
code TEXT PRIMARY KEY,
4+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
5+
code_challenge TEXT NOT NULL,
6+
redirect_uri TEXT NOT NULL,
7+
name TEXT,
8+
used INTEGER NOT NULL DEFAULT 0,
9+
created_at INTEGER NOT NULL,
10+
expires_at INTEGER NOT NULL
11+
);

apps/pwa/src/routes/auth.mjs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
// Email/password auth + the sign-in page (which also hosts passkey + CoinPay buttons).
22
import { Router } from "express";
33
import { page, footer, esc } from "../lib/html.mjs";
4-
import { csrfInput, createSession, destroySession } from "../lib/session.mjs";
4+
import { csrfInput, createSession, destroySession, takeNext } from "../lib/session.mjs";
55
import { hashPassword, verifyPassword } from "../lib/crypto.mjs";
66
import { createUserWithPassword, userByEmail } from "../lib/users.mjs";
7+
import { dashboardHandler } from "./pages.mjs";
78
import { config } from "../config.mjs";
89

910
export const authRouter = Router();
@@ -44,7 +45,11 @@ function authPage(req, { error = "", mode = "in" } = {}) {
4445
}
4546

4647
authRouter.get("/", (req, res) => {
47-
if (req.user) return res.redirect("/app");
48+
if (req.user) {
49+
const next = takeNext(req, res);
50+
if (next) return res.redirect(next);
51+
return dashboardHandler(req, res); // dashboard lives at the root
52+
}
4853
res.type("html").send(authPage(req, { mode: req.query.mode === "up" ? "up" : "in" }));
4954
});
5055

@@ -56,7 +61,7 @@ authRouter.post("/auth/register", async (req, res) => {
5661
if (await userByEmail(email)) return res.type("html").send(authPage(req, { mode: "up", error: "That email already has an account — sign in." }));
5762
const user = await createUserWithPassword(email, hashPassword(password));
5863
await createSession(res, user.id);
59-
res.redirect("/app");
64+
res.redirect(takeNext(req, res) || "/");
6065
});
6166

6267
authRouter.post("/auth/login", async (req, res) => {
@@ -67,7 +72,7 @@ authRouter.post("/auth/login", async (req, res) => {
6772
return res.type("html").send(authPage(req, { mode: "in", error: "Wrong email or password." }));
6873
}
6974
await createSession(res, user.id);
70-
res.redirect("/app");
75+
res.redirect(takeNext(req, res) || "/");
7176
});
7277

7378
authRouter.post("/auth/logout", async (req, res) => {

apps/pwa/src/routes/cli.mjs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// `moshcode login` OAuth-style flow (authorization code + PKCE + loopback):
2+
// GET /cli/authorize browser lands here (login required) → approve page
3+
// POST /cli/authorize approve → mint a code, redirect to the CLI's loopback
4+
// POST /cli/token CLI exchanges code + verifier → an API key (bearer)
5+
// GET /api/me Bearer → who am I (for `moshcode whoami`)
6+
import { Router } from "express";
7+
import crypto from "node:crypto";
8+
import { get, run } from "../db.mjs";
9+
import { id, token, sha256 } from "../lib/crypto.mjs";
10+
import { page, footer, appBar, esc } from "../lib/html.mjs";
11+
import { requireAuth, csrfInput } from "../lib/session.mjs";
12+
import { createApiKey, bearer, userForApiKey } from "../lib/apikey.mjs";
13+
import { balance } from "../lib/credits.mjs";
14+
15+
export const cliRouter = Router();
16+
17+
// Only loopback redirect URIs are allowed (the CLI listens on 127.0.0.1).
18+
function loopbackOk(uri) {
19+
try {
20+
const u = new URL(uri);
21+
return u.protocol === "http:" && (u.hostname === "127.0.0.1" || u.hostname === "localhost");
22+
} catch { return false; }
23+
}
24+
25+
cliRouter.get("/cli/authorize", requireAuth, (req, res) => {
26+
const { redirect_uri, state, code_challenge } = req.query;
27+
if (!loopbackOk(redirect_uri) || !state || !code_challenge) {
28+
return res.status(400).type("html").send(page({ body: `<main class="wrap" style="padding-top:12vh"><h1>Bad CLI request</h1><p class="dim mono">missing/invalid redirect_uri, state, or code_challenge.</p></main>` }));
29+
}
30+
const name = String(req.query.name || "moshcode cli").slice(0, 40);
31+
const body = `${appBar(req.user, 0)}
32+
<main class="wrap" style="max-width:460px;padding-top:8vh">
33+
<div class="card"><div class="card-body" style="text-align:center">
34+
<div style="font-size:2rem">🔑</div>
35+
<h1 style="font-size:1.4rem;margin:10px 0">Authorize the moshcode CLI</h1>
36+
<p class="dim mono" style="font-size:.82rem">Grant <b class="acid">${esc(name)}</b> on this machine access to create &amp; read approvals as <b>${esc(req.user.email || req.user.display_name)}</b>. This is how <span class="acid">notify()</span> / <span class="acid">ask()</span> reach you.</p>
37+
<form method="post" action="/cli/authorize" style="margin-top:18px">
38+
${csrfInput(req)}
39+
<input type="hidden" name="redirect_uri" value="${esc(redirect_uri)}">
40+
<input type="hidden" name="state" value="${esc(state)}">
41+
<input type="hidden" name="code_challenge" value="${esc(code_challenge)}">
42+
<input type="hidden" name="name" value="${esc(name)}">
43+
<button class="btn acid block" type="submit">Authorize &amp; connect 🤘</button>
44+
</form>
45+
<p class="faint mono" style="font-size:.72rem;margin-top:12px">You'll return to your terminal.</p>
46+
</div></div>
47+
</main>${footer}`;
48+
res.type("html").send(page({ title: "moshcode ▸ authorize CLI", body }));
49+
});
50+
51+
cliRouter.post("/cli/authorize", requireAuth, async (req, res) => {
52+
const { redirect_uri, state, code_challenge, name } = req.body;
53+
if (!loopbackOk(redirect_uri) || !state || !code_challenge) return res.status(400).send("bad request");
54+
const code = token(24);
55+
const now = Date.now();
56+
await run(
57+
`INSERT INTO cli_auth_codes (code,user_id,code_challenge,redirect_uri,name,created_at,expires_at) VALUES (?,?,?,?,?,?,?)`,
58+
[code, req.user.id, code_challenge, redirect_uri, String(name || "cli").slice(0, 40), now, now + 5 * 60 * 1000]
59+
);
60+
const u = new URL(redirect_uri);
61+
u.searchParams.set("code", code);
62+
u.searchParams.set("state", state);
63+
res.redirect(u.toString());
64+
});
65+
66+
cliRouter.post("/cli/token", async (req, res) => {
67+
const { code, code_verifier } = req.body || {};
68+
if (!code || !code_verifier) return res.status(400).json({ error: "code and code_verifier required" });
69+
const row = await get(`SELECT * FROM cli_auth_codes WHERE code = ?`, [code]);
70+
if (!row || row.used || row.expires_at < Date.now()) return res.status(400).json({ error: "invalid or expired code" });
71+
72+
// PKCE: base64url(sha256(verifier)) must equal the stored challenge
73+
const challenge = crypto.createHash("sha256").update(String(code_verifier)).digest("base64url");
74+
if (challenge !== row.code_challenge) return res.status(400).json({ error: "PKCE verification failed" });
75+
76+
await run(`UPDATE cli_auth_codes SET used = 1 WHERE code = ?`, [code]);
77+
const user = await get(`SELECT * FROM users WHERE id = ?`, [row.user_id]);
78+
const { plaintext } = await createApiKey(user.id, row.name || "moshcode cli");
79+
res.json({ access_token: plaintext, token_type: "bearer", user: { id: user.id, email: user.email || null, name: user.display_name } });
80+
});
81+
82+
cliRouter.get("/api/me", async (req, res) => {
83+
const user = await userForApiKey(bearer(req));
84+
if (!user) return res.status(401).json({ error: "invalid or missing API key" });
85+
res.json({ id: user.id, email: user.email || null, name: user.display_name, credits: await balance(user.id) });
86+
});

apps/pwa/src/routes/coinpay.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Router } from "express";
33
import crypto from "node:crypto";
44
import { config } from "../config.mjs";
55
import { token } from "../lib/crypto.mjs";
6-
import { createSession, setCeremony, getCeremony, clearCeremony } from "../lib/session.mjs";
6+
import { createSession, setCeremony, getCeremony, clearCeremony, takeNext } from "../lib/session.mjs";
77
import { userByCoinpay, createUserForCoinpay } from "../lib/users.mjs";
88

99
export const coinpayRouter = Router();
@@ -60,7 +60,7 @@ coinpayRouter.get("/auth/coinpay/callback", async (req, res) => {
6060
let user = await userByCoinpay(sub);
6161
if (!user) user = await createUserForCoinpay(sub, info.name || info.username);
6262
await createSession(res, user.id);
63-
res.redirect("/app");
63+
res.redirect(takeNext(req, res) || "/");
6464
} catch (e) {
6565
console.error("coinpay login failed:", e.message);
6666
res.redirect("/?err=coinpay-failed");

apps/pwa/src/routes/pages.mjs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ const timeago = (ts) => {
2020
};
2121

2222
// ---------- dashboard ----------
23-
pagesRouter.get("/app", requireAuth, async (req, res) => {
23+
// The dashboard lives at the root (/) when signed in; also reachable at /dashboard.
24+
export async function dashboardHandler(req, res) {
2425
const uid = req.user.id;
2526
const [bal, pending, resolved, led] = await Promise.all([
2627
balance(uid),
@@ -93,8 +94,11 @@ pagesRouter.get("/app", requireAuth, async (req, res) => {
9394
</div>
9495
</div></main>${footer}
9596
<script src="/push.js"></script>`;
96-
res.type("html").send(page({ title: "moshcode ▸ approvals", body }));
97-
});
97+
res.type("html").send(page({ title: "moshcode ▸ dashboard", body }));
98+
}
99+
100+
pagesRouter.get("/dashboard", requireAuth, dashboardHandler);
101+
pagesRouter.get("/app", (req, res) => res.redirect(301, "/")); // legacy → root
98102

99103
// ---------- settings ----------
100104
const CHANNEL_KINDS = ["push", "email", "slack", "telegram", "sms", "webhook"];

0 commit comments

Comments
 (0)