server/src/routes/teams.ts
Rama main · réplica de lectura del repositorio original.
import type { FastifyInstance } from 'fastify';
import { requireUser } from '../auth.ts';
import { all, get, newId, now, run, tx } from '../db.ts';
import { addTeamMember, canCreateTeams, createVault, isTeamManager, pruneVaultKeys, requireTeamRole, teamRole, type Perm, type TeamRole, type VaultRow } from '../access.ts';
import { audit, badRequest, body, forbidden, HttpError, notFound, str } from '../http.ts';
import { config } from '../config.ts';
import { sha256 } from '../crypto.ts';
import { INVITE_CODE_TTL_MS, newInviteCode, normalizeInviteCode, Throttle } from '../password.ts';
// 초대 보내기: 사람마다 하루 100개 (누구나 가입하는 서버에서 초대로 남을 괴롭히지 못하게)
const inviteThrottle = new Throttle(100, 24 * 60 * 60 * 1000);
// 비밀번호 로그인 서버의 초대: 초대받은 사람이 처음 비밀번호를 정할 때 낼 코드를 만든다(해시만 남기고 원문은 이번 한 번만 보여 준다)
function issueInviteCode(inviteId: string) {
const code = newInviteCode();
const expiresAt = now() + INVITE_CODE_TTL_MS;
run('UPDATE invites SET code_hash = ?, code_expires_at = ? WHERE id = ?', sha256(normalizeInviteCode(code)), expiresAt, inviteId);
return { code, codeExpiresAt: expiresAt };
}
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const MAX_OWNED_TEAMS = 20;
const MAX_TEAM_VAULTS = 50;
export function teamRoutes(app: FastifyInstance) {
app.post('/api/teams', async (req) => {
const user = requireUser(req);
if (!canCreateTeams(user)) throw forbidden('팀은 관리자만 만들 수 있습니다');
// 누구나 가입하는 서버의 남용 막기: 서버 관리자가 아니면 소유한 팀은 MAX_OWNED_TEAMS 개까지
const owned = get<{ n: number }>("SELECT COUNT(*) AS n FROM team_members WHERE user_id = ? AND role = 'owner'", user.id)!.n;
if (!user.is_admin && owned >= MAX_OWNED_TEAMS) throw badRequest(`팀은 한 사람이 ${MAX_OWNED_TEAMS}개까지 만들 수 있습니다`);
const name = str(body(req.body), 'name', { min: 1, max: 60 })!;
const id = newId();
// 볼트 키는 만든 사람의 앱·웹이 곧바로 만들어 올린다 (POST /api/vaults/:id/key)
const vaultId = tx(() => {
run('INSERT INTO teams (id, name, created_by, created_at) VALUES (?, ?, ?, ?)', id, name, user.id, now());
const v = createVault({ kind: 'team', teamId: id, name: 'Team', isDefault: true });
addTeamMember(id, user.id, 'owner');
return v;
});
audit({ userId: user.id, teamId: id, action: 'team_create', target: name, ip: req.ip });
return { id, vaultId };
});
app.patch('/api/teams/:teamId', async (req) => {
const user = requireUser(req);
const { teamId } = req.params as { teamId: string };
requireTeamRole(user, teamId, true);
const name = str(body(req.body), 'name', { min: 1, max: 60 })!;
run('UPDATE teams SET name = ? WHERE id = ?', name, teamId);
audit({ userId: user.id, teamId, action: 'team_rename', target: name, ip: req.ip });
return { ok: true };
});
app.get('/api/teams/:teamId', async (req) => {
const user = requireUser(req);
const { teamId } = req.params as { teamId: string };
const myRole = requireTeamRole(user, teamId, false);
const manager = isTeamManager(myRole);
const team = get<{ id: string; name: string }>('SELECT id, name FROM teams WHERE id = ?', teamId)!;
const members = all<{ userId: string; email: string; name: string; avatarUrl: string; role: TeamRole; joinedAt: number; lastLoginAt: number | null; publicKey: string | null }>(
`SELECT u.id AS userId, u.email, u.name, u.avatar_url AS avatarUrl, tm.role, tm.joined_at AS joinedAt, u.last_login_at AS lastLoginAt, u.public_key AS publicKey
FROM team_members tm JOIN users u ON u.id = tm.user_id
WHERE tm.team_id = ?
ORDER BY CASE tm.role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END, u.name`,
teamId,
// 마지막 로그인 시각은 팀 관리자에게만
).map((m) => (manager || m.userId === user.id ? m : { ...m, lastLoginAt: null }));
const invites = isTeamManager(myRole)
? all('SELECT id, email, role, created_at AS createdAt, code_expires_at AS codeExpiresAt FROM invites WHERE team_id = ? ORDER BY created_at DESC', teamId)
: [];
const vaults = all<{ id: string; name: string; isDefault: number }>(
'SELECT id, name, is_default AS isDefault FROM vaults WHERE team_id = ? ORDER BY is_default DESC, name',
teamId,
).map((v) => ({ ...v, isDefault: Boolean(v.isDefault) }));
// 소유자에게만: 이 팀이 지워지면 소속 팀이 하나도 남지 않는 사람 수 (관리자 제외 — 그 사람들은 로그인할 수 없게 된다)
const soleMembers =
myRole === 'owner'
? get<{ n: number }>(
`SELECT COUNT(*) AS n FROM team_members tm JOIN users u ON u.id = tm.user_id
WHERE tm.team_id = ? AND u.is_admin = 0
AND NOT EXISTS (SELECT 1 FROM team_members o WHERE o.user_id = tm.user_id AND o.team_id != tm.team_id)`,
teamId,
)!.n
: undefined;
return { team, myRole, members, invites, vaults, soleMembers };
});
// 팀 삭제: 소유자만, 팀 이름을 그대로 적어야 한다. 팀 볼트와 그 안의 항목·볼트 키, 팀원 관계, 초대가 함께 지워진다
// (외래 키 ON DELETE CASCADE). 기록(audit_log)은 남는다.
app.delete('/api/teams/:teamId', async (req) => {
const user = requireUser(req);
const { teamId } = req.params as { teamId: string };
const role = requireTeamRole(user, teamId, true);
if (role !== 'owner') throw forbidden('팀 삭제는 팀 소유자만 할 수 있습니다');
const team = get<{ name: string }>('SELECT name FROM teams WHERE id = ?', teamId);
if (!team) throw notFound('팀을 찾을 수 없습니다');
if (body(req.body).confirm !== team.name) throw badRequest('확인을 위해 팀 이름을 정확히 적어 주세요');
const vaults = all<{ id: string }>('SELECT id FROM vaults WHERE team_id = ?', teamId).length;
tx(() => {
run('DELETE FROM teams WHERE id = ?', teamId);
pruneVaultKeys();
});
audit({ userId: user.id, teamId, action: 'team_delete', target: team.name, detail: { vaults }, ip: req.ip });
return { ok: true };
});
// 초대장을 남긴다. 받은 사람이 로그인해서 수락해야 팀에 들어간다 (이미 계정이 있어도 — 보안 점검 M-3).
// 계정이 있든 없든 답은 같다 (초대로 가입 여부를 알아내지 못하게)
app.post('/api/teams/:teamId/invites', async (req) => {
const user = requireUser(req);
const { teamId } = req.params as { teamId: string };
const myRole = requireTeamRole(user, teamId, true);
const b = body(req.body);
const email = str(b, 'email', { min: 3, max: 200 })!.toLowerCase();
const role = (str(b, 'role', { optional: true }) ?? 'member') as TeamRole;
if (!EMAIL.test(email)) throw badRequest('이메일 형식이 올바르지 않습니다');
if (role !== 'member' && role !== 'admin') throw badRequest('역할은 admin 또는 member입니다');
if (role === 'admin' && myRole !== 'owner') throw forbidden('관리자 초대는 팀 소유자만 할 수 있습니다');
if (inviteThrottle.blocked(user.id)) throw new HttpError(429, 'too_many', '초대를 너무 많이 보냈습니다. 내일 다시 시도해 주세요.');
inviteThrottle.fail(user.id);
const existing = get<{ id: string }>('SELECT id FROM users WHERE email = ?', email);
if (existing && teamRole(existing.id, teamId)) throw badRequest('이미 팀에 있는 사람입니다');
run(
'INSERT INTO invites (id, team_id, email, role, invited_by, created_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (team_id, email) DO UPDATE SET role = excluded.role',
newId(),
teamId,
email,
role,
user.id,
now(),
);
audit({ userId: user.id, teamId, action: 'invite_create', target: email, detail: { role }, ip: req.ip });
if (!config.passwordLogin) return { invited: true };
const invite = get<{ id: string }>('SELECT id FROM invites WHERE team_id = ? AND email = ?', teamId, email)!;
return { invited: true, ...issueInviteCode(invite.id) };
});
// 초대 코드 다시 만들기 (잃어버렸거나 기한이 지났을 때). 전에 준 코드는 더는 쓸 수 없다
app.post('/api/teams/:teamId/invites/:inviteId/code', async (req) => {
const user = requireUser(req);
if (!config.passwordLogin) throw notFound();
const { teamId, inviteId } = req.params as { teamId: string; inviteId: string };
const myRole = requireTeamRole(user, teamId, true);
const invite = get<{ email: string; role: TeamRole }>('SELECT email, role FROM invites WHERE id = ? AND team_id = ?', inviteId, teamId);
if (!invite) throw notFound();
if (invite.role === 'admin' && myRole !== 'owner') throw forbidden('관리자 초대는 팀 소유자만 할 수 있습니다');
const out = issueInviteCode(inviteId);
audit({ userId: user.id, teamId, action: 'invite_code', target: invite.email, ip: req.ip });
return out;
});
app.delete('/api/teams/:teamId/invites/:inviteId', async (req) => {
const user = requireUser(req);
const { teamId, inviteId } = req.params as { teamId: string; inviteId: string };
requireTeamRole(user, teamId, true);
const invite = get<{ email: string }>('SELECT email FROM invites WHERE id = ? AND team_id = ?', inviteId, teamId);
if (!invite) throw notFound();
run('DELETE FROM invites WHERE id = ?', inviteId);
audit({ userId: user.id, teamId, action: 'invite_delete', target: invite.email, ip: req.ip });
return { ok: true };
});
app.patch('/api/teams/:teamId/members/:userId', async (req) => {
const user = requireUser(req);
const { teamId, userId } = req.params as { teamId: string; userId: string };
const myRole = requireTeamRole(user, teamId, true);
if (myRole !== 'owner') throw forbidden('역할 변경은 팀 소유자만 할 수 있습니다');
const role = str(body(req.body), 'role')! as TeamRole;
if (!['owner', 'admin', 'member'].includes(role)) throw badRequest('역할이 올바르지 않습니다');
const current = teamRole(userId, teamId);
if (!current) throw notFound('팀원을 찾을 수 없습니다');
if (userId === user.id && role !== 'owner') {
const owners = get<{ n: number }>("SELECT COUNT(*) AS n FROM team_members WHERE team_id = ? AND role = 'owner'", teamId)!.n;
if (owners <= 1) throw badRequest('마지막 소유자는 역할을 내릴 수 없습니다');
}
tx(() => {
run('UPDATE team_members SET role = ? WHERE team_id = ? AND user_id = ?', role, teamId, userId);
if (role === 'member' && current !== 'member') {
// owner/admin 이던 사람이 member 로 내려가면 기본 볼트 보기 권한만 남긴다 — 전에 받아 둔 다른 볼트 권한·키도 지운다
const def = get<{ id: string }>('SELECT id FROM vaults WHERE team_id = ? AND is_default = 1', teamId);
run('DELETE FROM vault_members WHERE user_id = ? AND vault_id IN (SELECT id FROM vaults WHERE team_id = ?)', userId, teamId);
if (def) run("INSERT INTO vault_members (vault_id, user_id, permission) VALUES (?, ?, 'view')", def.id, userId);
pruneVaultKeys();
}
});
const target = get<{ email: string }>('SELECT email FROM users WHERE id = ?', userId)!.email;
audit({ userId: user.id, teamId, action: 'member_role', target, detail: { from: current, to: role }, ip: req.ip });
return { ok: true };
});
app.delete('/api/teams/:teamId/members/:userId', async (req) => {
const user = requireUser(req);
const { teamId, userId } = req.params as { teamId: string; userId: string };
const myRole = requireTeamRole(user, teamId, userId !== user.id);
const role = teamRole(userId, teamId);
if (!role) throw notFound('팀원을 찾을 수 없습니다');
if (role === 'owner' && myRole !== 'owner') throw forbidden('소유자는 소유자만 내보낼 수 있습니다');
if (role === 'owner') {
const owners = get<{ n: number }>("SELECT COUNT(*) AS n FROM team_members WHERE team_id = ? AND role = 'owner'", teamId)!.n;
if (owners <= 1) throw badRequest('마지막 소유자는 나갈 수 없습니다');
}
tx(() => {
run('DELETE FROM team_members WHERE team_id = ? AND user_id = ?', teamId, userId);
run('DELETE FROM vault_members WHERE user_id = ? AND vault_id IN (SELECT id FROM vaults WHERE team_id = ?)', userId, teamId);
pruneVaultKeys();
});
const target = get<{ email: string }>('SELECT email FROM users WHERE id = ?', userId)?.email ?? userId;
audit({ userId: user.id, teamId, action: 'member_remove', target, ip: req.ip });
return { ok: true };
});
app.post('/api/teams/:teamId/vaults', async (req) => {
const user = requireUser(req);
const { teamId } = req.params as { teamId: string };
requireTeamRole(user, teamId, true);
const name = str(body(req.body), 'name', { min: 1, max: 60 })!;
if (get<{ n: number }>('SELECT COUNT(*) AS n FROM vaults WHERE team_id = ?', teamId)!.n >= MAX_TEAM_VAULTS) throw badRequest(`팀 볼트는 ${MAX_TEAM_VAULTS}개까지 만들 수 있습니다`);
const id = createVault({ kind: 'team', teamId, name, isDefault: false });
audit({ userId: user.id, teamId, vaultId: id, action: 'vault_create', target: name, ip: req.ip });
return { id };
});
app.patch('/api/vaults/:vaultId', async (req) => {
const user = requireUser(req);
const vault = teamVault(req.params);
requireTeamRole(user, vault.team_id!, true);
const name = str(body(req.body), 'name', { min: 1, max: 60 })!;
run('UPDATE vaults SET name = ? WHERE id = ?', name, vault.id);
audit({ userId: user.id, teamId: vault.team_id, vaultId: vault.id, action: 'vault_rename', target: name, ip: req.ip });
return { ok: true };
});
app.delete('/api/vaults/:vaultId', async (req) => {
const user = requireUser(req);
const vault = teamVault(req.params);
requireTeamRole(user, vault.team_id!, true);
if (vault.is_default) throw badRequest('기본 팀 볼트는 지울 수 없습니다');
run('DELETE FROM vaults WHERE id = ?', vault.id);
audit({ userId: user.id, teamId: vault.team_id, action: 'vault_delete', target: vault.name, ip: req.ip });
return { ok: true };
});
app.get('/api/vaults/:vaultId/members', async (req) => {
const user = requireUser(req);
const vault = teamVault(req.params);
requireTeamRole(user, vault.team_id!, true);
return all<{ userId: string; email: string; name: string; avatarUrl: string; role: TeamRole; permission: Perm | null; hasKeys: number; hasVaultKey: number }>(
`SELECT u.id AS userId, u.email, u.name, u.avatar_url AS avatarUrl, tm.role, vm.permission,
(u.public_key IS NOT NULL) AS hasKeys,
EXISTS (SELECT 1 FROM vault_keys vk WHERE vk.vault_id = ? AND vk.user_id = u.id) AS hasVaultKey
FROM team_members tm
JOIN users u ON u.id = tm.user_id
LEFT JOIN vault_members vm ON vm.vault_id = ? AND vm.user_id = tm.user_id
WHERE tm.team_id = ?
ORDER BY u.name`,
vault.id,
vault.id,
vault.team_id,
).map((m) => ({ ...m, hasKeys: Boolean(m.hasKeys), hasVaultKey: Boolean(m.hasVaultKey), permission: isTeamManager(m.role) ? 'edit' : m.permission, implicit: isTeamManager(m.role) }));
});
app.put('/api/vaults/:vaultId/members/:userId', async (req) => {
const user = requireUser(req);
const vault = teamVault(req.params);
const { userId } = req.params as { userId: string };
requireTeamRole(user, vault.team_id!, true);
const role = teamRole(userId, vault.team_id!);
if (!role) throw notFound('팀원이 아닙니다');
if (isTeamManager(role)) throw badRequest('팀 소유자·관리자는 모든 볼트를 편집할 수 있습니다');
const permission = (body(req.body).permission ?? null) as Perm | null;
if (permission !== null && permission !== 'edit' && permission !== 'view') throw badRequest('권한은 edit, view, null 중 하나입니다');
if (permission) {
run(
'INSERT INTO vault_members (vault_id, user_id, permission) VALUES (?, ?, ?) ON CONFLICT (vault_id, user_id) DO UPDATE SET permission = excluded.permission',
vault.id,
userId,
permission,
);
} else {
run('DELETE FROM vault_members WHERE vault_id = ? AND user_id = ?', vault.id, userId);
pruneVaultKeys();
}
const target = get<{ email: string }>('SELECT email FROM users WHERE id = ?', userId)!.email;
audit({ userId: user.id, teamId: vault.team_id, vaultId: vault.id, action: 'vault_member_set', target, detail: { permission }, ip: req.ip });
return { ok: true };
});
}
function teamVault(params: unknown): VaultRow {
const { vaultId } = params as { vaultId: string };
const vault = get<VaultRow>('SELECT * FROM vaults WHERE id = ?', vaultId);
if (!vault || vault.kind !== 'team') throw notFound('볼트를 찾을 수 없습니다');
return vault;
}