mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-14 23:07:29 +00:00
feat(pwa): logicsrc credentials app — real auth + Turso, redesigned; retire commandboard-api credshare
Adds apps/pwa: an Express + libSQL/Turso app that is now the home of team credential sharing, with the moshcode-style auth stack ported and reskinned to match logicsrc.com (light theme, Inter, green accent). apps/pwa - auth: email/password (scrypt), passkeys (WebAuthn), CoinPay OAuth, cookie sessions, and lsk_ API keys for the CLI via a loopback OAuth-PKCE flow (/cli/authorize + /cli/token). Ported from the moshcode PWA. - credshare API (/api/credshare/*): teams, members, invites, vaults, sealed grants, ciphertext secrets, audit — authed by session OR Bearer lsk_ key. Zero-knowledge: only ciphertext + sealed vault keys + public keys stored. - teams dashboard, accept-invite, and settings (API keys) pages, server-rendered in the LogicSRC brand (lib/html.mjs). - migrations (libSQL) 001_auth + 002_credshare, migrate-on-boot; Turso via TURSO_DATABASE_URL / TURSO_AUTH_TOKEN, or a local file db for dev. - trimmed moshcode-specific approvals/credits/push/deliver. CLI - `logicsrc login` now does browser loopback OAuth-PKCE against the app and stores an lsk_ token (email-OTP removed); --token for CI. Client repointed. Distribution - install.sh (served at logicsrc.com/install.sh) installs the CLI from the GitHub repo: tarball -> npm install -> `npm run build:cli` -> logicsrc wrapper. - root build:cli builds only the CLI's workspace chain (skips web/api/next). Cleanup - removed the commandboard-api credshare backend (superseded by the PWA) and its Supabase/Turso stores + libsql dep; commandboard-api tests green (40). - removed the Next.js /teams page (the PWA is the web UI now). Verified end-to-end: two accounts register on the PWA, mint lsk_ keys, CLI login uploads identity keys, owner pushes an encrypted .env, teammate invited -> accepted -> granted -> pulls the exact file. Server stores ciphertext only. Full workspace build + tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f057589d66
commit
9ba044577f
46 changed files with 2785 additions and 1730 deletions
13
.env.example
13
.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 <noreply@logicsrc.com>
|
||||
LOGICSRC_WEB_URL=https://logicsrc.com
|
||||
# Web page → API base (public). Falls back to https://commandboard.run.
|
||||
NEXT_PUBLIC_COMMANDBOARD_API_URL=
|
||||
# Team credential sharing lives in its own app — see apps/pwa/.env.example
|
||||
# (Express + libSQL/Turso; auth + end-to-end-encrypted team vaults).
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
import type { CredShareEmailSender } from "./router.js";
|
||||
|
||||
/**
|
||||
* Resend-backed email transport for login codes and team invites.
|
||||
*
|
||||
* Returns `undefined` when RESEND_API_KEY is not configured, so the API falls
|
||||
* back to echoing codes/tokens in its responses (fine for local dev; production
|
||||
* sets the key and never echoes secrets).
|
||||
*/
|
||||
export function createResendEmailSender(options: { webBaseUrl: string } = { webBaseUrl: "https://logicsrc.com" }): CredShareEmailSender | undefined {
|
||||
const apiKey = process.env.RESEND_API_KEY;
|
||||
if (!apiKey) return undefined;
|
||||
const from = process.env.CREDSHARE_EMAIL_FROM || "LogicSRC <noreply@logicsrc.com>";
|
||||
const webBaseUrl = process.env.LOGICSRC_WEB_URL || options.webBaseUrl;
|
||||
|
||||
async function send(to: string, subject: string, html: string, text: string): Promise<void> {
|
||||
const response = await fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
|
||||
body: JSON.stringify({ from, to, subject, html, text })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => "");
|
||||
throw new Error(`Resend send failed: ${response.status} ${body.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async sendLoginCode(email, code) {
|
||||
await send(
|
||||
email,
|
||||
"Your LogicSRC login code",
|
||||
`<p>Your LogicSRC login code is:</p><p style="font-size:24px;font-weight:bold;letter-spacing:3px">${code}</p><p>It expires in 10 minutes. If you didn't request this, ignore this email.</p>`,
|
||||
`Your LogicSRC login code is: ${code}\nIt expires in 10 minutes.`
|
||||
);
|
||||
},
|
||||
async sendInvite({ email, token, teamName, teamSlug, invitedByEmail }) {
|
||||
const acceptUrl = `${webBaseUrl}/teams/accept?token=${encodeURIComponent(token)}`;
|
||||
await send(
|
||||
email,
|
||||
`You're invited to the "${teamName}" credential team on LogicSRC`,
|
||||
`<p><strong>${invitedByEmail}</strong> invited you to share credentials on the <strong>${teamName}</strong> (<code>${teamSlug}</code>) team.</p>
|
||||
<p>Accept in the CLI:</p>
|
||||
<pre>logicsrc login --email ${email}
|
||||
logicsrc teams accept ${token}</pre>
|
||||
<p>…or <a href="${acceptUrl}">accept on the web</a>. Secrets stay end-to-end encrypted — the server never sees them.</p>`,
|
||||
`${invitedByEmail} invited you to the "${teamName}" (${teamSlug}) credential team on LogicSRC.\n\nAccept in the CLI:\n logicsrc login --email ${email}\n logicsrc teams accept ${token}\n\nOr on the web: ${acceptUrl}`
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { createServer, type Server, type IncomingMessage } from "node:http";
|
||||
import { createCredShareApi, type CredShareRequest } from "./router.js";
|
||||
import { createMemoryCredShareStore } from "./store.js";
|
||||
import {
|
||||
TeamClient,
|
||||
generateIdentityKeyPair,
|
||||
generateVaultKey,
|
||||
wrapVaultKey,
|
||||
unwrapVaultKey,
|
||||
encryptValue,
|
||||
decryptValue,
|
||||
fingerprintValue
|
||||
} from "@logicsrc/plugin-credential-sharing";
|
||||
|
||||
/**
|
||||
* True end-to-end test: the real HTTP server + the real TeamClient + real
|
||||
* libsodium crypto. Alice shares a .env with Bob purely through the server, and
|
||||
* we assert the server never held plaintext while Bob still recovers the values.
|
||||
*/
|
||||
describe("credshare end-to-end (server + client + crypto)", () => {
|
||||
let server: Server;
|
||||
let apiUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Hermetic server: real router + memory store, no email transport (so the
|
||||
// dev-code echo path is deterministic and independent of ambient env).
|
||||
const api = createCredShareApi({ store: createMemoryCredShareStore() });
|
||||
server = createServer(async (request, response) => {
|
||||
const url = new URL(request.url ?? "/", "http://localhost");
|
||||
const method = request.method ?? "GET";
|
||||
let body: unknown;
|
||||
if (method === "POST" || method === "PUT" || method === "PATCH") {
|
||||
body = await readBody(request);
|
||||
}
|
||||
const header = request.headers["authorization"];
|
||||
const token = header?.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() : undefined;
|
||||
const req: CredShareRequest = { method, path: url.pathname.replace(/^\/api\/credshare/, "") || "/", query: url.searchParams, body, token };
|
||||
const result = await api.handle(req);
|
||||
response.writeHead(result.status, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify(result.body));
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
apiUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
async function readBody(request: IncomingMessage): Promise<unknown> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
||||
const text = Buffer.concat(chunks).toString("utf8");
|
||||
return text ? JSON.parse(text) : undefined;
|
||||
}
|
||||
|
||||
async function loginClient(email: string) {
|
||||
const client = new TeamClient({ apiUrl });
|
||||
const req = await client.requestLoginCode(email);
|
||||
const verify = await client.verifyLoginCode(email, req.devCode!);
|
||||
client.setToken(verify.token);
|
||||
return client;
|
||||
}
|
||||
|
||||
it("shares an env bundle from Alice to Bob without the server ever seeing plaintext", async () => {
|
||||
const secretEnv = { DATABASE_URL: "postgres://user:pw@host/db", STRIPE_KEY: "sk_live_deadbeef" };
|
||||
|
||||
// --- Alice sets up ---
|
||||
const alice = await loginClient("alice@acme.dev");
|
||||
const aliceKeys = await generateIdentityKeyPair();
|
||||
await alice.uploadPublicKey(aliceKeys.publicKey);
|
||||
await alice.createTeam("acme-e2e", "Acme");
|
||||
const { vault } = await alice.createVault("acme-e2e", "prod");
|
||||
|
||||
// Alice mints the vault DEK, wraps it to herself, and pushes ciphertext.
|
||||
const dek = await generateVaultKey();
|
||||
await alice.putGrant(vault.id, "alice@acme.dev", await wrapVaultKey(dek, aliceKeys.publicKey));
|
||||
const upserts = [] as Array<{ name: string; nonce: string; ciphertext: string; fingerprint: string }>;
|
||||
for (const [name, value] of Object.entries(secretEnv)) {
|
||||
const sealed = await encryptValue(value, dek);
|
||||
upserts.push({ name, ...sealed, fingerprint: fingerprintValue(value) });
|
||||
}
|
||||
await alice.putSecrets(vault.id, upserts, []);
|
||||
|
||||
// The server must only hold ciphertext — assert no plaintext leaks over the wire.
|
||||
const stored = await alice.listSecrets(vault.id);
|
||||
for (const s of stored.secrets) {
|
||||
expect(s.ciphertext).not.toContain("postgres://");
|
||||
expect(s.ciphertext).not.toContain("sk_live");
|
||||
}
|
||||
|
||||
// --- Bob joins ---
|
||||
const bob = await loginClient("bob@acme.dev");
|
||||
const bobKeys = await generateIdentityKeyPair();
|
||||
await bob.uploadPublicKey(bobKeys.publicKey);
|
||||
const invite = await alice.invite("acme-e2e", "bob@acme.dev");
|
||||
await bob.acceptInvite(invite.token!);
|
||||
|
||||
// --- Alice grants Bob: look up Bob's pubkey, re-wrap the DEK to it ---
|
||||
const bobUser = await alice.lookupUser("bob@acme.dev");
|
||||
const aliceDek = await unwrapVaultKey((await alice.getMyGrant(vault.id)).wrappedDek, aliceKeys);
|
||||
await alice.putGrant(vault.id, "bob@acme.dev", await wrapVaultKey(aliceDek, bobUser.publicKey!));
|
||||
|
||||
// --- Bob pulls + decrypts on his machine ---
|
||||
const bobDek = await unwrapVaultKey((await bob.getMyGrant(vault.id)).wrappedDek, bobKeys);
|
||||
const bobSecrets = await bob.listSecrets(vault.id);
|
||||
const recovered: Record<string, string> = {};
|
||||
for (const s of bobSecrets.secrets) {
|
||||
recovered[s.name] = await decryptValue({ nonce: s.nonce, ciphertext: s.ciphertext }, bobDek);
|
||||
}
|
||||
expect(recovered).toEqual(secretEnv);
|
||||
});
|
||||
|
||||
it("denies decryption to a member who has not been granted the DEK", async () => {
|
||||
const alice = await loginClient("alice2@acme.dev");
|
||||
const aliceKeys = await generateIdentityKeyPair();
|
||||
await alice.uploadPublicKey(aliceKeys.publicKey);
|
||||
await alice.createTeam("acme-e2e-2", "Acme2");
|
||||
const { vault } = await alice.createVault("acme-e2e-2", "prod");
|
||||
const dek = await generateVaultKey();
|
||||
await alice.putGrant(vault.id, "alice2@acme.dev", await wrapVaultKey(dek, aliceKeys.publicKey));
|
||||
|
||||
const carol = await loginClient("carol@acme.dev");
|
||||
await carol.uploadPublicKey((await generateIdentityKeyPair()).publicKey);
|
||||
const invite = await alice.invite("acme-e2e-2", "carol@acme.dev");
|
||||
await carol.acceptInvite(invite.token!);
|
||||
|
||||
// Carol is a member (can see ciphertext) but has no grant → cannot get a DEK.
|
||||
await expect(carol.getMyGrant(vault.id)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { createMemoryCredShareStore } from "./store.js";
|
||||
import { createCredShareApi, type CredShareRequest } from "./router.js";
|
||||
|
||||
function api() {
|
||||
return createCredShareApi({ store: createMemoryCredShareStore() });
|
||||
}
|
||||
|
||||
async function login(app: ReturnType<typeof createCredShareApi>, email: string): Promise<string> {
|
||||
const req = await app.handle(reqOf("POST", "/auth/request", { body: { email } }));
|
||||
const code = (req.body as { devCode: string }).devCode;
|
||||
const verify = await app.handle(reqOf("POST", "/auth/verify", { body: { email, code } }));
|
||||
return (verify.body as { token: string }).token;
|
||||
}
|
||||
|
||||
function reqOf(method: string, path: string, opts: { body?: unknown; token?: string; query?: Record<string, string> } = {}): CredShareRequest {
|
||||
return {
|
||||
method,
|
||||
path,
|
||||
query: new URLSearchParams(opts.query ?? {}),
|
||||
body: opts.body,
|
||||
token: opts.token
|
||||
};
|
||||
}
|
||||
|
||||
describe("credshare API", () => {
|
||||
let app: ReturnType<typeof createCredShareApi>;
|
||||
beforeEach(() => {
|
||||
app = api();
|
||||
});
|
||||
|
||||
it("issues a token via the email-code flow and rejects a bad code", async () => {
|
||||
const request = await app.handle(reqOf("POST", "/auth/request", { body: { email: "Owner@Example.com" } }));
|
||||
expect(request.status).toBe(200);
|
||||
const code = (request.body as { devCode: string }).devCode;
|
||||
expect(code).toMatch(/^\d{6}$/);
|
||||
|
||||
const bad = await app.handle(reqOf("POST", "/auth/verify", { body: { email: "owner@example.com", code: "000000" } }));
|
||||
expect(bad.status).toBe(401);
|
||||
|
||||
const good = await app.handle(reqOf("POST", "/auth/verify", { body: { email: "owner@example.com", code } }));
|
||||
expect(good.status).toBe(200);
|
||||
expect((good.body as { user: { email: string } }).user.email).toBe("owner@example.com");
|
||||
});
|
||||
|
||||
it("rejects protected routes without a token", async () => {
|
||||
const res = await app.handle(reqOf("GET", "/teams"));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("runs the full invite → accept → push → grant → pull loop", async () => {
|
||||
const ownerToken = await login(app, "owner@example.com");
|
||||
const bobToken = await login(app, "bob@example.com");
|
||||
|
||||
// Both members upload public keys.
|
||||
await app.handle(reqOf("POST", "/keys", { token: ownerToken, body: { publicKey: "OWNER_PUBKEY_B64" } }));
|
||||
await app.handle(reqOf("POST", "/keys", { token: bobToken, body: { publicKey: "BOB_PUBKEY_B64" } }));
|
||||
|
||||
// Owner creates a team + vault.
|
||||
const team = await app.handle(reqOf("POST", "/teams", { token: ownerToken, body: { slug: "acme", name: "Acme" } }));
|
||||
expect(team.status).toBe(201);
|
||||
const vaultRes = await app.handle(reqOf("POST", "/teams/acme/vaults", { token: ownerToken, body: { name: "prod" } }));
|
||||
const vaultId = (vaultRes.body as { vault: { id: string } }).vault.id;
|
||||
|
||||
// Owner self-grants (wraps DEK to own key) and pushes ciphertext.
|
||||
await app.handle(reqOf("POST", `/vaults/${vaultId}/grants`, { token: ownerToken, body: { email: "owner@example.com", wrappedDek: "DEK_SEALED_TO_OWNER" } }));
|
||||
const push = await app.handle(
|
||||
reqOf("PUT", `/vaults/${vaultId}/secrets`, {
|
||||
token: ownerToken,
|
||||
body: { upserts: [{ name: "DATABASE_URL", nonce: "n1", ciphertext: "c1", fingerprint: "fp1" }] }
|
||||
})
|
||||
);
|
||||
expect(push.status).toBe(200);
|
||||
|
||||
// Bob can't access yet — no grant.
|
||||
const bobEarly = await app.handle(reqOf("GET", `/vaults/${vaultId}/grant`, { token: bobToken }));
|
||||
expect(bobEarly.status).toBe(403);
|
||||
|
||||
// Owner invites Bob; Bob accepts.
|
||||
const invite = await app.handle(reqOf("POST", "/teams/acme/invites", { token: ownerToken, body: { email: "bob@example.com" } }));
|
||||
expect(invite.status).toBe(201);
|
||||
const inviteToken = (invite.body as { token: string }).token;
|
||||
const accept = await app.handle(reqOf("POST", "/invites/accept", { token: bobToken, body: { token: inviteToken } }));
|
||||
expect(accept.status).toBe(200);
|
||||
|
||||
// Bob is now a member and can read ciphertext, but still needs a grant to decrypt.
|
||||
const members = await app.handle(reqOf("GET", "/teams/acme/members", { token: bobToken }));
|
||||
expect((members.body as { members: unknown[] }).members).toHaveLength(2);
|
||||
|
||||
// Owner grants Bob (re-wraps DEK to Bob's pubkey).
|
||||
const grantBob = await app.handle(reqOf("POST", `/vaults/${vaultId}/grants`, { token: ownerToken, body: { email: "bob@example.com", wrappedDek: "DEK_SEALED_TO_BOB" } }));
|
||||
expect(grantBob.status).toBe(201);
|
||||
|
||||
// Bob pulls his wrapped DEK and the ciphertext.
|
||||
const bobGrant = await app.handle(reqOf("GET", `/vaults/${vaultId}/grant`, { token: bobToken }));
|
||||
expect((bobGrant.body as { wrappedDek: string }).wrappedDek).toBe("DEK_SEALED_TO_BOB");
|
||||
const secrets = await app.handle(reqOf("GET", `/vaults/${vaultId}/secrets`, { token: bobToken }));
|
||||
expect((secrets.body as { secrets: { name: string }[] }).secrets[0].name).toBe("DATABASE_URL");
|
||||
});
|
||||
|
||||
it("stops a non-admin from inviting and enforces invite-email match", async () => {
|
||||
const ownerToken = await login(app, "owner@example.com");
|
||||
const bobToken = await login(app, "bob@example.com");
|
||||
const eveToken = await login(app, "eve@example.com");
|
||||
await app.handle(reqOf("POST", "/keys", { token: bobToken, body: { publicKey: "BOB" } }));
|
||||
await app.handle(reqOf("POST", "/teams", { token: ownerToken, body: { slug: "acme", name: "Acme" } }));
|
||||
|
||||
// Invite Bob as a plain member; Bob accepts.
|
||||
const inv = await app.handle(reqOf("POST", "/teams/acme/invites", { token: ownerToken, body: { email: "bob@example.com" } }));
|
||||
await app.handle(reqOf("POST", "/invites/accept", { token: bobToken, body: { token: (inv.body as { token: string }).token } }));
|
||||
|
||||
// Bob (member) cannot invite.
|
||||
const bobInvite = await app.handle(reqOf("POST", "/teams/acme/invites", { token: bobToken, body: { email: "carol@example.com" } }));
|
||||
expect(bobInvite.status).toBe(403);
|
||||
|
||||
// Eve can't accept Bob's-email invite.
|
||||
const inv2 = await app.handle(reqOf("POST", "/teams/acme/invites", { token: ownerToken, body: { email: "carol@example.com" } }));
|
||||
const eveAccept = await app.handle(reqOf("POST", "/invites/accept", { token: eveToken, body: { token: (inv2.body as { token: string }).token } }));
|
||||
expect(eveAccept.status).toBe(403);
|
||||
});
|
||||
|
||||
it("blocks a non-member from a team's vault entirely", async () => {
|
||||
const ownerToken = await login(app, "owner@example.com");
|
||||
const strangerToken = await login(app, "stranger@example.com");
|
||||
await app.handle(reqOf("POST", "/teams", { token: ownerToken, body: { slug: "acme" } }));
|
||||
const v = await app.handle(reqOf("POST", "/teams/acme/vaults", { token: ownerToken, body: { name: "prod" } }));
|
||||
const vaultId = (v.body as { vault: { id: string } }).vault.id;
|
||||
|
||||
const stranger = await app.handle(reqOf("GET", `/vaults/${vaultId}/secrets`, { token: strangerToken }));
|
||||
expect(stranger.status).toBe(403);
|
||||
const strangerMembers = await app.handle(reqOf("GET", "/teams/acme/members", { token: strangerToken }));
|
||||
expect(strangerMembers.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,391 +0,0 @@
|
|||
import { randomBytes, createHash } from "node:crypto";
|
||||
import { hashToken, normalizeEmail, type CredShareStore } from "./store.js";
|
||||
import type { CredShareMember, MemberRole } from "./types.js";
|
||||
|
||||
export interface CredShareRequest {
|
||||
method: string;
|
||||
/** Path AFTER the /api/credshare prefix, e.g. "/teams/acme/members". */
|
||||
path: string;
|
||||
query: URLSearchParams;
|
||||
body: unknown;
|
||||
/** Bearer token (without the "Bearer " prefix), if present. */
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface CredShareResponse {
|
||||
status: number;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
export interface CredShareEmailSender {
|
||||
sendLoginCode(email: string, code: string): Promise<void>;
|
||||
sendInvite(input: { email: string; token: string; teamName: string; teamSlug: string; invitedByEmail: string }): Promise<void>;
|
||||
}
|
||||
|
||||
export interface CredShareApiOptions {
|
||||
store: CredShareStore;
|
||||
email?: CredShareEmailSender;
|
||||
/** Public base URL used to build invite-accept links in emails. */
|
||||
webBaseUrl?: string;
|
||||
now?: () => Date;
|
||||
/** Test seams. */
|
||||
generateCode?: () => string;
|
||||
generateToken?: () => string;
|
||||
loginCodeTtlMs?: number;
|
||||
inviteTtlMs?: number;
|
||||
}
|
||||
|
||||
const ROLE_RANK: Record<MemberRole, number> = { member: 0, admin: 1, owner: 2 };
|
||||
|
||||
function ok(body: unknown, status = 200): CredShareResponse {
|
||||
return { status, body };
|
||||
}
|
||||
function err(status: number, message: string): CredShareResponse {
|
||||
return { status, body: { error: message } };
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function str(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
/** A member view safe to return to teammates (no tokens/keys, just identity + status). */
|
||||
function memberView(m: CredShareMember, publicKey: string | null | undefined, hasGrant?: boolean) {
|
||||
return {
|
||||
email: m.email,
|
||||
role: m.role,
|
||||
status: m.status,
|
||||
hasPublicKey: Boolean(publicKey),
|
||||
...(hasGrant === undefined ? {} : { hasVaultAccess: hasGrant }),
|
||||
joinedAt: m.joinedAt
|
||||
};
|
||||
}
|
||||
|
||||
export function createCredShareApi(options: CredShareApiOptions) {
|
||||
const { store, email } = options;
|
||||
const now = options.now ?? (() => new Date());
|
||||
const generateCode = options.generateCode ?? (() => String(randomBytes(4).readUInt32BE(0) % 1_000_000).padStart(6, "0"));
|
||||
const generateToken = options.generateToken ?? (() => randomBytes(32).toString("base64url"));
|
||||
const loginCodeTtlMs = options.loginCodeTtlMs ?? 10 * 60 * 1000;
|
||||
const inviteTtlMs = options.inviteTtlMs ?? 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
async function currentUserId(req: CredShareRequest): Promise<string | undefined> {
|
||||
if (!req.token) return undefined;
|
||||
return store.getUserIdForToken(hashToken(req.token));
|
||||
}
|
||||
|
||||
type MemberGuard =
|
||||
| { ok: true; team: Awaited<ReturnType<CredShareStore["getTeamBySlug"]>> & object; member: CredShareMember }
|
||||
| { ok: false; response: CredShareResponse };
|
||||
|
||||
async function requireMember(teamSlug: string, userId: string): Promise<MemberGuard> {
|
||||
const team = await store.getTeamBySlug(teamSlug);
|
||||
if (!team) return { ok: false, response: err(404, `Unknown team: ${teamSlug}`) };
|
||||
const member = await store.getMember(team.id, userId);
|
||||
if (!member || member.status !== "active") {
|
||||
return { ok: false, response: err(403, "You are not a member of this team.") };
|
||||
}
|
||||
return { ok: true, team, member };
|
||||
}
|
||||
|
||||
async function handle(req: CredShareRequest): Promise<CredShareResponse> {
|
||||
const { method, path } = req;
|
||||
|
||||
// ---- auth (unauthenticated) ------------------------------------------
|
||||
if (method === "POST" && path === "/auth/request") {
|
||||
if (!isRecord(req.body) || !str(req.body.email)) return err(422, "Expected { email }.");
|
||||
const emailAddr = normalizeEmail(str(req.body.email)!);
|
||||
const code = generateCode();
|
||||
await store.saveLoginCode({
|
||||
email: emailAddr,
|
||||
codeHash: createHash("sha256").update(code).digest("hex"),
|
||||
expiresAt: new Date(now().getTime() + loginCodeTtlMs).toISOString(),
|
||||
createdAt: now().toISOString()
|
||||
});
|
||||
let delivered = false;
|
||||
if (email) {
|
||||
await email.sendLoginCode(emailAddr, code);
|
||||
delivered = true;
|
||||
}
|
||||
// When no email transport is configured (local dev/tests), echo the code so
|
||||
// the flow is still usable. Never echo it once real delivery is on.
|
||||
return ok({ ok: true, emailSent: delivered, ...(delivered ? {} : { devCode: code }) });
|
||||
}
|
||||
|
||||
if (method === "POST" && path === "/auth/verify") {
|
||||
if (!isRecord(req.body) || !str(req.body.email) || !str(req.body.code)) return err(422, "Expected { email, code }.");
|
||||
const emailAddr = normalizeEmail(str(req.body.email)!);
|
||||
const codeHash = createHash("sha256").update(str(req.body.code)!).digest("hex");
|
||||
const valid = await store.consumeLoginCode(emailAddr, codeHash, now());
|
||||
if (!valid) return err(401, "Invalid or expired code.");
|
||||
const user = await store.upsertUserByEmail(emailAddr);
|
||||
const token = generateToken();
|
||||
await store.saveToken({ tokenHash: hashToken(token), userId: user.id, createdAt: now().toISOString(), lastUsedAt: null });
|
||||
return ok({ token, user: { id: user.id, email: user.email, publicKey: user.publicKey } });
|
||||
}
|
||||
|
||||
// ---- everything below requires a valid token -------------------------
|
||||
const userId = await currentUserId(req);
|
||||
if (!userId) return err(401, "Missing or invalid token. Run: logicsrc login --email you@example.com");
|
||||
const me = await store.getUserById(userId);
|
||||
if (!me) return err(401, "Unknown user for token.");
|
||||
|
||||
if (method === "POST" && path === "/logout") {
|
||||
if (req.token) await store.deleteToken(hashToken(req.token));
|
||||
return ok({ ok: true });
|
||||
}
|
||||
|
||||
if (method === "POST" && path === "/keys") {
|
||||
if (!isRecord(req.body) || !str(req.body.publicKey)) return err(422, "Expected { publicKey }.");
|
||||
const updated = await store.setUserPublicKey(userId, str(req.body.publicKey)!);
|
||||
return ok({ email: updated.email, publicKey: updated.publicKey });
|
||||
}
|
||||
|
||||
if (method === "GET" && path === "/me") {
|
||||
const teams = await store.listTeamsForUser(userId);
|
||||
return ok({ user: { id: me.id, email: me.email, publicKey: me.publicKey }, teams });
|
||||
}
|
||||
|
||||
if (method === "GET" && path === "/users") {
|
||||
const target = req.query.get("email");
|
||||
if (!target) return err(422, "Expected ?email=");
|
||||
const user = await store.getUserByEmail(target);
|
||||
return ok({ email: normalizeEmail(target), userId: user?.id ?? null, publicKey: user?.publicKey ?? null });
|
||||
}
|
||||
|
||||
// ---- teams -----------------------------------------------------------
|
||||
if (method === "POST" && path === "/teams") {
|
||||
if (!isRecord(req.body) || !str(req.body.slug)) return err(422, "Expected { slug, name? }.");
|
||||
const slug = normalizeSlug(str(req.body.slug)!);
|
||||
if (!slug) return err(422, "Slug must be lowercase letters, numbers, and dashes.");
|
||||
if (await store.getTeamBySlug(slug)) return err(409, `Team slug "${slug}" is taken.`);
|
||||
const team = await store.createTeam({ slug, name: str(req.body.name) ?? slug, createdBy: userId });
|
||||
await store.addMember({
|
||||
teamId: team.id,
|
||||
userId,
|
||||
email: me.email,
|
||||
role: "owner",
|
||||
status: "active",
|
||||
invitedBy: null,
|
||||
joinedAt: now().toISOString()
|
||||
});
|
||||
await store.appendAudit({ teamId: team.id, vaultId: null, actorUserId: userId, action: "team:create", keyName: null, fingerprint: null });
|
||||
return ok({ team }, 201);
|
||||
}
|
||||
|
||||
if (method === "GET" && path === "/teams") {
|
||||
return ok({ teams: await store.listTeamsForUser(userId) });
|
||||
}
|
||||
|
||||
const membersMatch = /^\/teams\/([^/]+)\/members$/.exec(path);
|
||||
if (membersMatch && method === "GET") {
|
||||
const guard = await requireMember(decodeURIComponent(membersMatch[1]), userId);
|
||||
if (!guard.ok) return guard.response;
|
||||
const rows = await store.listMembers(guard.team.id);
|
||||
const views = await Promise.all(
|
||||
rows.map(async (m) => {
|
||||
const u = m.userId ? await store.getUserById(m.userId) : undefined;
|
||||
return memberView(m, u?.publicKey);
|
||||
})
|
||||
);
|
||||
return ok({ members: views });
|
||||
}
|
||||
|
||||
// ---- invites ---------------------------------------------------------
|
||||
const invitesMatch = /^\/teams\/([^/]+)\/invites$/.exec(path);
|
||||
if (invitesMatch && method === "POST") {
|
||||
const guard = await requireMember(decodeURIComponent(invitesMatch[1]), userId);
|
||||
if (!guard.ok) return guard.response;
|
||||
if (ROLE_RANK[guard.member.role] < ROLE_RANK.admin) return err(403, "Only owners and admins can invite.");
|
||||
if (!isRecord(req.body) || !str(req.body.email)) return err(422, "Expected { email, role? }.");
|
||||
const inviteEmail = normalizeEmail(str(req.body.email)!);
|
||||
const role = (str(req.body.role) as MemberRole) ?? "member";
|
||||
if (!(role in ROLE_RANK)) return err(422, "role must be owner|admin|member.");
|
||||
|
||||
// Ensure a (possibly invited) member row exists.
|
||||
const existing = await store.getMemberByEmail(guard.team.id, inviteEmail);
|
||||
if (!existing) {
|
||||
const invitedUser = await store.getUserByEmail(inviteEmail);
|
||||
await store.addMember({
|
||||
teamId: guard.team.id,
|
||||
userId: invitedUser?.id ?? null,
|
||||
email: inviteEmail,
|
||||
role,
|
||||
status: "invited",
|
||||
invitedBy: userId,
|
||||
joinedAt: null
|
||||
});
|
||||
}
|
||||
const token = generateToken();
|
||||
const invite = await store.createInvite({
|
||||
teamId: guard.team.id,
|
||||
email: inviteEmail,
|
||||
role,
|
||||
tokenHash: hashToken(token),
|
||||
createdBy: userId,
|
||||
expiresAt: new Date(now().getTime() + inviteTtlMs).toISOString()
|
||||
});
|
||||
await store.appendAudit({ teamId: guard.team.id, vaultId: null, actorUserId: userId, action: "team:invite", keyName: inviteEmail, fingerprint: null });
|
||||
let delivered = false;
|
||||
if (email) {
|
||||
await email.sendInvite({ email: inviteEmail, token, teamName: guard.team.name, teamSlug: guard.team.slug, invitedByEmail: me.email });
|
||||
delivered = true;
|
||||
}
|
||||
return ok(
|
||||
{ invite: { id: invite.id, email: invite.email, role: invite.role, expiresAt: invite.expiresAt }, emailSent: delivered, ...(delivered ? {} : { token }) },
|
||||
201
|
||||
);
|
||||
}
|
||||
|
||||
if (method === "POST" && path === "/invites/accept") {
|
||||
if (!isRecord(req.body) || !str(req.body.token)) return err(422, "Expected { token }.");
|
||||
const invite = await store.getInviteByTokenHash(hashToken(str(req.body.token)!));
|
||||
if (!invite) return err(404, "Invite not found.");
|
||||
if (invite.acceptedAt) return err(409, "Invite already used.");
|
||||
if (new Date(invite.expiresAt).getTime() < now().getTime()) return err(410, "Invite expired.");
|
||||
if (invite.email !== me.email) return err(403, `This invite is for ${invite.email}, not ${me.email}.`);
|
||||
await store.updateMember(invite.teamId, me.email, { userId, status: "active", joinedAt: now().toISOString() });
|
||||
await store.markInviteAccepted(invite.id, now().toISOString());
|
||||
await store.appendAudit({ teamId: invite.teamId, vaultId: null, actorUserId: userId, action: "team:join", keyName: null, fingerprint: null });
|
||||
const team = (await store.listTeamsForUser(userId)).find((t) => t.id === invite.teamId);
|
||||
return ok({ ok: true, team });
|
||||
}
|
||||
|
||||
// ---- vaults ----------------------------------------------------------
|
||||
const vaultsMatch = /^\/teams\/([^/]+)\/vaults$/.exec(path);
|
||||
if (vaultsMatch) {
|
||||
const guard = await requireMember(decodeURIComponent(vaultsMatch[1]), userId);
|
||||
if (!guard.ok) return guard.response;
|
||||
if (method === "GET") {
|
||||
const vaults = await store.listVaults(guard.team.id);
|
||||
const rows = await Promise.all(
|
||||
vaults.map(async (v) => ({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
hasAccess: Boolean(await store.getGrant(v.id, userId)),
|
||||
secretCount: (await store.listSecrets(v.id)).length
|
||||
}))
|
||||
);
|
||||
return ok({ vaults: rows });
|
||||
}
|
||||
if (method === "POST") {
|
||||
if (!isRecord(req.body) || !str(req.body.name)) return err(422, "Expected { name }.");
|
||||
const name = normalizeSlug(str(req.body.name)!);
|
||||
if (!name) return err(422, "Vault name must be lowercase letters, numbers, and dashes.");
|
||||
const existing = await store.getVault(guard.team.id, name);
|
||||
if (existing) return ok({ vault: existing });
|
||||
const vault = await store.createVault({ teamId: guard.team.id, name, createdBy: userId });
|
||||
await store.appendAudit({ teamId: guard.team.id, vaultId: vault.id, actorUserId: userId, action: "vault:create", keyName: null, fingerprint: null });
|
||||
return ok({ vault }, 201);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- vault grants / secrets / audit (addressed by vault id) ----------
|
||||
const vaultIdMatch = /^\/vaults\/([^/]+)\/(grant|grants|secrets|audit)$/.exec(path);
|
||||
if (vaultIdMatch) {
|
||||
const vaultId = decodeURIComponent(vaultIdMatch[1]);
|
||||
const sub = vaultIdMatch[2];
|
||||
const vault = await store.getVaultById(vaultId);
|
||||
if (!vault) return err(404, "Unknown vault.");
|
||||
const member = await store.getMember(vault.teamId, userId);
|
||||
if (!member || member.status !== "active") return err(403, "You are not a member of this vault's team.");
|
||||
|
||||
if (sub === "grant" && method === "GET") {
|
||||
const grant = await store.getGrant(vaultId, userId);
|
||||
if (!grant) return err(403, "You do not have access to this vault yet. Ask a member to grant you.");
|
||||
return ok({ wrappedDek: grant.wrappedDek });
|
||||
}
|
||||
|
||||
if (sub === "grants") {
|
||||
if (method === "GET") {
|
||||
const grants = await store.listGrants(vaultId);
|
||||
const grantedUserIds = new Set(grants.map((g) => g.userId));
|
||||
const members = await store.listMembers(vault.teamId);
|
||||
const rows = await Promise.all(
|
||||
members.map(async (m) => {
|
||||
const u = m.userId ? await store.getUserById(m.userId) : undefined;
|
||||
return { email: m.email, hasPublicKey: Boolean(u?.publicKey), hasAccess: Boolean(m.userId && grantedUserIds.has(m.userId)) };
|
||||
})
|
||||
);
|
||||
return ok({ grants: rows });
|
||||
}
|
||||
if (method === "POST") {
|
||||
// The caller must already hold access (they can produce a valid wrapped DEK).
|
||||
const iHold = await store.getGrant(vaultId, userId);
|
||||
if (!iHold && (await store.listGrants(vaultId)).length > 0) {
|
||||
return err(403, "Only a member with vault access can grant others.");
|
||||
}
|
||||
if (!isRecord(req.body) || !str(req.body.wrappedDek) || !str(req.body.email)) {
|
||||
return err(422, "Expected { email, wrappedDek }.");
|
||||
}
|
||||
const targetUser = await store.getUserByEmail(str(req.body.email)!);
|
||||
if (!targetUser) return err(404, "Target user has not logged in yet.");
|
||||
if (!targetUser.publicKey) return err(409, "Target user has not uploaded a public key yet.");
|
||||
await store.upsertGrant({ vaultId, userId: targetUser.id, wrappedDek: str(req.body.wrappedDek)!, grantedBy: userId, createdAt: now().toISOString() });
|
||||
await store.appendAudit({ teamId: vault.teamId, vaultId, actorUserId: userId, action: "vault:grant", keyName: targetUser.email, fingerprint: null });
|
||||
return ok({ ok: true }, 201);
|
||||
}
|
||||
}
|
||||
|
||||
if (sub === "secrets") {
|
||||
if (method === "GET") {
|
||||
const secrets = await store.listSecrets(vaultId);
|
||||
return ok({
|
||||
vaultId,
|
||||
secrets: secrets.map((s) => ({ name: s.name, nonce: s.nonce, ciphertext: s.ciphertext, fingerprint: s.fingerprint, version: s.version, updatedAt: s.updatedAt }))
|
||||
});
|
||||
}
|
||||
if (method === "PUT") {
|
||||
if (!isRecord(req.body)) return err(422, "Expected { upserts?, deletes? }.");
|
||||
const upserts = Array.isArray(req.body.upserts) ? req.body.upserts : [];
|
||||
const deletes = Array.isArray(req.body.deletes) ? req.body.deletes : [];
|
||||
const existing = new Map((await store.listSecrets(vaultId)).map((s) => [s.name, s]));
|
||||
const applied: string[] = [];
|
||||
for (const raw of upserts) {
|
||||
if (!isRecord(raw) || !str(raw.name) || !str(raw.nonce) || !str(raw.ciphertext) || !str(raw.fingerprint)) {
|
||||
return err(422, "Each upsert needs { name, nonce, ciphertext, fingerprint }.");
|
||||
}
|
||||
const name = str(raw.name)!;
|
||||
const prev = existing.get(name);
|
||||
await store.putSecret({
|
||||
vaultId,
|
||||
name,
|
||||
nonce: str(raw.nonce)!,
|
||||
ciphertext: str(raw.ciphertext)!,
|
||||
fingerprint: str(raw.fingerprint)!,
|
||||
version: (prev?.version ?? 0) + 1,
|
||||
updatedBy: userId,
|
||||
updatedAt: now().toISOString()
|
||||
});
|
||||
await store.appendAudit({ teamId: vault.teamId, vaultId, actorUserId: userId, action: prev ? "secret:update" : "secret:add", keyName: name, fingerprint: str(raw.fingerprint)! });
|
||||
applied.push(name);
|
||||
}
|
||||
for (const raw of deletes) {
|
||||
const name = str(raw);
|
||||
if (!name) continue;
|
||||
await store.deleteSecret(vaultId, name);
|
||||
await store.appendAudit({ teamId: vault.teamId, vaultId, actorUserId: userId, action: "secret:remove", keyName: name, fingerprint: null });
|
||||
applied.push(name);
|
||||
}
|
||||
return ok({ ok: true, applied });
|
||||
}
|
||||
}
|
||||
|
||||
if (sub === "audit" && method === "GET") {
|
||||
return ok({ audit: await store.listAudit(vaultId) });
|
||||
}
|
||||
}
|
||||
|
||||
return err(404, "Not found");
|
||||
}
|
||||
|
||||
return { handle };
|
||||
}
|
||||
|
||||
function normalizeSlug(input: string): string | undefined {
|
||||
const slug = input.trim().toLowerCase();
|
||||
return /^[a-z0-9][a-z0-9-]{0,62}$/.test(slug) ? slug : undefined;
|
||||
}
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
import { randomUUID, createHash } from "node:crypto";
|
||||
import type {
|
||||
CredShareUser,
|
||||
CredShareTeam,
|
||||
CredShareMember,
|
||||
CredShareInvite,
|
||||
CredShareVault,
|
||||
CredShareGrant,
|
||||
CredShareSecret,
|
||||
CredShareAuditEvent,
|
||||
CredShareLoginCode,
|
||||
CredShareToken,
|
||||
MemberRole,
|
||||
MemberStatus
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Storage boundary for the credential-sharing server. The router talks only to
|
||||
* this interface, so it can run against an in-memory map (tests / local dev) or
|
||||
* Supabase (production) without changing a line of route logic.
|
||||
*/
|
||||
export interface CredShareStore {
|
||||
// users + keys
|
||||
upsertUserByEmail(email: string): Promise<CredShareUser>;
|
||||
getUserByEmail(email: string): Promise<CredShareUser | undefined>;
|
||||
getUserById(id: string): Promise<CredShareUser | undefined>;
|
||||
setUserPublicKey(userId: string, publicKey: string): Promise<CredShareUser>;
|
||||
|
||||
// auth
|
||||
saveLoginCode(code: CredShareLoginCode): Promise<void>;
|
||||
consumeLoginCode(email: string, codeHash: string, now: Date): Promise<boolean>;
|
||||
saveToken(token: CredShareToken): Promise<void>;
|
||||
getUserIdForToken(tokenHash: string): Promise<string | undefined>;
|
||||
deleteToken(tokenHash: string): Promise<void>;
|
||||
|
||||
// teams + members
|
||||
createTeam(input: { slug: string; name: string; createdBy: string }): Promise<CredShareTeam>;
|
||||
getTeamBySlug(slug: string): Promise<CredShareTeam | undefined>;
|
||||
listTeamsForUser(userId: string): Promise<CredShareTeam[]>;
|
||||
addMember(input: Omit<CredShareMember, "id" | "createdAt">): Promise<CredShareMember>;
|
||||
getMember(teamId: string, userId: string): Promise<CredShareMember | undefined>;
|
||||
getMemberByEmail(teamId: string, email: string): Promise<CredShareMember | undefined>;
|
||||
listMembers(teamId: string): Promise<CredShareMember[]>;
|
||||
updateMember(
|
||||
teamId: string,
|
||||
email: string,
|
||||
patch: Partial<Pick<CredShareMember, "userId" | "status" | "joinedAt" | "role">>
|
||||
): Promise<CredShareMember | undefined>;
|
||||
|
||||
// invites
|
||||
createInvite(input: Omit<CredShareInvite, "id" | "createdAt" | "acceptedAt">): Promise<CredShareInvite>;
|
||||
getInviteByTokenHash(tokenHash: string): Promise<CredShareInvite | undefined>;
|
||||
markInviteAccepted(id: string, at: string): Promise<void>;
|
||||
|
||||
// vaults + grants + secrets
|
||||
createVault(input: { teamId: string; name: string; createdBy: string }): Promise<CredShareVault>;
|
||||
getVault(teamId: string, name: string): Promise<CredShareVault | undefined>;
|
||||
getVaultById(vaultId: string): Promise<CredShareVault | undefined>;
|
||||
listVaults(teamId: string): Promise<CredShareVault[]>;
|
||||
upsertGrant(grant: CredShareGrant): Promise<void>;
|
||||
getGrant(vaultId: string, userId: string): Promise<CredShareGrant | undefined>;
|
||||
listGrants(vaultId: string): Promise<CredShareGrant[]>;
|
||||
listSecrets(vaultId: string): Promise<CredShareSecret[]>;
|
||||
putSecret(secret: CredShareSecret): Promise<void>;
|
||||
deleteSecret(vaultId: string, name: string): Promise<void>;
|
||||
|
||||
// audit
|
||||
appendAudit(event: Omit<CredShareAuditEvent, "id" | "createdAt">): Promise<CredShareAuditEvent>;
|
||||
listAudit(vaultId: string): Promise<CredShareAuditEvent[]>;
|
||||
}
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/** In-memory store — used by tests and single-process local dev. */
|
||||
export function createMemoryCredShareStore(): CredShareStore {
|
||||
const users = new Map<string, CredShareUser>(); // by id
|
||||
const usersByEmail = new Map<string, string>(); // email -> id
|
||||
const loginCodes = new Map<string, CredShareLoginCode>(); // email -> code
|
||||
const tokens = new Map<string, CredShareToken>(); // tokenHash -> token
|
||||
const teams = new Map<string, CredShareTeam>(); // by id
|
||||
const teamsBySlug = new Map<string, string>(); // slug -> id
|
||||
const members: CredShareMember[] = [];
|
||||
const invites = new Map<string, CredShareInvite>(); // by id
|
||||
const vaults = new Map<string, CredShareVault>(); // by id
|
||||
const grants = new Map<string, CredShareGrant>(); // `${vaultId}:${userId}`
|
||||
const secrets = new Map<string, CredShareSecret>(); // `${vaultId}:${name}`
|
||||
const audit: CredShareAuditEvent[] = [];
|
||||
|
||||
const iso = () => new Date().toISOString();
|
||||
|
||||
return {
|
||||
async upsertUserByEmail(email) {
|
||||
const key = normalizeEmail(email);
|
||||
const existingId = usersByEmail.get(key);
|
||||
if (existingId) {
|
||||
return users.get(existingId)!;
|
||||
}
|
||||
const user: CredShareUser = { id: randomUUID(), email: key, publicKey: null, createdAt: iso() };
|
||||
users.set(user.id, user);
|
||||
usersByEmail.set(key, user.id);
|
||||
return user;
|
||||
},
|
||||
async getUserByEmail(email) {
|
||||
const id = usersByEmail.get(normalizeEmail(email));
|
||||
return id ? users.get(id) : undefined;
|
||||
},
|
||||
async getUserById(id) {
|
||||
return users.get(id);
|
||||
},
|
||||
async setUserPublicKey(userId, publicKey) {
|
||||
const user = users.get(userId);
|
||||
if (!user) throw new Error("user not found");
|
||||
user.publicKey = publicKey;
|
||||
return user;
|
||||
},
|
||||
|
||||
async saveLoginCode(code) {
|
||||
loginCodes.set(normalizeEmail(code.email), code);
|
||||
},
|
||||
async consumeLoginCode(email, codeHash, now) {
|
||||
const key = normalizeEmail(email);
|
||||
const code = loginCodes.get(key);
|
||||
if (!code || code.codeHash !== codeHash || new Date(code.expiresAt).getTime() < now.getTime()) {
|
||||
return false;
|
||||
}
|
||||
loginCodes.delete(key);
|
||||
return true;
|
||||
},
|
||||
async saveToken(token) {
|
||||
tokens.set(token.tokenHash, token);
|
||||
},
|
||||
async getUserIdForToken(tokenHash) {
|
||||
const token = tokens.get(tokenHash);
|
||||
if (token) {
|
||||
token.lastUsedAt = iso();
|
||||
}
|
||||
return token?.userId;
|
||||
},
|
||||
async deleteToken(tokenHash) {
|
||||
tokens.delete(tokenHash);
|
||||
},
|
||||
|
||||
async createTeam({ slug, name, createdBy }) {
|
||||
const team: CredShareTeam = { id: randomUUID(), slug, name, createdBy, createdAt: iso() };
|
||||
teams.set(team.id, team);
|
||||
teamsBySlug.set(slug, team.id);
|
||||
return team;
|
||||
},
|
||||
async getTeamBySlug(slug) {
|
||||
const id = teamsBySlug.get(slug);
|
||||
return id ? teams.get(id) : undefined;
|
||||
},
|
||||
async listTeamsForUser(userId) {
|
||||
const teamIds = new Set(members.filter((m) => m.userId === userId && m.status === "active").map((m) => m.teamId));
|
||||
return [...teamIds].map((id) => teams.get(id)!).filter(Boolean);
|
||||
},
|
||||
async addMember(input) {
|
||||
const member: CredShareMember = { ...input, id: randomUUID(), createdAt: iso() };
|
||||
members.push(member);
|
||||
return member;
|
||||
},
|
||||
async getMember(teamId, userId) {
|
||||
return members.find((m) => m.teamId === teamId && m.userId === userId);
|
||||
},
|
||||
async getMemberByEmail(teamId, email) {
|
||||
const key = normalizeEmail(email);
|
||||
return members.find((m) => m.teamId === teamId && m.email === key);
|
||||
},
|
||||
async listMembers(teamId) {
|
||||
return members.filter((m) => m.teamId === teamId);
|
||||
},
|
||||
async updateMember(teamId, email, patch) {
|
||||
const key = normalizeEmail(email);
|
||||
const member = members.find((m) => m.teamId === teamId && m.email === key);
|
||||
if (!member) return undefined;
|
||||
Object.assign(member, patch);
|
||||
return member;
|
||||
},
|
||||
|
||||
async createInvite(input) {
|
||||
const invite: CredShareInvite = { ...input, id: randomUUID(), acceptedAt: null, createdAt: iso() };
|
||||
invites.set(invite.id, invite);
|
||||
return invite;
|
||||
},
|
||||
async getInviteByTokenHash(tokenHash) {
|
||||
return [...invites.values()].find((i) => i.tokenHash === tokenHash);
|
||||
},
|
||||
async markInviteAccepted(id, at) {
|
||||
const invite = invites.get(id);
|
||||
if (invite) invite.acceptedAt = at;
|
||||
},
|
||||
|
||||
async createVault({ teamId, name, createdBy }) {
|
||||
const vault: CredShareVault = { id: randomUUID(), teamId, name, createdBy, createdAt: iso() };
|
||||
vaults.set(vault.id, vault);
|
||||
return vault;
|
||||
},
|
||||
async getVault(teamId, name) {
|
||||
return [...vaults.values()].find((v) => v.teamId === teamId && v.name === name);
|
||||
},
|
||||
async getVaultById(vaultId) {
|
||||
return vaults.get(vaultId);
|
||||
},
|
||||
async listVaults(teamId) {
|
||||
return [...vaults.values()].filter((v) => v.teamId === teamId);
|
||||
},
|
||||
async upsertGrant(grant) {
|
||||
grants.set(`${grant.vaultId}:${grant.userId}`, grant);
|
||||
},
|
||||
async getGrant(vaultId, userId) {
|
||||
return grants.get(`${vaultId}:${userId}`);
|
||||
},
|
||||
async listGrants(vaultId) {
|
||||
return [...grants.values()].filter((g) => g.vaultId === vaultId);
|
||||
},
|
||||
async listSecrets(vaultId) {
|
||||
return [...secrets.values()].filter((s) => s.vaultId === vaultId).sort((a, b) => a.name.localeCompare(b.name));
|
||||
},
|
||||
async putSecret(secret) {
|
||||
secrets.set(`${secret.vaultId}:${secret.name}`, secret);
|
||||
},
|
||||
async deleteSecret(vaultId, name) {
|
||||
secrets.delete(`${vaultId}:${name}`);
|
||||
},
|
||||
|
||||
async appendAudit(event) {
|
||||
const full: CredShareAuditEvent = { ...event, id: randomUUID(), createdAt: iso() };
|
||||
audit.push(full);
|
||||
return full;
|
||||
},
|
||||
async listAudit(vaultId) {
|
||||
return audit.filter((e) => e.vaultId === vaultId).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export type { MemberRole, MemberStatus };
|
||||
|
|
@ -1,238 +0,0 @@
|
|||
import { createRequire } from "node:module";
|
||||
import type { CredShareStore } from "./store.js";
|
||||
import { normalizeEmail } from "./store.js";
|
||||
import type {
|
||||
CredShareUser,
|
||||
CredShareTeam,
|
||||
CredShareMember,
|
||||
CredShareInvite,
|
||||
CredShareVault,
|
||||
CredShareGrant,
|
||||
CredShareSecret,
|
||||
CredShareAuditEvent,
|
||||
CredShareLoginCode,
|
||||
CredShareToken
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Supabase-backed CredShareStore (production). Uses the SERVICE ROLE key and
|
||||
* enforces authorization in the router, not via RLS — RLS on these tables is
|
||||
* deny-by-default defense-in-depth. Returns `undefined` when unconfigured so
|
||||
* the API falls back to the in-memory store for local dev.
|
||||
*
|
||||
* Table columns are snake_case; this module maps to/from the camelCase domain
|
||||
* types. See supabase/migrations/*_credshare.sql.
|
||||
*/
|
||||
export function createSupabaseCredShareStore(): CredShareStore | undefined {
|
||||
const url = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const key = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY;
|
||||
if (!url || !key) return undefined;
|
||||
|
||||
// Lazy-require so the API runs without @supabase/supabase-js installed when
|
||||
// Supabase isn't configured (the memory store needs no dependency).
|
||||
const require = createRequire(import.meta.url);
|
||||
const { createClient } = require("@supabase/supabase-js") as typeof import("@supabase/supabase-js");
|
||||
const db = createClient(url, key, { auth: { persistSession: false } });
|
||||
|
||||
const T = {
|
||||
users: "credshare_users",
|
||||
loginCodes: "credshare_login_codes",
|
||||
tokens: "credshare_tokens",
|
||||
teams: "credshare_teams",
|
||||
members: "credshare_members",
|
||||
invites: "credshare_invites",
|
||||
vaults: "credshare_vaults",
|
||||
grants: "credshare_vault_grants",
|
||||
secrets: "credshare_secrets",
|
||||
audit: "credshare_audit"
|
||||
} as const;
|
||||
|
||||
const userOut = (r: Record<string, unknown>): CredShareUser => ({ id: r.id as string, email: r.email as string, publicKey: (r.public_key as string) ?? null, createdAt: r.created_at as string });
|
||||
const teamOut = (r: Record<string, unknown>): CredShareTeam => ({ id: r.id as string, slug: r.slug as string, name: r.name as string, createdBy: r.created_by as string, createdAt: r.created_at as string });
|
||||
const memberOut = (r: Record<string, unknown>): CredShareMember => ({ id: r.id as string, teamId: r.team_id as string, userId: (r.user_id as string) ?? null, email: r.email as string, role: r.role as CredShareMember["role"], status: r.status as CredShareMember["status"], invitedBy: (r.invited_by as string) ?? null, createdAt: r.created_at as string, joinedAt: (r.joined_at as string) ?? null });
|
||||
const vaultOut = (r: Record<string, unknown>): CredShareVault => ({ id: r.id as string, teamId: r.team_id as string, name: r.name as string, createdBy: r.created_by as string, createdAt: r.created_at as string });
|
||||
const secretOut = (r: Record<string, unknown>): CredShareSecret => ({ vaultId: r.vault_id as string, name: r.name as string, nonce: r.nonce as string, ciphertext: r.ciphertext as string, fingerprint: r.fingerprint as string, version: r.version as number, updatedBy: r.updated_by as string, updatedAt: r.updated_at as string });
|
||||
const grantOut = (r: Record<string, unknown>): CredShareGrant => ({ vaultId: r.vault_id as string, userId: r.user_id as string, wrappedDek: r.wrapped_dek as string, grantedBy: r.granted_by as string, createdAt: r.created_at as string });
|
||||
const auditOut = (r: Record<string, unknown>): CredShareAuditEvent => ({ id: r.id as string, teamId: (r.team_id as string) ?? null, vaultId: (r.vault_id as string) ?? null, actorUserId: r.actor_user_id as string, action: r.action as string, keyName: (r.key_name as string) ?? null, fingerprint: (r.fingerprint as string) ?? null, createdAt: r.created_at as string });
|
||||
|
||||
async function one<T>(query: PromiseLike<{ data: unknown; error: unknown }>, map: (r: Record<string, unknown>) => T): Promise<T | undefined> {
|
||||
const { data, error } = await query;
|
||||
if (error) throw error;
|
||||
return data ? map(data as Record<string, unknown>) : undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
async upsertUserByEmail(email) {
|
||||
const key = normalizeEmail(email);
|
||||
const existing = await one(db.from(T.users).select("*").eq("email", key).maybeSingle(), userOut);
|
||||
if (existing) return existing;
|
||||
const { data, error } = await db.from(T.users).insert({ email: key }).select("*").single();
|
||||
if (error) throw error;
|
||||
return userOut(data);
|
||||
},
|
||||
async getUserByEmail(email) {
|
||||
return one(db.from(T.users).select("*").eq("email", normalizeEmail(email)).maybeSingle(), userOut);
|
||||
},
|
||||
async getUserById(id) {
|
||||
return one(db.from(T.users).select("*").eq("id", id).maybeSingle(), userOut);
|
||||
},
|
||||
async setUserPublicKey(userId, publicKey) {
|
||||
const { data, error } = await db.from(T.users).update({ public_key: publicKey }).eq("id", userId).select("*").single();
|
||||
if (error) throw error;
|
||||
return userOut(data);
|
||||
},
|
||||
|
||||
async saveLoginCode(code: CredShareLoginCode) {
|
||||
await db.from(T.loginCodes).delete().eq("email", code.email);
|
||||
const { error } = await db.from(T.loginCodes).insert({ email: code.email, code_hash: code.codeHash, expires_at: code.expiresAt });
|
||||
if (error) throw error;
|
||||
},
|
||||
async consumeLoginCode(email, codeHash, nowDate) {
|
||||
const { data, error } = await db.from(T.loginCodes).select("*").eq("email", normalizeEmail(email)).eq("code_hash", codeHash).maybeSingle();
|
||||
if (error) throw error;
|
||||
if (!data || new Date(data.expires_at as string).getTime() < nowDate.getTime()) return false;
|
||||
await db.from(T.loginCodes).delete().eq("email", normalizeEmail(email));
|
||||
return true;
|
||||
},
|
||||
async saveToken(token: CredShareToken) {
|
||||
const { error } = await db.from(T.tokens).insert({ token_hash: token.tokenHash, user_id: token.userId });
|
||||
if (error) throw error;
|
||||
},
|
||||
async getUserIdForToken(tokenHash) {
|
||||
const { data, error } = await db.from(T.tokens).select("user_id").eq("token_hash", tokenHash).maybeSingle();
|
||||
if (error) throw error;
|
||||
if (data) await db.from(T.tokens).update({ last_used_at: new Date().toISOString() }).eq("token_hash", tokenHash);
|
||||
return (data?.user_id as string) ?? undefined;
|
||||
},
|
||||
async deleteToken(tokenHash) {
|
||||
await db.from(T.tokens).delete().eq("token_hash", tokenHash);
|
||||
},
|
||||
|
||||
async createTeam({ slug, name, createdBy }) {
|
||||
const { data, error } = await db.from(T.teams).insert({ slug, name, created_by: createdBy }).select("*").single();
|
||||
if (error) throw error;
|
||||
return teamOut(data);
|
||||
},
|
||||
async getTeamBySlug(slug) {
|
||||
return one(db.from(T.teams).select("*").eq("slug", slug).maybeSingle(), teamOut);
|
||||
},
|
||||
async listTeamsForUser(userId) {
|
||||
const { data, error } = await db.from(T.members).select("team_id").eq("user_id", userId).eq("status", "active");
|
||||
if (error) throw error;
|
||||
const ids = (data ?? []).map((r) => r.team_id as string);
|
||||
if (ids.length === 0) return [];
|
||||
const { data: teams, error: teamErr } = await db.from(T.teams).select("*").in("id", ids);
|
||||
if (teamErr) throw teamErr;
|
||||
return (teams ?? []).map(teamOut);
|
||||
},
|
||||
async addMember(input) {
|
||||
const { data, error } = await db
|
||||
.from(T.members)
|
||||
.insert({ team_id: input.teamId, user_id: input.userId, email: input.email, role: input.role, status: input.status, invited_by: input.invitedBy, joined_at: input.joinedAt })
|
||||
.select("*")
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return memberOut(data);
|
||||
},
|
||||
async getMember(teamId, userId) {
|
||||
return one(db.from(T.members).select("*").eq("team_id", teamId).eq("user_id", userId).maybeSingle(), memberOut);
|
||||
},
|
||||
async getMemberByEmail(teamId, email) {
|
||||
return one(db.from(T.members).select("*").eq("team_id", teamId).eq("email", normalizeEmail(email)).maybeSingle(), memberOut);
|
||||
},
|
||||
async listMembers(teamId) {
|
||||
const { data, error } = await db.from(T.members).select("*").eq("team_id", teamId).order("created_at");
|
||||
if (error) throw error;
|
||||
return (data ?? []).map(memberOut);
|
||||
},
|
||||
async updateMember(teamId, email, patch) {
|
||||
const update: Record<string, unknown> = {};
|
||||
if (patch.userId !== undefined) update.user_id = patch.userId;
|
||||
if (patch.status !== undefined) update.status = patch.status;
|
||||
if (patch.joinedAt !== undefined) update.joined_at = patch.joinedAt;
|
||||
if (patch.role !== undefined) update.role = patch.role;
|
||||
return one(db.from(T.members).update(update).eq("team_id", teamId).eq("email", normalizeEmail(email)).select("*").maybeSingle(), memberOut);
|
||||
},
|
||||
|
||||
async createInvite(input) {
|
||||
const { data, error } = await db
|
||||
.from(T.invites)
|
||||
.insert({ team_id: input.teamId, email: input.email, role: input.role, token_hash: input.tokenHash, created_by: input.createdBy, expires_at: input.expiresAt })
|
||||
.select("*")
|
||||
.single();
|
||||
if (error) throw error;
|
||||
const r = data as Record<string, unknown>;
|
||||
return { id: r.id as string, teamId: r.team_id as string, email: r.email as string, role: r.role as CredShareInvite["role"], tokenHash: r.token_hash as string, createdBy: r.created_by as string, expiresAt: r.expires_at as string, acceptedAt: (r.accepted_at as string) ?? null, createdAt: r.created_at as string };
|
||||
},
|
||||
async getInviteByTokenHash(tokenHash) {
|
||||
const { data, error } = await db.from(T.invites).select("*").eq("token_hash", tokenHash).maybeSingle();
|
||||
if (error) throw error;
|
||||
if (!data) return undefined;
|
||||
const r = data as Record<string, unknown>;
|
||||
return { id: r.id as string, teamId: r.team_id as string, email: r.email as string, role: r.role as CredShareInvite["role"], tokenHash: r.token_hash as string, createdBy: r.created_by as string, expiresAt: r.expires_at as string, acceptedAt: (r.accepted_at as string) ?? null, createdAt: r.created_at as string };
|
||||
},
|
||||
async markInviteAccepted(id, at) {
|
||||
await db.from(T.invites).update({ accepted_at: at }).eq("id", id);
|
||||
},
|
||||
|
||||
async createVault({ teamId, name, createdBy }) {
|
||||
const { data, error } = await db.from(T.vaults).insert({ team_id: teamId, name, created_by: createdBy }).select("*").single();
|
||||
if (error) throw error;
|
||||
return vaultOut(data);
|
||||
},
|
||||
async getVault(teamId, name) {
|
||||
return one(db.from(T.vaults).select("*").eq("team_id", teamId).eq("name", name).maybeSingle(), vaultOut);
|
||||
},
|
||||
async getVaultById(vaultId) {
|
||||
return one(db.from(T.vaults).select("*").eq("id", vaultId).maybeSingle(), vaultOut);
|
||||
},
|
||||
async listVaults(teamId) {
|
||||
const { data, error } = await db.from(T.vaults).select("*").eq("team_id", teamId).order("name");
|
||||
if (error) throw error;
|
||||
return (data ?? []).map(vaultOut);
|
||||
},
|
||||
async upsertGrant(grant) {
|
||||
const { error } = await db
|
||||
.from(T.grants)
|
||||
.upsert({ vault_id: grant.vaultId, user_id: grant.userId, wrapped_dek: grant.wrappedDek, granted_by: grant.grantedBy }, { onConflict: "vault_id,user_id" });
|
||||
if (error) throw error;
|
||||
},
|
||||
async getGrant(vaultId, userId) {
|
||||
return one(db.from(T.grants).select("*").eq("vault_id", vaultId).eq("user_id", userId).maybeSingle(), grantOut);
|
||||
},
|
||||
async listGrants(vaultId) {
|
||||
const { data, error } = await db.from(T.grants).select("*").eq("vault_id", vaultId);
|
||||
if (error) throw error;
|
||||
return (data ?? []).map(grantOut);
|
||||
},
|
||||
async listSecrets(vaultId) {
|
||||
const { data, error } = await db.from(T.secrets).select("*").eq("vault_id", vaultId).order("name");
|
||||
if (error) throw error;
|
||||
return (data ?? []).map(secretOut);
|
||||
},
|
||||
async putSecret(secret) {
|
||||
const { error } = await db
|
||||
.from(T.secrets)
|
||||
.upsert({ vault_id: secret.vaultId, name: secret.name, nonce: secret.nonce, ciphertext: secret.ciphertext, fingerprint: secret.fingerprint, version: secret.version, updated_by: secret.updatedBy, updated_at: secret.updatedAt }, { onConflict: "vault_id,name" });
|
||||
if (error) throw error;
|
||||
},
|
||||
async deleteSecret(vaultId, name) {
|
||||
await db.from(T.secrets).delete().eq("vault_id", vaultId).eq("name", name);
|
||||
},
|
||||
|
||||
async appendAudit(event) {
|
||||
const { data, error } = await db
|
||||
.from(T.audit)
|
||||
.insert({ team_id: event.teamId, vault_id: event.vaultId, actor_user_id: event.actorUserId, action: event.action, key_name: event.keyName, fingerprint: event.fingerprint })
|
||||
.select("*")
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return auditOut(data);
|
||||
},
|
||||
async listAudit(vaultId) {
|
||||
const { data, error } = await db.from(T.audit).select("*").eq("vault_id", vaultId).order("created_at", { ascending: false });
|
||||
if (error) throw error;
|
||||
return (data ?? []).map(auditOut);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
98
apps/logicsrc-web/public/install.sh
Executable file
98
apps/logicsrc-web/public/install.sh
Executable file
|
|
@ -0,0 +1,98 @@
|
|||
#!/bin/sh
|
||||
# LogicSRC — one-line installer for the `logicsrc` CLI (from the GitHub repo).
|
||||
#
|
||||
# curl -fsSL https://logicsrc.com/install.sh | sh
|
||||
#
|
||||
# Subcommands:
|
||||
# curl -fsSL https://logicsrc.com/install.sh | sh -s -- install (default)
|
||||
# curl -fsSL https://logicsrc.com/install.sh | sh -s -- update
|
||||
# curl -fsSL https://logicsrc.com/install.sh | sh -s -- uninstall
|
||||
#
|
||||
# What it does:
|
||||
# 1. Detects OS (Linux/macOS — Windows: use WSL) and requires Node 18+.
|
||||
# 2. Fetches the repo tarball from GitHub into $LOGICSRC_HOME/src.
|
||||
# 3. `npm install` + `npm run build:cli` (builds only the CLI's workspaces).
|
||||
# 4. Drops a `logicsrc` wrapper on $HOME/.local/bin.
|
||||
#
|
||||
# Env overrides:
|
||||
# LOGICSRC_HOME=/path install dir (default: $HOME/.logicsrc-cli)
|
||||
# LOGICSRC_BIN=/path/dir wrapper bin dir (default: $HOME/.local/bin)
|
||||
# LOGICSRC_REF=branch|tag git ref (default: master)
|
||||
set -eu
|
||||
|
||||
GH_REPO="profullstack/logicsrc"
|
||||
LOGICSRC_REF="${LOGICSRC_REF:-master}"
|
||||
TARBALL_URL="https://codeload.github.com/$GH_REPO/tar.gz/$LOGICSRC_REF"
|
||||
|
||||
# --- operator identity (curl|sh may land with HOME/USER unset) ---
|
||||
_home() { if [ -n "${HOME:-}" ] && [ -d "$HOME" ]; then echo "$HOME"; else echo "${HOME:-/tmp}"; fi; }
|
||||
HOME="$(_home)"; export HOME
|
||||
LOGICSRC_HOME="${LOGICSRC_HOME:-$HOME/.logicsrc-cli}"
|
||||
LOGICSRC_BIN="${LOGICSRC_BIN:-$HOME/.local/bin}"
|
||||
SRC_DIR="$LOGICSRC_HOME/src"
|
||||
WRAPPER="$LOGICSRC_BIN/logicsrc"
|
||||
|
||||
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
|
||||
G=$(printf '\033[32m'); Y=$(printf '\033[33m'); B=$(printf '\033[34m'); R=$(printf '\033[31m'); X=$(printf '\033[0m')
|
||||
else G=''; Y=''; B=''; R=''; X=''; fi
|
||||
info() { printf '%s==>%s %s\n' "$B" "$X" "$*"; }
|
||||
ok() { printf '%s ✓%s %s\n' "$G" "$X" "$*"; }
|
||||
warn() { printf '%s !%s %s\n' "$Y" "$X" "$*" >&2; }
|
||||
fail() { printf '%s ✗%s %s\n' "$R" "$X" "$*" >&2; exit 1; }
|
||||
|
||||
need() { command -v "$1" >/dev/null 2>&1 || fail "missing '$1' — please install it and re-run."; }
|
||||
|
||||
detect_os() {
|
||||
case "$(uname -s)" in
|
||||
Linux) : ;; Darwin) : ;;
|
||||
*) fail "unsupported OS (Linux and macOS only — Windows: use WSL)";;
|
||||
esac
|
||||
}
|
||||
|
||||
check_node() {
|
||||
need node
|
||||
major="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)"
|
||||
[ "$major" -ge 18 ] 2>/dev/null || fail "Node 18+ required (found $(node -v 2>/dev/null || echo none)). Install from https://nodejs.org or via mise/nvm."
|
||||
need npm
|
||||
}
|
||||
|
||||
do_install() {
|
||||
detect_os; check_node
|
||||
need curl; need tar
|
||||
info "fetching logicsrc@$LOGICSRC_REF from GitHub…"
|
||||
mkdir -p "$SRC_DIR"
|
||||
tmp="$(mktemp -d)"
|
||||
curl -fsSL "$TARBALL_URL" | tar -xz -C "$tmp" --strip-components=1
|
||||
rm -rf "$SRC_DIR"; mkdir -p "$(dirname "$SRC_DIR")"; mv "$tmp" "$SRC_DIR"
|
||||
ok "downloaded to $SRC_DIR"
|
||||
|
||||
info "installing dependencies (this can take a minute)…"
|
||||
( cd "$SRC_DIR" && npm install --no-audit --no-fund --ignore-scripts >/dev/null 2>&1 ) || fail "npm install failed — run it by hand in $SRC_DIR"
|
||||
info "building the CLI…"
|
||||
( cd "$SRC_DIR" && npm run build:cli >/dev/null 2>&1 ) || fail "build failed — run 'npm run build:cli' in $SRC_DIR"
|
||||
|
||||
mkdir -p "$LOGICSRC_BIN"
|
||||
cat > "$WRAPPER" <<EOF
|
||||
#!/bin/sh
|
||||
exec node "$SRC_DIR/packages/cli/dist/index.js" "\$@"
|
||||
EOF
|
||||
chmod +x "$WRAPPER"
|
||||
ok "installed logicsrc → $WRAPPER"
|
||||
|
||||
case ":$PATH:" in
|
||||
*":$LOGICSRC_BIN:"*) : ;;
|
||||
*) warn "add $LOGICSRC_BIN to your PATH: export PATH=\"$LOGICSRC_BIN:\$PATH\"";;
|
||||
esac
|
||||
printf '\n%s🔐 logicsrc installed.%s Next:\n logicsrc login\n logicsrc teams push <team> prod --env .env\n\n' "$G" "$X"
|
||||
}
|
||||
|
||||
do_uninstall() {
|
||||
rm -f "$WRAPPER"; rm -rf "$LOGICSRC_HOME"
|
||||
ok "removed logicsrc ($WRAPPER, $LOGICSRC_HOME)"
|
||||
}
|
||||
|
||||
case "${1:-install}" in
|
||||
install|update|upgrade) do_install ;;
|
||||
remove|uninstall) do_uninstall ;;
|
||||
*) fail "unknown command '$1' (install | update | uninstall)";;
|
||||
esac
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { SiteShell } from "@/components/site-shell";
|
||||
import { TeamsClient } from "@/components/teams-client";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Accept invite · LogicSRC Teams",
|
||||
description: "Accept a LogicSRC credential-sharing team invite.",
|
||||
robots: { index: false },
|
||||
};
|
||||
|
||||
// Invite emails link here as /teams/accept?token=… — log in, then the token is
|
||||
// auto-accepted by the client.
|
||||
export default async function AcceptInvitePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ token?: string }>;
|
||||
}): Promise<ReactNode> {
|
||||
const { token } = await searchParams;
|
||||
return (
|
||||
<SiteShell active="Credentials">
|
||||
<TeamsClient initialToken={token} />
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { SiteShell } from "@/components/site-shell";
|
||||
import { TeamsClient } from "@/components/teams-client";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Teams · Credential Sharing · LogicSRC",
|
||||
description:
|
||||
"Manage credential-sharing teams and invites. Share secrets with teammates by email — end-to-end encrypted, so the server never sees plaintext. Decryption happens only in the logicsrc CLI.",
|
||||
alternates: { canonical: "/teams" },
|
||||
};
|
||||
|
||||
export default function TeamsPage(): ReactNode {
|
||||
return (
|
||||
<SiteShell active="Credentials">
|
||||
<TeamsClient />
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -1,283 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Team credential-sharing management UI. The browser holds NO private key, so it
|
||||
* never decrypts secrets — it manages membership, invites, and shows vault
|
||||
* metadata (names, secret counts, who has access). Actual secret values are only
|
||||
* ever decrypted in the `logicsrc` CLI on a device that holds the identity key.
|
||||
*/
|
||||
|
||||
const API_BASE = (process.env.NEXT_PUBLIC_COMMANDBOARD_API_URL ?? "https://commandboard.run").replace(/\/$/, "");
|
||||
const TOKEN_KEY = "logicsrc.credshare.token";
|
||||
const EMAIL_KEY = "logicsrc.credshare.email";
|
||||
|
||||
interface Team {
|
||||
slug: string;
|
||||
name: string;
|
||||
}
|
||||
interface Member {
|
||||
email: string;
|
||||
role: string;
|
||||
status: string;
|
||||
hasPublicKey: boolean;
|
||||
}
|
||||
interface Vault {
|
||||
id: string;
|
||||
name: string;
|
||||
hasAccess: boolean;
|
||||
secretCount: number;
|
||||
}
|
||||
|
||||
async function api<T>(path: string, init: RequestInit & { token?: string } = {}): Promise<T> {
|
||||
const headers: Record<string, string> = { accept: "application/json", ...(init.headers as Record<string, string>) };
|
||||
if (init.body) headers["content-type"] = "application/json";
|
||||
if (init.token) headers["authorization"] = `Bearer ${init.token}`;
|
||||
const res = await fetch(`${API_BASE}/api/credshare${path}`, { ...init, headers });
|
||||
const text = await res.text();
|
||||
const parsed = text ? JSON.parse(text) : undefined;
|
||||
if (!res.ok) throw new Error((parsed && parsed.error) || `${res.status} ${res.statusText}`);
|
||||
return parsed as T;
|
||||
}
|
||||
|
||||
export function TeamsClient({ initialToken }: { initialToken?: string }): React.ReactElement {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [email, setEmail] = useState("");
|
||||
const [codeSent, setCodeSent] = useState(false);
|
||||
const [code, setCode] = useState("");
|
||||
const [me, setMe] = useState<{ email: string; publicKey: string | null } | null>(null);
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
const [active, setActive] = useState<string | null>(null);
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [vaults, setVaults] = useState<Vault[]>([]);
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = window.localStorage.getItem(TOKEN_KEY);
|
||||
if (saved) setToken(saved);
|
||||
setEmail(window.localStorage.getItem(EMAIL_KEY) ?? "");
|
||||
}, []);
|
||||
|
||||
const refreshTeams = useCallback(async (tok: string) => {
|
||||
const data = await api<{ user: { email: string; publicKey: string | null }; teams: Team[] }>("/me", { token: tok });
|
||||
setMe(data.user);
|
||||
setTeams(data.teams);
|
||||
setActive((cur) => cur ?? data.teams[0]?.slug ?? null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
refreshTeams(token).catch((e) => setError(String(e.message ?? e)));
|
||||
}, [token, refreshTeams]);
|
||||
|
||||
const loadTeam = useCallback(
|
||||
async (slug: string, tok: string) => {
|
||||
const [m, v] = await Promise.all([
|
||||
api<{ members: Member[] }>(`/teams/${encodeURIComponent(slug)}/members`, { token: tok }),
|
||||
api<{ vaults: Vault[] }>(`/teams/${encodeURIComponent(slug)}/vaults`, { token: tok })
|
||||
]);
|
||||
setMembers(m.members);
|
||||
setVaults(v.vaults);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (token && active) loadTeam(active, token).catch((e) => setError(String(e.message ?? e)));
|
||||
}, [token, active, loadTeam]);
|
||||
|
||||
// Auto-accept an invite passed via ?token= once the user is logged in.
|
||||
useEffect(() => {
|
||||
if (!initialToken || !token) return;
|
||||
(async () => {
|
||||
try {
|
||||
setBusy(true);
|
||||
const res = await api<{ team?: Team }>("/invites/accept", { method: "POST", token, body: JSON.stringify({ token: initialToken }) });
|
||||
setStatus(`Joined ${res.team?.slug ?? "the team"}. Grant + pull secrets from the CLI.`);
|
||||
await refreshTeams(token);
|
||||
if (res.team) setActive(res.team.slug);
|
||||
} catch (e) {
|
||||
setError(String((e as Error).message ?? e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
})();
|
||||
}, [initialToken, token, refreshTeams]);
|
||||
|
||||
async function requestCode() {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api<{ emailSent: boolean; devCode?: string }>("/auth/request", { method: "POST", body: JSON.stringify({ email }) });
|
||||
setCodeSent(true);
|
||||
window.localStorage.setItem(EMAIL_KEY, email);
|
||||
setStatus(res.emailSent ? `Code sent to ${email}.` : `Dev mode — your code is ${res.devCode}.`);
|
||||
if (res.devCode) setCode(res.devCode);
|
||||
} catch (e) {
|
||||
setError(String((e as Error).message ?? e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyCode() {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api<{ token: string }>("/auth/verify", { method: "POST", body: JSON.stringify({ email, code }) });
|
||||
window.localStorage.setItem(TOKEN_KEY, res.token);
|
||||
setToken(res.token);
|
||||
setCodeSent(false);
|
||||
setCode("");
|
||||
setStatus("Logged in.");
|
||||
} catch (e) {
|
||||
setError(String((e as Error).message ?? e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function createTeam() {
|
||||
const slug = window.prompt("New team slug (lowercase, dashes):");
|
||||
if (!slug || !token) return;
|
||||
try {
|
||||
await api("/teams", { method: "POST", token, body: JSON.stringify({ slug }) });
|
||||
await refreshTeams(token);
|
||||
setActive(slug);
|
||||
} catch (e) {
|
||||
setError(String((e as Error).message ?? e));
|
||||
}
|
||||
}
|
||||
|
||||
async function invite() {
|
||||
if (!inviteEmail || !active || !token) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api<{ emailSent: boolean; token?: string }>(`/teams/${encodeURIComponent(active)}/invites`, { method: "POST", token, body: JSON.stringify({ email: inviteEmail }) });
|
||||
setStatus(res.emailSent ? `Invited ${inviteEmail}.` : `Invited ${inviteEmail}. Share this accept link: ${window.location.origin}/teams/accept?token=${res.token}`);
|
||||
setInviteEmail("");
|
||||
await loadTeam(active, token);
|
||||
} catch (e) {
|
||||
setError(String((e as Error).message ?? e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
window.localStorage.removeItem(TOKEN_KEY);
|
||||
setToken(null);
|
||||
setMe(null);
|
||||
setTeams([]);
|
||||
setActive(null);
|
||||
setStatus("Logged out.");
|
||||
}
|
||||
|
||||
const notice = (
|
||||
<>
|
||||
{error && <p style={{ color: "#e5484d", margin: "0.5rem 0" }}>⚠ {error}</p>}
|
||||
{status && !error && <p style={{ color: "#30a46c", margin: "0.5rem 0" }}>{status}</p>}
|
||||
</>
|
||||
);
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="band" style={{ maxWidth: "32rem" }}>
|
||||
<div className="section-head">
|
||||
<h2>Team credential sharing</h2>
|
||||
<p>Log in by email to manage teams and invites. Secrets stay end-to-end encrypted — decrypt them with the <code>logicsrc</code> CLI, never here.</p>
|
||||
</div>
|
||||
{notice}
|
||||
{!codeSent ? (
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem", flexWrap: "wrap" }}>
|
||||
<input type="email" placeholder="you@example.com" value={email} onChange={(e) => setEmail(e.target.value)} style={inputStyle} />
|
||||
<button onClick={requestCode} disabled={busy || !email} style={buttonStyle}>Send code</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem", flexWrap: "wrap" }}>
|
||||
<input inputMode="numeric" placeholder="6-digit code" value={code} onChange={(e) => setCode(e.target.value)} style={inputStyle} />
|
||||
<button onClick={verifyCode} disabled={busy || !code} style={buttonStyle}>Verify</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="band" style={{ maxWidth: "48rem" }}>
|
||||
<div className="section-head" style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: "1rem" }}>
|
||||
<h2>Your teams</h2>
|
||||
<span style={{ fontSize: "0.85rem", opacity: 0.7 }}>
|
||||
{me?.email} {me && !me.publicKey && "· ⚠ no CLI key yet (run logicsrc login)"} · <a onClick={logout} style={{ cursor: "pointer" }}>log out</a>
|
||||
</span>
|
||||
</div>
|
||||
{notice}
|
||||
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", margin: "0.75rem 0" }}>
|
||||
{teams.map((t) => (
|
||||
<button key={t.slug} onClick={() => setActive(t.slug)} style={{ ...chipStyle, ...(active === t.slug ? chipActive : {}) }}>
|
||||
{t.name || t.slug}
|
||||
</button>
|
||||
))}
|
||||
<button onClick={createTeam} style={chipStyle}>+ new team</button>
|
||||
</div>
|
||||
|
||||
{active && (
|
||||
<>
|
||||
<h3 style={{ marginTop: "1.5rem" }}>Members</h3>
|
||||
<table style={tableStyle}>
|
||||
<thead>
|
||||
<tr><th style={thStyle}>Email</th><th style={thStyle}>Role</th><th style={thStyle}>Status</th><th style={thStyle}>CLI key</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{members.map((m) => (
|
||||
<tr key={m.email}>
|
||||
<td style={tdStyle}>{m.email}</td><td style={tdStyle}>{m.role}</td>
|
||||
<td style={tdStyle}>{m.status}</td><td style={tdStyle}>{m.hasPublicKey ? "✓" : "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.75rem", flexWrap: "wrap" }}>
|
||||
<input type="email" placeholder="teammate@example.com" value={inviteEmail} onChange={(e) => setInviteEmail(e.target.value)} style={inputStyle} />
|
||||
<button onClick={invite} disabled={busy || !inviteEmail} style={buttonStyle}>Invite</button>
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginTop: "1.5rem" }}>Vaults</h3>
|
||||
{vaults.length === 0 ? (
|
||||
<p style={{ opacity: 0.7 }}>No vaults yet. Create one from the CLI: <code>logicsrc teams push {active} prod</code></p>
|
||||
) : (
|
||||
<table style={tableStyle}>
|
||||
<thead><tr><th style={thStyle}>Vault</th><th style={thStyle}>Secrets</th><th style={thStyle}>Your access</th></tr></thead>
|
||||
<tbody>
|
||||
{vaults.map((v) => (
|
||||
<tr key={v.id}>
|
||||
<td style={tdStyle}><code>{v.name}</code></td><td style={tdStyle}>{v.secretCount}</td>
|
||||
<td style={tdStyle}>{v.hasAccess ? "✓ granted" : "— ask a member to grant you"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<p style={{ opacity: 0.7, marginTop: "1rem", fontSize: "0.9rem" }}>
|
||||
Pull secrets on your machine: <code>logicsrc teams pull {active} <vault></code> — values are decrypted locally with your key. The server (and this page) only ever see ciphertext.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = { padding: "0.6rem 0.8rem", borderRadius: "0.5rem", border: "1px solid var(--border, #333)", background: "transparent", color: "inherit", minWidth: "16rem", flex: 1 };
|
||||
const buttonStyle: React.CSSProperties = { padding: "0.6rem 1rem", borderRadius: "0.5rem", border: "1px solid var(--border, #333)", background: "var(--accent, #5b7cfa)", color: "#fff", cursor: "pointer" };
|
||||
const chipStyle: React.CSSProperties = { padding: "0.4rem 0.8rem", borderRadius: "999px", border: "1px solid var(--border, #333)", background: "transparent", color: "inherit", cursor: "pointer" };
|
||||
const chipActive: React.CSSProperties = { background: "var(--accent, #5b7cfa)", color: "#fff", borderColor: "transparent" };
|
||||
const tableStyle: React.CSSProperties = { width: "100%", borderCollapse: "collapse", marginTop: "0.5rem", fontSize: "0.92rem" };
|
||||
const thStyle: React.CSSProperties = { textAlign: "left", padding: "0.4rem 0.6rem", borderBottom: "1px solid var(--border, #333)", opacity: 0.7, fontWeight: 600 };
|
||||
const tdStyle: React.CSSProperties = { padding: "0.4rem 0.6rem", borderBottom: "1px solid var(--border, #222)" };
|
||||
22
apps/pwa/.env.example
Normal file
22
apps/pwa/.env.example
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# LogicSRC credentials app — copy to .env and fill in.
|
||||
NODE_ENV=development
|
||||
PORT=8080
|
||||
PUBLIC_ORIGIN=http://localhost:8080
|
||||
|
||||
# datastore: Turso (libSQL) in prod, or file:./data/local.db for local dev
|
||||
TURSO_DATABASE_URL=file:./data/local.db
|
||||
TURSO_AUTH_TOKEN=
|
||||
|
||||
# auth — generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
SESSION_SECRET=change-me-32-bytes-hex
|
||||
|
||||
# email invites via Resend (optional — without it, invite tokens are returned in the API response)
|
||||
RESEND_API_KEY=
|
||||
CREDSHARE_EMAIL_FROM=LogicSRC <noreply@logicsrc.com>
|
||||
|
||||
# "Sign in with CoinPay" (optional)
|
||||
COINPAY_API_BASE=https://coinpayportal.com
|
||||
COINPAY_OAUTH_AUTHORIZE_URL=https://coinpayportal.com/oauth/authorize
|
||||
COINPAY_OAUTH_TOKEN_URL=https://coinpayportal.com/api/oauth/token
|
||||
COINPAY_OAUTH_USERINFO_URL=https://coinpayportal.com/api/oauth/userinfo
|
||||
COINPAY_OAUTH_CLIENT_ID=
|
||||
8
apps/pwa/.gitignore
vendored
Normal file
8
apps/pwa/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
node_modules/
|
||||
data/
|
||||
*.db
|
||||
*.db-*
|
||||
.env
|
||||
.env.local
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
1
apps/pwa/Procfile
Normal file
1
apps/pwa/Procfile
Normal file
|
|
@ -0,0 +1 @@
|
|||
web: npm start
|
||||
16
apps/pwa/README.md
Normal file
16
apps/pwa/README.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# @logicsrc/pwa — LogicSRC credentials
|
||||
|
||||
Express + libSQL/Turso app for **team credential sharing**: auth (email/password,
|
||||
passkeys, CoinPay OAuth, sessions, `lsk_` CLI API keys) + end-to-end-encrypted
|
||||
team vaults. Zero-knowledge — the server only stores ciphertext, per-member
|
||||
sealed vault keys, and identity public keys. Decryption happens in the
|
||||
`logicsrc` CLI.
|
||||
|
||||
```bash
|
||||
cp .env.example .env # set SESSION_SECRET; TURSO_* for prod (else local file db)
|
||||
npm install
|
||||
npm start # migrates on boot, serves on :8080
|
||||
```
|
||||
|
||||
The CLI connects with `LOGICSRC_API=<origin> logicsrc login` (browser OAuth-PKCE
|
||||
loopback → an `lsk_` key). See `docs/credential-sharing.md` in the repo root.
|
||||
23
apps/pwa/package.json
Normal file
23
apps/pwa/package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "@logicsrc/pwa",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "LogicSRC credentials — Express + libSQL/Turso app: auth + end-to-end-encrypted team credential sharing.",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node src/server.mjs",
|
||||
"dev": "node --watch src/server.mjs",
|
||||
"migrate": "node src/migrate.mjs",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@libsql/client": "^0.14.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.1.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"express": "^4.21.2"
|
||||
}
|
||||
}
|
||||
6
apps/pwa/public/icon.svg
Normal file
6
apps/pwa/public/icon.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="112" fill="#070806"/>
|
||||
<rect x="96" y="96" width="320" height="320" rx="64" fill="#a6ff1a"/>
|
||||
<text x="256" y="256" font-family="Helvetica,Arial,sans-serif" font-size="240" font-weight="800"
|
||||
fill="#0a1400" text-anchor="middle" dominant-baseline="central">M</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 381 B |
15
apps/pwa/public/manifest.webmanifest
Normal file
15
apps/pwa/public/manifest.webmanifest
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "LogicSRC Credentials",
|
||||
"short_name": "LogicSRC",
|
||||
"description": "Human-in-the-loop approvals for your moshscript loops.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#070806",
|
||||
"theme_color": "#070806",
|
||||
"icons": [
|
||||
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" },
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||
]
|
||||
}
|
||||
53
apps/pwa/public/passkey.js
Normal file
53
apps/pwa/public/passkey.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/* Passkey button: try to sign in with a discoverable passkey; if there's none,
|
||||
register a new one. Uses the @simplewebauthn/browser UMD bundle (/vendor). */
|
||||
(function () {
|
||||
var btn = document.getElementById("passkey-btn");
|
||||
var msg = document.getElementById("passkey-msg");
|
||||
if (!btn) return;
|
||||
|
||||
function csrf() {
|
||||
var m = document.cookie.match(/(?:^|; )mc_csrf=([^;]+)/);
|
||||
return m ? decodeURIComponent(m[1]) : "";
|
||||
}
|
||||
function post(url, body) {
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", "x-csrf-token": csrf() },
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
}
|
||||
function say(t) { if (msg) msg.textContent = t; }
|
||||
|
||||
async function register() {
|
||||
say("creating a passkey…");
|
||||
var opts = await (await post("/auth/passkey/register/options")).json();
|
||||
var att = await SimpleWebAuthnBrowser.startRegistration({ optionsJSON: opts });
|
||||
var r = await post("/auth/passkey/register/verify", att);
|
||||
var out = await r.json();
|
||||
if (out.ok) location.href = out.redirect || "/";
|
||||
else say(out.error || "couldn't create passkey");
|
||||
}
|
||||
|
||||
async function login() {
|
||||
var opts = await (await post("/auth/passkey/login/options")).json();
|
||||
var asr = await SimpleWebAuthnBrowser.startAuthentication({ optionsJSON: opts });
|
||||
var r = await post("/auth/passkey/login/verify", asr);
|
||||
var out = await r.json();
|
||||
if (out.ok) { location.href = out.redirect || "/"; return true; }
|
||||
throw new Error(out.error || "sign-in failed");
|
||||
}
|
||||
|
||||
btn.addEventListener("click", async function () {
|
||||
if (!window.PublicKeyCredential) { say("this device doesn't support passkeys"); return; }
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await login();
|
||||
} catch (e) {
|
||||
// no discoverable credential / user cancelled login → offer to register
|
||||
try { await register(); }
|
||||
catch (e2) { say(String(e2.message || e2)); }
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
})();
|
||||
55
apps/pwa/public/sw.js
Normal file
55
apps/pwa/public/sw.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/* LogicSRC PWA service worker — offline app shell (network-first for docs). */
|
||||
const CACHE = "logicsrc-v1";
|
||||
const SHELL = ["/", "/icon.svg", "/manifest.webmanifest", "/passkey.js"];
|
||||
|
||||
self.addEventListener("install", (e) => {
|
||||
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (e) => {
|
||||
e.waitUntil(
|
||||
caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
// approval push notifications
|
||||
self.addEventListener("push", (e) => {
|
||||
let d = {};
|
||||
try { d = e.data ? e.data.json() : {}; } catch (_) {}
|
||||
e.waitUntil(self.registration.showNotification(d.title || "LogicSRC", {
|
||||
body: d.body || "You have an approval waiting.",
|
||||
icon: "/icon.svg",
|
||||
badge: "/icon.svg",
|
||||
data: { url: d.url || "/" },
|
||||
tag: "logicsrc",
|
||||
}));
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (e) => {
|
||||
e.notification.close();
|
||||
const url = (e.notification.data && e.notification.data.url) || "/";
|
||||
e.waitUntil(clients.matchAll({ type: "window" }).then((cs) => {
|
||||
for (const c of cs) if ("focus" in c) { c.navigate(url); return c.focus(); }
|
||||
return clients.openWindow(url);
|
||||
}));
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (e) => {
|
||||
const { request } = e;
|
||||
if (request.method !== "GET") return; // never cache POSTs / API writes
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/auth/") || url.pathname.startsWith("/webhooks/")) return;
|
||||
|
||||
// network-first, fall back to cache (so approvals stay fresh, offline still loads a shell)
|
||||
e.respondWith(
|
||||
fetch(request)
|
||||
.then((res) => {
|
||||
if (res.ok && url.origin === location.origin) {
|
||||
const copy = res.clone();
|
||||
caches.open(CACHE).then((c) => c.put(request, copy));
|
||||
}
|
||||
return res;
|
||||
})
|
||||
.catch(() => caches.match(request).then((r) => r || caches.match("/")))
|
||||
);
|
||||
});
|
||||
10
apps/pwa/railway.json
Normal file
10
apps/pwa/railway.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"$schema": "https://railway.app/railway.schema.json",
|
||||
"build": { "builder": "NIXPACKS" },
|
||||
"deploy": {
|
||||
"startCommand": "npm start",
|
||||
"healthcheckPath": "/healthz",
|
||||
"restartPolicyType": "ON_FAILURE",
|
||||
"restartPolicyMaxRetries": 10
|
||||
}
|
||||
}
|
||||
64
apps/pwa/src/config.mjs
Normal file
64
apps/pwa/src/config.mjs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// Central config. Reads .env (if present) with zero deps, then process.env wins.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
// Tiny .env loader — does not override anything already in the environment.
|
||||
function loadEnv() {
|
||||
const file = path.join(ROOT, ".env");
|
||||
if (!fs.existsSync(file)) return;
|
||||
for (const line of fs.readFileSync(file, "utf8").split("\n")) {
|
||||
const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i.exec(line);
|
||||
if (!m) continue;
|
||||
const key = m[1];
|
||||
if (process.env[key] !== undefined) continue;
|
||||
let val = m[2];
|
||||
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
process.env[key] = val;
|
||||
}
|
||||
}
|
||||
loadEnv();
|
||||
|
||||
const origin = (process.env.PUBLIC_ORIGIN || `http://localhost:${process.env.PORT || 8080}`).replace(/\/+$/, "");
|
||||
const rpID = new URL(origin).hostname;
|
||||
|
||||
export const config = {
|
||||
root: ROOT,
|
||||
env: process.env.NODE_ENV || "development",
|
||||
port: Number(process.env.PORT || 8080),
|
||||
origin,
|
||||
// WebAuthn relying party = this host.
|
||||
rpID,
|
||||
rpName: "LogicSRC",
|
||||
sessionSecret: process.env.SESSION_SECRET || "dev-insecure-secret-change-me",
|
||||
db: {
|
||||
// Turso (libSQL) in prod; a local file for dev. TURSO_* takes precedence.
|
||||
url: process.env.TURSO_DATABASE_URL || process.env.DATABASE_URL || "file:./data/local.db",
|
||||
authToken: process.env.TURSO_AUTH_TOKEN || process.env.DATABASE_AUTH_TOKEN || undefined,
|
||||
},
|
||||
resend: {
|
||||
apiKey: process.env.RESEND_API_KEY || "",
|
||||
from: process.env.CREDSHARE_EMAIL_FROM || process.env.RESEND_FROM || "LogicSRC <noreply@logicsrc.com>",
|
||||
},
|
||||
coinpay: {
|
||||
apiBase: (process.env.COINPAY_API_BASE || "https://coinpayportal.com").replace(/\/+$/, ""),
|
||||
businessId: process.env.COINPAY_BUSINESS_ID || "",
|
||||
webhookSecret: process.env.COINPAY_WEBHOOK_SECRET || "",
|
||||
oauth: {
|
||||
authorizeUrl: process.env.COINPAY_OAUTH_AUTHORIZE_URL || "",
|
||||
tokenUrl: process.env.COINPAY_OAUTH_TOKEN_URL || "",
|
||||
userinfoUrl: process.env.COINPAY_OAUTH_USERINFO_URL || "",
|
||||
clientId: process.env.COINPAY_OAUTH_CLIENT_ID || "",
|
||||
redirectUri: `${origin}/auth/coinpay/callback`,
|
||||
scope: process.env.COINPAY_OAUTH_SCOPE || "openid profile",
|
||||
},
|
||||
},
|
||||
get coinpayLoginEnabled() {
|
||||
return Boolean(this.coinpay.oauth.authorizeUrl && this.coinpay.oauth.clientId);
|
||||
},
|
||||
secure: (process.env.NODE_ENV || "development") === "production",
|
||||
};
|
||||
29
apps/pwa/src/db.mjs
Normal file
29
apps/pwa/src/db.mjs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// libSQL (SQLite / Turso) client + a tiny query helper.
|
||||
import { createClient } from "@libsql/client";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { config } from "./config.mjs";
|
||||
|
||||
// For a local file: url, make sure the directory exists.
|
||||
if (config.db.url.startsWith("file:")) {
|
||||
const p = config.db.url.slice("file:".length);
|
||||
const dir = path.dirname(path.resolve(config.root, p));
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
export const db = createClient({ url: config.db.url, authToken: config.db.authToken });
|
||||
|
||||
/** Run a statement; returns the raw result. */
|
||||
export const run = (sql, args = []) => db.execute({ sql, args });
|
||||
|
||||
/** First row (or null). */
|
||||
export async function get(sql, args = []) {
|
||||
const r = await db.execute({ sql, args });
|
||||
return r.rows[0] ?? null;
|
||||
}
|
||||
|
||||
/** All rows. */
|
||||
export async function all(sql, args = []) {
|
||||
const r = await db.execute({ sql, args });
|
||||
return r.rows;
|
||||
}
|
||||
36
apps/pwa/src/lib/apikey.mjs
Normal file
36
apps/pwa/src/lib/apikey.mjs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// API keys (lsk_…) for the logicsrc CLI to authenticate.
|
||||
import { get, all, run } from "../db.mjs";
|
||||
import { id, token, sha256 } from "./crypto.mjs";
|
||||
|
||||
// Returns { plaintext, row } — plaintext shown ONCE.
|
||||
export async function createApiKey(userId, name = "cli") {
|
||||
const plaintext = "lsk_" + token(24);
|
||||
const prefix = plaintext.slice(0, 12);
|
||||
const row = { id: id(), user_id: userId, name, token_hash: sha256(plaintext), prefix, created_at: Date.now() };
|
||||
await run(
|
||||
`INSERT INTO api_keys (id, user_id, name, token_hash, prefix, created_at) VALUES (?,?,?,?,?,?)`,
|
||||
[row.id, row.user_id, row.name, row.token_hash, row.prefix, row.created_at]
|
||||
);
|
||||
return { plaintext, row };
|
||||
}
|
||||
|
||||
// Resolve a Bearer token to its owning user (or null). Updates last_used_at.
|
||||
export async function userForApiKey(bearer) {
|
||||
if (!bearer) return null;
|
||||
const key = await get(`SELECT * FROM api_keys WHERE token_hash = ?`, [sha256(bearer)]);
|
||||
if (!key) return null;
|
||||
await run(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`, [Date.now(), key.id]);
|
||||
return get(`SELECT * FROM users WHERE id = ?`, [key.user_id]);
|
||||
}
|
||||
|
||||
export const listApiKeys = (userId) =>
|
||||
all(`SELECT id, name, prefix, created_at, last_used_at FROM api_keys WHERE user_id = ? ORDER BY created_at DESC`, [userId]);
|
||||
|
||||
export const revokeApiKey = (userId, keyId) =>
|
||||
run(`DELETE FROM api_keys WHERE id = ? AND user_id = ?`, [keyId, userId]);
|
||||
|
||||
// Pull the Bearer token off a request.
|
||||
export function bearer(req) {
|
||||
const h = req.get("authorization") || "";
|
||||
return h.startsWith("Bearer ") ? h.slice(7).trim() : null;
|
||||
}
|
||||
41
apps/pwa/src/lib/crypto.mjs
Normal file
41
apps/pwa/src/lib/crypto.mjs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// Password hashing (scrypt) + signed cookies + tokens — all node:crypto, no deps.
|
||||
import crypto from "node:crypto";
|
||||
import { config } from "../config.mjs";
|
||||
|
||||
// ---- passwords (scrypt) ----
|
||||
export function hashPassword(password) {
|
||||
const salt = crypto.randomBytes(16);
|
||||
const dk = crypto.scryptSync(String(password), salt, 32);
|
||||
return `scrypt$${salt.toString("hex")}$${dk.toString("hex")}`;
|
||||
}
|
||||
|
||||
export function verifyPassword(password, stored) {
|
||||
if (!stored || !stored.startsWith("scrypt$")) return false;
|
||||
const [, saltHex, hashHex] = stored.split("$");
|
||||
const salt = Buffer.from(saltHex, "hex");
|
||||
const expected = Buffer.from(hashHex, "hex");
|
||||
const dk = crypto.scryptSync(String(password), salt, expected.length);
|
||||
return dk.length === expected.length && crypto.timingSafeEqual(dk, expected);
|
||||
}
|
||||
|
||||
// ---- ids / tokens ----
|
||||
export const id = () => crypto.randomUUID();
|
||||
export const token = (bytes = 32) => crypto.randomBytes(bytes).toString("base64url");
|
||||
export const sha256 = (s) => crypto.createHash("sha256").update(s).digest("hex");
|
||||
|
||||
// ---- signed cookies (stateless ceremony state) ----
|
||||
export function sign(value) {
|
||||
const payload = Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
const mac = crypto.createHmac("sha256", config.sessionSecret).update(payload).digest("base64url");
|
||||
return `${payload}.${mac}`;
|
||||
}
|
||||
|
||||
export function unsign(signed) {
|
||||
if (!signed || typeof signed !== "string" || !signed.includes(".")) return null;
|
||||
const [payload, mac] = signed.split(".");
|
||||
const expected = crypto.createHmac("sha256", config.sessionSecret).update(payload).digest("base64url");
|
||||
const a = Buffer.from(mac);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
|
||||
try { return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); } catch { return null; }
|
||||
}
|
||||
121
apps/pwa/src/lib/html.mjs
Normal file
121
apps/pwa/src/lib/html.mjs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// Server-rendered views in the LogicSRC brand (matches logicsrc.com):
|
||||
// light ground (#f6f7f4), ink text (#101418), green accent (#0a7d59), Inter.
|
||||
// Keeps the same class vocabulary the auth routes use so they reskin for free.
|
||||
|
||||
export function esc(s) {
|
||||
return String(s ?? "").replace(/[&<>"']/g, (c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])
|
||||
);
|
||||
}
|
||||
|
||||
export const BRAND_CSS = `
|
||||
:root{
|
||||
color-scheme:light;
|
||||
--bg:#f6f7f4;--surface:#ffffff;--surface-2:#f0f2ec;
|
||||
--ink:#101418;--text:#101418;--dim:#58615b;--faint:#8b938a;
|
||||
--line:#d9ded4;--line-2:#c7cec1;
|
||||
--green:#0a7d59;--green-2:#0b8f66;--green-ink:#ffffff;--mint:#5ac8a6;
|
||||
--danger:#c23a3a;--warn:#b7791f;
|
||||
--rail:#101418;--rail-text:#f6f7f4;--rail-dim:#b5beb2;--rail-line:#263039;
|
||||
--mono:ui-monospace,"JetBrains Mono","SF Mono",SFMono-Regular,Menlo,Consolas,monospace;
|
||||
--sans:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
|
||||
--maxw:72rem;--r:10px;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--text);font-family:var(--sans);line-height:1.55;-webkit-font-smoothing:antialiased}
|
||||
.wrap{max-width:var(--maxw);margin:0 auto;padding:0 24px}
|
||||
a{color:var(--green);text-decoration:none}
|
||||
a:hover{text-decoration:underline}
|
||||
::selection{background:var(--mint);color:#06110c}
|
||||
h1,h2,h3{margin:0;font-weight:800;letter-spacing:-.01em;text-wrap:balance}
|
||||
h1{font-size:2.2rem;line-height:1.05}
|
||||
.label{font-family:var(--mono);font-size:.64rem;letter-spacing:.18em;text-transform:uppercase;color:var(--faint)}
|
||||
.mono{font-family:var(--mono)}
|
||||
.dim{color:var(--dim)}.faint{color:var(--faint)}.acid,.green{color:var(--green)}
|
||||
.pill{font-family:var(--mono);font-size:.62rem;letter-spacing:.1em;text-transform:uppercase;padding:3px 9px;border-radius:999px;border:1px solid var(--line-2);color:var(--dim);white-space:nowrap}
|
||||
.pill.on{color:var(--green);border-color:color-mix(in srgb,var(--green) 45%,var(--line));background:color-mix(in srgb,var(--green) 8%,transparent)}
|
||||
.pill.warn{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 40%,var(--line))}
|
||||
.btn{font-family:var(--sans);font-size:.85rem;font-weight:600;padding:10px 16px;border-radius:8px;cursor:pointer;border:1px solid var(--line-2);background:var(--surface);color:var(--text);display:inline-flex;align-items:center;justify-content:center;gap:8px;transition:border-color .14s,background .14s,transform .05s;white-space:nowrap;text-align:center}
|
||||
.btn:hover{border-color:var(--faint);background:var(--surface-2);text-decoration:none}
|
||||
.btn:active{transform:translateY(1px)}
|
||||
.btn.acid,.btn.primary{background:var(--green);color:var(--green-ink);border-color:var(--green);font-weight:700}
|
||||
.btn.acid:hover,.btn.primary:hover{background:var(--green-2);border-color:var(--green-2)}
|
||||
.btn.danger{color:var(--danger);border-color:color-mix(in srgb,var(--danger) 45%,var(--line))}
|
||||
.btn.danger:hover{background:color-mix(in srgb,var(--danger) 10%,transparent);border-color:var(--danger)}
|
||||
.btn.block{width:100%}
|
||||
.btn:focus-visible{outline:2px solid var(--green);outline-offset:2px}
|
||||
input,textarea,select{font-family:var(--mono);font-size:.85rem;color:var(--text);background:var(--surface);border:1px solid var(--line-2);border-radius:9px;padding:11px 13px;width:100%}
|
||||
input:focus,textarea:focus{outline:none;border-color:var(--green)}
|
||||
input::placeholder,textarea::placeholder{color:var(--faint)}
|
||||
label.field{display:block;margin-bottom:14px}
|
||||
label.field span{display:block;font-family:var(--mono);font-size:.66rem;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);margin-bottom:6px}
|
||||
.card{border:1px solid var(--line);border-radius:var(--r);background:var(--surface)}
|
||||
.card-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:13px 16px;border-bottom:1px solid var(--line)}
|
||||
.card-head .h{font-family:var(--mono);font-size:.72rem;letter-spacing:.14em;text-transform:uppercase;color:var(--faint)}
|
||||
.card-body{padding:18px}
|
||||
.bar{position:sticky;top:0;z-index:30;background:var(--rail);color:var(--rail-text);border-bottom:1px solid var(--rail-line)}
|
||||
.bar a{color:var(--rail-text)}
|
||||
.bar-inner{display:flex;align-items:center;gap:16px;height:60px}
|
||||
.brand{display:flex;align-items:center;gap:10px;font-weight:800;letter-spacing:-.01em;font-size:1.12rem}
|
||||
.brand .mark{width:26px;height:26px;border-radius:6px;border:1px solid var(--mint);color:var(--mint);display:grid;place-items:center;font-family:var(--mono);font-weight:800;font-size:.8rem;background:transparent}
|
||||
.brand .app{font-family:var(--mono);font-weight:600;font-size:.62rem;letter-spacing:.2em;color:var(--faint);text-transform:uppercase;border:1px solid var(--line-2);padding:2px 6px;border-radius:5px}
|
||||
.bar .brand .app{color:var(--rail-dim);border-color:var(--rail-line)}
|
||||
.bar-right{margin-left:auto;display:flex;align-items:center;gap:12px}
|
||||
.grid{display:grid;grid-template-columns:1.55fr .95fr;gap:22px;align-items:start}
|
||||
.col{display:flex;flex-direction:column;gap:22px}
|
||||
.section-title{display:flex;align-items:baseline;gap:12px;margin-bottom:14px}
|
||||
.section-title h2{font-size:1.24rem}
|
||||
.section-title .count{font-family:var(--mono);font-size:.74rem;color:var(--green)}
|
||||
.notice{border:1px solid var(--line-2);border-radius:9px;padding:11px 14px;font-family:var(--mono);font-size:.78rem;margin-bottom:16px}
|
||||
.notice.err{color:var(--danger);border-color:color-mix(in srgb,var(--danger) 45%,var(--line));background:color-mix(in srgb,var(--danger) 6%,transparent)}
|
||||
.notice.ok{color:var(--green);border-color:color-mix(in srgb,var(--green) 45%,var(--line));background:color-mix(in srgb,var(--green) 6%,transparent)}
|
||||
.divider{display:flex;align-items:center;gap:12px;color:var(--faint);font-family:var(--mono);font-size:.66rem;letter-spacing:.16em;text-transform:uppercase;margin:18px 0}
|
||||
.divider::before,.divider::after{content:"";height:1px;background:var(--line);flex:1}
|
||||
table{width:100%;border-collapse:collapse;font-size:.9rem}
|
||||
th{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);font-family:var(--mono);font-size:.66rem;letter-spacing:.1em;text-transform:uppercase;color:var(--faint);font-weight:600}
|
||||
td{padding:8px 10px;border-bottom:1px solid var(--line)}
|
||||
code{font-family:var(--mono);font-size:.85em;background:var(--surface-2);padding:1px 5px;border-radius:4px}
|
||||
footer{border-top:1px solid var(--line);padding:26px 0;margin-top:40px}
|
||||
.foot{display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;align-items:center;font-family:var(--mono);font-size:.74rem;color:var(--faint)}
|
||||
.foot .green b{color:var(--green);font-weight:600}
|
||||
@media (max-width:940px){.grid{grid-template-columns:1fr}}
|
||||
`;
|
||||
|
||||
/** Full HTML document with the brand shell. */
|
||||
export function page({ title = "LogicSRC ▸ credentials", body = "", head = "" }) {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<meta name="theme-color" content="#101418">
|
||||
<title>${esc(title)}</title>
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
||||
<style>${BRAND_CSS}</style>
|
||||
${head}
|
||||
</head>
|
||||
<body>${body}
|
||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(()=>{})}</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function appBar(user) {
|
||||
return `<header class="bar"><div class="wrap bar-inner">
|
||||
<a class="brand" href="/"><span class="mark">LS</span>LogicSRC<span class="app">credentials</span></a>
|
||||
<div class="bar-right">
|
||||
${user
|
||||
? `<span class="mono faint" style="font-size:.78rem">${esc(user.email || user.display_name || "signed in")}</span>
|
||||
<a class="btn" href="/settings">Settings</a>
|
||||
<form method="post" action="/auth/logout" style="margin:0"><button class="btn">Sign out</button></form>`
|
||||
: `<a class="btn acid" href="/">Sign in</a>`}
|
||||
</div>
|
||||
</div></header>`;
|
||||
}
|
||||
|
||||
export const footer = `<footer><div class="wrap foot">
|
||||
<div class="brand" style="font-size:.9rem;color:var(--ink)"><span class="mark" style="width:20px;height:20px;font-size:.62rem">LS</span>LogicSRC</div>
|
||||
<div style="display:flex;gap:20px;flex-wrap:wrap"><a href="https://logicsrc.com">logicsrc.com</a><a href="/">Teams</a><a href="/settings">Settings</a></div>
|
||||
<div class="green">end-to-end encrypted. <b>the server never sees your secrets.</b></div>
|
||||
</div></footer>`;
|
||||
86
apps/pwa/src/lib/session.mjs
Normal file
86
apps/pwa/src/lib/session.mjs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// Cookie sessions + auth middleware + CSRF (double-submit).
|
||||
import { get, run } from "../db.mjs";
|
||||
import { id, token, sign, unsign } from "./crypto.mjs";
|
||||
import { config } from "../config.mjs";
|
||||
|
||||
const COOKIE = "mc_sess";
|
||||
const CSRF = "mc_csrf";
|
||||
const TTL = 1000 * 60 * 60 * 24 * 30; // 30 days
|
||||
|
||||
function cookieOpts(extra = {}) {
|
||||
return { httpOnly: true, sameSite: "lax", secure: config.secure, path: "/", ...extra };
|
||||
}
|
||||
|
||||
export async function createSession(res, userId) {
|
||||
const t = token();
|
||||
const now = Date.now();
|
||||
await run(`INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?,?,?,?)`,
|
||||
[t, userId, now, now + TTL]);
|
||||
res.cookie(COOKIE, t, cookieOpts({ maxAge: TTL }));
|
||||
}
|
||||
|
||||
export async function destroySession(req, res) {
|
||||
const t = req.cookies?.[COOKIE];
|
||||
if (t) await run(`DELETE FROM sessions WHERE token = ?`, [t]);
|
||||
res.clearCookie(COOKIE, cookieOpts());
|
||||
}
|
||||
|
||||
// Attach req.user (or null) from the session cookie, and ensure a CSRF token.
|
||||
export async function sessionMiddleware(req, res, next) {
|
||||
req.user = null;
|
||||
const t = req.cookies?.[COOKIE];
|
||||
if (t) {
|
||||
const row = await get(
|
||||
`SELECT u.* FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ?`,
|
||||
[t, Date.now()]
|
||||
);
|
||||
if (row) req.user = row;
|
||||
else res.clearCookie(COOKIE, cookieOpts());
|
||||
}
|
||||
// double-submit CSRF token
|
||||
let csrf = req.cookies?.[CSRF];
|
||||
if (!csrf) { csrf = token(16); res.cookie(CSRF, csrf, cookieOpts({ httpOnly: false, maxAge: TTL })); }
|
||||
req.csrfToken = csrf;
|
||||
next();
|
||||
}
|
||||
|
||||
export function requireAuth(req, res, next) {
|
||||
if (!req.user) { setNext(res, req.originalUrl); return res.redirect("/"); }
|
||||
next();
|
||||
}
|
||||
|
||||
// Remember where to go after login (safe local paths only), across any auth method.
|
||||
export function setNext(res, pathname) {
|
||||
if (typeof pathname === "string" && pathname.startsWith("/") && !pathname.startsWith("//")) {
|
||||
res.cookie("mc_next", sign(pathname), cookieOpts({ maxAge: 1000 * 60 * 10 }));
|
||||
}
|
||||
}
|
||||
export function takeNext(req, res) {
|
||||
const p = unsign(req.cookies?.mc_next);
|
||||
if (req.cookies?.mc_next) res.clearCookie("mc_next", cookieOpts());
|
||||
return typeof p === "string" && p.startsWith("/") && !p.startsWith("//") ? p : null;
|
||||
}
|
||||
|
||||
// CSRF guard for unsafe methods on browser (form) routes. API/webhooks are Bearer/HMAC.
|
||||
export function csrfGuard(req, res, next) {
|
||||
if (["GET", "HEAD", "OPTIONS"].includes(req.method)) return next();
|
||||
// machine endpoints are Bearer/HMAC/PKCE-authenticated, not cookie sessions
|
||||
if (req.path.startsWith("/api/") || req.path.startsWith("/webhooks/") ||
|
||||
req.path === "/cli/token" || req.path.startsWith("/cli/device/")) return next();
|
||||
const sent = req.body?._csrf || req.get("x-csrf-token");
|
||||
if (!sent || sent !== req.cookies?.[CSRF]) return res.status(403).send("bad csrf token");
|
||||
next();
|
||||
}
|
||||
|
||||
export const csrfInput = (req) => `<input type="hidden" name="_csrf" value="${req.csrfToken}">`;
|
||||
|
||||
// ---- ephemeral auth-ceremony state (webauthn challenge / oauth pkce) in signed cookies ----
|
||||
export function setCeremony(res, name, value, ttlMs = 1000 * 60 * 5) {
|
||||
res.cookie(`mc_c_${name}`, sign({ v: value, exp: Date.now() + ttlMs }), cookieOpts({ maxAge: ttlMs }));
|
||||
}
|
||||
export function getCeremony(req, name) {
|
||||
const data = unsign(req.cookies?.[`mc_c_${name}`]);
|
||||
if (!data || data.exp < Date.now()) return null;
|
||||
return data.v;
|
||||
}
|
||||
export function clearCeremony(res, name) { res.clearCookie(`mc_c_${name}`, cookieOpts()); }
|
||||
29
apps/pwa/src/lib/users.mjs
Normal file
29
apps/pwa/src/lib/users.mjs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// User creation + lookups.
|
||||
import { get, run } from "../db.mjs";
|
||||
import { id } from "./crypto.mjs";
|
||||
|
||||
export async function createUserWithPassword(email, passwordHash, displayName) {
|
||||
const uid = id();
|
||||
await run(`INSERT INTO users (id, email, password_hash, display_name, created_at) VALUES (?,?,?,?,?)`,
|
||||
[uid, email, passwordHash, displayName || email.split("@")[0], Date.now()]);
|
||||
return get(`SELECT * FROM users WHERE id = ?`, [uid]);
|
||||
}
|
||||
|
||||
export async function createUserForCoinpay(sub, displayName) {
|
||||
const uid = id();
|
||||
await run(`INSERT INTO users (id, coinpay_sub, display_name, created_at) VALUES (?,?,?,?)`,
|
||||
[uid, sub, displayName || "logicsrc user", Date.now()]);
|
||||
return get(`SELECT * FROM users WHERE id = ?`, [uid]);
|
||||
}
|
||||
|
||||
// Passkey-first signup (no email/password yet).
|
||||
export async function createUserPasskey(displayName) {
|
||||
const uid = id();
|
||||
await run(`INSERT INTO users (id, display_name, created_at) VALUES (?,?,?)`,
|
||||
[uid, displayName || "logicsrc user", Date.now()]);
|
||||
return get(`SELECT * FROM users WHERE id = ?`, [uid]);
|
||||
}
|
||||
|
||||
export const userByEmail = (email) => get(`SELECT * FROM users WHERE email = ?`, [String(email).toLowerCase()]);
|
||||
export const userByCoinpay = (sub) => get(`SELECT * FROM users WHERE coinpay_sub = ?`, [sub]);
|
||||
export const userById = (uid) => get(`SELECT * FROM users WHERE id = ?`, [uid]);
|
||||
33
apps/pwa/src/migrate.mjs
Normal file
33
apps/pwa/src/migrate.mjs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// Apply SQL migrations in order. Idempotent — tracks applied files in _migrations.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { db, run, all } from "./db.mjs";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DIR = path.join(HERE, "migrations");
|
||||
|
||||
export async function migrate() {
|
||||
// Bootstrap the tracking table (the first migration also declares it IF NOT EXISTS).
|
||||
await run(`CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)`);
|
||||
const done = new Set((await all(`SELECT name FROM _migrations`)).map((r) => r.name));
|
||||
|
||||
const files = fs.readdirSync(DIR).filter((f) => f.endsWith(".sql")).sort();
|
||||
for (const file of files) {
|
||||
if (done.has(file)) { console.log(`· ${file} (already applied)`); continue; }
|
||||
const sql = fs.readFileSync(path.join(DIR, file), "utf8");
|
||||
// libSQL executes one statement per call — split on semicolons at line ends.
|
||||
const statements = sql.split(/;\s*(?:\n|$)/).map((s) => s.trim()).filter(Boolean);
|
||||
for (const stmt of statements) await run(stmt);
|
||||
await run(`INSERT INTO _migrations (name, applied_at) VALUES (?, ?)`, [file, Date.now()]);
|
||||
console.log(`✓ ${file}`);
|
||||
}
|
||||
console.log("migrations up to date");
|
||||
}
|
||||
|
||||
// Run directly (npm run migrate) — not when imported by the server.
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
migrate()
|
||||
.then(() => db.close?.())
|
||||
.catch((e) => { console.error(e); process.exit(1); });
|
||||
}
|
||||
55
apps/pwa/src/migrations/001_auth.sql
Normal file
55
apps/pwa/src/migrations/001_auth.sql
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
-- LogicSRC credentials app — auth schema (libSQL / SQLite).
|
||||
-- Ported from the moshcode PWA auth stack: email/password, passkeys, CoinPay
|
||||
-- OAuth, server sessions, and CLI API keys.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE,
|
||||
password_hash TEXT, -- null when the user only uses passkey / coinpay
|
||||
coinpay_sub TEXT UNIQUE, -- subject from "sign in with CoinPay"
|
||||
display_name TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- WebAuthn / passkey credentials
|
||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||
id TEXT PRIMARY KEY, -- credential id (base64url)
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
public_key TEXT NOT NULL, -- base64url COSE public key
|
||||
counter INTEGER NOT NULL DEFAULT 0,
|
||||
transports TEXT, -- json array
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_webauthn_user ON webauthn_credentials(user_id);
|
||||
|
||||
-- server-side sessions (revocable cookie tokens)
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- API keys (lsk_…) for the logicsrc CLI to authenticate
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT,
|
||||
token_hash TEXT NOT NULL, -- sha256 of the key; prefix stored for display
|
||||
prefix TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_apikeys_user ON api_keys(user_id);
|
||||
|
||||
-- Short-lived authorization codes for `logicsrc login` (loopback PKCE flow).
|
||||
CREATE TABLE IF NOT EXISTS cli_auth_codes (
|
||||
code TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
code_challenge TEXT NOT NULL,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
name TEXT,
|
||||
used INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
91
apps/pwa/src/migrations/002_credshare.sql
Normal file
91
apps/pwa/src/migrations/002_credshare.sql
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
-- LogicSRC credential sharing — teams, vaults, and end-to-end-encrypted secrets.
|
||||
-- Zero-knowledge: only ciphertext, per-member sealed vault keys, and member
|
||||
-- identity public keys are stored. Members are the app's `users`.
|
||||
|
||||
-- A member's X25519 identity public key (one per user; the secret key stays on
|
||||
-- their device in ~/.logicsrc/identity.json and is never uploaded).
|
||||
CREATE TABLE IF NOT EXISTS credshare_keys (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
public_key TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS credshare_teams (
|
||||
id TEXT PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS credshare_members (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL REFERENCES credshare_teams(id) ON DELETE CASCADE,
|
||||
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
email TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member', -- owner | admin | member
|
||||
status TEXT NOT NULL DEFAULT 'invited', -- active | invited
|
||||
invited_by TEXT REFERENCES users(id),
|
||||
joined_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(team_id, email)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_credshare_members_team ON credshare_members(team_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_credshare_members_user ON credshare_members(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS credshare_invites (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL REFERENCES credshare_teams(id) ON DELETE CASCADE,
|
||||
email TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member',
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
expires_at INTEGER NOT NULL,
|
||||
accepted_at INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS credshare_vaults (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL REFERENCES credshare_teams(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(team_id, name)
|
||||
);
|
||||
|
||||
-- Vault data-encryption key sealed to a member's public key (one row per member).
|
||||
CREATE TABLE IF NOT EXISTS credshare_vault_grants (
|
||||
vault_id TEXT NOT NULL REFERENCES credshare_vaults(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
wrapped_dek TEXT NOT NULL,
|
||||
granted_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (vault_id, user_id)
|
||||
);
|
||||
|
||||
-- Encrypted secrets (ciphertext + nonce decrypt only with the vault DEK, which
|
||||
-- the server never sees). fingerprint is a salted hash for redacted diffs.
|
||||
CREATE TABLE IF NOT EXISTS credshare_secrets (
|
||||
vault_id TEXT NOT NULL REFERENCES credshare_vaults(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
nonce TEXT NOT NULL,
|
||||
ciphertext TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
updated_by TEXT NOT NULL REFERENCES users(id),
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (vault_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS credshare_audit (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT,
|
||||
vault_id TEXT,
|
||||
actor_user_id TEXT NOT NULL REFERENCES users(id),
|
||||
action TEXT NOT NULL,
|
||||
key_name TEXT,
|
||||
fingerprint TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_credshare_audit_vault ON credshare_audit(vault_id, created_at DESC);
|
||||
81
apps/pwa/src/routes/auth.mjs
Normal file
81
apps/pwa/src/routes/auth.mjs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// Email/password auth + the sign-in page (which also hosts passkey + CoinPay buttons).
|
||||
import { Router } from "express";
|
||||
import { page, footer, esc } from "../lib/html.mjs";
|
||||
import { csrfInput, createSession, destroySession, takeNext } from "../lib/session.mjs";
|
||||
import { hashPassword, verifyPassword } from "../lib/crypto.mjs";
|
||||
import { createUserWithPassword, userByEmail } from "../lib/users.mjs";
|
||||
import { dashboardHandler } from "./pages.mjs";
|
||||
import { config } from "../config.mjs";
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
function authPage(req, { error = "", mode = "in" } = {}) {
|
||||
const body = `
|
||||
<main class="wrap" style="max-width:440px;padding-top:8vh">
|
||||
<a class="brand" href="/" style="justify-content:center;font-size:1.5rem;margin-bottom:6px"><span class="mark">LS</span>LogicSRC<span class="app">credentials</span></a>
|
||||
<p class="label" style="text-align:center;margin-bottom:26px">Share secrets, end-to-end encrypted</p>
|
||||
<div class="card"><div class="card-body">
|
||||
${error ? `<div class="notice err">${esc(error)}</div>` : ""}
|
||||
<form method="post" action="/auth/${mode === "up" ? "register" : "login"}">
|
||||
${csrfInput(req)}
|
||||
<label class="field"><span>Email</span>
|
||||
<input type="email" name="email" autocomplete="username" required placeholder="you@example.com" value="${esc(req.query.email || "")}"></label>
|
||||
<label class="field"><span>Password</span>
|
||||
<input type="password" name="password" autocomplete="${mode === "up" ? "new-password" : "current-password"}" required minlength="8" placeholder="8+ characters"></label>
|
||||
<button class="btn acid block" type="submit">${mode === "up" ? "Create account" : "Sign in"}</button>
|
||||
</form>
|
||||
<p class="mono" style="text-align:center;font-size:.74rem;margin:14px 0 0">
|
||||
${mode === "up"
|
||||
? `Already have an account? <a class="acid" href="/?mode=in">Sign in</a>`
|
||||
: `New here? <a class="acid" href="/?mode=up">Create account</a>`}
|
||||
</p>
|
||||
|
||||
<div class="divider">or</div>
|
||||
|
||||
<button class="btn block" type="button" id="passkey-btn" style="margin-bottom:10px">🔑 Continue with a passkey</button>
|
||||
${config.coinpayLoginEnabled
|
||||
? `<a class="btn block" href="/auth/coinpay/start">◆ Continue with CoinPay</a>`
|
||||
: `<button class="btn block" type="button" disabled title="Set COINPAY_OAUTH_* to enable">◆ Continue with CoinPay</button>`}
|
||||
<p id="passkey-msg" class="mono faint" style="font-size:.72rem;text-align:center;margin:12px 0 0"></p>
|
||||
</div></div>
|
||||
</main>${footer}
|
||||
<script src="/vendor/simplewebauthn-browser.umd.js"></script>
|
||||
<script src="/passkey.js"></script>`;
|
||||
return page({ title: "LogicSRC ▸ sign in", body });
|
||||
}
|
||||
|
||||
authRouter.get("/", (req, res) => {
|
||||
if (req.user) {
|
||||
const next = takeNext(req, res);
|
||||
if (next) return res.redirect(next);
|
||||
return dashboardHandler(req, res); // dashboard lives at the root
|
||||
}
|
||||
res.type("html").send(authPage(req, { mode: req.query.mode === "up" ? "up" : "in" }));
|
||||
});
|
||||
|
||||
authRouter.post("/auth/register", async (req, res) => {
|
||||
const email = String(req.body.email || "").trim().toLowerCase();
|
||||
const password = String(req.body.password || "");
|
||||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return res.type("html").send(authPage(req, { mode: "up", error: "Enter a valid email." }));
|
||||
if (password.length < 8) return res.type("html").send(authPage(req, { mode: "up", error: "Password must be at least 8 characters." }));
|
||||
if (await userByEmail(email)) return res.type("html").send(authPage(req, { mode: "up", error: "That email already has an account — sign in." }));
|
||||
const user = await createUserWithPassword(email, hashPassword(password));
|
||||
await createSession(res, user.id);
|
||||
res.redirect(takeNext(req, res) || "/");
|
||||
});
|
||||
|
||||
authRouter.post("/auth/login", async (req, res) => {
|
||||
const email = String(req.body.email || "").trim().toLowerCase();
|
||||
const password = String(req.body.password || "");
|
||||
const user = await userByEmail(email);
|
||||
if (!user || !verifyPassword(password, user.password_hash)) {
|
||||
return res.type("html").send(authPage(req, { mode: "in", error: "Wrong email or password." }));
|
||||
}
|
||||
await createSession(res, user.id);
|
||||
res.redirect(takeNext(req, res) || "/");
|
||||
});
|
||||
|
||||
authRouter.post("/auth/logout", async (req, res) => {
|
||||
await destroySession(req, res);
|
||||
res.redirect("/");
|
||||
});
|
||||
85
apps/pwa/src/routes/cli.mjs
Normal file
85
apps/pwa/src/routes/cli.mjs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
// `logicsrc login` OAuth-style flow (authorization code + PKCE + loopback):
|
||||
// GET /cli/authorize browser lands here (login required) → approve page
|
||||
// POST /cli/authorize approve → mint a code, redirect to the CLI's loopback
|
||||
// POST /cli/token CLI exchanges code + verifier → an lsk_ API key (bearer)
|
||||
// GET /api/me Bearer → who am I (for `logicsrc whoami`)
|
||||
import { Router } from "express";
|
||||
import crypto from "node:crypto";
|
||||
import { get, run } from "../db.mjs";
|
||||
import { token } from "../lib/crypto.mjs";
|
||||
import { page, footer, appBar, esc } from "../lib/html.mjs";
|
||||
import { requireAuth, csrfInput } from "../lib/session.mjs";
|
||||
import { createApiKey, bearer, userForApiKey } from "../lib/apikey.mjs";
|
||||
|
||||
export const cliRouter = Router();
|
||||
|
||||
// Only loopback redirect URIs are allowed (the CLI listens on 127.0.0.1).
|
||||
function loopbackOk(uri) {
|
||||
try {
|
||||
const u = new URL(uri);
|
||||
return u.protocol === "http:" && (u.hostname === "127.0.0.1" || u.hostname === "localhost");
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
cliRouter.get("/cli/authorize", requireAuth, (req, res) => {
|
||||
const { redirect_uri, state, code_challenge } = req.query;
|
||||
if (!loopbackOk(redirect_uri) || !state || !code_challenge) {
|
||||
return res.status(400).type("html").send(page({ body: `<main class="wrap" style="padding-top:12vh"><h1>Bad CLI request</h1><p class="dim mono">missing/invalid redirect_uri, state, or code_challenge.</p></main>` }));
|
||||
}
|
||||
const name = String(req.query.name || "logicsrc cli").slice(0, 40);
|
||||
const body = `${appBar(req.user)}
|
||||
<main class="wrap" style="max-width:460px;padding-top:8vh">
|
||||
<div class="card"><div class="card-body" style="text-align:center">
|
||||
<div style="font-size:2rem">🔑</div>
|
||||
<h1 style="font-size:1.4rem;margin:10px 0">Authorize the LogicSRC CLI</h1>
|
||||
<p class="dim mono" style="font-size:.82rem">Grant <b class="green">${esc(name)}</b> on this machine access to manage teams & encrypted credentials as <b>${esc(req.user.email || req.user.display_name)}</b>.</p>
|
||||
<form method="post" action="/cli/authorize" style="margin-top:18px">
|
||||
${csrfInput(req)}
|
||||
<input type="hidden" name="redirect_uri" value="${esc(redirect_uri)}">
|
||||
<input type="hidden" name="state" value="${esc(state)}">
|
||||
<input type="hidden" name="code_challenge" value="${esc(code_challenge)}">
|
||||
<input type="hidden" name="name" value="${esc(name)}">
|
||||
<button class="btn acid block" type="submit">Authorize & connect</button>
|
||||
</form>
|
||||
<p class="faint mono" style="font-size:.72rem;margin-top:12px">You'll return to your terminal.</p>
|
||||
</div></div>
|
||||
</main>${footer}`;
|
||||
res.type("html").send(page({ title: "LogicSRC ▸ authorize CLI", body }));
|
||||
});
|
||||
|
||||
cliRouter.post("/cli/authorize", requireAuth, async (req, res) => {
|
||||
const { redirect_uri, state, code_challenge, name } = req.body;
|
||||
if (!loopbackOk(redirect_uri) || !state || !code_challenge) return res.status(400).send("bad request");
|
||||
const code = token(24);
|
||||
const now = Date.now();
|
||||
await run(
|
||||
`INSERT INTO cli_auth_codes (code,user_id,code_challenge,redirect_uri,name,created_at,expires_at) VALUES (?,?,?,?,?,?,?)`,
|
||||
[code, req.user.id, code_challenge, redirect_uri, String(name || "cli").slice(0, 40), now, now + 5 * 60 * 1000]
|
||||
);
|
||||
const u = new URL(redirect_uri);
|
||||
u.searchParams.set("code", code);
|
||||
u.searchParams.set("state", state);
|
||||
res.redirect(u.toString());
|
||||
});
|
||||
|
||||
cliRouter.post("/cli/token", async (req, res) => {
|
||||
const { code, code_verifier } = req.body || {};
|
||||
if (!code || !code_verifier) return res.status(400).json({ error: "code and code_verifier required" });
|
||||
const row = await get(`SELECT * FROM cli_auth_codes WHERE code = ?`, [code]);
|
||||
if (!row || row.used || row.expires_at < Date.now()) return res.status(400).json({ error: "invalid or expired code" });
|
||||
|
||||
// PKCE: base64url(sha256(verifier)) must equal the stored challenge
|
||||
const challenge = crypto.createHash("sha256").update(String(code_verifier)).digest("base64url");
|
||||
if (challenge !== row.code_challenge) return res.status(400).json({ error: "PKCE verification failed" });
|
||||
|
||||
await run(`UPDATE cli_auth_codes SET used = 1 WHERE code = ?`, [code]);
|
||||
const user = await get(`SELECT * FROM users WHERE id = ?`, [row.user_id]);
|
||||
const { plaintext } = await createApiKey(user.id, row.name || "logicsrc cli");
|
||||
res.json({ access_token: plaintext, token_type: "bearer", user: { id: user.id, email: user.email || null, name: user.display_name } });
|
||||
});
|
||||
|
||||
cliRouter.get("/api/me", async (req, res) => {
|
||||
const user = await userForApiKey(bearer(req));
|
||||
if (!user) return res.status(401).json({ error: "invalid or missing API key" });
|
||||
res.json({ id: user.id, email: user.email || null, name: user.display_name });
|
||||
});
|
||||
68
apps/pwa/src/routes/coinpay.mjs
Normal file
68
apps/pwa/src/routes/coinpay.mjs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// "Sign in with CoinPay" — OAuth 2.0 authorization-code + PKCE.
|
||||
import { Router } from "express";
|
||||
import crypto from "node:crypto";
|
||||
import { config } from "../config.mjs";
|
||||
import { token } from "../lib/crypto.mjs";
|
||||
import { createSession, setCeremony, getCeremony, clearCeremony, takeNext } from "../lib/session.mjs";
|
||||
import { userByCoinpay, createUserForCoinpay } from "../lib/users.mjs";
|
||||
|
||||
export const coinpayRouter = Router();
|
||||
|
||||
const b64url = (buf) => Buffer.from(buf).toString("base64url");
|
||||
|
||||
coinpayRouter.get("/auth/coinpay/start", (req, res) => {
|
||||
if (!config.coinpayLoginEnabled) return res.redirect("/?err=coinpay-not-configured");
|
||||
const state = token(16);
|
||||
const verifier = b64url(crypto.randomBytes(32));
|
||||
const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
|
||||
setCeremony(res, "cp", { state, verifier }, 1000 * 60 * 10);
|
||||
|
||||
const u = new URL(config.coinpay.oauth.authorizeUrl);
|
||||
u.searchParams.set("response_type", "code");
|
||||
u.searchParams.set("client_id", config.coinpay.oauth.clientId);
|
||||
u.searchParams.set("redirect_uri", config.coinpay.oauth.redirectUri);
|
||||
u.searchParams.set("scope", config.coinpay.oauth.scope);
|
||||
u.searchParams.set("state", state);
|
||||
u.searchParams.set("code_challenge", challenge);
|
||||
u.searchParams.set("code_challenge_method", "S256");
|
||||
res.redirect(u.toString());
|
||||
});
|
||||
|
||||
coinpayRouter.get("/auth/coinpay/callback", async (req, res) => {
|
||||
const ceremony = getCeremony(req, "cp");
|
||||
clearCeremony(res, "cp");
|
||||
if (!ceremony || req.query.state !== ceremony.state) return res.redirect("/?err=coinpay-state");
|
||||
if (!req.query.code) return res.redirect("/?err=coinpay-denied");
|
||||
|
||||
try {
|
||||
const tokenRes = await fetch(config.coinpay.oauth.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: String(req.query.code),
|
||||
redirect_uri: config.coinpay.oauth.redirectUri,
|
||||
client_id: config.coinpay.oauth.clientId,
|
||||
code_verifier: ceremony.verifier,
|
||||
}),
|
||||
});
|
||||
if (!tokenRes.ok) throw new Error(`token exchange ${tokenRes.status}`);
|
||||
const tok = await tokenRes.json();
|
||||
|
||||
const infoRes = await fetch(config.coinpay.oauth.userinfoUrl, {
|
||||
headers: { authorization: `Bearer ${tok.access_token}` },
|
||||
});
|
||||
if (!infoRes.ok) throw new Error(`userinfo ${infoRes.status}`);
|
||||
const info = await infoRes.json();
|
||||
const sub = String(info.sub || info.id || info.user_id || "");
|
||||
if (!sub) throw new Error("no subject in userinfo");
|
||||
|
||||
let user = await userByCoinpay(sub);
|
||||
if (!user) user = await createUserForCoinpay(sub, info.name || info.username);
|
||||
await createSession(res, user.id);
|
||||
res.redirect(takeNext(req, res) || "/");
|
||||
} catch (e) {
|
||||
console.error("coinpay login failed:", e.message);
|
||||
res.redirect("/?err=coinpay-failed");
|
||||
}
|
||||
});
|
||||
259
apps/pwa/src/routes/credshare.mjs
Normal file
259
apps/pwa/src/routes/credshare.mjs
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
// LogicSRC credential sharing API (end-to-end encrypted team vaults).
|
||||
//
|
||||
// Zero-knowledge: this server only ever stores ciphertext, per-member sealed
|
||||
// vault keys, and member identity public keys. All crypto happens in the CLI.
|
||||
//
|
||||
// Auth: the acting user comes from a browser session (req.user) OR a
|
||||
// `Bearer lsk_…` API key (the logicsrc CLI). Mounted at /api/credshare.
|
||||
import { Router } from "express";
|
||||
import { get, all, run } from "../db.mjs";
|
||||
import { id, token, sha256 } from "../lib/crypto.mjs";
|
||||
import { bearer, userForApiKey } from "../lib/apikey.mjs";
|
||||
import { config } from "../config.mjs";
|
||||
|
||||
export const credshareRouter = Router();
|
||||
|
||||
const ROLE_RANK = { member: 0, admin: 1, owner: 2 };
|
||||
const INVITE_TTL = 1000 * 60 * 60 * 24 * 7;
|
||||
const norm = (e) => String(e || "").trim().toLowerCase();
|
||||
const slugify = (s) => {
|
||||
const v = String(s || "").trim().toLowerCase();
|
||||
return /^[a-z0-9][a-z0-9-]{0,62}$/.test(v) ? v : null;
|
||||
};
|
||||
|
||||
// Resolve the acting user from session or API key.
|
||||
async function actor(req) {
|
||||
if (req.user) return req.user;
|
||||
return userForApiKey(bearer(req));
|
||||
}
|
||||
function api(handler) {
|
||||
return async (req, res) => {
|
||||
const user = await actor(req);
|
||||
if (!user) return res.status(401).json({ error: "Not authenticated. Run: logicsrc login" });
|
||||
try {
|
||||
await handler(req, res, user);
|
||||
} catch (e) {
|
||||
console.error("credshare:", e);
|
||||
res.status(500).json({ error: e.message || String(e) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function requireMember(res, slug, userId) {
|
||||
const team = await get(`SELECT * FROM credshare_teams WHERE slug = ?`, [slug]);
|
||||
if (!team) { res.status(404).json({ error: `Unknown team: ${slug}` }); return null; }
|
||||
const member = await get(`SELECT * FROM credshare_members WHERE team_id = ? AND user_id = ?`, [team.id, userId]);
|
||||
if (!member || member.status !== "active") { res.status(403).json({ error: "You are not a member of this team." }); return null; }
|
||||
return { team, member };
|
||||
}
|
||||
|
||||
async function publicKeyFor(userId) {
|
||||
const r = await get(`SELECT public_key FROM credshare_keys WHERE user_id = ?`, [userId]);
|
||||
return r?.public_key ?? null;
|
||||
}
|
||||
|
||||
async function audit(ev) {
|
||||
await run(`INSERT INTO credshare_audit (id, team_id, vault_id, actor_user_id, action, key_name, fingerprint, created_at) VALUES (?,?,?,?,?,?,?,?)`,
|
||||
[id(), ev.teamId ?? null, ev.vaultId ?? null, ev.actorUserId, ev.action, ev.keyName ?? null, ev.fingerprint ?? null, Date.now()]);
|
||||
}
|
||||
|
||||
async function sendInviteEmail(to, tok, team, fromEmail) {
|
||||
if (!config.resend.apiKey) return false;
|
||||
const url = `${config.origin}/teams/accept?token=${encodeURIComponent(tok)}`;
|
||||
const r = await fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${config.resend.apiKey}`, "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
from: config.resend.from, to,
|
||||
subject: `You're invited to the "${team.name}" credential team on LogicSRC`,
|
||||
text: `${fromEmail} invited you to share credentials on ${team.name} (${team.slug}).\n\nAccept in the CLI:\n logicsrc login\n logicsrc teams accept ${tok}\n\nOr on the web: ${url}\n\nSecrets are end-to-end encrypted — the server never sees them.`
|
||||
})
|
||||
}).catch(() => null);
|
||||
return Boolean(r && r.ok);
|
||||
}
|
||||
|
||||
// ---- identity key + lookup ----
|
||||
credshareRouter.post("/api/credshare/keys", api(async (req, res, user) => {
|
||||
const publicKey = req.body?.publicKey;
|
||||
if (typeof publicKey !== "string" || !publicKey) return res.status(422).json({ error: "Expected { publicKey }." });
|
||||
await run(`INSERT INTO credshare_keys (user_id, public_key, updated_at) VALUES (?,?,?) ON CONFLICT(user_id) DO UPDATE SET public_key = excluded.public_key, updated_at = excluded.updated_at`,
|
||||
[user.id, publicKey, Date.now()]);
|
||||
res.json({ email: user.email, publicKey });
|
||||
}));
|
||||
|
||||
credshareRouter.get("/api/credshare/me", api(async (_req, res, user) => {
|
||||
const teams = await all(`SELECT t.id, t.slug, t.name FROM credshare_teams t JOIN credshare_members m ON m.team_id = t.id WHERE m.user_id = ? AND m.status = 'active'`, [user.id]);
|
||||
res.json({ user: { id: user.id, email: user.email, publicKey: await publicKeyFor(user.id) }, teams });
|
||||
}));
|
||||
|
||||
credshareRouter.get("/api/credshare/users", api(async (req, res) => {
|
||||
const email = norm(req.query.email);
|
||||
if (!email) return res.status(422).json({ error: "Expected ?email=" });
|
||||
const u = await get(`SELECT id FROM users WHERE email = ?`, [email]);
|
||||
res.json({ email, userId: u?.id ?? null, publicKey: u ? await publicKeyFor(u.id) : null });
|
||||
}));
|
||||
|
||||
// ---- teams / members / invites ----
|
||||
credshareRouter.post("/api/credshare/teams", api(async (req, res, user) => {
|
||||
const slug = slugify(req.body?.slug);
|
||||
if (!slug) return res.status(422).json({ error: "Slug must be lowercase letters, numbers, and dashes." });
|
||||
if (await get(`SELECT 1 FROM credshare_teams WHERE slug = ?`, [slug])) return res.status(409).json({ error: `Team slug "${slug}" is taken.` });
|
||||
const team = { id: id(), slug, name: (req.body?.name && String(req.body.name)) || slug, createdBy: user.id, createdAt: Date.now() };
|
||||
await run(`INSERT INTO credshare_teams (id, slug, name, created_by, created_at) VALUES (?,?,?,?,?)`, [team.id, team.slug, team.name, team.createdBy, team.createdAt]);
|
||||
await run(`INSERT INTO credshare_members (id, team_id, user_id, email, role, status, joined_at, created_at) VALUES (?,?,?,?,?,?,?,?)`,
|
||||
[id(), team.id, user.id, norm(user.email) || user.id, "owner", "active", Date.now(), Date.now()]);
|
||||
await audit({ teamId: team.id, actorUserId: user.id, action: "team:create" });
|
||||
res.status(201).json({ team: { id: team.id, slug: team.slug, name: team.name } });
|
||||
}));
|
||||
|
||||
credshareRouter.get("/api/credshare/teams", api(async (_req, res, user) => {
|
||||
const teams = await all(`SELECT t.id, t.slug, t.name FROM credshare_teams t JOIN credshare_members m ON m.team_id = t.id WHERE m.user_id = ? AND m.status = 'active' ORDER BY t.created_at`, [user.id]);
|
||||
res.json({ teams });
|
||||
}));
|
||||
|
||||
credshareRouter.get("/api/credshare/teams/:slug/members", api(async (req, res, user) => {
|
||||
const ctx = await requireMember(res, req.params.slug, user.id); if (!ctx) return;
|
||||
const rows = await all(`SELECT * FROM credshare_members WHERE team_id = ? ORDER BY created_at`, [ctx.team.id]);
|
||||
const members = [];
|
||||
for (const m of rows) members.push({ email: m.email, role: m.role, status: m.status, hasPublicKey: m.user_id ? Boolean(await publicKeyFor(m.user_id)) : false, joinedAt: m.joined_at });
|
||||
res.json({ members });
|
||||
}));
|
||||
|
||||
credshareRouter.post("/api/credshare/teams/:slug/invites", api(async (req, res, user) => {
|
||||
const ctx = await requireMember(res, req.params.slug, user.id); if (!ctx) return;
|
||||
if (ROLE_RANK[ctx.member.role] < ROLE_RANK.admin) return res.status(403).json({ error: "Only owners and admins can invite." });
|
||||
const email = norm(req.body?.email);
|
||||
if (!email) return res.status(422).json({ error: "Expected { email, role? }." });
|
||||
const role = req.body?.role && ROLE_RANK[req.body.role] != null ? req.body.role : "member";
|
||||
const existing = await get(`SELECT 1 FROM credshare_members WHERE team_id = ? AND email = ?`, [ctx.team.id, email]);
|
||||
if (!existing) {
|
||||
const invitedUser = await get(`SELECT id FROM users WHERE email = ?`, [email]);
|
||||
await run(`INSERT INTO credshare_members (id, team_id, user_id, email, role, status, invited_by, created_at) VALUES (?,?,?,?,?,?,?,?)`,
|
||||
[id(), ctx.team.id, invitedUser?.id ?? null, email, role, "invited", user.id, Date.now()]);
|
||||
}
|
||||
const tok = token(24);
|
||||
await run(`INSERT INTO credshare_invites (id, team_id, email, role, token_hash, created_by, expires_at, created_at) VALUES (?,?,?,?,?,?,?,?)`,
|
||||
[id(), ctx.team.id, email, role, sha256(tok), user.id, Date.now() + INVITE_TTL, Date.now()]);
|
||||
await audit({ teamId: ctx.team.id, actorUserId: user.id, action: "team:invite", keyName: email });
|
||||
const emailSent = await sendInviteEmail(email, tok, ctx.team, user.email);
|
||||
res.status(201).json({ invite: { email, role }, emailSent, ...(emailSent ? {} : { token: tok }) });
|
||||
}));
|
||||
|
||||
credshareRouter.post("/api/credshare/invites/accept", api(async (req, res, user) => {
|
||||
const raw = req.body?.token;
|
||||
if (!raw) return res.status(422).json({ error: "Expected { token }." });
|
||||
const invite = await get(`SELECT * FROM credshare_invites WHERE token_hash = ?`, [sha256(String(raw))]);
|
||||
if (!invite) return res.status(404).json({ error: "Invite not found." });
|
||||
if (invite.accepted_at) return res.status(409).json({ error: "Invite already used." });
|
||||
if (invite.expires_at < Date.now()) return res.status(410).json({ error: "Invite expired." });
|
||||
if (norm(invite.email) !== norm(user.email)) return res.status(403).json({ error: `This invite is for ${invite.email}, not ${user.email}.` });
|
||||
await run(`UPDATE credshare_members SET user_id = ?, status = 'active', joined_at = ? WHERE team_id = ? AND email = ?`, [user.id, Date.now(), invite.team_id, norm(invite.email)]);
|
||||
await run(`UPDATE credshare_invites SET accepted_at = ? WHERE id = ?`, [Date.now(), invite.id]);
|
||||
await audit({ teamId: invite.team_id, actorUserId: user.id, action: "team:join" });
|
||||
const team = await get(`SELECT id, slug, name FROM credshare_teams WHERE id = ?`, [invite.team_id]);
|
||||
res.json({ ok: true, team });
|
||||
}));
|
||||
|
||||
// ---- vaults ----
|
||||
credshareRouter.get("/api/credshare/teams/:slug/vaults", api(async (req, res, user) => {
|
||||
const ctx = await requireMember(res, req.params.slug, user.id); if (!ctx) return;
|
||||
const vaults = await all(`SELECT * FROM credshare_vaults WHERE team_id = ? ORDER BY name`, [ctx.team.id]);
|
||||
const out = [];
|
||||
for (const v of vaults) {
|
||||
const grant = await get(`SELECT 1 FROM credshare_vault_grants WHERE vault_id = ? AND user_id = ?`, [v.id, user.id]);
|
||||
const count = await get(`SELECT COUNT(*) AS n FROM credshare_secrets WHERE vault_id = ?`, [v.id]);
|
||||
out.push({ id: v.id, name: v.name, hasAccess: Boolean(grant), secretCount: Number(count?.n || 0) });
|
||||
}
|
||||
res.json({ vaults: out });
|
||||
}));
|
||||
|
||||
credshareRouter.post("/api/credshare/teams/:slug/vaults", api(async (req, res, user) => {
|
||||
const ctx = await requireMember(res, req.params.slug, user.id); if (!ctx) return;
|
||||
const name = slugify(req.body?.name);
|
||||
if (!name) return res.status(422).json({ error: "Vault name must be lowercase letters, numbers, and dashes." });
|
||||
const existing = await get(`SELECT id, name FROM credshare_vaults WHERE team_id = ? AND name = ?`, [ctx.team.id, name]);
|
||||
if (existing) return res.json({ vault: { id: existing.id, name: existing.name } });
|
||||
const vault = { id: id(), name };
|
||||
await run(`INSERT INTO credshare_vaults (id, team_id, name, created_by, created_at) VALUES (?,?,?,?,?)`, [vault.id, ctx.team.id, name, user.id, Date.now()]);
|
||||
await audit({ teamId: ctx.team.id, vaultId: vault.id, actorUserId: user.id, action: "vault:create" });
|
||||
res.status(201).json({ vault });
|
||||
}));
|
||||
|
||||
// ---- vault grants / secrets / audit (by vault id) ----
|
||||
async function vaultCtx(res, vaultId, userId) {
|
||||
const vault = await get(`SELECT * FROM credshare_vaults WHERE id = ?`, [vaultId]);
|
||||
if (!vault) { res.status(404).json({ error: "Unknown vault." }); return null; }
|
||||
const member = await get(`SELECT * FROM credshare_members WHERE team_id = ? AND user_id = ?`, [vault.team_id, userId]);
|
||||
if (!member || member.status !== "active") { res.status(403).json({ error: "You are not a member of this vault's team." }); return null; }
|
||||
return vault;
|
||||
}
|
||||
|
||||
credshareRouter.get("/api/credshare/vaults/:id/grant", api(async (req, res, user) => {
|
||||
const vault = await vaultCtx(res, req.params.id, user.id); if (!vault) return;
|
||||
const grant = await get(`SELECT wrapped_dek FROM credshare_vault_grants WHERE vault_id = ? AND user_id = ?`, [vault.id, user.id]);
|
||||
if (!grant) return res.status(403).json({ error: "You do not have access to this vault yet. Ask a member to grant you." });
|
||||
res.json({ wrappedDek: grant.wrapped_dek });
|
||||
}));
|
||||
|
||||
credshareRouter.get("/api/credshare/vaults/:id/grants", api(async (req, res, user) => {
|
||||
const vault = await vaultCtx(res, req.params.id, user.id); if (!vault) return;
|
||||
const granted = new Set((await all(`SELECT user_id FROM credshare_vault_grants WHERE vault_id = ?`, [vault.id])).map((r) => r.user_id));
|
||||
const members = await all(`SELECT * FROM credshare_members WHERE team_id = ?`, [vault.team_id]);
|
||||
const grants = [];
|
||||
for (const m of members) grants.push({ email: m.email, hasPublicKey: m.user_id ? Boolean(await publicKeyFor(m.user_id)) : false, hasAccess: Boolean(m.user_id && granted.has(m.user_id)) });
|
||||
res.json({ grants });
|
||||
}));
|
||||
|
||||
credshareRouter.post("/api/credshare/vaults/:id/grants", api(async (req, res, user) => {
|
||||
const vault = await vaultCtx(res, req.params.id, user.id); if (!vault) return;
|
||||
const iHold = await get(`SELECT 1 FROM credshare_vault_grants WHERE vault_id = ? AND user_id = ?`, [vault.id, user.id]);
|
||||
const anyGrants = await get(`SELECT 1 FROM credshare_vault_grants WHERE vault_id = ?`, [vault.id]);
|
||||
if (!iHold && anyGrants) return res.status(403).json({ error: "Only a member with vault access can grant others." });
|
||||
const email = norm(req.body?.email), wrappedDek = req.body?.wrappedDek;
|
||||
if (!email || typeof wrappedDek !== "string" || !wrappedDek) return res.status(422).json({ error: "Expected { email, wrappedDek }." });
|
||||
const target = await get(`SELECT id FROM users WHERE email = ?`, [email]);
|
||||
if (!target) return res.status(404).json({ error: "Target user has not logged in yet." });
|
||||
if (!(await publicKeyFor(target.id))) return res.status(409).json({ error: "Target user has not uploaded a public key yet." });
|
||||
await run(`INSERT INTO credshare_vault_grants (vault_id, user_id, wrapped_dek, granted_by, created_at) VALUES (?,?,?,?,?) ON CONFLICT(vault_id, user_id) DO UPDATE SET wrapped_dek = excluded.wrapped_dek, granted_by = excluded.granted_by, created_at = excluded.created_at`,
|
||||
[vault.id, target.id, wrappedDek, user.id, Date.now()]);
|
||||
await audit({ teamId: vault.team_id, vaultId: vault.id, actorUserId: user.id, action: "vault:grant", keyName: email });
|
||||
res.status(201).json({ ok: true });
|
||||
}));
|
||||
|
||||
credshareRouter.get("/api/credshare/vaults/:id/secrets", api(async (req, res, user) => {
|
||||
const vault = await vaultCtx(res, req.params.id, user.id); if (!vault) return;
|
||||
const rows = await all(`SELECT * FROM credshare_secrets WHERE vault_id = ? ORDER BY name`, [vault.id]);
|
||||
res.json({ vaultId: vault.id, secrets: rows.map((s) => ({ name: s.name, nonce: s.nonce, ciphertext: s.ciphertext, fingerprint: s.fingerprint, version: s.version, updatedAt: s.updated_at })) });
|
||||
}));
|
||||
|
||||
credshareRouter.put("/api/credshare/vaults/:id/secrets", api(async (req, res, user) => {
|
||||
const vault = await vaultCtx(res, req.params.id, user.id); if (!vault) return;
|
||||
const upserts = Array.isArray(req.body?.upserts) ? req.body.upserts : [];
|
||||
const deletes = Array.isArray(req.body?.deletes) ? req.body.deletes : [];
|
||||
const applied = [];
|
||||
for (const u of upserts) {
|
||||
if (!u || typeof u.name !== "string" || typeof u.nonce !== "string" || typeof u.ciphertext !== "string" || typeof u.fingerprint !== "string") {
|
||||
return res.status(422).json({ error: "Each upsert needs { name, nonce, ciphertext, fingerprint }." });
|
||||
}
|
||||
const prev = await get(`SELECT version FROM credshare_secrets WHERE vault_id = ? AND name = ?`, [vault.id, u.name]);
|
||||
const version = (prev?.version ?? 0) + 1;
|
||||
await run(`INSERT INTO credshare_secrets (vault_id, name, nonce, ciphertext, fingerprint, version, updated_by, updated_at) VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(vault_id, name) DO UPDATE SET nonce = excluded.nonce, ciphertext = excluded.ciphertext, fingerprint = excluded.fingerprint, version = excluded.version, updated_by = excluded.updated_by, updated_at = excluded.updated_at`,
|
||||
[vault.id, u.name, u.nonce, u.ciphertext, u.fingerprint, version, user.id, Date.now()]);
|
||||
await audit({ teamId: vault.team_id, vaultId: vault.id, actorUserId: user.id, action: prev ? "secret:update" : "secret:add", keyName: u.name, fingerprint: u.fingerprint });
|
||||
applied.push(u.name);
|
||||
}
|
||||
for (const raw of deletes) {
|
||||
const name = typeof raw === "string" ? raw : null;
|
||||
if (!name) continue;
|
||||
await run(`DELETE FROM credshare_secrets WHERE vault_id = ? AND name = ?`, [vault.id, name]);
|
||||
await audit({ teamId: vault.team_id, vaultId: vault.id, actorUserId: user.id, action: "secret:remove", keyName: name });
|
||||
applied.push(name);
|
||||
}
|
||||
res.json({ ok: true, applied });
|
||||
}));
|
||||
|
||||
credshareRouter.get("/api/credshare/vaults/:id/audit", api(async (req, res, user) => {
|
||||
const vault = await vaultCtx(res, req.params.id, user.id); if (!vault) return;
|
||||
const auditRows = await all(`SELECT * FROM credshare_audit WHERE vault_id = ? ORDER BY created_at DESC`, [vault.id]);
|
||||
res.json({ audit: auditRows });
|
||||
}));
|
||||
168
apps/pwa/src/routes/pages.mjs
Normal file
168
apps/pwa/src/routes/pages.mjs
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
// Teams dashboard (/) + accept-invite (/teams/accept) + settings (/settings).
|
||||
// The browser holds no private key, so it never decrypts — it manages teams,
|
||||
// members, vaults (ciphertext metadata), invites, and CLI API keys.
|
||||
import { Router } from "express";
|
||||
import { get, all, run } from "../db.mjs";
|
||||
import { id, token, sha256 } from "../lib/crypto.mjs";
|
||||
import { page, footer, appBar, esc } from "../lib/html.mjs";
|
||||
import { requireAuth, csrfInput } from "../lib/session.mjs";
|
||||
import { createApiKey, listApiKeys, revokeApiKey } from "../lib/apikey.mjs";
|
||||
import { config } from "../config.mjs";
|
||||
|
||||
export const pagesRouter = Router();
|
||||
|
||||
// placeholder replaced per-request (teamCard can't see req to render csrfInput)
|
||||
const CSRF = "__CSRF__";
|
||||
|
||||
const CLI_HINT = (origin) => `<div class="card" style="margin-bottom:22px"><div class="card-head"><span class="h">Connect the CLI</span><span class="pill on">end-to-end encrypted</span></div>
|
||||
<div class="card-body">
|
||||
<p class="dim" style="margin-top:0;font-size:.9rem">Secrets are encrypted on your machine — decrypt them with the <code>logicsrc</code> CLI, never here.</p>
|
||||
<pre class="mono" style="background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:12px;overflow:auto;font-size:.8rem;margin:0">LOGICSRC_API=${esc(origin)} logicsrc login
|
||||
logicsrc teams push <team> prod --env .env # share
|
||||
logicsrc teams pull <team> prod --env .env # receive</pre>
|
||||
</div></div>`;
|
||||
|
||||
async function teamCard(team, uid) {
|
||||
const members = await all(`SELECT * FROM credshare_members WHERE team_id = ? ORDER BY created_at`, [team.id]);
|
||||
const me = members.find((m) => m.user_id === uid);
|
||||
const vaults = await all(`SELECT * FROM credshare_vaults WHERE team_id = ? ORDER BY name`, [team.id]);
|
||||
const canInvite = me && (me.role === "owner" || me.role === "admin");
|
||||
|
||||
const memberRows = [];
|
||||
for (const m of members) {
|
||||
const key = m.user_id ? await get(`SELECT 1 FROM credshare_keys WHERE user_id = ?`, [m.user_id]) : null;
|
||||
memberRows.push(`<tr><td>${esc(m.email)}</td><td>${esc(m.role)}</td><td><span class="pill ${m.status === "active" ? "on" : ""}">${esc(m.status)}</span></td><td>${key ? "✓" : "—"}</td></tr>`);
|
||||
}
|
||||
const vaultRows = [];
|
||||
for (const v of vaults) {
|
||||
const count = await get(`SELECT COUNT(*) AS n FROM credshare_secrets WHERE vault_id = ?`, [v.id]);
|
||||
const mine = await get(`SELECT 1 FROM credshare_vault_grants WHERE vault_id = ? AND user_id = ?`, [v.id, uid]);
|
||||
vaultRows.push(`<tr><td><code>${esc(v.name)}</code></td><td>${Number(count?.n || 0)}</td><td>${mine ? "✓ you have access" : "— ask a member to grant you"}</td></tr>`);
|
||||
}
|
||||
|
||||
return `<div class="card" style="margin-bottom:22px">
|
||||
<div class="card-head"><span class="h">${esc(team.name)} <span class="faint">/${esc(team.slug)}</span></span><span class="pill">${me ? esc(me.role) : "member"}</span></div>
|
||||
<div class="card-body">
|
||||
<div class="label" style="margin-bottom:6px">Members</div>
|
||||
<table><thead><tr><th>Email</th><th>Role</th><th>Status</th><th>Key</th></tr></thead><tbody>${memberRows.join("")}</tbody></table>
|
||||
${canInvite ? `<form method="post" action="/teams/${esc(team.slug)}/invite" style="display:flex;gap:8px;margin-top:12px">${CSRF}
|
||||
<input type="email" name="email" placeholder="teammate@example.com" required style="flex:1"><button class="btn">Invite</button></form>` : ""}
|
||||
<div class="label" style="margin:18px 0 6px">Vaults</div>
|
||||
${vaults.length ? `<table><thead><tr><th>Vault</th><th>Secrets</th><th>Your access</th></tr></thead><tbody>${vaultRows.join("")}</tbody></table>`
|
||||
: `<p class="faint mono" style="font-size:.82rem">No vaults yet — create one from the CLI: <code>logicsrc teams push ${esc(team.slug)} prod</code></p>`}
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
export async function dashboardHandler(req, res) {
|
||||
const uid = req.user.id;
|
||||
const teams = await all(`SELECT t.* FROM credshare_teams t JOIN credshare_members m ON m.team_id = t.id WHERE m.user_id = ? AND m.status = 'active' ORDER BY t.created_at`, [uid]);
|
||||
let cards = "";
|
||||
for (const t of teams) cards += await teamCard(t, uid);
|
||||
cards = cards.split(CSRF).join(csrfInput(req));
|
||||
|
||||
const body = `${appBar(req.user)}
|
||||
<main class="wrap" style="max-width:820px;padding:26px 0 40px">
|
||||
<div class="section-title"><h1 style="font-size:1.6rem">Your teams</h1><span class="count">${teams.length}</span></div>
|
||||
${CLI_HINT(config.origin)}
|
||||
${cards || `<div class="card"><div class="card-body dim">You're not on any teams yet. Create one below or accept an invite.</div></div>`}
|
||||
<div class="card" style="margin-top:22px"><div class="card-head"><span class="h">New team</span></div>
|
||||
<div class="card-body"><form method="post" action="/teams" style="display:flex;gap:8px">${csrfInput(req)}
|
||||
<input name="slug" placeholder="team-slug" required style="flex:1"><button class="btn acid">Create team</button></form></div></div>
|
||||
</main>${footer}`;
|
||||
res.type("html").send(page({ title: "LogicSRC ▸ teams", body }));
|
||||
}
|
||||
|
||||
pagesRouter.get("/dashboard", requireAuth, dashboardHandler);
|
||||
|
||||
// ---- team + invite form actions (session + CSRF) ----
|
||||
pagesRouter.post("/teams", requireAuth, async (req, res) => {
|
||||
const slug = String(req.body.slug || "").trim().toLowerCase();
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(slug)) return res.redirect("/dashboard?err=bad-slug");
|
||||
if (await get(`SELECT 1 FROM credshare_teams WHERE slug = ?`, [slug])) return res.redirect("/dashboard?err=slug-taken");
|
||||
const teamId = id(), now = Date.now();
|
||||
await run(`INSERT INTO credshare_teams (id, slug, name, created_by, created_at) VALUES (?,?,?,?,?)`, [teamId, slug, slug, req.user.id, now]);
|
||||
await run(`INSERT INTO credshare_members (id, team_id, user_id, email, role, status, joined_at, created_at) VALUES (?,?,?,?,?,?,?,?)`,
|
||||
[id(), teamId, req.user.id, String(req.user.email || req.user.id).toLowerCase(), "owner", "active", now, now]);
|
||||
res.redirect("/dashboard");
|
||||
});
|
||||
|
||||
pagesRouter.post("/teams/:slug/invite", requireAuth, async (req, res) => {
|
||||
const team = await get(`SELECT * FROM credshare_teams WHERE slug = ?`, [req.params.slug]);
|
||||
const me = team && await get(`SELECT * FROM credshare_members WHERE team_id = ? AND user_id = ?`, [team.id, req.user.id]);
|
||||
if (!team || !me || (me.role !== "owner" && me.role !== "admin")) return res.redirect("/dashboard?err=not-allowed");
|
||||
const email = String(req.body.email || "").trim().toLowerCase();
|
||||
if (!email) return res.redirect("/dashboard");
|
||||
const now = Date.now();
|
||||
if (!(await get(`SELECT 1 FROM credshare_members WHERE team_id = ? AND email = ?`, [team.id, email]))) {
|
||||
const u = await get(`SELECT id FROM users WHERE email = ?`, [email]);
|
||||
await run(`INSERT INTO credshare_members (id, team_id, user_id, email, role, status, invited_by, created_at) VALUES (?,?,?,?,?,?,?,?)`,
|
||||
[id(), team.id, u?.id ?? null, email, "member", "invited", req.user.id, now]);
|
||||
}
|
||||
const tok = token(24);
|
||||
await run(`INSERT INTO credshare_invites (id, team_id, email, role, token_hash, created_by, expires_at, created_at) VALUES (?,?,?,?,?,?,?,?)`,
|
||||
[id(), team.id, email, "member", sha256(tok), req.user.id, now + 7 * 864e5, now]);
|
||||
res.redirect("/teams/accept?token=" + encodeURIComponent(tok) + "&shared=1");
|
||||
});
|
||||
|
||||
// ---- accept invite ----
|
||||
pagesRouter.get("/teams/accept", requireAuth, (req, res) => {
|
||||
const tok = String(req.query.token || "");
|
||||
const shared = req.query.shared;
|
||||
const err = req.query.err;
|
||||
const body = `${appBar(req.user)}
|
||||
<main class="wrap" style="max-width:460px;padding-top:8vh">
|
||||
<div class="card"><div class="card-body" style="text-align:center">
|
||||
<h1 style="font-size:1.4rem;margin-bottom:12px">Accept team invite</h1>
|
||||
${err ? `<div class="notice err">${esc(String(err).replace(/-/g, " "))}</div>` : ""}
|
||||
${shared ? `<div class="notice ok">Invite created. Share this link with the teammate, or accept below if it's for you.</div>` : ""}
|
||||
<form method="post" action="/teams/accept">${csrfInput(req)}
|
||||
<label class="field"><span>Invite token</span><input name="token" value="${esc(tok)}" required></label>
|
||||
<button class="btn acid block">Accept invite</button>
|
||||
</form>
|
||||
</div></div>
|
||||
</main>${footer}`;
|
||||
res.type("html").send(page({ title: "LogicSRC ▸ accept invite", body }));
|
||||
});
|
||||
|
||||
pagesRouter.post("/teams/accept", requireAuth, async (req, res) => {
|
||||
const invite = await get(`SELECT * FROM credshare_invites WHERE token_hash = ?`, [sha256(String(req.body.token || ""))]);
|
||||
if (!invite || invite.accepted_at || invite.expires_at < Date.now()) return res.redirect("/teams/accept?err=invalid-or-expired");
|
||||
if (String(invite.email).toLowerCase() !== String(req.user.email || "").toLowerCase()) return res.redirect("/teams/accept?err=wrong-account");
|
||||
const now = Date.now();
|
||||
await run(`UPDATE credshare_members SET user_id = ?, status = 'active', joined_at = ? WHERE team_id = ? AND email = ?`, [req.user.id, now, invite.team_id, String(invite.email).toLowerCase()]);
|
||||
await run(`UPDATE credshare_invites SET accepted_at = ? WHERE id = ?`, [now, invite.id]);
|
||||
res.redirect("/dashboard");
|
||||
});
|
||||
|
||||
// ---- settings: CLI API keys ----
|
||||
pagesRouter.get("/settings", requireAuth, async (req, res) => {
|
||||
const keys = await listApiKeys(req.user.id);
|
||||
const newKey = req.query.key ? String(req.query.key) : "";
|
||||
const keysHtml = keys.length ? keys.map((k) => `
|
||||
<div style="display:flex;gap:12px;align-items:center;padding:10px 0;border-bottom:1px solid var(--line)" class="mono">
|
||||
<span style="flex:1">${esc(k.name)} <span class="faint">${esc(k.prefix)}…</span></span>
|
||||
<form method="post" action="/settings/apikeys/${k.id}/delete" style="margin:0">${csrfInput(req)}<button class="btn danger" style="padding:5px 10px;font-size:.72rem">revoke</button></form>
|
||||
</div>`).join("") : `<div class="faint mono" style="font-size:.78rem;padding:6px 0">no keys yet</div>`;
|
||||
const body = `${appBar(req.user)}
|
||||
<main class="wrap" style="max-width:640px;padding-top:30px">
|
||||
<h1 style="font-size:1.5rem;margin-bottom:20px">Settings</h1>
|
||||
${newKey ? `<div class="notice ok">New API key (copy it now — shown once):<br><b class="mono" style="word-break:break-all">${esc(newKey)}</b></div>` : ""}
|
||||
<div class="card"><div class="card-head"><span class="h">API keys · for the logicsrc CLI</span></div>
|
||||
<div class="card-body">
|
||||
<p class="dim" style="font-size:.85rem;margin-top:0">Usually you don't need these — <code>logicsrc login</code> creates one automatically. Manual keys are for CI.</p>
|
||||
${keysHtml}
|
||||
<form method="post" action="/settings/apikeys" style="margin-top:14px;display:flex;gap:10px">${csrfInput(req)}
|
||||
<input name="name" placeholder="key name (e.g. ci)" style="flex:1"><button class="btn">Create key</button></form>
|
||||
</div></div>
|
||||
</main>${footer}`;
|
||||
res.type("html").send(page({ title: "LogicSRC ▸ settings", body }));
|
||||
});
|
||||
|
||||
pagesRouter.post("/settings/apikeys", requireAuth, async (req, res) => {
|
||||
const { plaintext } = await createApiKey(req.user.id, String(req.body.name || "cli").slice(0, 40));
|
||||
res.redirect("/settings?key=" + encodeURIComponent(plaintext));
|
||||
});
|
||||
pagesRouter.post("/settings/apikeys/:id/delete", requireAuth, async (req, res) => {
|
||||
await revokeApiKey(req.user.id, req.params.id);
|
||||
res.redirect("/settings");
|
||||
});
|
||||
110
apps/pwa/src/routes/passkey.mjs
Normal file
110
apps/pwa/src/routes/passkey.mjs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
// Passkey (WebAuthn) auth: register a new passkey-first user, or sign in with a
|
||||
// discoverable credential. Client uses @simplewebauthn/browser (served at /vendor).
|
||||
import { Router } from "express";
|
||||
import {
|
||||
generateRegistrationOptions,
|
||||
verifyRegistrationResponse,
|
||||
generateAuthenticationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
} from "@simplewebauthn/server";
|
||||
import { get, run } from "../db.mjs";
|
||||
import { config } from "../config.mjs";
|
||||
import { id } from "../lib/crypto.mjs";
|
||||
import { createSession, setCeremony, getCeremony, clearCeremony, takeNext } from "../lib/session.mjs";
|
||||
import { createUserPasskey, userById } from "../lib/users.mjs";
|
||||
|
||||
export const passkeyRouter = Router();
|
||||
const enc = (s) => new TextEncoder().encode(s);
|
||||
|
||||
// ---- registration (new passkey-first account, or add to the logged-in user) ----
|
||||
passkeyRouter.post("/auth/passkey/register/options", async (req, res) => {
|
||||
const handle = req.user?.id || id();
|
||||
const name = req.user?.email || req.user?.display_name || `logicsrc-${handle.slice(0, 6)}`;
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: config.rpName,
|
||||
rpID: config.rpID,
|
||||
userName: name,
|
||||
userID: enc(handle),
|
||||
attestationType: "none",
|
||||
authenticatorSelection: { residentKey: "required", userVerification: "preferred" },
|
||||
});
|
||||
setCeremony(res, "reg", { challenge: options.challenge, handle, name, existing: Boolean(req.user) });
|
||||
res.json(options);
|
||||
});
|
||||
|
||||
passkeyRouter.post("/auth/passkey/register/verify", async (req, res) => {
|
||||
const ceremony = getCeremony(req, "reg");
|
||||
if (!ceremony) return res.status(400).json({ error: "registration expired — try again" });
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response: req.body,
|
||||
expectedChallenge: ceremony.challenge,
|
||||
expectedOrigin: config.origin,
|
||||
expectedRPID: config.rpID,
|
||||
});
|
||||
} catch (e) {
|
||||
return res.status(400).json({ error: String(e.message || e) });
|
||||
}
|
||||
if (!verification.verified) return res.status(400).json({ error: "could not verify passkey" });
|
||||
|
||||
const { credential } = verification.registrationInfo;
|
||||
const user = ceremony.existing ? await userById(ceremony.handle) : await createUserPasskey(ceremony.name);
|
||||
await run(
|
||||
`INSERT INTO webauthn_credentials (id, user_id, public_key, counter, transports, created_at) VALUES (?,?,?,?,?,?)`,
|
||||
[
|
||||
credential.id,
|
||||
user.id,
|
||||
Buffer.from(credential.publicKey).toString("base64url"),
|
||||
credential.counter || 0,
|
||||
JSON.stringify(credential.transports || req.body.response?.transports || []),
|
||||
Date.now(),
|
||||
]
|
||||
);
|
||||
clearCeremony(res, "reg");
|
||||
await createSession(res, user.id);
|
||||
res.json({ ok: true, redirect: takeNext(req, res) || "/" });
|
||||
});
|
||||
|
||||
// ---- authentication (discoverable / usernameless sign-in) ----
|
||||
passkeyRouter.post("/auth/passkey/login/options", async (req, res) => {
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: config.rpID,
|
||||
allowCredentials: [], // discoverable credentials
|
||||
userVerification: "preferred",
|
||||
});
|
||||
setCeremony(res, "auth", { challenge: options.challenge });
|
||||
res.json(options);
|
||||
});
|
||||
|
||||
passkeyRouter.post("/auth/passkey/login/verify", async (req, res) => {
|
||||
const ceremony = getCeremony(req, "auth");
|
||||
if (!ceremony) return res.status(400).json({ error: "sign-in expired — try again" });
|
||||
const cred = await get(`SELECT * FROM webauthn_credentials WHERE id = ?`, [req.body.id]);
|
||||
if (!cred) return res.status(400).json({ error: "unknown passkey — create an account" });
|
||||
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyAuthenticationResponse({
|
||||
response: req.body,
|
||||
expectedChallenge: ceremony.challenge,
|
||||
expectedOrigin: config.origin,
|
||||
expectedRPID: config.rpID,
|
||||
credential: {
|
||||
id: cred.id,
|
||||
publicKey: Buffer.from(cred.public_key, "base64url"),
|
||||
counter: Number(cred.counter),
|
||||
transports: JSON.parse(cred.transports || "[]"),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
return res.status(400).json({ error: String(e.message || e) });
|
||||
}
|
||||
if (!verification.verified) return res.status(400).json({ error: "passkey did not verify" });
|
||||
|
||||
await run(`UPDATE webauthn_credentials SET counter = ? WHERE id = ?`,
|
||||
[verification.authenticationInfo.newCounter, cred.id]);
|
||||
clearCeremony(res, "auth");
|
||||
await createSession(res, cred.user_id);
|
||||
res.json({ ok: true, redirect: takeNext(req, res) || "/" });
|
||||
});
|
||||
58
apps/pwa/src/server.mjs
Normal file
58
apps/pwa/src/server.mjs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// LogicSRC credentials — Express PWA entrypoint (auth + team credential sharing).
|
||||
import express from "express";
|
||||
import cookieParser from "cookie-parser";
|
||||
import path from "node:path";
|
||||
import { config } from "./config.mjs";
|
||||
import { migrate } from "./migrate.mjs";
|
||||
import { sessionMiddleware, csrfGuard } from "./lib/session.mjs";
|
||||
import { authRouter } from "./routes/auth.mjs";
|
||||
import { passkeyRouter } from "./routes/passkey.mjs";
|
||||
import { coinpayRouter } from "./routes/coinpay.mjs";
|
||||
import { credshareRouter } from "./routes/credshare.mjs";
|
||||
import { cliRouter } from "./routes/cli.mjs";
|
||||
import { pagesRouter } from "./routes/pages.mjs";
|
||||
|
||||
const app = express();
|
||||
app.disable("x-powered-by");
|
||||
if (config.secure) app.set("trust proxy", 1); // Railway terminates TLS
|
||||
|
||||
// body parsing — keep the raw body for HMAC signature verification
|
||||
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString("utf8"); } }));
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
app.use(cookieParser());
|
||||
|
||||
// static
|
||||
app.use(express.static(path.join(config.root, "public"), { maxAge: "1h" }));
|
||||
// the @simplewebauthn/browser UMD bundle, served from node_modules (no CDN)
|
||||
app.get("/vendor/simplewebauthn-browser.umd.js", (_req, res) =>
|
||||
res.sendFile(path.join(config.root, "node_modules/@simplewebauthn/browser/dist/bundle/index.umd.min.js")));
|
||||
|
||||
app.get("/healthz", (_req, res) => res.json({ ok: true, env: config.env }));
|
||||
|
||||
app.use(sessionMiddleware);
|
||||
app.use(csrfGuard);
|
||||
|
||||
// routes
|
||||
app.use(authRouter); // GET / (+ /auth/login|register|logout)
|
||||
app.use(passkeyRouter);
|
||||
app.use(coinpayRouter);
|
||||
app.use(credshareRouter); // /api/credshare/* (session or lsk_ Bearer)
|
||||
app.use(cliRouter); // /cli/authorize, /cli/token, /api/me
|
||||
app.use(pagesRouter); // /dashboard, /teams/*, /settings
|
||||
|
||||
app.use((req, res) => res.status(404).type("html").send(
|
||||
`<body style="background:#f6f7f4;color:#101418;font-family:system-ui,sans-serif;padding:14vh 24px;text-align:center"><h1 style="color:#0a7d59">404</h1><p>no such page.</p><a style="color:#0a7d59" href="/">back to your teams →</a></body>`));
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error(err);
|
||||
res.status(500).type("html").send(`<body style="background:#f6f7f4;color:#c23a3a;font-family:system-ui,sans-serif;padding:14vh 24px;text-align:center"><h1>500</h1><p>something broke.</p></body>`);
|
||||
});
|
||||
|
||||
async function main() {
|
||||
await migrate();
|
||||
app.listen(config.port, () => console.log(`🔐 logicsrc credentials on :${config.port} (${config.env}) — ${config.origin}`));
|
||||
}
|
||||
main().catch((e) => { console.error("boot failed:", e); process.exit(1); });
|
||||
|
||||
export { app };
|
||||
964
package-lock.json
generated
964
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -78,19 +78,11 @@ program.action(async (options) => {
|
|||
|
||||
program
|
||||
.command("login")
|
||||
.option("--email <email>", "Email to log in with (LogicSRC team credential sharing)")
|
||||
.option("--code <code>", "Login code (skip the interactive prompt)")
|
||||
.option("--did <did>", "CoinPay DID (legacy)")
|
||||
.option("--oauth <provider>", "OAuth provider (legacy)")
|
||||
.description("Log in by email for team credential sharing (registers your device identity key).")
|
||||
.option("--api-url <url>", "LogicSRC app URL", process.env.LOGICSRC_API_URL || process.env.COMMANDBOARD_API_URL)
|
||||
.option("--token <lsk_key>", "Use an existing API key instead of the browser flow (CI)")
|
||||
.description("Log in via your browser (loopback OAuth) for team credential sharing; registers your device identity key.")
|
||||
.action(async (options) => {
|
||||
if (!options.email && (options.did || options.oauth)) {
|
||||
const mode = options.did ? `CoinPay DID ${options.did}` : `${options.oauth} OAuth`;
|
||||
console.log(`Login flow ready: ${mode}`);
|
||||
console.log("Token storage target: $HOME/.logicsrc/identity.json");
|
||||
return;
|
||||
}
|
||||
await loginAction({ email: options.email, code: options.code });
|
||||
await loginAction({ apiUrl: options.apiUrl, token: options.token });
|
||||
});
|
||||
|
||||
program.command("logout").description("Clear local auth token (keeps your identity key).").action(async () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { createInterface } from "node:readline/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { hostname } from "node:os";
|
||||
import { spawn } from "node:child_process";
|
||||
import {
|
||||
TeamClient,
|
||||
TeamApiError,
|
||||
|
|
@ -26,15 +29,79 @@ function authedClient(): { client: TeamClient; identity: ReturnType<typeof requi
|
|||
return { client, identity };
|
||||
}
|
||||
|
||||
async function prompt(question: string): Promise<string> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
||||
const b64url = (buf: Buffer): string => buf.toString("base64url");
|
||||
|
||||
function openBrowser(url: string): void {
|
||||
const [cmd, args] =
|
||||
process.platform === "darwin" ? ["open", [url]]
|
||||
: process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
|
||||
: ["xdg-open", [url]];
|
||||
try {
|
||||
return (await rl.question(question)).trim();
|
||||
} finally {
|
||||
rl.close();
|
||||
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
||||
child.on("error", () => {});
|
||||
child.unref();
|
||||
} catch {
|
||||
/* print fallback below */
|
||||
}
|
||||
}
|
||||
|
||||
const DONE_PAGE = (msg: string) =>
|
||||
`<!doctype html><meta charset=utf-8><body style="background:#f6f7f4;color:#101418;font-family:system-ui,sans-serif;text-align:center;padding:16vh 24px"><h1 style="color:#0a7d59">${msg}</h1><p>Return to your terminal — you can close this tab.</p></body>`;
|
||||
|
||||
/** Browser OAuth-PKCE loopback login against the LogicSRC app → an lsk_ token. */
|
||||
function loopbackLogin(apiUrl: string, timeoutMs = 180000): Promise<{ token: string; email: string | null; userId?: string }> {
|
||||
const verifier = b64url(randomBytes(32));
|
||||
const challenge = b64url(createHash("sha256").update(verifier).digest());
|
||||
const state = b64url(randomBytes(16));
|
||||
const base = apiUrl.replace(/\/+$/, "");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (url.pathname !== "/callback") {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const code = url.searchParams.get("code");
|
||||
if (url.searchParams.get("error")) throw new Error(`authorization denied (${url.searchParams.get("error")})`);
|
||||
if (!code || url.searchParams.get("state") !== state) throw new Error("bad authorization response (state mismatch)");
|
||||
const tokRes = await fetch(`${base}/cli/token`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ code, code_verifier: verifier })
|
||||
});
|
||||
if (!tokRes.ok) throw new Error(`token exchange failed (${tokRes.status})`);
|
||||
const tok = (await tokRes.json()) as { access_token: string; user?: { email?: string; id?: string } };
|
||||
res.writeHead(200, { "content-type": "text/html" }).end(DONE_PAGE("You're in."));
|
||||
server.close();
|
||||
resolve({ token: tok.access_token, email: tok.user?.email ?? null, userId: tok.user?.id });
|
||||
} catch (error) {
|
||||
res.writeHead(400, { "content-type": "text/html" }).end(DONE_PAGE("Login failed — check the terminal."));
|
||||
server.close();
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const { port } = server.address() as { port: number };
|
||||
const authUrl = `${base}/cli/authorize?` + new URLSearchParams({
|
||||
redirect_uri: `http://127.0.0.1:${port}/callback`,
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
name: `logicsrc cli @ ${hostname()}`
|
||||
});
|
||||
console.error("\n🔑 Opening your browser to authorize the LogicSRC CLI…");
|
||||
console.error(` If it doesn't open, visit:\n ${authUrl}\n`);
|
||||
openBrowser(authUrl);
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => { server.close(); reject(new Error("login timed out — run `logicsrc login` again")); }, timeoutMs);
|
||||
server.on("close", () => clearTimeout(timer));
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveVaultId(client: TeamClient, slug: string, vault: string): Promise<string> {
|
||||
const { vaults } = await client.listVaults(slug);
|
||||
const found = vaults.find((v) => v.name === vault);
|
||||
|
|
@ -42,42 +109,37 @@ async function resolveVaultId(client: TeamClient, slug: string, vault: string):
|
|||
return found.id;
|
||||
}
|
||||
|
||||
export async function loginAction(options: { email?: string; code?: string }): Promise<void> {
|
||||
export async function loginAction(options: { apiUrl?: string; token?: string }): Promise<void> {
|
||||
const identity = await loadOrCreateIdentity();
|
||||
const email = options.email ?? (await prompt("Email: "));
|
||||
if (!email) throw new Error("An email is required: logicsrc login --email you@example.com");
|
||||
const client = new TeamClient({ apiUrl: identity.apiUrl || defaultApiUrl() });
|
||||
const apiUrl = (options.apiUrl || identity.apiUrl || defaultApiUrl()).replace(/\/+$/, "");
|
||||
|
||||
const requested = await client.requestLoginCode(email);
|
||||
let code = options.code;
|
||||
if (requested.devCode) {
|
||||
// No email transport configured server-side (local dev) — the code is returned.
|
||||
console.error(`(dev) login code: ${requested.devCode}`);
|
||||
code = code ?? requested.devCode;
|
||||
// Loopback browser OAuth-PKCE (like `moshcode login`), or a --token for CI.
|
||||
let token = options.token;
|
||||
let email: string | null = null;
|
||||
let userId: string | undefined;
|
||||
if (token) {
|
||||
const client = new TeamClient({ apiUrl, token });
|
||||
const me = await client.me();
|
||||
email = me.user.email;
|
||||
userId = me.user.id;
|
||||
} else {
|
||||
const result = await loopbackLogin(apiUrl);
|
||||
token = result.token;
|
||||
email = result.email;
|
||||
userId = result.userId;
|
||||
}
|
||||
if (!code) code = await prompt(`Enter the 6-digit code sent to ${email}: `);
|
||||
|
||||
const verified = await client.verifyLoginCode(email, code);
|
||||
client.setToken(verified.token);
|
||||
const client = new TeamClient({ apiUrl, token: token! });
|
||||
await client.uploadPublicKey(identity.keys.publicKey);
|
||||
await updateIdentity({ email: verified.user.email, userId: verified.user.id, apiToken: verified.token, apiUrl: identity.apiUrl || defaultApiUrl() });
|
||||
await updateIdentity({ email: email ?? undefined, userId, apiToken: token, apiUrl });
|
||||
|
||||
console.error(`Logged in as ${verified.user.email}. Identity key registered.`);
|
||||
print({ email: verified.user.email, userId: verified.user.id, apiUrl: identity.apiUrl || defaultApiUrl() }, "table");
|
||||
console.error(`Logged in${email ? ` as ${email}` : ""}. Identity key registered on ${apiUrl}.`);
|
||||
print({ email, apiUrl }, "table");
|
||||
}
|
||||
|
||||
export async function logoutAction(): Promise<void> {
|
||||
const identity = readIdentity();
|
||||
if (identity?.apiToken) {
|
||||
try {
|
||||
const client = new TeamClient({ apiUrl: identity.apiUrl || defaultApiUrl(), token: identity.apiToken });
|
||||
await client.logout();
|
||||
} catch {
|
||||
// best effort — token may already be gone
|
||||
}
|
||||
}
|
||||
await updateIdentity({ apiToken: undefined, email: undefined, userId: undefined });
|
||||
console.error("Logged out. Local identity key retained (delete ~/.logicsrc/identity.json to remove it).");
|
||||
console.error("Logged out (local token cleared; revoke the key at /settings). Identity key retained — delete ~/.logicsrc/identity.json to remove it.");
|
||||
}
|
||||
|
||||
export async function whoamiAction(format: OutputFormat): Promise<void> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue