feat(credentials): manage team members and rotate invites
Some checks are pending
CI / build (push) Waiting to run
test / test (push) Waiting to run

This commit is contained in:
Anthony Ettinger 2026-08-04 19:16:02 +00:00
parent fd253b0485
commit e4723f31b1
10 changed files with 531 additions and 61 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@logicsrc/pwa",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"type": "module",
"description": "LogicSRC credentials — Express + libSQL/Turso app: auth + end-to-end-encrypted team credential sharing.",

View file

@ -45,7 +45,7 @@ h1{font-size:2.2rem;line-height:1.05}
.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:focus,textarea:focus,select: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}
@ -86,6 +86,10 @@ label.field span{display:block;font-family:var(--mono);font-size:.66rem;letter-s
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)}
.table-scroll{overflow-x:auto}
.member-role-form,.member-actions{display:flex;align-items:center;gap:6px;margin:0}
.member-role-form select{width:auto;min-width:94px;padding:6px 9px}
.btn.compact{padding:6px 9px;font-size:.74rem}
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)}

View file

@ -0,0 +1,172 @@
import { get, batch } from "../db.mjs";
import { id, token, sha256 } from "./crypto.mjs";
import { config } from "../config.mjs";
export const TEAM_ROLES = ["owner", "admin", "member"];
export const ROLE_RANK = { member: 0, admin: 1, owner: 2 };
export const INVITE_TTL = 1000 * 60 * 60 * 24 * 7;
export const normEmail = (email) => String(email || "").trim().toLowerCase();
export class TeamMemberError extends Error {
constructor(status, message, code = "member-error") {
super(message);
this.name = "TeamMemberError";
this.status = status;
this.code = code;
}
}
export function canManageMember(actor, target) {
if (!actor || actor.status !== "active") return false;
if (actor.role === "owner") return true;
return actor.role === "admin" && target.role === "member";
}
function assertInviteRole(actor, role) {
if (!TEAM_ROLES.includes(role)) {
throw new TeamMemberError(422, "Role must be owner, admin, or member.", "bad-role");
}
if (actor.role !== "owner" && !(actor.role === "admin" && role === "member")) {
throw new TeamMemberError(403, "Only owners can invite admins or owners.", "not-allowed");
}
}
async function sendInviteEmail(to, tok, team, fromEmail) {
if (!config.resend.apiKey) return false;
const url = `${config.origin}/teams/accept?token=${encodeURIComponent(tok)}`;
const response = 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(response && response.ok);
}
// Create or resend an invite. A resend replaces every unused invite for this
// team/email pair so the old key stops working immediately.
export async function issueTeamInvite({ team, actor, email: rawEmail, role: rawRole }) {
if (!actor || actor.status !== "active" || ROLE_RANK[actor.role] < ROLE_RANK.admin) {
throw new TeamMemberError(403, "Only owners and admins can invite.", "not-allowed");
}
const email = normEmail(rawEmail);
if (!email) throw new TeamMemberError(422, "Expected an email address.", "bad-email");
const role = String(rawRole || "member");
assertInviteRole(actor, role);
const existing = await get(`SELECT * FROM credshare_members WHERE team_id = ? AND email = ?`, [team.id, email]);
if (existing?.status === "active") {
throw new TeamMemberError(409, `${email} is already an active team member.`, "already-member");
}
if (existing && actor.role === "admin" && existing.role !== "member") {
throw new TeamMemberError(403, "Admins cannot replace an owner or admin invite.", "not-allowed");
}
const invitedUser = await get(`SELECT id FROM users WHERE email = ?`, [email]);
const tok = token(24);
const now = Date.now();
const inviteId = id();
const statements = [];
if (existing) {
statements.push({
sql: `UPDATE credshare_members SET user_id = COALESCE(user_id, ?), role = ?, invited_by = ? WHERE id = ?`,
args: [invitedUser?.id ?? null, role, actor.user_id, existing.id]
});
} else {
statements.push({
sql: `INSERT INTO credshare_members (id, team_id, user_id, email, role, status, invited_by, created_at) VALUES (?,?,?,?,?,?,?,?)`,
args: [id(), team.id, invitedUser?.id ?? null, email, role, "invited", actor.user_id, now]
});
}
statements.push(
{
sql: `DELETE FROM credshare_invites WHERE team_id = ? AND email = ? AND accepted_at IS NULL`,
args: [team.id, email]
},
{
sql: `INSERT INTO credshare_invites (id, team_id, email, role, token_hash, created_by, expires_at, created_at) VALUES (?,?,?,?,?,?,?,?)`,
args: [inviteId, team.id, email, role, sha256(tok), actor.user_id, now + INVITE_TTL, now]
},
{
sql: `INSERT INTO credshare_audit (id, team_id, vault_id, actor_user_id, action, key_name, fingerprint, created_at) VALUES (?,?,?,?,?,?,?,?)`,
args: [id(), team.id, null, actor.user_id, existing ? "team:invite:resend" : "team:invite", email, null, now]
}
);
await batch(statements);
const emailSent = await sendInviteEmail(email, tok, team, actor.email);
return {
invite: { id: inviteId, email, role, expiresAt: now + INVITE_TTL },
emailSent,
token: tok,
resent: Boolean(existing)
};
}
export async function changeMemberRole({ team, actor, memberId, role }) {
if (!actor || actor.status !== "active" || actor.role !== "owner") {
throw new TeamMemberError(403, "Only owners can change member permissions.", "not-allowed");
}
if (!TEAM_ROLES.includes(role)) {
throw new TeamMemberError(422, "Role must be owner, admin, or member.", "bad-role");
}
const target = await get(`SELECT * FROM credshare_members WHERE id = ? AND team_id = ?`, [memberId, team.id]);
if (!target) throw new TeamMemberError(404, "Team member not found.", "member-not-found");
if (target.role === role) return target;
if (target.role === "owner" && target.status === "active" && role !== "owner") {
const owners = await get(`SELECT COUNT(*) AS n FROM credshare_members WHERE team_id = ? AND role = 'owner' AND status = 'active'`, [team.id]);
if (Number(owners?.n || 0) <= 1) {
throw new TeamMemberError(409, "A team must keep at least one active owner.", "last-owner");
}
}
const now = Date.now();
await batch([
{ sql: `UPDATE credshare_members SET role = ? WHERE id = ?`, args: [role, target.id] },
{ sql: `UPDATE credshare_invites SET role = ? WHERE team_id = ? AND email = ? AND accepted_at IS NULL`, args: [role, team.id, target.email] },
{
sql: `INSERT INTO credshare_audit (id, team_id, vault_id, actor_user_id, action, key_name, fingerprint, created_at) VALUES (?,?,?,?,?,?,?,?)`,
args: [id(), team.id, null, actor.user_id, `team:member:role:${target.role}->${role}`, target.email, null, now]
}
]);
return { ...target, role };
}
export async function removeTeamMember({ team, actor, memberId }) {
const target = await get(`SELECT * FROM credshare_members WHERE id = ? AND team_id = ?`, [memberId, team.id]);
if (!target) throw new TeamMemberError(404, "Team member not found.", "member-not-found");
if (!canManageMember(actor, target)) {
throw new TeamMemberError(403, "You cannot remove this team member.", "not-allowed");
}
if (target.role === "owner" && target.status === "active") {
const owners = await get(`SELECT COUNT(*) AS n FROM credshare_members WHERE team_id = ? AND role = 'owner' AND status = 'active'`, [team.id]);
if (Number(owners?.n || 0) <= 1) {
throw new TeamMemberError(409, "A team must keep at least one active owner.", "last-owner");
}
}
const grantCount = target.user_id
? await get(`SELECT COUNT(*) AS n FROM credshare_vault_grants WHERE user_id = ? AND vault_id IN (SELECT id FROM credshare_vaults WHERE team_id = ?)`, [target.user_id, team.id])
: null;
const revokedVaultGrants = Number(grantCount?.n || 0);
const now = Date.now();
await batch([
{
sql: `DELETE FROM credshare_vault_grants WHERE user_id = ? AND vault_id IN (SELECT id FROM credshare_vaults WHERE team_id = ?)`,
args: [target.user_id || "", team.id]
},
{ sql: `DELETE FROM credshare_invites WHERE team_id = ? AND email = ? AND accepted_at IS NULL`, args: [team.id, target.email] },
{ sql: `DELETE FROM credshare_members WHERE id = ?`, args: [target.id] },
{
sql: `INSERT INTO credshare_audit (id, team_id, vault_id, actor_user_id, action, key_name, fingerprint, created_at) VALUES (?,?,?,?,?,?,?,?)`,
args: [id(), team.id, null, actor.user_id, "team:member:remove", target.email, null, now]
}
]);
return { member: target, revokedVaultGrants, rotationRequired: revokedVaultGrants > 0 };
}

View file

@ -7,15 +7,19 @@
// `Bearer lsk_…` API key (the logicsrc CLI). Mounted at /api/credshare.
import { Router } from "express";
import { get, all, run, batch } from "../db.mjs";
import { id, token, sha256 } from "../lib/crypto.mjs";
import { id, sha256 } from "../lib/crypto.mjs";
import { bearer, userForApiKey } from "../lib/apikey.mjs";
import { config } from "../config.mjs";
import {
TeamMemberError,
normEmail,
issueTeamInvite,
changeMemberRole,
removeTeamMember
} from "../lib/team-members.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 norm = normEmail;
const slugify = (s) => {
const v = String(s || "").trim().toLowerCase();
return /^[a-z0-9][a-z0-9-]{0,62}$/.test(v) ? v : null;
@ -57,21 +61,6 @@ async function audit(ev) {
[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;
@ -115,28 +104,61 @@ credshareRouter.get("/api/credshare/teams/:slug/members", api(async (req, res, u
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 });
for (const m of rows) members.push({ id: m.id, 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()]);
try {
const result = await issueTeamInvite({
team: ctx.team,
actor: { ...ctx.member, email: user.email },
email: req.body?.email,
role: req.body?.role
});
res.status(201).json({
invite: result.invite,
emailSent: result.emailSent,
resent: result.resent,
...(result.emailSent ? {} : { token: result.token })
});
} catch (error) {
if (error instanceof TeamMemberError) return res.status(error.status).json({ error: error.message, code: error.code });
throw error;
}
}));
credshareRouter.patch("/api/credshare/teams/:slug/members/:memberId", api(async (req, res, user) => {
const ctx = await requireMember(res, req.params.slug, user.id); if (!ctx) return;
try {
const member = await changeMemberRole({
team: ctx.team,
actor: ctx.member,
memberId: req.params.memberId,
role: req.body?.role
});
res.json({ member: { id: member.id, email: member.email, role: member.role, status: member.status } });
} catch (error) {
if (error instanceof TeamMemberError) return res.status(error.status).json({ error: error.message, code: error.code });
throw error;
}
}));
credshareRouter.delete("/api/credshare/teams/:slug/members/:memberId", api(async (req, res, user) => {
const ctx = await requireMember(res, req.params.slug, user.id); if (!ctx) return;
try {
const result = await removeTeamMember({ team: ctx.team, actor: ctx.member, memberId: req.params.memberId });
res.json({
ok: true,
removed: result.member.email,
revokedVaultGrants: result.revokedVaultGrants,
rotationRequired: result.rotationRequired
});
} catch (error) {
if (error instanceof TeamMemberError) return res.status(error.status).json({ error: error.message, code: error.code });
throw error;
}
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) => {

View file

@ -3,13 +3,20 @@
// 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 { id, 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 { requestOrigin } from "../lib/origin.mjs";
import { CLI_HINT } from "../lib/cli-hint.mjs";
import { config } from "../config.mjs";
import {
TeamMemberError,
canManageMember,
issueTeamInvite,
changeMemberRole,
removeTeamMember
} from "../lib/team-members.mjs";
export const pagesRouter = Router();
@ -25,7 +32,18 @@ async function teamCard(team, uid) {
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 roleControl = me.role === "owner"
? `<form method="post" action="/teams/${esc(team.slug)}/members/${esc(m.id)}/role" class="member-role-form">${CSRF}
<select name="role" aria-label="Permission for ${esc(m.email)}">
${["owner", "admin", "member"].map((role) => `<option value="${role}"${m.role === role ? " selected" : ""}>${role}</option>`).join("")}
</select><button class="btn compact">Save</button></form>`
: `<span class="pill">${esc(m.role)}</span>`;
const canResend = m.status === "invited" && (me.role === "owner" || (me.role === "admin" && m.role === "member"));
const actions = [
canResend ? `<form method="post" action="/teams/${esc(team.slug)}/members/${esc(m.id)}/resend" style="margin:0">${CSRF}<button class="btn compact">Resend</button></form>` : "",
canManageMember(me, m) ? `<form method="post" action="/teams/${esc(team.slug)}/members/${esc(m.id)}/delete" style="margin:0" onsubmit="return confirm('Remove this team member?')">${CSRF}<button class="btn compact danger">Remove</button></form>` : ""
].filter(Boolean).join("");
memberRows.push(`<tr><td>${esc(m.email)}</td><td>${roleControl}</td><td><span class="pill ${m.status === "active" ? "on" : "warn"}">${esc(m.status)}</span></td><td>${key ? "✓" : "—"}</td><td><div class="member-actions">${actions || "—"}</div></td></tr>`);
}
const vaultRows = [];
for (const v of vaults) {
@ -38,9 +56,11 @@ async function teamCard(team, uid) {
<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>
<div class="table-scroll"><table><thead><tr><th>Email</th><th>Permission</th><th>Status</th><th>Key</th><th>Actions</th></tr></thead><tbody>${memberRows.join("")}</tbody></table></div>
${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>` : ""}
<input type="email" name="email" placeholder="teammate@example.com" required style="flex:1">
${me.role === "owner" ? `<select name="role" aria-label="Permission" style="width:auto"><option value="member">member</option><option value="admin">admin</option><option value="owner">owner</option></select>` : `<input type="hidden" name="role" value="member">`}
<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)} &lt;project&gt; &lt;env&gt;</code></p>`}
@ -54,9 +74,28 @@ export async function dashboardHandler(req, res) {
for (const t of teams) cards += await teamCard(t, uid);
cards = cards.split(CSRF).join(csrfInput(req));
const notices = {
"member-updated": "Member permissions updated.",
"member-removed": "Team member removed.",
"invite-resent": "Invite replaced with a new key.",
};
const errors = {
"already-member": "That person is already an active team member.",
"bad-email": "Enter a valid email address.",
"bad-role": "Choose a valid permission.",
"last-owner": "A team must keep at least one active owner.",
"member-not-found": "That team member no longer exists.",
"not-allowed": "You do not have permission to make that change.",
};
const ok = notices[String(req.query.ok || "")];
const err = errors[String(req.query.err || "")];
const rotation = req.query.rotation === "1";
const body = `${appBar(req)}
<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>
${ok ? `<div class="notice ok">${esc(ok)}${rotation ? " Vault grants were revoked; rotate affected vault keys before re-adding this person." : ""}</div>` : ""}
${err ? `<div class="notice err">${esc(err)}</div>` : ""}
${CLI_HINT(requestOrigin(req, 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>
@ -83,32 +122,81 @@ pagesRouter.post("/teams", requireAuth, async (req, res) => {
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]);
if (!team || !me) return res.redirect("/dashboard?err=not-allowed");
try {
const result = await issueTeamInvite({
team,
actor: { ...me, email: req.user.email },
email: req.body.email,
role: req.body.role
});
return res.redirect("/teams/accept?token=" + encodeURIComponent(result.token) + "&shared=1");
} catch (error) {
if (error instanceof TeamMemberError) return res.redirect("/dashboard?err=" + encodeURIComponent(error.code));
throw error;
}
});
async function teamMemberContext(req) {
const team = await get(`SELECT * FROM credshare_teams WHERE slug = ?`, [req.params.slug]);
const actor = team && await get(`SELECT * FROM credshare_members WHERE team_id = ? AND user_id = ? AND status = 'active'`, [team.id, req.user.id]);
return team && actor ? { team, actor } : null;
}
pagesRouter.post("/teams/:slug/members/:memberId/resend", requireAuth, async (req, res) => {
const ctx = await teamMemberContext(req);
const target = ctx && await get(`SELECT * FROM credshare_members WHERE id = ? AND team_id = ?`, [req.params.memberId, ctx.team.id]);
if (!ctx || !target || target.status !== "invited") return res.redirect("/dashboard?err=member-not-found");
try {
const result = await issueTeamInvite({
team: ctx.team,
actor: { ...ctx.actor, email: req.user.email },
email: target.email,
role: target.role
});
return res.redirect("/teams/accept?token=" + encodeURIComponent(result.token) + "&shared=1&resent=1");
} catch (error) {
if (error instanceof TeamMemberError) return res.redirect("/dashboard?err=" + encodeURIComponent(error.code));
throw error;
}
});
pagesRouter.post("/teams/:slug/members/:memberId/role", requireAuth, async (req, res) => {
const ctx = await teamMemberContext(req);
if (!ctx) return res.redirect("/dashboard?err=not-allowed");
try {
await changeMemberRole({ team: ctx.team, actor: ctx.actor, memberId: req.params.memberId, role: req.body.role });
return res.redirect("/dashboard?ok=member-updated");
} catch (error) {
if (error instanceof TeamMemberError) return res.redirect("/dashboard?err=" + encodeURIComponent(error.code));
throw error;
}
});
pagesRouter.post("/teams/:slug/members/:memberId/delete", requireAuth, async (req, res) => {
const ctx = await teamMemberContext(req);
if (!ctx) return res.redirect("/dashboard?err=not-allowed");
try {
const result = await removeTeamMember({ team: ctx.team, actor: ctx.actor, memberId: req.params.memberId });
return res.redirect(`/dashboard?ok=member-removed${result.rotationRequired ? "&rotation=1" : ""}`);
} catch (error) {
if (error instanceof TeamMemberError) return res.redirect("/dashboard?err=" + encodeURIComponent(error.code));
throw error;
}
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 resent = req.query.resent;
const err = req.query.err;
const body = `${appBar(req)}
<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>` : ""}
${shared ? `<div class="notice ok">${resent ? "Invite replaced. The previous key is invalid; share this new link with the teammate." : "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>

View file

@ -0,0 +1,170 @@
// Integration coverage for team-member CRUD, invite-key rotation, and the
// dashboard controls that expose those operations.
process.env.DATABASE_URL = ":memory:";
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import express from "express";
const here = dirname(fileURLToPath(import.meta.url));
const { db, run, get } = await import("../src/db.mjs");
const { sha256 } = await import("../src/lib/crypto.mjs");
const { credshareRouter } = await import("../src/routes/credshare.mjs");
const { pagesRouter } = await import("../src/routes/pages.mjs");
for (const file of ["001_auth.sql", "002_credshare.sql"]) {
const sql = readFileSync(join(here, "..", "src", "migrations", file), "utf8");
for (const statement of sql.split(/;\s*$/m).map((s) => s.trim()).filter(Boolean)) await db.execute(statement);
}
const now = Date.now();
for (const [id, email] of [
["u_owner", "owner@example.com"],
["u_owner2", "owner2@example.com"],
["u_admin", "admin@example.com"],
["u_member", "member@example.com"]
]) await run(`INSERT INTO users (id, email, created_at) VALUES (?,?,?)`, [id, email, now]);
async function seedTeam(id, slug, members) {
await run(`INSERT INTO credshare_teams (id, slug, name, created_by, created_at) VALUES (?,?,?,?,?)`, [id, slug, slug, "u_owner", now]);
for (const member of members) {
await run(`INSERT INTO credshare_members (id, team_id, user_id, email, role, status, created_at) VALUES (?,?,?,?,?,?,?)`,
[member.id, id, member.userId ?? null, member.email, member.role, member.status, now]);
}
}
await seedTeam("t_rotate", "rotate", [
{ id: "rotate_owner", userId: "u_owner", email: "owner@example.com", role: "owner", status: "active" },
{ id: "rotate_invited", email: "new@example.com", role: "member", status: "invited" }
]);
await run(`INSERT INTO credshare_invites (id, team_id, email, role, token_hash, created_by, expires_at, created_at) VALUES (?,?,?,?,?,?,?,?)`,
["old_invite", "t_rotate", "new@example.com", "member", sha256("old-key"), "u_owner", now + 864e5, now]);
await seedTeam("t_manage", "manage", [
{ id: "manage_owner", userId: "u_owner", email: "owner@example.com", role: "owner", status: "active" },
{ id: "manage_admin", userId: "u_admin", email: "admin@example.com", role: "admin", status: "active" },
{ id: "manage_member", userId: "u_member", email: "member@example.com", role: "member", status: "active" },
{ id: "manage_member2", email: "member2@example.com", role: "member", status: "invited" },
{ id: "manage_invited_admin", email: "future-admin@example.com", role: "admin", status: "invited" }
]);
await run(`INSERT INTO credshare_vaults (id, team_id, name, created_by, created_at) VALUES (?,?,?,?,?)`, ["v_manage", "t_manage", "app--prod", "u_owner", now]);
await run(`INSERT INTO credshare_vault_grants (vault_id, user_id, wrapped_dek, granted_by, created_at) VALUES (?,?,?,?,?)`, ["v_manage", "u_member", "wrapped", "u_owner", now]);
await seedTeam("t_owners", "owners", [
{ id: "owners_owner", userId: "u_owner", email: "owner@example.com", role: "owner", status: "active" },
{ id: "owners_owner2", userId: "u_owner2", email: "owner2@example.com", role: "owner", status: "active" }
]);
async function serve(actingUserId) {
const user = await get(`SELECT * FROM users WHERE id = ?`, [actingUserId]);
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use((req, _res, next) => {
req.user = user;
req.csrfToken = "test-csrf";
next();
});
app.use(credshareRouter);
app.use(pagesRouter);
const server = app.listen(0);
await new Promise((resolve) => server.once("listening", resolve));
const base = `http://127.0.0.1:${server.address().port}`;
return {
async request(method, path, body) {
const response = await fetch(`${base}${path}`, {
method,
headers: body === undefined ? undefined : { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
redirect: "manual"
});
const text = await response.text();
return { status: response.status, body: response.headers.get("content-type")?.includes("json") ? JSON.parse(text) : text, location: response.headers.get("location") };
},
close: () => new Promise((resolve) => server.close(resolve))
};
}
test("resending rotates the invite key and updates its requested permission", async (t) => {
const app = await serve("u_owner");
t.after(() => app.close());
const response = await app.request("POST", "/api/credshare/teams/rotate/invites", { email: "new@example.com", role: "admin" });
assert.equal(response.status, 201);
assert.equal(response.body.resent, true);
assert.equal(response.body.invite.role, "admin");
assert.ok(response.body.token);
assert.equal(await get(`SELECT 1 AS hit FROM credshare_invites WHERE token_hash = ?`, [sha256("old-key")]), null);
assert.equal((await get(`SELECT COUNT(*) AS n FROM credshare_invites WHERE team_id = 't_rotate' AND email = 'new@example.com' AND accepted_at IS NULL`)).n, 1);
assert.equal((await get(`SELECT role FROM credshare_members WHERE id = 'rotate_invited'`)).role, "admin");
const oldAccept = await app.request("POST", "/api/credshare/invites/accept", { token: "old-key" });
assert.equal(oldAccept.status, 404);
});
test("owners can change permissions but the final active owner cannot be demoted", async (t) => {
const app = await serve("u_owner");
t.after(() => app.close());
const promoted = await app.request("PATCH", "/api/credshare/teams/manage/members/manage_member", { role: "admin" });
assert.equal(promoted.status, 200);
assert.equal(promoted.body.member.role, "admin");
const lastOwner = await app.request("PATCH", "/api/credshare/teams/manage/members/manage_owner", { role: "admin" });
assert.equal(lastOwner.status, 409);
assert.equal(lastOwner.body.code, "last-owner");
const transferable = await app.request("PATCH", "/api/credshare/teams/owners/members/owners_owner", { role: "member" });
assert.equal(transferable.status, 200);
});
test("admins cannot promote members or manage owners", async (t) => {
const app = await serve("u_admin");
t.after(() => app.close());
const promote = await app.request("PATCH", "/api/credshare/teams/manage/members/manage_member", { role: "admin" });
assert.equal(promote.status, 403);
const removeOwner = await app.request("DELETE", "/api/credshare/teams/manage/members/manage_owner");
assert.equal(removeOwner.status, 403);
const removeMember = await app.request("DELETE", "/api/credshare/teams/manage/members/manage_member2");
assert.equal(removeMember.status, 200);
const replaceAdminInvite = await app.request("POST", "/api/credshare/teams/manage/invites", { email: "future-admin@example.com", role: "member" });
assert.equal(replaceAdminInvite.status, 403);
});
test("removing a member also removes their vault grants", async (t) => {
// The previous role test promoted manage_member, so the owner performs this
// removal; an admin must not be able to remove a peer admin.
const app = await serve("u_owner");
t.after(() => app.close());
const response = await app.request("DELETE", "/api/credshare/teams/manage/members/manage_member");
assert.equal(response.status, 200);
assert.equal(response.body.revokedVaultGrants, 1);
assert.equal(response.body.rotationRequired, true);
assert.equal(await get(`SELECT 1 AS hit FROM credshare_members WHERE id = 'manage_member'`), null);
assert.equal(await get(`SELECT 1 AS hit FROM credshare_vault_grants WHERE vault_id = 'v_manage' AND user_id = 'u_member'`), null);
const readded = await app.request("POST", "/api/credshare/teams/manage/invites", { email: "member@example.com", role: "member" });
assert.equal(readded.status, 201);
assert.equal(readded.body.resent, false);
assert.equal((await get(`SELECT status FROM credshare_members WHERE team_id = 't_manage' AND email = 'member@example.com'`)).status, "invited");
assert.equal(await get(`SELECT 1 AS hit FROM credshare_vault_grants WHERE vault_id = 'v_manage' AND user_id = 'u_member'`), null);
});
test("dashboard renders permission, resend, and remove controls", async (t) => {
const app = await serve("u_owner");
t.after(() => app.close());
const response = await app.request("GET", "/dashboard");
assert.equal(response.status, 200);
assert.match(response.body, /aria-label="Permission for new@example\.com"/);
assert.match(response.body, />Resend<\/button>/);
assert.match(response.body, />Remove<\/button>/);
assert.match(response.body, /<select name="role" aria-label="Permission"/);
});

6
package-lock.json generated
View file

@ -160,7 +160,7 @@
},
"apps/pwa": {
"name": "@logicsrc/pwa",
"version": "0.1.0",
"version": "0.1.1",
"dependencies": {
"@libsql/client": "^0.14.0",
"@simplewebauthn/browser": "^13.3.0",
@ -7754,7 +7754,7 @@
},
"packages/cli": {
"name": "@logicsrc/cli",
"version": "0.1.1",
"version": "0.1.2",
"dependencies": {
"@logicsrc/account-core": "file:../account-core",
"@logicsrc/openontology": "file:../openontology",
@ -7911,7 +7911,7 @@
},
"plugins/credential-sharing": {
"name": "@logicsrc/plugin-credential-sharing",
"version": "0.1.1",
"version": "0.1.2",
"dependencies": {
"@logicsrc/account-core": "file:../../packages/account-core",
"@logicsrc/plugin-core": "file:../../packages/plugin-core",

View file

@ -1,6 +1,6 @@
{
"name": "@logicsrc/cli",
"version": "0.1.1",
"version": "0.1.2",
"description": "LogicSRC OpenSpec CLI.",
"type": "module",
"main": "./dist/index.js",

View file

@ -1,6 +1,6 @@
{
"name": "@logicsrc/plugin-credential-sharing",
"version": "0.1.1",
"version": "0.1.2",
"description": "LogicSRC Credential Sharing OpenSpec plugin: portable, auditable secret sync across .env, Doppler, Railway, and GitHub Secrets.",
"type": "module",
"main": "./dist/index.js",

View file

@ -25,6 +25,7 @@ export interface RemoteTeam {
}
export interface RemoteMember {
id: string;
email: string;
role: "owner" | "admin" | "member";
status: "active" | "invited";
@ -136,12 +137,25 @@ export class TeamClient {
return this.request<{ members: RemoteMember[] }>("GET", `/teams/${encodeURIComponent(slug)}/members`);
}
invite(slug: string, email: string, role?: "owner" | "admin" | "member") {
return this.request<{ invite: { id: string; email: string; role: string; expiresAt: string }; emailSent: boolean; token?: string }>(
return this.request<{ invite: { id: string; email: string; role: string; expiresAt: number }; emailSent: boolean; resent: boolean; token?: string }>(
"POST",
`/teams/${encodeURIComponent(slug)}/invites`,
{ email, role }
);
}
updateMemberRole(slug: string, memberId: string, role: "owner" | "admin" | "member") {
return this.request<{ member: Pick<RemoteMember, "id" | "email" | "role" | "status"> }>(
"PATCH",
`/teams/${encodeURIComponent(slug)}/members/${encodeURIComponent(memberId)}`,
{ role }
);
}
removeMember(slug: string, memberId: string) {
return this.request<{ ok: boolean; removed: string; revokedVaultGrants: number; rotationRequired: boolean }>(
"DELETE",
`/teams/${encodeURIComponent(slug)}/members/${encodeURIComponent(memberId)}`
);
}
acceptInvite(token: string) {
return this.request<{ ok: boolean; team?: RemoteTeam }>("POST", "/invites/accept", { token });
}