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

@ -34,14 +34,5 @@ SH1PT_WEBHOOK_SECRET=
# openssl rand -hex 32 # openssl rand -hex 32
BLOG_WEBHOOK_SECRET= BLOG_WEBHOOK_SECRET=
# --- Team credential sharing (commandboard-api /api/credshare) --- # Team credential sharing lives in its own app — see apps/pwa/.env.example
# Production storage. Without these, the API uses an in-memory store (dev/tests). # (Express + libSQL/Turso; auth + end-to-end-encrypted team vaults).
SUPABASE_URL=
SUPABASE_SERVICE_ROLE_KEY=
# Email transport for login codes + team invites. Without RESEND_API_KEY the API
# returns codes/tokens in its responses (local dev only — never in production).
RESEND_API_KEY=
CREDSHARE_EMAIL_FROM=LogicSRC <noreply@logicsrc.com>
LOGICSRC_WEB_URL=https://logicsrc.com
# Web page → API base (public). Falls back to https://commandboard.run.
NEXT_PUBLIC_COMMANDBOARD_API_URL=

View file

@ -28,7 +28,6 @@
"nodemailer": "^9.0.1" "nodemailer": "^9.0.1"
}, },
"devDependencies": { "devDependencies": {
"@logicsrc/plugin-credential-sharing": "file:../../plugins/credential-sharing",
"@types/mailparser": "^3.4.6", "@types/mailparser": "^3.4.6",
"@types/nodemailer": "^8.0.1", "@types/nodemailer": "^8.0.1",
"tsx": "^4.21.0", "tsx": "^4.21.0",

View file

@ -1,51 +0,0 @@
import type { CredShareEmailSender } from "./router.js";
/**
* Resend-backed email transport for login codes and team invites.
*
* Returns `undefined` when RESEND_API_KEY is not configured, so the API falls
* back to echoing codes/tokens in its responses (fine for local dev; production
* sets the key and never echoes secrets).
*/
export function createResendEmailSender(options: { webBaseUrl: string } = { webBaseUrl: "https://logicsrc.com" }): CredShareEmailSender | undefined {
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) return undefined;
const from = process.env.CREDSHARE_EMAIL_FROM || "LogicSRC <noreply@logicsrc.com>";
const webBaseUrl = process.env.LOGICSRC_WEB_URL || options.webBaseUrl;
async function send(to: string, subject: string, html: string, text: string): Promise<void> {
const response = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
body: JSON.stringify({ from, to, subject, html, text })
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(`Resend send failed: ${response.status} ${body.slice(0, 200)}`);
}
}
return {
async sendLoginCode(email, code) {
await send(
email,
"Your LogicSRC login code",
`<p>Your LogicSRC login code is:</p><p style="font-size:24px;font-weight:bold;letter-spacing:3px">${code}</p><p>It expires in 10 minutes. If you didn't request this, ignore this email.</p>`,
`Your LogicSRC login code is: ${code}\nIt expires in 10 minutes.`
);
},
async sendInvite({ email, token, teamName, teamSlug, invitedByEmail }) {
const acceptUrl = `${webBaseUrl}/teams/accept?token=${encodeURIComponent(token)}`;
await send(
email,
`You're invited to the "${teamName}" credential team on LogicSRC`,
`<p><strong>${invitedByEmail}</strong> invited you to share credentials on the <strong>${teamName}</strong> (<code>${teamSlug}</code>) team.</p>
<p>Accept in the CLI:</p>
<pre>logicsrc login --email ${email}
logicsrc teams accept ${token}</pre>
<p>or <a href="${acceptUrl}">accept on the web</a>. Secrets stay end-to-end encrypted the server never sees them.</p>`,
`${invitedByEmail} invited you to the "${teamName}" (${teamSlug}) credential team on LogicSRC.\n\nAccept in the CLI:\n logicsrc login --email ${email}\n logicsrc teams accept ${token}\n\nOr on the web: ${acceptUrl}`
);
}
};
}

View file

@ -1,132 +0,0 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import type { AddressInfo } from "node:net";
import { createServer, type Server, type IncomingMessage } from "node:http";
import { createCredShareApi, type CredShareRequest } from "./router.js";
import { createMemoryCredShareStore } from "./store.js";
import {
TeamClient,
generateIdentityKeyPair,
generateVaultKey,
wrapVaultKey,
unwrapVaultKey,
encryptValue,
decryptValue,
fingerprintValue
} from "@logicsrc/plugin-credential-sharing";
/**
* True end-to-end test: the real HTTP server + the real TeamClient + real
* libsodium crypto. Alice shares a .env with Bob purely through the server, and
* we assert the server never held plaintext while Bob still recovers the values.
*/
describe("credshare end-to-end (server + client + crypto)", () => {
let server: Server;
let apiUrl: string;
beforeAll(async () => {
// Hermetic server: real router + memory store, no email transport (so the
// dev-code echo path is deterministic and independent of ambient env).
const api = createCredShareApi({ store: createMemoryCredShareStore() });
server = createServer(async (request, response) => {
const url = new URL(request.url ?? "/", "http://localhost");
const method = request.method ?? "GET";
let body: unknown;
if (method === "POST" || method === "PUT" || method === "PATCH") {
body = await readBody(request);
}
const header = request.headers["authorization"];
const token = header?.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() : undefined;
const req: CredShareRequest = { method, path: url.pathname.replace(/^\/api\/credshare/, "") || "/", query: url.searchParams, body, token };
const result = await api.handle(req);
response.writeHead(result.status, { "content-type": "application/json" });
response.end(JSON.stringify(result.body));
});
await new Promise<void>((resolve) => server.listen(0, resolve));
apiUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
async function readBody(request: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(Buffer.from(chunk));
const text = Buffer.concat(chunks).toString("utf8");
return text ? JSON.parse(text) : undefined;
}
async function loginClient(email: string) {
const client = new TeamClient({ apiUrl });
const req = await client.requestLoginCode(email);
const verify = await client.verifyLoginCode(email, req.devCode!);
client.setToken(verify.token);
return client;
}
it("shares an env bundle from Alice to Bob without the server ever seeing plaintext", async () => {
const secretEnv = { DATABASE_URL: "postgres://user:pw@host/db", STRIPE_KEY: "sk_live_deadbeef" };
// --- Alice sets up ---
const alice = await loginClient("alice@acme.dev");
const aliceKeys = await generateIdentityKeyPair();
await alice.uploadPublicKey(aliceKeys.publicKey);
await alice.createTeam("acme-e2e", "Acme");
const { vault } = await alice.createVault("acme-e2e", "prod");
// Alice mints the vault DEK, wraps it to herself, and pushes ciphertext.
const dek = await generateVaultKey();
await alice.putGrant(vault.id, "alice@acme.dev", await wrapVaultKey(dek, aliceKeys.publicKey));
const upserts = [] as Array<{ name: string; nonce: string; ciphertext: string; fingerprint: string }>;
for (const [name, value] of Object.entries(secretEnv)) {
const sealed = await encryptValue(value, dek);
upserts.push({ name, ...sealed, fingerprint: fingerprintValue(value) });
}
await alice.putSecrets(vault.id, upserts, []);
// The server must only hold ciphertext — assert no plaintext leaks over the wire.
const stored = await alice.listSecrets(vault.id);
for (const s of stored.secrets) {
expect(s.ciphertext).not.toContain("postgres://");
expect(s.ciphertext).not.toContain("sk_live");
}
// --- Bob joins ---
const bob = await loginClient("bob@acme.dev");
const bobKeys = await generateIdentityKeyPair();
await bob.uploadPublicKey(bobKeys.publicKey);
const invite = await alice.invite("acme-e2e", "bob@acme.dev");
await bob.acceptInvite(invite.token!);
// --- Alice grants Bob: look up Bob's pubkey, re-wrap the DEK to it ---
const bobUser = await alice.lookupUser("bob@acme.dev");
const aliceDek = await unwrapVaultKey((await alice.getMyGrant(vault.id)).wrappedDek, aliceKeys);
await alice.putGrant(vault.id, "bob@acme.dev", await wrapVaultKey(aliceDek, bobUser.publicKey!));
// --- Bob pulls + decrypts on his machine ---
const bobDek = await unwrapVaultKey((await bob.getMyGrant(vault.id)).wrappedDek, bobKeys);
const bobSecrets = await bob.listSecrets(vault.id);
const recovered: Record<string, string> = {};
for (const s of bobSecrets.secrets) {
recovered[s.name] = await decryptValue({ nonce: s.nonce, ciphertext: s.ciphertext }, bobDek);
}
expect(recovered).toEqual(secretEnv);
});
it("denies decryption to a member who has not been granted the DEK", async () => {
const alice = await loginClient("alice2@acme.dev");
const aliceKeys = await generateIdentityKeyPair();
await alice.uploadPublicKey(aliceKeys.publicKey);
await alice.createTeam("acme-e2e-2", "Acme2");
const { vault } = await alice.createVault("acme-e2e-2", "prod");
const dek = await generateVaultKey();
await alice.putGrant(vault.id, "alice2@acme.dev", await wrapVaultKey(dek, aliceKeys.publicKey));
const carol = await loginClient("carol@acme.dev");
await carol.uploadPublicKey((await generateIdentityKeyPair()).publicKey);
const invite = await alice.invite("acme-e2e-2", "carol@acme.dev");
await carol.acceptInvite(invite.token!);
// Carol is a member (can see ciphertext) but has no grant → cannot get a DEK.
await expect(carol.getMyGrant(vault.id)).rejects.toThrow();
});
});

View file

@ -1,134 +0,0 @@
import { describe, it, expect, beforeEach } from "vitest";
import { createMemoryCredShareStore } from "./store.js";
import { createCredShareApi, type CredShareRequest } from "./router.js";
function api() {
return createCredShareApi({ store: createMemoryCredShareStore() });
}
async function login(app: ReturnType<typeof createCredShareApi>, email: string): Promise<string> {
const req = await app.handle(reqOf("POST", "/auth/request", { body: { email } }));
const code = (req.body as { devCode: string }).devCode;
const verify = await app.handle(reqOf("POST", "/auth/verify", { body: { email, code } }));
return (verify.body as { token: string }).token;
}
function reqOf(method: string, path: string, opts: { body?: unknown; token?: string; query?: Record<string, string> } = {}): CredShareRequest {
return {
method,
path,
query: new URLSearchParams(opts.query ?? {}),
body: opts.body,
token: opts.token
};
}
describe("credshare API", () => {
let app: ReturnType<typeof createCredShareApi>;
beforeEach(() => {
app = api();
});
it("issues a token via the email-code flow and rejects a bad code", async () => {
const request = await app.handle(reqOf("POST", "/auth/request", { body: { email: "Owner@Example.com" } }));
expect(request.status).toBe(200);
const code = (request.body as { devCode: string }).devCode;
expect(code).toMatch(/^\d{6}$/);
const bad = await app.handle(reqOf("POST", "/auth/verify", { body: { email: "owner@example.com", code: "000000" } }));
expect(bad.status).toBe(401);
const good = await app.handle(reqOf("POST", "/auth/verify", { body: { email: "owner@example.com", code } }));
expect(good.status).toBe(200);
expect((good.body as { user: { email: string } }).user.email).toBe("owner@example.com");
});
it("rejects protected routes without a token", async () => {
const res = await app.handle(reqOf("GET", "/teams"));
expect(res.status).toBe(401);
});
it("runs the full invite → accept → push → grant → pull loop", async () => {
const ownerToken = await login(app, "owner@example.com");
const bobToken = await login(app, "bob@example.com");
// Both members upload public keys.
await app.handle(reqOf("POST", "/keys", { token: ownerToken, body: { publicKey: "OWNER_PUBKEY_B64" } }));
await app.handle(reqOf("POST", "/keys", { token: bobToken, body: { publicKey: "BOB_PUBKEY_B64" } }));
// Owner creates a team + vault.
const team = await app.handle(reqOf("POST", "/teams", { token: ownerToken, body: { slug: "acme", name: "Acme" } }));
expect(team.status).toBe(201);
const vaultRes = await app.handle(reqOf("POST", "/teams/acme/vaults", { token: ownerToken, body: { name: "prod" } }));
const vaultId = (vaultRes.body as { vault: { id: string } }).vault.id;
// Owner self-grants (wraps DEK to own key) and pushes ciphertext.
await app.handle(reqOf("POST", `/vaults/${vaultId}/grants`, { token: ownerToken, body: { email: "owner@example.com", wrappedDek: "DEK_SEALED_TO_OWNER" } }));
const push = await app.handle(
reqOf("PUT", `/vaults/${vaultId}/secrets`, {
token: ownerToken,
body: { upserts: [{ name: "DATABASE_URL", nonce: "n1", ciphertext: "c1", fingerprint: "fp1" }] }
})
);
expect(push.status).toBe(200);
// Bob can't access yet — no grant.
const bobEarly = await app.handle(reqOf("GET", `/vaults/${vaultId}/grant`, { token: bobToken }));
expect(bobEarly.status).toBe(403);
// Owner invites Bob; Bob accepts.
const invite = await app.handle(reqOf("POST", "/teams/acme/invites", { token: ownerToken, body: { email: "bob@example.com" } }));
expect(invite.status).toBe(201);
const inviteToken = (invite.body as { token: string }).token;
const accept = await app.handle(reqOf("POST", "/invites/accept", { token: bobToken, body: { token: inviteToken } }));
expect(accept.status).toBe(200);
// Bob is now a member and can read ciphertext, but still needs a grant to decrypt.
const members = await app.handle(reqOf("GET", "/teams/acme/members", { token: bobToken }));
expect((members.body as { members: unknown[] }).members).toHaveLength(2);
// Owner grants Bob (re-wraps DEK to Bob's pubkey).
const grantBob = await app.handle(reqOf("POST", `/vaults/${vaultId}/grants`, { token: ownerToken, body: { email: "bob@example.com", wrappedDek: "DEK_SEALED_TO_BOB" } }));
expect(grantBob.status).toBe(201);
// Bob pulls his wrapped DEK and the ciphertext.
const bobGrant = await app.handle(reqOf("GET", `/vaults/${vaultId}/grant`, { token: bobToken }));
expect((bobGrant.body as { wrappedDek: string }).wrappedDek).toBe("DEK_SEALED_TO_BOB");
const secrets = await app.handle(reqOf("GET", `/vaults/${vaultId}/secrets`, { token: bobToken }));
expect((secrets.body as { secrets: { name: string }[] }).secrets[0].name).toBe("DATABASE_URL");
});
it("stops a non-admin from inviting and enforces invite-email match", async () => {
const ownerToken = await login(app, "owner@example.com");
const bobToken = await login(app, "bob@example.com");
const eveToken = await login(app, "eve@example.com");
await app.handle(reqOf("POST", "/keys", { token: bobToken, body: { publicKey: "BOB" } }));
await app.handle(reqOf("POST", "/teams", { token: ownerToken, body: { slug: "acme", name: "Acme" } }));
// Invite Bob as a plain member; Bob accepts.
const inv = await app.handle(reqOf("POST", "/teams/acme/invites", { token: ownerToken, body: { email: "bob@example.com" } }));
await app.handle(reqOf("POST", "/invites/accept", { token: bobToken, body: { token: (inv.body as { token: string }).token } }));
// Bob (member) cannot invite.
const bobInvite = await app.handle(reqOf("POST", "/teams/acme/invites", { token: bobToken, body: { email: "carol@example.com" } }));
expect(bobInvite.status).toBe(403);
// Eve can't accept Bob's-email invite.
const inv2 = await app.handle(reqOf("POST", "/teams/acme/invites", { token: ownerToken, body: { email: "carol@example.com" } }));
const eveAccept = await app.handle(reqOf("POST", "/invites/accept", { token: eveToken, body: { token: (inv2.body as { token: string }).token } }));
expect(eveAccept.status).toBe(403);
});
it("blocks a non-member from a team's vault entirely", async () => {
const ownerToken = await login(app, "owner@example.com");
const strangerToken = await login(app, "stranger@example.com");
await app.handle(reqOf("POST", "/teams", { token: ownerToken, body: { slug: "acme" } }));
const v = await app.handle(reqOf("POST", "/teams/acme/vaults", { token: ownerToken, body: { name: "prod" } }));
const vaultId = (v.body as { vault: { id: string } }).vault.id;
const stranger = await app.handle(reqOf("GET", `/vaults/${vaultId}/secrets`, { token: strangerToken }));
expect(stranger.status).toBe(403);
const strangerMembers = await app.handle(reqOf("GET", "/teams/acme/members", { token: strangerToken }));
expect(strangerMembers.status).toBe(403);
});
});

View file

@ -1,391 +0,0 @@
import { randomBytes, createHash } from "node:crypto";
import { hashToken, normalizeEmail, type CredShareStore } from "./store.js";
import type { CredShareMember, MemberRole } from "./types.js";
export interface CredShareRequest {
method: string;
/** Path AFTER the /api/credshare prefix, e.g. "/teams/acme/members". */
path: string;
query: URLSearchParams;
body: unknown;
/** Bearer token (without the "Bearer " prefix), if present. */
token?: string;
}
export interface CredShareResponse {
status: number;
body: unknown;
}
export interface CredShareEmailSender {
sendLoginCode(email: string, code: string): Promise<void>;
sendInvite(input: { email: string; token: string; teamName: string; teamSlug: string; invitedByEmail: string }): Promise<void>;
}
export interface CredShareApiOptions {
store: CredShareStore;
email?: CredShareEmailSender;
/** Public base URL used to build invite-accept links in emails. */
webBaseUrl?: string;
now?: () => Date;
/** Test seams. */
generateCode?: () => string;
generateToken?: () => string;
loginCodeTtlMs?: number;
inviteTtlMs?: number;
}
const ROLE_RANK: Record<MemberRole, number> = { member: 0, admin: 1, owner: 2 };
function ok(body: unknown, status = 200): CredShareResponse {
return { status, body };
}
function err(status: number, message: string): CredShareResponse {
return { status, body: { error: message } };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function str(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
/** A member view safe to return to teammates (no tokens/keys, just identity + status). */
function memberView(m: CredShareMember, publicKey: string | null | undefined, hasGrant?: boolean) {
return {
email: m.email,
role: m.role,
status: m.status,
hasPublicKey: Boolean(publicKey),
...(hasGrant === undefined ? {} : { hasVaultAccess: hasGrant }),
joinedAt: m.joinedAt
};
}
export function createCredShareApi(options: CredShareApiOptions) {
const { store, email } = options;
const now = options.now ?? (() => new Date());
const generateCode = options.generateCode ?? (() => String(randomBytes(4).readUInt32BE(0) % 1_000_000).padStart(6, "0"));
const generateToken = options.generateToken ?? (() => randomBytes(32).toString("base64url"));
const loginCodeTtlMs = options.loginCodeTtlMs ?? 10 * 60 * 1000;
const inviteTtlMs = options.inviteTtlMs ?? 7 * 24 * 60 * 60 * 1000;
async function currentUserId(req: CredShareRequest): Promise<string | undefined> {
if (!req.token) return undefined;
return store.getUserIdForToken(hashToken(req.token));
}
type MemberGuard =
| { ok: true; team: Awaited<ReturnType<CredShareStore["getTeamBySlug"]>> & object; member: CredShareMember }
| { ok: false; response: CredShareResponse };
async function requireMember(teamSlug: string, userId: string): Promise<MemberGuard> {
const team = await store.getTeamBySlug(teamSlug);
if (!team) return { ok: false, response: err(404, `Unknown team: ${teamSlug}`) };
const member = await store.getMember(team.id, userId);
if (!member || member.status !== "active") {
return { ok: false, response: err(403, "You are not a member of this team.") };
}
return { ok: true, team, member };
}
async function handle(req: CredShareRequest): Promise<CredShareResponse> {
const { method, path } = req;
// ---- auth (unauthenticated) ------------------------------------------
if (method === "POST" && path === "/auth/request") {
if (!isRecord(req.body) || !str(req.body.email)) return err(422, "Expected { email }.");
const emailAddr = normalizeEmail(str(req.body.email)!);
const code = generateCode();
await store.saveLoginCode({
email: emailAddr,
codeHash: createHash("sha256").update(code).digest("hex"),
expiresAt: new Date(now().getTime() + loginCodeTtlMs).toISOString(),
createdAt: now().toISOString()
});
let delivered = false;
if (email) {
await email.sendLoginCode(emailAddr, code);
delivered = true;
}
// When no email transport is configured (local dev/tests), echo the code so
// the flow is still usable. Never echo it once real delivery is on.
return ok({ ok: true, emailSent: delivered, ...(delivered ? {} : { devCode: code }) });
}
if (method === "POST" && path === "/auth/verify") {
if (!isRecord(req.body) || !str(req.body.email) || !str(req.body.code)) return err(422, "Expected { email, code }.");
const emailAddr = normalizeEmail(str(req.body.email)!);
const codeHash = createHash("sha256").update(str(req.body.code)!).digest("hex");
const valid = await store.consumeLoginCode(emailAddr, codeHash, now());
if (!valid) return err(401, "Invalid or expired code.");
const user = await store.upsertUserByEmail(emailAddr);
const token = generateToken();
await store.saveToken({ tokenHash: hashToken(token), userId: user.id, createdAt: now().toISOString(), lastUsedAt: null });
return ok({ token, user: { id: user.id, email: user.email, publicKey: user.publicKey } });
}
// ---- everything below requires a valid token -------------------------
const userId = await currentUserId(req);
if (!userId) return err(401, "Missing or invalid token. Run: logicsrc login --email you@example.com");
const me = await store.getUserById(userId);
if (!me) return err(401, "Unknown user for token.");
if (method === "POST" && path === "/logout") {
if (req.token) await store.deleteToken(hashToken(req.token));
return ok({ ok: true });
}
if (method === "POST" && path === "/keys") {
if (!isRecord(req.body) || !str(req.body.publicKey)) return err(422, "Expected { publicKey }.");
const updated = await store.setUserPublicKey(userId, str(req.body.publicKey)!);
return ok({ email: updated.email, publicKey: updated.publicKey });
}
if (method === "GET" && path === "/me") {
const teams = await store.listTeamsForUser(userId);
return ok({ user: { id: me.id, email: me.email, publicKey: me.publicKey }, teams });
}
if (method === "GET" && path === "/users") {
const target = req.query.get("email");
if (!target) return err(422, "Expected ?email=");
const user = await store.getUserByEmail(target);
return ok({ email: normalizeEmail(target), userId: user?.id ?? null, publicKey: user?.publicKey ?? null });
}
// ---- teams -----------------------------------------------------------
if (method === "POST" && path === "/teams") {
if (!isRecord(req.body) || !str(req.body.slug)) return err(422, "Expected { slug, name? }.");
const slug = normalizeSlug(str(req.body.slug)!);
if (!slug) return err(422, "Slug must be lowercase letters, numbers, and dashes.");
if (await store.getTeamBySlug(slug)) return err(409, `Team slug "${slug}" is taken.`);
const team = await store.createTeam({ slug, name: str(req.body.name) ?? slug, createdBy: userId });
await store.addMember({
teamId: team.id,
userId,
email: me.email,
role: "owner",
status: "active",
invitedBy: null,
joinedAt: now().toISOString()
});
await store.appendAudit({ teamId: team.id, vaultId: null, actorUserId: userId, action: "team:create", keyName: null, fingerprint: null });
return ok({ team }, 201);
}
if (method === "GET" && path === "/teams") {
return ok({ teams: await store.listTeamsForUser(userId) });
}
const membersMatch = /^\/teams\/([^/]+)\/members$/.exec(path);
if (membersMatch && method === "GET") {
const guard = await requireMember(decodeURIComponent(membersMatch[1]), userId);
if (!guard.ok) return guard.response;
const rows = await store.listMembers(guard.team.id);
const views = await Promise.all(
rows.map(async (m) => {
const u = m.userId ? await store.getUserById(m.userId) : undefined;
return memberView(m, u?.publicKey);
})
);
return ok({ members: views });
}
// ---- invites ---------------------------------------------------------
const invitesMatch = /^\/teams\/([^/]+)\/invites$/.exec(path);
if (invitesMatch && method === "POST") {
const guard = await requireMember(decodeURIComponent(invitesMatch[1]), userId);
if (!guard.ok) return guard.response;
if (ROLE_RANK[guard.member.role] < ROLE_RANK.admin) return err(403, "Only owners and admins can invite.");
if (!isRecord(req.body) || !str(req.body.email)) return err(422, "Expected { email, role? }.");
const inviteEmail = normalizeEmail(str(req.body.email)!);
const role = (str(req.body.role) as MemberRole) ?? "member";
if (!(role in ROLE_RANK)) return err(422, "role must be owner|admin|member.");
// Ensure a (possibly invited) member row exists.
const existing = await store.getMemberByEmail(guard.team.id, inviteEmail);
if (!existing) {
const invitedUser = await store.getUserByEmail(inviteEmail);
await store.addMember({
teamId: guard.team.id,
userId: invitedUser?.id ?? null,
email: inviteEmail,
role,
status: "invited",
invitedBy: userId,
joinedAt: null
});
}
const token = generateToken();
const invite = await store.createInvite({
teamId: guard.team.id,
email: inviteEmail,
role,
tokenHash: hashToken(token),
createdBy: userId,
expiresAt: new Date(now().getTime() + inviteTtlMs).toISOString()
});
await store.appendAudit({ teamId: guard.team.id, vaultId: null, actorUserId: userId, action: "team:invite", keyName: inviteEmail, fingerprint: null });
let delivered = false;
if (email) {
await email.sendInvite({ email: inviteEmail, token, teamName: guard.team.name, teamSlug: guard.team.slug, invitedByEmail: me.email });
delivered = true;
}
return ok(
{ invite: { id: invite.id, email: invite.email, role: invite.role, expiresAt: invite.expiresAt }, emailSent: delivered, ...(delivered ? {} : { token }) },
201
);
}
if (method === "POST" && path === "/invites/accept") {
if (!isRecord(req.body) || !str(req.body.token)) return err(422, "Expected { token }.");
const invite = await store.getInviteByTokenHash(hashToken(str(req.body.token)!));
if (!invite) return err(404, "Invite not found.");
if (invite.acceptedAt) return err(409, "Invite already used.");
if (new Date(invite.expiresAt).getTime() < now().getTime()) return err(410, "Invite expired.");
if (invite.email !== me.email) return err(403, `This invite is for ${invite.email}, not ${me.email}.`);
await store.updateMember(invite.teamId, me.email, { userId, status: "active", joinedAt: now().toISOString() });
await store.markInviteAccepted(invite.id, now().toISOString());
await store.appendAudit({ teamId: invite.teamId, vaultId: null, actorUserId: userId, action: "team:join", keyName: null, fingerprint: null });
const team = (await store.listTeamsForUser(userId)).find((t) => t.id === invite.teamId);
return ok({ ok: true, team });
}
// ---- vaults ----------------------------------------------------------
const vaultsMatch = /^\/teams\/([^/]+)\/vaults$/.exec(path);
if (vaultsMatch) {
const guard = await requireMember(decodeURIComponent(vaultsMatch[1]), userId);
if (!guard.ok) return guard.response;
if (method === "GET") {
const vaults = await store.listVaults(guard.team.id);
const rows = await Promise.all(
vaults.map(async (v) => ({
id: v.id,
name: v.name,
hasAccess: Boolean(await store.getGrant(v.id, userId)),
secretCount: (await store.listSecrets(v.id)).length
}))
);
return ok({ vaults: rows });
}
if (method === "POST") {
if (!isRecord(req.body) || !str(req.body.name)) return err(422, "Expected { name }.");
const name = normalizeSlug(str(req.body.name)!);
if (!name) return err(422, "Vault name must be lowercase letters, numbers, and dashes.");
const existing = await store.getVault(guard.team.id, name);
if (existing) return ok({ vault: existing });
const vault = await store.createVault({ teamId: guard.team.id, name, createdBy: userId });
await store.appendAudit({ teamId: guard.team.id, vaultId: vault.id, actorUserId: userId, action: "vault:create", keyName: null, fingerprint: null });
return ok({ vault }, 201);
}
}
// ---- vault grants / secrets / audit (addressed by vault id) ----------
const vaultIdMatch = /^\/vaults\/([^/]+)\/(grant|grants|secrets|audit)$/.exec(path);
if (vaultIdMatch) {
const vaultId = decodeURIComponent(vaultIdMatch[1]);
const sub = vaultIdMatch[2];
const vault = await store.getVaultById(vaultId);
if (!vault) return err(404, "Unknown vault.");
const member = await store.getMember(vault.teamId, userId);
if (!member || member.status !== "active") return err(403, "You are not a member of this vault's team.");
if (sub === "grant" && method === "GET") {
const grant = await store.getGrant(vaultId, userId);
if (!grant) return err(403, "You do not have access to this vault yet. Ask a member to grant you.");
return ok({ wrappedDek: grant.wrappedDek });
}
if (sub === "grants") {
if (method === "GET") {
const grants = await store.listGrants(vaultId);
const grantedUserIds = new Set(grants.map((g) => g.userId));
const members = await store.listMembers(vault.teamId);
const rows = await Promise.all(
members.map(async (m) => {
const u = m.userId ? await store.getUserById(m.userId) : undefined;
return { email: m.email, hasPublicKey: Boolean(u?.publicKey), hasAccess: Boolean(m.userId && grantedUserIds.has(m.userId)) };
})
);
return ok({ grants: rows });
}
if (method === "POST") {
// The caller must already hold access (they can produce a valid wrapped DEK).
const iHold = await store.getGrant(vaultId, userId);
if (!iHold && (await store.listGrants(vaultId)).length > 0) {
return err(403, "Only a member with vault access can grant others.");
}
if (!isRecord(req.body) || !str(req.body.wrappedDek) || !str(req.body.email)) {
return err(422, "Expected { email, wrappedDek }.");
}
const targetUser = await store.getUserByEmail(str(req.body.email)!);
if (!targetUser) return err(404, "Target user has not logged in yet.");
if (!targetUser.publicKey) return err(409, "Target user has not uploaded a public key yet.");
await store.upsertGrant({ vaultId, userId: targetUser.id, wrappedDek: str(req.body.wrappedDek)!, grantedBy: userId, createdAt: now().toISOString() });
await store.appendAudit({ teamId: vault.teamId, vaultId, actorUserId: userId, action: "vault:grant", keyName: targetUser.email, fingerprint: null });
return ok({ ok: true }, 201);
}
}
if (sub === "secrets") {
if (method === "GET") {
const secrets = await store.listSecrets(vaultId);
return ok({
vaultId,
secrets: secrets.map((s) => ({ name: s.name, nonce: s.nonce, ciphertext: s.ciphertext, fingerprint: s.fingerprint, version: s.version, updatedAt: s.updatedAt }))
});
}
if (method === "PUT") {
if (!isRecord(req.body)) return err(422, "Expected { upserts?, deletes? }.");
const upserts = Array.isArray(req.body.upserts) ? req.body.upserts : [];
const deletes = Array.isArray(req.body.deletes) ? req.body.deletes : [];
const existing = new Map((await store.listSecrets(vaultId)).map((s) => [s.name, s]));
const applied: string[] = [];
for (const raw of upserts) {
if (!isRecord(raw) || !str(raw.name) || !str(raw.nonce) || !str(raw.ciphertext) || !str(raw.fingerprint)) {
return err(422, "Each upsert needs { name, nonce, ciphertext, fingerprint }.");
}
const name = str(raw.name)!;
const prev = existing.get(name);
await store.putSecret({
vaultId,
name,
nonce: str(raw.nonce)!,
ciphertext: str(raw.ciphertext)!,
fingerprint: str(raw.fingerprint)!,
version: (prev?.version ?? 0) + 1,
updatedBy: userId,
updatedAt: now().toISOString()
});
await store.appendAudit({ teamId: vault.teamId, vaultId, actorUserId: userId, action: prev ? "secret:update" : "secret:add", keyName: name, fingerprint: str(raw.fingerprint)! });
applied.push(name);
}
for (const raw of deletes) {
const name = str(raw);
if (!name) continue;
await store.deleteSecret(vaultId, name);
await store.appendAudit({ teamId: vault.teamId, vaultId, actorUserId: userId, action: "secret:remove", keyName: name, fingerprint: null });
applied.push(name);
}
return ok({ ok: true, applied });
}
}
if (sub === "audit" && method === "GET") {
return ok({ audit: await store.listAudit(vaultId) });
}
}
return err(404, "Not found");
}
return { handle };
}
function normalizeSlug(input: string): string | undefined {
const slug = input.trim().toLowerCase();
return /^[a-z0-9][a-z0-9-]{0,62}$/.test(slug) ? slug : undefined;
}

View file

@ -1,243 +0,0 @@
import { randomUUID, createHash } from "node:crypto";
import type {
CredShareUser,
CredShareTeam,
CredShareMember,
CredShareInvite,
CredShareVault,
CredShareGrant,
CredShareSecret,
CredShareAuditEvent,
CredShareLoginCode,
CredShareToken,
MemberRole,
MemberStatus
} from "./types.js";
/**
* Storage boundary for the credential-sharing server. The router talks only to
* this interface, so it can run against an in-memory map (tests / local dev) or
* Supabase (production) without changing a line of route logic.
*/
export interface CredShareStore {
// users + keys
upsertUserByEmail(email: string): Promise<CredShareUser>;
getUserByEmail(email: string): Promise<CredShareUser | undefined>;
getUserById(id: string): Promise<CredShareUser | undefined>;
setUserPublicKey(userId: string, publicKey: string): Promise<CredShareUser>;
// auth
saveLoginCode(code: CredShareLoginCode): Promise<void>;
consumeLoginCode(email: string, codeHash: string, now: Date): Promise<boolean>;
saveToken(token: CredShareToken): Promise<void>;
getUserIdForToken(tokenHash: string): Promise<string | undefined>;
deleteToken(tokenHash: string): Promise<void>;
// teams + members
createTeam(input: { slug: string; name: string; createdBy: string }): Promise<CredShareTeam>;
getTeamBySlug(slug: string): Promise<CredShareTeam | undefined>;
listTeamsForUser(userId: string): Promise<CredShareTeam[]>;
addMember(input: Omit<CredShareMember, "id" | "createdAt">): Promise<CredShareMember>;
getMember(teamId: string, userId: string): Promise<CredShareMember | undefined>;
getMemberByEmail(teamId: string, email: string): Promise<CredShareMember | undefined>;
listMembers(teamId: string): Promise<CredShareMember[]>;
updateMember(
teamId: string,
email: string,
patch: Partial<Pick<CredShareMember, "userId" | "status" | "joinedAt" | "role">>
): Promise<CredShareMember | undefined>;
// invites
createInvite(input: Omit<CredShareInvite, "id" | "createdAt" | "acceptedAt">): Promise<CredShareInvite>;
getInviteByTokenHash(tokenHash: string): Promise<CredShareInvite | undefined>;
markInviteAccepted(id: string, at: string): Promise<void>;
// vaults + grants + secrets
createVault(input: { teamId: string; name: string; createdBy: string }): Promise<CredShareVault>;
getVault(teamId: string, name: string): Promise<CredShareVault | undefined>;
getVaultById(vaultId: string): Promise<CredShareVault | undefined>;
listVaults(teamId: string): Promise<CredShareVault[]>;
upsertGrant(grant: CredShareGrant): Promise<void>;
getGrant(vaultId: string, userId: string): Promise<CredShareGrant | undefined>;
listGrants(vaultId: string): Promise<CredShareGrant[]>;
listSecrets(vaultId: string): Promise<CredShareSecret[]>;
putSecret(secret: CredShareSecret): Promise<void>;
deleteSecret(vaultId: string, name: string): Promise<void>;
// audit
appendAudit(event: Omit<CredShareAuditEvent, "id" | "createdAt">): Promise<CredShareAuditEvent>;
listAudit(vaultId: string): Promise<CredShareAuditEvent[]>;
}
export function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
/** In-memory store — used by tests and single-process local dev. */
export function createMemoryCredShareStore(): CredShareStore {
const users = new Map<string, CredShareUser>(); // by id
const usersByEmail = new Map<string, string>(); // email -> id
const loginCodes = new Map<string, CredShareLoginCode>(); // email -> code
const tokens = new Map<string, CredShareToken>(); // tokenHash -> token
const teams = new Map<string, CredShareTeam>(); // by id
const teamsBySlug = new Map<string, string>(); // slug -> id
const members: CredShareMember[] = [];
const invites = new Map<string, CredShareInvite>(); // by id
const vaults = new Map<string, CredShareVault>(); // by id
const grants = new Map<string, CredShareGrant>(); // `${vaultId}:${userId}`
const secrets = new Map<string, CredShareSecret>(); // `${vaultId}:${name}`
const audit: CredShareAuditEvent[] = [];
const iso = () => new Date().toISOString();
return {
async upsertUserByEmail(email) {
const key = normalizeEmail(email);
const existingId = usersByEmail.get(key);
if (existingId) {
return users.get(existingId)!;
}
const user: CredShareUser = { id: randomUUID(), email: key, publicKey: null, createdAt: iso() };
users.set(user.id, user);
usersByEmail.set(key, user.id);
return user;
},
async getUserByEmail(email) {
const id = usersByEmail.get(normalizeEmail(email));
return id ? users.get(id) : undefined;
},
async getUserById(id) {
return users.get(id);
},
async setUserPublicKey(userId, publicKey) {
const user = users.get(userId);
if (!user) throw new Error("user not found");
user.publicKey = publicKey;
return user;
},
async saveLoginCode(code) {
loginCodes.set(normalizeEmail(code.email), code);
},
async consumeLoginCode(email, codeHash, now) {
const key = normalizeEmail(email);
const code = loginCodes.get(key);
if (!code || code.codeHash !== codeHash || new Date(code.expiresAt).getTime() < now.getTime()) {
return false;
}
loginCodes.delete(key);
return true;
},
async saveToken(token) {
tokens.set(token.tokenHash, token);
},
async getUserIdForToken(tokenHash) {
const token = tokens.get(tokenHash);
if (token) {
token.lastUsedAt = iso();
}
return token?.userId;
},
async deleteToken(tokenHash) {
tokens.delete(tokenHash);
},
async createTeam({ slug, name, createdBy }) {
const team: CredShareTeam = { id: randomUUID(), slug, name, createdBy, createdAt: iso() };
teams.set(team.id, team);
teamsBySlug.set(slug, team.id);
return team;
},
async getTeamBySlug(slug) {
const id = teamsBySlug.get(slug);
return id ? teams.get(id) : undefined;
},
async listTeamsForUser(userId) {
const teamIds = new Set(members.filter((m) => m.userId === userId && m.status === "active").map((m) => m.teamId));
return [...teamIds].map((id) => teams.get(id)!).filter(Boolean);
},
async addMember(input) {
const member: CredShareMember = { ...input, id: randomUUID(), createdAt: iso() };
members.push(member);
return member;
},
async getMember(teamId, userId) {
return members.find((m) => m.teamId === teamId && m.userId === userId);
},
async getMemberByEmail(teamId, email) {
const key = normalizeEmail(email);
return members.find((m) => m.teamId === teamId && m.email === key);
},
async listMembers(teamId) {
return members.filter((m) => m.teamId === teamId);
},
async updateMember(teamId, email, patch) {
const key = normalizeEmail(email);
const member = members.find((m) => m.teamId === teamId && m.email === key);
if (!member) return undefined;
Object.assign(member, patch);
return member;
},
async createInvite(input) {
const invite: CredShareInvite = { ...input, id: randomUUID(), acceptedAt: null, createdAt: iso() };
invites.set(invite.id, invite);
return invite;
},
async getInviteByTokenHash(tokenHash) {
return [...invites.values()].find((i) => i.tokenHash === tokenHash);
},
async markInviteAccepted(id, at) {
const invite = invites.get(id);
if (invite) invite.acceptedAt = at;
},
async createVault({ teamId, name, createdBy }) {
const vault: CredShareVault = { id: randomUUID(), teamId, name, createdBy, createdAt: iso() };
vaults.set(vault.id, vault);
return vault;
},
async getVault(teamId, name) {
return [...vaults.values()].find((v) => v.teamId === teamId && v.name === name);
},
async getVaultById(vaultId) {
return vaults.get(vaultId);
},
async listVaults(teamId) {
return [...vaults.values()].filter((v) => v.teamId === teamId);
},
async upsertGrant(grant) {
grants.set(`${grant.vaultId}:${grant.userId}`, grant);
},
async getGrant(vaultId, userId) {
return grants.get(`${vaultId}:${userId}`);
},
async listGrants(vaultId) {
return [...grants.values()].filter((g) => g.vaultId === vaultId);
},
async listSecrets(vaultId) {
return [...secrets.values()].filter((s) => s.vaultId === vaultId).sort((a, b) => a.name.localeCompare(b.name));
},
async putSecret(secret) {
secrets.set(`${secret.vaultId}:${secret.name}`, secret);
},
async deleteSecret(vaultId, name) {
secrets.delete(`${vaultId}:${name}`);
},
async appendAudit(event) {
const full: CredShareAuditEvent = { ...event, id: randomUUID(), createdAt: iso() };
audit.push(full);
return full;
},
async listAudit(vaultId) {
return audit.filter((e) => e.vaultId === vaultId).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
};
}
export type { MemberRole, MemberStatus };

View file

@ -1,238 +0,0 @@
import { createRequire } from "node:module";
import type { CredShareStore } from "./store.js";
import { normalizeEmail } from "./store.js";
import type {
CredShareUser,
CredShareTeam,
CredShareMember,
CredShareInvite,
CredShareVault,
CredShareGrant,
CredShareSecret,
CredShareAuditEvent,
CredShareLoginCode,
CredShareToken
} from "./types.js";
/**
* Supabase-backed CredShareStore (production). Uses the SERVICE ROLE key and
* enforces authorization in the router, not via RLS RLS on these tables is
* deny-by-default defense-in-depth. Returns `undefined` when unconfigured so
* the API falls back to the in-memory store for local dev.
*
* Table columns are snake_case; this module maps to/from the camelCase domain
* types. See supabase/migrations/*_credshare.sql.
*/
export function createSupabaseCredShareStore(): CredShareStore | undefined {
const url = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY;
if (!url || !key) return undefined;
// Lazy-require so the API runs without @supabase/supabase-js installed when
// Supabase isn't configured (the memory store needs no dependency).
const require = createRequire(import.meta.url);
const { createClient } = require("@supabase/supabase-js") as typeof import("@supabase/supabase-js");
const db = createClient(url, key, { auth: { persistSession: false } });
const T = {
users: "credshare_users",
loginCodes: "credshare_login_codes",
tokens: "credshare_tokens",
teams: "credshare_teams",
members: "credshare_members",
invites: "credshare_invites",
vaults: "credshare_vaults",
grants: "credshare_vault_grants",
secrets: "credshare_secrets",
audit: "credshare_audit"
} as const;
const userOut = (r: Record<string, unknown>): CredShareUser => ({ id: r.id as string, email: r.email as string, publicKey: (r.public_key as string) ?? null, createdAt: r.created_at as string });
const teamOut = (r: Record<string, unknown>): CredShareTeam => ({ id: r.id as string, slug: r.slug as string, name: r.name as string, createdBy: r.created_by as string, createdAt: r.created_at as string });
const memberOut = (r: Record<string, unknown>): CredShareMember => ({ id: r.id as string, teamId: r.team_id as string, userId: (r.user_id as string) ?? null, email: r.email as string, role: r.role as CredShareMember["role"], status: r.status as CredShareMember["status"], invitedBy: (r.invited_by as string) ?? null, createdAt: r.created_at as string, joinedAt: (r.joined_at as string) ?? null });
const vaultOut = (r: Record<string, unknown>): CredShareVault => ({ id: r.id as string, teamId: r.team_id as string, name: r.name as string, createdBy: r.created_by as string, createdAt: r.created_at as string });
const secretOut = (r: Record<string, unknown>): CredShareSecret => ({ vaultId: r.vault_id as string, name: r.name as string, nonce: r.nonce as string, ciphertext: r.ciphertext as string, fingerprint: r.fingerprint as string, version: r.version as number, updatedBy: r.updated_by as string, updatedAt: r.updated_at as string });
const grantOut = (r: Record<string, unknown>): CredShareGrant => ({ vaultId: r.vault_id as string, userId: r.user_id as string, wrappedDek: r.wrapped_dek as string, grantedBy: r.granted_by as string, createdAt: r.created_at as string });
const auditOut = (r: Record<string, unknown>): CredShareAuditEvent => ({ id: r.id as string, teamId: (r.team_id as string) ?? null, vaultId: (r.vault_id as string) ?? null, actorUserId: r.actor_user_id as string, action: r.action as string, keyName: (r.key_name as string) ?? null, fingerprint: (r.fingerprint as string) ?? null, createdAt: r.created_at as string });
async function one<T>(query: PromiseLike<{ data: unknown; error: unknown }>, map: (r: Record<string, unknown>) => T): Promise<T | undefined> {
const { data, error } = await query;
if (error) throw error;
return data ? map(data as Record<string, unknown>) : undefined;
}
return {
async upsertUserByEmail(email) {
const key = normalizeEmail(email);
const existing = await one(db.from(T.users).select("*").eq("email", key).maybeSingle(), userOut);
if (existing) return existing;
const { data, error } = await db.from(T.users).insert({ email: key }).select("*").single();
if (error) throw error;
return userOut(data);
},
async getUserByEmail(email) {
return one(db.from(T.users).select("*").eq("email", normalizeEmail(email)).maybeSingle(), userOut);
},
async getUserById(id) {
return one(db.from(T.users).select("*").eq("id", id).maybeSingle(), userOut);
},
async setUserPublicKey(userId, publicKey) {
const { data, error } = await db.from(T.users).update({ public_key: publicKey }).eq("id", userId).select("*").single();
if (error) throw error;
return userOut(data);
},
async saveLoginCode(code: CredShareLoginCode) {
await db.from(T.loginCodes).delete().eq("email", code.email);
const { error } = await db.from(T.loginCodes).insert({ email: code.email, code_hash: code.codeHash, expires_at: code.expiresAt });
if (error) throw error;
},
async consumeLoginCode(email, codeHash, nowDate) {
const { data, error } = await db.from(T.loginCodes).select("*").eq("email", normalizeEmail(email)).eq("code_hash", codeHash).maybeSingle();
if (error) throw error;
if (!data || new Date(data.expires_at as string).getTime() < nowDate.getTime()) return false;
await db.from(T.loginCodes).delete().eq("email", normalizeEmail(email));
return true;
},
async saveToken(token: CredShareToken) {
const { error } = await db.from(T.tokens).insert({ token_hash: token.tokenHash, user_id: token.userId });
if (error) throw error;
},
async getUserIdForToken(tokenHash) {
const { data, error } = await db.from(T.tokens).select("user_id").eq("token_hash", tokenHash).maybeSingle();
if (error) throw error;
if (data) await db.from(T.tokens).update({ last_used_at: new Date().toISOString() }).eq("token_hash", tokenHash);
return (data?.user_id as string) ?? undefined;
},
async deleteToken(tokenHash) {
await db.from(T.tokens).delete().eq("token_hash", tokenHash);
},
async createTeam({ slug, name, createdBy }) {
const { data, error } = await db.from(T.teams).insert({ slug, name, created_by: createdBy }).select("*").single();
if (error) throw error;
return teamOut(data);
},
async getTeamBySlug(slug) {
return one(db.from(T.teams).select("*").eq("slug", slug).maybeSingle(), teamOut);
},
async listTeamsForUser(userId) {
const { data, error } = await db.from(T.members).select("team_id").eq("user_id", userId).eq("status", "active");
if (error) throw error;
const ids = (data ?? []).map((r) => r.team_id as string);
if (ids.length === 0) return [];
const { data: teams, error: teamErr } = await db.from(T.teams).select("*").in("id", ids);
if (teamErr) throw teamErr;
return (teams ?? []).map(teamOut);
},
async addMember(input) {
const { data, error } = await db
.from(T.members)
.insert({ team_id: input.teamId, user_id: input.userId, email: input.email, role: input.role, status: input.status, invited_by: input.invitedBy, joined_at: input.joinedAt })
.select("*")
.single();
if (error) throw error;
return memberOut(data);
},
async getMember(teamId, userId) {
return one(db.from(T.members).select("*").eq("team_id", teamId).eq("user_id", userId).maybeSingle(), memberOut);
},
async getMemberByEmail(teamId, email) {
return one(db.from(T.members).select("*").eq("team_id", teamId).eq("email", normalizeEmail(email)).maybeSingle(), memberOut);
},
async listMembers(teamId) {
const { data, error } = await db.from(T.members).select("*").eq("team_id", teamId).order("created_at");
if (error) throw error;
return (data ?? []).map(memberOut);
},
async updateMember(teamId, email, patch) {
const update: Record<string, unknown> = {};
if (patch.userId !== undefined) update.user_id = patch.userId;
if (patch.status !== undefined) update.status = patch.status;
if (patch.joinedAt !== undefined) update.joined_at = patch.joinedAt;
if (patch.role !== undefined) update.role = patch.role;
return one(db.from(T.members).update(update).eq("team_id", teamId).eq("email", normalizeEmail(email)).select("*").maybeSingle(), memberOut);
},
async createInvite(input) {
const { data, error } = await db
.from(T.invites)
.insert({ team_id: input.teamId, email: input.email, role: input.role, token_hash: input.tokenHash, created_by: input.createdBy, expires_at: input.expiresAt })
.select("*")
.single();
if (error) throw error;
const r = data as Record<string, unknown>;
return { id: r.id as string, teamId: r.team_id as string, email: r.email as string, role: r.role as CredShareInvite["role"], tokenHash: r.token_hash as string, createdBy: r.created_by as string, expiresAt: r.expires_at as string, acceptedAt: (r.accepted_at as string) ?? null, createdAt: r.created_at as string };
},
async getInviteByTokenHash(tokenHash) {
const { data, error } = await db.from(T.invites).select("*").eq("token_hash", tokenHash).maybeSingle();
if (error) throw error;
if (!data) return undefined;
const r = data as Record<string, unknown>;
return { id: r.id as string, teamId: r.team_id as string, email: r.email as string, role: r.role as CredShareInvite["role"], tokenHash: r.token_hash as string, createdBy: r.created_by as string, expiresAt: r.expires_at as string, acceptedAt: (r.accepted_at as string) ?? null, createdAt: r.created_at as string };
},
async markInviteAccepted(id, at) {
await db.from(T.invites).update({ accepted_at: at }).eq("id", id);
},
async createVault({ teamId, name, createdBy }) {
const { data, error } = await db.from(T.vaults).insert({ team_id: teamId, name, created_by: createdBy }).select("*").single();
if (error) throw error;
return vaultOut(data);
},
async getVault(teamId, name) {
return one(db.from(T.vaults).select("*").eq("team_id", teamId).eq("name", name).maybeSingle(), vaultOut);
},
async getVaultById(vaultId) {
return one(db.from(T.vaults).select("*").eq("id", vaultId).maybeSingle(), vaultOut);
},
async listVaults(teamId) {
const { data, error } = await db.from(T.vaults).select("*").eq("team_id", teamId).order("name");
if (error) throw error;
return (data ?? []).map(vaultOut);
},
async upsertGrant(grant) {
const { error } = await db
.from(T.grants)
.upsert({ vault_id: grant.vaultId, user_id: grant.userId, wrapped_dek: grant.wrappedDek, granted_by: grant.grantedBy }, { onConflict: "vault_id,user_id" });
if (error) throw error;
},
async getGrant(vaultId, userId) {
return one(db.from(T.grants).select("*").eq("vault_id", vaultId).eq("user_id", userId).maybeSingle(), grantOut);
},
async listGrants(vaultId) {
const { data, error } = await db.from(T.grants).select("*").eq("vault_id", vaultId);
if (error) throw error;
return (data ?? []).map(grantOut);
},
async listSecrets(vaultId) {
const { data, error } = await db.from(T.secrets).select("*").eq("vault_id", vaultId).order("name");
if (error) throw error;
return (data ?? []).map(secretOut);
},
async putSecret(secret) {
const { error } = await db
.from(T.secrets)
.upsert({ vault_id: secret.vaultId, name: secret.name, nonce: secret.nonce, ciphertext: secret.ciphertext, fingerprint: secret.fingerprint, version: secret.version, updated_by: secret.updatedBy, updated_at: secret.updatedAt }, { onConflict: "vault_id,name" });
if (error) throw error;
},
async deleteSecret(vaultId, name) {
await db.from(T.secrets).delete().eq("vault_id", vaultId).eq("name", name);
},
async appendAudit(event) {
const { data, error } = await db
.from(T.audit)
.insert({ team_id: event.teamId, vault_id: event.vaultId, actor_user_id: event.actorUserId, action: event.action, key_name: event.keyName, fingerprint: event.fingerprint })
.select("*")
.single();
if (error) throw error;
return auditOut(data);
},
async listAudit(vaultId) {
const { data, error } = await db.from(T.audit).select("*").eq("vault_id", vaultId).order("created_at", { ascending: false });
if (error) throw error;
return (data ?? []).map(auditOut);
}
};
}

View file

@ -1,107 +0,0 @@
/**
* Server-side types for the LogicSRC team credential-sharing API.
*
* The server is a ZERO-KNOWLEDGE relay for secret VALUES: it stores member
* public keys, per-member wrapped vault keys (sealed to those public keys), and
* secret ciphertext + nonces. It never receives or stores a plaintext secret or
* a vault data-encryption key.
*/
export type MemberRole = "owner" | "admin" | "member";
export type MemberStatus = "active" | "invited";
export interface CredShareUser {
id: string;
email: string;
/** X25519 identity public key (base64), or null until the member uploads one. */
publicKey: string | null;
createdAt: string;
}
export interface CredShareTeam {
id: string;
slug: string;
name: string;
createdBy: string;
createdAt: string;
}
export interface CredShareMember {
id: string;
teamId: string;
userId: string | null;
email: string;
role: MemberRole;
status: MemberStatus;
invitedBy: string | null;
createdAt: string;
joinedAt: string | null;
}
export interface CredShareInvite {
id: string;
teamId: string;
email: string;
role: MemberRole;
tokenHash: string;
createdBy: string;
expiresAt: string;
acceptedAt: string | null;
createdAt: string;
}
export interface CredShareVault {
id: string;
teamId: string;
name: string;
createdBy: string;
createdAt: string;
}
/** A vault DEK sealed to one member's public key. Server can't open it. */
export interface CredShareGrant {
vaultId: string;
userId: string;
wrappedDek: string;
grantedBy: string;
createdAt: string;
}
/** One secret, encrypted under the vault DEK. `fingerprint` is a salted hash for diffing. */
export interface CredShareSecret {
vaultId: string;
name: string;
nonce: string;
ciphertext: string;
fingerprint: string;
version: number;
updatedBy: string;
updatedAt: string;
}
export interface CredShareAuditEvent {
id: string;
teamId: string | null;
vaultId: string | null;
actorUserId: string;
action: string;
keyName: string | null;
fingerprint: string | null;
createdAt: string;
}
/** A pending email login code (the code itself is stored hashed). */
export interface CredShareLoginCode {
email: string;
codeHash: string;
expiresAt: string;
createdAt: string;
}
/** An issued API token (stored hashed). */
export interface CredShareToken {
tokenHash: string;
userId: string;
createdAt: string;
lastUsedAt: string | null;
}

View file

@ -11,10 +11,6 @@ import { listSocialAccountProviders, socialAccountsPlugin } from "@logicsrc/plug
import { uGigPlugin } from "@logicsrc/plugin-ugig"; import { uGigPlugin } from "@logicsrc/plugin-ugig";
import { schemas, validate } from "@logicsrc/validators"; import { schemas, validate } from "@logicsrc/validators";
import { buildAgentMailService, mailIdentity } from "./agentmail.js"; import { buildAgentMailService, mailIdentity } from "./agentmail.js";
import { createCredShareApi, type CredShareRequest } from "./credshare/router.js";
import { createMemoryCredShareStore } from "./credshare/store.js";
import { createSupabaseCredShareStore } from "./credshare/supabase-store.js";
import { createResendEmailSender } from "./credshare/email.js";
const registry = createPluginRegistry([coinPayPlugin, uGigPlugin, sh1ptPlugin, c0mputePlugin, feedDiscoveryPlugin, socialAccountsPlugin, emailAccountsPlugin, agentMailPlugin]); const registry = createPluginRegistry([coinPayPlugin, uGigPlugin, sh1ptPlugin, c0mputePlugin, feedDiscoveryPlugin, socialAccountsPlugin, emailAccountsPlugin, agentMailPlugin]);
@ -61,42 +57,6 @@ const c0mputeWorkers = [
{ id: "worker_pool_1", region: "us-west", status: "preview", capacity: "wip" } { id: "worker_pool_1", region: "us-west", status: "preview", capacity: "wip" }
]; ];
// Credential-sharing API: Supabase-backed when SUPABASE_URL + service key are
// present, else an in-process memory store (local dev / tests). Zero-knowledge:
// the server only ever relays ciphertext, wrapped keys, and public keys.
const credShareApi = createCredShareApi({
store: createSupabaseCredShareStore() ?? createMemoryCredShareStore(),
email: createResendEmailSender(),
webBaseUrl: process.env.LOGICSRC_WEB_URL || "https://logicsrc.com"
});
const CREDSHARE_PREFIX = "/api/credshare";
async function handleCredShare(request: IncomingMessage, response: ServerResponse, url: URL) {
const method = request.method ?? "GET";
let body: unknown;
if (method === "POST" || method === "PUT" || method === "PATCH") {
try {
body = await readJson(request);
} catch {
json(response, 400, { error: "Invalid JSON body" });
return;
}
}
const authHeader = request.headers["authorization"];
const header = Array.isArray(authHeader) ? authHeader[0] : authHeader;
const token = header?.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() : undefined;
const req: CredShareRequest = {
method,
path: url.pathname.slice(CREDSHARE_PREFIX.length) || "/",
query: url.searchParams,
body,
token
};
const result = await credShareApi.handle(req);
json(response, result.status, result.body);
}
class InvalidJsonBodyError extends Error { class InvalidJsonBodyError extends Error {
constructor() { constructor() {
super("Invalid JSON body"); super("Invalid JSON body");
@ -125,7 +85,7 @@ async function route(request: IncomingMessage, response: ServerResponse) {
json(response, 200, { json(response, 200, {
ok: true, ok: true,
service: "commandboard-api", service: "commandboard-api",
endpoints: ["/health", "/api/boards", "/api/tasks", "/api/plugins", "/api/schemas", "/api/accounts/providers", "/api/accounts", "/api/social/providers", "/api/email/providers", "/api/feeds/discover", "/api/feeds/providers", "/api/credshare/auth/request", "/api/credshare/auth/verify", "/api/credshare/keys", "/api/credshare/teams", "/api/credshare/teams/:slug/members", "/api/credshare/teams/:slug/invites", "/api/credshare/teams/:slug/vaults", "/api/credshare/invites/accept", "/api/credshare/vaults/:id/secrets", "/api/credshare/vaults/:id/grant", "/api/credshare/vaults/:id/grants", "/api/credshare/vaults/:id/audit", "/api/plugins/agentmail/mailboxes", "/api/plugins/agentmail/mailboxes/:mailbox/messages", "/api/plugins/agentmail/mailboxes/:mailbox/messages/:uid", "/api/plugins/agentmail/search", "/api/plugins/agentmail/messages", "/rss/discover/:keyword.xml", "/opml/discover/:keyword.xml", "/atom/discover/:keyword.xml", "/json-feed/discover/:keyword.json"] endpoints: ["/health", "/api/boards", "/api/tasks", "/api/plugins", "/api/schemas", "/api/accounts/providers", "/api/accounts", "/api/social/providers", "/api/email/providers", "/api/feeds/discover", "/api/feeds/providers", "/api/plugins/agentmail/mailboxes", "/api/plugins/agentmail/mailboxes/:mailbox/messages", "/api/plugins/agentmail/mailboxes/:mailbox/messages/:uid", "/api/plugins/agentmail/search", "/api/plugins/agentmail/messages", "/rss/discover/:keyword.xml", "/opml/discover/:keyword.xml", "/atom/discover/:keyword.xml", "/json-feed/discover/:keyword.json"]
}); });
return; return;
} }
@ -260,11 +220,6 @@ async function route(request: IncomingMessage, response: ServerResponse) {
return; return;
} }
if (url.pathname === CREDSHARE_PREFIX || url.pathname.startsWith(`${CREDSHARE_PREFIX}/`)) {
await handleCredShare(request, response, url);
return;
}
if (url.pathname === "/api/plugins/agentmail" || url.pathname.startsWith("/api/plugins/agentmail/")) { if (url.pathname === "/api/plugins/agentmail" || url.pathname.startsWith("/api/plugins/agentmail/")) {
await handleAgentMail(request, response, url); await handleAgentMail(request, response, url);
return; return;

View file

@ -0,0 +1,98 @@
#!/bin/sh
# LogicSRC — one-line installer for the `logicsrc` CLI (from the GitHub repo).
#
# curl -fsSL https://logicsrc.com/install.sh | sh
#
# Subcommands:
# curl -fsSL https://logicsrc.com/install.sh | sh -s -- install (default)
# curl -fsSL https://logicsrc.com/install.sh | sh -s -- update
# curl -fsSL https://logicsrc.com/install.sh | sh -s -- uninstall
#
# What it does:
# 1. Detects OS (Linux/macOS — Windows: use WSL) and requires Node 18+.
# 2. Fetches the repo tarball from GitHub into $LOGICSRC_HOME/src.
# 3. `npm install` + `npm run build:cli` (builds only the CLI's workspaces).
# 4. Drops a `logicsrc` wrapper on $HOME/.local/bin.
#
# Env overrides:
# LOGICSRC_HOME=/path install dir (default: $HOME/.logicsrc-cli)
# LOGICSRC_BIN=/path/dir wrapper bin dir (default: $HOME/.local/bin)
# LOGICSRC_REF=branch|tag git ref (default: master)
set -eu
GH_REPO="profullstack/logicsrc"
LOGICSRC_REF="${LOGICSRC_REF:-master}"
TARBALL_URL="https://codeload.github.com/$GH_REPO/tar.gz/$LOGICSRC_REF"
# --- operator identity (curl|sh may land with HOME/USER unset) ---
_home() { if [ -n "${HOME:-}" ] && [ -d "$HOME" ]; then echo "$HOME"; else echo "${HOME:-/tmp}"; fi; }
HOME="$(_home)"; export HOME
LOGICSRC_HOME="${LOGICSRC_HOME:-$HOME/.logicsrc-cli}"
LOGICSRC_BIN="${LOGICSRC_BIN:-$HOME/.local/bin}"
SRC_DIR="$LOGICSRC_HOME/src"
WRAPPER="$LOGICSRC_BIN/logicsrc"
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
G=$(printf '\033[32m'); Y=$(printf '\033[33m'); B=$(printf '\033[34m'); R=$(printf '\033[31m'); X=$(printf '\033[0m')
else G=''; Y=''; B=''; R=''; X=''; fi
info() { printf '%s==>%s %s\n' "$B" "$X" "$*"; }
ok() { printf '%s ✓%s %s\n' "$G" "$X" "$*"; }
warn() { printf '%s !%s %s\n' "$Y" "$X" "$*" >&2; }
fail() { printf '%s ✗%s %s\n' "$R" "$X" "$*" >&2; exit 1; }
need() { command -v "$1" >/dev/null 2>&1 || fail "missing '$1' — please install it and re-run."; }
detect_os() {
case "$(uname -s)" in
Linux) : ;; Darwin) : ;;
*) fail "unsupported OS (Linux and macOS only — Windows: use WSL)";;
esac
}
check_node() {
need node
major="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)"
[ "$major" -ge 18 ] 2>/dev/null || fail "Node 18+ required (found $(node -v 2>/dev/null || echo none)). Install from https://nodejs.org or via mise/nvm."
need npm
}
do_install() {
detect_os; check_node
need curl; need tar
info "fetching logicsrc@$LOGICSRC_REF from GitHub…"
mkdir -p "$SRC_DIR"
tmp="$(mktemp -d)"
curl -fsSL "$TARBALL_URL" | tar -xz -C "$tmp" --strip-components=1
rm -rf "$SRC_DIR"; mkdir -p "$(dirname "$SRC_DIR")"; mv "$tmp" "$SRC_DIR"
ok "downloaded to $SRC_DIR"
info "installing dependencies (this can take a minute)…"
( cd "$SRC_DIR" && npm install --no-audit --no-fund --ignore-scripts >/dev/null 2>&1 ) || fail "npm install failed — run it by hand in $SRC_DIR"
info "building the CLI…"
( cd "$SRC_DIR" && npm run build:cli >/dev/null 2>&1 ) || fail "build failed — run 'npm run build:cli' in $SRC_DIR"
mkdir -p "$LOGICSRC_BIN"
cat > "$WRAPPER" <<EOF
#!/bin/sh
exec node "$SRC_DIR/packages/cli/dist/index.js" "\$@"
EOF
chmod +x "$WRAPPER"
ok "installed logicsrc → $WRAPPER"
case ":$PATH:" in
*":$LOGICSRC_BIN:"*) : ;;
*) warn "add $LOGICSRC_BIN to your PATH: export PATH=\"$LOGICSRC_BIN:\$PATH\"";;
esac
printf '\n%s🔐 logicsrc installed.%s Next:\n logicsrc login\n logicsrc teams push <team> prod --env .env\n\n' "$G" "$X"
}
do_uninstall() {
rm -f "$WRAPPER"; rm -rf "$LOGICSRC_HOME"
ok "removed logicsrc ($WRAPPER, $LOGICSRC_HOME)"
}
case "${1:-install}" in
install|update|upgrade) do_install ;;
remove|uninstall) do_uninstall ;;
*) fail "unknown command '$1' (install | update | uninstall)";;
esac

View file

@ -1,25 +0,0 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { SiteShell } from "@/components/site-shell";
import { TeamsClient } from "@/components/teams-client";
export const metadata: Metadata = {
title: "Accept invite · LogicSRC Teams",
description: "Accept a LogicSRC credential-sharing team invite.",
robots: { index: false },
};
// Invite emails link here as /teams/accept?token=… — log in, then the token is
// auto-accepted by the client.
export default async function AcceptInvitePage({
searchParams,
}: {
searchParams: Promise<{ token?: string }>;
}): Promise<ReactNode> {
const { token } = await searchParams;
return (
<SiteShell active="Credentials">
<TeamsClient initialToken={token} />
</SiteShell>
);
}

View file

@ -1,19 +0,0 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { SiteShell } from "@/components/site-shell";
import { TeamsClient } from "@/components/teams-client";
export const metadata: Metadata = {
title: "Teams · Credential Sharing · LogicSRC",
description:
"Manage credential-sharing teams and invites. Share secrets with teammates by email — end-to-end encrypted, so the server never sees plaintext. Decryption happens only in the logicsrc CLI.",
alternates: { canonical: "/teams" },
};
export default function TeamsPage(): ReactNode {
return (
<SiteShell active="Credentials">
<TeamsClient />
</SiteShell>
);
}

View file

@ -8,7 +8,6 @@ const NAV: Array<{ href: string; label: string; external?: boolean }> = [
{ href: "/agent-swarm", label: "Soon" }, { href: "/agent-swarm", label: "Soon" },
{ href: "/agentbyte", label: "AgentByte" }, { href: "/agentbyte", label: "AgentByte" },
{ href: "/credential-sharing", label: "Credentials" }, { href: "/credential-sharing", label: "Credentials" },
{ href: "/teams", label: "Teams" },
{ href: "/#cli", label: "CLI" }, { href: "/#cli", label: "CLI" },
{ href: "/docs", label: "Docs" }, { href: "/docs", label: "Docs" },
{ href: "/blog", label: "Blog" }, { href: "/blog", label: "Blog" },

View file

@ -1,283 +0,0 @@
"use client";
import { useCallback, useEffect, useState } from "react";
/**
* Team credential-sharing management UI. The browser holds NO private key, so it
* never decrypts secrets it manages membership, invites, and shows vault
* metadata (names, secret counts, who has access). Actual secret values are only
* ever decrypted in the `logicsrc` CLI on a device that holds the identity key.
*/
const API_BASE = (process.env.NEXT_PUBLIC_COMMANDBOARD_API_URL ?? "https://commandboard.run").replace(/\/$/, "");
const TOKEN_KEY = "logicsrc.credshare.token";
const EMAIL_KEY = "logicsrc.credshare.email";
interface Team {
slug: string;
name: string;
}
interface Member {
email: string;
role: string;
status: string;
hasPublicKey: boolean;
}
interface Vault {
id: string;
name: string;
hasAccess: boolean;
secretCount: number;
}
async function api<T>(path: string, init: RequestInit & { token?: string } = {}): Promise<T> {
const headers: Record<string, string> = { accept: "application/json", ...(init.headers as Record<string, string>) };
if (init.body) headers["content-type"] = "application/json";
if (init.token) headers["authorization"] = `Bearer ${init.token}`;
const res = await fetch(`${API_BASE}/api/credshare${path}`, { ...init, headers });
const text = await res.text();
const parsed = text ? JSON.parse(text) : undefined;
if (!res.ok) throw new Error((parsed && parsed.error) || `${res.status} ${res.statusText}`);
return parsed as T;
}
export function TeamsClient({ initialToken }: { initialToken?: string }): React.ReactElement {
const [token, setToken] = useState<string | null>(null);
const [email, setEmail] = useState("");
const [codeSent, setCodeSent] = useState(false);
const [code, setCode] = useState("");
const [me, setMe] = useState<{ email: string; publicKey: string | null } | null>(null);
const [teams, setTeams] = useState<Team[]>([]);
const [active, setActive] = useState<string | null>(null);
const [members, setMembers] = useState<Member[]>([]);
const [vaults, setVaults] = useState<Vault[]>([]);
const [inviteEmail, setInviteEmail] = useState("");
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
const saved = window.localStorage.getItem(TOKEN_KEY);
if (saved) setToken(saved);
setEmail(window.localStorage.getItem(EMAIL_KEY) ?? "");
}, []);
const refreshTeams = useCallback(async (tok: string) => {
const data = await api<{ user: { email: string; publicKey: string | null }; teams: Team[] }>("/me", { token: tok });
setMe(data.user);
setTeams(data.teams);
setActive((cur) => cur ?? data.teams[0]?.slug ?? null);
}, []);
useEffect(() => {
if (!token) return;
refreshTeams(token).catch((e) => setError(String(e.message ?? e)));
}, [token, refreshTeams]);
const loadTeam = useCallback(
async (slug: string, tok: string) => {
const [m, v] = await Promise.all([
api<{ members: Member[] }>(`/teams/${encodeURIComponent(slug)}/members`, { token: tok }),
api<{ vaults: Vault[] }>(`/teams/${encodeURIComponent(slug)}/vaults`, { token: tok })
]);
setMembers(m.members);
setVaults(v.vaults);
},
[]
);
useEffect(() => {
if (token && active) loadTeam(active, token).catch((e) => setError(String(e.message ?? e)));
}, [token, active, loadTeam]);
// Auto-accept an invite passed via ?token= once the user is logged in.
useEffect(() => {
if (!initialToken || !token) return;
(async () => {
try {
setBusy(true);
const res = await api<{ team?: Team }>("/invites/accept", { method: "POST", token, body: JSON.stringify({ token: initialToken }) });
setStatus(`Joined ${res.team?.slug ?? "the team"}. Grant + pull secrets from the CLI.`);
await refreshTeams(token);
if (res.team) setActive(res.team.slug);
} catch (e) {
setError(String((e as Error).message ?? e));
} finally {
setBusy(false);
}
})();
}, [initialToken, token, refreshTeams]);
async function requestCode() {
setError(null);
setBusy(true);
try {
const res = await api<{ emailSent: boolean; devCode?: string }>("/auth/request", { method: "POST", body: JSON.stringify({ email }) });
setCodeSent(true);
window.localStorage.setItem(EMAIL_KEY, email);
setStatus(res.emailSent ? `Code sent to ${email}.` : `Dev mode — your code is ${res.devCode}.`);
if (res.devCode) setCode(res.devCode);
} catch (e) {
setError(String((e as Error).message ?? e));
} finally {
setBusy(false);
}
}
async function verifyCode() {
setError(null);
setBusy(true);
try {
const res = await api<{ token: string }>("/auth/verify", { method: "POST", body: JSON.stringify({ email, code }) });
window.localStorage.setItem(TOKEN_KEY, res.token);
setToken(res.token);
setCodeSent(false);
setCode("");
setStatus("Logged in.");
} catch (e) {
setError(String((e as Error).message ?? e));
} finally {
setBusy(false);
}
}
async function createTeam() {
const slug = window.prompt("New team slug (lowercase, dashes):");
if (!slug || !token) return;
try {
await api("/teams", { method: "POST", token, body: JSON.stringify({ slug }) });
await refreshTeams(token);
setActive(slug);
} catch (e) {
setError(String((e as Error).message ?? e));
}
}
async function invite() {
if (!inviteEmail || !active || !token) return;
setBusy(true);
setError(null);
try {
const res = await api<{ emailSent: boolean; token?: string }>(`/teams/${encodeURIComponent(active)}/invites`, { method: "POST", token, body: JSON.stringify({ email: inviteEmail }) });
setStatus(res.emailSent ? `Invited ${inviteEmail}.` : `Invited ${inviteEmail}. Share this accept link: ${window.location.origin}/teams/accept?token=${res.token}`);
setInviteEmail("");
await loadTeam(active, token);
} catch (e) {
setError(String((e as Error).message ?? e));
} finally {
setBusy(false);
}
}
function logout() {
window.localStorage.removeItem(TOKEN_KEY);
setToken(null);
setMe(null);
setTeams([]);
setActive(null);
setStatus("Logged out.");
}
const notice = (
<>
{error && <p style={{ color: "#e5484d", margin: "0.5rem 0" }}> {error}</p>}
{status && !error && <p style={{ color: "#30a46c", margin: "0.5rem 0" }}>{status}</p>}
</>
);
if (!token) {
return (
<div className="band" style={{ maxWidth: "32rem" }}>
<div className="section-head">
<h2>Team credential sharing</h2>
<p>Log in by email to manage teams and invites. Secrets stay end-to-end encrypted decrypt them with the <code>logicsrc</code> CLI, never here.</p>
</div>
{notice}
{!codeSent ? (
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem", flexWrap: "wrap" }}>
<input type="email" placeholder="you@example.com" value={email} onChange={(e) => setEmail(e.target.value)} style={inputStyle} />
<button onClick={requestCode} disabled={busy || !email} style={buttonStyle}>Send code</button>
</div>
) : (
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem", flexWrap: "wrap" }}>
<input inputMode="numeric" placeholder="6-digit code" value={code} onChange={(e) => setCode(e.target.value)} style={inputStyle} />
<button onClick={verifyCode} disabled={busy || !code} style={buttonStyle}>Verify</button>
</div>
)}
</div>
);
}
return (
<div className="band" style={{ maxWidth: "48rem" }}>
<div className="section-head" style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: "1rem" }}>
<h2>Your teams</h2>
<span style={{ fontSize: "0.85rem", opacity: 0.7 }}>
{me?.email} {me && !me.publicKey && "· ⚠ no CLI key yet (run logicsrc login)"} · <a onClick={logout} style={{ cursor: "pointer" }}>log out</a>
</span>
</div>
{notice}
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", margin: "0.75rem 0" }}>
{teams.map((t) => (
<button key={t.slug} onClick={() => setActive(t.slug)} style={{ ...chipStyle, ...(active === t.slug ? chipActive : {}) }}>
{t.name || t.slug}
</button>
))}
<button onClick={createTeam} style={chipStyle}>+ new team</button>
</div>
{active && (
<>
<h3 style={{ marginTop: "1.5rem" }}>Members</h3>
<table style={tableStyle}>
<thead>
<tr><th style={thStyle}>Email</th><th style={thStyle}>Role</th><th style={thStyle}>Status</th><th style={thStyle}>CLI key</th></tr>
</thead>
<tbody>
{members.map((m) => (
<tr key={m.email}>
<td style={tdStyle}>{m.email}</td><td style={tdStyle}>{m.role}</td>
<td style={tdStyle}>{m.status}</td><td style={tdStyle}>{m.hasPublicKey ? "✓" : "—"}</td>
</tr>
))}
</tbody>
</table>
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.75rem", flexWrap: "wrap" }}>
<input type="email" placeholder="teammate@example.com" value={inviteEmail} onChange={(e) => setInviteEmail(e.target.value)} style={inputStyle} />
<button onClick={invite} disabled={busy || !inviteEmail} style={buttonStyle}>Invite</button>
</div>
<h3 style={{ marginTop: "1.5rem" }}>Vaults</h3>
{vaults.length === 0 ? (
<p style={{ opacity: 0.7 }}>No vaults yet. Create one from the CLI: <code>logicsrc teams push {active} prod</code></p>
) : (
<table style={tableStyle}>
<thead><tr><th style={thStyle}>Vault</th><th style={thStyle}>Secrets</th><th style={thStyle}>Your access</th></tr></thead>
<tbody>
{vaults.map((v) => (
<tr key={v.id}>
<td style={tdStyle}><code>{v.name}</code></td><td style={tdStyle}>{v.secretCount}</td>
<td style={tdStyle}>{v.hasAccess ? "✓ granted" : "— ask a member to grant you"}</td>
</tr>
))}
</tbody>
</table>
)}
<p style={{ opacity: 0.7, marginTop: "1rem", fontSize: "0.9rem" }}>
Pull secrets on your machine: <code>logicsrc teams pull {active} &lt;vault&gt;</code> values are decrypted locally with your key. The server (and this page) only ever see ciphertext.
</p>
</>
)}
</div>
);
}
const inputStyle: React.CSSProperties = { padding: "0.6rem 0.8rem", borderRadius: "0.5rem", border: "1px solid var(--border, #333)", background: "transparent", color: "inherit", minWidth: "16rem", flex: 1 };
const buttonStyle: React.CSSProperties = { padding: "0.6rem 1rem", borderRadius: "0.5rem", border: "1px solid var(--border, #333)", background: "var(--accent, #5b7cfa)", color: "#fff", cursor: "pointer" };
const chipStyle: React.CSSProperties = { padding: "0.4rem 0.8rem", borderRadius: "999px", border: "1px solid var(--border, #333)", background: "transparent", color: "inherit", cursor: "pointer" };
const chipActive: React.CSSProperties = { background: "var(--accent, #5b7cfa)", color: "#fff", borderColor: "transparent" };
const tableStyle: React.CSSProperties = { width: "100%", borderCollapse: "collapse", marginTop: "0.5rem", fontSize: "0.92rem" };
const thStyle: React.CSSProperties = { textAlign: "left", padding: "0.4rem 0.6rem", borderBottom: "1px solid var(--border, #333)", opacity: 0.7, fontWeight: 600 };
const tdStyle: React.CSSProperties = { padding: "0.4rem 0.6rem", borderBottom: "1px solid var(--border, #222)" };

22
apps/pwa/.env.example Normal file
View file

@ -0,0 +1,22 @@
# LogicSRC credentials app — copy to .env and fill in.
NODE_ENV=development
PORT=8080
PUBLIC_ORIGIN=http://localhost:8080
# datastore: Turso (libSQL) in prod, or file:./data/local.db for local dev
TURSO_DATABASE_URL=file:./data/local.db
TURSO_AUTH_TOKEN=
# auth — generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
SESSION_SECRET=change-me-32-bytes-hex
# email invites via Resend (optional — without it, invite tokens are returned in the API response)
RESEND_API_KEY=
CREDSHARE_EMAIL_FROM=LogicSRC <noreply@logicsrc.com>
# "Sign in with CoinPay" (optional)
COINPAY_API_BASE=https://coinpayportal.com
COINPAY_OAUTH_AUTHORIZE_URL=https://coinpayportal.com/oauth/authorize
COINPAY_OAUTH_TOKEN_URL=https://coinpayportal.com/api/oauth/token
COINPAY_OAUTH_USERINFO_URL=https://coinpayportal.com/api/oauth/userinfo
COINPAY_OAUTH_CLIENT_ID=

8
apps/pwa/.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
node_modules/
data/
*.db
*.db-*
.env
.env.local
npm-debug.log*
.DS_Store

1
apps/pwa/Procfile Normal file
View file

@ -0,0 +1 @@
web: npm start

16
apps/pwa/README.md Normal file
View file

@ -0,0 +1,16 @@
# @logicsrc/pwa — LogicSRC credentials
Express + libSQL/Turso app for **team credential sharing**: auth (email/password,
passkeys, CoinPay OAuth, sessions, `lsk_` CLI API keys) + end-to-end-encrypted
team vaults. Zero-knowledge — the server only stores ciphertext, per-member
sealed vault keys, and identity public keys. Decryption happens in the
`logicsrc` CLI.
```bash
cp .env.example .env # set SESSION_SECRET; TURSO_* for prod (else local file db)
npm install
npm start # migrates on boot, serves on :8080
```
The CLI connects with `LOGICSRC_API=<origin> logicsrc login` (browser OAuth-PKCE
loopback → an `lsk_` key). See `docs/credential-sharing.md` in the repo root.

23
apps/pwa/package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "@logicsrc/pwa",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "LogicSRC credentials — Express + libSQL/Turso app: auth + end-to-end-encrypted team credential sharing.",
"engines": {
"node": ">=20"
},
"scripts": {
"start": "node src/server.mjs",
"dev": "node --watch src/server.mjs",
"migrate": "node src/migrate.mjs",
"test": "node --test"
},
"dependencies": {
"@libsql/client": "^0.14.0",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.1.0",
"cookie-parser": "^1.4.7",
"express": "^4.21.2"
}
}

6
apps/pwa/public/icon.svg Normal file
View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" fill="#070806"/>
<rect x="96" y="96" width="320" height="320" rx="64" fill="#a6ff1a"/>
<text x="256" y="256" font-family="Helvetica,Arial,sans-serif" font-size="240" font-weight="800"
fill="#0a1400" text-anchor="middle" dominant-baseline="central">M</text>
</svg>

After

Width:  |  Height:  |  Size: 381 B

View file

@ -0,0 +1,15 @@
{
"name": "LogicSRC Credentials",
"short_name": "LogicSRC",
"description": "Human-in-the-loop approvals for your moshscript loops.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#070806",
"theme_color": "#070806",
"icons": [
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" },
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}

View file

@ -0,0 +1,53 @@
/* Passkey button: try to sign in with a discoverable passkey; if there's none,
register a new one. Uses the @simplewebauthn/browser UMD bundle (/vendor). */
(function () {
var btn = document.getElementById("passkey-btn");
var msg = document.getElementById("passkey-msg");
if (!btn) return;
function csrf() {
var m = document.cookie.match(/(?:^|; )mc_csrf=([^;]+)/);
return m ? decodeURIComponent(m[1]) : "";
}
function post(url, body) {
return fetch(url, {
method: "POST",
headers: { "content-type": "application/json", "x-csrf-token": csrf() },
body: JSON.stringify(body || {}),
});
}
function say(t) { if (msg) msg.textContent = t; }
async function register() {
say("creating a passkey…");
var opts = await (await post("/auth/passkey/register/options")).json();
var att = await SimpleWebAuthnBrowser.startRegistration({ optionsJSON: opts });
var r = await post("/auth/passkey/register/verify", att);
var out = await r.json();
if (out.ok) location.href = out.redirect || "/";
else say(out.error || "couldn't create passkey");
}
async function login() {
var opts = await (await post("/auth/passkey/login/options")).json();
var asr = await SimpleWebAuthnBrowser.startAuthentication({ optionsJSON: opts });
var r = await post("/auth/passkey/login/verify", asr);
var out = await r.json();
if (out.ok) { location.href = out.redirect || "/"; return true; }
throw new Error(out.error || "sign-in failed");
}
btn.addEventListener("click", async function () {
if (!window.PublicKeyCredential) { say("this device doesn't support passkeys"); return; }
btn.disabled = true;
try {
await login();
} catch (e) {
// no discoverable credential / user cancelled login → offer to register
try { await register(); }
catch (e2) { say(String(e2.message || e2)); }
} finally {
btn.disabled = false;
}
});
})();

55
apps/pwa/public/sw.js Normal file
View file

@ -0,0 +1,55 @@
/* LogicSRC PWA service worker — offline app shell (network-first for docs). */
const CACHE = "logicsrc-v1";
const SHELL = ["/", "/icon.svg", "/manifest.webmanifest", "/passkey.js"];
self.addEventListener("install", (e) => {
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
});
self.addEventListener("activate", (e) => {
e.waitUntil(
caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))).then(() => self.clients.claim())
);
});
// approval push notifications
self.addEventListener("push", (e) => {
let d = {};
try { d = e.data ? e.data.json() : {}; } catch (_) {}
e.waitUntil(self.registration.showNotification(d.title || "LogicSRC", {
body: d.body || "You have an approval waiting.",
icon: "/icon.svg",
badge: "/icon.svg",
data: { url: d.url || "/" },
tag: "logicsrc",
}));
});
self.addEventListener("notificationclick", (e) => {
e.notification.close();
const url = (e.notification.data && e.notification.data.url) || "/";
e.waitUntil(clients.matchAll({ type: "window" }).then((cs) => {
for (const c of cs) if ("focus" in c) { c.navigate(url); return c.focus(); }
return clients.openWindow(url);
}));
});
self.addEventListener("fetch", (e) => {
const { request } = e;
if (request.method !== "GET") return; // never cache POSTs / API writes
const url = new URL(request.url);
if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/auth/") || url.pathname.startsWith("/webhooks/")) return;
// network-first, fall back to cache (so approvals stay fresh, offline still loads a shell)
e.respondWith(
fetch(request)
.then((res) => {
if (res.ok && url.origin === location.origin) {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(request, copy));
}
return res;
})
.catch(() => caches.match(request).then((r) => r || caches.match("/")))
);
});

10
apps/pwa/railway.json Normal file
View file

@ -0,0 +1,10 @@
{
"$schema": "https://railway.app/railway.schema.json",
"build": { "builder": "NIXPACKS" },
"deploy": {
"startCommand": "npm start",
"healthcheckPath": "/healthz",
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 10
}
}

64
apps/pwa/src/config.mjs Normal file
View file

@ -0,0 +1,64 @@
// Central config. Reads .env (if present) with zero deps, then process.env wins.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
// Tiny .env loader — does not override anything already in the environment.
function loadEnv() {
const file = path.join(ROOT, ".env");
if (!fs.existsSync(file)) return;
for (const line of fs.readFileSync(file, "utf8").split("\n")) {
const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i.exec(line);
if (!m) continue;
const key = m[1];
if (process.env[key] !== undefined) continue;
let val = m[2];
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
process.env[key] = val;
}
}
loadEnv();
const origin = (process.env.PUBLIC_ORIGIN || `http://localhost:${process.env.PORT || 8080}`).replace(/\/+$/, "");
const rpID = new URL(origin).hostname;
export const config = {
root: ROOT,
env: process.env.NODE_ENV || "development",
port: Number(process.env.PORT || 8080),
origin,
// WebAuthn relying party = this host.
rpID,
rpName: "LogicSRC",
sessionSecret: process.env.SESSION_SECRET || "dev-insecure-secret-change-me",
db: {
// Turso (libSQL) in prod; a local file for dev. TURSO_* takes precedence.
url: process.env.TURSO_DATABASE_URL || process.env.DATABASE_URL || "file:./data/local.db",
authToken: process.env.TURSO_AUTH_TOKEN || process.env.DATABASE_AUTH_TOKEN || undefined,
},
resend: {
apiKey: process.env.RESEND_API_KEY || "",
from: process.env.CREDSHARE_EMAIL_FROM || process.env.RESEND_FROM || "LogicSRC <noreply@logicsrc.com>",
},
coinpay: {
apiBase: (process.env.COINPAY_API_BASE || "https://coinpayportal.com").replace(/\/+$/, ""),
businessId: process.env.COINPAY_BUSINESS_ID || "",
webhookSecret: process.env.COINPAY_WEBHOOK_SECRET || "",
oauth: {
authorizeUrl: process.env.COINPAY_OAUTH_AUTHORIZE_URL || "",
tokenUrl: process.env.COINPAY_OAUTH_TOKEN_URL || "",
userinfoUrl: process.env.COINPAY_OAUTH_USERINFO_URL || "",
clientId: process.env.COINPAY_OAUTH_CLIENT_ID || "",
redirectUri: `${origin}/auth/coinpay/callback`,
scope: process.env.COINPAY_OAUTH_SCOPE || "openid profile",
},
},
get coinpayLoginEnabled() {
return Boolean(this.coinpay.oauth.authorizeUrl && this.coinpay.oauth.clientId);
},
secure: (process.env.NODE_ENV || "development") === "production",
};

29
apps/pwa/src/db.mjs Normal file
View file

@ -0,0 +1,29 @@
// libSQL (SQLite / Turso) client + a tiny query helper.
import { createClient } from "@libsql/client";
import fs from "node:fs";
import path from "node:path";
import { config } from "./config.mjs";
// For a local file: url, make sure the directory exists.
if (config.db.url.startsWith("file:")) {
const p = config.db.url.slice("file:".length);
const dir = path.dirname(path.resolve(config.root, p));
fs.mkdirSync(dir, { recursive: true });
}
export const db = createClient({ url: config.db.url, authToken: config.db.authToken });
/** Run a statement; returns the raw result. */
export const run = (sql, args = []) => db.execute({ sql, args });
/** First row (or null). */
export async function get(sql, args = []) {
const r = await db.execute({ sql, args });
return r.rows[0] ?? null;
}
/** All rows. */
export async function all(sql, args = []) {
const r = await db.execute({ sql, args });
return r.rows;
}

View file

