feat(pwa): logicsrc credentials app — real auth + Turso, redesigned; retire commandboard-api credshare
Some checks failed
CI / build (push) Has been cancelled
test / test (push) Has been cancelled

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:
Anthony Ettinger 2026-07-13 14:29:29 +00:00
parent f057589d66
commit 9ba044577f
46 changed files with 2785 additions and 1730 deletions

View 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 &amp; 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 &amp; 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 });
});