mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-13 14:37:26 +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
|
|
@ -78,19 +78,11 @@ program.action(async (options) => {
|
|||
|
||||
program
|
||||
.command("login")
|
||||
.option("--email <email>", "Email to log in with (LogicSRC team credential sharing)")
|
||||
.option("--code <code>", "Login code (skip the interactive prompt)")
|
||||
.option("--did <did>", "CoinPay DID (legacy)")
|
||||
.option("--oauth <provider>", "OAuth provider (legacy)")
|
||||
.description("Log in by email for team credential sharing (registers your device identity key).")
|
||||
.option("--api-url <url>", "LogicSRC app URL", process.env.LOGICSRC_API_URL || process.env.COMMANDBOARD_API_URL)
|
||||
.option("--token <lsk_key>", "Use an existing API key instead of the browser flow (CI)")
|
||||
.description("Log in via your browser (loopback OAuth) for team credential sharing; registers your device identity key.")
|
||||
.action(async (options) => {
|
||||
if (!options.email && (options.did || options.oauth)) {
|
||||
const mode = options.did ? `CoinPay DID ${options.did}` : `${options.oauth} OAuth`;
|
||||
console.log(`Login flow ready: ${mode}`);
|
||||
console.log("Token storage target: $HOME/.logicsrc/identity.json");
|
||||
return;
|
||||
}
|
||||
await loginAction({ email: options.email, code: options.code });
|
||||
await loginAction({ apiUrl: options.apiUrl, token: options.token });
|
||||
});
|
||||
|
||||
program.command("logout").description("Clear local auth token (keeps your identity key).").action(async () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { createInterface } from "node:readline/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { hostname } from "node:os";
|
||||
import { spawn } from "node:child_process";
|
||||
import {
|
||||
TeamClient,
|
||||
TeamApiError,
|
||||
|
|
@ -26,15 +29,79 @@ function authedClient(): { client: TeamClient; identity: ReturnType<typeof requi
|
|||
return { client, identity };
|
||||
}
|
||||
|
||||
async function prompt(question: string): Promise<string> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
||||
const b64url = (buf: Buffer): string => buf.toString("base64url");
|
||||
|
||||
function openBrowser(url: string): void {
|
||||
const [cmd, args] =
|
||||
process.platform === "darwin" ? ["open", [url]]
|
||||
: process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
|
||||
: ["xdg-open", [url]];
|
||||
try {
|
||||
return (await rl.question(question)).trim();
|
||||
} finally {
|
||||
rl.close();
|
||||
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
||||
child.on("error", () => {});
|
||||
child.unref();
|
||||
} catch {
|
||||
/* print fallback below */
|
||||
}
|
||||
}
|
||||
|
||||
const DONE_PAGE = (msg: string) =>
|
||||
`<!doctype html><meta charset=utf-8><body style="background:#f6f7f4;color:#101418;font-family:system-ui,sans-serif;text-align:center;padding:16vh 24px"><h1 style="color:#0a7d59">${msg}</h1><p>Return to your terminal — you can close this tab.</p></body>`;
|
||||
|
||||
/** Browser OAuth-PKCE loopback login against the LogicSRC app → an lsk_ token. */
|
||||
function loopbackLogin(apiUrl: string, timeoutMs = 180000): Promise<{ token: string; email: string | null; userId?: string }> {
|
||||
const verifier = b64url(randomBytes(32));
|
||||
const challenge = b64url(createHash("sha256").update(verifier).digest());
|
||||
const state = b64url(randomBytes(16));
|
||||
const base = apiUrl.replace(/\/+$/, "");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (url.pathname !== "/callback") {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const code = url.searchParams.get("code");
|
||||
if (url.searchParams.get("error")) throw new Error(`authorization denied (${url.searchParams.get("error")})`);
|
||||
if (!code || url.searchParams.get("state") !== state) throw new Error("bad authorization response (state mismatch)");
|
||||
const tokRes = await fetch(`${base}/cli/token`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ code, code_verifier: verifier })
|
||||
});
|
||||
if (!tokRes.ok) throw new Error(`token exchange failed (${tokRes.status})`);
|
||||
const tok = (await tokRes.json()) as { access_token: string; user?: { email?: string; id?: string } };
|
||||
res.writeHead(200, { "content-type": "text/html" }).end(DONE_PAGE("You're in."));
|
||||
server.close();
|
||||
resolve({ token: tok.access_token, email: tok.user?.email ?? null, userId: tok.user?.id });
|
||||
} catch (error) {
|
||||
res.writeHead(400, { "content-type": "text/html" }).end(DONE_PAGE("Login failed — check the terminal."));
|
||||
server.close();
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const { port } = server.address() as { port: number };
|
||||
const authUrl = `${base}/cli/authorize?` + new URLSearchParams({
|
||||
redirect_uri: `http://127.0.0.1:${port}/callback`,
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
name: `logicsrc cli @ ${hostname()}`
|
||||
});
|
||||
console.error("\n🔑 Opening your browser to authorize the LogicSRC CLI…");
|
||||
console.error(` If it doesn't open, visit:\n ${authUrl}\n`);
|
||||
openBrowser(authUrl);
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => { server.close(); reject(new Error("login timed out — run `logicsrc login` again")); }, timeoutMs);
|
||||
server.on("close", () => clearTimeout(timer));
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveVaultId(client: TeamClient, slug: string, vault: string): Promise<string> {
|
||||
const { vaults } = await client.listVaults(slug);
|
||||
const found = vaults.find((v) => v.name === vault);
|
||||
|
|
@ -42,42 +109,37 @@ async function resolveVaultId(client: TeamClient, slug: string, vault: string):
|
|||
return found.id;
|
||||
}
|
||||
|
||||
export async function loginAction(options: { email?: string; code?: string }): Promise<void> {
|
||||
export async function loginAction(options: { apiUrl?: string; token?: string }): Promise<void> {
|
||||
const identity = await loadOrCreateIdentity();
|
||||
const email = options.email ?? (await prompt("Email: "));
|
||||
if (!email) throw new Error("An email is required: logicsrc login --email you@example.com");
|
||||
const client = new TeamClient({ apiUrl: identity.apiUrl || defaultApiUrl() });
|
||||
const apiUrl = (options.apiUrl || identity.apiUrl || defaultApiUrl()).replace(/\/+$/, "");
|
||||
|
||||
const requested = await client.requestLoginCode(email);
|
||||
let code = options.code;
|
||||
if (requested.devCode) {
|
||||
// No email transport configured server-side (local dev) — the code is returned.
|
||||
console.error(`(dev) login code: ${requested.devCode}`);
|
||||
code = code ?? requested.devCode;
|
||||
// Loopback browser OAuth-PKCE (like `moshcode login`), or a --token for CI.
|
||||
let token = options.token;
|
||||
let email: string | null = null;
|
||||
let userId: string | undefined;
|
||||
if (token) {
|
||||
const client = new TeamClient({ apiUrl, token });
|
||||
const me = await client.me();
|
||||
email = me.user.email;
|
||||
userId = me.user.id;
|
||||
} else {
|
||||
const result = await loopbackLogin(apiUrl);
|
||||
token = result.token;
|
||||
email = result.email;
|
||||
userId = result.userId;
|
||||
}
|
||||
if (!code) code = await prompt(`Enter the 6-digit code sent to ${email}: `);
|
||||
|
||||
const verified = await client.verifyLoginCode(email, code);
|
||||
client.setToken(verified.token);
|
||||
const client = new TeamClient({ apiUrl, token: token! });
|
||||
await client.uploadPublicKey(identity.keys.publicKey);
|
||||
await updateIdentity({ email: verified.user.email, userId: verified.user.id, apiToken: verified.token, apiUrl: identity.apiUrl || defaultApiUrl() });
|
||||
await updateIdentity({ email: email ?? undefined, userId, apiToken: token, apiUrl });
|
||||
|
||||
console.error(`Logged in as ${verified.user.email}. Identity key registered.`);
|
||||
print({ email: verified.user.email, userId: verified.user.id, apiUrl: identity.apiUrl || defaultApiUrl() }, "table");
|
||||
console.error(`Logged in${email ? ` as ${email}` : ""}. Identity key registered on ${apiUrl}.`);
|
||||
print({ email, apiUrl }, "table");
|
||||
}
|
||||
|
||||
export async function logoutAction(): Promise<void> {
|
||||
const identity = readIdentity();
|
||||
if (identity?.apiToken) {
|
||||
try {
|
||||
const client = new TeamClient({ apiUrl: identity.apiUrl || defaultApiUrl(), token: identity.apiToken });
|
||||
await client.logout();
|
||||
} catch {
|
||||
// best effort — token may already be gone
|
||||
}
|
||||
}
|
||||
await updateIdentity({ apiToken: undefined, email: undefined, userId: undefined });
|
||||
console.error("Logged out. Local identity key retained (delete ~/.logicsrc/identity.json to remove it).");
|
||||
console.error("Logged out (local token cleared; revoke the key at /settings). Identity key retained — delete ~/.logicsrc/identity.json to remove it.");
|
||||
}
|
||||
|
||||
export async function whoamiAction(format: OutputFormat): Promise<void> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue