diff --git a/.env.example b/.env.example index c5b0586..1db40cb 100644 --- a/.env.example +++ b/.env.example @@ -34,14 +34,5 @@ SH1PT_WEBHOOK_SECRET= # openssl rand -hex 32 BLOG_WEBHOOK_SECRET= -# --- Team credential sharing (commandboard-api /api/credshare) --- -# Production storage. Without these, the API uses an in-memory store (dev/tests). -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 -LOGICSRC_WEB_URL=https://logicsrc.com -# Web page → API base (public). Falls back to https://commandboard.run. -NEXT_PUBLIC_COMMANDBOARD_API_URL= +# Team credential sharing lives in its own app — see apps/pwa/.env.example +# (Express + libSQL/Turso; auth + end-to-end-encrypted team vaults). diff --git a/apps/commandboard-api/package.json b/apps/commandboard-api/package.json index c20607c..b9d95f1 100644 --- a/apps/commandboard-api/package.json +++ b/apps/commandboard-api/package.json @@ -28,7 +28,6 @@ "nodemailer": "^9.0.1" }, "devDependencies": { - "@logicsrc/plugin-credential-sharing": "file:../../plugins/credential-sharing", "@types/mailparser": "^3.4.6", "@types/nodemailer": "^8.0.1", "tsx": "^4.21.0", diff --git a/apps/commandboard-api/src/credshare/email.ts b/apps/commandboard-api/src/credshare/email.ts deleted file mode 100644 index 9bac084..0000000 --- a/apps/commandboard-api/src/credshare/email.ts +++ /dev/null @@ -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 "; - const webBaseUrl = process.env.LOGICSRC_WEB_URL || options.webBaseUrl; - - async function send(to: string, subject: string, html: string, text: string): Promise { - 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", - `

Your LogicSRC login code is:

${code}

It expires in 10 minutes. If you didn't request this, ignore this email.

`, - `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`, - `

${invitedByEmail} invited you to share credentials on the ${teamName} (${teamSlug}) team.

-

Accept in the CLI:

-
logicsrc login --email ${email}
-logicsrc teams accept ${token}
-

…or accept on the web. Secrets stay end-to-end encrypted — the server never sees them.

`, - `${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}` - ); - } - }; -} diff --git a/apps/commandboard-api/src/credshare/integration.test.ts b/apps/commandboard-api/src/credshare/integration.test.ts deleted file mode 100644 index 2c42543..0000000 --- a/apps/commandboard-api/src/credshare/integration.test.ts +++ /dev/null @@ -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((resolve) => server.listen(0, resolve)); - apiUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; - }); - afterAll(async () => { - await new Promise((resolve) => server.close(() => resolve())); - }); - - async function readBody(request: IncomingMessage): Promise { - 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 = {}; - 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(); - }); -}); diff --git a/apps/commandboard-api/src/credshare/router.test.ts b/apps/commandboard-api/src/credshare/router.test.ts deleted file mode 100644 index abb034b..0000000 --- a/apps/commandboard-api/src/credshare/router.test.ts +++ /dev/null @@ -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, email: string): Promise { - 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 } = {}): CredShareRequest { - return { - method, - path, - query: new URLSearchParams(opts.query ?? {}), - body: opts.body, - token: opts.token - }; -} - -describe("credshare API", () => { - let app: ReturnType; - 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); - }); -}); diff --git a/apps/commandboard-api/src/credshare/router.ts b/apps/commandboard-api/src/credshare/router.ts deleted file mode 100644 index 5fd9de6..0000000 --- a/apps/commandboard-api/src/credshare/router.ts +++ /dev/null @@ -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; - sendInvite(input: { email: string; token: string; teamName: string; teamSlug: string; invitedByEmail: string }): Promise; -} - -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 = { 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 { - 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 { - if (!req.token) return undefined; - return store.getUserIdForToken(hashToken(req.token)); - } - - type MemberGuard = - | { ok: true; team: Awaited> & object; member: CredShareMember } - | { ok: false; response: CredShareResponse }; - - async function requireMember(teamSlug: string, userId: string): Promise { - 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 { - 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; -} diff --git a/apps/commandboard-api/src/credshare/store.ts b/apps/commandboard-api/src/credshare/store.ts deleted file mode 100644 index 8209ec5..0000000 --- a/apps/commandboard-api/src/credshare/store.ts +++ /dev/null @@ -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; - getUserByEmail(email: string): Promise; - getUserById(id: string): Promise; - setUserPublicKey(userId: string, publicKey: string): Promise; - - // auth - saveLoginCode(code: CredShareLoginCode): Promise; - consumeLoginCode(email: string, codeHash: string, now: Date): Promise; - saveToken(token: CredShareToken): Promise; - getUserIdForToken(tokenHash: string): Promise; - deleteToken(tokenHash: string): Promise; - - // teams + members - createTeam(input: { slug: string; name: string; createdBy: string }): Promise; - getTeamBySlug(slug: string): Promise; - listTeamsForUser(userId: string): Promise; - addMember(input: Omit): Promise; - getMember(teamId: string, userId: string): Promise; - getMemberByEmail(teamId: string, email: string): Promise; - listMembers(teamId: string): Promise; - updateMember( - teamId: string, - email: string, - patch: Partial> - ): Promise; - - // invites - createInvite(input: Omit): Promise; - getInviteByTokenHash(tokenHash: string): Promise; - markInviteAccepted(id: string, at: string): Promise; - - // vaults + grants + secrets - createVault(input: { teamId: string; name: string; createdBy: string }): Promise; - getVault(teamId: string, name: string): Promise; - getVaultById(vaultId: string): Promise; - listVaults(teamId: string): Promise; - upsertGrant(grant: CredShareGrant): Promise; - getGrant(vaultId: string, userId: string): Promise; - listGrants(vaultId: string): Promise; - listSecrets(vaultId: string): Promise; - putSecret(secret: CredShareSecret): Promise; - deleteSecret(vaultId: string, name: string): Promise; - - // audit - appendAudit(event: Omit): Promise; - listAudit(vaultId: string): Promise; -} - -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(); // by id - const usersByEmail = new Map(); // email -> id - const loginCodes = new Map(); // email -> code - const tokens = new Map(); // tokenHash -> token - const teams = new Map(); // by id - const teamsBySlug = new Map(); // slug -> id - const members: CredShareMember[] = []; - const invites = new Map(); // by id - const vaults = new Map(); // by id - const grants = new Map(); // `${vaultId}:${userId}` - const secrets = new Map(); // `${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 }; diff --git a/apps/commandboard-api/src/credshare/supabase-store.ts b/apps/commandboard-api/src/credshare/supabase-store.ts deleted file mode 100644 index bd42497..0000000 --- a/apps/commandboard-api/src/credshare/supabase-store.ts +++ /dev/null @@ -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): 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): 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): 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): 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): 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): 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): 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(query: PromiseLike<{ data: unknown; error: unknown }>, map: (r: Record) => T): Promise { - const { data, error } = await query; - if (error) throw error; - return data ? map(data as Record) : 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 = {}; - 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; - 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; - 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); - } - }; -} diff --git a/apps/commandboard-api/src/credshare/types.ts b/apps/commandboard-api/src/credshare/types.ts deleted file mode 100644 index f36350a..0000000 --- a/apps/commandboard-api/src/credshare/types.ts +++ /dev/null @@ -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; -} diff --git a/apps/commandboard-api/src/index.ts b/apps/commandboard-api/src/index.ts index 435ca90..809c798 100644 --- a/apps/commandboard-api/src/index.ts +++ b/apps/commandboard-api/src/index.ts @@ -11,10 +11,6 @@ import { listSocialAccountProviders, socialAccountsPlugin } from "@logicsrc/plug import { uGigPlugin } from "@logicsrc/plugin-ugig"; import { schemas, validate } from "@logicsrc/validators"; 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]); @@ -61,42 +57,6 @@ const c0mputeWorkers = [ { 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 { constructor() { super("Invalid JSON body"); @@ -125,7 +85,7 @@ async function route(request: IncomingMessage, response: ServerResponse) { json(response, 200, { ok: true, 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; } @@ -260,11 +220,6 @@ async function route(request: IncomingMessage, response: ServerResponse) { 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/")) { await handleAgentMail(request, response, url); return; diff --git a/apps/logicsrc-web/public/install.sh b/apps/logicsrc-web/public/install.sh new file mode 100755 index 0000000..d6642db --- /dev/null +++ b/apps/logicsrc-web/public/install.sh @@ -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" < 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 diff --git a/apps/logicsrc-web/src/app/teams/accept/page.tsx b/apps/logicsrc-web/src/app/teams/accept/page.tsx deleted file mode 100644 index 64136cd..0000000 --- a/apps/logicsrc-web/src/app/teams/accept/page.tsx +++ /dev/null @@ -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 { - const { token } = await searchParams; - return ( - - - - ); -} diff --git a/apps/logicsrc-web/src/app/teams/page.tsx b/apps/logicsrc-web/src/app/teams/page.tsx deleted file mode 100644 index 33d575b..0000000 --- a/apps/logicsrc-web/src/app/teams/page.tsx +++ /dev/null @@ -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 ( - - - - ); -} diff --git a/apps/logicsrc-web/src/components/site-shell.tsx b/apps/logicsrc-web/src/components/site-shell.tsx index d880b37..348df8a 100644 --- a/apps/logicsrc-web/src/components/site-shell.tsx +++ b/apps/logicsrc-web/src/components/site-shell.tsx @@ -8,7 +8,6 @@ const NAV: Array<{ href: string; label: string; external?: boolean }> = [ { href: "/agent-swarm", label: "Soon" }, { href: "/agentbyte", label: "AgentByte" }, { href: "/credential-sharing", label: "Credentials" }, - { href: "/teams", label: "Teams" }, { href: "/#cli", label: "CLI" }, { href: "/docs", label: "Docs" }, { href: "/blog", label: "Blog" }, diff --git a/apps/logicsrc-web/src/components/teams-client.tsx b/apps/logicsrc-web/src/components/teams-client.tsx deleted file mode 100644 index 5b2d39c..0000000 --- a/apps/logicsrc-web/src/components/teams-client.tsx +++ /dev/null @@ -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(path: string, init: RequestInit & { token?: string } = {}): Promise { - const headers: Record = { accept: "application/json", ...(init.headers as Record) }; - 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(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([]); - const [active, setActive] = useState(null); - const [members, setMembers] = useState([]); - const [vaults, setVaults] = useState([]); - const [inviteEmail, setInviteEmail] = useState(""); - const [status, setStatus] = useState(null); - const [error, setError] = useState(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 &&

⚠ {error}

} - {status && !error &&

{status}

} - - ); - - if (!token) { - return ( -
-
-

Team credential sharing

-

Log in by email to manage teams and invites. Secrets stay end-to-end encrypted — decrypt them with the logicsrc CLI, never here.

-
- {notice} - {!codeSent ? ( -
- setEmail(e.target.value)} style={inputStyle} /> - -
- ) : ( -
- setCode(e.target.value)} style={inputStyle} /> - -
- )} -
- ); - } - - return ( -
-
-

Your teams

- - {me?.email} {me && !me.publicKey && "· ⚠ no CLI key yet (run logicsrc login)"} · log out - -
- {notice} - -
- {teams.map((t) => ( - - ))} - -
- - {active && ( - <> -

Members

- - - - - - {members.map((m) => ( - - - - - ))} - -
EmailRoleStatusCLI key
{m.email}{m.role}{m.status}{m.hasPublicKey ? "✓" : "—"}
- -
- setInviteEmail(e.target.value)} style={inputStyle} /> - -
- -

Vaults

- {vaults.length === 0 ? ( -

No vaults yet. Create one from the CLI: logicsrc teams push {active} prod

- ) : ( - - - - {vaults.map((v) => ( - - - - - ))} - -
VaultSecretsYour access
{v.name}{v.secretCount}{v.hasAccess ? "✓ granted" : "— ask a member to grant you"}
- )} -

- Pull secrets on your machine: logicsrc teams pull {active} <vault> — values are decrypted locally with your key. The server (and this page) only ever see ciphertext. -

- - )} -
- ); -} - -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)" }; diff --git a/apps/pwa/.env.example b/apps/pwa/.env.example new file mode 100644 index 0000000..f1dcffe --- /dev/null +++ b/apps/pwa/.env.example @@ -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 + +# "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= diff --git a/apps/pwa/.gitignore b/apps/pwa/.gitignore new file mode 100644 index 0000000..289a717 --- /dev/null +++ b/apps/pwa/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +data/ +*.db +*.db-* +.env +.env.local +npm-debug.log* +.DS_Store diff --git a/apps/pwa/Procfile b/apps/pwa/Procfile new file mode 100644 index 0000000..063b78f --- /dev/null +++ b/apps/pwa/Procfile @@ -0,0 +1 @@ +web: npm start diff --git a/apps/pwa/README.md b/apps/pwa/README.md new file mode 100644 index 0000000..ef5b164 --- /dev/null +++ b/apps/pwa/README.md @@ -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= logicsrc login` (browser OAuth-PKCE +loopback → an `lsk_` key). See `docs/credential-sharing.md` in the repo root. diff --git a/apps/pwa/package.json b/apps/pwa/package.json new file mode 100644 index 0000000..6db1c08 --- /dev/null +++ b/apps/pwa/package.json @@ -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" + } +} diff --git a/apps/pwa/public/icon.svg b/apps/pwa/public/icon.svg new file mode 100644 index 0000000..e3ac86c --- /dev/null +++ b/apps/pwa/public/icon.svg @@ -0,0 +1,6 @@ + + + + M + diff --git a/apps/pwa/public/manifest.webmanifest b/apps/pwa/public/manifest.webmanifest new file mode 100644 index 0000000..6d1d0f4 --- /dev/null +++ b/apps/pwa/public/manifest.webmanifest @@ -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" } + ] +} diff --git a/apps/pwa/public/passkey.js b/apps/pwa/public/passkey.js new file mode 100644 index 0000000..4d46186 --- /dev/null +++ b/apps/pwa/public/passkey.js @@ -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; + } + }); +})(); diff --git a/apps/pwa/public/sw.js b/apps/pwa/public/sw.js new file mode 100644 index 0000000..1dfbdc0 --- /dev/null +++ b/apps/pwa/public/sw.js @@ -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("/"))) + ); +}); diff --git a/apps/pwa/railway.json b/apps/pwa/railway.json new file mode 100644 index 0000000..6db3ff4 --- /dev/null +++ b/apps/pwa/railway.json @@ -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 + } +} diff --git a/apps/pwa/src/config.mjs b/apps/pwa/src/config.mjs new file mode 100644 index 0000000..882708a --- /dev/null +++ b/apps/pwa/src/config.mjs @@ -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 ", + }, + 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", +}; diff --git a/apps/pwa/src/db.mjs b/apps/pwa/src/db.mjs new file mode 100644 index 0000000..9515986 --- /dev/null +++ b/apps/pwa/src/db.mjs @@ -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; +} diff --git a/apps/pwa/src/lib/apikey.mjs b/apps/pwa/src/lib/apikey.mjs new file mode 100644 index 0000000..5a1e023 --- /dev/null +++ b/apps/pwa/src/lib/apikey.mjs @@ -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; +} diff --git a/apps/pwa/src/lib/crypto.mjs b/apps/pwa/src/lib/crypto.mjs new file mode 100644 index 0000000..d09a6f1 --- /dev/null +++ b/apps/pwa/src/lib/crypto.mjs @@ -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; } +} diff --git a/apps/pwa/src/lib/html.mjs b/apps/pwa/src/lib/html.mjs new file mode 100644 index 0000000..85342d8 --- /dev/null +++ b/apps/pwa/src/lib/html.mjs @@ -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) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 ` + + + + + +${esc(title)} + + + +${head} + +${body} + + +`; +} + +export function appBar(user) { + return `
+ LSLogicSRCcredentials +
+ ${user + ? `${esc(user.email || user.display_name || "signed in")} + Settings +
` + : `Sign in`} +
+
`; +} + +export const footer = ``; diff --git a/apps/pwa/src/lib/session.mjs b/apps/pwa/src/lib/session.mjs new file mode 100644 index 0000000..3aab491 --- /dev/null +++ b/apps/pwa/src/lib/session.mjs @@ -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) => ``; + +// ---- 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()); } diff --git a/apps/pwa/src/lib/users.mjs b/apps/pwa/src/lib/users.mjs new file mode 100644 index 0000000..37b23c0 --- /dev/null +++ b/apps/pwa/src/lib/users.mjs @@ -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]); diff --git a/apps/pwa/src/migrate.mjs b/apps/pwa/src/migrate.mjs new file mode 100644 index 0000000..906fa94 --- /dev/null +++ b/apps/pwa/src/migrate.mjs @@ -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); }); +} diff --git a/apps/pwa/src/migrations/001_auth.sql b/apps/pwa/src/migrations/001_auth.sql new file mode 100644 index 0000000..4d72ecc --- /dev/null +++ b/apps/pwa/src/migrations/001_auth.sql @@ -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 +); diff --git a/apps/pwa/src/migrations/002_credshare.sql b/apps/pwa/src/migrations/002_credshare.sql new file mode 100644 index 0000000..b652ad3 --- /dev/null +++ b/apps/pwa/src/migrations/002_credshare.sql @@ -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); diff --git a/apps/pwa/src/routes/auth.mjs b/apps/pwa/src/routes/auth.mjs new file mode 100644 index 0000000..e90ce3c --- /dev/null +++ b/apps/pwa/src/routes/auth.mjs @@ -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 = ` +
+ LSLogicSRCcredentials +

Share secrets, end-to-end encrypted

+
+ ${error ? `
${esc(error)}
` : ""} +
+ ${csrfInput(req)} + + + +
+

+ ${mode === "up" + ? `Already have an account? Sign in` + : `New here? Create account`} +

+ +
or
+ + + ${config.coinpayLoginEnabled + ? `◆ Continue with CoinPay` + : ``} +

+
+
${footer} + + `; + 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("/"); +}); diff --git a/apps/pwa/src/routes/cli.mjs b/apps/pwa/src/routes/cli.mjs new file mode 100644 index 0000000..46223c3 --- /dev/null +++ b/apps/pwa/src/routes/cli.mjs @@ -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: `

Bad CLI request

missing/invalid redirect_uri, state, or code_challenge.

` })); + } + const name = String(req.query.name || "logicsrc cli").slice(0, 40); + const body = `${appBar(req.user)} +
+
+
🔑
+

Authorize the LogicSRC CLI

+

Grant ${esc(name)} on this machine access to manage teams & encrypted credentials as ${esc(req.user.email || req.user.display_name)}.

+
+ ${csrfInput(req)} + + + + + +
+

You'll return to your terminal.

+
+
${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 }); +}); diff --git a/apps/pwa/src/routes/coinpay.mjs b/apps/pwa/src/routes/coinpay.mjs new file mode 100644 index 0000000..0e4add1 --- /dev/null +++ b/apps/pwa/src/routes/coinpay.mjs @@ -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"); + } +}); diff --git a/apps/pwa/src/routes/credshare.mjs b/apps/pwa/src/routes/credshare.mjs new file mode 100644 index 0000000..75f862c --- /dev/null +++ b/apps/pwa/src/routes/credshare.mjs @@ -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 }); +})); diff --git a/apps/pwa/src/routes/pages.mjs b/apps/pwa/src/routes/pages.mjs new file mode 100644 index 0000000..8677c28 --- /dev/null +++ b/apps/pwa/src/routes/pages.mjs @@ -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) => `
Connect the CLIend-to-end encrypted
+
+

Secrets are encrypted on your machine — decrypt them with the logicsrc CLI, never here.

+
LOGICSRC_API=${esc(origin)} logicsrc login
+logicsrc teams push <team> prod --env .env   # share
+logicsrc teams pull <team> prod --env .env   # receive
+
`; + +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(`${esc(m.email)}${esc(m.role)}${esc(m.status)}${key ? "✓" : "—"}`); + } + 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(`${esc(v.name)}${Number(count?.n || 0)}${mine ? "✓ you have access" : "— ask a member to grant you"}`); + } + + return `
+
${esc(team.name)} /${esc(team.slug)}${me ? esc(me.role) : "member"}
+
+
Members
+ ${memberRows.join("")}
EmailRoleStatusKey
+ ${canInvite ? `
${CSRF} +
` : ""} +
Vaults
+ ${vaults.length ? `${vaultRows.join("")}
VaultSecretsYour access
` + : `

No vaults yet — create one from the CLI: logicsrc teams push ${esc(team.slug)} prod

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

Your teams

${teams.length}
+ ${CLI_HINT(config.origin)} + ${cards || `
You're not on any teams yet. Create one below or accept an invite.
`} +
New team
+
${csrfInput(req)} +
+
${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)} +
+
+

Accept team invite

+ ${err ? `
${esc(String(err).replace(/-/g, " "))}
` : ""} + ${shared ? `
Invite created. Share this link with the teammate, or accept below if it's for you.
` : ""} +
${csrfInput(req)} + + +
+
+
${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) => ` +
+ ${esc(k.name)} ${esc(k.prefix)}… +
${csrfInput(req)}
+
`).join("") : `
no keys yet
`; + const body = `${appBar(req.user)} +
+

Settings

+ ${newKey ? `
New API key (copy it now — shown once):
${esc(newKey)}
` : ""} +
API keys · for the logicsrc CLI
+
+

Usually you don't need these — logicsrc login creates one automatically. Manual keys are for CI.

+ ${keysHtml} +
${csrfInput(req)} +
+
+
${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"); +}); diff --git a/apps/pwa/src/routes/passkey.mjs b/apps/pwa/src/routes/passkey.mjs new file mode 100644 index 0000000..7ad3804 --- /dev/null +++ b/apps/pwa/src/routes/passkey.mjs @@ -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) || "/" }); +}); diff --git a/apps/pwa/src/server.mjs b/apps/pwa/src/server.mjs new file mode 100644 index 0000000..ad12051 --- /dev/null +++ b/apps/pwa/src/server.mjs @@ -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( + `

404

no such page.

back to your teams →`)); + +// eslint-disable-next-line no-unused-vars +app.use((err, _req, res, _next) => { + console.error(err); + res.status(500).type("html").send(`

500

something broke.

`); +}); + +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 }; diff --git a/package-lock.json b/package-lock.json index 5214762..a73ddc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,6 @@ "nodemailer": "^9.0.1" }, "devDependencies": { - "@logicsrc/plugin-credential-sharing": "file:../../plugins/credential-sharing", "@types/mailparser": "^3.4.6", "@types/nodemailer": "^8.0.1", "tsx": "^4.21.0", @@ -155,6 +154,459 @@ "vitest": "^4.0.8" } }, + "apps/pwa": { + "name": "@logicsrc/pwa", + "version": "0.1.0", + "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" + }, + "engines": { + "node": ">=20" + } + }, + "apps/pwa/node_modules/@libsql/client": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.14.0.tgz", + "integrity": "sha512-/9HEKfn6fwXB5aTEEoMeFh4CtG0ZzbncBb1e++OCdVpgKZ/xyMsIVYXm0w7Pv4RUel803vE6LwniB3PqD72R0Q==", + "license": "MIT", + "dependencies": { + "@libsql/core": "^0.14.0", + "@libsql/hrana-client": "^0.7.0", + "js-base64": "^3.7.5", + "libsql": "^0.4.4", + "promise-limit": "^2.7.0" + } + }, + "apps/pwa/node_modules/@libsql/core": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@libsql/core/-/core-0.14.0.tgz", + "integrity": "sha512-nhbuXf7GP3PSZgdCY2Ecj8vz187ptHlZQ0VRc751oB2C1W8jQUXKKklvt7t1LJiUTQBVJuadF628eUk+3cRi4Q==", + "license": "MIT", + "dependencies": { + "js-base64": "^3.7.5" + } + }, + "apps/pwa/node_modules/@libsql/darwin-arm64": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@libsql/darwin-arm64/-/darwin-arm64-0.4.7.tgz", + "integrity": "sha512-yOL742IfWUlUevnI5PdnIT4fryY3LYTdLm56bnY0wXBw7dhFcnjuA7jrH3oSVz2mjZTHujxoITgAE7V6Z+eAbg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "apps/pwa/node_modules/@libsql/darwin-x64": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@libsql/darwin-x64/-/darwin-x64-0.4.7.tgz", + "integrity": "sha512-ezc7V75+eoyyH07BO9tIyJdqXXcRfZMbKcLCeF8+qWK5nP8wWuMcfOVywecsXGRbT99zc5eNra4NEx6z5PkSsA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "apps/pwa/node_modules/@libsql/hrana-client": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@libsql/hrana-client/-/hrana-client-0.7.0.tgz", + "integrity": "sha512-OF8fFQSkbL7vJY9rfuegK1R7sPgQ6kFMkDamiEccNUvieQ+3urzfDFI616oPl8V7T9zRmnTkSjMOImYCAVRVuw==", + "license": "MIT", + "dependencies": { + "@libsql/isomorphic-fetch": "^0.3.1", + "@libsql/isomorphic-ws": "^0.1.5", + "js-base64": "^3.7.5", + "node-fetch": "^3.3.2" + } + }, + "apps/pwa/node_modules/@libsql/linux-arm64-gnu": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-gnu/-/linux-arm64-gnu-0.4.7.tgz", + "integrity": "sha512-WlX2VYB5diM4kFfNaYcyhw5y+UJAI3xcMkEUJZPtRDEIu85SsSFrQ+gvoKfcVh76B//ztSeEX2wl9yrjF7BBCA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "apps/pwa/node_modules/@libsql/linux-arm64-musl": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-musl/-/linux-arm64-musl-0.4.7.tgz", + "integrity": "sha512-6kK9xAArVRlTCpWeqnNMCoXW1pe7WITI378n4NpvU5EJ0Ok3aNTIC2nRPRjhro90QcnmLL1jPcrVwO4WD1U0xw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "apps/pwa/node_modules/@libsql/linux-x64-gnu": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-gnu/-/linux-x64-gnu-0.4.7.tgz", + "integrity": "sha512-CMnNRCmlWQqqzlTw6NeaZXzLWI8bydaXDke63JTUCvu8R+fj/ENsLrVBtPDlxQ0wGsYdXGlrUCH8Qi9gJep0yQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "apps/pwa/node_modules/@libsql/linux-x64-musl": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-musl/-/linux-x64-musl-0.4.7.tgz", + "integrity": "sha512-nI6tpS1t6WzGAt1Kx1n1HsvtBbZ+jHn0m7ogNNT6pQHZQj7AFFTIMeDQw/i/Nt5H38np1GVRNsFe99eSIMs9XA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "apps/pwa/node_modules/@libsql/win32-x64-msvc": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@libsql/win32-x64-msvc/-/win32-x64-msvc-0.4.7.tgz", + "integrity": "sha512-7pJzOWzPm6oJUxml+PCDRzYQ4A1hTMHAciTAHfFK4fkbDZX33nWPVG7Y3vqdKtslcwAzwmrNDc6sXy2nwWnbiw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "apps/pwa/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "apps/pwa/node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "apps/pwa/node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "apps/pwa/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "apps/pwa/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "apps/pwa/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "apps/pwa/node_modules/detect-libc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", + "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "apps/pwa/node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "apps/pwa/node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "apps/pwa/node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "apps/pwa/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "apps/pwa/node_modules/libsql": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/libsql/-/libsql-0.4.7.tgz", + "integrity": "sha512-T9eIRCs6b0J1SHKYIvD8+KCJMcWZ900iZyxdnSCdqxN12Z1ijzT+jY5nrk72Jw4B0HGzms2NgpryArlJqvc3Lw==", + "cpu": [ + "x64", + "arm64", + "wasm32" + ], + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "@neon-rs/load": "^0.0.4", + "detect-libc": "2.0.2" + }, + "optionalDependencies": { + "@libsql/darwin-arm64": "0.4.7", + "@libsql/darwin-x64": "0.4.7", + "@libsql/linux-arm64-gnu": "0.4.7", + "@libsql/linux-arm64-musl": "0.4.7", + "@libsql/linux-x64-gnu": "0.4.7", + "@libsql/linux-x64-musl": "0.4.7", + "@libsql/win32-x64-msvc": "0.4.7" + } + }, + "apps/pwa/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "apps/pwa/node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "apps/pwa/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "apps/pwa/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "apps/pwa/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "apps/pwa/node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "apps/pwa/node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "apps/pwa/node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "apps/pwa/node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "apps/pwa/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -865,6 +1317,12 @@ "node": ">=18" } }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, "node_modules/@hono/node-server": { "version": "1.19.14", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", @@ -1388,6 +1846,31 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, + "node_modules/@libsql/isomorphic-fetch": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@libsql/isomorphic-fetch/-/isomorphic-fetch-0.3.1.tgz", + "integrity": "sha512-6kK3SUK5Uu56zPq/Las620n5aS9xJq+jMBcNSOmjhNf/MUvdyji4vrMTqD7ptY7/4/CAVEAYDeotUz60LNQHtw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@libsql/isomorphic-ws": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@libsql/isomorphic-ws/-/isomorphic-ws-0.1.5.tgz", + "integrity": "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==", + "license": "MIT", + "dependencies": { + "@types/ws": "^8.5.4", + "ws": "^8.13.0" + } + }, "node_modules/@logicsrc/account-core": { "resolved": "packages/account-core", "link": true @@ -1464,6 +1947,10 @@ "resolved": "plugins/ugig", "link": true }, + "node_modules/@logicsrc/pwa": { + "resolved": "apps/pwa", + "link": true + }, "node_modules/@logicsrc/schemas": { "resolved": "packages/schemas", "link": true @@ -1542,6 +2029,12 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@neon-rs/load": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@neon-rs/load/-/load-0.0.4.tgz", + "integrity": "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==", + "license": "MIT" + }, "node_modules/@next/env": { "version": "16.2.6", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz", @@ -1697,6 +2190,174 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@peculiar/asn1-android": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.8.0.tgz", + "integrity": "sha512-skLbS+IOGv1lUgDqtChr8xvtvEr3HMse/JGBaL2r1J1o/n7a8wqOrovMtlRq/UXLhxvmLaONP67hwtshgzwfzA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz", + "integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz", + "integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz", + "integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz", + "integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-rsa": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz", + "integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz", + "integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pfx": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz", + "integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", + "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz", + "integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz", + "integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -2320,6 +2981,31 @@ "selderee": "~0.12.0" } }, + "node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, + "node_modules/@simplewebauthn/server": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.2.tgz", + "integrity": "sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/x509": "^1.14.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -2530,7 +3216,6 @@ "version": "24.13.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.1.tgz", "integrity": "sha512-RSpUJGmvsJ1ZeBehQZFhIdpsz+bIpES0nIQXko4Ybq+N+kX6XvOq3Jo+iJ82FWLdblFq85AsMikd3m35jgezYg==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -2583,6 +3268,15 @@ "htmlparser2": "^10.1" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitejs/plugin-react": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", @@ -2773,6 +3467,26 @@ } } }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -3029,6 +3743,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", @@ -3104,6 +3837,15 @@ "dev": true, "license": "MIT" }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/dayjs": { "version": "1.11.21", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", @@ -3154,6 +3896,16 @@ "node": ">= 0.8" } }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3592,6 +4344,29 @@ } } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -3613,6 +4388,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3920,6 +4707,12 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-base64": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.8.1.tgz", + "integrity": "sha512-5xVjhUZlHHeuO2W7w2rDFj/Kl1xLX+HjZxdOQwCsUOifl6UaoH1o1wsbsTMz+r0aeC7gCijvru02j6TfKZWzKg==", + "license": "BSD-3-Clause" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4366,6 +5159,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -4505,6 +5319,44 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-releases": { "version": "2.0.47", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", @@ -4881,6 +5733,12 @@ ], "license": "MIT" }, + "node_modules/promise-limit": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", + "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==", + "license": "ISC" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -4903,6 +5761,24 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -4987,6 +5863,12 @@ "node": ">= 12.13.0" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -5126,6 +6008,26 @@ "node": ">= 0.6" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-stable-stringify": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", @@ -6072,6 +6974,24 @@ "@esbuild/win32-x64": "0.28.0" } }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/type-is": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", @@ -6135,7 +7055,6 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "devOptional": true, "license": "MIT" }, "node_modules/unpipe": { @@ -6177,6 +7096,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -6353,6 +7281,15 @@ } } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -6425,6 +7362,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", diff --git a/package.json b/package.json index df5c6b1..a6b863f 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "check": "npm run build && npm run test", "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: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": { "@types/node": "^24.10.1", diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 9468305..962a724 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -78,19 +78,11 @@ program.action(async (options) => { program .command("login") - .option("--email ", "Email to log in with (LogicSRC team credential sharing)") - .option("--code ", "Login code (skip the interactive prompt)") - .option("--did ", "CoinPay DID (legacy)") - .option("--oauth ", "OAuth provider (legacy)") - .description("Log in by email for team credential sharing (registers your device identity key).") + .option("--api-url ", "LogicSRC app URL", process.env.LOGICSRC_API_URL || process.env.COMMANDBOARD_API_URL) + .option("--token ", "Use an existing API key instead of the browser flow (CI)") + .description("Log in via your browser (loopback OAuth) for team credential sharing; registers your device identity key.") .action(async (options) => { - if (!options.email && (options.did || options.oauth)) { - const mode = options.did ? `CoinPay DID ${options.did}` : `${options.oauth} OAuth`; - console.log(`Login flow ready: ${mode}`); - console.log("Token storage target: $HOME/.logicsrc/identity.json"); - return; - } - await loginAction({ email: options.email, code: options.code }); + await loginAction({ apiUrl: options.apiUrl, token: options.token }); }); program.command("logout").description("Clear local auth token (keeps your identity key).").action(async () => { diff --git a/packages/cli/src/teams.ts b/packages/cli/src/teams.ts index 469408e..5d95047 100644 --- a/packages/cli/src/teams.ts +++ b/packages/cli/src/teams.ts @@ -1,4 +1,7 @@ -import { createInterface } from "node:readline/promises"; +import { createServer } from "node:http"; +import { createHash, randomBytes } from "node:crypto"; +import { hostname } from "node:os"; +import { spawn } from "node:child_process"; import { TeamClient, TeamApiError, @@ -26,15 +29,79 @@ function authedClient(): { client: TeamClient; identity: ReturnType { - const rl = createInterface({ input: process.stdin, output: process.stderr }); +const b64url = (buf: Buffer): string => buf.toString("base64url"); + +function openBrowser(url: string): void { + const [cmd, args] = + process.platform === "darwin" ? ["open", [url]] + : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] + : ["xdg-open", [url]]; try { - return (await rl.question(question)).trim(); - } finally { - rl.close(); + const child = spawn(cmd, args, { stdio: "ignore", detached: true }); + child.on("error", () => {}); + child.unref(); + } catch { + /* print fallback below */ } } +const DONE_PAGE = (msg: string) => + `

${msg}

Return to your terminal — you can close this tab.

`; + +/** 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 { const { vaults } = await client.listVaults(slug); const found = vaults.find((v) => v.name === vault); @@ -42,42 +109,37 @@ async function resolveVaultId(client: TeamClient, slug: string, vault: string): return found.id; } -export async function loginAction(options: { email?: string; code?: string }): Promise { +export async function loginAction(options: { apiUrl?: string; token?: string }): Promise { const identity = await loadOrCreateIdentity(); - const email = options.email ?? (await prompt("Email: ")); - if (!email) throw new Error("An email is required: logicsrc login --email you@example.com"); - const client = new TeamClient({ apiUrl: identity.apiUrl || defaultApiUrl() }); + const apiUrl = (options.apiUrl || identity.apiUrl || defaultApiUrl()).replace(/\/+$/, ""); - const requested = await client.requestLoginCode(email); - let code = options.code; - if (requested.devCode) { - // No email transport configured server-side (local dev) — the code is returned. - console.error(`(dev) login code: ${requested.devCode}`); - code = code ?? requested.devCode; + // Loopback browser OAuth-PKCE (like `moshcode login`), or a --token for CI. + let token = options.token; + let email: string | null = null; + let userId: string | undefined; + if (token) { + const client = new TeamClient({ apiUrl, token }); + const me = await client.me(); + email = me.user.email; + userId = me.user.id; + } else { + const result = await loopbackLogin(apiUrl); + token = result.token; + email = result.email; + userId = result.userId; } - if (!code) code = await prompt(`Enter the 6-digit code sent to ${email}: `); - const verified = await client.verifyLoginCode(email, code); - client.setToken(verified.token); + const client = new TeamClient({ apiUrl, token: token! }); await client.uploadPublicKey(identity.keys.publicKey); - await updateIdentity({ email: verified.user.email, userId: verified.user.id, apiToken: verified.token, apiUrl: identity.apiUrl || defaultApiUrl() }); + await updateIdentity({ email: email ?? undefined, userId, apiToken: token, apiUrl }); - console.error(`Logged in as ${verified.user.email}. Identity key registered.`); - print({ email: verified.user.email, userId: verified.user.id, apiUrl: identity.apiUrl || defaultApiUrl() }, "table"); + console.error(`Logged in${email ? ` as ${email}` : ""}. Identity key registered on ${apiUrl}.`); + print({ email, apiUrl }, "table"); } export async function logoutAction(): Promise { - const identity = readIdentity(); - if (identity?.apiToken) { - try { - const client = new TeamClient({ apiUrl: identity.apiUrl || defaultApiUrl(), token: identity.apiToken }); - await client.logout(); - } catch { - // best effort — token may already be gone - } - } await updateIdentity({ apiToken: undefined, email: undefined, userId: undefined }); - console.error("Logged out. Local identity key retained (delete ~/.logicsrc/identity.json to remove it)."); + console.error("Logged out (local token cleared; revoke the key at /settings). Identity key retained — delete ~/.logicsrc/identity.json to remove it."); } export async function whoamiAction(format: OutputFormat): Promise {