mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-03 13:17:07 +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
86
apps/pwa/src/lib/session.mjs
Normal file
86
apps/pwa/src/lib/session.mjs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// Cookie sessions + auth middleware + CSRF (double-submit).
|
||||
import { get, run } from "../db.mjs";
|
||||
import { id, token, sign, unsign } from "./crypto.mjs";
|
||||
import { config } from "../config.mjs";
|
||||
|
||||
const COOKIE = "mc_sess";
|
||||
const CSRF = "mc_csrf";
|
||||
const TTL = 1000 * 60 * 60 * 24 * 30; // 30 days
|
||||
|
||||
function cookieOpts(extra = {}) {
|
||||
return { httpOnly: true, sameSite: "lax", secure: config.secure, path: "/", ...extra };
|
||||
}
|
||||
|
||||
export async function createSession(res, userId) {
|
||||
const t = token();
|
||||
const now = Date.now();
|
||||
await run(`INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?,?,?,?)`,
|
||||
[t, userId, now, now + TTL]);
|
||||
res.cookie(COOKIE, t, cookieOpts({ maxAge: TTL }));
|
||||
}
|
||||
|
||||
export async function destroySession(req, res) {
|
||||
const t = req.cookies?.[COOKIE];
|
||||
if (t) await run(`DELETE FROM sessions WHERE token = ?`, [t]);
|
||||
res.clearCookie(COOKIE, cookieOpts());
|
||||
}
|
||||
|
||||
// Attach req.user (or null) from the session cookie, and ensure a CSRF token.
|
||||
export async function sessionMiddleware(req, res, next) {
|
||||
req.user = null;
|
||||
const t = req.cookies?.[COOKIE];
|
||||
if (t) {
|
||||
const row = await get(
|
||||
`SELECT u.* FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ?`,
|
||||
[t, Date.now()]
|
||||
);
|
||||
if (row) req.user = row;
|
||||
else res.clearCookie(COOKIE, cookieOpts());
|
||||
}
|
||||
// double-submit CSRF token
|
||||
let csrf = req.cookies?.[CSRF];
|
||||
if (!csrf) { csrf = token(16); res.cookie(CSRF, csrf, cookieOpts({ httpOnly: false, maxAge: TTL })); }
|
||||
req.csrfToken = csrf;
|
||||
next();
|
||||
}
|
||||
|
||||
export function requireAuth(req, res, next) {
|
||||
if (!req.user) { setNext(res, req.originalUrl); return res.redirect("/"); }
|
||||
next();
|
||||
}
|
||||
|
||||
// Remember where to go after login (safe local paths only), across any auth method.
|
||||
export function setNext(res, pathname) {
|
||||
if (typeof pathname === "string" && pathname.startsWith("/") && !pathname.startsWith("//")) {
|
||||
res.cookie("mc_next", sign(pathname), cookieOpts({ maxAge: 1000 * 60 * 10 }));
|
||||
}
|
||||
}
|
||||
export function takeNext(req, res) {
|
||||
const p = unsign(req.cookies?.mc_next);
|
||||
if (req.cookies?.mc_next) res.clearCookie("mc_next", cookieOpts());
|
||||
return typeof p === "string" && p.startsWith("/") && !p.startsWith("//") ? p : null;
|
||||
}
|
||||
|
||||
// CSRF guard for unsafe methods on browser (form) routes. API/webhooks are Bearer/HMAC.
|
||||
export function csrfGuard(req, res, next) {
|
||||
if (["GET", "HEAD", "OPTIONS"].includes(req.method)) return next();
|
||||
// machine endpoints are Bearer/HMAC/PKCE-authenticated, not cookie sessions
|
||||
if (req.path.startsWith("/api/") || req.path.startsWith("/webhooks/") ||
|
||||
req.path === "/cli/token" || req.path.startsWith("/cli/device/")) return next();
|
||||
const sent = req.body?._csrf || req.get("x-csrf-token");
|
||||
if (!sent || sent !== req.cookies?.[CSRF]) return res.status(403).send("bad csrf token");
|
||||
next();
|
||||
}
|
||||
|
||||
export const csrfInput = (req) => `<input type="hidden" name="_csrf" value="${req.csrfToken}">`;
|
||||
|
||||
// ---- ephemeral auth-ceremony state (webauthn challenge / oauth pkce) in signed cookies ----
|
||||
export function setCeremony(res, name, value, ttlMs = 1000 * 60 * 5) {
|
||||
res.cookie(`mc_c_${name}`, sign({ v: value, exp: Date.now() + ttlMs }), cookieOpts({ maxAge: ttlMs }));
|
||||
}
|
||||
export function getCeremony(req, name) {
|
||||
const data = unsign(req.cookies?.[`mc_c_${name}`]);
|
||||
if (!data || data.exp < Date.now()) return null;
|
||||
return data.v;
|
||||
}
|
||||
export function clearCeremony(res, name) { res.clearCookie(`mc_c_${name}`, cookieOpts()); }
|
||||
Loading…
Add table
Add a link
Reference in a new issue