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,81 @@
// Email/password auth + the sign-in page (which also hosts passkey + CoinPay buttons).
import { Router } from "express";
import { page, footer, esc } from "../lib/html.mjs";
import { csrfInput, createSession, destroySession, takeNext } from "../lib/session.mjs";
import { hashPassword, verifyPassword } from "../lib/crypto.mjs";
import { createUserWithPassword, userByEmail } from "../lib/users.mjs";
import { dashboardHandler } from "./pages.mjs";
import { config } from "../config.mjs";
export const authRouter = Router();
function authPage(req, { error = "", mode = "in" } = {}) {
const body = `
<main class="wrap" style="max-width:440px;padding-top:8vh">
<a class="brand" href="/" style="justify-content:center;font-size:1.5rem;margin-bottom:6px"><span class="mark">LS</span>LogicSRC<span class="app">credentials</span></a>
<p class="label" style="text-align:center;margin-bottom:26px">Share secrets, end-to-end encrypted</p>
<div class="card"><div class="card-body">
${error ? `<div class="notice err">${esc(error)}</div>` : ""}
<form method="post" action="/auth/${mode === "up" ? "register" : "login"}">
${csrfInput(req)}
<label class="field"><span>Email</span>
<input type="email" name="email" autocomplete="username" required placeholder="you@example.com" value="${esc(req.query.email || "")}"></label>
<label class="field"><span>Password</span>
<input type="password" name="password" autocomplete="${mode === "up" ? "new-password" : "current-password"}" required minlength="8" placeholder="8+ characters"></label>
<button class="btn acid block" type="submit">${mode === "up" ? "Create account" : "Sign in"}</button>
</form>
<p class="mono" style="text-align:center;font-size:.74rem;margin:14px 0 0">
${mode === "up"
? `Already have an account? <a class="acid" href="/?mode=in">Sign in</a>`
: `New here? <a class="acid" href="/?mode=up">Create account</a>`}
</p>
<div class="divider">or</div>
<button class="btn block" type="button" id="passkey-btn" style="margin-bottom:10px">🔑 Continue with a passkey</button>
${config.coinpayLoginEnabled
? `<a class="btn block" href="/auth/coinpay/start">◆ Continue with CoinPay</a>`
: `<button class="btn block" type="button" disabled title="Set COINPAY_OAUTH_* to enable">◆ Continue with CoinPay</button>`}
<p id="passkey-msg" class="mono faint" style="font-size:.72rem;text-align:center;margin:12px 0 0"></p>
</div></div>
</main>${footer}
<script src="/vendor/simplewebauthn-browser.umd.js"></script>
<script src="/passkey.js"></script>`;
return page({ title: "LogicSRC ▸ sign in", body });
}
authRouter.get("/", (req, res) => {
if (req.user) {
const next = takeNext(req, res);
if (next) return res.redirect(next);
return dashboardHandler(req, res); // dashboard lives at the root
}
res.type("html").send(authPage(req, { mode: req.query.mode === "up" ? "up" : "in" }));
});
authRouter.post("/auth/register", async (req, res) => {
const email = String(req.body.email || "").trim().toLowerCase();
const password = String(req.body.password || "");
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return res.type("html").send(authPage(req, { mode: "up", error: "Enter a valid email." }));
if (password.length < 8) return res.type("html").send(authPage(req, { mode: "up", error: "Password must be at least 8 characters." }));
if (await userByEmail(email)) return res.type("html").send(authPage(req, { mode: "up", error: "That email already has an account — sign in." }));
const user = await createUserWithPassword(email, hashPassword(password));
await createSession(res, user.id);
res.redirect(takeNext(req, res) || "/");
});
authRouter.post("/auth/login", async (req, res) => {
const email = String(req.body.email || "").trim().toLowerCase();
const password = String(req.body.password || "");
const user = await userByEmail(email);
if (!user || !verifyPassword(password, user.password_hash)) {
return res.type("html").send(authPage(req, { mode: "in", error: "Wrong email or password." }));
}
await createSession(res, user.id);
res.redirect(takeNext(req, res) || "/");
});
authRouter.post("/auth/logout", async (req, res) => {
await destroySession(req, res);
res.redirect("/");
});