@ -0,0 +1,36 @@
// API keys (lsk_…) for the logicsrc CLI to authenticate.
import { get, all, run } from "../db.mjs";
import { id, token, sha256 } from "./crypto.mjs";
// Returns { plaintext, row } — plaintext shown ONCE.
export async function createApiKey(userId, name = "cli") {
const plaintext = "lsk_" + token(24);
const prefix = plaintext.slice(0, 12);
const row = { id: id(), user_id: userId, name, token_hash: sha256(plaintext), prefix, created_at: Date.now() };
await run(
`INSERT INTO api_keys (id, user_id, name, token_hash, prefix, created_at) VALUES (?,?,?,?,?,?)`,
[row.id, row.user_id, row.name, row.token_hash, row.prefix, row.created_at]
);
return { plaintext, row };
}
// Resolve a Bearer token to its owning user (or null). Updates last_used_at.
export async function userForApiKey(bearer) {
if (!bearer) return null;
const key = await get(`SELECT * FROM api_keys WHERE token_hash = ?`, [sha256(bearer)]);
if (!key) return null;
await run(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`, [Date.now(), key.id]);
return get(`SELECT * FROM users WHERE id = ?`, [key.user_id]);
}
export const listApiKeys = (userId) =>
all(`SELECT id, name, prefix, created_at, last_used_at FROM api_keys WHERE user_id = ? ORDER BY created_at DESC`, [userId]);
export const revokeApiKey = (userId, keyId) =>
run(`DELETE FROM api_keys WHERE id = ? AND user_id = ?`, [keyId, userId]);
// Pull the Bearer token off a request.
export function bearer(req) {
const h = req.get("authorization") || "";
return h.startsWith("Bearer ") ? h.slice(7).trim() : null;
}

View file

@ -0,0 +1,41 @@
// Password hashing (scrypt) + signed cookies + tokens — all node:crypto, no deps.
import crypto from "node:crypto";
import { config } from "../config.mjs";
// ---- passwords (scrypt) ----
export function hashPassword(password) {
const salt = crypto.randomBytes(16);
const dk = crypto.scryptSync(String(password), salt, 32);
return `scrypt$${salt.toString("hex")}$${dk.toString("hex")}`;
}
export function verifyPassword(password, stored) {
if (!stored || !stored.startsWith("scrypt$")) return false;
const [, saltHex, hashHex] = stored.split("$");
const salt = Buffer.from(saltHex, "hex");
const expected = Buffer.from(hashHex, "hex");
const dk = crypto.scryptSync(String(password), salt, expected.length);
return dk.length === expected.length && crypto.timingSafeEqual(dk, expected);
}
// ---- ids / tokens ----
export const id = () => crypto.randomUUID();
export const token = (bytes = 32) => crypto.randomBytes(bytes).toString("base64url");
export const sha256 = (s) => crypto.createHash("sha256").update(s).digest("hex");
// ---- signed cookies (stateless ceremony state) ----
export function sign(value) {
const payload = Buffer.from(JSON.stringify(value)).toString("base64url");
const mac = crypto.createHmac("sha256", config.sessionSecret).update(payload).digest("base64url");
return `${payload}.${mac}`;
}
export function unsign(signed) {
if (!signed || typeof signed !== "string" || !signed.includes(".")) return null;
const [payload, mac] = signed.split(".");
const expected = crypto.createHmac("sha256", config.sessionSecret).update(payload).digest("base64url");
const a = Buffer.from(mac);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
try { return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); } catch { return null; }
}

121
apps/pwa/src/lib/html.mjs Normal file
View file

@ -0,0 +1,121 @@
// Server-rendered views in the LogicSRC brand (matches logicsrc.com):
// light ground (#f6f7f4), ink text (#101418), green accent (#0a7d59), Inter.
// Keeps the same class vocabulary the auth routes use so they reskin for free.
export function esc(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c])
);
}
export const BRAND_CSS = `
:root{
color-scheme:light;
--bg:#f6f7f4;--surface:#ffffff;--surface-2:#f0f2ec;
--ink:#101418;--text:#101418;--dim:#58615b;--faint:#8b938a;
--line:#d9ded4;--line-2:#c7cec1;
--green:#0a7d59;--green-2:#0b8f66;--green-ink:#ffffff;--mint:#5ac8a6;
--danger:#c23a3a;--warn:#b7791f;
--rail:#101418;--rail-text:#f6f7f4;--rail-dim:#b5beb2;--rail-line:#263039;
--mono:ui-monospace,"JetBrains Mono","SF Mono",SFMono-Regular,Menlo,Consolas,monospace;
--sans:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
--maxw:72rem;--r:10px;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);font-family:var(--sans);line-height:1.55;-webkit-font-smoothing:antialiased}
.wrap{max-width:var(--maxw);margin:0 auto;padding:0 24px}
a{color:var(--green);text-decoration:none}
a:hover{text-decoration:underline}
::selection{background:var(--mint);color:#06110c}
h1,h2,h3{margin:0;font-weight:800;letter-spacing:-.01em;text-wrap:balance}
h1{font-size:2.2rem;line-height:1.05}
.label{font-family:var(--mono);font-size:.64rem;letter-spacing:.18em;text-transform:uppercase;color:var(--faint)}
.mono{font-family:var(--mono)}
.dim{color:var(--dim)}.faint{color:var(--faint)}.acid,.green{color:var(--green)}
.pill{font-family:var(--mono);font-size:.62rem;letter-spacing:.1em;text-transform:uppercase;padding:3px 9px;border-radius:999px;border:1px solid var(--line-2);color:var(--dim);white-space:nowrap}
.pill.on{color:var(--green);border-color:color-mix(in srgb,var(--green) 45%,var(--line));background:color-mix(in srgb,var(--green) 8%,transparent)}
.pill.warn{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 40%,var(--line))}
.btn{font-family:var(--sans);font-size:.85rem;font-weight:600;padding:10px 16px;border-radius:8px;cursor:pointer;border:1px solid var(--line-2);background:var(--surface);color:var(--text);display:inline-flex;align-items:center;justify-content:center;gap:8px;transition:border-color .14s,background .14s,transform .05s;white-space:nowrap;text-align:center}
.btn:hover{border-color:var(--faint);background:var(--surface-2);text-decoration:none}
.btn:active{transform:translateY(1px)}
.btn.acid,.btn.primary{background:var(--green);color:var(--green-ink);border-color:var(--green);font-weight:700}
.btn.acid:hover,.btn.primary:hover{background:var(--green-2);border-color:var(--green-2)}
.btn.danger{color:var(--danger);border-color:color-mix(in srgb,var(--danger) 45%,var(--line))}
.btn.danger:hover{background:color-mix(in srgb,var(--danger) 10%,transparent);border-color:var(--danger)}
.btn.block{width:100%}
.btn:focus-visible{outline:2px solid var(--green);outline-offset:2px}
input,textarea,select{font-family:var(--mono);font-size:.85rem;color:var(--text);background:var(--surface);border:1px solid var(--line-2);border-radius:9px;padding:11px 13px;width:100%}
input:focus,textarea:focus{outline:none;border-color:var(--green)}
input::placeholder,textarea::placeholder{color:var(--faint)}
label.field{display:block;margin-bottom:14px}
label.field span{display:block;font-family:var(--mono);font-size:.66rem;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);margin-bottom:6px}
.card{border:1px solid var(--line);border-radius:var(--r);background:var(--surface)}
.card-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:13px 16px;border-bottom:1px solid var(--line)}
.card-head .h{font-family:var(--mono);font-size:.72rem;letter-spacing:.14em;text-transform:uppercase;color:var(--faint)}
.card-body{padding:18px}
.bar{position:sticky;top:0;z-index:30;background:var(--rail);color:var(--rail-text);border-bottom:1px solid var(--rail-line)}
.bar a{color:var(--rail-text)}
.bar-inner{display:flex;align-items:center;gap:16px;height:60px}
.brand{display:flex;align-items:center;gap:10px;font-weight:800;letter-spacing:-.01em;font-size:1.12rem}
.brand .mark{width:26px;height:26px;border-radius:6px;border:1px solid var(--mint);color:var(--mint);display:grid;place-items:center;font-family:var(--mono);font-weight:800;font-size:.8rem;background:transparent}
.brand .app{font-family:var(--mono);font-weight:600;font-size:.62rem;letter-spacing:.2em;color:var(--faint);text-transform:uppercase;border:1px solid var(--line-2);padding:2px 6px;border-radius:5px}
.bar .brand .app{color:var(--rail-dim);border-color:var(--rail-line)}
.bar-right{margin-left:auto;display:flex;align-items:center;gap:12px}
.grid{display:grid;grid-template-columns:1.55fr .95fr;gap:22px;align-items:start}
.col{display:flex;flex-direction:column;gap:22px}
.section-title{display:flex;align-items:baseline;gap:12px;margin-bottom:14px}
.section-title h2{font-size:1.24rem}
.section-title .count{font-family:var(--mono);font-size:.74rem;color:var(--green)}
.notice{border:1px solid var(--line-2);border-radius:9px;padding:11px 14px;font-family:var(--mono);font-size:.78rem;margin-bottom:16px}
.notice.err{color:var(--danger);border-color:color-mix(in srgb,var(--danger) 45%,var(--line));background:color-mix(in srgb,var(--danger) 6%,transparent)}
.notice.ok{color:var(--green);border-color:color-mix(in srgb,var(--green) 45%,var(--line));background:color-mix(in srgb,var(--green) 6%,transparent)}
.divider{display:flex;align-items:center;gap:12px;color:var(--faint);font-family:var(--mono);font-size:.66rem;letter-spacing:.16em;text-transform:uppercase;margin:18px 0}
.divider::before,.divider::after{content:"";height:1px;background:var(--line);flex:1}
table{width:100%;border-collapse:collapse;font-size:.9rem}
th{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);font-family:var(--mono);font-size:.66rem;letter-spacing:.1em;text-transform:uppercase;color:var(--faint);font-weight:600}
td{padding:8px 10px;border-bottom:1px solid var(--line)}
code{font-family:var(--mono);font-size:.85em;background:var(--surface-2);padding:1px 5px;border-radius:4px}
footer{border-top:1px solid var(--line);padding:26px 0;margin-top:40px}
.foot{display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;align-items:center;font-family:var(--mono);font-size:.74rem;color:var(--faint)}
.foot .green b{color:var(--green);font-weight:600}
@media (max-width:940px){.grid{grid-template-columns:1fr}}
`;
/** Full HTML document with the brand shell. */
export function page({ title = "LogicSRC ▸ credentials", body = "", head = "" }) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#101418">
<title>${esc(title)}</title>
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<style>${BRAND_CSS}</style>
${head}
</head>
<body>${body}
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(()=>{})}</script>
</body>
</html>`;
}
export function appBar(user) {
return `<header class="bar"><div class="wrap bar-inner">
<a class="brand" href="/"><span class="mark">LS</span>LogicSRC<span class="app">credentials</span></a>
<div class="bar-right">
${user
? `<span class="mono faint" style="font-size:.78rem">${esc(user.email || user.display_name || "signed in")}</span>
<a class="btn" href="/settings">Settings</a>
<form method="post" action="/auth/logout" style="margin:0"><button class="btn">Sign out</button></form>`
: `<a class="btn acid" href="/">Sign in</a>`}
</div>
</div></header>`;
}
export const footer = `<footer><div class="wrap foot">
<div class="brand" style="font-size:.9rem;color:var(--ink)"><span class="mark" style="width:20px;height:20px;font-size:.62rem">LS</span>LogicSRC</div>
<div style="display:flex;gap:20px;flex-wrap:wrap"><a href="https://logicsrc.com">logicsrc.com</a><a href="/">Teams</a><a href="/settings">Settings</a></div>
<div class="green">end-to-end encrypted. <b>the server never sees your secrets.</b></div>
</div></footer>`;

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

View file

@ -0,0 +1,29 @@
// User creation + lookups.
import { get, run } from "../db.mjs";
import { id } from "./crypto.mjs";
export async function createUserWithPassword(email, passwordHash, displayName) {
const uid = id();
await run(`INSERT INTO users (id, email, password_hash, display_name, created_at) VALUES (?,?,?,?,?)`,
[uid, email, passwordHash, displayName || email.split("@")[0], Date.now()]);
return get(`SELECT * FROM users WHERE id = ?`, [uid]);
}
export async function createUserForCoinpay(sub, displayName) {
const uid = id();
await run(`INSERT INTO users (id, coinpay_sub, display_name, created_at) VALUES (?,?,?,?)`,
[uid, sub, displayName || "logicsrc user", Date.now()]);
return get(`SELECT * FROM users WHERE id = ?`, [uid]);
}
// Passkey-first signup (no email/password yet).
export async function createUserPasskey(displayName) {
const uid = id();
await run(`INSERT INTO users (id, display_name, created_at) VALUES (?,?,?)`,
[uid, displayName || "logicsrc user", Date.now()]);
return get(`SELECT * FROM users WHERE id = ?`, [uid]);
}
export const userByEmail = (email) => get(`SELECT * FROM users WHERE email = ?`, [String(email).toLowerCase()]);
export const userByCoinpay = (sub) => get(`SELECT * FROM users WHERE coinpay_sub = ?`, [sub]);
export const userById = (uid) => get(`SELECT * FROM users WHERE id = ?`, [uid]);

33
apps/pwa/src/migrate.mjs Normal file
View file

@ -0,0 +1,33 @@
// Apply SQL migrations in order. Idempotent — tracks applied files in _migrations.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { db, run, all } from "./db.mjs";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const DIR = path.join(HERE, "migrations");
export async function migrate() {
// Bootstrap the tracking table (the first migration also declares it IF NOT EXISTS).
await run(`CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)`);
const done = new Set((await all(`SELECT name FROM _migrations`)).map((r) => r.name));
const files = fs.readdirSync(DIR).filter((f) => f.endsWith(".sql")).sort();
for (const file of files) {
if (done.has(file)) { console.log(`· ${file} (already applied)`); continue; }
const sql = fs.readFileSync(path.join(DIR, file), "utf8");
// libSQL executes one statement per call — split on semicolons at line ends.
const statements = sql.split(/;\s*(?:\n|$)/).map((s) => s.trim()).filter(Boolean);
for (const stmt of statements) await run(stmt);
await run(`INSERT INTO _migrations (name, applied_at) VALUES (?, ?)`, [file, Date.now()]);
console.log(`${file}`);
}
console.log("migrations up to date");
}
// Run directly (npm run migrate) — not when imported by the server.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
migrate()
.then(() => db.close?.())
.catch((e) => { console.error(e); process.exit(1); });
}

View file

@ -0,0 +1,55 @@
-- LogicSRC credentials app — auth schema (libSQL / SQLite).
-- Ported from the moshcode PWA auth stack: email/password, passkeys, CoinPay
-- OAuth, server sessions, and CLI API keys.
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE,
password_hash TEXT, -- null when the user only uses passkey / coinpay
coinpay_sub TEXT UNIQUE, -- subject from "sign in with CoinPay"
display_name TEXT,
created_at INTEGER NOT NULL
);
-- WebAuthn / passkey credentials
CREATE TABLE IF NOT EXISTS webauthn_credentials (
id TEXT PRIMARY KEY, -- credential id (base64url)
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
public_key TEXT NOT NULL, -- base64url COSE public key
counter INTEGER NOT NULL DEFAULT 0,
transports TEXT, -- json array
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_webauthn_user ON webauthn_credentials(user_id);
-- server-side sessions (revocable cookie tokens)
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
-- API keys (lsk_…) for the logicsrc CLI to authenticate
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT,
token_hash TEXT NOT NULL, -- sha256 of the key; prefix stored for display
prefix TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_apikeys_user ON api_keys(user_id);
-- Short-lived authorization codes for `logicsrc login` (loopback PKCE flow).
CREATE TABLE IF NOT EXISTS cli_auth_codes (
code TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_challenge TEXT NOT NULL,
redirect_uri TEXT NOT NULL,
name TEXT,
used INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);

View file

@ -0,0 +1,91 @@
-- LogicSRC credential sharing — teams, vaults, and end-to-end-encrypted secrets.
-- Zero-knowledge: only ciphertext, per-member sealed vault keys, and member
-- identity public keys are stored. Members are the app's `users`.
-- A member's X25519 identity public key (one per user; the secret key stays on
-- their device in ~/.logicsrc/identity.json and is never uploaded).
CREATE TABLE IF NOT EXISTS credshare_keys (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
public_key TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS credshare_teams (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_by TEXT NOT NULL REFERENCES users(id),
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS credshare_members (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL REFERENCES credshare_teams(id) ON DELETE CASCADE,
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
email TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member', -- owner | admin | member
status TEXT NOT NULL DEFAULT 'invited', -- active | invited
invited_by TEXT REFERENCES users(id),
joined_at INTEGER,
created_at INTEGER NOT NULL,
UNIQUE(team_id, email)
);
CREATE INDEX IF NOT EXISTS idx_credshare_members_team ON credshare_members(team_id);
CREATE INDEX IF NOT EXISTS idx_credshare_members_user ON credshare_members(user_id);
CREATE TABLE IF NOT EXISTS credshare_invites (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL REFERENCES credshare_teams(id) ON DELETE CASCADE,
email TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
token_hash TEXT NOT NULL UNIQUE,
created_by TEXT NOT NULL REFERENCES users(id),
expires_at INTEGER NOT NULL,
accepted_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS credshare_vaults (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL REFERENCES credshare_teams(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_by TEXT NOT NULL REFERENCES users(id),
created_at INTEGER NOT NULL,
UNIQUE(team_id, name)
);
-- Vault data-encryption key sealed to a member's public key (one row per member).
CREATE TABLE IF NOT EXISTS credshare_vault_grants (
vault_id TEXT NOT NULL REFERENCES credshare_vaults(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
wrapped_dek TEXT NOT NULL,
granted_by TEXT NOT NULL REFERENCES users(id),
created_at INTEGER NOT NULL,
PRIMARY KEY (vault_id, user_id)
);
-- Encrypted secrets (ciphertext + nonce decrypt only with the vault DEK, which
-- the server never sees). fingerprint is a salted hash for redacted diffs.
CREATE TABLE IF NOT EXISTS credshare_secrets (
vault_id TEXT NOT NULL REFERENCES credshare_vaults(id) ON DELETE CASCADE,
name TEXT NOT NULL,
nonce TEXT NOT NULL,
ciphertext TEXT NOT NULL,
fingerprint TEXT NOT NULL,
version INTEGER NOT NULL,
updated_by TEXT NOT NULL REFERENCES users(id),
updated_at INTEGER NOT NULL,
PRIMARY KEY (vault_id, name)
);
CREATE TABLE IF NOT EXISTS credshare_audit (
id TEXT PRIMARY KEY,
team_id TEXT,
vault_id TEXT,
actor_user_id TEXT NOT NULL REFERENCES users(id),
action TEXT NOT NULL,
key_name TEXT,
fingerprint TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_credshare_audit_vault ON credshare_audit(vault_id, created_at DESC);

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("/");
});

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 });
});

View file

@ -0,0 +1,68 @@
// "Sign in with CoinPay" — OAuth 2.0 authorization-code + PKCE.
import { Router } from "express";
import crypto from "node:crypto";
import { config } from "../config.mjs";
import { token } from "../lib/crypto.mjs";
import { createSession, setCeremony, getCeremony, clearCeremony, takeNext } from "../lib/session.mjs";
import { userByCoinpay, createUserForCoinpay } from "../lib/users.mjs";
export const coinpayRouter = Router();
const b64url = (buf) => Buffer.from(buf).toString("base64url");
coinpayRouter.get("/auth/coinpay/start", (req, res) => {
if (!config.coinpayLoginEnabled) return res.redirect("/?err=coinpay-not-configured");
const state = token(16);
const verifier = b64url(crypto.randomBytes(32));
const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
setCeremony(res, "cp", { state, verifier }, 1000 * 60 * 10);
const u = new URL(config.coinpay.oauth.authorizeUrl);
u.searchParams.set("response_type", "code");
u.searchParams.set("client_id", config.coinpay.oauth.clientId);
u.searchParams.set("redirect_uri", config.coinpay.oauth.redirectUri);
u.searchParams.set("scope", config.coinpay.oauth.scope);
u.searchParams.set("state", state);
u.searchParams.set("code_challenge", challenge);
u.searchParams.set("code_challenge_method", "S256");
res.redirect(u.toString());
});
coinpayRouter.get("/auth/coinpay/callback", async (req, res) => {
const ceremony = getCeremony(req, "cp");
clearCeremony(res, "cp");
if (!ceremony || req.query.state !== ceremony.state) return res.redirect("/?err=coinpay-state");
if (!req.query.code) return res.redirect("/?err=coinpay-denied");
try {
const tokenRes = await fetch(config.coinpay.oauth.tokenUrl, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: String(req.query.code),
redirect_uri: config.coinpay.oauth.redirectUri,
client_id: config.coinpay.oauth.clientId,
code_verifier: ceremony.verifier,
}),
});
if (!tokenRes.ok) throw new Error(`token exchange ${tokenRes.status}`);
const tok = await tokenRes.json();
const infoRes = await fetch(config.coinpay.oauth.userinfoUrl, {
headers: { authorization: `Bearer ${tok.access_token}` },
});
if (!infoRes.ok) throw new Error(`userinfo ${infoRes.status}`);
const info = await infoRes.json();
const sub = String(info.sub || info.id || info.user_id || "");
if (!sub) throw new Error("no subject in userinfo");
let user = await userByCoinpay(sub);
if (!user) user = await createUserForCoinpay(sub, info.name || info.username);
await createSession(res, user.id);
res.redirect(takeNext(req, res) || "/");
} catch (e) {
console.error("coinpay login failed:", e.message);
res.redirect("/?err=coinpay-failed");
}
});

View file

@ -0,0 +1,259 @@
// LogicSRC credential sharing API (end-to-end encrypted team vaults).
//
// Zero-knowledge: this server only ever stores ciphertext, per-member sealed
// vault keys, and member identity public keys. All crypto happens in the CLI.
//
// Auth: the acting user comes from a browser session (req.user) OR a
// `Bearer lsk_…` API key (the logicsrc CLI). Mounted at /api/credshare.
import { Router } from "express";
import { get, all, run } from "../db.mjs";
import { id, token, sha256 } from "../lib/crypto.mjs";
import { bearer, userForApiKey } from "../lib/apikey.mjs";
import { config } from "../config.mjs";
export const credshareRouter = Router();
const ROLE_RANK = { member: 0, admin: 1, owner: 2 };
const INVITE_TTL = 1000 * 60 * 60 * 24 * 7;
const norm = (e) => String(e || "").trim().toLowerCase();
const slugify = (s) => {
const v = String(s || "").trim().toLowerCase();
return /^[a-z0-9][a-z0-9-]{0,62}$/.test(v) ? v : null;
};
// Resolve the acting user from session or API key.
async function actor(req) {
if (req.user) return req.user;
return userForApiKey(bearer(req));
}
function api(handler) {
return async (req, res) => {
const user = await actor(req);
if (!user) return res.status(401).json({ error: "Not authenticated. Run: logicsrc login" });
try {
await handler(req, res, user);
} catch (e) {
console.error("credshare:", e);
res.status(500).json({ error: e.message || String(e) });
}
};
}
async function requireMember(res, slug, userId) {
const team = await get(`SELECT * FROM credshare_teams WHERE slug = ?`, [slug]);
if (!team) { res.status(404).json({ error: `Unknown team: ${slug}` }); return null; }
const member = await get(`SELECT * FROM credshare_members WHERE team_id = ? AND user_id = ?`, [team.id, userId]);
if (!member || member.status !== "active") { res.status(403).json({ error: "You are not a member of this team." }); return null; }
return { team, member };
}
async function publicKeyFor(userId) {
const r = await get(`SELECT public_key FROM credshare_keys WHERE user_id = ?`, [userId]);
return r?.public_key ?? null;
}
async function audit(ev) {
await run(`INSERT INTO credshare_audit (id, team_id, vault_id, actor_user_id, action, key_name, fingerprint, created_at) VALUES (?,?,?,?,?,?,?,?)`,
[id(), ev.teamId ?? null, ev.vaultId ?? null, ev.actorUserId, ev.action, ev.keyName ?? null, ev.fingerprint ?? null, Date.now()]);
}
async function sendInviteEmail(to, tok, team, fromEmail) {
if (!config.resend.apiKey) return false;
const url = `${config.origin}/teams/accept?token=${encodeURIComponent(tok)}`;
const r = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: { authorization: `Bearer ${config.resend.apiKey}`, "content-type": "application/json" },
body: JSON.stringify({
from: config.resend.from, to,
subject: `You're invited to the "${team.name}" credential team on LogicSRC`,
text: `${fromEmail} invited you to share credentials on ${team.name} (${team.slug}).\n\nAccept in the CLI:\n logicsrc login\n logicsrc teams accept ${tok}\n\nOr on the web: ${url}\n\nSecrets are end-to-end encrypted — the server never sees them.`
})
}).catch(() => null);
return Boolean(r && r.ok);
}
// ---- identity key + lookup ----
credshareRouter.post("/api/credshare/keys", api(async (req, res, user) => {
const publicKey = req.body?.publicKey;
if (typeof publicKey !== "string" || !publicKey) return res.status(422).json({ error: "Expected { publicKey }." });
await run(`INSERT INTO credshare_keys (user_id, public_key, updated_at) VALUES (?,?,?) ON CONFLICT(user_id) DO UPDATE SET public_key = excluded.public_key, updated_at = excluded.updated_at`,
[user.id, publicKey, Date.now()]);
res.json({ email: user.email, publicKey });
}));
credshareRouter.get("/api/credshare/me", api(async (_req, res, user) => {
const teams = await all(`SELECT t.id, t.slug, t.name FROM credshare_teams t JOIN credshare_members m ON m.team_id = t.id WHERE m.user_id = ? AND m.status = 'active'`, [user.id]);
res.json({ user: { id: user.id, email: user.email, publicKey: await publicKeyFor(user.id) }, teams });
}));
credshareRouter.get("/api/credshare/users", api(async (req, res) => {
const email = norm(req.query.email);
if (!email) return res.status(422).json({ error: "Expected ?email=" });
const u = await get(`SELECT id FROM users WHERE email = ?`, [email]);
res.json({ email, userId: u?.id ?? null, publicKey: u ? await publicKeyFor(u.id) : null });
}));
// ---- teams / members / invites ----
credshareRouter.post("/api/credshare/teams", api(async (req, res, user) => {
const slug = slugify(req.body?.slug);
if (!slug) return res.status(422).json({ error: "Slug must be lowercase letters, numbers, and dashes." });
if (await get(`SELECT 1 FROM credshare_teams WHERE slug = ?`, [slug])) return res.status(409).json({ error: `Team slug "${slug}" is taken.` });
const team = { id: id(), slug, name: (req.body?.name && String(req.body.name)) || slug, createdBy: user.id, createdAt: Date.now() };
await run(`INSERT INTO credshare_teams (id, slug, name, created_by, created_at) VALUES (?,?,?,?,?)`, [team.id, team.slug, team.name, team.createdBy, team.createdAt]);
await run(`INSERT INTO credshare_members (id, team_id, user_id, email, role, status, joined_at, created_at) VALUES (?,?,?,?,?,?,?,?)`,
[id(), team.id, user.id, norm(user.email) || user.id, "owner", "active", Date.now(), Date.now()]);
await audit({ teamId: team.id, actorUserId: user.id, action: "team:create" });
res.status(201).json({ team: { id: team.id, slug: team.slug, name: team.name } });
}));
credshareRouter.get("/api/credshare/teams", api(async (_req, res, user) => {
const teams = await all(`SELECT t.id, t.slug, t.name FROM credshare_teams t JOIN credshare_members m ON m.team_id = t.id WHERE m.user_id = ? AND m.status = 'active' ORDER BY t.created_at`, [user.id]);
res.json({ teams });
}));
credshareRouter.get("/api/credshare/teams/:slug/members", api(async (req, res, user) => {
const ctx = await requireMember(res, req.params.slug, user.id); if (!ctx) return;
const rows = await all(`SELECT * FROM credshare_members WHERE team_id = ? ORDER BY created_at`, [ctx.team.id]);
const members = [];
for (const m of rows) members.push({ email: m.email, role: m.role, status: m.status, hasPublicKey: m.user_id ? Boolean(await publicKeyFor(m.user_id)) : false, joinedAt: m.joined_at });
res.json({ members });
}));
credshareRouter.post("/api/credshare/teams/:slug/invites", api(async (req, res, user) => {
const ctx = await requireMember(res, req.params.slug, user.id); if (!ctx) return;
if (ROLE_RANK[ctx.member.role] < ROLE_RANK.admin) return res.status(403).json({ error: "Only owners and admins can invite." });
const email = norm(req.body?.email);
if (!email) return res.status(422).json({ error: "Expected { email, role? }." });
const role = req.body?.role && ROLE_RANK[req.body.role] != null ? req.body.role : "member";
const existing = await get(`SELECT 1 FROM credshare_members WHERE team_id = ? AND email = ?`, [ctx.team.id, email]);
if (!existing) {
const invitedUser = await get(`SELECT id FROM users WHERE email = ?`, [email]);
await run(`INSERT INTO credshare_members (id, team_id, user_id, email, role, status, invited_by, created_at) VALUES (?,?,?,?,?,?,?,?)`,
[id(), ctx.team.id, invitedUser?.id ?? null, email, role, "invited", user.id, Date.now()]);
}
const tok = token(24);
await run(`INSERT INTO credshare_invites (id, team_id, email, role, token_hash, created_by, expires_at, created_at) VALUES (?,?,?,?,?,?,?,?)`,
[id(), ctx.team.id, email, role, sha256(tok), user.id, Date.now() + INVITE_TTL, Date.now()]);
await audit({ teamId: ctx.team.id, actorUserId: user.id, action: "team:invite", keyName: email });
const emailSent = await sendInviteEmail(email, tok, ctx.team, user.email);
res.status(201).json({ invite: { email, role }, emailSent, ...(emailSent ? {} : { token: tok }) });
}));
credshareRouter.post("/api/credshare/invites/accept", api(async (req, res, user) => {
const raw = req.body?.token;
if (!raw) return res.status(422).json({ error: "Expected { token }." });
const invite = await get(`SELECT * FROM credshare_invites WHERE token_hash = ?`, [sha256(String(raw))]);
if (!invite) return res.status(404).json({ error: "Invite not found." });
if (invite.accepted_at) return res.status(409).json({ error: "Invite already used." });
if (invite.expires_at < Date.now()) return res.status(410).json({ error: "Invite expired." });
if (norm(invite.email) !== norm(user.email)) return res.status(403).json({ error: `This invite is for ${invite.email}, not ${user.email}.` });
await run(`UPDATE credshare_members SET user_id = ?, status = 'active', joined_at = ? WHERE team_id = ? AND email = ?`, [user.id, Date.now(), invite.team_id, norm(invite.email)]);
await run(`UPDATE credshare_invites SET accepted_at = ? WHERE id = ?`, [Date.now(), invite.id]);
await audit({ teamId: invite.team_id, actorUserId: user.id, action: "team:join" });
const team = await get(`SELECT id, slug, name FROM credshare_teams WHERE id = ?`, [invite.team_id]);
res.json({ ok: true, team });
}));
// ---- vaults ----
credshareRouter.get("/api/credshare/teams/:slug/vaults", api(async (req, res, user) => {
const ctx = await requireMember(res, req.params.slug, user.id); if (!ctx) return;
const vaults = await all(`SELECT * FROM credshare_vaults WHERE team_id = ? ORDER BY name`, [ctx.team.id]);
const out = [];
for (const v of vaults) {
const grant = await get(`SELECT 1 FROM credshare_vault_grants WHERE vault_id = ? AND user_id = ?`, [v.id, user.id]);
const count = await get(`SELECT COUNT(*) AS n FROM credshare_secrets WHERE vault_id = ?`, [v.id]);
out.push({ id: v.id, name: v.name, hasAccess: Boolean(grant), secretCount: Number(count?.n || 0) });
}
res.json({ vaults: out });
}));
credshareRouter.post("/api/credshare/teams/:slug/vaults", api(async (req, res, user) => {
const ctx = await requireMember(res, req.params.slug, user.id); if (!ctx) return;
const name = slugify(req.body?.name);
if (!name) return res.status(422).json({ error: "Vault name must be lowercase letters, numbers, and dashes." });
const existing = await get(`SELECT id, name FROM credshare_vaults WHERE team_id = ? AND name = ?`, [ctx.team.id, name]);
if (existing) return res.json({ vault: { id: existing.id, name: existing.name } });
const vault = { id: id(), name };
await run(`INSERT INTO credshare_vaults (id, team_id, name, created_by, created_at) VALUES (?,?,?,?,?)`, [vault.id, ctx.team.id, name, user.id, Date.now()]);
await audit({ teamId: ctx.team.id, vaultId: vault.id, actorUserId: user.id, action: "vault:create" });
res.status(201).json({ vault });
}));
// ---- vault grants / secrets / audit (by vault id) ----
async function vaultCtx(res, vaultId, userId) {
const vault = await get(`SELECT * FROM credshare_vaults WHERE id = ?`, [vaultId]);
if (!vault) { res.status(404).json({ error: "Unknown vault." }); return null; }
const member = await get(`SELECT * FROM credshare_members WHERE team_id = ? AND user_id = ?`, [vault.team_id, userId]);
if (!member || member.status !== "active") { res.status(403).json({ error: "You are not a member of this vault's team." }); return null; }
return vault;
}
credshareRouter.get("/api/credshare/vaults/:id/grant", api(async (req, res, user) => {
const vault = await vaultCtx(res, req.params.id, user.id); if (!vault) return;
const grant = await get(`SELECT wrapped_dek FROM credshare_vault_grants WHERE vault_id = ? AND user_id = ?`, [vault.id, user.id]);
if (!grant) return res.status(403).json({ error: "You do not have access to this vault yet. Ask a member to grant you." });
res.json({ wrappedDek: grant.wrapped_dek });
}));
credshareRouter.get("/api/credshare/vaults/:id/grants", api(async (req, res, user) => {
const vault = await vaultCtx(res, req.params.id, user.id); if (!vault) return;
const granted = new Set((await all(`SELECT user_id FROM credshare_vault_grants WHERE vault_id = ?`, [vault.id])).map((r) => r.user_id));
const members = await all(`SELECT * FROM credshare_members WHERE team_id = ?`, [vault.team_id]);
const grants = [];
for (const m of members) grants.push({ email: m.email, hasPublicKey: m.user_id ? Boolean(await publicKeyFor(m.user_id)) : false, hasAccess: Boolean(m.user_id && granted.has(m.user_id)) });
res.json({ grants });
}));
credshareRouter.post("/api/credshare/vaults/:id/grants", api(async (req, res, user) => {
const vault = await vaultCtx(res, req.params.id, user.id); if (!vault) return;
const iHold = await get(`SELECT 1 FROM credshare_vault_grants WHERE vault_id = ? AND user_id = ?`, [vault.id, user.id]);
const anyGrants = await get(`SELECT 1 FROM credshare_vault_grants WHERE vault_id = ?`, [vault.id]);
if (!iHold && anyGrants) return res.status(403).json({ error: "Only a member with vault access can grant others." });
const email = norm(req.body?.email), wrappedDek = req.body?.wrappedDek;
if (!email || typeof wrappedDek !== "string" || !wrappedDek) return res.status(422).json({ error: "Expected { email, wrappedDek }." });
const target = await get(`SELECT id FROM users WHERE email = ?`, [email]);
if (!target) return res.status(404).json({ error: "Target user has not logged in yet." });
if (!(await publicKeyFor(target.id))) return res.status(409).json({ error: "Target user has not uploaded a public key yet." });
await run(`INSERT INTO credshare_vault_grants (vault_id, user_id, wrapped_dek, granted_by, created_at) VALUES (?,?,?,?,?) ON CONFLICT(vault_id, user_id) DO UPDATE SET wrapped_dek = excluded.wrapped_dek, granted_by = excluded.granted_by, created_at = excluded.created_at`,
[vault.id, target.id, wrappedDek, user.id, Date.now()]);
await audit({ teamId: vault.team_id, vaultId: vault.id, actorUserId: user.id, action: "vault:grant", keyName: email });
res.status(201).json({ ok: true });
}));
credshareRouter.get("/api/credshare/vaults/:id/secrets", api(async (req, res, user) => {
const vault = await vaultCtx(res, req.params.id, user.id); if (!vault) return;
const rows = await all(`SELECT * FROM credshare_secrets WHERE vault_id = ? ORDER BY name`, [vault.id]);
res.json({ vaultId: vault.id, secrets: rows.map((s) => ({ name: s.name, nonce: s.nonce, ciphertext: s.ciphertext, fingerprint: s.fingerprint, version: s.version, updatedAt: s.updated_at })) });
}));
credshareRouter.put("/api/credshare/vaults/:id/secrets", api(async (req, res, user) => {
const vault = await vaultCtx(res, req.params.id, user.id); if (!vault) return;
const upserts = Array.isArray(req.body?.upserts) ? req.body.upserts : [];
const deletes = Array.isArray(req.body?.deletes) ? req.body.deletes : [];
const applied = [];
for (const u of upserts) {
if (!u || typeof u.name !== "string" || typeof u.nonce !== "string" || typeof u.ciphertext !== "string" || typeof u.fingerprint !== "string") {
return res.status(422).json({ error: "Each upsert needs { name, nonce, ciphertext, fingerprint }." });
}
const prev = await get(`SELECT version FROM credshare_secrets WHERE vault_id = ? AND name = ?`, [vault.id, u.name]);
const version = (prev?.version ?? 0) + 1;
await run(`INSERT INTO credshare_secrets (vault_id, name, nonce, ciphertext, fingerprint, version, updated_by, updated_at) VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(vault_id, name) DO UPDATE SET nonce = excluded.nonce, ciphertext = excluded.ciphertext, fingerprint = excluded.fingerprint, version = excluded.version, updated_by = excluded.updated_by, updated_at = excluded.updated_at`,
[vault.id, u.name, u.nonce, u.ciphertext, u.fingerprint, version, user.id, Date.now()]);
await audit({ teamId: vault.team_id, vaultId: vault.id, actorUserId: user.id, action: prev ? "secret:update" : "secret:add", keyName: u.name, fingerprint: u.fingerprint });
applied.push(u.name);
}
for (const raw of deletes) {
const name = typeof raw === "string" ? raw : null;
if (!name) continue;
await run(`DELETE FROM credshare_secrets WHERE vault_id = ? AND name = ?`, [vault.id, name]);
await audit({ teamId: vault.team_id, vaultId: vault.id, actorUserId: user.id, action: "secret:remove", keyName: name });
applied.push(name);
}
res.json({ ok: true, applied });
}));
credshareRouter.get("/api/credshare/vaults/:id/audit", api(async (req, res, user) => {
const vault = await vaultCtx(res, req.params.id, user.id); if (!vault) return;
const auditRows = await all(`SELECT * FROM credshare_audit WHERE vault_id = ? ORDER BY created_at DESC`, [vault.id]);
res.json({ audit: auditRows });
}));

View file

@ -0,0 +1,168 @@
// Teams dashboard (/) + accept-invite (/teams/accept) + settings (/settings).
// The browser holds no private key, so it never decrypts — it manages teams,
// members, vaults (ciphertext metadata), invites, and CLI API keys.
import { Router } from "express";
import { get, all, run } from "../db.mjs";
import { id, token, sha256 } from "../lib/crypto.mjs";
import { page, footer, appBar, esc } from "../lib/html.mjs";
import { requireAuth, csrfInput } from "../lib/session.mjs";
import { createApiKey, listApiKeys, revokeApiKey } from "../lib/apikey.mjs";
import { config } from "../config.mjs";
export const pagesRouter = Router();
// placeholder replaced per-request (teamCard can't see req to render csrfInput)
const CSRF = "__CSRF__";
const CLI_HINT = (origin) => `<div class="card" style="margin-bottom:22px"><div class="card-head"><span class="h">Connect the CLI</span><span class="pill on">end-to-end encrypted</span></div>
<div class="card-body">
<p class="dim" style="margin-top:0;font-size:.9rem">Secrets are encrypted on your machine decrypt them with the <code>logicsrc</code> CLI, never here.</p>
<pre class="mono" style="background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:12px;overflow:auto;font-size:.8rem;margin:0">LOGICSRC_API=${esc(origin)} logicsrc login
logicsrc teams push &lt;team&gt; prod --env .env # share
logicsrc teams pull &lt;team&gt; prod --env .env # receive</pre>
</div></div>`;
async function teamCard(team, uid) {
const members = await all(`SELECT * FROM credshare_members WHERE team_id = ? ORDER BY created_at`, [team.id]);
const me = members.find((m) => m.user_id === uid);
const vaults = await all(`SELECT * FROM credshare_vaults WHERE team_id = ? ORDER BY name`, [team.id]);
const canInvite = me && (me.role === "owner" || me.role === "admin");
const memberRows = [];
for (const m of members) {
const key = m.user_id ? await get(`SELECT 1 FROM credshare_keys WHERE user_id = ?`, [m.user_id]) : null;
memberRows.push(`<tr><td>${esc(m.email)}</td><td>${esc(m.role)}</td><td><span class="pill ${m.status === "active" ? "on" : ""}">${esc(m.status)}</span></td><td>${key ? "✓" : "—"}</td></tr>`);
}
const vaultRows = [];
for (const v of vaults) {
const count = await get(`SELECT COUNT(*) AS n FROM credshare_secrets WHERE vault_id = ?`, [v.id]);
const mine = await get(`SELECT 1 FROM credshare_vault_grants WHERE vault_id = ? AND user_id = ?`, [v.id, uid]);
vaultRows.push(`<tr><td><code>${esc(v.name)}</code></td><td>${Number(count?.n || 0)}</td><td>${mine ? "✓ you have access" : "— ask a member to grant you"}</td></tr>`);
}
return `<div class="card" style="margin-bottom:22px">
<div class="card-head"><span class="h">${esc(team.name)} <span class="faint">/${esc(team.slug)}</span></span><span class="pill">${me ? esc(me.role) : "member"}</span></div>
<div class="card-body">
<div class="label" style="margin-bottom:6px">Members</div>
<table><thead><tr><th>Email</th><th>Role</th><th>Status</th><th>Key</th></tr></thead><tbody>${memberRows.join("")}</tbody></table>
${canInvite ? `<form method="post" action="/teams/${esc(team.slug)}/invite" style="display:flex;gap:8px;margin-top:12px">${CSRF}
<input type="email" name="email" placeholder="teammate@example.com" required style="flex:1"><button class="btn">Invite</button></form>` : ""}
<div class="label" style="margin:18px 0 6px">Vaults</div>
${vaults.length ? `<table><thead><tr><th>Vault</th><th>Secrets</th><th>Your access</th></tr></thead><tbody>${vaultRows.join("")}</tbody></table>`
: `<p class="faint mono" style="font-size:.82rem">No vaults yet — create one from the CLI: <code>logicsrc teams push ${esc(team.slug)} prod</code></p>`}
</div></div>`;
}
export async function dashboardHandler(req, res) {
const uid = req.user.id;
const teams = await all(`SELECT t.* FROM credshare_teams t JOIN credshare_members m ON m.team_id = t.id WHERE m.user_id = ? AND m.status = 'active' ORDER BY t.created_at`, [uid]);
let cards = "";
for (const t of teams) cards += await teamCard(t, uid);
cards = cards.split(CSRF).join(csrfInput(req));
const body = `${appBar(req.user)}
<main class="wrap" style="max-width:820px;padding:26px 0 40px">
<div class="section-title"><h1 style="font-size:1.6rem">Your teams</h1><span class="count">${teams.length}</span></div>
${CLI_HINT(config.origin)}
${cards || `<div class="card"><div class="card-body dim">You're not on any teams yet. Create one below or accept an invite.</div></div>`}
<div class="card" style="margin-top:22px"><div class="card-head"><span class="h">New team</span></div>
<div class="card-body"><form method="post" action="/teams" style="display:flex;gap:8px">${csrfInput(req)}
<input name="slug" placeholder="team-slug" required style="flex:1"><button class="btn acid">Create team</button></form></div></div>
</main>${footer}`;
res.type("html").send(page({ title: "LogicSRC ▸ teams", body }));
}
pagesRouter.get("/dashboard", requireAuth, dashboardHandler);
// ---- team + invite form actions (session + CSRF) ----
pagesRouter.post("/teams", requireAuth, async (req, res) => {
const slug = String(req.body.slug || "").trim().toLowerCase();
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(slug)) return res.redirect("/dashboard?err=bad-slug");
if (await get(`SELECT 1 FROM credshare_teams WHERE slug = ?`, [slug])) return res.redirect("/dashboard?err=slug-taken");
const teamId = id(), now = Date.now();
await run(`INSERT INTO credshare_teams (id, slug, name, created_by, created_at) VALUES (?,?,?,?,?)`, [teamId, slug, slug, req.user.id, now]);
await run(`INSERT INTO credshare_members (id, team_id, user_id, email, role, status, joined_at, created_at) VALUES (?,?,?,?,?,?,?,?)`,
[id(), teamId, req.user.id, String(req.user.email || req.user.id).toLowerCase(), "owner", "active", now, now]);
res.redirect("/dashboard");
});
pagesRouter.post("/teams/:slug/invite", requireAuth, async (req, res) => {
const team = await get(`SELECT * FROM credshare_teams WHERE slug = ?`, [req.params.slug]);
const me = team && await get(`SELECT * FROM credshare_members WHERE team_id = ? AND user_id = ?`, [team.id, req.user.id]);
if (!team || !me || (me.role !== "owner" && me.role !== "admin")) return res.redirect("/dashboard?err=not-allowed");
const email = String(req.body.email || "").trim().toLowerCase();
if (!email) return res.redirect("/dashboard");
const now = Date.now();
if (!(await get(`SELECT 1 FROM credshare_members WHERE team_id = ? AND email = ?`, [team.id, email]))) {
const u = await get(`SELECT id FROM users WHERE email = ?`, [email]);
await run(`INSERT INTO credshare_members (id, team_id, user_id, email, role, status, invited_by, created_at) VALUES (?,?,?,?,?,?,?,?)`,
[id(), team.id, u?.id ?? null, email, "member", "invited", req.user.id, now]);
}
const tok = token(24);
await run(`INSERT INTO credshare_invites (id, team_id, email, role, token_hash, created_by, expires_at, created_at) VALUES (?,?,?,?,?,?,?,?)`,
[id(), team.id, email, "member", sha256(tok), req.user.id, now + 7 * 864e5, now]);
res.redirect("/teams/accept?token=" + encodeURIComponent(tok) + "&shared=1");
});
// ---- accept invite ----
pagesRouter.get("/teams/accept", requireAuth, (req, res) => {
const tok = String(req.query.token || "");
const shared = req.query.shared;
const err = req.query.err;
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">
<h1 style="font-size:1.4rem;margin-bottom:12px">Accept team invite</h1>
${err ? `<div class="notice err">${esc(String(err).replace(/-/g, " "))}</div>` : ""}
${shared ? `<div class="notice ok">Invite created. Share this link with the teammate, or accept below if it's for you.</div>` : ""}
<form method="post" action="/teams/accept">${csrfInput(req)}
<label class="field"><span>Invite token</span><input name="token" value="${esc(tok)}" required></label>
<button class="btn acid block">Accept invite</button>
</form>
</div></div>
</main>${footer}`;
res.type("html").send(page({ title: "LogicSRC ▸ accept invite", body }));
});
pagesRouter.post("/teams/accept", requireAuth, async (req, res) => {
const invite = await get(`SELECT * FROM credshare_invites WHERE token_hash = ?`, [sha256(String(req.body.token || ""))]);
if (!invite || invite.accepted_at || invite.expires_at < Date.now()) return res.redirect("/teams/accept?err=invalid-or-expired");
if (String(invite.email).toLowerCase() !== String(req.user.email || "").toLowerCase()) return res.redirect("/teams/accept?err=wrong-account");
const now = Date.now();
await run(`UPDATE credshare_members SET user_id = ?, status = 'active', joined_at = ? WHERE team_id = ? AND email = ?`, [req.user.id, now, invite.team_id, String(invite.email).toLowerCase()]);
await run(`UPDATE credshare_invites SET accepted_at = ? WHERE id = ?`, [now, invite.id]);
res.redirect("/dashboard");
});
// ---- settings: CLI API keys ----
pagesRouter.get("/settings", requireAuth, async (req, res) => {
const keys = await listApiKeys(req.user.id);
const newKey = req.query.key ? String(req.query.key) : "";
const keysHtml = keys.length ? keys.map((k) => `
<div style="display:flex;gap:12px;align-items:center;padding:10px 0;border-bottom:1px solid var(--line)" class="mono">
<span style="flex:1">${esc(k.name)} <span class="faint">${esc(k.prefix)}</span></span>
<form method="post" action="/settings/apikeys/${k.id}/delete" style="margin:0">${csrfInput(req)}<button class="btn danger" style="padding:5px 10px;font-size:.72rem">revoke</button></form>
</div>`).join("") : `<div class="faint mono" style="font-size:.78rem;padding:6px 0">no keys yet</div>`;
const body = `${appBar(req.user)}
<main class="wrap" style="max-width:640px;padding-top:30px">
<h1 style="font-size:1.5rem;margin-bottom:20px">Settings</h1>
${newKey ? `<div class="notice ok">New API key (copy it now — shown once):<br><b class="mono" style="word-break:break-all">${esc(newKey)}</b></div>` : ""}
<div class="card"><div class="card-head"><span class="h">API keys · for the logicsrc CLI</span></div>
<div class="card-body">
<p class="dim" style="font-size:.85rem;margin-top:0">Usually you don't need these <code>logicsrc login</code> creates one automatically. Manual keys are for CI.</p>
${keysHtml}
<form method="post" action="/settings/apikeys" style="margin-top:14px;display:flex;gap:10px">${csrfInput(req)}
<input name="name" placeholder="key name (e.g. ci)" style="flex:1"><button class="btn">Create key</button></form>
</div></div>
</main>${footer}`;
res.type("html").send(page({ title: "LogicSRC ▸ settings", body }));
});
pagesRouter.post("/settings/apikeys", requireAuth, async (req, res) => {
const { plaintext } = await createApiKey(req.user.id, String(req.body.name || "cli").slice(0, 40));
res.redirect("/settings?key=" + encodeURIComponent(plaintext));
});
pagesRouter.post("/settings/apikeys/:id/delete", requireAuth, async (req, res) => {
await revokeApiKey(req.user.id, req.params.id);
res.redirect("/settings");
});

View file

@ -0,0 +1,110 @@
// Passkey (WebAuthn) auth: register a new passkey-first user, or sign in with a
// discoverable credential. Client uses @simplewebauthn/browser (served at /vendor).
import { Router } from "express";
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from "@simplewebauthn/server";
import { get, run } from "../db.mjs";
import { config } from "../config.mjs";
import { id } from "../lib/crypto.mjs";
import { createSession, setCeremony, getCeremony, clearCeremony, takeNext } from "../lib/session.mjs";
import { createUserPasskey, userById } from "../lib/users.mjs";
export const passkeyRouter = Router();
const enc = (s) => new TextEncoder().encode(s);
// ---- registration (new passkey-first account, or add to the logged-in user) ----
passkeyRouter.post("/auth/passkey/register/options", async (req, res) => {
const handle = req.user?.id || id();
const name = req.user?.email || req.user?.display_name || `logicsrc-${handle.slice(0, 6)}`;
const options = await generateRegistrationOptions({
rpName: config.rpName,
rpID: config.rpID,
userName: name,
userID: enc(handle),
attestationType: "none",
authenticatorSelection: { residentKey: "required", userVerification: "preferred" },
});
setCeremony(res, "reg", { challenge: options.challenge, handle, name, existing: Boolean(req.user) });
res.json(options);
});
passkeyRouter.post("/auth/passkey/register/verify", async (req, res) => {
const ceremony = getCeremony(req, "reg");
if (!ceremony) return res.status(400).json({ error: "registration expired — try again" });
let verification;
try {
verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge: ceremony.challenge,
expectedOrigin: config.origin,
expectedRPID: config.rpID,
});
} catch (e) {
return res.status(400).json({ error: String(e.message || e) });
}
if (!verification.verified) return res.status(400).json({ error: "could not verify passkey" });
const { credential } = verification.registrationInfo;
const user = ceremony.existing ? await userById(ceremony.handle) : await createUserPasskey(ceremony.name);
await run(
`INSERT INTO webauthn_credentials (id, user_id, public_key, counter, transports, created_at) VALUES (?,?,?,?,?,?)`,
[
credential.id,
user.id,
Buffer.from(credential.publicKey).toString("base64url"),
credential.counter || 0,
JSON.stringify(credential.transports || req.body.response?.transports || []),
Date.now(),
]
);
clearCeremony(res, "reg");
await createSession(res, user.id);
res.json({ ok: true, redirect: takeNext(req, res) || "/" });
});
// ---- authentication (discoverable / usernameless sign-in) ----
passkeyRouter.post("/auth/passkey/login/options", async (req, res) => {
const options = await generateAuthenticationOptions({
rpID: config.rpID,
allowCredentials: [], // discoverable credentials
userVerification: "preferred",
});
setCeremony(res, "auth", { challenge: options.challenge });
res.json(options);
});
passkeyRouter.post("/auth/passkey/login/verify", async (req, res) => {
const ceremony = getCeremony(req, "auth");
if (!ceremony) return res.status(400).json({ error: "sign-in expired — try again" });
const cred = await get(`SELECT * FROM webauthn_credentials WHERE id = ?`, [req.body.id]);
if (!cred) return res.status(400).json({ error: "unknown passkey — create an account" });
let verification;
try {
verification = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge: ceremony.challenge,
expectedOrigin: config.origin,
expectedRPID: config.rpID,
credential: {
id: cred.id,
publicKey: Buffer.from(cred.public_key, "base64url"),
counter: Number(cred.counter),
transports: JSON.parse(cred.transports || "[]"),
},
});
} catch (e) {
return res.status(400).json({ error: String(e.message || e) });
}
if (!verification.verified) return res.status(400).json({ error: "passkey did not verify" });
await run(`UPDATE webauthn_credentials SET counter = ? WHERE id = ?`,
[verification.authenticationInfo.newCounter, cred.id]);
clearCeremony(res, "auth");
await createSession(res, cred.user_id);
res.json({ ok: true, redirect: takeNext(req, res) || "/" });
});

58
apps/pwa/src/server.mjs Normal file
View file

@ -0,0 +1,58 @@
// LogicSRC credentials — Express PWA entrypoint (auth + team credential sharing).
import express from "express";
import cookieParser from "cookie-parser";
import path from "node:path";
import { config } from "./config.mjs";
import { migrate } from "./migrate.mjs";
import { sessionMiddleware, csrfGuard } from "./lib/session.mjs";
import { authRouter } from "./routes/auth.mjs";
import { passkeyRouter } from "./routes/passkey.mjs";
import { coinpayRouter } from "./routes/coinpay.mjs";
import { credshareRouter } from "./routes/credshare.mjs";
import { cliRouter } from "./routes/cli.mjs";
import { pagesRouter } from "./routes/pages.mjs";
const app = express();
app.disable("x-powered-by");
if (config.secure) app.set("trust proxy", 1); // Railway terminates TLS
// body parsing — keep the raw body for HMAC signature verification
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString("utf8"); } }));
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
// static
app.use(express.static(path.join(config.root, "public"), { maxAge: "1h" }));
// the @simplewebauthn/browser UMD bundle, served from node_modules (no CDN)
app.get("/vendor/simplewebauthn-browser.umd.js", (_req, res) =>
res.sendFile(path.join(config.root, "node_modules/@simplewebauthn/browser/dist/bundle/index.umd.min.js")));
app.get("/healthz", (_req, res) => res.json({ ok: true, env: config.env }));
app.use(sessionMiddleware);
app.use(csrfGuard);
// routes
app.use(authRouter); // GET / (+ /auth/login|register|logout)
app.use(passkeyRouter);
app.use(coinpayRouter);
app.use(credshareRouter); // /api/credshare/* (session or lsk_ Bearer)
app.use(cliRouter); // /cli/authorize, /cli/token, /api/me
app.use(pagesRouter); // /dashboard, /teams/*, /settings
app.use((req, res) => res.status(404).type("html").send(
`<body style="background:#f6f7f4;color:#101418;font-family:system-ui,sans-serif;padding:14vh 24px;text-align:center"><h1 style="color:#0a7d59">404</h1><p>no such page.</p><a style="color:#0a7d59" href="/">back to your teams →</a></body>`));
// eslint-disable-next-line no-unused-vars
app.use((err, _req, res, _next) => {
console.error(err);
res.status(500).type("html").send(`<body style="background:#f6f7f4;color:#c23a3a;font-family:system-ui,sans-serif;padding:14vh 24px;text-align:center"><h1>500</h1><p>something broke.</p></body>`);
});
async function main() {
await migrate();
app.listen(config.port, () => console.log(`🔐 logicsrc credentials on :${config.port} (${config.env}) — ${config.origin}`));
}
main().catch((e) => { console.error("boot failed:", e); process.exit(1); });
export { app };

964
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -18,7 +18,8 @@
"check": "npm run build && npm run test", "check": "npm run build && npm run test",
"schemas:validate": "npm --workspace @logicsrc/validators run validate:fixtures", "schemas:validate": "npm --workspace @logicsrc/validators run validate:fixtures",
"test:contract": "npm --workspace @logicsrc/commandboard-api run test:contract && npm --workspace @logicsrc/web run test:contract", "test:contract": "npm --workspace @logicsrc/commandboard-api run test:contract && npm --workspace @logicsrc/web run test:contract",
"test:e2e": "npm --workspace @logicsrc/commandboard-web run test:e2e && npm --workspace @logicsrc/web run test:e2e" "test:e2e": "npm --workspace @logicsrc/commandboard-web run test:e2e && npm --workspace @logicsrc/web run test:e2e",
"build:cli": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-credential-sharing run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.10.1", "@types/node": "^24.10.1",

View file

@ -78,19 +78,11 @@ program.action(async (options) => {
program program
.command("login") .command("login")
.option("--email <email>", "Email to log in with (LogicSRC team credential sharing)") .option("--api-url <url>", "LogicSRC app URL", process.env.LOGICSRC_API_URL || process.env.COMMANDBOARD_API_URL)
.option("--code <code>", "Login code (skip the interactive prompt)") .option("--token <lsk_key>", "Use an existing API key instead of the browser flow (CI)")
.option("--did <did>", "CoinPay DID (legacy)") .description("Log in via your browser (loopback OAuth) for team credential sharing; registers your device identity key.")
.option("--oauth <provider>", "OAuth provider (legacy)")
.description("Log in by email for team credential sharing (registers your device identity key).")
.action(async (options) => { .action(async (options) => {
if (!options.email && (options.did || options.oauth)) { await loginAction({ apiUrl: options.apiUrl, token: options.token });
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 });
}); });
program.command("logout").description("Clear local auth token (keeps your identity key).").action(async () => { program.command("logout").description("Clear local auth token (keeps your identity key).").action(async () => {

View file

@ -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 { import {
TeamClient, TeamClient,
TeamApiError, TeamApiError,
@ -26,15 +29,79 @@ function authedClient(): { client: TeamClient; identity: ReturnType<typeof requi
return { client, identity }; return { client, identity };
} }
async function prompt(question: string): Promise<string> { const b64url = (buf: Buffer): string => buf.toString("base64url");
const rl = createInterface({ input: process.stdin, output: process.stderr });
function openBrowser(url: string): void {
const [cmd, args] =
process.platform === "darwin" ? ["open", [url]]
: process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
: ["xdg-open", [url]];
try { try {
return (await rl.question(question)).trim(); const child = spawn(cmd, args, { stdio: "ignore", detached: true });
} finally { child.on("error", () => {});
rl.close(); 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> { async function resolveVaultId(client: TeamClient, slug: string, vault: string): Promise<string> {
const { vaults } = await client.listVaults(slug); const { vaults } = await client.listVaults(slug);
const found = vaults.find((v) => v.name === vault); const found = vaults.find((v) => v.name === vault);
@ -42,42 +109,37 @@ async function resolveVaultId(client: TeamClient, slug: string, vault: string):
return found.id; 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 identity = await loadOrCreateIdentity();
const email = options.email ?? (await prompt("Email: ")); const apiUrl = (options.apiUrl || identity.apiUrl || defaultApiUrl()).replace(/\/+$/, "");
if (!email) throw new Error("An email is required: logicsrc login --email you@example.com");
const client = new TeamClient({ apiUrl: identity.apiUrl || defaultApiUrl() });
const requested = await client.requestLoginCode(email); // Loopback browser OAuth-PKCE (like `moshcode login`), or a --token for CI.
let code = options.code; let token = options.token;
if (requested.devCode) { let email: string | null = null;
// No email transport configured server-side (local dev) — the code is returned. let userId: string | undefined;
console.error(`(dev) login code: ${requested.devCode}`); if (token) {
code = code ?? requested.devCode; 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); const client = new TeamClient({ apiUrl, token: token! });
client.setToken(verified.token);
await client.uploadPublicKey(identity.keys.publicKey); 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.`); console.error(`Logged in${email ? ` as ${email}` : ""}. Identity key registered on ${apiUrl}.`);
print({ email: verified.user.email, userId: verified.user.id, apiUrl: identity.apiUrl || defaultApiUrl() }, "table"); print({ email, apiUrl }, "table");
} }
export async function logoutAction(): Promise<void> { 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 }); 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> { export async function whoamiAction(format: OutputFormat): Promise<void> {