Skip to content

Commit 50cc920

Browse files
ralyodioclaude
andcommitted
feat: device-code login (headless/CI) + push notification toggle
moshcode login --device: RFC 8628-style device flow — CLI prints a short XXXX-XXXX code + URL, you approve at app.moshcode.sh/device in ANY browser, CLI polls until authorized. No browser/loopback on the box, so it works on servers and CI. Auto-selected when stdin isn't a TTY. (migration 004) App: /cli/device/code, /cli/device/token (machine, CSRF-exempt), /device approve page; single-use codes; claimed→expired. Verified E2E. Dashboard push button is now a toggle: reflects whether THIS device is subscribed and flips between "Enable push" and "Disable notifications on this device" (+ /push/unsubscribe). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bd159e6 commit 50cc920

9 files changed

Lines changed: 197 additions & 30 deletions

File tree

apps/pwa/public/push.js

Lines changed: 43 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
/* Enable web-push for approvals. Subscribes this device and stores it server-side. */
1+
/* Web-push toggle for approvals. Reflects whether THIS device is subscribed:
2+
"Enable push on this device" ⇄ "Disable notifications on this device". */
23
(function () {
34
var btn = document.getElementById("push-btn");
45
if (!btn) return;
@@ -8,34 +9,55 @@
89
var m = document.cookie.match(/(?:^|; )mc_csrf=([^;]+)/);
910
return m ? decodeURIComponent(m[1]) : "";
1011
}
12+
function post(url, body) {
13+
return fetch(url, {
14+
method: "POST",
15+
headers: { "content-type": "application/json", "x-csrf-token": csrf() },
16+
body: JSON.stringify(body || {}),
17+
});
18+
}
1119
function urlB64ToUint8(base64) {
1220
var pad = "=".repeat((4 - (base64.length % 4)) % 4);
1321
var b64 = (base64 + pad).replace(/-/g, "+").replace(/_/g, "/");
14-
var raw = atob(b64);
15-
var out = new Uint8Array(raw.length);
22+
var raw = atob(b64), out = new Uint8Array(raw.length);
1623
for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
1724
return out;
1825
}
26+
function setState(on) {
27+
btn.dataset.on = on ? "1" : "0";
28+
btn.textContent = on ? "🔕 Disable notifications on this device" : "🔔 Enable push on this device";
29+
btn.classList.toggle("danger", on);
30+
}
31+
32+
async function enable() {
33+
var perm = await Notification.requestPermission();
34+
if (perm !== "granted") { btn.textContent = "permission denied"; return; }
35+
var reg = await navigator.serviceWorker.ready;
36+
var sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlB64ToUint8(VAPID) });
37+
var j = sub.toJSON();
38+
var r = await post("/push/subscribe", { endpoint: j.endpoint, p256dh: j.keys.p256dh, auth: j.keys.auth });
39+
if (r.ok) setState(true); else btn.textContent = "failed — retry";
40+
}
41+
async function disable() {
42+
var reg = await navigator.serviceWorker.ready;
43+
var sub = await reg.pushManager.getSubscription();
44+
if (sub) { await post("/push/unsubscribe", { endpoint: sub.endpoint }); await sub.unsubscribe(); }
45+
setState(false);
46+
}
1947

2048
btn.addEventListener("click", async function () {
21-
if (!("serviceWorker" in navigator) || !("PushManager" in window)) { btn.textContent = "push unsupported"; return; }
49+
if (!("serviceWorker" in navigator) || !("PushManager" in window)) { btn.disabled = true; btn.textContent = "push unsupported"; return; }
2250
if (!VAPID) { btn.textContent = "push not configured"; return; }
23-
btn.disabled = true; btn.textContent = "enabling…";
24-
try {
25-
var perm = await Notification.requestPermission();
26-
if (perm !== "granted") { btn.textContent = "permission denied"; btn.disabled = false; return; }
27-
var reg = await navigator.serviceWorker.ready;
28-
var sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlB64ToUint8(VAPID) });
29-
var json = sub.toJSON();
30-
var r = await fetch("/push/subscribe", {
31-
method: "POST",
32-
headers: { "content-type": "application/json", "x-csrf-token": csrf() },
33-
body: JSON.stringify({ endpoint: json.endpoint, p256dh: json.keys.p256dh, auth: json.keys.auth }),
34-
});
35-
btn.textContent = r.ok ? "✓ push enabled" : "failed — retry";
36-
btn.disabled = r.ok;
37-
} catch (e) {
38-
btn.textContent = "failed — retry"; btn.disabled = false;
39-
}
51+
btn.disabled = true;
52+
try { if (btn.dataset.on === "1") await disable(); else await enable(); }
53+
catch (e) { btn.textContent = "failed — retry"; }
54+
finally { btn.disabled = false; }
4055
});
56+
57+
// reflect current state on load
58+
(async function () {
59+
if (!("serviceWorker" in navigator) || !("PushManager" in window)) { btn.disabled = true; btn.textContent = "push unsupported here"; return; }
60+
try { var reg = await navigator.serviceWorker.ready; setState(!!(await reg.pushManager.getSubscription())); }
61+
catch (e) { /* leave default label */ }
62+
})();
4163
})();

apps/pwa/public/sw.js

Lines changed: 1 addition & 1 deletion
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-v2";
2+
const CACHE = "moshcode-v3";
33
const SHELL = ["/", "/icon.svg", "/manifest.webmanifest", "/passkey.js"];
44

55
self.addEventListener("install", (e) => {

apps/pwa/src/lib/session.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ export function takeNext(req, res) {
6565
export function csrfGuard(req, res, next) {
6666
if (["GET", "HEAD", "OPTIONS"].includes(req.method)) return next();
6767
// 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();
68+
if (req.path.startsWith("/api/") || req.path.startsWith("/webhooks/") ||
69+
req.path === "/cli/token" || req.path.startsWith("/cli/device/")) return next();
6970
const sent = req.body?._csrf || req.get("x-csrf-token");
7071
if (!sent || sent !== req.cookies?.[CSRF]) return res.status(403).send("bad csrf token");
7172
next();
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
-- Device-code login (RFC 8628 style) for headless / CI `moshcode login --device`.
2+
CREATE TABLE IF NOT EXISTS device_codes (
3+
device_code TEXT PRIMARY KEY, -- secret the CLI polls with
4+
user_code TEXT NOT NULL UNIQUE, -- short human code (XXXX-XXXX)
5+
user_id TEXT REFERENCES users(id) ON DELETE CASCADE, -- set on approval
6+
status TEXT NOT NULL DEFAULT 'pending', -- pending | approved | denied | claimed
7+
name TEXT,
8+
interval_s INTEGER NOT NULL DEFAULT 5,
9+
created_at INTEGER NOT NULL,
10+
expires_at INTEGER NOT NULL
11+
);
12+
CREATE INDEX IF NOT EXISTS idx_device_user_code ON device_codes(user_code);

apps/pwa/src/routes/cli.mjs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,19 @@ import { page, footer, appBar, esc } from "../lib/html.mjs";
1111
import { requireAuth, csrfInput } from "../lib/session.mjs";
1212
import { createApiKey, bearer, userForApiKey } from "../lib/apikey.mjs";
1313
import { balance } from "../lib/credits.mjs";
14+
import { config } from "../config.mjs";
1415

1516
export const cliRouter = Router();
1617

18+
// Unambiguous alphabet for the short human device code (no 0/O/1/I/L/vowels).
19+
const CODE_ALPHA = "BCDFGHJKMNPQRSTVWXYZ23456789";
20+
function makeUserCode() {
21+
let s = "";
22+
for (let i = 0; i < 8; i++) s += CODE_ALPHA[crypto.randomInt(CODE_ALPHA.length)];
23+
return `${s.slice(0, 4)}-${s.slice(4)}`;
24+
}
25+
const normCode = (s) => String(s || "").toUpperCase().replace(/[^A-Z0-9]/g, "").replace(/(.{4})(.{4})/, "$1-$2");
26+
1727
// Only loopback redirect URIs are allowed (the CLI listens on 127.0.0.1).
1828
function loopbackOk(uri) {
1929
try {
@@ -84,3 +94,71 @@ cliRouter.get("/api/me", async (req, res) => {
8494
if (!user) return res.status(401).json({ error: "invalid or missing API key" });
8595
res.json({ id: user.id, email: user.email || null, name: user.display_name, credits: await balance(user.id) });
8696
});
97+
98+
// ---- device-code flow (headless / CI: `moshcode login --device`) ----
99+
100+
// CLI asks for a code pair.
101+
cliRouter.post("/cli/device/code", async (req, res) => {
102+
const deviceCode = token(32);
103+
let userCode = makeUserCode();
104+
// avoid the astronomically-unlikely collision on the human code
105+
for (let i = 0; i < 3 && await get(`SELECT 1 FROM device_codes WHERE user_code = ?`, [userCode]); i++) userCode = makeUserCode();
106+
const now = Date.now();
107+
const interval = 5, ttl = 10 * 60 * 1000;
108+
await run(
109+
`INSERT INTO device_codes (device_code,user_code,status,name,interval_s,created_at,expires_at) VALUES (?,?,?,?,?,?,?)`,
110+
[deviceCode, userCode, "pending", String(req.body?.name || "moshcode cli").slice(0, 40), interval, now, now + ttl]
111+
);
112+
res.json({
113+
device_code: deviceCode,
114+
user_code: userCode,
115+
verification_uri: `${config.origin}/device`,
116+
verification_uri_complete: `${config.origin}/device?code=${encodeURIComponent(userCode)}`,
117+
expires_in: Math.floor(ttl / 1000),
118+
interval,
119+
});
120+
});
121+
122+
// The page a human opens to approve a device.
123+
cliRouter.get("/device", requireAuth, (req, res) => {
124+
const prefill = req.query.code ? normCode(req.query.code) : "";
125+
const done = req.query.done;
126+
const bad = req.query.bad;
127+
const body = `${appBar(req.user, 0)}
128+
<main class="wrap" style="max-width:440px;padding-top:8vh">
129+
<div class="card"><div class="card-body" style="text-align:center">
130+
<div style="font-size:2rem">🔑</div>
131+
<h1 style="font-size:1.4rem;margin:10px 0">Connect a device</h1>
132+
${done ? `<div class="notice ok">✓ device connected — return to your terminal 🤘</div>`
133+
: `<p class="dim mono" style="font-size:.82rem">Enter the code shown in your terminal to authorize the moshcode CLI as <b>${esc(req.user.email || req.user.display_name)}</b>.</p>
134+
${bad ? `<div class="notice err">that code is invalid or expired — check your terminal.</div>` : ""}
135+
<form method="post" action="/device" style="margin-top:14px">${csrfInput(req)}
136+
<input name="user_code" value="${esc(prefill)}" placeholder="XXXX-XXXX" autocomplete="off" autocapitalize="characters"
137+
style="text-align:center;font-size:1.3rem;letter-spacing:.2em;text-transform:uppercase" required>
138+
<button class="btn acid block" type="submit" style="margin-top:12px">Authorize 🤘</button>
139+
</form>`}
140+
</div></div>
141+
</main>${footer}`;
142+
res.type("html").send(page({ title: "moshcode ▸ connect device", body }));
143+
});
144+
145+
cliRouter.post("/device", requireAuth, async (req, res) => {
146+
const userCode = normCode(req.body.user_code);
147+
const row = await get(`SELECT * FROM device_codes WHERE user_code = ? AND status = 'pending' AND expires_at > ?`, [userCode, Date.now()]);
148+
if (!row) return res.redirect(`/device?bad=1${req.body.user_code ? "&code=" + encodeURIComponent(req.body.user_code) : ""}`);
149+
await run(`UPDATE device_codes SET status = 'approved', user_id = ? WHERE device_code = ?`, [req.user.id, row.device_code]);
150+
res.redirect("/device?done=1");
151+
});
152+
153+
// CLI polls here until approved.
154+
cliRouter.post("/cli/device/token", async (req, res) => {
155+
const row = await get(`SELECT * FROM device_codes WHERE device_code = ?`, [req.body?.device_code || ""]);
156+
if (!row || row.expires_at < Date.now() || row.status === "claimed") return res.status(400).json({ error: "expired_token" });
157+
if (row.status === "denied") return res.status(400).json({ error: "access_denied" });
158+
if (row.status !== "approved") return res.status(400).json({ error: "authorization_pending" });
159+
160+
await run(`UPDATE device_codes SET status = 'claimed' WHERE device_code = ?`, [row.device_code]);
161+
const user = await get(`SELECT * FROM users WHERE id = ?`, [row.user_id]);
162+
const { plaintext } = await createApiKey(user.id, row.name || "moshcode cli");
163+
res.json({ access_token: plaintext, token_type: "bearer", user: { id: user.id, email: user.email || null, name: user.display_name } });
164+
});

apps/pwa/src/routes/pages.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,8 @@ pagesRouter.post("/push/subscribe", requireAuth, async (req, res) => {
193193
[id(), req.user.id, endpoint, p256dh, auth, Date.now()]);
194194
res.json({ ok: true });
195195
});
196+
197+
pagesRouter.post("/push/unsubscribe", requireAuth, async (req, res) => {
198+
if (req.body?.endpoint) await run(`DELETE FROM push_subscriptions WHERE endpoint = ? AND user_id = ?`, [req.body.endpoint, req.user.id]);
199+
res.json({ ok: true });
200+
});

bin/moshcode.mjs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { runUpgrade } from "../src/upgrade.mjs";
1818
import { mcpCommand, skillCommand } from "../src/integrations.mjs";
1919
import { locate, tilde } from "../src/pwd.mjs";
2020
import { createPrd, listPrds, authoringPrompt } from "../src/prd.mjs";
21-
import { login, whoami, logout } from "../src/auth.mjs";
21+
import { login, loginDevice, whoami, logout } from "../src/auth.mjs";
2222
import { tui } from "../src/tui.mjs";
2323

2424
const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -122,8 +122,9 @@ usage:
122122
moshcode prd [idea] publish the next numbered PRD (OpenPRD) to
123123
prd/NNNN-slug.md and hand it to an engine to
124124
author; no arg lists existing PRDs
125-
moshcode login authenticate this machine with app.moshcode.sh
126-
(browser OAuth+PKCE) so notify()/ask() reach you
125+
moshcode login [--device] authenticate this machine with app.moshcode.sh
126+
(browser OAuth+PKCE; --device = headless/CI
127+
code flow) so notify()/ask() reach you
127128
moshcode whoami | logout show / clear the logged-in account
128129
moshcode pwd show the current dir + git repo/branch/origin
129130
moshcode engines list engines + install status
@@ -253,8 +254,11 @@ async function main() {
253254
return;
254255
}
255256
if (cmd === "login") {
256-
try { const { email } = await login(); console.log(`✓ logged in${email ? ` as ${email}` : ""} 🤘 — notify()/ask() will reach you now.`); }
257-
catch (e) { console.error(String(e.message || e)); process.exitCode = 1; }
257+
const device = rest.includes("--device") || rest.includes("-d") || !process.stdin.isTTY;
258+
try {
259+
const { email } = device ? await loginDevice() : await login();
260+
console.log(`✓ logged in${email ? ` as ${email}` : ""} 🤘 — notify()/ask() will reach you now.`);
261+
} catch (e) { console.error(String(e.message || e)); process.exitCode = 1; }
258262
return;
259263
}
260264
if (cmd === "whoami") { await whoami(); return; }

src/auth.mjs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,50 @@ export function login({ timeoutMs = 180000 } = {}) {
8888
});
8989
}
9090

91+
/**
92+
* Device-code login (headless / CI): no local browser or loopback needed. Prints
93+
* a short code + URL; you approve it in ANY browser; the CLI polls until done.
94+
*/
95+
export async function loginDevice({ open = true } = {}) {
96+
const startRes = await fetch(`${API()}/cli/device/code`, {
97+
method: "POST",
98+
headers: { "content-type": "application/json" },
99+
body: JSON.stringify({ name: `moshcode cli @ ${os.hostname()}` }),
100+
});
101+
if (!startRes.ok) throw new Error(`couldn't start device login (${startRes.status})`);
102+
const d = await startRes.json();
103+
104+
console.log(`\n🔑 to log in, open: ${d.verification_uri}`);
105+
console.log(` and enter code: \x1b[1m\x1b[38;5;154m${d.user_code}\x1b[0m\n`);
106+
if (open) openBrowser(d.verification_uri_complete);
107+
108+
const interval = Math.max(2, d.interval || 5) * 1000;
109+
const deadline = Date.now() + (d.expires_in || 600) * 1000;
110+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
111+
112+
console.log(" waiting for you to authorize…");
113+
for (;;) {
114+
if (Date.now() > deadline) throw new Error("code expired — run `moshcode login --device` again");
115+
await sleep(interval);
116+
let data;
117+
try {
118+
const r = await fetch(`${API()}/cli/device/token`, {
119+
method: "POST",
120+
headers: { "content-type": "application/json" },
121+
body: JSON.stringify({ device_code: d.device_code }),
122+
});
123+
data = await r.json();
124+
} catch { continue; } // transient network — keep polling
125+
if (data.access_token) {
126+
saveCreds({ api: API(), token: data.access_token, email: data.user?.email || null, id: data.user?.id });
127+
return { email: data.user?.email || null };
128+
}
129+
if (data.error === "access_denied") throw new Error("authorization denied");
130+
if (data.error === "expired_token") throw new Error("code expired — run `moshcode login --device` again");
131+
// authorization_pending / slow_down → keep waiting
132+
}
133+
}
134+
91135
/** Print who is logged in (verified against the app). */
92136
export async function whoami() {
93137
const creds = loadCreds();

src/tui.mjs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { TOOLS, resolveTool, toolStatus, openTool } from "./tools.mjs";
1212
import { runUpgrade } from "./upgrade.mjs";
1313
import { locate, tilde } from "./pwd.mjs";
1414
import { createPrd, listPrds, authoringPrompt } from "./prd.mjs";
15-
import { login, whoami, logout } from "./auth.mjs";
15+
import { login, loginDevice, whoami, logout } from "./auth.mjs";
1616
import { runScript } from "./runtime.mjs";
1717
import { moshVocabulary } from "./commands.mjs";
1818
import { mcpCommand, skillCommand } from "./integrations.mjs";
@@ -311,7 +311,8 @@ export async function tui() {
311311
if (cmd === "help" || cmd === "?" || cmd === "h") { printHelp(); continue; }
312312
if (cmd === "pwd" || cmd === "where") { printPwd(); continue; }
313313
if (cmd === "login") {
314-
try { const { email } = await login(); console.log(ok(`logged in${email ? ` as ${email}` : ""} 🤘`)); }
314+
const device = rest.includes("--device") || rest.includes("device") || rest.includes("-d");
315+
try { const { email } = device ? await loginDevice() : await login(); console.log(ok(`logged in${email ? ` as ${email}` : ""} 🤘`)); }
315316
catch (e) { console.log(err(String(e.message || e))); }
316317
continue;
317318
}

0 commit comments

Comments
 (0)