Ctrl K
ComunidadGuías…

server/src/routes/teams.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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;
}