mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-14 14:57:28 +00:00
feat(pwa): logicsrc credentials app — real auth + Turso, redesigned; retire commandboard-api credshare
Adds apps/pwa: an Express + libSQL/Turso app that is now the home of team credential sharing, with the moshcode-style auth stack ported and reskinned to match logicsrc.com (light theme, Inter, green accent). apps/pwa - auth: email/password (scrypt), passkeys (WebAuthn), CoinPay OAuth, cookie sessions, and lsk_ API keys for the CLI via a loopback OAuth-PKCE flow (/cli/authorize + /cli/token). Ported from the moshcode PWA. - credshare API (/api/credshare/*): teams, members, invites, vaults, sealed grants, ciphertext secrets, audit — authed by session OR Bearer lsk_ key. Zero-knowledge: only ciphertext + sealed vault keys + public keys stored. - teams dashboard, accept-invite, and settings (API keys) pages, server-rendered in the LogicSRC brand (lib/html.mjs). - migrations (libSQL) 001_auth + 002_credshare, migrate-on-boot; Turso via TURSO_DATABASE_URL / TURSO_AUTH_TOKEN, or a local file db for dev. - trimmed moshcode-specific approvals/credits/push/deliver. CLI - `logicsrc login` now does browser loopback OAuth-PKCE against the app and stores an lsk_ token (email-OTP removed); --token for CI. Client repointed. Distribution - install.sh (served at logicsrc.com/install.sh) installs the CLI from the GitHub repo: tarball -> npm install -> `npm run build:cli` -> logicsrc wrapper. - root build:cli builds only the CLI's workspace chain (skips web/api/next). Cleanup - removed the commandboard-api credshare backend (superseded by the PWA) and its Supabase/Turso stores + libsql dep; commandboard-api tests green (40). - removed the Next.js /teams page (the PWA is the web UI now). Verified end-to-end: two accounts register on the PWA, mint lsk_ keys, CLI login uploads identity keys, owner pushes an encrypted .env, teammate invited -> accepted -> granted -> pulls the exact file. Server stores ciphertext only. Full workspace build + tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f057589d66
commit
9ba044577f
46 changed files with 2785 additions and 1730 deletions
85
apps/pwa/src/routes/cli.mjs
Normal file
85
apps/pwa/src/routes/cli.mjs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
// `logicsrc login` OAuth-style flow (authorization code + PKCE + loopback):
|
||||
// GET /cli/authorize browser lands here (login required) → approve page
|
||||
// POST /cli/authorize approve → mint a code, redirect to the CLI's loopback
|
||||
// POST /cli/token CLI exchanges code + verifier → an lsk_ API key (bearer)
|
||||
// GET /api/me Bearer → who am I (for `logicsrc whoami`)
|
||||
import { Router } from "express";
|
||||
import crypto from "node:crypto";
|
||||
import { get, run } from "../db.mjs";
|
||||
import { token } from "../lib/crypto.mjs";
|
||||
import { page, footer, appBar, esc } from "../lib/html.mjs";
|
||||
import { requireAuth, csrfInput } from "../lib/session.mjs";
|
||||
import { createApiKey, bearer, userForApiKey } from "../lib/apikey.mjs";
|
||||
|
||||
export const cliRouter = Router();
|
||||
|
||||
// Only loopback redirect URIs are allowed (the CLI listens on 127.0.0.1).
|
||||
function loopbackOk(uri) {
|
||||
try {
|
||||
const u = new URL(uri);
|
||||
return u.protocol === "http:" && (u.hostname === "127.0.0.1" || u.hostname === "localhost");
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
cliRouter.get("/cli/authorize", requireAuth, (req, res) => {
|
||||
const { redirect_uri, state, code_challenge } = req.query;
|
||||
if (!loopbackOk(redirect_uri) || !state || !code_challenge) {
|
||||
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>` }));
|
||||
}
|
||||
const name = String(req.query.name || "logicsrc cli").slice(0, 40);
|
||||
const body = `${appBar(req.user)}
|
||||
<main class="wrap" style="max-width:460px;padding-top:8vh">
|
||||
<div class="card"><div class="card-body" style="text-align:center">
|
||||
<div style="font-size:2rem">🔑</div>
|
||||
<h1 style="font-size:1.4rem;margin:10px 0">Authorize the LogicSRC CLI</h1>
|
||||
<p class="dim mono" style="font-size:.82rem">Grant <b class="green">${esc(name)}</b> on this machine access to manage teams & encrypted credentials as <b>${esc(req.user.email || req.user.display_name)}</b>.</p>
|
||||
<form method="post" action="/cli/authorize" style="margin-top:18px">
|
||||
${csrfInput(req)}
|
||||
<input type="hidden" name="redirect_uri" value="${esc(redirect_uri)}">
|
||||
<input type="hidden" name="state" value="${esc(state)}">
|
||||
<input type="hidden" name="code_challenge" value="${esc(code_challenge)}">
|
||||
<input type="hidden" name="name" value="${esc(name)}">
|
||||
<button class="btn acid block" type="submit">Authorize & connect</button>
|
||||
</form>
|
||||
<p class="faint mono" style="font-size:.72rem;margin-top:12px">You'll return to your terminal.</p>
|
||||
</div></div>
|
||||
</main>${footer}`;
|
||||
res.type("html").send(page({ title: "LogicSRC ▸ authorize CLI", body }));
|
||||
});
|
||||
|
||||
cliRouter.post("/cli/authorize", requireAuth, async (req, res) => {
|
||||
const { redirect_uri, state, code_challenge, name } = req.body;
|
||||
if (!loopbackOk(redirect_uri) || !state || !code_challenge) return res.status(400).send("bad request");
|
||||
const code = token(24);
|
||||
const now = Date.now();
|
||||
await run(
|
||||
`INSERT INTO cli_auth_codes (code,user_id,code_challenge,redirect_uri,name,created_at,expires_at) VALUES (?,?,?,?,?,?,?)`,
|
||||
[code, req.user.id, code_challenge, redirect_uri, String(name || "cli").slice(0, 40), now, now + 5 * 60 * 1000]
|
||||
);
|
||||
const u = new URL(redirect_uri);
|
||||
u.searchParams.set("code", code);
|
||||
u.searchParams.set("state", state);
|
||||
res.redirect(u.toString());
|
||||
});
|
||||
|
||||
cliRouter.post("/cli/token", async (req, res) => {
|
||||
const { code, code_verifier } = req.body || {};
|
||||
if (!code || !code_verifier) return res.status(400).json({ error: "code and code_verifier required" });
|
||||
const row = await get(`SELECT * FROM cli_auth_codes WHERE code = ?`, [code]);
|
||||
if (!row || row.used || row.expires_at < Date.now()) return res.status(400).json({ error: "invalid or expired code" });
|
||||
|
||||
// PKCE: base64url(sha256(verifier)) must equal the stored challenge
|
||||
const challenge = crypto.createHash("sha256").update(String(code_verifier)).digest("base64url");
|
||||
if (challenge !== row.code_challenge) return res.status(400).json({ error: "PKCE verification failed" });
|
||||
|
||||
await run(`UPDATE cli_auth_codes SET used = 1 WHERE code = ?`, [code]);
|
||||
const user = await get(`SELECT * FROM users WHERE id = ?`, [row.user_id]);
|
||||
const { plaintext } = await createApiKey(user.id, row.name || "logicsrc cli");
|
||||
res.json({ access_token: plaintext, token_type: "bearer", user: { id: user.id, email: user.email || null, name: user.display_name } });
|
||||
});
|
||||
|
||||
cliRouter.get("/api/me", async (req, res) => {
|
||||
const user = await userForApiKey(bearer(req));
|
||||
if (!user) return res.status(401).json({ error: "invalid or missing API key" });
|
||||
res.json({ id: user.id, email: user.email || null, name: user.display_name });
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue