import express, { Router } from 'express'; import { dirname } from 'node:path'; import type { Repository } from '../db/repository.js'; import { spaceWorkspaceDir } from '../spaces/paths.js'; import { scaffoldCaseSpace, removeSpaceDirs } from '../spaces/scaffold.js'; import { canManageSpace } from './visibility.js'; import { registerSpaceFilesRoutes } from './space-files-api.js'; import { registerSpaceMembersRoutes } from './space-members-api.js'; import { registerSpaceCalendarRoutes } from './space-calendar-api.js'; import { registerSpaceInviteRoutes } from './space-invite-api.js'; import { registerSpaceAppShareRoutes } from './space-app-share-api.js'; import { registerSpaceToolPolicyRoutes } from './space-tool-policy-api.js'; import { registerSpacePythonPackagesRoutes } from './space-python-packages-api.js'; import { registerSpaceWebhooksRoutes } from './space-webhooks-api.js'; import { viewerOf } from './space-viewer.js'; import { logger } from '../logger.js'; import { sendOfficePreview, handleOfficePreviewError } from './office-preview.js'; import { parseToolPolicy } from '../engine/workspace-tool-policy.js'; import { listToolCategories, SENSITIVE_CATEGORIES, SENSITIVE_TOOLS, } from '../engine/tools/tool-categories.js'; import { listTransferCandidates, executeTransfer, } from '../services/space-credential-transfer.js'; // cross-calendar-api.ts は parseTzOffset / monthBounds を space-api 経由で参照する。 // 実体はカレンダールートと同居させたほうが凝集が高いので space-calendar-api に移し、 // 後方互換のためここで再エクスポートする(spaceViewerOf と同じ公開境界の扱い)。 export { parseTzOffset, monthBounds } from './space-calendar-api.js'; export interface SpaceApiDeps { repo: Repository; dataRoot: string; worktreeDir: string; authActive: boolean; /** アップロード JSON ボディの上限(MB)。未指定は 50MB(base64 は ~33% 膨張する点に注意)。 */ uploadLimitMb?: number; /** Per-space Python packages のライブ config(loadConfig() を都度読む getter)。未配線 = 無効扱い。 */ getPythonPackagesConfig?: () => import('../config.js').PythonPackagesConfigShape | undefined; /** ワークスペース Webhook 通知(issue #797)— test-send 実行用。null/未配線 = 501/503 応答。 */ webhookService?: import('../webhook-service.js').WebhookDeliveryService | null; } /** * 認証 OFF 環境のフォールバックユーザーを返す(cross-calendar-api 等から流用)。 * 規約は viewerOf と同一(synthetic 'local' admin)。 */ export function spaceViewerOf(req: any, authActive: boolean): Express.User { return viewerOf(req, authActive); } export function createSpaceApi(deps: SpaceApiDeps): Router { const router = Router(); const { repo, dataRoot, worktreeDir } = deps; // body を読むルート(create / patch / upload)専用の json parser。 // グローバル json は server.ts から外したので、各ルートが自前で宣言する。 // upload を通すため 50mb(または config 指定)まで許容する。 const jsonParser = express.json({ limit: deps.uploadLimitMb ? `${deps.uploadLimitMb}mb` : '50mb' }); // 一覧(個人スペースは初回アクセス時に lazy 生成)。各スペースに閲覧者の myRole も付与 // (クライアントの editor 含むファイル編集可否判定に使う。SpaceDetail は一覧から space を引く)。 router.get('/', async (req, res) => { const viewer = viewerOf(req, deps.authActive); await repo.ensurePersonalSpace(viewer.id, viewer.name ?? undefined); const spaces = await repo.listSpacesWithDisplayPrefs({ viewer }); res.json(spaces.map(s => ({ ...s, myRole: repo.getSpaceMemberRole(s.id, viewer.id) }))); }); // 詳細。閲覧者のメンバーロール(myRole)も載せる。クライアントが editor を含む // ファイル編集可否(canEditInSpace 相当)を判定するために使う。非メンバーは null。 router.get('/:id', async (req, res) => { const viewer = viewerOf(req, deps.authActive); const space = await repo.getSpace(req.params.id, { viewer }); if (!space) return res.status(404).json({ error: 'not found' }); const myRole = repo.getSpaceMemberRole(space.id, viewer.id); res.json({ ...space, myRole }); }); router.patch("/:id/display", jsonParser, async (req, res) => { const viewer = viewerOf(req, deps.authActive); const space = await repo.getSpace(req.params.id, { viewer }); if (!space) return res.status(404).json({ error: "not found" }); const patch: { favorite?: boolean; hidden?: boolean } = {}; if (req.body?.favorite !== undefined) { if (typeof req.body.favorite !== "boolean") return res.status(400).json({ error: "favorite must be boolean" }); patch.favorite = req.body.favorite; } if (req.body?.hidden !== undefined) { if (typeof req.body.hidden !== "boolean") return res.status(400).json({ error: "hidden must be boolean" }); patch.hidden = req.body.hidden; } if (patch.favorite === undefined && patch.hidden === undefined) { return res.status(400).json({ error: "favorite or hidden is required" }); } const prefs = repo.updateSpaceDisplayPrefs(viewer.id, space.id, patch); res.json(prefs); }); // 作成(案件スペース)。DB 行 → 雛形作成。雛形失敗時は行を消してロールバック。 router.post('/', jsonParser, async (req, res) => { const viewer = viewerOf(req, deps.authActive); const title = (req.body?.title ?? '').toString().trim(); if (!title) return res.status(400).json({ error: 'title is required' }); const ownerId = viewer.id === 'local' ? null : viewer.id; const space = await repo.createSpace({ kind: 'case', title, description: req.body?.description?.toString() ?? '', ownerId, visibility: req.body?.visibility ?? 'private', visibilityScopeOrgId: req.body?.visibilityScopeOrgId ?? null, brandColor: req.body?.brandColor ?? null, }); try { // 案件スペースのフォルダ実体は data/spaces/{id}(spec §5.3)。dataRoot は // userFolderRoot(data/users)が渡るため、scaffold の spaceDataDir が // {dataRoot}/spaces/{id} を作る前提に合わせ dataRoot=dirname(userFolderRoot)=data を渡す。 scaffoldCaseSpace({ dataRoot: dirname(dataRoot), worktreeDir, id: space.id, title }); // workspace_dir を確定保存 const ws = spaceWorkspaceDir(worktreeDir, space.id); await repo.setSpaceWorkspaceDir(space.id, ws); const fresh = await repo.getSpace(space.id, { viewer }); res.status(201).json(fresh); } catch (err) { logger.error(`[space-api] scaffold failed, rolling back space=${space.id} err=${(err as Error).message}`); removeSpaceDirs({ dataRoot: dirname(dataRoot), worktreeDir, id: space.id }); await repo.hardDeleteSpace(space.id); res.status(500).json({ error: 'failed to create space' }); } }); // 更新(owner / admin / member.role==='owner') router.patch('/:id', jsonParser, async (req, res) => { const viewer = viewerOf(req, deps.authActive); const space = await repo.getSpace(req.params.id, { viewer }); if (!space) return res.status(404).json({ error: 'not found' }); const memberRole = repo.getSpaceMemberRole(space.id, viewer.id); if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) { return res.status(403).json({ error: 'forbidden' }); } const updated = await repo.updateSpace(req.params.id, { title: req.body?.title, description: req.body?.description, brandColor: req.body?.brandColor, visibility: req.body?.visibility, visibilityScopeOrgId: req.body?.visibilityScopeOrgId, }); res.json(updated); }); // アーカイブ(owner / admin / member.role==='owner'、案件のみ) router.post('/:id/archive', async (req, res) => { const viewer = viewerOf(req, deps.authActive); const space = await repo.getSpace(req.params.id, { viewer }); if (!space) return res.status(404).json({ error: 'not found' }); const memberRole = repo.getSpaceMemberRole(space.id, viewer.id); if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) { return res.status(403).json({ error: 'forbidden' }); } if (space.kind === 'personal') return res.status(400).json({ error: 'cannot archive personal space' }); await repo.archiveSpace(req.params.id); res.json({ ok: true }); }); // Space ファイル窓ルート(/:id/files/*)は space-files-api.ts に分離(巨大関数分割)。 registerSpaceFilesRoutes(router, deps); // カレンダー(予定 + 日次集計)は space-calendar-api.ts に分離(巨大関数分割)。 registerSpaceCalendarRoutes(router, deps); // メンバー(協働者)CRUD は space-members-api.ts に分離(巨大関数分割)。 registerSpaceMembersRoutes(router, deps); // 招待リンク(再利用トークン)は space-invite-api.ts に分離(巨大関数分割)。 registerSpaceInviteRoutes(router, deps); // 公開アプリ共有リンクは space-app-share-api.ts に分離(巨大関数分割)。 registerSpaceAppShareRoutes(router, deps); // ツールポリシー GET/PUT は space-tool-policy-api.ts に分離(巨大関数分割)。 registerSpaceToolPolicyRoutes(router, deps); // Python パッケージ管理 GET/POST/DELETE は space-python-packages-api.ts に分離。 registerSpacePythonPackagesRoutes(router, deps); // Webhook 通知(Discord/Slack/Teams)CRUD + test-send は space-webhooks-api.ts に分離。 registerSpaceWebhooksRoutes(router, deps); // ─── スペース間資格情報コピー ────────────────────────────────────── // 両スペースの管理権(canManageSpace)を持つユーザーのみ利用可能。 // 成功時に audit_log へ記録する。 /** 閲覧者が source・target 両スペースを管理できるかを確認する。 */ async function canManageBothSpaces( viewer: Express.User, sourceId: string, targetId: string, ): Promise { const source = await repo.getSpace(sourceId, { viewer }); const target = await repo.getSpace(targetId, { viewer }); if (!source || !target) return false; const srcRole = repo.getSpaceMemberRole(sourceId, viewer.id); const tgtRole = repo.getSpaceMemberRole(targetId, viewer.id); return ( canManageSpace(viewer, source, srcRole) && canManageSpace(viewer, target, tgtRole) ); } // GET /:targetId/transfer/candidates?sourceId= // 元スペースの MCP サーバー・SSH 接続の転送候補一覧を返す。 router.get('/:targetId/transfer/candidates', async (req, res) => { const viewer = viewerOf(req, deps.authActive); const targetId = req.params.targetId; const sourceId = String(req.query.sourceId ?? ''); if (!sourceId) return res.status(400).json({ error: 'sourceId required' }); if (sourceId === targetId) return res.status(400).json({ error: 'source and target must differ' }); if (!(await canManageBothSpaces(viewer, sourceId, targetId))) { return res.status(403).json({ error: 'forbidden' }); } const db = repo.getDb(); return res.json(listTransferCandidates({ db }, sourceId, targetId)); }); // POST /:targetId/transfer // body: { sourceId, mcpServerIds: string[], sshConnectionIds: string[] } // 指定した MCP サーバー・SSH 接続を転送し、copied/skipped サマリを返す。 router.post('/:targetId/transfer', jsonParser, async (req, res) => { const viewer = viewerOf(req, deps.authActive); const targetId = req.params.targetId; const sourceId = String(req.body?.sourceId ?? ''); if (!sourceId) return res.status(400).json({ error: 'sourceId required' }); if (sourceId === targetId) return res.status(400).json({ error: 'source and target must differ' }); const mcpServerIds = req.body?.mcpServerIds; const sshConnectionIds = req.body?.sshConnectionIds; if (mcpServerIds !== undefined && !Array.isArray(mcpServerIds)) { return res.status(400).json({ error: 'mcpServerIds must be an array' }); } if (sshConnectionIds !== undefined && !Array.isArray(sshConnectionIds)) { return res.status(400).json({ error: 'sshConnectionIds must be an array' }); } if (!(await canManageBothSpaces(viewer, sourceId, targetId))) { return res.status(403).json({ error: 'forbidden' }); } const db = repo.getDb(); const result = executeTransfer( { db }, { sourceSpaceId: sourceId, targetSpaceId: targetId, actorId: viewer.id, mcpServerIds: (mcpServerIds as string[]) ?? [], sshConnectionIds: (sshConnectionIds as string[]) ?? [], }, ); await deps.repo.addAuditLog(null, 'space.credential.transfer', viewer.id, { sourceId, targetId, copied: result.copied.length, skipped: result.skipped.length, }); return res.json(result); }); return router; }