maestro/src/bridge/space-api.ts
oss-sync a67c40d33c
All checks were successful
CI / build-and-test (push) Successful in 8m58s
sync: update from private repo (a5db2ab0)
2026-07-10 00:51:05 +00:00

278 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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。未指定は 50MBbase64 は ~33% 膨張する点に注意)。 */
uploadLimitMb?: number;
/** Per-space Python packages のライブ configloadConfig() を都度読む 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 は
// userFolderRootdata/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/TeamsCRUD + 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<boolean> {
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=<id>
// 元スペースの 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;
}